From 4fdc6b3fc8e2d482112371dd6df7d7734d658043 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:21:56 +0100 Subject: [PATCH 01/61] refactor(log-viewer): give one object the whole key and path vocabulary (#972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview After #970 the interned keys sat on `LogStore` while the paths they step through sat in `KeyPathIds`. Nothing tied a cached key id to the table that minted it, a stack id could be stepped into a path that means nothing, and a test double had to mirror three coupled facts to work at all. One object now owns the keys, their per-event memo, the stack keys and the paths, and states the invariant its callers rely on: a row's id is the interned chain of the frames the row holds. Tidying it also dropped work and memory. | 100MB log (864k lines, 431k calls) | Before | After | | --- | --- | --- | | Bottom-Up build | 508ms | **458ms** | | Aggregated build | 124ms | **121ms** | | Mark a 42,433-occurrence pick | 75ms | **61ms** | | Per-event key cache | 2 arrays, 3.4MB, filled up front | **1 array, 1.7MB, filled on use** | ## ๐Ÿ› ๏ธ Changes made - A bucket key (`type|namespace|text`) holds its stack key (`namespace|text`), so a stack id is derived from a key id and kept per signature rather than per event. That removes the second per-event array, and the two id spaces are separate by construction. - Cache slots hold `id + 1`, so an unset slot reads as 0 and a zero-initialised array costs nothing until a frame is asked about. - The mark walks the frames and composes their ids inside the table, so neither chain direction builds an intermediate array; `prefixesOf` is deleted rather than left with no caller. - The scoped aggregation carries one map where it carried three, dropping an array per recursion level and a lookup per row. - `eventKeyChain` joins its only dependency in `core/log/eventKeys.ts`, so `bucketRows` takes one import for one vocabulary. ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [x] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A โ€” nothing changes on screen. ## ๐Ÿ”— Related Issues related #113 related #373 ## โœ… Tests added? - [x] ๐Ÿ‘ yes The key, stack-key and both path directions are now tested on the class that owns them: one id per signature rather than per frame, the stack key reading through the entry type where the bucket key does not, and a frame naming one top-down row but one row per caller depth in bottom-up. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ™… not needed Unreleased inspector work already described by the #113 and #373 entries. ## Anything else we need to know? [optional] A `WeakMap` keyed by the event was measured as the alternative to the dense array: 542ms against 458ms for the build and 16MB more held, so the array stays. Two fixtures failed during this and it was the fixtures, not the code: the scoped-tree test double shared one intern table across tests while its fixtures reuse `eventIndex` values for different frames, so one test's key was read back for another. A table is per log in production, so the double now takes a fresh one per test. Follow-up, unchanged by this: the Call Tree grid still interns its keys per build (`toBottomUpTree`) or groups by string (`toAggregatedCallTree`, 335ms), and recovers a row's path by walking Tabulator tree parents and its occurrences by splitting key strings. Adopting this vocabulary there retires `bottomUpOccurrences.ts` and `rowCallChain`. --- .../CallTreeDetailScopedBuild.test.ts | 5 +- .../components/__tests__/locatedRow.test.ts | 50 +----- .../__tests__/scopedCallTree.test.ts | 18 +-- log-viewer/src/components/locatedRow.ts | 50 +----- log-viewer/src/components/scopedCallTree.ts | 45 +++--- log-viewer/src/core/log/LogStore.ts | 58 +------ .../src/core/log/__tests__/LogStore.test.ts | 27 ---- .../src/core/log/__tests__/keyPathIds.test.ts | 84 ++++++++-- log-viewer/src/core/log/eventKeys.ts | 12 ++ log-viewer/src/core/log/keyPathIds.ts | 152 ++++++++++++------ .../features/call-tree/utils/Aggregation.ts | 12 -- .../features/call-tree/utils/bucketRows.ts | 3 +- 12 files changed, 243 insertions(+), 273 deletions(-) diff --git a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts index 954865419..144c0d2cf 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts @@ -53,7 +53,6 @@ import { LOCATED_ROW_CLASS } from '../locatedRow.js'; import type { ApexLog } from 'apex-log-parser'; import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; -import { ROOT_PATH_ID } from '../../core/log/keyPathIds.js'; const build = jest.mocked(buildScopedCallTree); @@ -297,7 +296,7 @@ describe('CallTreeDetail scoped build', () => { log.eventsById[8] = { ...event, parent: log, children: [] }; const pathId = logStoreFor(log as unknown as ApexLog) .keyPathIds() - .pathId(ROOT_PATH_ID, 'METHOD_ENTRY||m'); + .pathOf(['METHOD_ENTRY||m']); const merged = [ { id: -3, eventIndexes: [8, 12], _pathId: pathId, _children: null }, ] as unknown as ScopedRow[]; @@ -341,7 +340,7 @@ describe('CallTreeDetail scoped build', () => { log.eventsById[12] = { ...event, eventIndex: 12, parent: log, children: [] }; const pathId = logStoreFor(log as unknown as ApexLog) .keyPathIds() - .pathId(ROOT_PATH_ID, 'METHOD_ENTRY||m'); + .pathOf(['METHOD_ENTRY||m']); const merged = [ { id: -3, eventIndexes: [8], _pathId: pathId, _children: null }, ] as unknown as ScopedRow[]; diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index 42c861165..9bddba7c1 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -8,13 +8,12 @@ import type { RowComponent } from 'tabulator-tables'; import type { ApexLog, LogEvent } from 'apex-log-parser'; -import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; -import { ROOT_PATH_ID, KeyPathIds } from '../../core/log/keyPathIds.js'; +import { logStoreFor } from '../../core/log/LogStore.js'; +import { KeyPathIds } from '../../core/log/keyPathIds.js'; import { LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, - eventPathIds, rowIndexStamper, rowPathId, rowPathStamper, @@ -66,7 +65,7 @@ function rowFor(container: HTMLElement, index: number): HTMLElement { describe('rowPathStamper', () => { it('marks the row under one parent and not its namesake under another', () => { - const ids = new KeyPathIds(); + const ids = new KeyPathIds(0); const stampPath = rowPathStamper(ids); const container = document.createElement('div'); const rows = [bucketRow('Trigger1', 'Util.log'), bucketRow('Trigger2', 'Util.log')]; @@ -98,11 +97,11 @@ describe('rowIndexStamper', () => { describe('rowPathId', () => { let ids: KeyPathIds; beforeEach(() => { - ids = new KeyPathIds(); + ids = new KeyPathIds(0); }); it('names a top-level row by its own key alone', () => { - expect(rowPathId(bucketRow('A'), ids)).toBe(ids.pathId(ROOT_PATH_ID, 'A')); + expect(rowPathId(bucketRow('A'), ids)).toBe(ids.pathOf(['A'])); }); it('tells two same-named rows apart by the parents that reach them', () => { @@ -123,41 +122,6 @@ describe('rowPathId', () => { }); }); -describe('eventPathIds', () => { - const root = ev('exec', null); - const outer = ev('outer', root); - const inner = ev('inner', outer); - let store: LogStore; - let ids: KeyPathIds; - beforeEach(() => { - // A store per test, since each expects an empty table. The frames are not in - // this log's index, so the ids are minted rather than read from the cache. - store = logStoreFor({ eventsById: [] } as unknown as ApexLog); - ids = store.keyPathIds(); - }); - - it('names one row in a top-down view, at the depth the frame ran at', () => { - const found = eventPathIds(inner, 'callees', store); - - expect(found).toHaveLength(1); - expect(found[0]).toBe(rowPathId(bucketRow('METHOD_ENTRY||outer', 'METHOD_ENTRY||inner'), ids)); - }); - - it('names a row per caller depth in a bottom-up view', () => { - // The frame heads a row on its own, and one under each caller above it. - const found = eventPathIds(inner, 'callers', store); - - expect(found).toEqual([ - rowPathId(bucketRow('METHOD_ENTRY||inner'), ids), - rowPathId(bucketRow('METHOD_ENTRY||inner', 'METHOD_ENTRY||outer'), ids), - ]); - }); - - it('leaves the log root out, as it is a row in neither view', () => { - expect(eventPathIds(root, 'callers', store)).toEqual([]); - }); -}); - describe('LocatedRowMarker', () => { it('marks the rendered row for the event', () => { const container = host(1, 2); @@ -228,7 +192,9 @@ describe('LocatedRowIds', () => { const found = new LocatedRowIds().idsFor(log, [5], 'callers'); // The log's own table, so a row stamped from it reaches the same ids. - expect(found).toEqual(eventPathIds(frame, 'callers', logStoreFor(log))); + const expected = new Set(); + logStoreFor(log).keyPathIds().pathIdsOf(frame, 'callers', expected); + expect(found).toEqual([...expected]); }); it('reuses what it built for the frames it was last asked about', () => { diff --git a/log-viewer/src/components/__tests__/scopedCallTree.test.ts b/log-viewer/src/components/__tests__/scopedCallTree.test.ts index 40543617a..cc6985703 100644 --- a/log-viewer/src/components/__tests__/scopedCallTree.test.ts +++ b/log-viewer/src/components/__tests__/scopedCallTree.test.ts @@ -1,9 +1,7 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { describe, expect, it } from '@jest/globals'; - -import type { LogEvent } from 'apex-log-parser'; +import { beforeEach, describe, expect, it } from '@jest/globals'; interface FakeEvent { eventIndex: number; @@ -56,16 +54,14 @@ let selectedIndex = 4; const { KeyPathIds } = jest.requireActual( '../../core/log/keyPathIds.js', ); -const { getEventKey, getStackKey } = jest.requireActual< - typeof import('../../core/log/eventKeys.js') ->('../../core/log/eventKeys.js'); -const paths = new KeyPathIds(); +// One table per log in production. The fixtures below reuse event indexes for +// different frames, so each test gets its own rather than one frame's key being +// read back for another. +let paths = new KeyPathIds(1024); jest.mock('../../core/log/LogStore.js', () => ({ currentLogStore: () => ({ log: root, keyPathIds: () => paths, - keyIdOf: (event: FakeEvent) => paths.keyId(getEventKey(event as unknown as LogEvent)), - stackIdOf: (event: FakeEvent) => paths.keyId(getStackKey(event as unknown as LogEvent)), eventByIndex: (i: number) => byId.get(i) ?? null, // Mirrors LogStore.stackByEventIndex over the fixture's own index. stackByEventIndex: (i: number) => { @@ -93,6 +89,10 @@ import type { FrameBudgetOptions } from '../../core/utility/FrameBudget.js'; * is only there to satisfy the contract. */ const options: FrameBudgetOptions = { yieldSlice: () => Promise.resolve() }; +beforeEach(() => { + paths = new KeyPathIds(1024); +}); + function build(eventIndex: number, instances?: number[]) { return buildScopedCallTree(eventIndex, instances ?? null, options); } diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index 15a1bcd5e..0e4d22f72 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -6,8 +6,8 @@ import type { ApexLog, LogEvent } from 'apex-log-parser'; import type { RowComponent } from 'tabulator-tables'; import type { DetailSelection, SelectionView } from '../core/events/EventBus.js'; -import { logStoreFor, type LogStore } from '../core/log/LogStore.js'; -import { ROOT_PATH_ID, type KeyPathIds } from '../core/log/keyPathIds.js'; +import { logStoreFor } from '../core/log/LogStore.js'; +import type { KeyPathIds } from '../core/log/keyPathIds.js'; import { eventByEventIndex } from '../core/utility/EventSearch.js'; import { occurrencesThrough } from '../features/call-tree/utils/bottomUpOccurrences.js'; @@ -155,43 +155,6 @@ export function rowPathStamper(ids: KeyPathIds): (row: RowComponent) => void { }; } -/** - * The path ids naming the rows a frame belongs to in a merged view. - * - * A top-down row sits at the frame's own depth, so one id names it. A bottom-up - * row is the frame plus however many of its callers the chain shows, so every - * prefix names a row the frame heads โ€” which is why one frame marks several rows - * there, and why each prefix is interned as it is reached. - * - * The log root is not a row in either view, so the walk stops below it. - */ -export function eventPathIds(event: LogEvent, direction: SelectionView, store: LogStore): number[] { - if (!event.parent) { - return []; - } - const paths = store.keyPathIds(); - if (direction === 'callers') { - // The parent walk is already innermost first, which is the order the ids are - // composed in, so the chain needs no array of its own. - const prefixes: number[] = []; - let id = ROOT_PATH_ID; - for (let node: LogEvent | null = event; node?.parent; node = node.parent) { - id = paths.step(id, store.keyIdOf(node)); - prefixes.push(id); - } - return prefixes; - } - const chain: number[] = []; - for (let node: LogEvent | null = event; node?.parent; node = node.parent) { - chain.push(store.keyIdOf(node)); - } - let id = ROOT_PATH_ID; - for (let depth = chain.length - 1; depth >= 0; depth--) { - id = paths.step(id, chain[depth]!); - } - return [id]; -} - /** True where the row holds no calls of its own, so its chain answers for it. */ function isDerived(data: CallRow): boolean { return !data.instances?.length && data.key !== undefined; @@ -232,15 +195,12 @@ function pathIdsForEvents( eventIndexes: readonly number[], direction: SelectionView, ): number[] { - const store = logStoreFor(root); + const paths = logStoreFor(root).keyPathIds(); const found = new Set(); for (const eventIndex of eventIndexes) { const event = eventByEventIndex(root, eventIndex); - if (!event) { - continue; - } - for (const id of eventPathIds(event, direction, store)) { - found.add(id); + if (event) { + paths.pathIdsOf(event, direction, found); } } return [...found]; diff --git a/log-viewer/src/components/scopedCallTree.ts b/log-viewer/src/components/scopedCallTree.ts index dafa85a7a..15fd07842 100644 --- a/log-viewer/src/components/scopedCallTree.ts +++ b/log-viewer/src/components/scopedCallTree.ts @@ -522,20 +522,19 @@ async function aggregate( // The recursion follows the call depth, which is shallow; each level's input // is the wide dimension, so that is where the slicing goes. async function merge(input: ScopedRow[], parentPathId: number): Promise { - const groups = new Map(); - const order: number[] = []; - // The occurrences behind each group โ€” a set, so one frame cannot be listed - // twice. - const indexes = new Map>(); + // The occurrences behind each group are a set, so one frame cannot be listed + // twice; `groups` keeps its own insertion order, so it is also the output + // order. + const groups = new Map }>(); for (let i = 0; i < input.length; i++) { if (i % CHECK_EVERY === 0 && !(await tick())) { return null; } const row = input[i]!; - const key = store.keyIdOf(row.originalData); - let group = groups.get(key); - if (!group) { - group = { + const key = paths.keyIdOf(row.originalData); + let held = groups.get(key); + if (!held) { + const group: ScopedRow = { id: nextId(), originalData: row.originalData, text: row.text, @@ -547,10 +546,10 @@ async function aggregate( _pathId: paths.step(parentPathId, key), _children: [], }; - groups.set(key, group); - order.push(key); - indexes.set(key, new Set()); + held = { row: group, seen: new Set() }; + groups.set(key, held); } + const group = held.row; if (!row.onPath) { // A group holding one real call is not a route, so it stays closed. group.onPath = undefined; @@ -559,9 +558,8 @@ async function aggregate( group.duration.self += row.duration.self; group.callCount += row.callCount; // Every merged occurrence, so pointing at the group points at all of them. - const seen = indexes.get(key)!; for (const index of locatableEventIndexes(row)) { - seen.add(index); + held.seen.add(index); } if (row._children) { const kids = group._children as ScopedRow[]; @@ -572,9 +570,8 @@ async function aggregate( } const merged: ScopedRow[] = []; - for (const key of order) { - const group = groups.get(key)!; - group.eventIndexes = [...indexes.get(key)!]; + for (const { row: group, seen } of groups.values()) { + group.eventIndexes = [...seen]; const kids = group._children as ScopedRow[]; if (kids.length) { const mergedKids = await merge(kids, group._pathId!); @@ -687,8 +684,8 @@ async function buildBottomUp( const entryFor = (row: ScopedRow, callers: WalkEntry | null): WalkEntry => ({ row, callers, - keyId: store.keyIdOf(row.originalData), - stackId: store.stackIdOf(row.originalData), + keyId: paths.keyIdOf(row.originalData), + stackId: paths.stackIdOf(row.originalData), leaving: false, attributed: 0, outer: null, @@ -749,16 +746,16 @@ async function buildBottomUp( } // Each occurrence is tagged with the chain that reached it, which is how a // caller row picks out the ones it stands for. - const store = seed._seed; + const occurrences = seed._seed; const held = row.eventIndexes; if (held) { for (let i = 0; i < held.length; i++) { - store.eventIndexes.push(held[i]!); - store.chains.push(pathId); + occurrences.eventIndexes.push(held[i]!); + occurrences.chains.push(pathId); } } else { - store.eventIndexes.push(row.originalData.eventIndex); - store.chains.push(pathId); + occurrences.eventIndexes.push(row.originalData.eventIndex); + occurrences.chains.push(pathId); } } } diff --git a/log-viewer/src/core/log/LogStore.ts b/log-viewer/src/core/log/LogStore.ts index d1dd3d8bf..89b15c1ad 100644 --- a/log-viewer/src/core/log/LogStore.ts +++ b/log-viewer/src/core/log/LogStore.ts @@ -9,7 +9,6 @@ import { SOSLExecuteBeginLine, } from 'apex-log-parser'; -import { getEventKey, getStackKey } from './eventKeys.js'; import { KeyPathIds } from './keyPathIds.js'; export type Stack = LogEvent[]; @@ -26,8 +25,6 @@ export class LogStore { private _statements: Statements | null = null; private _keyPathIds: KeyPathIds | null = null; - private _keyIds: Int32Array | null = null; - private _stackIds: Int32Array | null = null; constructor(log: ApexLog) { this.log = log; @@ -70,52 +67,12 @@ export class LogStore { return this.statements().sosl; } - /** The interned bucket paths of this log, shared by every view that marks a row - * whose occurrences are merged. */ + /** The interned keys and bucket paths of this log, shared by every view that + * marks a row whose occurrences are merged. */ keyPathIds(): KeyPathIds { - return (this._keyPathIds ??= new KeyPathIds()); - } - - /** - * The event's interned bucket key, kept per event. - * - * A mark walks the caller chain of every occurrence a pick names, and those - * occurrences share their ancestors, so building the key string per frame was - * most of what a mark cost. - */ - keyIdOf(event: LogEvent): number { - const cache = (this._keyIds ??= idCache(this.log)); - const at = event.eventIndex; - if (at >= 0 && at < cache.length) { - let id = cache[at]!; - if (id < 0) { - id = this.keyPathIds().keyId(getEventKey(event)); - cache[at] = id; - } - return id; - } - // An event the log's own index does not cover, so there is no slot to keep. - return this.keyPathIds().keyId(getEventKey(event)); - } - - /** - * The event's interned stack key, which tells a recursive call from a fresh - * one. Interned in the same table as {@link keyIdOf}, since the two vocabularies - * are never compared with each other. - */ - stackIdOf(event: LogEvent): number { - const cache = (this._stackIds ??= idCache(this.log)); - const at = event.eventIndex; - if (at >= 0 && at < cache.length) { - let id = cache[at]!; - if (id < 0) { - id = this.keyPathIds().keyId(getStackKey(event)); - cache[at] = id; - } - return id; - } - // An event the log's own index does not cover, so there is no slot to keep. - return this.keyPathIds().keyId(getStackKey(event)); + // No index means no slot to keep a key in, which costs only the key being + // built again. + return (this._keyPathIds ??= new KeyPathIds(this.log.eventsById?.length ?? 0)); } private statements(): Statements { @@ -123,11 +80,6 @@ export class LogStore { } } -/** One slot per event, -1 until the event is asked about. */ -function idCache(log: ApexLog): Int32Array { - return new Int32Array(log.eventsById.length).fill(-1); -} - interface Statements { soql: SOQLExecuteBeginLine[]; dml: DMLBeginLine[]; diff --git a/log-viewer/src/core/log/__tests__/LogStore.test.ts b/log-viewer/src/core/log/__tests__/LogStore.test.ts index aca966280..d02335a5c 100644 --- a/log-viewer/src/core/log/__tests__/LogStore.test.ts +++ b/log-viewer/src/core/log/__tests__/LogStore.test.ts @@ -63,33 +63,6 @@ describe('LogStore', () => { expect(stack[stack.length - 1]?.text).toBe('ns.ClassTwo.second()'); }); - it('gives a frame one interned key, and tells the two vocabularies apart', () => { - const log = - '09:18:22.6 (6574780)|EXECUTION_STARTED\n' + - '09:18:22.6 (6586704)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|ns.Thing.run()\n' + - '09:19:13.82 (51592737891)|CODE_UNIT_FINISHED|ns.Thing.run()\n' + - '09:19:13.82 (51595120059)|EXECUTION_FINISHED\n'; - - const apexLog = parse(log); - const store = logStoreFor(apexLog); - const unit = apexLog.eventsById.find((event) => event.text === 'ns.Thing.run()')!; - - // Asked twice, kept once โ€” the mark reads the same frame per occurrence. - expect(store.keyIdOf(unit)).toBe(store.keyIdOf(unit)); - // The bucket key carries the event type and the stack key does not, so a - // frame's two ids are not the same id. - expect(store.stackIdOf(unit)).not.toBe(store.keyIdOf(unit)); - }); - - it('keys a frame the log index does not cover', () => { - const apexLog = parse('09:18:22.6 (6574780)|EXECUTION_STARTED\n'); - const store = logStoreFor(apexLog); - // Built rather than parsed, so it has no slot in the log's own index. - const loose = { type: 'METHOD_ENTRY', namespace: '', text: 'made up', eventIndex: 9999 }; - - expect(store.keyIdOf(loose as never)).toBe(store.keyIdOf(loose as never)); - }); - it('gives one log one store, whichever view asks', () => { const log = '09:18:22.6 (6574780)|EXECUTION_STARTED\n' + '09:18:22.6 (7400000)|EXECUTION_FINISHED\n'; diff --git a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts index 7cbcd437b..7f19bc6d0 100644 --- a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts +++ b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts @@ -3,12 +3,19 @@ */ import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { LogEvent } from 'apex-log-parser'; + import { KeyPathIds, ROOT_PATH_ID } from '../keyPathIds.js'; +/** A frame at `eventIndex`, of the type and text a bucket key is built from. */ +function ev(eventIndex: number, text: string, parent: LogEvent | null, type = 'METHOD_ENTRY') { + return { eventIndex, type, namespace: '', text, parent } as unknown as LogEvent; +} + describe('KeyPathIds', () => { let ids: KeyPathIds; beforeEach(() => { - ids = new KeyPathIds(); + ids = new KeyPathIds(32); }); /** Interns a whole path, named outermost key first as a row reads. */ @@ -30,15 +37,6 @@ describe('KeyPathIds', () => { expect(pathFor(ids, 'A', 'B')).not.toBe(pathFor(ids, 'A')); }); - it('names the two directions apart, since a row means each in one view only', () => { - const chain = ['leaf', 'caller']; - - // Top-down names the whole chain; bottom-up names the leaf, then the leaf - // under its caller. - expect(ids.prefixesOf(chain)).toEqual([pathFor(ids, 'leaf'), expect.any(Number)]); - expect(ids.prefixesOf(chain)[1]).not.toBe(ids.pathOf(chain)); - }); - it('reads a path back, outermost first', () => { expect(ids.keysOf(pathFor(ids, 'A', 'B', 'C'))).toEqual(['A', 'B', 'C']); }); @@ -59,10 +57,74 @@ describe('KeyPathIds', () => { }); it('mints on its own, so an id from one log means nothing to another', () => { - const other = new KeyPathIds(); + const other = new KeyPathIds(32); expect(pathFor(other, 'Z')).toBe(pathFor(ids, 'A')); expect(other.keysOf(pathFor(other, 'Z'))).toEqual(['Z']); expect(ids.keysOf(pathFor(ids, 'A'))).toEqual(['A']); }); + + describe('keyIdOf', () => { + it('keeps one id per signature, not one per frame', () => { + const first = ev(1, 'Util.log', null); + const second = ev(2, 'Util.log', null); + + expect(ids.keyIdOf(first)).toBe(ids.keyIdOf(first)); + // Same type, namespace and text is the same bucket. + expect(ids.keyIdOf(second)).toBe(ids.keyIdOf(first)); + }); + + it('keys a frame no slot of its own covers', () => { + // Built rather than parsed, so it sits outside the log's own index. + const loose = ev(9999, 'made up', null); + + expect(ids.keyIdOf(loose)).toBe(ids.keyIdOf(loose)); + }); + }); + + describe('stackIdOf', () => { + it('reads through the entry type, which a bucket key does not', () => { + const unit = ev(1, 'Thing.run()', null, 'CODE_UNIT_STARTED'); + const method = ev(2, 'Thing.run()', null, 'METHOD_ENTRY'); + + // A method that recurses as a code unit is one frame to the stack. + expect(ids.stackIdOf(unit)).toBe(ids.stackIdOf(method)); + expect(ids.keyIdOf(unit)).not.toBe(ids.keyIdOf(method)); + }); + + it('tells two frames apart', () => { + expect(ids.stackIdOf(ev(1, 'one', null))).not.toBe(ids.stackIdOf(ev(2, 'two', null))); + }); + }); + + describe('pathIdsOf', () => { + const root = ev(1, 'exec', null); + const outer = ev(2, 'outer', root); + const inner = ev(3, 'inner', outer); + + /** The ids the walk adds, in the order it adds them. */ + function found(event: LogEvent, direction: 'callers' | 'callees'): number[] { + const into = new Set(); + ids.pathIdsOf(event, direction, into); + return [...into]; + } + + it('names one row in a top-down view, at the depth the frame ran at', () => { + expect(found(inner, 'callees')).toEqual([ + pathFor(ids, 'METHOD_ENTRY||outer', 'METHOD_ENTRY||inner'), + ]); + }); + + it('names a row per caller depth in a bottom-up view', () => { + // The frame heads a row on its own, and one under each caller above it. + expect(found(inner, 'callers')).toEqual([ + pathFor(ids, 'METHOD_ENTRY||inner'), + pathFor(ids, 'METHOD_ENTRY||inner', 'METHOD_ENTRY||outer'), + ]); + }); + + it('leaves the log root out, as it heads a row in neither view', () => { + expect(found(root, 'callers')).toEqual([]); + }); + }); }); diff --git a/log-viewer/src/core/log/eventKeys.ts b/log-viewer/src/core/log/eventKeys.ts index b91177f8c..662e89630 100644 --- a/log-viewer/src/core/log/eventKeys.ts +++ b/log-viewer/src/core/log/eventKeys.ts @@ -22,3 +22,15 @@ export function getEventKey(event: LogEvent): string { export function getStackKey(event: LogEvent): string { return `${event.namespace}|${event.text}`; } + +/** + * The bucket keys from `event` out to its outermost frame, innermost first. The + * log root heads no row in any view, so the walk stops below it. + */ +export function eventKeyChain(event: LogEvent): string[] { + const keys: string[] = []; + for (let node: LogEvent | null = event; node?.parent; node = node.parent) { + keys.push(getEventKey(node)); + } + return keys; +} diff --git a/log-viewer/src/core/log/keyPathIds.ts b/log-viewer/src/core/log/keyPathIds.ts index 5779e08a9..053bbb6e4 100644 --- a/log-viewer/src/core/log/keyPathIds.ts +++ b/log-viewer/src/core/log/keyPathIds.ts @@ -1,12 +1,19 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import type { LogEvent } from 'apex-log-parser'; + +import type { SelectionView } from '../events/EventBus.js'; +import { getEventKey, getStackKey } from './eventKeys.js'; /** The path every chain starts from, which no row stands for. */ export const ROOT_PATH_ID = 0; +/** One frame's chain, reused: the walk never yields, so one is enough. */ +const chain: number[] = []; + /** - * The bucket paths of one log, each interned to an integer. + * The interned keys and bucket paths of one log. * * A row in a view whose rows merge occurrences is named by the bucket keys that * reach it, since one method holds a row under every caller it has. Joining @@ -14,48 +21,121 @@ export const ROOT_PATH_ID = 0; * cost of a mark. An id per distinct path bounds the table by the tree rather * than by the calls, and makes matching an integer test. * - * Every caller depends on one invariant: a row's id is the interned chain of the - * frames the row holds. Compose ids through {@link pathOf} or - * {@link prefixesOf} rather than folding {@link pathId} by hand, since the two - * directions are separate spaces and an id from one means nothing in the other. + * One invariant holds it together: a row's id is the interned chain of the + * frames the row holds. {@link pathIdsOf} and {@link pathOf} are the two ways to + * reach one, so the two chain directions stay separate spaces here rather than + * in every caller. * * One table per log, held by `LogStore`: an id means nothing to another log. */ export class KeyPathIds { private keyIds = new Map(); private keys: string[] = []; + /** Each event's bucket key, as `id + 1` so an unset slot reads as 0. */ + private keyOfEvent: Int32Array; + private stackIds = new Map(); + /** Each bucket key's stack key. A bucket key holds the stack key, so this is + * per signature rather than per event. */ + private stackOfKey: number[] = []; // Indexed by path id: the paths reachable from it, its own parent, and the key // it was minted with. Index 0 is the empty path. private children: Array | undefined> = [new Map()]; private parents: number[] = [ROOT_PATH_ID]; private keyOf: number[] = [-1]; + constructor(eventCount: number) { + this.keyOfEvent = new Int32Array(eventCount); + } + /** - * The id for the path that reaches `key` through `parentPathId`, minted on - * first use. - * - * @param parentPathId - {@link ROOT_PATH_ID} for the outermost key of a chain + * The event's interned bucket key, kept per event: a mark reads the same + * ancestors once per occurrence it names. */ - public pathId(parentPathId: number, key: string): number { - return this.step(parentPathId, this.keyId(key)); + public keyIdOf(event: LogEvent): number { + const at = event.eventIndex; + const cached = this.keyOfEvent[at] ?? 0; + if (cached) { + return cached - 1; + } + const id = this.keyId(getEventKey(event)); + // Ignored where the log's own index has no such slot, which only costs the + // key being built again. + this.keyOfEvent[at] = id + 1; + return id; } /** - * The id of one bucket key, interned. A walk that steps the same key many times - * interns it once and calls {@link step}, since hashing the key is what a path - * id exists to avoid. + * The event's interned stack key, which tells a recursive call from a fresh + * one. Its own space: a stack key is never a step in a path. */ - public keyId(key: string): number { - let id = this.keyIds.get(key); + public stackIdOf(event: LogEvent): number { + const keyId = this.keyIdOf(event); + let id = this.stackOfKey[keyId]; if (id === undefined) { - id = this.keys.length; - this.keyIds.set(key, id); - this.keys.push(key); + const key = getStackKey(event); + id = this.stackIds.get(key) ?? this.stackIds.size; + this.stackIds.set(key, id); + this.stackOfKey[keyId] = id; } return id; } - /** {@link pathId} for a key already interned by {@link keyId}. */ + /** + * The ids naming the rows a frame belongs to in a merged view, added to `into`. + * + * A top-down row sits at the frame's own depth, so one id names it. A + * bottom-up row is the frame plus however many of its callers the chain shows, + * so every prefix names a row the frame heads โ€” which is why one frame marks + * several rows there. + * + * The log root heads no row in either view, so the walk stops below it. + */ + public pathIdsOf(event: LogEvent, direction: SelectionView, into: Set): void { + if (!event.parent) { + return; + } + if (direction === 'callers') { + // The parent walk is already innermost first, which is the order these ids + // compose in, so nothing is collected on the way. + let id = ROOT_PATH_ID; + for (let node: LogEvent | null = event; node?.parent; node = node.parent) { + id = this.step(id, this.keyIdOf(node)); + into.add(id); + } + return; + } + chain.length = 0; + for (let node: LogEvent | null = event; node?.parent; node = node.parent) { + chain.push(this.keyIdOf(node)); + } + let id = ROOT_PATH_ID; + for (let depth = chain.length - 1; depth >= 0; depth--) { + id = this.step(id, chain[depth]!); + } + into.add(id); + } + + /** + * The id for a whole chain, outermost key first: what names a top-down row, and + * what a row built from key strings is stamped with. + * + * @param keys - the chain innermost first, as a row's own parent walk gives it + */ + public pathOf(keys: readonly string[]): number { + let id = ROOT_PATH_ID; + for (let depth = keys.length - 1; depth >= 0; depth--) { + id = this.step(id, this.keyId(keys[depth]!)); + } + return id; + } + + /** + * The id for the path that reaches an interned key through `parentPathId`, + * minted on first use. For a walk that already holds the key's id and composes + * a path as it goes. + * + * @param parentPathId - {@link ROOT_PATH_ID} for the outermost key of a chain + */ public step(parentPathId: number, keyId: number): number { const reachable = (this.children[parentPathId] ??= new Map()); let id = reachable.get(keyId); @@ -72,35 +152,17 @@ export class KeyPathIds { return id; } - /** - * The id for a whole chain, outermost key first: what names a top-down row. - * - * @param keys - the chain innermost first, as `eventKeyChain` gives it - */ - public pathOf(keys: readonly string[]): number { - let id = ROOT_PATH_ID; - for (let depth = keys.length - 1; depth >= 0; depth--) { - id = this.pathId(id, keys[depth]!); + /** One bucket key's id, interned. */ + public keyId(key: string): number { + let id = this.keyIds.get(key); + if (id === undefined) { + id = this.keys.length; + this.keyIds.set(key, id); + this.keys.push(key); } return id; } - /** - * An id per step out along a chain: the bottom-up rows a frame heads, which are - * the frame alone, then the frame under one caller, and so on. - * - * @param keys - the chain innermost first - */ - public prefixesOf(keys: readonly string[]): number[] { - const prefixes: number[] = []; - let id = ROOT_PATH_ID; - for (const key of keys) { - id = this.pathId(id, key); - prefixes.push(id); - } - return prefixes; - } - /** * True where `path` runs through `pathId`: the same path, or one that extends * it. An id is minted per parent and key, so running through a path is being diff --git a/log-viewer/src/features/call-tree/utils/Aggregation.ts b/log-viewer/src/features/call-tree/utils/Aggregation.ts index 5ac044c0c..edc613d2c 100644 --- a/log-viewer/src/features/call-tree/utils/Aggregation.ts +++ b/log-viewer/src/features/call-tree/utils/Aggregation.ts @@ -134,18 +134,6 @@ export interface BottomUpRow { _hasDetailsDeep: boolean; } -/** - * The bucket keys from `event` out to its outermost frame, innermost first. The - * log root heads no row in any view, so the walk stops below it. - */ -export function eventKeyChain(event: LogEvent): string[] { - const keys: string[] = []; - for (let node: LogEvent | null = event; node?.parent; node = node.parent) { - keys.push(getEventKey(node)); - } - return keys; -} - /** * Creates an aggregated call tree where all calls to the same function signature * are merged together, with aggregated metrics. diff --git a/log-viewer/src/features/call-tree/utils/bucketRows.ts b/log-viewer/src/features/call-tree/utils/bucketRows.ts index 29c06ffaf..267e542e7 100644 --- a/log-viewer/src/features/call-tree/utils/bucketRows.ts +++ b/log-viewer/src/features/call-tree/utils/bucketRows.ts @@ -7,8 +7,7 @@ import type { RowComponent } from 'tabulator-tables'; import type { SelectionView } from '../../../core/events/EventBus.js'; import { withCodeDrivenExpand } from '../../../tabulator/module/expandOrigin.js'; -import { getEventKey } from '../../../core/log/eventKeys.js'; -import { eventKeyChain } from './Aggregation.js'; +import { eventKeyChain, getEventKey } from '../../../core/log/eventKeys.js'; interface BucketRow { key?: string; From e2fd2a290209a5b9f0943813e918631eba745bbb Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:28:08 +0100 Subject: [PATCH 02/61] perf(log-viewer): 24% faster Call Tree grids, and a highlight that follows your row (#973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview A merged call-tree row was identified by joining its bucket keys into a string, which was most of the cost of the grid builds. Separately, a bottom-up caller row reported the calls it counts rather than the frames it is, so every caller depth highlighted the same leaf frames however deep you picked. The grids now group on the interned key ids the inspector already uses, and a row now reports the frames it stands for, so stepping down the callers walks the highlight up the stack. Three navigation bugs found while walking that change are fixed here too. ## ๐Ÿ› ๏ธ Changes made - **Grid builds on interned key ids** โ€” both builders take the log's `KeyPathIds` and group on integers instead of joined key strings. On a 95MB log (864,216 lines, 431,307 calls) `toBottomUpTree` goes 497ms โ†’ 377ms and `toAggregatedCallTree` 337ms โ†’ 308ms. Medians of four runs; run-to-run variance on this log is about 50ms, so read the second figure as directional. - **A frame outside the log's own index is keyed but not cached** โ€” `keyIdOf` wrote past the end of its `Int32Array`, which lands as an ordinary property, so every frame built rather than parsed read back the first one's id. - **The Analysis reveal reads no occurrence** โ€” it found its bucket by scanning the 431,307 occurrences the root buckets hold between them, then listed every active row again to test one filter. One key compare over the top-level rows and one boolean read now do it. - **A bottom-up row highlights its own frames** โ€” `frameEventIndexes` climbs the row's own path depth from each counted call and dedupes, so the flame chart and call tree point at the frames the row is. Details still describes the calls it counts, which ride on a field of their own. - **The keyboard survives a tree-control click** โ€” the control is not focusable, so clicking it dropped focus, and the key bindings only answer while the table body holds it: the arrows scrolled the table instead of moving through it. Focus now returns on every pointer expand and collapse, not only the first one before any row is selected. - **A bucket descent waits for the render it needs** โ€” the descent skipped the wait entirely for any row already open, so it read empty children and fell back to that row: picking a deep inspector row landed on one of its callers and needed a second click. It now waits for any row whose children have not arrived. - **Analysis moves to a picked inspector row** โ€” it marked the bucket but never scrolled to it, because only a finding click revealed. It now reveals on a sticky locate and then marks, as the Call Tree does. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [x] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A. Nothing new appears on screen: what changes is build timing and which frames light up. ## ๐Ÿ”— Related Issues None. ## โœ… Tests added? - [x] ๐Ÿ‘ yes - [ ] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [x] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features - [ ] ๐Ÿ™… not needed Two entries, for the keyboard and navigation fixes. The highlight and Analysis fixes correct the Inspector, which is unreleased, so they belong to its existing entry rather than a new one. ## Anything else we need to know? [optional] **Where to start.** `log-viewer/src/core/log/keyPathIds.ts` carries the vocabulary; read its class doc first. The two id spaces matter: the aggregated build composes outermost-first and the bottom-up build innermost-first, so ids from the two directions must never be compared. **Two questions, two accessors.** `locatableEventIndexes` is the calls a row counts, which its totals describe. `frameEventIndexes` is the frames the row is, which a highlight points at. They differ only for a bottom-up caller row. **Test plan.** - `pnpm lint` and `pnpm test`. - Call Tree, Bottom Up: expand a method to its callers. Totals and call counts read as before. Hover depths 2, 3 and 4: the lit frames walk up the stack a level at a time, and `Called by` names the row hovered. - Click a row's expand arrow, then press the up and down arrows: the selection moves and the table does not scroll on its own. - Aggregated: pick a deep row from the inspector's call tree. It lands on that row first time, not on one of its callers. - Analysis: pick a row in the inspector's call tree. The grid scrolls to the bucket and selects it. Picking a bucket the Show Details filter hides turns that filter off, as a finding click already did. - Both themes, and the Inspector docked at the side and at the bottom. **Known unrelated failure locally.** `lana/src/services/__tests__/servicesRuntime.test.ts` cannot resolve `effect` in a worktree that has not been installed since #951. No `lana` file is touched here. **Reverted during review.** A first attempt routed that wait through `RowNavigation`'s pending-render flag. Tabulator dispatches `renderStarted` and `renderComplete` in one synchronous call, so the flag always read false where the wait was awaited and the indirection bought nothing, while its one live branch could only be entered by a `renderStarted` whose `renderComplete` never came โ€” which one throwing subscriber causes, since `_dispatch` has no try/catch and five modules subscribe. The wait would then never settle. The inline wait is back. **Follow-ups, not in this PR.** - The reverse direction still marks on the old rule: hovering a frame marks the rows whose leaf it is, rather than the rows whose own frame it is. Doing it properly needs a walk of that frame's subtree. - The reveal-then-mark handler is now shared in shape by four views but copied in two. `DatabaseView` and `ApexLogTimeline` mark without revealing, so "does a pick move this view" deserves to be an argument rather than a property of which handler was copied last. --- CHANGELOG.md | 2 + log-viewer/src/components/CallTreeDetail.ts | 28 ++- .../CallTreeDetailScopedBuild.test.ts | 44 ++++- .../components/__tests__/locatedRow.test.ts | 54 ++---- .../__tests__/scopedCallTree.test.ts | 32 ++++ log-viewer/src/components/locatedRow.ts | 156 +++++++--------- log-viewer/src/components/scopedCallTree.ts | 42 +++++ log-viewer/src/core/events/EventBus.ts | 13 +- .../src/core/log/__tests__/keyPathIds.test.ts | 30 +++- log-viewer/src/core/log/keyPathIds.ts | 73 +++++--- .../analysis/components/AnalysisView.ts | 56 ++++-- .../components/__tests__/AnalysisView.test.ts | 106 +++++++++-- .../call-tree/components/AggregatedTable.ts | 7 +- .../call-tree/components/BottomUpTable.ts | 7 +- .../call-tree/components/CalltreeView.ts | 11 +- .../features/call-tree/utils/Aggregation.ts | 167 ++++++++++-------- .../call-tree/utils/CategoryColoring.ts | 18 +- .../utils/__tests__/Aggregation.test.ts | 118 +++++++------ .../__tests__/bottomUpOccurrences.test.ts | 63 ------- .../utils/__tests__/bucketRows.test.ts | 23 ++- .../call-tree/utils/bottomUpOccurrences.ts | 63 ------- .../features/call-tree/utils/bucketRows.ts | 16 +- .../tabulator/module/RowKeyboardNavigation.ts | 33 +++- .../__tests__/RowKeyboardNavigation.test.ts | 26 ++- scripts/measure/measure.ts | 10 +- 25 files changed, 694 insertions(+), 504 deletions(-) delete mode 100644 log-viewer/src/features/call-tree/utils/__tests__/bottomUpOccurrences.test.ts delete mode 100644 log-viewer/src/features/call-tree/utils/bottomUpOccurrences.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58441b77e..d62b2b3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐ŸŽจ **Timeline theme switch**: parts of the Timeline did not update on theme switch until the log view was reopened; they now do. - ๐Ÿ“Š **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. ([#873]) - ๐ŸŽจ **Theme colours**: some colours did not update on theme switch; they now do. +- โŒจ๏ธ **Call Tree keyboard**: clicking a row's expand arrow dropped keyboard focus, so the arrow keys scrolled the table instead of moving through it; focus now returns after every expand and collapse. +- ๐Ÿงญ **Call Tree navigation**: jumping to a row in the Aggregated or Bottom-Up view could stop on one of its callers and need a second try, because the walk read a row's children before they had rendered; it now waits for the render. - ๐Ÿงญ **Inspector call stack**: cumulative limit and profiling frames appeared in the stack, so the path to a selection read wrong; the stack now excludes them, like the call tree already did. - ๐Ÿ› **Go to Code**: Match methods with namespace/`System`-qualified parameter types. ([#834]) - ๐Ÿ“ **Timeline height**: the Flame Chart stopped short of the bottom of its panel, leaving a strip of empty space; it now fills the panel and follows the Inspector as you resize or re-dock it. diff --git a/log-viewer/src/components/CallTreeDetail.ts b/log-viewer/src/components/CallTreeDetail.ts index 5e87f00da..0fdf3ffa7 100644 --- a/log-viewer/src/components/CallTreeDetail.ts +++ b/log-viewer/src/components/CallTreeDetail.ts @@ -49,6 +49,7 @@ import { PANEL_ROW_MENU_ITEMS, runPanelRowAction } from './panelRowMenu.js'; import { buildScopedCallTree, buildWholeLogCallTree, + frameEventIndexes, locatableEventIndexes, revealableEventIndex, rowIdsByPath, @@ -618,8 +619,8 @@ export class CallTreeDetail extends LitElement { // Selecting a real frame reveals it in the tab on screen. Aggregated and // bottom-up rows merge occurrences behind a synthetic negative id, so // revealing one would misname which occurrence was clicked; the pick marks - // every occurrence instead, and holds until it is dropped โ€” as the Chrome - // DevTools performance panel keeps a selected group's instances lit. + // every frame the row stands for instead, and holds until it is dropped, as + // the Chrome DevTools performance panel keeps a selected group's frames lit. table.on('rowSelectionChanged', (_data, rows) => { if (this._echoGuard.suppressed) { return; @@ -633,7 +634,7 @@ export class CallTreeDetail extends LitElement { // reads the same either way. Built from the row: a scoped row carries no // key, which is what the tab's own rows are read through. const instances = locatableEventIndexes(data); - dispatchInspectorLocate(this, instances, true, { + dispatchInspectorLocate(this, frameEventIndexes(data), true, { kind: 'aggregate', instances, calledBy: this.viewMode === 'bottom-up' ? callerOfRow(rows[0]) : undefined, @@ -642,9 +643,9 @@ export class CallTreeDetail extends LitElement { }); // Hovering a row marks it in the tab on screen, so the user can see where it // sits before deciding to pick it. A grouped row cannot be revealed - there is - // no one frame to jump to - but every occurrence it merges can be marked. + // no one frame to jump to - but every frame it stands for can be marked. table.on('rowMouseEnter', (_e, row) => { - dispatchInspectorLocate(this, locatableEventIndexes(row.getData() as Partial)); + dispatchInspectorLocate(this, frameEventIndexes(row.getData() as Partial)); }); table.on('rowMouseLeave', () => { dispatchInspectorLocate(this, []); @@ -797,20 +798,11 @@ export class CallTreeDetail extends LitElement { } } -/** - * The frame that called a bottom-up row's own calls: the caller shown directly - * above the row's seed. A tree parent is the callee there, so the walk runs to - * the row one level below the top. - */ +/** What a picked bottom-up row's calls were reached through: the row's own frame, + * or nothing on a top-level row, which names its own calls. */ function callerOfRow(row: RowComponent | undefined): string | undefined { - let node = row; - let parent = node?.getTreeParent(); - if (!node || !parent) { + if (!row?.getTreeParent()) { return undefined; } - for (let above = parent.getTreeParent(); above; above = parent.getTreeParent()) { - node = parent; - parent = above; - } - return (node.getData() as Partial).text; + return (row.getData() as Partial).text; } diff --git a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts index 144c0d2cf..173cfac11 100644 --- a/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts +++ b/log-viewer/src/components/__tests__/CallTreeDetailScopedBuild.test.ts @@ -38,21 +38,23 @@ jest.mock('../scopedCallTree.js', () => ({ // Keep the real row readers: the hover test is about which rows name a frame. revealableEventIndex: jest.requireActual('../scopedCallTree.js').revealableEventIndex, locatableEventIndexes: jest.requireActual('../scopedCallTree.js').locatableEventIndexes, + frameEventIndexes: jest.requireActual('../scopedCallTree.js').frameEventIndexes, rowIdsByPath: jest.requireActual('../scopedCallTree.js').rowIdsByPath, })); -import { Tabulator } from 'tabulator-tables'; +import { Tabulator, type RowComponent } from 'tabulator-tables'; import type { CallTreeDetail } from '../CallTreeDetail.js'; import '../CallTreeDetail.js'; import { buildScopedCallTree, type ScopedCallTree, type ScopedRow } from '../scopedCallTree.js'; import { INSPECTOR_LOCATE_EVENT, type InspectorLocateEvent } from '../inspectorReveal.js'; -import { eventBus } from '../../core/events/EventBus.js'; +import { eventBus, type DetailSelection } from '../../core/events/EventBus.js'; import type { ProgressParams } from '../../tabulator/format/ProgressMS.js'; import { LOCATED_ROW_CLASS } from '../locatedRow.js'; import type { ApexLog } from 'apex-log-parser'; import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; +import { ROOT_PATH_ID } from '../../core/log/keyPathIds.js'; const build = jest.mocked(buildScopedCallTree); @@ -294,9 +296,8 @@ describe('CallTreeDetail scoped build', () => { const event = { eventIndex: 8, type: 'METHOD_ENTRY', namespace: '', text: 'm' }; const log = { eventsById: [] as unknown[], children: [] }; log.eventsById[8] = { ...event, parent: log, children: [] }; - const pathId = logStoreFor(log as unknown as ApexLog) - .keyPathIds() - .pathOf(['METHOD_ENTRY||m']); + const paths = logStoreFor(log as unknown as ApexLog).keyPathIds(); + const pathId = paths.step(ROOT_PATH_ID, paths.keyId('METHOD_ENTRY||m')); const merged = [ { id: -3, eventIndexes: [8, 12], _pathId: pathId, _children: null }, ] as unknown as ScopedRow[]; @@ -338,9 +339,8 @@ describe('CallTreeDetail scoped build', () => { const log = { eventsById: [] as unknown[], children: [] }; log.eventsById[8] = { ...event, parent: log, children: [] }; log.eventsById[12] = { ...event, eventIndex: 12, parent: log, children: [] }; - const pathId = logStoreFor(log as unknown as ApexLog) - .keyPathIds() - .pathOf(['METHOD_ENTRY||m']); + const paths = logStoreFor(log as unknown as ApexLog).keyPathIds(); + const pathId = paths.step(ROOT_PATH_ID, paths.keyId('METHOD_ENTRY||m')); const merged = [ { id: -3, eventIndexes: [8], _pathId: pathId, _children: null }, ] as unknown as ScopedRow[]; @@ -367,6 +367,34 @@ describe('CallTreeDetail scoped build', () => { expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(true); }); + it('names the picked bottom-up row, so one caller depth reads apart from the next', async () => { + build.mockImplementation((eventIndex) => Promise.resolve(tree(eventIndex * 1000))); + const el = await mount(5, 'callees'); + await frame(el); + const pick = tables.instances[0]!.on.mock.calls.find( + (call) => call[0] === 'rowSelectionChanged', + )?.[1] as ((...args: unknown[]) => void) | undefined; + + const seen: Array = []; + const located = (e: Event) => seen.push((e as InspectorLocateEvent).detail.selection); + document.addEventListener(INSPECTOR_LOCATE_EVENT, located); + + // A seed row and the two caller depths above it, which hold the same call. + const fakeRow = (data: unknown, parent?: unknown) => + ({ getData: () => data, getTreeParent: () => parent ?? false }) as unknown as RowComponent; + const seed = fakeRow({ id: -1, text: 'seed', eventIndexes: [8] }); + const depth2 = fakeRow({ id: -2, text: 'B', eventIndexes: [8] }, seed); + pick?.(null, [seed]); + pick?.(null, [depth2]); + pick?.(null, [fakeRow({ id: -3, text: 'A', eventIndexes: [8] }, depth2)]); + + document.removeEventListener(INSPECTOR_LOCATE_EVENT, located); + // The seed names its own calls; each caller depth names itself. + expect( + seen.map((selection) => (selection?.kind === 'aggregate' ? selection.calledBy : null)), + ).toEqual([undefined, 'B', 'A']); + }); + it('reports every occurrence the row under the pointer stands for', async () => { build.mockImplementation((eventIndex) => Promise.resolve(tree(eventIndex * 1000))); const el = await mount(5); diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index 9bddba7c1..14585218a 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -3,20 +3,20 @@ * * @jest-environment jsdom */ -import { beforeEach, describe, expect, it } from '@jest/globals'; +import { describe, expect, it } from '@jest/globals'; import type { RowComponent } from 'tabulator-tables'; import type { ApexLog, LogEvent } from 'apex-log-parser'; import { logStoreFor } from '../../core/log/LogStore.js'; -import { KeyPathIds } from '../../core/log/keyPathIds.js'; +import { KeyPathIds, ROOT_PATH_ID } from '../../core/log/keyPathIds.js'; import { LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, rowIndexStamper, rowPathId, - rowPathStamper, + stampRowPath, } from '../locatedRow.js'; const stamp = rowIndexStamper('eventIndex'); @@ -33,14 +33,17 @@ function rowComponent( } as unknown as RowComponent; } -/** A bucket row and its chain of parents, innermost last, as tabulator hands them - * over: the tree parent of a bottom-up caller row is the frame it called. */ -function bucketRow(...keys: string[]): RowComponent { - let row: RowComponent | false = false; +/** A bucket row as its builder leaves it: the key it merges, and the path that + * tells it from a same-named row under another caller. */ +function bucketRow(ids: KeyPathIds, ...keys: string[]): RowComponent { + let pathId = ROOT_PATH_ID; for (const key of keys) { - row = rowComponent(document.createElement('div'), { key }, row); + pathId = ids.step(pathId, ids.keyId(key)); } - return row as RowComponent; + return rowComponent(document.createElement('div'), { + key: keys[keys.length - 1], + _pathId: pathId, + }); } function ev(text: string, parent: LogEvent | null): LogEvent { @@ -63,21 +66,20 @@ function rowFor(container: HTMLElement, index: number): HTMLElement { return container.children[index] as HTMLElement; } -describe('rowPathStamper', () => { +describe('stampRowPath', () => { it('marks the row under one parent and not its namesake under another', () => { const ids = new KeyPathIds(0); - const stampPath = rowPathStamper(ids); const container = document.createElement('div'); - const rows = [bucketRow('Trigger1', 'Util.log'), bucketRow('Trigger2', 'Util.log')]; + const rows = [bucketRow(ids, 'Trigger1', 'Util.log'), bucketRow(ids, 'Trigger2', 'Util.log')]; for (const row of rows) { const element = row.getElement(); element.classList.add('tabulator-row'); - stampPath(row); + stampRowPath(row); container.append(element); } const marker = new LocatedRowMarker(); - marker.mark(container, [rowPathId(rows[0]!, ids)!]); + marker.mark(container, [rowPathId(rows[0]!)!]); expect(rows[0]!.getElement().classList.contains(LOCATED_ROW_CLASS)).toBe(true); expect(rows[1]!.getElement().classList.contains(LOCATED_ROW_CLASS)).toBe(false); @@ -95,30 +97,8 @@ describe('rowIndexStamper', () => { }); describe('rowPathId', () => { - let ids: KeyPathIds; - beforeEach(() => { - ids = new KeyPathIds(0); - }); - - it('names a top-level row by its own key alone', () => { - expect(rowPathId(bucketRow('A'), ids)).toBe(ids.pathOf(['A'])); - }); - - it('tells two same-named rows apart by the parents that reach them', () => { - // One method holds a row under every caller it has, so the key alone cannot. - expect(rowPathId(bucketRow('Trigger1', 'Util.log'), ids)).not.toBe( - rowPathId(bucketRow('Trigger2', 'Util.log'), ids), - ); - }); - - it('gives one id to the whole path, so two rows on it agree', () => { - const deep = rowPathId(bucketRow('A', 'B', 'C'), ids); - expect(deep).toBe(rowPathId(bucketRow('A', 'B', 'C'), ids)); - expect(deep).not.toBe(rowPathId(bucketRow('A', 'B'), ids)); - }); - it('leaves a row that stands for one frame unnamed, as its index names it', () => { - expect(rowPathId(rowComponent(document.createElement('div'), { id: 7 }), ids)).toBeUndefined(); + expect(rowPathId(rowComponent(document.createElement('div'), { id: 7 }))).toBeUndefined(); }); }); diff --git a/log-viewer/src/components/__tests__/scopedCallTree.test.ts b/log-viewer/src/components/__tests__/scopedCallTree.test.ts index cc6985703..a5addf4d5 100644 --- a/log-viewer/src/components/__tests__/scopedCallTree.test.ts +++ b/log-viewer/src/components/__tests__/scopedCallTree.test.ts @@ -79,6 +79,7 @@ jest.mock('../../core/log/LogStore.js', () => ({ import { buildScopedCallTree, buildWholeLogCallTree, + frameEventIndexes, locatableEventIndexes, rowIdsByPath, type ScopedRow, @@ -551,6 +552,37 @@ describe('rowIdsByPath', () => { }); }); +describe('frameEventIndexes', () => { + it("names the callers at the row's own depth, not the calls they conducted", async () => { + // exec -> m1 -> m2 -> soql, so the bottom-up seed is the statement and each + // row under it is one frame further up the same stack. + const rows = (await (await build(1))!.bottomUp(options))!; + const seed = rows[0]!; + const m2Row = seed._children![0]!; + const m1Row = m2Row._children![0]!; + + expect(frameEventIndexes(seed)).toEqual([soql.eventIndex]); + expect(frameEventIndexes(m2Row)).toEqual([m2.eventIndex]); + expect(frameEventIndexes(m1Row)).toEqual([m1.eventIndex]); + }); + + it('names one caller frame however many calls it made', async () => { + const instances = loopOccurrences(2); + const rows = (await (await build(300))!.bottomUp(options))!; + const caller = rows[0]!._children![0]!; + + // The row counts both calls, and is the single frame that made them. + expect(locatableEventIndexes(caller)).toEqual(instances); + expect(frameEventIndexes(caller)).toEqual([300]); + }); + + it('names the one frame of a row that merges nothing', () => { + const row = { id: 1, originalData: soql } as unknown as Partial; + + expect(frameEventIndexes(row)).toEqual([soql.eventIndex]); + }); +}); + describe('bottom-up occurrences', () => { it('derives a caller row from the top-level row, holding no calls itself', async () => { // Rebuilds the loop and its two calls; the loop itself is the selection. diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index 0e4d22f72..a0bdd7134 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -7,9 +7,7 @@ import type { RowComponent } from 'tabulator-tables'; import type { DetailSelection, SelectionView } from '../core/events/EventBus.js'; import { logStoreFor } from '../core/log/LogStore.js'; -import type { KeyPathIds } from '../core/log/keyPathIds.js'; import { eventByEventIndex } from '../core/utility/EventSearch.js'; -import { occurrencesThrough } from '../features/call-tree/utils/bottomUpOccurrences.js'; /** Class the marked row carries; each table styles it itself. */ export const LOCATED_ROW_CLASS = 'located-row'; @@ -28,8 +26,8 @@ const ROW_INDEX_ATTRIBUTE = 'data-row-index'; * The index is read from the data rather than `getIndex()`: Tabulator runs the * formatter for its calc rows too, and those carry no index. * - * A view whose rows merge occurrences stamps {@link rowPathStamper} instead, as - * a bucket has no event of its own. + * A view whose rows merge occurrences stamps {@link stampRowPath} instead, as a + * bucket has no event of its own. * * @param indexField - the table's `index` option */ @@ -46,6 +44,8 @@ export function rowIndexStamper(indexField: string): (row: RowComponent) => void * occurrences it holds, which a bottom-up caller bucket has none of. */ interface CallRow { key?: string; + /** The interned bucket path the row's builder stamped on it. */ + _pathId?: number; text?: string; instances?: LogEvent[]; originalData?: LogEvent; @@ -53,51 +53,28 @@ interface CallRow { const rowCallData = (row: RowComponent): CallRow => row.getData() as CallRow; -/** Derived calls, held per row: a pointer sweep re-enters rows, and the click - * that follows a hover asks again. Both answers are cached, because deriving - * them reads every occurrence the root bucket holds. */ -const derivedCalls = new WeakMap(); +/** Held per row: a pointer sweep re-enters rows and the click that follows a + * hover asks again, but deriving reads every occurrence the root bucket holds. */ const derivedIndexes = new WeakMap(); -const derivedChains = new WeakMap(); const NO_CALLS: LogEvent[] = []; -/** Where a derived bottom-up row sits: the root bucket whose calls it stands - * for, the keys from that root out to the row, and the row under the root โ€” - * the frame that made those calls. */ -interface CallChain { - root: CallRow; - keys: string[]; - caller: CallRow; -} - /** - * A derived row's place in the bottom-up tree, or null where the walk leaves the - * merged rows and the row stands for nothing. Cached: the occurrences and the - * caller are both read off it. + * The root bucket a derived row reads its calls from, or null where the walk + * leaves the merged rows and the row stands for nothing. */ -function rowCallChain(row: RowComponent, data: CallRow): CallChain | null { - const cached = derivedChains.get(data); - if (cached !== undefined) { - return cached; - } - const keys = [data.key!]; +function rootBucketOf(row: RowComponent, data: CallRow): CallRow | null { let node = data; - let caller = data; for (let parent = row.getTreeParent(); parent; parent = parent.getTreeParent()) { const parentData = rowCallData(parent); if (parentData.key === undefined) { - derivedChains.set(data, null); return null; } - caller = node; node = parentData; - keys.push(parentData.key); } - // The tree parent is the callee, so the walk runs inwards; the path runs out. - const chain: CallChain = { root: node, keys: keys.reverse(), caller }; - derivedChains.set(data, chain); - return chain; + // The tree parent is the callee, so the walk runs inwards, to the bucket that + // holds the calls. + return node; } /** @@ -112,59 +89,32 @@ export function rowId(row: RowComponent | undefined): number | undefined { return typeof id === 'number' ? id : undefined; } -const pathIds = new WeakMap(); - /** * What tells a merged row apart from a same-named row under a different parent: - * the bucket keys from its top-level ancestor out to the row itself, interned to - * one integer. A single key does not, because a bucket map is allocated per - * parent, so one method holds a row under every caller it has. + * the bucket path its builder stamped on it. A single key does not, because a + * bucket map is allocated per parent, so one method holds a row under every + * caller it has. * * Undefined on a row that stands for one frame, which its event index identifies. */ -export function rowPathId(row: RowComponent, ids: KeyPathIds): number | undefined { - const data = rowCallData(row); - if (data.key === undefined) { - return undefined; - } - const cached = pathIds.get(data); - if (cached !== undefined) { - return cached; - } - const keys = [data.key]; - for (let parent = row.getTreeParent(); parent; parent = parent.getTreeParent()) { - const parentKey = rowCallData(parent).key; - if (parentKey === undefined) { - break; - } - keys.push(parentKey); - } - const id = ids.pathOf(keys); - pathIds.set(data, id); - return id; +export function rowPathId(row: RowComponent): number | undefined { + return rowCallData(row)._pathId; } /** A `rowFormatter` for a view whose rows merge occurrences, stamping the path id * so the mark finds the row with the same one DOM query. */ -export function rowPathStamper(ids: KeyPathIds): (row: RowComponent) => void { - return (row) => { - const id = rowPathId(row, ids); - if (id !== undefined) { - row.getElement()?.setAttribute(ROW_INDEX_ATTRIBUTE, String(id)); - } - }; -} - -/** True where the row holds no calls of its own, so its chain answers for it. */ -function isDerived(data: CallRow): boolean { - return !data.instances?.length && data.key !== undefined; +export function stampRowPath(row: RowComponent): void { + const id = rowPathId(row); + if (id !== undefined) { + row.getElement()?.setAttribute(ROW_INDEX_ATTRIBUTE, String(id)); + } } /** * The calls a row stands for. A bottom-up caller row holds none of its own, so it * is derived from its root bucket and the chain that reaches it. */ -function rowCallOccurrences(row: RowComponent): LogEvent[] { +function rowCallOccurrences(row: RowComponent, root: ApexLog | null): LogEvent[] { const data = rowCallData(row); if (data.instances?.length) { return data.instances; @@ -172,14 +122,26 @@ function rowCallOccurrences(row: RowComponent): LogEvent[] { if (data.key === undefined) { return data.originalData ? [data.originalData] : NO_CALLS; } - const cached = derivedCalls.get(data); - if (cached) { - return cached; + return deriveCalls(row, data, root); +} + +/** + * The root bucket's calls whose own chain runs through the row. + * + * The table is the log's that built the rows: a path id is minted per log, so + * another log's table would answer about a path of its own. + */ +function deriveCalls(row: RowComponent, data: CallRow, root: ApexLog | null): LogEvent[] { + const pathId = data._pathId; + if (pathId === undefined || !root) { + return NO_CALLS; + } + const paths = logStoreFor(root).keyPathIds(); + const instances = rootBucketOf(row, data)?.instances; + if (!instances?.length) { + return NO_CALLS; } - const chain = rowCallChain(row, data); - const derived = chain ? occurrencesThrough(chain.root.instances ?? [], chain.keys) : NO_CALLS; - derivedCalls.set(data, derived); - return derived; + return instances.filter((event) => paths.chainReaches(event, pathId)); } /** @@ -206,23 +168,34 @@ function pathIdsForEvents( return [...found]; } -/** The calls a row stands for, as the event indexes the mark works in. */ -export function rowOccurrences(row: RowComponent): number[] { +/** The calls a row stands for, as the event indexes the mark works in. + * + * @param root - the log the row was built from, which its path id belongs to */ +export function rowOccurrences(row: RowComponent, root: ApexLog | null): number[] { const data = rowCallData(row); const cached = derivedIndexes.get(data); if (cached) { return cached; } - const indexes = rowCallOccurrences(row).map((event) => event.eventIndex); - derivedIndexes.set(data, indexes); + const indexes = rowCallOccurrences(row, root).map((event) => event.eventIndex); + if (indexes.length) { + // Not kept where nothing derived: the calls are read through the log on + // screen, so an answer of none can be that log not being set yet. + derivedIndexes.set(data, indexes); + } return indexes; } /** * What a selected row tells the inspector: a merged row names every call it * counts, a Time Order row the one call it is, and no row nothing. + * + * @param root - the log the row was built from, which its path id belongs to */ -export function rowDetailSelection(row: RowComponent | undefined): DetailSelection | null { +export function rowDetailSelection( + row: RowComponent | undefined, + root: ApexLog | null, +): DetailSelection | null { if (!row) { return null; } @@ -234,13 +207,14 @@ export function rowDetailSelection(row: RowComponent | undefined): DetailSelecti if (data.key === undefined) { return { kind: 'event', eventIndex: event.eventIndex }; } - // A bucket stands for its calls even where none derive: `originalData` is the - // caller frame, which is the mis-scoping this scoping exists to avoid. - const chain = isDerived(data) ? rowCallChain(row, data) : null; + // The row itself is what reached the calls, at whatever depth it sits: a + // deeper row narrows the same calls to the ones its own chain conducted, so + // naming a fixed frame would read the same at every depth. A root bucket holds + // its own calls, so nothing reached them but it. return { kind: 'aggregate', - instances: rowOccurrences(row), - calledBy: chain?.caller.text, + instances: rowOccurrences(row, root), + calledBy: data.instances?.length ? undefined : data.text, }; } @@ -310,7 +284,7 @@ export class LocatedRowIds { * to mark, so they are left alone. * * The table must stamp its rows: {@link rowIndexStamper} where a row is one - * frame, {@link rowPathStamper} where rows merge occurrences. + * frame, {@link stampRowPath} where rows merge occurrences. */ export class LocatedRowMarker { private elements: HTMLElement[] = []; diff --git a/log-viewer/src/components/scopedCallTree.ts b/log-viewer/src/components/scopedCallTree.ts index 15fd07842..30823a2d8 100644 --- a/log-viewer/src/components/scopedCallTree.ts +++ b/log-viewer/src/components/scopedCallTree.ts @@ -45,6 +45,8 @@ export interface ScopedRow { * calls its chain conducted, so it derives them from the top-level row rather * than holding a copy of the list. */ _seed?: OccurrenceSeed; + /** The frames the row itself stands for, once derived. */ + _frameIndexes?: number[]; _children: ScopedRow[] | null; } @@ -106,6 +108,46 @@ export function locatableEventIndexes(row: Partial | undefined): numb return single === null ? [] : [single]; } +/** + * The frames a scoped row stands for, which is what a highlight elsewhere points + * at: the flame chart dims to them, and the call tree selects one. + * + * A bottom-up caller row is one of the frames above a call, so it stands for the + * callers at its own depth rather than the calls they conducted, and stepping + * down the callers walks the highlight up the stack. + * + * {@link locatableEventIndexes} stays the calls the row counts, which is what its + * totals describe. + */ +export function frameEventIndexes(row: Partial | undefined): number[] { + if (row?._frameIndexes) { + return row._frameIndexes; + } + const conducted = locatableEventIndexes(row); + const seed = row?._seed; + const store = currentLogStore(); + if (!seed || !store) { + return conducted; + } + // `_seed` is only ever set alongside `_pathId`. + const levels = seed.paths.depthOf(row._pathId!) - 1; + if (levels <= 0) { + return conducted; + } + // Thousands of calls sit under a handful of callers. + const own = new Set(); + for (const index of conducted) { + let frame = store.eventByIndex(index); + for (let up = levels; up > 0 && frame; up--) { + frame = frame.parent; + } + if (frame) { + own.add(frame.eventIndex); + } + } + return (row._frameIndexes = [...own]); +} + /** * The rows of one view keyed by the bucket path each stands for, so a frame * named elsewhere can be found behind the synthetic id of a row that merges diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index 8c7eb8665..92f4edfe6 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -86,12 +86,15 @@ interface EventMap { // `detail:select` is strictly inbound to it; separate events stop an echo loop. 'inspector:reveal': { source: DetailSource; eventIndex: number }; - // A row in the inspector points at events โ€” mark them in the tab the inspector - // is showing, so the user can see where they sit without the view moving: - // no scroll, no pan, and no selection beyond `inspector:reveal`'s. A grouped - // row names every occurrence it merges, and an empty list drops the mark. + // A row in the inspector points at events: mark them in the tab the inspector + // is showing. The list is the frames the row stands for, so a bottom-up caller + // row names the callers at its own depth rather than the calls they conducted, + // and an empty list drops the mark. // `sticky` is true when the row was picked, so the mark holds while the pointer - // is elsewhere, and false for the pointer itself. + // is elsewhere, and false for the pointer itself. A hover moves nothing at all. + // A pick also reveals its first frame in the views that have a row for one, so + // the Call Tree and Analysis grids scroll and select, while the Database grids + // and the flame chart only mark. 'inspector:locate': { source: DetailSource; eventIndexes: readonly number[]; diff --git a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts index 7f19bc6d0..ba3127779 100644 --- a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts +++ b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts @@ -18,9 +18,14 @@ describe('KeyPathIds', () => { ids = new KeyPathIds(32); }); - /** Interns a whole path, named outermost key first as a row reads. */ + /** Interns a whole path, named outermost key first as a row reads, the way a + * tree build composes one. */ function pathFor(table: KeyPathIds, ...keys: string[]): number { - return table.pathOf([...keys].reverse()); + let id = ROOT_PATH_ID; + for (const key of keys) { + id = table.step(id, table.keyId(key)); + } + return id; } it('gives one id to the same path, however often it is asked for', () => { @@ -56,6 +61,11 @@ describe('KeyPathIds', () => { expect(ids.reaches(inner, pathFor(ids, 'Z'))).toBe(false); }); + it('reads back how many keys a path stands for, and none for the empty one', () => { + expect(ids.depthOf(pathFor(ids, 'A', 'B', 'C'))).toBe(3); + expect(ids.depthOf(ROOT_PATH_ID)).toBe(0); + }); + it('mints on its own, so an id from one log means nothing to another', () => { const other = new KeyPathIds(32); @@ -74,11 +84,21 @@ describe('KeyPathIds', () => { expect(ids.keyIdOf(second)).toBe(ids.keyIdOf(first)); }); - it('keys a frame no slot of its own covers', () => { - // Built rather than parsed, so it sits outside the log's own index. - const loose = ev(9999, 'made up', null); + it('keys a frame no slot of its own covers, without one standing in for another', () => { + // Built rather than parsed, so neither carries an index at all. Keeping one + // under an index-less write lands it on the memo as an ordinary property, + // which the next such frame then reads back as its own. + const loose = { type: 'METHOD_ENTRY', namespace: '', text: 'made up' } as unknown as LogEvent; + const other = { + type: 'METHOD_ENTRY', + namespace: '', + text: 'and another', + } as unknown as LogEvent; expect(ids.keyIdOf(loose)).toBe(ids.keyIdOf(loose)); + expect(ids.keyIdOf(other)).not.toBe(ids.keyIdOf(loose)); + // And one whose index is past the end of the log's own array. + expect(ids.keyIdOf(ev(9999, 'past the end', null))).not.toBe(ids.keyIdOf(loose)); }); }); diff --git a/log-viewer/src/core/log/keyPathIds.ts b/log-viewer/src/core/log/keyPathIds.ts index 053bbb6e4..290a35014 100644 --- a/log-viewer/src/core/log/keyPathIds.ts +++ b/log-viewer/src/core/log/keyPathIds.ts @@ -9,9 +9,6 @@ import { getEventKey, getStackKey } from './eventKeys.js'; /** The path every chain starts from, which no row stands for. */ export const ROOT_PATH_ID = 0; -/** One frame's chain, reused: the walk never yields, so one is enough. */ -const chain: number[] = []; - /** * The interned keys and bucket paths of one log. * @@ -22,9 +19,9 @@ const chain: number[] = []; * than by the calls, and makes matching an integer test. * * One invariant holds it together: a row's id is the interned chain of the - * frames the row holds. {@link pathIdsOf} and {@link pathOf} are the two ways to - * reach one, so the two chain directions stay separate spaces here rather than - * in every caller. + * frames the row holds. {@link pathIdsOf} names the rows a frame belongs to and + * {@link chainReaches} asks whether a frame's chain runs through one, so the two + * chain directions stay separate spaces here rather than in every caller. * * One table per log, held by `LogStore`: an id means nothing to another log. */ @@ -42,6 +39,9 @@ export class KeyPathIds { private children: Array | undefined> = [new Map()]; private parents: number[] = [ROOT_PATH_ID]; private keyOf: number[] = [-1]; + /** One frame's chain, reused: {@link pathIdsOf} never yields, so one is enough. + * Per table rather than per module, so two logs cannot share the buffer. */ + private chain: number[] = []; constructor(eventCount: number) { this.keyOfEvent = new Int32Array(eventCount); @@ -53,14 +53,20 @@ export class KeyPathIds { */ public keyIdOf(event: LogEvent): number { const at = event.eventIndex; - const cached = this.keyOfEvent[at] ?? 0; - if (cached) { - return cached - 1; + // A frame the log's own index has no slot for is keyed but not kept. A frame + // built rather than parsed has no index at all, and that writes an ordinary + // property on the typed array, which every other such frame reads as its own. + const slotted = at >= 0 && at < this.keyOfEvent.length; + if (slotted) { + const cached = this.keyOfEvent[at]!; + if (cached) { + return cached - 1; + } } const id = this.keyId(getEventKey(event)); - // Ignored where the log's own index has no such slot, which only costs the - // key being built again. - this.keyOfEvent[at] = id + 1; + if (slotted) { + this.keyOfEvent[at] = id + 1; + } return id; } @@ -68,8 +74,7 @@ export class KeyPathIds { * The event's interned stack key, which tells a recursive call from a fresh * one. Its own space: a stack key is never a step in a path. */ - public stackIdOf(event: LogEvent): number { - const keyId = this.keyIdOf(event); + public stackIdOf(event: LogEvent, keyId = this.keyIdOf(event)): number { let id = this.stackOfKey[keyId]; if (id === undefined) { const key = getStackKey(event); @@ -104,6 +109,7 @@ export class KeyPathIds { } return; } + const chain = this.chain; chain.length = 0; for (let node: LogEvent | null = event; node?.parent; node = node.parent) { chain.push(this.keyIdOf(node)); @@ -116,17 +122,27 @@ export class KeyPathIds { } /** - * The id for a whole chain, outermost key first: what names a top-down row, and - * what a row built from key strings is stamped with. + * True where the frame's own chain of callers runs through `pathId`: what tells + * the calls a bottom-up caller row holds from the rest of its bucket's. * - * @param keys - the chain innermost first, as a row's own parent walk gives it + * Reads without minting, unlike {@link step}: a query that grew the table would + * leave a node behind for every frame it was asked about. Ids only rise as a + * chain deepens, so the walk stops once it passes the depth asked about. */ - public pathOf(keys: readonly string[]): number { + public chainReaches(event: LogEvent, pathId: number): boolean { let id = ROOT_PATH_ID; - for (let depth = keys.length - 1; depth >= 0; depth--) { - id = this.step(id, this.keyId(keys[depth]!)); + for (let node: LogEvent | null = event; node?.parent; node = node.parent) { + const next = this.children[id]?.get(this.keyIdOf(node)); + if (next === undefined || next > pathId) { + // Never minted, so no row stands for it; or past the row's own depth. + return false; + } + id = next; + if (id === pathId) { + return true; + } } - return id; + return false; } /** @@ -163,6 +179,11 @@ export class KeyPathIds { return id; } + /** The key an id was minted for, for a row that shows the key it merges. */ + public keyText(keyId: number): string { + return this.keys[keyId]!; + } + /** * True where `path` runs through `pathId`: the same path, or one that extends * it. An id is minted per parent and key, so running through a path is being @@ -179,6 +200,16 @@ export class KeyPathIds { return id === pathId; } + /** How many keys `pathId` stands for: the depth of the row it names, 0 at the + * root. */ + public depthOf(pathId: number): number { + let depth = 0; + for (let id = pathId; id > ROOT_PATH_ID; id = this.parents[id]!) { + depth++; + } + return depth; + } + /** * The keys `pathId` stands for, outermost first. For reading a stamped id back * while debugging: nothing on a hot path calls it. diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 597d12460..f188a63a2 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -23,6 +23,7 @@ import { } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; +import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; import { getSettings, updateSetting } from '../../settings/Settings.js'; import { createBottomUpTable } from '../../call-tree/components/BottomUpTable.js'; @@ -36,6 +37,7 @@ import { toggleField, } from '../../../tabulator/ColumnViews.js'; import type { BottomUpRow } from '../../call-tree/utils/Aggregation.js'; +import { findRootBucket } from '../../call-tree/utils/bucketRows.js'; import { categoryColoringStyles, groupedRowFormatter, @@ -161,6 +163,9 @@ export class AnalysisView extends LitElement { private _locatedRow = new LocatedRowMarker(); private _locateIds = new LocatedRowIds(); private _emphasis = new InspectorEmphasis(); + /** Counts the locate reports, so a mark held back by a reveal is dropped once + * a later report has replaced it. */ + private _locateReport = 0; constructor() { super(); @@ -175,8 +180,24 @@ export class AnalysisView extends LitElement { // Mark the buckets the inspector points at. A row is a method bucket rather // than one event, so a frame is translated into the paths of the rows it heads. this._inspectorLocateUnsubscribe = eventBus.on('inspector:locate', (detail) => { - if (detail.source === 'analysis') { - this._markLocated(this._emphasis.report(detail.eventIndexes, detail.sticky)); + if (detail.source !== 'analysis') { + return; + } + const ids = this._emphasis.report(detail.eventIndexes, detail.sticky); + if (detail.sticky && detail.eventIndexes.length) { + // A picked row moves the grid to the bucket it names, as one picked in + // the Call Tree does. The reveal re-renders the rows the mark lands on, + // so the mark goes on after, and only while it is still the last report: + // dropping the pick clears the mark while the reveal is still in flight. + const reported = ++this._locateReport; + void this._revealEventIndex(detail.eventIndexes[0]!).then(() => { + if (reported === this._locateReport) { + this._markLocated(ids); + } + }); + } else { + this._locateReport++; + this._markLocated(ids); } }); document.addEventListener('lv-find', this._findEvt); @@ -233,24 +254,25 @@ export class AnalysisView extends LitElement { */ private async _revealEventIndex(eventIndex: number): Promise { const table = this.analysisTable; - // `instances` is populated on root buckets only, which is what the grid lists. - const match = table - ?.getRows() - .find((row) => - (row.getData() as BottomUpRow).instances?.some((event) => event.eventIndex === eventIndex), - ); - if (!table || !match) { + const root = this.timelineRoot; + if (!table || !root) { + return; + } + const event = eventByEventIndex(root, eventIndex); + if (!event) { + return; + } + // The grid is bottom-up, so the frame heads a top-level bucket its own key + // finds, without reading what any bucket holds. + const match = findRootBucket(table.getRows(), event); + if (!match) { return; } // Show Details keeps only rows with a duration, so the buckets for debug // lines, thrown exceptions and query plans are filtered out โ€” exactly the // events a finding points at. Turn the filter off rather than reveal nothing. - const data = match.getData(); - if ( - !this.filterState.showDetails && - !table.getRows('active').some((row) => row.getData() === data) - ) { + if (!this.filterState.showDetails && !this._showDetailsFilter(match.getData() as BottomUpRow)) { this._handleShowDetailsChange(); await this.updateComplete; } @@ -638,7 +660,7 @@ export class AnalysisView extends LitElement { this._clearSearchHighlights(); } }, - rowFormatter: groupedRowFormatter(rootMethod), + rowFormatter: groupedRowFormatter, }, { placeholder: 'No Analysis Available', @@ -671,7 +693,7 @@ export class AnalysisView extends LitElement { } eventBus.emit('detail:select', { source: 'analysis', - selection: rowDetailSelection(rows[0]), + selection: rowDetailSelection(rows[0], this.timelineRoot), // The grid ranks methods by self time and expands to their callers, so // the inspector opens on the forward view instead. view: 'callers', @@ -683,7 +705,7 @@ export class AnalysisView extends LitElement { this.analysisTable.on('rowMouseEnter', (_e, row) => { eventBus.emit('detail:locate', { source: 'analysis', - eventIndexes: rowOccurrences(row), + eventIndexes: rowOccurrences(row, this.timelineRoot), }); }); this.analysisTable.on('rowMouseLeave', () => { diff --git a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts index b20dad3de..85020ac52 100644 --- a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts @@ -11,7 +11,14 @@ import type { RowComponent, Tabulator } from 'tabulator-tables'; // jest; this suite drives only the selection the view reports to the inspector. jest.mock('../../../call-tree/components/BottomUpTable.js', () => ({ createBottomUpTable: () => ({ - table: { on: (name: string, handler: unknown) => handlers.set(name, handler) }, + table: { + on: (name: string, handler: unknown) => handlers.set(name, handler), + getRows: (...args: unknown[]) => { + stub.getRowsArgs.push(args); + return stub.rows; + }, + goToRow: (row: RowComponent) => stub.revealed.push(row), + }, // Left pending: the columns are applied on build, and none are set up here. tableBuilt: new Promise(() => {}), }), @@ -27,15 +34,21 @@ import { type DetailSource, } from '../../../../core/events/EventBus.js'; import { toBottomUpTree, type BottomUpRow } from '../../../call-tree/utils/Aggregation.js'; +import { logStoreFor } from '../../../../core/log/LogStore.js'; import { AnalysisView } from '../AnalysisView.js'; +import { LocatedRowMarker } from '../../../../components/locatedRow.js'; const handlers = new Map(); +/** The stub table's state, so a reveal's reads can be counted. */ +let stub: { rows: RowComponent[]; getRowsArgs: unknown[][]; revealed: RowComponent[] }; -let nextEventIndex = 0; +/** The log's own index, which a reveal resolves its frame through, and which the + * fixture's event indexes count off. */ +let byEventIndex: LogEvent[] = []; function frame(text: string, self: number, total: number, parent: LogEvent | null): LogEvent { const event = { - eventIndex: nextEventIndex++, + eventIndex: byEventIndex.length, type: 'METHOD_ENTRY', namespace: 'default', text, @@ -54,18 +67,27 @@ function frame(text: string, self: number, total: number, parent: LogEvent | nul heapPeak: 0, } as unknown as LogEvent; parent?.children.push(event); + byEventIndex.push(event); return event; } -/** A -> B -> A on one branch, A -> C -> A on the other. */ -function recursiveRoots(): LogEvent[] { - const outer1 = frame('A', 10, 100, null); +/** + * A -> B -> A on one branch, A -> C -> A on the other, under a log root as the + * parser leaves it: a bottom-up chain runs out to the root and stops there, so a + * top-level frame with no parent above it would name no row for its own callees. + * + * A derived row's calls come from the log's own key table, so the view must read + * the log the rows were built from. + */ +function recursiveLog(): ApexLog { + const root = frame('LOG_ROOT', 0, 150, null); + const outer1 = frame('A', 10, 100, root); const b1 = frame('B', 20, 90, outer1); frame('A', 70, 70, b1); - const outer2 = frame('A', 5, 50, null); + const outer2 = frame('A', 5, 50, root); const c1 = frame('C', 15, 45, outer2); frame('A', 30, 30, c1); - return [outer1, outer2]; + return Object.assign(root, { eventsById: byEventIndex }) as unknown as ApexLog; } function rowComponent(data: BottomUpRow, treeParent?: RowComponent): RowComponent { @@ -85,19 +107,25 @@ function findRow(rows: BottomUpRow[], text: string): BottomUpRow { describe('analysis-view selection', () => { let view: AnalysisView; + let log: ApexLog; let roots: BottomUpRow[]; let seen: Array<{ source: DetailSource; selection: DetailSelection | null }>; let off: () => void; beforeEach(() => { - nextEventIndex = 0; + byEventIndex = []; handlers.clear(); - roots = toBottomUpTree(recursiveRoots()); + stub = { rows: [], getRowsArgs: [], revealed: [] }; + log = recursiveLog(); + roots = toBottomUpTree(log.children, logStoreFor(log).keyPathIds()); view = new AnalysisView(); + // The app hands the log down as a property, and a row's calls are read + // through the table that built it. + view.timelineRoot = log; // The table mounts in a wrapper the view finds in its render root; it has // none until it is updated, so stand one in. view.tableContainer = document.createElement('div'); - void view._renderAnalysis({} as ApexLog); + void view._renderAnalysis(log); seen = []; off = eventBus.on('detail:select', (detail) => seen.push(detail)); }); @@ -132,7 +160,7 @@ describe('analysis-view selection', () => { ]); }); - it('scopes a caller row to the calls it holds, and names the frame that made them', () => { + it('scopes a caller row to the calls it holds, and names the row they were reached through', () => { const rootRow = findRow(roots, 'A'); const throughB = findRow(rootRow._children ?? [], 'B'); const throughBA = findRow(throughB._children ?? [], 'A'); @@ -144,7 +172,8 @@ describe('analysis-view selection', () => { const derived = rootRow.instances.filter((event) => event.parent?.text === 'B'); expect(derived).toHaveLength(1); expect(seen.map((detail) => detail.selection)).toEqual([ - // The calls are A's, made by B, whichever row named them. + // Both rows hold the same one call, so the row is what tells them apart: + // reached through B, then through the A above B. { kind: 'aggregate', instances: derived.map((event) => event.eventIndex), @@ -153,11 +182,60 @@ describe('analysis-view selection', () => { { kind: 'aggregate', instances: derived.map((event) => event.eventIndex), - calledBy: 'B', + calledBy: 'A', }, ]); }); + it('reveals a bucket by its key, reading no occurrence', async () => { + // The grid lists root buckets; a dataTree hands only those back. + stub.rows = roots.map((data) => rowComponent(data)); + + // Frame 2 is the log's own `B` call, which the `B` bucket heads. + eventBus.emit('inspector:reveal', { source: 'analysis', eventIndex: 2 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(stub.revealed.map((row) => row.getData())).toEqual([findRow(roots, 'B')]); + // One read of the top-level rows, and never `getRows('active')`: listing the + // active rows, or reading a bucket's occurrences, walked the whole log. + expect(stub.getRowsArgs).toEqual([[]]); + }); + + it('moves to the bucket a picked inspector row names', async () => { + stub.rows = roots.map((data) => rowComponent(data)); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [2], sticky: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // A hover only marks; a pick moves the grid, as it does in every other view. + expect(stub.revealed.map((row) => row.getData())).toEqual([findRow(roots, 'B')]); + }); + + it('only marks for a row under the pointer', async () => { + stub.rows = roots.map((data) => rowComponent(data)); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [2], sticky: false }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(stub.revealed).toEqual([]); + }); + + it('drops a mark a later report has replaced', async () => { + stub.rows = roots.map((data) => rowComponent(data)); + const marks: Array = []; + const spy = jest.spyOn(LocatedRowMarker.prototype, 'mark').mockImplementation((_host, ids) => { + marks.push(ids); + }); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [2], sticky: true }); + // The pick is dropped while the reveal it started is still in flight. + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [], sticky: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(marks.at(-1)).toEqual([]); + spy.mockRestore(); + }); + it('clears the inspector when the selection goes', () => { (handlers.get('rowSelectionChanged') as (data: unknown, rows: RowComponent[]) => void)( null, diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index d6a4c630e..8ea3b57d6 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -4,6 +4,7 @@ import type { ApexLog } from 'apex-log-parser'; import { Tabulator } from 'tabulator-tables'; +import { logStoreFor } from '../../../core/log/LogStore.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { formatDuration } from '../../../core/utility/Util.js'; import { TIME_WIDTH } from '../../../tabulator/ColumnWidths.js'; @@ -41,7 +42,11 @@ export function createAggregatedTable( const selfTimeBottomCalc = makeSumSelfTimeAllVisible(() => tableRef.current); const heapFooters = createSelfSumHeapFooters(() => tableRef.current); - const tableData = toAggregatedCallTree(rootMethod.children, rootMethod.governorLimits); + const tableData = toAggregatedCallTree( + rootMethod.children, + logStoreFor(rootMethod).keyPathIds(), + rootMethod.governorLimits, + ); const table = new Tabulator(container, { data: tableData, diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index 09b1ae5eb..1d432c776 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -4,6 +4,7 @@ import type { ApexLog, LogEvent } from 'apex-log-parser'; import { Tabulator, type Options } from 'tabulator-tables'; +import { logStoreFor } from '../../../core/log/LogStore.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { formatDuration } from '../../../core/utility/Util.js'; import { TIME_WIDTH } from '../../../tabulator/ColumnWidths.js'; @@ -115,7 +116,11 @@ export function createBottomUpTable( } : {}; - const tableData = toBottomUpTree(rootMethod.children, rootMethod.governorLimits); + const tableData = toBottomUpTree( + rootMethod.children, + logStoreFor(rootMethod).keyPathIds(), + rootMethod.governorLimits, + ); const tabulatorOptions = { data: tableData, diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 0a167b29f..1a1a722ae 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -1130,7 +1130,7 @@ export class CalltreeView extends LitElement { this._clearSearchHighlights(); } }, - rowFormatter: groupedRowFormatter(rootMethod), + rowFormatter: groupedRowFormatter, }); this.aggregatedTreeTable = table; await tableBuilt; @@ -1156,7 +1156,7 @@ export class CalltreeView extends LitElement { this._clearSearchHighlights(); } }, - rowFormatter: groupedRowFormatter(rootMethod), + rowFormatter: groupedRowFormatter, }, { selectableRows: 'highlight', @@ -1181,7 +1181,7 @@ export class CalltreeView extends LitElement { if (this._echoGuard.suppressed) { return; } - const selection = rowDetailSelection(rows[0]); + const selection = rowDetailSelection(rows[0], this.rootMethod); if (!selection) { // The selection went with it, and so does a mark a picked inspector row // left here โ€” it was never a selection of this table. @@ -1204,7 +1204,7 @@ export class CalltreeView extends LitElement { table.on('rowMouseEnter', (_e, row) => { eventBus.emit('detail:locate', { source, - eventIndexes: rowOccurrences(row), + eventIndexes: rowOccurrences(row, this.rootMethod), }); }); table.on('rowMouseLeave', () => { @@ -1216,6 +1216,9 @@ export class CalltreeView extends LitElement { // in the DOM), with a two-frame fallback in case the expand triggers no // redraw. A single rAF can race the virtual renderer and leave getTreeChildren // empty mid-descent. + // A pending-render flag is no use here: Tabulator dispatches `renderStarted` + // and `renderComplete` in one synchronous call, so the flag always reads false + // by the time this is awaited. private _waitForTableRender(): Promise { const table = this._getActiveTable(); if (!table) { diff --git a/log-viewer/src/features/call-tree/utils/Aggregation.ts b/log-viewer/src/features/call-tree/utils/Aggregation.ts index edc613d2c..b71a0a250 100644 --- a/log-viewer/src/features/call-tree/utils/Aggregation.ts +++ b/log-viewer/src/features/call-tree/utils/Aggregation.ts @@ -3,9 +3,8 @@ */ import type { GovernorLimits, LogEvent, SelfTotal } from 'apex-log-parser'; -import { getEventKey, getStackKey } from '../../../core/log/eventKeys.js'; +import { ROOT_PATH_ID, type KeyPathIds } from '../../../core/log/keyPathIds.js'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; -import { Multiset } from '../../../core/utility/Multiset.js'; import { computeHasDetailsDeep } from './DetailsFilter.js'; import { setGovernorCost } from './GovernorCost.js'; @@ -18,6 +17,8 @@ export interface AggregatedRow { id: number; /** Unique grouping key for this function signature */ key: string; + /** The interned bucket path that names this row, which the mark matches on. */ + _pathId: number; /** Display name */ text: string; /** Package namespace */ @@ -80,6 +81,8 @@ export interface BottomUpRow { /** Internal interned int id matching {@link key}; used for fast child-bucket * lookup during the trie build. Not consumed externally. */ _keyId: number; + /** The interned bucket path that names this row, which the mark matches on. */ + _pathId: number; /** Display name */ text: string; /** Package namespace */ @@ -136,11 +139,12 @@ export interface BottomUpRow { /** * Creates an aggregated call tree where all calls to the same function signature - * are merged together, with aggregated metrics. - * Uses Multiset call-stack tracking to prevent double-counting of recursive calls. + * are merged together, with aggregated metrics. A recursive call adds no total + * time of its own: the frame open above it already holds the time they share. */ export function toAggregatedCallTree( rootChildren: LogEvent[], + paths: KeyPathIds, governorLimits?: GovernorLimits, ): AggregatedRow[] { if (rootChildren.length === 0) { @@ -152,68 +156,84 @@ export function toAggregatedCallTree( let next = 0; const idFor = (): number => ++next; - // Group root-level events by signature with call stack tracking - const rootMap = new Map(); - const keyStack = new Multiset(); + // Group root-level events by signature with call stack tracking. Keyed by the + // log's interned ids: a bucket key is hashed once for the log rather than once + // per event, and the ids are the ones a mark matches rows on. + const rootMap = new Map(); for (const event of rootChildren) { // Process every event so callCount/DML/SOQL/exception counts roll up even // when the event has no timing contribution. - const key = getEventKey(event); - let row = rootMap.get(key); + const keyId = paths.keyIdOf(event); + let row = rootMap.get(keyId); if (!row) { - row = createEmptyAggregatedRow(key, event, idFor); - rootMap.set(key, row); + row = createEmptyAggregatedRow( + paths.keyText(keyId), + paths.step(ROOT_PATH_ID, keyId), + event, + idFor, + ); + rootMap.set(keyId, row); } - const stackKey = getStackKey(event); - addEventToAggregatedRowWithStack(row, event, stackKey, keyStack); + // Nothing is open above a root child, so no call of one is recursive here. + addEventToAggregatedRow(row, event, paths.stackIdOf(event, keyId), NO_STACK); } // Recursively aggregate children for each row for (const row of rootMap.values()) { - const firstInstance = row.instances[0]; - const stackKey = firstInstance ? getStackKey(firstInstance) : row.key; - row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor, governorLimits); - calculateAverages(row); - if (governorLimits) { - setGovernorCost(row, governorLimits); - } - row._hasDetailsDeep = computeHasDetailsDeep(row, row.totalTime, row.originalData.type); + row._children = aggregateChildrenRecursive(row, paths, idFor, governorLimits); + finaliseAggregatedRow(row, governorLimits); } // Sort by total time descending return Array.from(rootMap.values()).sort((a, b) => b.totalTime - a.totalTime); } +/** No frame open above the level, so nothing in it can be a recursive call. */ +const NO_STACK = -1; + +/** Averages, governor cost and the Show Details roll-up, once `_children` is set. */ +function finaliseAggregatedRow(row: AggregatedRow, governorLimits?: GovernorLimits): void { + calculateAverages(row); + if (governorLimits) { + setGovernorCost(row, governorLimits); + } + row._hasDetailsDeep = computeHasDetailsDeep(row, row.totalTime, row.originalData.type); +} + /** * Recursively aggregates children of all instances. * Tracks the parent key to detect recursive calls within the same aggregation context. */ function aggregateChildrenRecursive( - instances: LogEvent[], - parentStackKey: string, + parent: AggregatedRow, + paths: KeyPathIds, idFor: () => number, governorLimits?: GovernorLimits, ): AggregatedRow[] | null { - const childMap = new Map(); - // Create a new stack for each aggregation level, starting with the parent stack key - const keyStack = new Multiset(); - keyStack.add(parentStackKey); + const childMap = new Map(); + // The parent's frame is open over every call in it, so a call of that same + // frame inside is recursive. `originalData` is the bucket's own first call. + const parentStackId = paths.stackIdOf(parent.originalData); - for (const instance of instances) { + for (const instance of parent.instances) { for (const child of instance.children) { - const key = getEventKey(child); - let row = childMap.get(key); + const keyId = paths.keyIdOf(child); + let row = childMap.get(keyId); if (!row) { - row = createEmptyAggregatedRow(key, child, idFor); - childMap.set(key, row); + row = createEmptyAggregatedRow( + paths.keyText(keyId), + paths.step(parent._pathId, keyId), + child, + idFor, + ); + childMap.set(keyId, row); } - const stackKey = getStackKey(child); - addEventToAggregatedRowWithStack(row, child, stackKey, keyStack); + addEventToAggregatedRow(row, child, paths.stackIdOf(child, keyId), parentStackId); } } @@ -223,37 +243,28 @@ function aggregateChildrenRecursive( // Recursively aggregate children using stack key for recursion tracking for (const row of childMap.values()) { - const firstInstance = row.instances[0]; - const stackKey = firstInstance ? getStackKey(firstInstance) : row.key; - row._children = aggregateChildrenRecursive(row.instances, stackKey, idFor, governorLimits); - calculateAverages(row); - if (governorLimits) { - setGovernorCost(row, governorLimits); - } - row._hasDetailsDeep = computeHasDetailsDeep(row, row.totalTime, row.originalData.type); + row._children = aggregateChildrenRecursive(row, paths, idFor, governorLimits); + finaliseAggregatedRow(row, governorLimits); } // Sort by total time descending return Array.from(childMap.values()).sort((a, b) => b.totalTime - a.totalTime); } -/** - * Adds an event to an aggregated row, using call stack tracking to prevent - * double-counting of totalTime for recursive calls. - */ -function addEventToAggregatedRowWithStack( +/** Adds an event to an aggregated row, leaving `totalTime` alone where the call + * is recursive: the frame open above this level already holds that time. */ +function addEventToAggregatedRow( row: AggregatedRow, event: LogEvent, - stackKey: string, - keyStack: Multiset, + stackId: number, + openStackId: number, ): void { row.callCount++; row.totalSelfTime += event.duration.self; // Always add self time - // Only add totalTime if this method is not already on the stack (avoids recursive double-counting) - // Uses stackKey (text+namespace, no type) so CODE_UNIT_STARTED and METHOD_ENTRY for the same - // method are recognised as the same function in the call stack. - if (!keyStack.has(stackKey)) { + // The stack id reads through the entry type, so CODE_UNIT_STARTED and + // METHOD_ENTRY for the same method are one frame on the call stack. + if (stackId !== openStackId) { row.totalTime += event.duration.total; } @@ -316,7 +327,7 @@ function addEventToAggregatedRowWithStack( */ type FrameContext = { frame: LogEvent; - stackKey: string; + stackId: number; prior: FrameContext | undefined; // Attribution accumulator โ€” initialised to the frame's own totals and // decremented as same-name descendants are entered. Final when the frame @@ -343,7 +354,8 @@ type DfsEntry = { * Single iterative DFS that fuses attribution computation with trie insertion. * * Pre-order on entering N: - * - Intern N's event key to an int id; push onto the chain stack. + * - Take N's interned bucket key id from the log's table; push it onto the + * chain stack. * - Look up `prior` same-name ancestor; build N's `FrameContext` initialised * to N's own totals; decrement `prior.totalTime`/โ€ฆ by N's totals (the * deepest-active-frame attribution rule). @@ -353,23 +365,22 @@ type DfsEntry = { * chain stack from top (= N) down to depth 0. The chain is the live DFS * ancestor path, so no `frame.parent` walk and no per-step Map lookup is * needed. Bucket-key comparisons are int equality on `_keyId`. - * - Restore `activeByName[stackKey]` from `ctx.prior`. + * - Restore `activeByStack[stackId]` from `ctx.prior`. * * Zero-delta guards on the DML/SOQL/row/thrown accumulators avoid the no-op * `bucket.x += 0` writes that dominate logs without heavy DB work. */ export function toBottomUpTree( rootChildren: LogEvent[], + paths: KeyPathIds, governorLimits?: GovernorLimits, ): BottomUpRow[] { if (rootChildren.length === 0) { return []; } - const intern = new Map(); - const idToKey: string[] = []; const rootBuckets = new Map(); - const activeByName = new Map(); + const activeByStack = new Map(); const dfs: DfsEntry[] = []; const chainIds: number[] = []; @@ -379,20 +390,14 @@ export function toBottomUpTree( const idFor = (): number => ++next; const enter = (node: LogEvent): void => { - const eventKey = getEventKey(node); - let id = intern.get(eventKey); - if (id === undefined) { - id = intern.size; - intern.set(eventKey, id); - idToKey.push(eventKey); - } - chainIds.push(id); + const keyId = paths.keyIdOf(node); + chainIds.push(keyId); - const stackKey = getStackKey(node); - const prior = activeByName.get(stackKey); + const stackId = paths.stackIdOf(node, keyId); + const prior = activeByStack.get(stackId); const ctx: FrameContext = { frame: node, - stackKey, + stackId, prior, totalTime: node.duration.total, dmlTotal: node.dmlCount.total, @@ -418,7 +423,7 @@ export function toBottomUpTree( prior.heapTotal -= node.heapAllocated.total; prior.heapGrossTotal -= node.heapGross.total; } - activeByName.set(stackKey, ctx); + activeByStack.set(stackId, ctx); dfs.push({ node, childIdx: 0, ctx }); }; @@ -520,7 +525,8 @@ export function toBottomUpTree( const rootId = chainIds[top]!; let bucket = rootBuckets.get(rootId); if (!bucket) { - bucket = createEmptyBottomUpRow(idToKey[rootId]!, rootId, node, idFor); + const pathId = paths.step(ROOT_PATH_ID, rootId); + bucket = createEmptyBottomUpRow(paths.keyText(rootId), rootId, pathId, node, idFor); rootBuckets.set(rootId, bucket); } accumulate(bucket); @@ -535,7 +541,14 @@ export function toBottomUpTree( const existingChildren = parentBucket._children ?? []; let childBucket = existingChildren.find((c) => c._keyId === ancestorId); if (!childBucket) { - childBucket = createEmptyBottomUpRow(idToKey[ancestorId]!, ancestorId, ancestor, idFor); + const pathId = paths.step(parentBucket._pathId, ancestorId); + childBucket = createEmptyBottomUpRow( + paths.keyText(ancestorId), + ancestorId, + pathId, + ancestor, + idFor, + ); existingChildren.push(childBucket); parentBucket._children = existingChildren; } @@ -544,9 +557,9 @@ export function toBottomUpTree( } if (ctx.prior) { - activeByName.set(ctx.stackKey, ctx.prior); + activeByStack.set(ctx.stackId, ctx.prior); } else { - activeByName.delete(ctx.stackKey); + activeByStack.delete(ctx.stackId); } chainIds.pop(); dfs.pop(); @@ -613,12 +626,14 @@ function sortBuckets(rows: BottomUpRow[]): void { function createEmptyAggregatedRow( key: string, + pathId: number, event: LogEvent, idFor: () => number, ): AggregatedRow { return { id: idFor(), key, + _pathId: pathId, text: event.text, namespace: event.namespace, callerNamespace: getCallerNamespace(event), @@ -649,6 +664,7 @@ function createEmptyAggregatedRow( function createEmptyBottomUpRow( key: string, keyId: number, + pathId: number, event: LogEvent, idFor: () => number, ): BottomUpRow { @@ -656,6 +672,7 @@ function createEmptyBottomUpRow( id: idFor(), key, _keyId: keyId, + _pathId: pathId, text: event.text, namespace: event.namespace, callerNamespace: getCallerNamespace(event), diff --git a/log-viewer/src/features/call-tree/utils/CategoryColoring.ts b/log-viewer/src/features/call-tree/utils/CategoryColoring.ts index d4b3413d5..e47c735e3 100644 --- a/log-viewer/src/features/call-tree/utils/CategoryColoring.ts +++ b/log-viewer/src/features/call-tree/utils/CategoryColoring.ts @@ -1,12 +1,10 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import type { ApexLog } from 'apex-log-parser'; import { css } from 'lit'; import type { RowComponent } from 'tabulator-tables'; -import { rowPathStamper } from '../../../components/locatedRow.js'; -import { logStoreFor } from '../../../core/log/LogStore.js'; +import { stampRowPath } from '../../../components/locatedRow.js'; import { VSCodeExtensionMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { subscribeSettings, type LanaSettings } from '../../settings/Settings.js'; import { CATEGORY_THEME_KEY, DEFAULT_THEME_NAME } from '../../timeline/themes/Themes.js'; @@ -45,15 +43,11 @@ export const categoryRowFormatter = (row: RowComponent): void => { }; /** The `rowFormatter` for a view whose rows merge occurrences: the colour strip, - * plus the path id the inspector's mark finds the row by. The ids are the log's, - * so a row and the frames it holds reach the same one. */ -export function groupedRowFormatter(root: ApexLog): (row: RowComponent) => void { - const stamp = rowPathStamper(logStoreFor(root).keyPathIds()); - return (row) => { - categoryRowFormatter(row); - stamp(row); - }; -} + * plus the path id the inspector's mark finds the row by. */ +export const groupedRowFormatter = (row: RowComponent): void => { + categoryRowFormatter(row); + stampRowPath(row); +}; function applyCategoryTheme(host: HTMLElement, themeName: string): void { const theme = getTheme(themeName); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts index b0acd2487..4634b4652 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts @@ -5,8 +5,13 @@ import { beforeEach, describe, expect, it } from '@jest/globals'; import type { LogEvent } from 'apex-log-parser'; import { outermostEvents } from '../../../../core/utility/EventTree.js'; -import { toAggregatedCallTree, toBottomUpTree, type BottomUpRow } from '../Aggregation.js'; -import { occurrencesThrough } from '../bottomUpOccurrences.js'; +import { + toAggregatedCallTree, + toBottomUpTree, + type AggregatedRow, + type BottomUpRow, +} from '../Aggregation.js'; +import { KeyPathIds } from '../../../../core/log/keyPathIds.js'; type EventOptions = { text: string; @@ -78,6 +83,15 @@ function createEvent(options: EventOptions): LogEvent { return event; } +/** A table per build: the fixtures below reuse event indexes for different + * frames, so one frame's key must not be read back for another. */ +function bottomUpOf(children: LogEvent[]): BottomUpRow[] { + return toBottomUpTree(children, new KeyPathIds(2048)); +} +function aggregatedOf(children: LogEvent[]): AggregatedRow[] { + return toAggregatedCallTree(children, new KeyPathIds(2048)); +} + function findRowByText(rows: T[], text: string): T { const row = rows.find((candidate) => candidate.text === text); if (!row) { @@ -242,7 +256,7 @@ describe('toBottomUpTree', () => { thrown: 2, }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const childRow = findRowByText(rows, 'Child'); expect(childRow).toMatchObject({ totalSelfTime: 30, @@ -315,7 +329,7 @@ describe('toBottomUpTree', () => { }); createEvent({ text: 'WrappedLeaf', self: 40, total: 40, parent: zeroSelfWrapper }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); expect(rows.some((row) => row.text === 'ZeroSelfWrapper')).toBe(true); expect(rows.some((row) => row.text === 'WrappedLeaf')).toBe(true); @@ -377,7 +391,7 @@ describe('toBottomUpTree', () => { thrown: 3, }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const hotMethod = findRowByText(rows, 'HotMethod'); expect(hotMethod.callCount).toBe(2); @@ -404,7 +418,7 @@ describe('toBottomUpTree', () => { createEvent({ text: 'MyMethod', self: 10, total: 10, parent: root, type: 'CODE_UNIT_STARTED' }); createEvent({ text: 'MyMethod', self: 15, total: 15, parent: root, type: 'METHOD_ENTRY' }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const myMethodRows = rows.filter((r) => r.text === 'MyMethod'); expect(myMethodRows).toHaveLength(2); @@ -441,7 +455,7 @@ describe('toBottomUpTree', () => { }); createEvent({ text: 'Leaf', self: 7, total: 7, parent: parentB }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const leafRow = findRowByText(rows, 'Leaf'); const callerRows = leafRow._children ?? []; @@ -468,7 +482,7 @@ describe('toBottomUpTree', () => { const rec2 = createEvent({ text: 'Search', self: 50, total: 800, parent: rec1 }); createEvent({ text: 'Search', self: 50, total: 700, parent: rec2 }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const searchRow = rows.find((r) => r.text === 'Search'); if (!searchRow) { @@ -487,7 +501,7 @@ describe('toBottomUpTree', () => { const rec2 = createEvent({ text: 'Search', self: 50, total: 800, parent: rec1 }); createEvent({ text: 'Search', self: 50, total: 700, parent: rec2 }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const searchRow = rows.find((r) => r.text === 'Search'); if (!searchRow) { @@ -511,7 +525,7 @@ describe('toBottomUpTree', () => { const rec2 = createEvent({ text: 'Search', self: 50, total: 800, parent: rec1 }); createEvent({ text: 'Search', self: 50, total: 700, parent: rec2 }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const searchRow = findRowByText(rows, 'Search'); const callerRows = searchRow._children ?? []; @@ -538,7 +552,7 @@ describe('toBottomUpTree', () => { const rec2 = createEvent({ text: 'Search', self: 30, total: 600, parent: rec1 }); createEvent({ text: 'Search', self: 20, total: 300, parent: rec2 }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const rootSearchRow = findRowByText(rows, 'Search'); const firstRecursiveCaller = findRowByText(rootSearchRow._children ?? [], 'Search'); const secondRecursiveCaller = findRowByText(firstRecursiveCaller._children ?? [], 'Search'); @@ -601,7 +615,7 @@ describe('toBottomUpTree', () => { thrown: 1, }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const searchRow = rows.find((r) => r.text === 'Search'); if (!searchRow) { @@ -650,7 +664,7 @@ describe('toBottomUpTree', () => { const search2 = createEvent({ text: 'buildNode', self: 11, total: 300, parent: search1 }); createEvent({ text: 'buildNode', self: 9, total: 120, parent: search2 }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); assertTotalIsAtLeastSelf(rows); const buildNodeRows = rows.filter((row) => row.text === 'buildNode'); @@ -678,7 +692,7 @@ describe('toBottomUpTree', () => { createEvent({ text: 'search', self: 7, total: 140, parent: recursive, type: 'METHOD_ENTRY' }); createEvent({ text: 'helper', self: 20, total: 20, parent: topLevel, type: 'METHOD_ENTRY' }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const uniqueKeys = new Set(rows.map((row) => row.key)); expect(uniqueKeys.size).toBe(rows.length); @@ -699,7 +713,7 @@ describe('toBottomUpTree', () => { createEvent({ text: 'C', self: 25, total: 25, parent: b }); createEvent({ text: 'D', self: 40, total: 40, parent: root }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const rootSelfBudget = rows.reduce((sum, row) => sum + row.totalSelfTime, 0); const traceSelfBudget = sumTraceSelfTime(root.children); @@ -713,7 +727,7 @@ describe('toBottomUpTree', () => { const r2 = createEvent({ text: 'recursive', self: 8, total: 25, parent: r1 }); createEvent({ text: 'recursive', self: 17, total: 17, parent: r2 }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const recursive = findRowByText(rows, 'recursive'); expect(recursive.totalSelfTime).toBe(35); @@ -744,7 +758,7 @@ describe('toBottomUpTree', () => { createEvent({ text: 'sub other', self: 6, total: 6, parent: other }); createEvent({ text: 'sub other', self: 9, total: 9, parent: other }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const subOther = findRowByText(rows, 'sub other'); expect(subOther).toMatchObject({ totalSelfTime: 15, totalTime: 15, callCount: 2 }); @@ -779,7 +793,7 @@ describe('toBottomUpTree', () => { // Sibling Search directly under Outer createEvent({ text: 'Search', self: 30, total: 30, parent: outer }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const search = findRowByText(rows, 'Search'); expect(search).toMatchObject({ totalSelfTime: 290, totalTime: 840 }); @@ -877,7 +891,7 @@ describe('toBottomUpTree', () => { soqlRowTotal: 5, }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); assertPartitionInvariant(rows); const search = findRowByText(rows, 'Search'); @@ -895,7 +909,7 @@ describe('toBottomUpTree', () => { const callerC = createEvent({ text: 'CallerC', self: 12, total: 90, parent: root }); createEvent({ text: 'MethodB', self: 30, total: 70, parent: callerC }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const methodB = findRowByText(rows, 'MethodB'); const callers = methodB._children ?? []; @@ -917,7 +931,7 @@ describe('toBottomUpTree', () => { thrown: 1, }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const limitOnly = findRowByText(rows, 'LimitOnly'); expect(limitOnly.callCount).toBe(1); expect(limitOnly.dmlCount.self).toBe(1); @@ -930,7 +944,7 @@ describe('toBottomUpTree', () => { createEvent({ text: 'Alpha', self: 20, total: 20, parent: root }); createEvent({ text: 'Beta', self: 20, total: 20, parent: root }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); expect(rows.map((row) => row.text)).toEqual(['Zulu', 'Alpha', 'Beta']); }); @@ -1000,7 +1014,7 @@ describe('toBottomUpTree', () => { thrown: 1, }); - const rows = toBottomUpTree(root.children) as PartitionRow[]; + const rows = bottomUpOf(root.children) as PartitionRow[]; const globalRoot: PartitionRow = { text: outer.text, totalTime: outer.duration.total, @@ -1034,49 +1048,53 @@ describe('bottom-up caller row scope', () => { return root; } - /** Every bucket with the chain that reaches it, root bucket key first. */ - function bucketsWithPaths( + /** Every bucket with the root bucket that holds the calls it derives from. */ + function bucketsWithRoots( rows: BottomUpRow[], - parentPath: string[] = [], - ): Array<{ row: BottomUpRow; keyPath: string[] }> { + root?: BottomUpRow, + ): Array<{ row: BottomUpRow; root: BottomUpRow }> { return rows.flatMap((row) => { - const keyPath = [...parentPath, row.key]; - return [{ row, keyPath }, ...bucketsWithPaths(row._children ?? [], keyPath)]; + const held = root ?? row; + return [{ row, root: held }, ...bucketsWithRoots(row._children ?? [], held)]; }); } - function derivedFor(roots: BottomUpRow[], keyPath: string[]): LogEvent[] { - const root = roots.find((candidate) => candidate.key === keyPath[0]); - return occurrencesThrough(root?.instances ?? [], keyPath); + /** The root bucket's calls whose own chain of callers runs through the row, + * which is what the grid counts the row's totals from. */ + function derivedFor(paths: KeyPathIds, root: BottomUpRow, row: BottomUpRow): LogEvent[] { + return root.instances.filter((event) => paths.chainReaches(event, row._pathId)); } it('counts one call per occurrence the row derives', () => { - const roots = toBottomUpTree(recursiveRoot().children); + const paths = new KeyPathIds(2048); + const roots = toBottomUpTree(recursiveRoot().children, paths); - const buckets = bucketsWithPaths(roots); + const buckets = bucketsWithRoots(roots); expect(buckets.length).toBeGreaterThan(roots.length); - for (const { row, keyPath } of buckets) { - expect(derivedFor(roots, keyPath)).toHaveLength(row.callCount); + for (const { row, root } of buckets) { + expect(derivedFor(paths, root, row)).toHaveLength(row.callCount); } }); it('scopes a caller row to the calls made through it, not to every call', () => { - const roots = toBottomUpTree(recursiveRoot().children); + const paths = new KeyPathIds(2048); + const roots = toBottomUpTree(recursiveRoot().children, paths); const recursive = findRowByText(roots, 'A'); const throughB = findRowByText(recursive._children ?? [], 'B'); expect(recursive.callCount).toBe(4); expect(throughB.callCount).toBe(1); - expect(derivedFor(roots, [recursive.key, throughB.key])).toEqual([ + expect(derivedFor(paths, recursive, throughB)).toEqual([ recursive.instances.find((event) => event.parent?.text === 'B'), ]); }); it('agrees with the row totals the grid shows', () => { - const roots = toBottomUpTree(recursiveRoot().children); + const paths = new KeyPathIds(2048); + const roots = toBottomUpTree(recursiveRoot().children, paths); - for (const { row, keyPath } of bucketsWithPaths(roots)) { - const derived = derivedFor(roots, keyPath); + for (const { row, root } of bucketsWithRoots(roots)) { + const derived = derivedFor(paths, root, row); const totalTime = outermostEvents(derived).reduce( (sum, event) => sum + event.duration.total, 0, @@ -1104,7 +1122,7 @@ describe('toAggregatedCallTree', () => { soqlTotal: 2, }); - const rows = toAggregatedCallTree(root.children); + const rows = aggregatedOf(root.children); const limitOnly = findRowByText(rows, 'LimitOnly'); expect(limitOnly.callCount).toBe(1); expect(limitOnly.soqlCount.self).toBe(2); @@ -1120,7 +1138,7 @@ describe('_hasDetailsDeep precomputation', () => { const root = createEvent({ text: 'LOG_ROOT', self: 0, total: 0, type: 'EXECUTION_STARTED' }); createEvent({ text: 'NoTime', self: 0, total: 0, parent: root }); - const rows = toAggregatedCallTree(root.children); + const rows = aggregatedOf(root.children); const noTime = findRowByText(rows, 'NoTime'); expect(noTime._hasDetailsDeep).toBe(false); }); @@ -1135,7 +1153,7 @@ describe('_hasDetailsDeep precomputation', () => { type: 'CUMULATIVE_LIMIT_USAGE', }); - const rows = toAggregatedCallTree(root.children); + const rows = aggregatedOf(root.children); const limit = findRowByText(rows, 'LimitUsage'); expect(limit._hasDetailsDeep).toBe(true); }); @@ -1145,7 +1163,7 @@ describe('_hasDetailsDeep precomputation', () => { const parent = createEvent({ text: 'ZeroParent', self: 0, total: 0, parent: root }); createEvent({ text: 'BusyChild', self: 5, total: 5, parent }); - const rows = toAggregatedCallTree(root.children); + const rows = aggregatedOf(root.children); const zeroParent = findRowByText(rows, 'ZeroParent'); expect(zeroParent.totalTime).toBe(0); expect(zeroParent._hasDetailsDeep).toBe(true); @@ -1156,7 +1174,7 @@ describe('_hasDetailsDeep precomputation', () => { const parent = createEvent({ text: 'ZeroParent', self: 0, total: 0, parent: root }); createEvent({ text: 'ZeroChild', self: 0, total: 0, parent }); - const rows = toAggregatedCallTree(root.children); + const rows = aggregatedOf(root.children); const zeroParent = findRowByText(rows, 'ZeroParent'); expect(zeroParent._hasDetailsDeep).toBe(false); }); @@ -1166,7 +1184,7 @@ describe('_hasDetailsDeep precomputation', () => { const caller = createEvent({ text: 'Caller', self: 0, total: 5, parent: root }); createEvent({ text: 'Callee', self: 5, total: 5, parent: caller }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const callee = findRowByText(rows, 'Callee'); expect(callee._hasDetailsDeep).toBe(true); // Caller appears as a child of Callee in bottom-up @@ -1184,7 +1202,7 @@ describe('_hasDetailsDeep precomputation', () => { type: 'LIMIT_USAGE_FOR_NS', }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const limit = findRowByText(rows, 'LimitUsage'); expect(limit._hasDetailsDeep).toBe(true); }); @@ -1215,7 +1233,7 @@ describe('SOSL rollup', () => { soslRowTotal: 5, }); - const rows = toAggregatedCallTree(root.children); + const rows = aggregatedOf(root.children); const searchRow = findRowByText(rows, 'Search'); expect(searchRow.soslCount.total).toBe(2); expect(searchRow.soslRowCount.total).toBe(15); @@ -1235,7 +1253,7 @@ describe('SOSL rollup', () => { soslRowTotal: 7, }); - const rows = toBottomUpTree(root.children); + const rows = bottomUpOf(root.children); const callee = findRowByText(rows, 'Callee'); expect(callee.soslCount.self).toBe(1); expect(callee.soslRowCount.total).toBe(7); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/bottomUpOccurrences.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/bottomUpOccurrences.test.ts deleted file mode 100644 index 13a6a8cbc..000000000 --- a/log-viewer/src/features/call-tree/utils/__tests__/bottomUpOccurrences.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ -import { describe, expect, it } from '@jest/globals'; -import type { LogEvent } from 'apex-log-parser'; - -import { getEventKey as keyOf } from '../../../../core/log/eventKeys.js'; -import { occurrencesThrough } from '../bottomUpOccurrences.js'; - -let nextEventIndex = 0; - -/** A frame carrying only what a bucket key and an ancestor walk read. */ -function frame(text: string, parent: LogEvent | null): LogEvent { - const event = { - eventIndex: nextEventIndex++, - type: 'METHOD_ENTRY', - namespace: 'default', - text, - parent, - children: [], - } as unknown as LogEvent; - parent?.children.push(event); - return event; -} - -// A -> B -> A on one branch, A -> C -> A on the other: the two-level recursion -// the bottom-up root bucket for A holds every occurrence of. -const outer1 = frame('A', null); -const b1 = frame('B', outer1); -const a2 = frame('A', b1); -const outer2 = frame('A', null); -const c1 = frame('C', outer2); -const a3 = frame('A', c1); - -const rootInstances = [a2, a3, outer1, outer2]; - -describe('occurrencesThrough', () => { - it('reaches every occurrence for a root row', () => { - expect(occurrencesThrough(rootInstances, [keyOf(outer1)])).toEqual(rootInstances); - }); - - it('reaches the subset a caller row stands for', () => { - expect(occurrencesThrough(rootInstances, [keyOf(a2), keyOf(b1)])).toEqual([a2]); - expect(occurrencesThrough(rootInstances, [keyOf(a3), keyOf(c1)])).toEqual([a3]); - }); - - it('follows a two-level recursion up to the outer call', () => { - expect(occurrencesThrough(rootInstances, [keyOf(a2), keyOf(b1), keyOf(outer1)])).toEqual([a2]); - }); - - it('reaches nothing through a chain no occurrence took', () => { - expect(occurrencesThrough(rootInstances, [keyOf(a2), keyOf(b1), keyOf(c1)])).toEqual([]); - // Past the outermost call there is no ancestor left to match. - expect( - occurrencesThrough(rootInstances, [keyOf(a2), keyOf(b1), keyOf(outer1), keyOf(b1)]), - ).toEqual([]); - expect(occurrencesThrough(rootInstances, ['no such key'])).toEqual([]); - }); - - it('reaches nothing without a path', () => { - expect(occurrencesThrough(rootInstances, [])).toEqual([]); - }); -}); diff --git a/log-viewer/src/features/call-tree/utils/__tests__/bucketRows.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/bucketRows.test.ts index 4aa2ac621..d07f72093 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/bucketRows.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/bucketRows.test.ts @@ -19,6 +19,9 @@ interface FakeRow { expanded: boolean; /** Set once the row is asked to expand, so a test can prove it was. */ expandedByWalk?: boolean; + /** Reads of the children that come back empty before the rest do, as they do + * while the renderer is still building them. */ + emptyReads?: number; } function row(text: string, ...children: FakeRow[]): FakeRow { @@ -31,7 +34,13 @@ function asRows(rows: FakeRow[]): RowComponent[] { (data) => ({ getData: () => ({ key: data.key, _children: data.children }), - getTreeChildren: () => (data.expanded ? asRows(data.children) : []), + getTreeChildren: () => { + if (data.emptyReads) { + data.emptyReads -= 1; + return []; + } + return data.expanded ? asRows(data.children) : []; + }, isTreeExpanded: () => data.expanded, treeExpand: () => { data.expanded = true; @@ -94,6 +103,18 @@ describe('findBucketRow', () => { expect(found && (found.getData() as { key: string }).key).toBe(key('exec')); }); + it('waits again where an open row has not built its children yet', async () => { + // Open already, so nothing expands it: without a second read the descent + // would land on this row and the pick would need a second click. + const mid = row('a', row('target')); + mid.expanded = true; + mid.emptyReads = 1; + + const found = await findBucketRow(asRows([row('exec', mid)]), target, 'callees', settled); + + expect(found && (found.getData() as { key: string }).key).toBe(key('target')); + }); + it('finds nothing where the outermost frame has no row', async () => { const found = await findBucketRow(asRows([row('elsewhere')]), target, 'callees', settled); diff --git a/log-viewer/src/features/call-tree/utils/bottomUpOccurrences.ts b/log-viewer/src/features/call-tree/utils/bottomUpOccurrences.ts deleted file mode 100644 index 0d61e0749..000000000 --- a/log-viewer/src/features/call-tree/utils/bottomUpOccurrences.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ - -import type { LogEvent } from 'apex-log-parser'; - -/** - * Occurrences reached through `keyPath`: `keyPath[0]` is the occurrence itself, so - * the root bucket's own key, and the last entry is the bucket being scoped. - * - * A bottom-up caller bucket stores no occurrences of its own, so they are derived - * from the root bucket, which holds every one of them. Storing them per bucket - * would cost the sum of all chain depths. - * - * @param rootInstances - every occurrence the root bucket holds - * @param keyPath - the chain that reaches the bucket, the occurrence first - */ -export function occurrencesThrough( - rootInstances: readonly LogEvent[], - keyPath: readonly string[], -): LogEvent[] { - if (!keyPath.length) { - return []; - } - // Split once per call rather than rebuilding a key per frame per level: the - // filter runs over every occurrence the root holds, on a pointer move. - const path = keyPath.map(splitKey); - return rootInstances.filter((instance) => reachedThrough(instance, path)); -} - -/** A bucket key's three parts. `text` can itself hold a separator, so only the - * first two are cut. */ -interface KeyParts { - type: string; - namespace: string; - text: string; -} - -function splitKey(key: string): KeyParts { - const type = key.indexOf('|'); - const namespace = key.indexOf('|', type + 1); - return { - type: key.slice(0, type), - namespace: key.slice(type + 1, namespace), - text: key.slice(namespace + 1), - }; -} - -function reachedThrough(instance: LogEvent, path: readonly KeyParts[]): boolean { - let frame: LogEvent | null = instance; - for (const part of path) { - if ( - !frame || - (frame.type ?? '') !== part.type || - frame.namespace !== part.namespace || - frame.text !== part.text - ) { - return false; - } - frame = frame.parent; - } - return true; -} diff --git a/log-viewer/src/features/call-tree/utils/bucketRows.ts b/log-viewer/src/features/call-tree/utils/bucketRows.ts index 267e542e7..735424828 100644 --- a/log-viewer/src/features/call-tree/utils/bucketRows.ts +++ b/log-viewer/src/features/call-tree/utils/bucketRows.ts @@ -28,6 +28,11 @@ const bucketOf = (row: RowComponent): BucketRow => row.getData() as BucketRow; * @param rows - the view's top-level rows * @param waitForRender - resolves once an expanded row's children exist */ +export function findRootBucket(rows: RowComponent[], event: LogEvent): RowComponent | null { + const key = getEventKey(event); + return rows.find((row) => bucketOf(row).key === key) ?? null; +} + export async function findBucketRow( rows: RowComponent[], event: LogEvent, @@ -35,8 +40,7 @@ export async function findBucketRow( waitForRender: () => Promise, ): Promise { if (direction === 'callers') { - const key = getEventKey(event); - return rows.find((row) => bucketOf(row).key === key) ?? null; + return findRootBucket(rows, event); } const path = eventKeyChain(event).reverse(); @@ -54,8 +58,12 @@ export async function findBucketRow( break; } let children = next.getTreeChildren() ?? []; - if (!children.length && bucketOf(next)._children?.length && !next.isTreeExpanded()) { - withCodeDrivenExpand(() => next.treeExpand()); + if (!children.length && bucketOf(next)._children?.length) { + if (!next.isTreeExpanded()) { + withCodeDrivenExpand(() => next.treeExpand()); + } + // An open row can still be waiting on the renderer, and reading through + // without waiting landed the descent on this row rather than the target. await waitForRender(); children = next.getTreeChildren() ?? []; } diff --git a/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts b/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts index 4c2010aa0..6fabb154a 100644 --- a/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts +++ b/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts @@ -45,9 +45,12 @@ export class RowKeyboardNavigation extends Module { initialize() { this.setOption('selectableRows', 'highlight'); - this.localTable.on('dataTreeRowExpanded', (row, _level) => { + this.localTable.on('dataTreeRowExpanded', (row: RowComponent) => { this.rowExpanded(row); }); + this.localTable.on('dataTreeRowCollapsed', () => { + this.rowCollapsed(); + }); this.localTable.on('rowClick', (event, row) => { this.rowClick(event, row); }); @@ -55,12 +58,30 @@ export class RowKeyboardNavigation extends Module { /** The user's first expansion gives the keyboard a row to move from. */ rowExpanded(row: RowComponent) { - if (isCodeDrivenExpand() || this.localTable.getSelectedRows().length) { + if (isCodeDrivenExpand()) { return; } + if (!this.localTable.getSelectedRows().length) { + row.select(); + } + this.takeFocusBack(); + } + + /** A collapse hands focus back and nothing else: selecting the row the user + * just closed would re-scope the inspector to it. */ + rowCollapsed() { + if (!isCodeDrivenExpand()) { + this.takeFocusBack(); + } + } - row.select(); - // The key bindings only fire while the holder itself holds focus. + /** + * Tabulator gives the tree control its own `tabIndex`, so working it moves + * focus onto the control. The key bindings only answer while the table body is + * the event target, so without this the arrows scroll the table instead of + * moving down it. + */ + private takeFocusBack(): void { tableHolder(this.localTable.element)?.focus({ preventScroll: true }); } @@ -185,7 +206,9 @@ export class RowKeyboardNavigation extends Module { prevRow.getElement().scrollIntoView({ block: 'nearest' }); } } else { - row.treeCollapse(); + // Declared like `expandRow`'s: the collapse is the code's, so it + // must not read as the user reaching for the tree control. + withCodeDrivenExpand(() => row.treeCollapse()); } }, }, diff --git a/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts b/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts index 30ed34880..c53f0c266 100644 --- a/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts +++ b/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts @@ -36,7 +36,8 @@ function setup(selected: RowComponent[] = []) { const row = { select: jest.fn() } as unknown as RowComponent; const expand = () => handlers['dataTreeRowExpanded']?.forEach((fn) => fn(row, 0)); - return { handlers, holder, row, expand }; + const collapse = () => handlers['dataTreeRowCollapsed']?.forEach((fn) => fn(row, 0)); + return { handlers, holder, row, expand, collapse }; } describe('RowKeyboardNavigation', () => { @@ -53,20 +54,33 @@ describe('RowKeyboardNavigation', () => { expect(holder.focus).toHaveBeenCalled(); }); - it('leaves an existing selection where it is', () => { + it('leaves an existing selection where it is, and still takes focus back', () => { const selected = { select: jest.fn() } as unknown as RowComponent; const { row, holder, expand } = setup([selected]); expand(); expect(row.select).not.toHaveBeenCalled(); - expect(holder.focus).not.toHaveBeenCalled(); + // The tree control drops focus, so the arrows would scroll the table. + expect(holder.focus).toHaveBeenCalled(); + }); + + it('takes focus back after a collapse, without selecting the closed row', () => { + const { row, holder, collapse } = setup(); + + collapse(); + + expect(holder.focus).toHaveBeenCalled(); + // Selecting it would re-scope the inspector to the row just closed. + expect(row.select).not.toHaveBeenCalled(); }); - it('does not answer a collapse at all', () => { - const { handlers } = setup(); + it('ignores a collapse the code drove', () => { + const { holder, collapse } = setup(); - expect(handlers['dataTreeRowCollapsed']).toBeUndefined(); + withCodeDrivenExpand(collapse); + + expect(holder.focus).not.toHaveBeenCalled(); }); it('ignores an expansion the code drove', () => { diff --git a/scripts/measure/measure.ts b/scripts/measure/measure.ts index ba3b03604..f15e32033 100644 --- a/scripts/measure/measure.ts +++ b/scripts/measure/measure.ts @@ -23,7 +23,7 @@ import { rowIdsByPath, type ScopedRow, } from '../../log-viewer/src/components/scopedCallTree.js'; -import { setCurrentLog } from '../../log-viewer/src/core/log/LogStore.js'; +import { LogStore, setCurrentLog } from '../../log-viewer/src/core/log/LogStore.js'; import { toAggregatedCallTree, toBottomUpTree, @@ -128,5 +128,9 @@ await time('mark callees', () => new LocatedRowIds().idsFor(log, picked, 'callee // The Call Tree tab's own grouped builds, for comparison with the inspector's. console.log(''); -await time('grid toAggregatedCallTree', () => toAggregatedCallTree(log.children)); -await time('grid toBottomUpTree', () => toBottomUpTree(log.children)); +// A store of its own, so the inspector's builds above have not warmed the key +// table. Only the first build below is cold; the second reads what the first +// interned, as it does on screen where every view shares one table. +const gridPaths = new LogStore(log).keyPathIds(); +await time('grid toAggregatedCallTree', () => toAggregatedCallTree(log.children, gridPaths)); +await time('grid toBottomUpTree', () => toBottomUpTree(log.children, gridPaths)); From 72e90894d15ccea3c2176734d9bccc865a1cef79 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:02:42 +0100 Subject: [PATCH 03/61] fix(log-viewer): keep the inspector mark and the tab it points at in step (#974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview Picking a row in the inspector's call tree moves the tab's grid to the bucket it names, and the move re-renders the rows the mark sits on, so the mark has to be applied after it. The Call Tree applied the mark it had read *before* the move. Dropping the pick while the move ran cleared the mark, and the move then put it back with nothing picked; hovering another row instead had that hover's own mark stripped and never re-applied, leaving the grid unmarked until the pointer moved again. The mark now reads what the inspector is pointing at when the move settles, which is the emphasis the view already keeps, so there is no second copy of that truth to fall out of step. The same handler was written four times, once per tab, with the defect in two of the copies; all four now share one. ## ๐Ÿ› ๏ธ Changes made - **One handler for four tabs** โ€” `inspectorLocateHandler` in `log-viewer/src/components/inspectorLocate.ts` replaces the hand-written `inspector:locate` handlers in `CalltreeView`, `AnalysisView`, `DatabaseView` and `ApexLogTimeline`. Whether a pick moves a view is now an argument rather than a property of which handler was copied last. - **The mark is read, not restored** โ€” the deferred mark takes `InspectorEmphasis.current()` when the move settles, so a report that arrived during the move wins and a dropped pick stays dropped. `Escape` reaches a view as `selection:clear`, which never passes through this handler, so anything that tracked reports here rather than on the emphasis would have missed it. - **A failed move no longer leaks** โ€” the move is awaited in a `try`, and the mark goes on either way: it says where the frames are whether the view reached them or not. Previously a rejection surfaced as an unhandled rejection. - **`EventDetail`** โ€” exported from `EventBus.ts` so a shared handler can be typed against one event's payload without lifting that payload out of `EventMap`, which stays where each event is described. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A. What changes is whether a mark is still on screen after a pick moves the grid. ## ๐Ÿ”— Related Issues None. ## โœ… Tests added? - [x] ๐Ÿ‘ yes - [ ] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [x] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features - [ ] ๐Ÿ™… not needed No entry: the Inspector is unreleased, so this belongs to its existing entry rather than a new one. The box is ticked to record that it was considered. ## Anything else we need to know? [optional] **Where to start.** `log-viewer/src/components/inspectorLocate.ts` is the whole of it, 46 lines. `log-viewer/src/components/inspectorEmphasis.ts` is deliberately untouched: it stays pure state, and the new module composes it. **Test plan.** - \`pnpm lint\` and \`pnpm test\`. - Call Tree tab, inspector Call Tree โ†’ Bottom Up. Click a caller row, then press \`Escape\` before the scroll settles: the mark must stay gone. - Same again, but hover a different inspector row instead of pressing \`Escape\`: the hovered row's frames must be marked once the move settles, not the picked row's, and the grid must not be left unmarked. - Timeline: hover and pick inspector rows. The chart dims around them and must not pan. - Database: hover and pick. Statement rows mark, and the grid must not scroll. - Analysis: pick moves the grid and marks. Hover marks only. - \`Escape\` on each tab clears the selection and any held mark. **Known unrelated failure locally.** \`lana/src/services/__tests__/servicesRuntime.test.ts\` cannot resolve \`effect\` in a worktree not installed since #951. No \`lana\` file is touched here, and it passes in CI. **Follow-ups, not in this PR.** - **The grid mark is a one-shot DOM sweep.** \`LocatedRowMarker.mark\` adds a class to the rows present at that instant, and the row formatters stamp the row id but never re-apply the class, so scrolling a grid while a picked inspector row is lit loses the mark. Making the marker hold the wanted id set and having the formatter apply the class would fix that and delete the await-then-mark ordering this PR gets right by hand. - **The source filter is now in nine places.** Every \`DetailSource\`-carrying event is filtered the same way in each view; an \`eventBus.onSource\` would fold all nine, and with it the four near-identical \`inspector:reveal\` handlers, so one gesture stops needing two subscriptions per view. - **A stale move is not abandoned.** Clicking two inspector rows quickly lets the earlier move settle last and scroll away from the newer row. The mark is now correct either way, but the scroll is not; a Tabulator expand and scroll is not cancellable, so this needs its own approach. - **Whether the Database grids and the flame chart should move on a merged pick.** Both can. They do not today, and the shared handler makes that an explicit argument rather than an accident, but changing it is a product decision. --- .../__tests__/inspectorLocate.test.ts | 122 ++++++++++++++++++ log-viewer/src/components/inspectorLocate.ts | 46 +++++++ log-viewer/src/core/events/EventBus.ts | 4 + .../analysis/components/AnalysisView.ts | 34 ++--- .../call-tree/components/CalltreeView.ts | 27 ++-- .../database/components/DatabaseView.ts | 12 +- .../timeline/optimised/ApexLogTimeline.ts | 12 +- 7 files changed, 208 insertions(+), 49 deletions(-) create mode 100644 log-viewer/src/components/__tests__/inspectorLocate.test.ts create mode 100644 log-viewer/src/components/inspectorLocate.ts diff --git a/log-viewer/src/components/__tests__/inspectorLocate.test.ts b/log-viewer/src/components/__tests__/inspectorLocate.test.ts new file mode 100644 index 000000000..0154a73ba --- /dev/null +++ b/log-viewer/src/components/__tests__/inspectorLocate.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { InspectorEmphasis } from '../inspectorEmphasis.js'; +import { inspectorLocateHandler } from '../inspectorLocate.js'; + +describe('inspectorLocateHandler', () => { + /** A view whose move settles only when the test says so, which is the window a + * later report has to arrive in. */ + function wire(move?: (eventIndex: number) => Promise) { + const marks: Array = []; + const revealed: number[] = []; + let settle: () => void = () => {}; + const emphasis = new InspectorEmphasis(); + const handle = inspectorLocateHandler( + 'calltree', + emphasis, + (eventIndexes) => marks.push(eventIndexes), + move === undefined + ? (eventIndex) => { + revealed.push(eventIndex); + return new Promise((resolve) => { + settle = resolve; + }); + } + : move, + ); + return { marks, revealed, emphasis, finish: () => settle(), handle }; + } + + const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it('leaves a report for another tab alone', () => { + const { marks, revealed, handle } = wire(); + + handle({ source: 'analysis', eventIndexes: [4], sticky: true }); + + expect(marks).toEqual([]); + expect(revealed).toEqual([]); + }); + + it('marks under the pointer without moving the view', () => { + const { marks, revealed, handle } = wire(); + + handle({ source: 'calltree', eventIndexes: [4, 5], sticky: false }); + + expect(marks).toEqual([[4, 5]]); + expect(revealed).toEqual([]); + }); + + it('moves to a picked row first, then marks', async () => { + const { marks, revealed, finish, handle } = wire(); + + handle({ source: 'calltree', eventIndexes: [4, 5], sticky: true }); + expect(revealed).toEqual([4]); + // The mark waits: the move re-renders the rows it lands on. + expect(marks).toEqual([]); + + finish(); + await settled(); + + expect(marks).toEqual([[4, 5]]); + }); + + it('marks what a report arriving during the move replaced it with', async () => { + const { marks, finish, handle } = wire(); + + handle({ source: 'calltree', eventIndexes: [4], sticky: true }); + // The pointer reaches another row while the move it started is in flight. + handle({ source: 'calltree', eventIndexes: [9], sticky: false }); + finish(); + await settled(); + + // The move re-rendered the rows that hover had marked, so it goes on again. + expect(marks.at(-1)).toEqual([9]); + }); + + it('clears the mark where the pick was dropped during the move', async () => { + const { marks, finish, handle } = wire(); + + handle({ source: 'calltree', eventIndexes: [4], sticky: true }); + handle({ source: 'calltree', eventIndexes: [], sticky: true }); + finish(); + await settled(); + + expect(marks.at(-1)).toEqual([]); + }); + + it('clears the mark where the view cleared its own emphasis during the move', async () => { + const { marks, emphasis, finish, handle } = wire(); + + handle({ source: 'calltree', eventIndexes: [4], sticky: true }); + // Escape reaches the view as `selection:clear`, which never passes here. + emphasis.pick([]); + finish(); + await settled(); + + expect(marks.at(-1)).toEqual([]); + }); + + it('marks even where the move fails, since the mark still says where the frames are', async () => { + const { marks, handle } = wire(() => Promise.reject(new Error('no row for it'))); + + handle({ source: 'calltree', eventIndexes: [4], sticky: true }); + await settled(); + + expect(marks).toEqual([[4]]); + }); + + it('marks a picked row where the view cannot move to one', () => { + const marks: Array = []; + const handle = inspectorLocateHandler('calltree', new InspectorEmphasis(), (ids) => + marks.push(ids), + ); + + handle({ source: 'calltree', eventIndexes: [4], sticky: true }); + + expect(marks).toEqual([[4]]); + }); +}); diff --git a/log-viewer/src/components/inspectorLocate.ts b/log-viewer/src/components/inspectorLocate.ts new file mode 100644 index 000000000..e81314f02 --- /dev/null +++ b/log-viewer/src/components/inspectorLocate.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { DetailSource, EventDetail } from '../core/events/EventBus.js'; +import type { InspectorEmphasis } from './inspectorEmphasis.js'; + +/** + * A view's answer to the inspector pointing at frames: mark what the report + * names, and where a picked row merges occurrences, move to the first of them. + * + * @param source - the tab this view is, so a report for another is left alone + * @param revealFirstOccurrence - omitted where jumping to one of several merged + * occurrences would be arbitrary, which is why the Database grids and the + * flame chart only mark. A pick of a single frame arrives as + * `inspector:reveal` instead, and every view answers that. + */ +export function inspectorLocateHandler( + source: DetailSource, + emphasis: InspectorEmphasis, + mark: (eventIndexes: readonly number[]) => void, + revealFirstOccurrence?: (eventIndex: number) => Promise, +): (detail: EventDetail<'inspector:locate'>) => void { + return (detail) => { + if (detail.source !== source) { + return; + } + const marked = emphasis.report(detail.eventIndexes, detail.sticky); + if (!revealFirstOccurrence || !detail.sticky || !detail.eventIndexes.length) { + mark(marked); + return; + } + // Moving re-renders the rows a mark sits on, so the mark goes on after it. + void (async () => { + try { + await revealFirstOccurrence(detail.eventIndexes[0]!); + } catch { + // The move failed, and the view reports that itself. The mark still has + // to go on: it says where the frames are, moved to or not. + } + // Read again rather than re-applying the report that started the move: a + // report arriving while it ran has already replaced that one, and the + // re-render stripped whatever mark it had put on. + mark(emphasis.current()); + })(); + }; +} diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index 92f4edfe6..54ec4ee6d 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -109,6 +109,10 @@ interface EventMap { 'detail:locate': { source: DetailSource; eventIndexes: readonly number[] }; } +/** One event's payload, for code that answers an event it is handed rather than + * one it names itself. The map stays where each payload is described. */ +export type EventDetail = EventMap[K]; + type EventCallback = (detail: EventMap[K]) => void; class EventBusImpl { diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index f188a63a2..27bcda9b0 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -22,6 +22,7 @@ import { rowOccurrences, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; +import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; @@ -163,9 +164,6 @@ export class AnalysisView extends LitElement { private _locatedRow = new LocatedRowMarker(); private _locateIds = new LocatedRowIds(); private _emphasis = new InspectorEmphasis(); - /** Counts the locate reports, so a mark held back by a reveal is dropped once - * a later report has replaced it. */ - private _locateReport = 0; constructor() { super(); @@ -179,27 +177,15 @@ export class AnalysisView extends LitElement { }); // Mark the buckets the inspector points at. A row is a method bucket rather // than one event, so a frame is translated into the paths of the rows it heads. - this._inspectorLocateUnsubscribe = eventBus.on('inspector:locate', (detail) => { - if (detail.source !== 'analysis') { - return; - } - const ids = this._emphasis.report(detail.eventIndexes, detail.sticky); - if (detail.sticky && detail.eventIndexes.length) { - // A picked row moves the grid to the bucket it names, as one picked in - // the Call Tree does. The reveal re-renders the rows the mark lands on, - // so the mark goes on after, and only while it is still the last report: - // dropping the pick clears the mark while the reveal is still in flight. - const reported = ++this._locateReport; - void this._revealEventIndex(detail.eventIndexes[0]!).then(() => { - if (reported === this._locateReport) { - this._markLocated(ids); - } - }); - } else { - this._locateReport++; - this._markLocated(ids); - } - }); + this._inspectorLocateUnsubscribe = eventBus.on( + 'inspector:locate', + inspectorLocateHandler( + 'analysis', + this._emphasis, + (eventIndexes) => this._markLocated(eventIndexes), + (eventIndex) => this._revealEventIndex(eventIndex), + ), + ); document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-match', this._findEvt); document.addEventListener('lv-find-close', this._findEvt); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 1a1a722ae..0f735e7fa 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -72,6 +72,7 @@ import { rowOccurrences, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; +import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; import { createTimeOrderTable } from './TimeOrderTable.js'; /** Time Order keys its rows by event index; the grouped views key theirs by the @@ -187,21 +188,17 @@ export class CalltreeView extends LitElement { }); // Mark the frames the inspector points at, while the Call Tree is the tab the - // inspector is showing. - this._inspectorLocateUnsubscribe = eventBus.on('inspector:locate', (detail) => { - if (detail.source === 'calltree') { - const ids = this._emphasis.report(detail.eventIndexes, detail.sticky); - if (detail.sticky && detail.eventIndexes.length) { - // A picked inspector row merges calls, so the mark shows all of them - // while the view moves to the first, as a pick of one frame does. The - // reveal expands and scrolls, which re-renders the rows the mark lands - // on, so it goes on after. - void this._revealEventIndex(detail.eventIndexes[0]!).then(() => this._markLocated(ids)); - } else { - this._markLocated(ids); - } - } - }); + // inspector is showing. A picked row merges calls, so the mark shows all of + // them while the view moves to the first, as a pick of one frame does. + this._inspectorLocateUnsubscribe = eventBus.on( + 'inspector:locate', + inspectorLocateHandler( + 'calltree', + this._emphasis, + (eventIndexes) => this._markLocated(eventIndexes), + (eventIndex) => this._revealEventIndex(eventIndex), + ), + ); // Escape (app-wide) deselects here; the table reports the clear itself. It // also drops a mark held by a picked inspector row, which is no selection of diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index d28ea5dec..268a799f7 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -17,6 +17,7 @@ import { limitTotals } from '../../../components/logOverviewMetrics.js'; import { eventBus, type StatementType } from '../../../core/events/EventBus.js'; import { apexLimitTimeSeries } from '../../timeline/optimised/apex-limit-series.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; +import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { isVisible } from '../../../core/utility/Util.js'; import { soslRowsMetric } from '../limits.js'; @@ -119,11 +120,12 @@ export class DatabaseView extends LitElement { // Mark the statements the inspector points at, while the Database tab is the // tab the inspector is showing. The eventIndex belongs to one grid, so the // others simply find nothing to mark. - this._offInspectorLocate = eventBus.on('inspector:locate', (d) => { - if (d.source === 'database') { - this._markLocated(this._emphasis.report(d.eventIndexes, d.sticky)); - } - }); + this._offInspectorLocate = eventBus.on( + 'inspector:locate', + inspectorLocateHandler('database', this._emphasis, (eventIndexes) => + this._markLocated(eventIndexes), + ), + ); // Escape (app-wide) deselects here. Only one grid holds the selection, and // its report of the clear reaches the inspector the same way a click does. It diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index d4c89f0a0..69327d10c 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -41,6 +41,7 @@ import { } from '../types/flamechart.types.js'; import type { SearchCursor } from '../types/search.types.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; +import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; import { isFrameOffscreen, toDetailSelection } from '../utils/detail-selection-sync.js'; import { extractExceptionMarkers, extractMarkers } from '../utils/marker-utils.js'; import { seekWindow } from '../utils/navigate-window.js'; @@ -226,11 +227,12 @@ export class ApexLogTimeline { // Dim the chart around the frames the inspector points at, while the timeline // is the tab the inspector is showing. - this.inspectorLocateUnsubscribe = eventBus.on('inspector:locate', (detail) => { - if (detail.source === 'timeline') { - this.applyEmphasis(this.emphasis.report(detail.eventIndexes, detail.sticky)); - } - }); + this.inspectorLocateUnsubscribe = eventBus.on( + 'inspector:locate', + inspectorLocateHandler('timeline', this.emphasis, (eventIndexes) => + this.applyEmphasis(eventIndexes), + ), + ); // Escape (app-wide) deselects here; the chart reports the clear itself. // The flame chart's own Escape (container focused) consumes the key first. From 104b9e5c0c0bad478b085352cdcd8f50515295e0 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:29:11 +0100 Subject: [PATCH 04/61] fix(log-viewer): mark every row an inspector pick names, not just the rendered ones (#975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview The mark that shows where an inspector row's frames sit was a one-shot DOM sweep over the rows a table had rendered. Tabulator builds a row's element on its first render, so the sweep could not reach a row that had never been on screen. Pick a row, then scroll down to a marked row below the viewport or expand a tree row whose children are built afterwards, and those rows arrive unmarked. The mark now belongs to the table rather than to a list of elements: the marker records the ids it wants, and the row formatter lights a row as it stamps it. That also removes the reason a pick's mark had to wait for the view to finish moving, so the handler #974 added loses its ordering, its awaits and three of its tests. ## ๐Ÿ› ๏ธ Changes made - **The mark is declarative** โ€” `LocatedRowMarker` records the wanted ids per table host, and `rowIndexStamper` / `stampRowPath` light a row as they stamp it. Every `rowFormatter` in the app already routes through one of those two, so no table factory needed a marker plumbed into it. - **The sweep un-lights as well as lights** โ€” it toggles rather than adds. Tabulator re-uses a row's element rather than rebuilding it, so a row can come back carrying a mark that has since moved, and a view that switches tables no longer leaves the one it left marked. - **Mark before moving** โ€” `inspectorLocateHandler` marks, then asks the view to move, and waits for nothing. A row the move renders lights itself. Gone with the ordering: the async body, the emphasis read-back, and the three tests that only covered which report won a race. - **A failed move is still answered**, so a view that cannot reach a frame leaves no unhandled rejection. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A. What changes is whether a row that was never on screen is marked when you reach it. ## ๐Ÿ”— Related Issues Follows #974, which added the shared handler this simplifies. ## โœ… Tests added? - [x] ๐Ÿ‘ yes - [ ] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help Four new `LocatedRowMarker` cases: a row arriving after the mark lights itself, a re-used element loses a stale mark, a table nothing has marked is left alone, and a table the mark has left stops lighting rows. ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [x] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features - [ ] ๐Ÿ™… not needed No entry: the Inspector is unreleased, so this belongs to its existing entry. Ticked to record that it was considered. ## Anything else we need to know? [optional] **Where to start.** `log-viewer/src/components/locatedRow.ts` โ€” `wantedByHost`, `stamp` and `sweep` are the whole mechanism. `inspectorLocate.ts` is what falls out of it, now 16 lines with no async. **What does not happen, since it reads as though it should.** A class on a row element survives ordinary scrolling: `Row.create()` is guarded by `this.created`, `Row.initialize()` deletes cells but re-uses the element, `RowManager.styleRow` adds and removes parity classes rather than assigning `className`, and our renderer only detaches and re-attaches the element. The gap is the first render, not a later one. **Why a walk up the DOM.** `stamp` finds its table by walking parents until it meets a marked host. The alternative was threading the view's marker through four table factories to reach the formatters. The walk is a handful of nodes per row per render, against cell rendering that is orders of magnitude more, and it keeps the change inside one file. **Test plan.** - \`pnpm lint\` and \`pnpm test\`. - Call Tree tab, inspector Call Tree โ†’ Bottom Up. Pick a caller row, then scroll the grid down past what was on screen: rows the pick names are marked when you reach them. That is the fix. - Expand a tree row under a marked row: the children it builds arrive marked where they should be. - Pick a row and press \`Escape\` quickly: the mark stays gone, including on rows you scroll to afterwards. - Pick a row, then hover a different one: the hovered row's frames end up lit. - Timeline dims without panning; the Database grids mark without scrolling; Call Tree and Analysis move and mark. - Both themes, Inspector docked at the side and at the bottom. **Known unrelated failure locally.** \`lana/src/services/__tests__/servicesRuntime.test.ts\` cannot resolve \`effect\` in a worktree not installed since #951. No \`lana\` file is touched here, and it passes in CI. **Follow-ups, not in this PR.** - **The source filter is in nine places.** Every \`DetailSource\`-carrying event is filtered the same way in each view; an \`eventBus.onSource\` would fold all nine, and with it the four near-identical \`inspector:reveal\` handlers, so one gesture stops needing two subscriptions per view. - **A stale move is not abandoned.** Clicking two inspector rows quickly lets the earlier move settle last and scroll away from the newer row. The mark is right either way; the scroll is not, and a Tabulator expand and scroll is not cancellable. - **Whether the Database grids and the flame chart should move on a merged pick.** Both can. The shared handler makes that an explicit argument rather than an accident, but changing it is a product decision. --- .../__tests__/inspectorLocate.test.ts | 90 +++++++------------ .../components/__tests__/locatedRow.test.ts | 51 +++++++++++ log-viewer/src/components/inspectorLocate.ts | 26 ++---- log-viewer/src/components/locatedRow.ts | 86 +++++++++++++----- .../components/__tests__/AnalysisView.test.ts | 17 ---- 5 files changed, 156 insertions(+), 114 deletions(-) diff --git a/log-viewer/src/components/__tests__/inspectorLocate.test.ts b/log-viewer/src/components/__tests__/inspectorLocate.test.ts index 0154a73ba..a6cbedf64 100644 --- a/log-viewer/src/components/__tests__/inspectorLocate.test.ts +++ b/log-viewer/src/components/__tests__/inspectorLocate.test.ts @@ -7,31 +7,26 @@ import { InspectorEmphasis } from '../inspectorEmphasis.js'; import { inspectorLocateHandler } from '../inspectorLocate.js'; describe('inspectorLocateHandler', () => { - /** A view whose move settles only when the test says so, which is the window a - * later report has to arrive in. */ - function wire(move?: (eventIndex: number) => Promise) { + /** A view that records what it was asked to mark and what to move to. */ + function wire(canMove = true) { const marks: Array = []; const revealed: number[] = []; - let settle: () => void = () => {}; - const emphasis = new InspectorEmphasis(); const handle = inspectorLocateHandler( 'calltree', - emphasis, - (eventIndexes) => marks.push(eventIndexes), - move === undefined + new InspectorEmphasis(), + (eventIndexes) => { + marks.push(eventIndexes); + }, + canMove ? (eventIndex) => { revealed.push(eventIndex); - return new Promise((resolve) => { - settle = resolve; - }); + return Promise.resolve(); } - : move, + : undefined, ); - return { marks, revealed, emphasis, finish: () => settle(), handle }; + return { marks, revealed, handle }; } - const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); - it('leaves a report for another tab alone', () => { const { marks, revealed, handle } = wire(); @@ -50,73 +45,50 @@ describe('inspectorLocateHandler', () => { expect(revealed).toEqual([]); }); - it('moves to a picked row first, then marks', async () => { - const { marks, revealed, finish, handle } = wire(); + it('marks a picked row and asks the view to move to its first occurrence', () => { + const { marks, revealed, handle } = wire(); handle({ source: 'calltree', eventIndexes: [4, 5], sticky: true }); - expect(revealed).toEqual([4]); - // The mark waits: the move re-renders the rows it lands on. - expect(marks).toEqual([]); - - finish(); - await settled(); + // The mark goes on first: a row the move renders lights itself from it. expect(marks).toEqual([[4, 5]]); + expect(revealed).toEqual([4]); }); - it('marks what a report arriving during the move replaced it with', async () => { - const { marks, finish, handle } = wire(); - - handle({ source: 'calltree', eventIndexes: [4], sticky: true }); - // The pointer reaches another row while the move it started is in flight. - handle({ source: 'calltree', eventIndexes: [9], sticky: false }); - finish(); - await settled(); - - // The move re-rendered the rows that hover had marked, so it goes on again. - expect(marks.at(-1)).toEqual([9]); - }); - - it('clears the mark where the pick was dropped during the move', async () => { - const { marks, finish, handle } = wire(); + it('clears the mark when the pick is dropped', () => { + const { marks, revealed, handle } = wire(); handle({ source: 'calltree', eventIndexes: [4], sticky: true }); handle({ source: 'calltree', eventIndexes: [], sticky: true }); - finish(); - await settled(); - - expect(marks.at(-1)).toEqual([]); - }); - - it('clears the mark where the view cleared its own emphasis during the move', async () => { - const { marks, emphasis, finish, handle } = wire(); - - handle({ source: 'calltree', eventIndexes: [4], sticky: true }); - // Escape reaches the view as `selection:clear`, which never passes here. - emphasis.pick([]); - finish(); - await settled(); expect(marks.at(-1)).toEqual([]); + // Nothing to move to, so the view is left where the user put it. + expect(revealed).toEqual([4]); }); - it('marks even where the move fails, since the mark still says where the frames are', async () => { - const { marks, handle } = wire(() => Promise.reject(new Error('no row for it'))); + it('keeps the mark where the view cannot move', async () => { + const marks: Array = []; + const handle = inspectorLocateHandler( + 'calltree', + new InspectorEmphasis(), + (eventIndexes) => { + marks.push(eventIndexes); + }, + () => Promise.reject(new Error('no row for it')), + ); handle({ source: 'calltree', eventIndexes: [4], sticky: true }); - await settled(); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(marks).toEqual([[4]]); }); it('marks a picked row where the view cannot move to one', () => { - const marks: Array = []; - const handle = inspectorLocateHandler('calltree', new InspectorEmphasis(), (ids) => - marks.push(ids), - ); + const { marks, revealed, handle } = wire(false); handle({ source: 'calltree', eventIndexes: [4], sticky: true }); expect(marks).toEqual([[4]]); + expect(revealed).toEqual([]); }); }); diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index 14585218a..c878b8c5d 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -66,6 +66,16 @@ function rowFor(container: HTMLElement, index: number): HTMLElement { return container.children[index] as HTMLElement; } +/** A row entering an already-mounted table, which is what the renderer does with + * one scrolled back into view: in the DOM first, stamped as it initialises. */ +function renderRow(container: HTMLElement, index: number): HTMLElement { + const row = document.createElement('div'); + row.classList.add('tabulator-row'); + container.append(row); + stamp(rowComponent(row, { eventIndex: index })); + return row; +} + describe('stampRowPath', () => { it('marks the row under one parent and not its namesake under another', () => { const ids = new KeyPathIds(0); @@ -152,6 +162,47 @@ describe('LocatedRowMarker', () => { expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); }); + it('lights a row that arrives after the mark, as a scroll back brings one', () => { + const container = host(); + new LocatedRowMarker().mark(container, [4]); + + // The sweep found no rows, so this is the row lighting itself. + expect(renderRow(container, 4).classList.contains(LOCATED_ROW_CLASS)).toBe(true); + expect(renderRow(container, 5).classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + + it('un-lights a row the renderer hands back with the class still on it', () => { + const container = host(); + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); + const row = renderRow(container, 4); + + marker.mark(container, [5]); + // Re-used rather than rebuilt, so it arrives carrying the old mark. + row.classList.add(LOCATED_ROW_CLASS); + stamp(rowComponent(row, { eventIndex: 4 })); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + + it('leaves a row alone where nothing has marked its table', () => { + const row = renderRow(document.createElement('div'), 4); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + + it('stops lighting rows of a table the mark has left', () => { + const first = host(); + const second = host(); + const marker = new LocatedRowMarker(); + + marker.mark(first, [4]); + marker.mark(second, [4]); + + expect(renderRow(first, 4).classList.contains(LOCATED_ROW_CLASS)).toBe(false); + expect(renderRow(second, 4).classList.contains(LOCATED_ROW_CLASS)).toBe(true); + }); + it('leaves a row the table has not rendered alone', () => { const container = host(1); const marker = new LocatedRowMarker(); diff --git a/log-viewer/src/components/inspectorLocate.ts b/log-viewer/src/components/inspectorLocate.ts index e81314f02..92db8458e 100644 --- a/log-viewer/src/components/inspectorLocate.ts +++ b/log-viewer/src/components/inspectorLocate.ts @@ -8,6 +8,9 @@ import type { InspectorEmphasis } from './inspectorEmphasis.js'; * A view's answer to the inspector pointing at frames: mark what the report * names, and where a picked row merges occurrences, move to the first of them. * + * The mark goes on before the move, and the move needs no answer: a row the move + * renders lights itself from the mark the table now holds. + * * @param source - the tab this view is, so a report for another is left alone * @param revealFirstOccurrence - omitted where jumping to one of several merged * occurrences would be arbitrary, which is why the Database grids and the @@ -24,23 +27,12 @@ export function inspectorLocateHandler( if (detail.source !== source) { return; } - const marked = emphasis.report(detail.eventIndexes, detail.sticky); - if (!revealFirstOccurrence || !detail.sticky || !detail.eventIndexes.length) { - mark(marked); - return; + mark(emphasis.report(detail.eventIndexes, detail.sticky)); + if (revealFirstOccurrence && detail.sticky && detail.eventIndexes.length) { + revealFirstOccurrence(detail.eventIndexes[0]!).catch(() => { + // The view could not move, and reports that itself. The mark is already + // on: it says where the frames are, moved to or not. + }); } - // Moving re-renders the rows a mark sits on, so the mark goes on after it. - void (async () => { - try { - await revealFirstOccurrence(detail.eventIndexes[0]!); - } catch { - // The move failed, and the view reports that itself. The mark still has - // to go on: it says where the frames are, moved to or not. - } - // Read again rather than re-applying the report that started the move: a - // report arriving while it ran has already replaced that one, and the - // re-render stripped whatever mark it had put on. - mark(emphasis.current()); - })(); }; } diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index a0bdd7134..ef4dfb482 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -15,6 +15,54 @@ export const LOCATED_ROW_CLASS = 'located-row'; /** Attribute holding a row's index, so the mark can find its element. */ const ROW_INDEX_ATTRIBUTE = 'data-row-index'; +/** Nothing wanted, shared so a cleared table costs no allocation. */ +const NOTHING_WANTED: ReadonlySet = new Set(); + +/** + * The stamped ids each marked table wants lit. + * + * Held per host rather than on the marker so a row can light itself as it enters + * the DOM. The renderer de-initialises a row scrolled out of view and builds it + * again on the way back, which drops the class the sweep put on it, so a mark + * held only as a list of elements is lost the moment the user scrolls. + */ +const wantedByHost = new WeakMap>(); + +/** Applies `wanted` to the rows a table has rendered. */ +function sweep(host: HTMLElement, wanted: ReadonlySet): void { + for (const element of host.querySelectorAll( + `.tabulator-row[${ROW_INDEX_ATTRIBUTE}]`, + )) { + const id = element.getAttribute(ROW_INDEX_ATTRIBUTE)!; + element.classList.toggle(LOCATED_ROW_CLASS, wanted.has(id)); + } +} + +/** + * Stamps what identifies `row` in its table, and lights it where the mark wants + * that id. + * + * The walk up is short, a row sitting a handful of nodes below its table, and it + * is what lets the mark be a property of the table rather than of the elements + * that happened to be rendered when it was set. A row in a table nothing has + * marked is left as it is. + */ +function stamp(row: RowComponent, id: number | string): void { + const element = row.getElement(); + if (!element) { + return; + } + const stamped = String(id); + element.setAttribute(ROW_INDEX_ATTRIBUTE, stamped); + for (let node: HTMLElement | null = element; node; node = node.parentElement) { + const wanted = wantedByHost.get(node); + if (wanted) { + element.classList.toggle(LOCATED_ROW_CLASS, wanted.has(stamped)); + return; + } + } +} + /** * Builds a Tabulator `rowFormatter` that stamps what identifies the row in its * own table: an event index where every row is one frame. @@ -35,7 +83,7 @@ export function rowIndexStamper(indexField: string): (row: RowComponent) => void return (row) => { const index = (row.getData() as Record)[indexField]; if (typeof index === 'number' || typeof index === 'string') { - row.getElement()?.setAttribute(ROW_INDEX_ATTRIBUTE, String(index)); + stamp(row, index); } }; } @@ -106,7 +154,7 @@ export function rowPathId(row: RowComponent): number | undefined { export function stampRowPath(row: RowComponent): void { const id = rowPathId(row); if (id !== undefined) { - row.getElement()?.setAttribute(ROW_INDEX_ATTRIBUTE, String(id)); + stamp(row, id); } } @@ -280,44 +328,40 @@ export class LocatedRowIds { * a mark can land on several rows at once. * * The mark is not a selection: it only styles the row elements, so nothing - * scrolls, expands or re-sorts. Rows the table has not rendered have no element - * to mark, so they are left alone. + * scrolls, expands or re-sorts. It belongs to the table rather than to the rows + * rendered when it was set, so a row scrolled back into view lights itself. * * The table must stamp its rows: {@link rowIndexStamper} where a row is one * frame, {@link stampRowPath} where rows merge occurrences. */ export class LocatedRowMarker { - private elements: HTMLElement[] = []; + private host: HTMLElement | null = null; /** * Move the mark to the rows `ids` name, or drop it with an empty list. Only the - * rendered rows are read, so the cost follows the viewport rather than the + * rendered rows are swept, so the cost follows the viewport rather than the * table; callers still only call this when the target changes. * * @param host - Element the table is mounted in * @param ids - What the table stamps for the rows to mark, empty to clear */ public mark(host: HTMLElement | null, ids: readonly (number | string)[]): void { - this.clear(); - if (!host || !ids.length) { - return; + if (this.host && this.host !== host) { + // A view that switches tables would leave the one it left marked. + wantedByHost.delete(this.host); + sweep(this.host, NOTHING_WANTED); } - const wanted = new Set(ids.map(String)); - for (const element of host.querySelectorAll( - `.tabulator-row[${ROW_INDEX_ATTRIBUTE}]`, - )) { - if (wanted.has(element.getAttribute(ROW_INDEX_ATTRIBUTE)!)) { - element.classList.add(LOCATED_ROW_CLASS); - this.elements.push(element); - } + this.host = host; + if (!host) { + return; } + const wanted = ids.length ? new Set(ids.map(String)) : NOTHING_WANTED; + wantedByHost.set(host, wanted); + sweep(host, wanted); } /** Drop the mark, if one is set. */ public clear(): void { - for (const element of this.elements) { - element.classList.remove(LOCATED_ROW_CLASS); - } - this.elements = []; + this.mark(this.host, []); } } diff --git a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts index 85020ac52..049b56726 100644 --- a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts @@ -36,7 +36,6 @@ import { import { toBottomUpTree, type BottomUpRow } from '../../../call-tree/utils/Aggregation.js'; import { logStoreFor } from '../../../../core/log/LogStore.js'; import { AnalysisView } from '../AnalysisView.js'; -import { LocatedRowMarker } from '../../../../components/locatedRow.js'; const handlers = new Map(); /** The stub table's state, so a reveal's reads can be counted. */ @@ -220,22 +219,6 @@ describe('analysis-view selection', () => { expect(stub.revealed).toEqual([]); }); - it('drops a mark a later report has replaced', async () => { - stub.rows = roots.map((data) => rowComponent(data)); - const marks: Array = []; - const spy = jest.spyOn(LocatedRowMarker.prototype, 'mark').mockImplementation((_host, ids) => { - marks.push(ids); - }); - - eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [2], sticky: true }); - // The pick is dropped while the reveal it started is still in flight. - eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [], sticky: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(marks.at(-1)).toEqual([]); - spy.mockRestore(); - }); - it('clears the inspector when the selection goes', () => { (handlers.get('rowSelectionChanged') as (data: unknown, rows: RowComponent[]) => void)( null, From 5bc9dad9434eb0b21c3b0c91ffcd4cbf1eb42c16 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:37:27 +0100 Subject: [PATCH 05/61] docs(log-viewer): say what the row mark actually missed (#976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview #975 explained the row mark with a mechanism that does not exist: that the renderer de-initialises a row scrolled out of view and rebuilds it, dropping the class. A reader who trusts that comment would expect an ordinary scroll to lose a mark, and would look in the wrong place when the mark misbehaves. What actually happens: `Row.create()` is guarded by `this.created`, `Row.initialize()` deletes cells but re-uses the element, `RowManager.styleRow` adds and removes parity classes rather than assigning `className`, and our renderer only detaches and re-attaches the element. A class on a row element survives scrolling. The gap the declarative mark closes is the **first** render: a row that has never been on screen has no element, so a sweep of what is rendered cannot reach it. ## ๐Ÿ› ๏ธ Changes made - Correct the `wantedByHost` comment to name the real gap: a row below the viewport, or a tree child built after the mark was set. - Rename one test from "as a scroll back brings one" to "as scrolling to a new one does", and correct its helper comment, so the test says which case it guards. ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [x] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A. ## ๐Ÿ”— Related Issues Corrects comments added in #975. ## โœ… Tests added? - [ ] ๐Ÿ‘ yes - [x] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help Comments and one test name. The nine `LocatedRowMarker` tests still pass unchanged. ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [ ] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features - [x] ๐Ÿ™… not needed Nothing user-visible changes. ## Anything else we need to know? [optional] No code changes: 6 insertions and 6 deletions, all inside comments and one `it` title. --- log-viewer/src/components/__tests__/locatedRow.test.ts | 6 +++--- log-viewer/src/components/locatedRow.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index c878b8c5d..8f2195c28 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -66,8 +66,8 @@ function rowFor(container: HTMLElement, index: number): HTMLElement { return container.children[index] as HTMLElement; } -/** A row entering an already-mounted table, which is what the renderer does with - * one scrolled back into view: in the DOM first, stamped as it initialises. */ +/** A row entering an already-mounted table, which is what the renderer does the + * first time one is scrolled to: in the DOM first, stamped as it initialises. */ function renderRow(container: HTMLElement, index: number): HTMLElement { const row = document.createElement('div'); row.classList.add('tabulator-row'); @@ -162,7 +162,7 @@ describe('LocatedRowMarker', () => { expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); }); - it('lights a row that arrives after the mark, as a scroll back brings one', () => { + it('lights a row that arrives after the mark, as scrolling to a new one does', () => { const container = host(); new LocatedRowMarker().mark(container, [4]); diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index ef4dfb482..3cda53eec 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -22,9 +22,9 @@ const NOTHING_WANTED: ReadonlySet = new Set(); * The stamped ids each marked table wants lit. * * Held per host rather than on the marker so a row can light itself as it enters - * the DOM. The renderer de-initialises a row scrolled out of view and builds it - * again on the way back, which drops the class the sweep put on it, so a mark - * held only as a list of elements is lost the moment the user scrolls. + * the DOM. Tabulator builds a row's element on its first render, so a sweep of + * what is rendered cannot reach a row that has never been on screen: one below + * the viewport, or a tree child built after the mark was set. */ const wantedByHost = new WeakMap>(); From 181c74ca9e8427c2a17b1ab910f2ce47e388f4d9 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:15:57 +0100 Subject: [PATCH 06/61] refactor(log-viewer): give a tab one wiring call to the inspector (#977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview Three events reach every tab's view and each view answered only its own, so the same `detail.source === ''` test sat in nine places. Behind that was a bigger repeat: all four views subscribe to the same three events, hold three nullable unsubscribe fields, and undo them one at a time in teardown. `ApexLogTimeline` spent twelve lines on it. The bus gains `onSource(event, source, callback)`, which delivers only what names a tab. On top of that, `wireInspectorTab` names the set of three once and returns one unsubscribe, so a view supplies only what it does differently. Net 90 insertions against 291 deletions. ## ๐Ÿ› ๏ธ Changes made - **`eventBus.onSource`** โ€” a `SourcedEvent` type admits only the payloads that name a tab, so the source test lives on the bus. No cast is needed: `EventMap[K]` already resolves to the union of sourced payloads. - **`wireInspectorTab(source, emphasis, sync)`** โ€” one call per view, one unsubscribe. A view gives `mark`, `reveal`, `clear`, and `movesToMergedPick` where a picked row that merges occurrences should also move. - **One `reveal` for both kinds of pick.** The Call Tree and Analysis views were passing the same method twice, once for a single frame and once for the first of several. `movesToMergedPick` now says *when* it moves, and the helper catches a rejection, so no view needs `void` on a promise. - **`inspectorLocate.ts` is gone**, absorbed into `inspectorTab.ts`. Its doc no longer has to ask the caller to subscribe it a particular way, since the module does the subscribing. - **Twelve unsubscribe fields become four**, and four teardown blocks lose about thirty lines between them. ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A. Nothing changes on screen. ## ๐Ÿ”— Related Issues Follows #974, #975 and #976, which built the inspector mark this wiring carries. ## โœ… Tests added? - [x] ๐Ÿ‘ yes - [ ] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help Nine `wireInspectorTab` cases driven through the real bus, and three for `onSource`. Three guards were proven by reverting the code they cover: returning only the first unsubscribe, dropping the `movesToMergedPick` gate, and swapping mark with move each fail a test. The last needed an ordered log, since two separate lists cannot show order; the ordering had no test before this. ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [ ] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features - [x] ๐Ÿ™… not needed Nothing user-visible changes. ## Anything else we need to know? [optional] **Where to start.** `log-viewer/src/components/inspectorTab.ts` is the whole mechanism, then any one view to see what a caller now looks like. **What was deliberately left.** `SourcedEvent` also admits `detail:select`, `detail:view` and `detail:locate`. Those must **not** be filtered by source: the inspector records every tab's selection, so filtering would lose the tab it is not showing. The type's doc says so, since the constraint belongs to the caller rather than the type. **Test plan.** - \`pnpm lint\` and \`pnpm test\`. - In each of the four tabs, with the Inspector open: hover an inspector row and the tab marks; pick one and the Call Tree and Analysis grids also move, while the Database grids and the flame chart only mark. - Pick a row, then \`Escape\`: the tab's own selection and the mark both go. - Switch tabs with a mark set: the tab you left stops marking, and the one you arrive at answers. - Both themes, Inspector docked at the side and at the bottom. **Known unrelated failure locally.** \`lana/src/services/__tests__/servicesRuntime.test.ts\` cannot resolve \`effect\` in a worktree not installed since #951. No \`lana\` file is touched here, and it passes in CI. **Follow-ups, not in this PR.** - **Two subscriptions still carry no source.** \`DatabaseTimeTree.ts:205\` and \`:210\` answer \`detail:locate\` and \`selection:clear\` from every tab, so a Timeline hover marks rows in the Database tab's time tree and Escape anywhere drops that table's pick. \`onSource\` makes the fix one word, but narrowing it changes behaviour, so it wants its own change. - **All four views subscribe in the constructor and release in \`disconnectedCallback\`**, so a Lit element that is detached and re-attached comes back with dead subscriptions. Pre-existing, and unchanged here. - **A stale move is not abandoned.** Clicking two inspector rows quickly lets the earlier move settle last and scroll away from the newer row. --- .../__tests__/inspectorLocate.test.ts | 94 ------------ .../components/__tests__/inspectorTab.test.ts | 137 ++++++++++++++++++ log-viewer/src/components/inspectorLocate.ts | 38 ----- log-viewer/src/components/inspectorTab.ts | 73 ++++++++++ log-viewer/src/core/events/EventBus.ts | 28 ++++ .../core/events/__tests__/EventBus.test.ts | 50 +++++++ .../analysis/components/AnalysisView.ts | 53 ++----- .../call-tree/components/CalltreeView.ts | 51 ++----- .../database/components/DatabaseView.ts | 65 +++------ .../timeline/optimised/ApexLogTimeline.ts | 52 ++----- 10 files changed, 350 insertions(+), 291 deletions(-) delete mode 100644 log-viewer/src/components/__tests__/inspectorLocate.test.ts create mode 100644 log-viewer/src/components/__tests__/inspectorTab.test.ts delete mode 100644 log-viewer/src/components/inspectorLocate.ts create mode 100644 log-viewer/src/components/inspectorTab.ts create mode 100644 log-viewer/src/core/events/__tests__/EventBus.test.ts diff --git a/log-viewer/src/components/__tests__/inspectorLocate.test.ts b/log-viewer/src/components/__tests__/inspectorLocate.test.ts deleted file mode 100644 index a6cbedf64..000000000 --- a/log-viewer/src/components/__tests__/inspectorLocate.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ -import { describe, expect, it } from '@jest/globals'; - -import { InspectorEmphasis } from '../inspectorEmphasis.js'; -import { inspectorLocateHandler } from '../inspectorLocate.js'; - -describe('inspectorLocateHandler', () => { - /** A view that records what it was asked to mark and what to move to. */ - function wire(canMove = true) { - const marks: Array = []; - const revealed: number[] = []; - const handle = inspectorLocateHandler( - 'calltree', - new InspectorEmphasis(), - (eventIndexes) => { - marks.push(eventIndexes); - }, - canMove - ? (eventIndex) => { - revealed.push(eventIndex); - return Promise.resolve(); - } - : undefined, - ); - return { marks, revealed, handle }; - } - - it('leaves a report for another tab alone', () => { - const { marks, revealed, handle } = wire(); - - handle({ source: 'analysis', eventIndexes: [4], sticky: true }); - - expect(marks).toEqual([]); - expect(revealed).toEqual([]); - }); - - it('marks under the pointer without moving the view', () => { - const { marks, revealed, handle } = wire(); - - handle({ source: 'calltree', eventIndexes: [4, 5], sticky: false }); - - expect(marks).toEqual([[4, 5]]); - expect(revealed).toEqual([]); - }); - - it('marks a picked row and asks the view to move to its first occurrence', () => { - const { marks, revealed, handle } = wire(); - - handle({ source: 'calltree', eventIndexes: [4, 5], sticky: true }); - - // The mark goes on first: a row the move renders lights itself from it. - expect(marks).toEqual([[4, 5]]); - expect(revealed).toEqual([4]); - }); - - it('clears the mark when the pick is dropped', () => { - const { marks, revealed, handle } = wire(); - - handle({ source: 'calltree', eventIndexes: [4], sticky: true }); - handle({ source: 'calltree', eventIndexes: [], sticky: true }); - - expect(marks.at(-1)).toEqual([]); - // Nothing to move to, so the view is left where the user put it. - expect(revealed).toEqual([4]); - }); - - it('keeps the mark where the view cannot move', async () => { - const marks: Array = []; - const handle = inspectorLocateHandler( - 'calltree', - new InspectorEmphasis(), - (eventIndexes) => { - marks.push(eventIndexes); - }, - () => Promise.reject(new Error('no row for it')), - ); - - handle({ source: 'calltree', eventIndexes: [4], sticky: true }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(marks).toEqual([[4]]); - }); - - it('marks a picked row where the view cannot move to one', () => { - const { marks, revealed, handle } = wire(false); - - handle({ source: 'calltree', eventIndexes: [4], sticky: true }); - - expect(marks).toEqual([[4]]); - expect(revealed).toEqual([]); - }); -}); diff --git a/log-viewer/src/components/__tests__/inspectorTab.test.ts b/log-viewer/src/components/__tests__/inspectorTab.test.ts new file mode 100644 index 000000000..404d823ae --- /dev/null +++ b/log-viewer/src/components/__tests__/inspectorTab.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { afterEach, describe, expect, it } from '@jest/globals'; + +import { eventBus } from '../../core/events/EventBus.js'; +import { InspectorEmphasis } from '../inspectorEmphasis.js'; +import { wireInspectorTab } from '../inspectorTab.js'; + +describe('wireInspectorTab', () => { + let off: (() => void) | null = null; + + afterEach(() => { + off?.(); + off = null; + }); + + /** A Call Tree-like view: it records what it marked, moved to and cleared. */ + function wire(movesToMergedPick = true, reveal?: (eventIndex: number) => Promise) { + const marks: Array = []; + const revealed: number[] = []; + /** Marks and moves in the order they arrived, which the two lists cannot show. */ + const order: string[] = []; + let clears = 0; + off = wireInspectorTab('calltree', new InspectorEmphasis(), { + mark: (eventIndexes) => { + marks.push(eventIndexes); + order.push('mark'); + }, + reveal: (eventIndex) => { + order.push('move'); + if (reveal) { + return reveal(eventIndex); + } + revealed.push(eventIndex); + }, + clear: () => { + clears++; + }, + movesToMergedPick, + }); + return { marks, revealed, order, clears: () => clears }; + } + + it('leaves an event for another tab alone', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [4], sticky: true }); + eventBus.emit('inspector:reveal', { source: 'analysis', eventIndex: 4 }); + eventBus.emit('selection:clear', { source: 'analysis' }); + + expect(view.marks).toEqual([]); + expect(view.revealed).toEqual([]); + expect(view.clears()).toBe(0); + }); + + it('marks under the pointer without moving the view', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4, 5], sticky: false }); + + expect(view.marks).toEqual([[4, 5]]); + expect(view.revealed).toEqual([]); + }); + + it('marks a picked row and moves to the first of its occurrences', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4, 5], sticky: true }); + + expect(view.marks).toEqual([[4, 5]]); + expect(view.revealed).toEqual([4]); + // The mark goes on first: a row the move renders lights itself from it. + expect(view.order).toEqual(['mark', 'move']); + }); + + it('only marks a picked row where moving to one occurrence would be arbitrary', () => { + const view = wire(false); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4, 5], sticky: true }); + + expect(view.marks).toEqual([[4, 5]]); + expect(view.revealed).toEqual([]); + }); + + it('moves to the one frame a single-frame pick names, wherever the mark is', () => { + const view = wire(false); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 7 }); + + expect(view.revealed).toEqual([7]); + }); + + it('clears the mark when the pick is dropped', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [], sticky: true }); + + expect(view.marks.at(-1)).toEqual([]); + // Nothing to move to, so the view is left where the user put it. + expect(view.revealed).toEqual([4]); + }); + + it('drops the view selection and the mark on an app-wide clear', () => { + const view = wire(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + eventBus.emit('selection:clear', { source: 'calltree' }); + + expect(view.clears()).toBe(1); + expect(view.marks.at(-1)).toEqual([]); + }); + + it('keeps the mark where the view cannot move', async () => { + const view = wire(true, () => Promise.reject(new Error('no row for it'))); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(view.marks).toEqual([[4]]); + }); + + it('stops answering once unsubscribed', () => { + const view = wire(); + + off?.(); + off = null; + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: true }); + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + eventBus.emit('selection:clear', { source: 'calltree' }); + + expect(view.marks).toEqual([]); + expect(view.revealed).toEqual([]); + expect(view.clears()).toBe(0); + }); +}); diff --git a/log-viewer/src/components/inspectorLocate.ts b/log-viewer/src/components/inspectorLocate.ts deleted file mode 100644 index 92db8458e..000000000 --- a/log-viewer/src/components/inspectorLocate.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ -import type { DetailSource, EventDetail } from '../core/events/EventBus.js'; -import type { InspectorEmphasis } from './inspectorEmphasis.js'; - -/** - * A view's answer to the inspector pointing at frames: mark what the report - * names, and where a picked row merges occurrences, move to the first of them. - * - * The mark goes on before the move, and the move needs no answer: a row the move - * renders lights itself from the mark the table now holds. - * - * @param source - the tab this view is, so a report for another is left alone - * @param revealFirstOccurrence - omitted where jumping to one of several merged - * occurrences would be arbitrary, which is why the Database grids and the - * flame chart only mark. A pick of a single frame arrives as - * `inspector:reveal` instead, and every view answers that. - */ -export function inspectorLocateHandler( - source: DetailSource, - emphasis: InspectorEmphasis, - mark: (eventIndexes: readonly number[]) => void, - revealFirstOccurrence?: (eventIndex: number) => Promise, -): (detail: EventDetail<'inspector:locate'>) => void { - return (detail) => { - if (detail.source !== source) { - return; - } - mark(emphasis.report(detail.eventIndexes, detail.sticky)); - if (revealFirstOccurrence && detail.sticky && detail.eventIndexes.length) { - revealFirstOccurrence(detail.eventIndexes[0]!).catch(() => { - // The view could not move, and reports that itself. The mark is already - // on: it says where the frames are, moved to or not. - }); - } - }; -} diff --git a/log-viewer/src/components/inspectorTab.ts b/log-viewer/src/components/inspectorTab.ts new file mode 100644 index 000000000..253fc91cc --- /dev/null +++ b/log-viewer/src/components/inspectorTab.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { type DetailSource, eventBus } from '../core/events/EventBus.js'; +import type { InspectorEmphasis } from './inspectorEmphasis.js'; + +/** What a tab's view does when the inspector points into it. */ +export interface InspectorTabSync { + /** Light the frames the inspector names, in whatever the view's rows are. */ + mark: (eventIndexes: readonly number[]) => void; + + /** + * Move to one frame. A rejection is the view's own to report: it says the view + * cannot reach the frame, and the mark still says where the frame is. + */ + reveal: (eventIndex: number) => void | Promise; + + /** Drop the view's own selection, for the app-wide Escape. */ + clear: () => void; + + /** + * True where a picked row that merges occurrences also moves, to the first of + * them. Omitted where choosing one of several would be arbitrary, which is why + * the Database grids and the flame chart only mark. + */ + movesToMergedPick?: boolean; +} + +/** + * Subscribes a tab's view to the inspector, and returns the one unsubscribe. + * + * All four tabs answer the same three events for their own source, so this is + * where that set is named. A view supplies only what it does differently. + * + * The mark goes on before any move, and the move needs no answer: a row the move + * renders lights itself from the mark the table now holds. + */ +export function wireInspectorTab( + source: DetailSource, + emphasis: InspectorEmphasis, + sync: InspectorTabSync, +): () => void { + const move = (eventIndex: number): void => { + // The view reports its own failure; the mark stands either way. + void Promise.resolve(sync.reveal(eventIndex)).catch(() => {}); + }; + + const offs = [ + eventBus.onSource('inspector:reveal', source, (detail) => { + move(detail.eventIndex); + }), + + eventBus.onSource('inspector:locate', source, (detail) => { + sync.mark(emphasis.report(detail.eventIndexes, detail.sticky)); + if (sync.movesToMergedPick && detail.sticky && detail.eventIndexes.length) { + move(detail.eventIndexes[0]!); + } + }), + + // A picked inspector row is no selection of the view's own, so the mark is + // dropped here rather than by the view reporting its clear. + eventBus.onSource('selection:clear', source, () => { + sync.clear(); + sync.mark(emphasis.pick([])); + }), + ]; + + return () => { + for (const off of offs) { + off(); + } + }; +} diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index 54ec4ee6d..2bd3ff4fe 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -113,6 +113,17 @@ interface EventMap { * one it names itself. The map stays where each payload is described. */ export type EventDetail = EventMap[K]; +/** + * The events that name the tab they are for. + * + * Naming a tab is not the same as being for one tab only: the inspector records + * every tab's `detail:select` and `detail:view`, so filtering those by source + * would lose the tab it is not showing. `onSource` is for a tab's own view. + */ +type SourcedEvent = { + [K in keyof EventMap]: EventMap[K] extends { source: DetailSource } ? K : never; +}[keyof EventMap]; + type EventCallback = (detail: EventMap[K]) => void; class EventBusImpl { @@ -130,6 +141,23 @@ class EventBusImpl { }; } + /** + * Subscribes to an event only where it names `source`: the whole contract of a + * view that answers for one tab. Every such event reaches every view, so the + * filter belongs to the bus rather than to each view. + */ + onSource( + event: K, + source: DetailSource, + callback: EventCallback, + ): () => void { + return this.on(event, (detail) => { + if (detail.source === source) { + callback(detail); + } + }); + } + emit(event: K, detail: EventMap[K]): void { this.listeners.get(event)?.forEach((callback) => callback(detail)); } diff --git a/log-viewer/src/core/events/__tests__/EventBus.test.ts b/log-viewer/src/core/events/__tests__/EventBus.test.ts new file mode 100644 index 000000000..88d1c4800 --- /dev/null +++ b/log-viewer/src/core/events/__tests__/EventBus.test.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { eventBus } from '../EventBus.js'; + +describe('eventBus.onSource', () => { + it('hands over an event that names the source, and nothing else', () => { + const seen: number[] = []; + const off = eventBus.onSource('inspector:reveal', 'calltree', (detail) => { + seen.push(detail.eventIndex); + }); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + eventBus.emit('inspector:reveal', { source: 'analysis', eventIndex: 5 }); + + off(); + expect(seen).toEqual([4]); + }); + + it('gives one event to the tab that names it and to no other', () => { + const seen: string[] = []; + const offTimeline = eventBus.onSource('inspector:locate', 'timeline', () => { + seen.push('timeline'); + }); + const offCalltree = eventBus.onSource('inspector:locate', 'calltree', () => { + seen.push('calltree'); + }); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [4], sticky: false }); + + offTimeline(); + offCalltree(); + expect(seen).toEqual(['calltree']); + }); + + it('stops on unsubscribe', () => { + let count = 0; + const off = eventBus.onSource('selection:clear', 'database', () => { + count++; + }); + + eventBus.emit('selection:clear', { source: 'database' }); + off(); + eventBus.emit('selection:clear', { source: 'database' }); + + expect(count).toBe(1); + }); +}); diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 27bcda9b0..c471534dc 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -22,7 +22,7 @@ import { rowOccurrences, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; +import { wireInspectorTab } from '../../../components/inspectorTab.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; @@ -155,12 +155,10 @@ export class AnalysisView extends LitElement { /** Releases the category-colouring settings subscription; set while connected. */ private _categoryColoringOff: (() => void) | null = null; - private _selectionClearUnsubscribe: (() => void) | null = null; /** Guards the programmatic select made on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); - private _inspectorRevealUnsubscribe: (() => void) | null = null; - private _inspectorLocateUnsubscribe: (() => void) | null = null; + private _inspectorUnsubscribe: (() => void) | null = null; private _locatedRow = new LocatedRowMarker(); private _locateIds = new LocatedRowIds(); private _emphasis = new InspectorEmphasis(); @@ -168,37 +166,22 @@ export class AnalysisView extends LitElement { constructor() { super(); - // An inspector finding names one event; the grid holds it in the bucket for - // its method, so that bucket is what gets revealed. - this._inspectorRevealUnsubscribe = eventBus.on('inspector:reveal', (detail) => { - if (detail.source === 'analysis') { - void this._revealEventIndex(detail.eventIndex); - } + this._inspectorUnsubscribe = wireInspectorTab('analysis', this._emphasis, { + // A row is a method bucket rather than one event, so a frame is translated + // into the paths of the rows it heads. + mark: (eventIndexes) => this._markLocated(eventIndexes), + // An inspector finding names one event; the grid holds it in the bucket for + // its method, so that bucket is what gets revealed. + reveal: (eventIndex) => this._revealEventIndex(eventIndex), + clear: () => { + // The table reports the clear itself, which is what reaches the inspector. + this.analysisTable?.deselectRow(); + }, + movesToMergedPick: true, }); - // Mark the buckets the inspector points at. A row is a method bucket rather - // than one event, so a frame is translated into the paths of the rows it heads. - this._inspectorLocateUnsubscribe = eventBus.on( - 'inspector:locate', - inspectorLocateHandler( - 'analysis', - this._emphasis, - (eventIndexes) => this._markLocated(eventIndexes), - (eventIndex) => this._revealEventIndex(eventIndex), - ), - ); document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-match', this._findEvt); document.addEventListener('lv-find-close', this._findEvt); - - // Escape (app-wide) deselects here; the table reports the clear itself. It - // also drops a mark held by a picked inspector row, which is no selection of - // this table's own. - this._selectionClearUnsubscribe = eventBus.on('selection:clear', (detail) => { - if (detail.source === 'analysis') { - this.analysisTable?.deselectRow(); - this._markLocated(this._emphasis.pick([])); - } - }); } override connectedCallback(): void { @@ -213,12 +196,8 @@ export class AnalysisView extends LitElement { document.removeEventListener('lv-find', this._findEvt); document.removeEventListener('lv-find-match', this._findEvt); document.removeEventListener('lv-find-close', this._findEvt); - this._selectionClearUnsubscribe?.(); - this._selectionClearUnsubscribe = null; - this._inspectorRevealUnsubscribe?.(); - this._inspectorRevealUnsubscribe = null; - this._inspectorLocateUnsubscribe?.(); - this._inspectorLocateUnsubscribe = null; + this._inspectorUnsubscribe?.(); + this._inspectorUnsubscribe = null; this._locatedRow.clear(); } diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 0f735e7fa..c05e568b2 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -72,7 +72,7 @@ import { rowOccurrences, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; +import { wireInspectorTab } from '../../../components/inspectorTab.js'; import { createTimeOrderTable } from './TimeOrderTable.js'; /** Time Order keys its rows by event index; the grouped views key theirs by the @@ -168,9 +168,7 @@ export class CalltreeView extends LitElement { /** Guards the programmatic select made on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); - private _inspectorRevealUnsubscribe: (() => void) | null = null; - private _inspectorLocateUnsubscribe: (() => void) | null = null; - private _selectionClearUnsubscribe: (() => void) | null = null; + private _inspectorUnsubscribe: (() => void) | null = null; private _locatedRow = new LocatedRowMarker(); private _locateIds = new LocatedRowIds(); /** Which of the inspector's reports the mark follows. */ @@ -179,37 +177,18 @@ export class CalltreeView extends LitElement { constructor() { super(); - // Reveal an inspector row here, but only while the Call Tree is the tab the - // inspector is showing. - this._inspectorRevealUnsubscribe = eventBus.on('inspector:reveal', (detail) => { - if (detail.source === 'calltree') { - void this._revealEventIndex(detail.eventIndex); - } - }); - - // Mark the frames the inspector points at, while the Call Tree is the tab the - // inspector is showing. A picked row merges calls, so the mark shows all of - // them while the view moves to the first, as a pick of one frame does. - this._inspectorLocateUnsubscribe = eventBus.on( - 'inspector:locate', - inspectorLocateHandler( - 'calltree', - this._emphasis, - (eventIndexes) => this._markLocated(eventIndexes), - (eventIndex) => this._revealEventIndex(eventIndex), - ), - ); - - // Escape (app-wide) deselects here; the table reports the clear itself. It - // also drops a mark held by a picked inspector row, which is no selection of - // this table's own. - this._selectionClearUnsubscribe = eventBus.on('selection:clear', (detail) => { - if (detail.source === 'calltree') { + this._inspectorUnsubscribe = wireInspectorTab('calltree', this._emphasis, { + mark: (eventIndexes) => this._markLocated(eventIndexes), + reveal: (eventIndex) => this._revealEventIndex(eventIndex), + clear: () => { + // The table reports the clear itself, which is what reaches the inspector. for (const table of this._tables) { table.deselectRow(); } - this._markLocated(this._emphasis.pick([])); - } + }, + // A picked row merges calls, so the mark shows all of them while the view + // moves to the first, as a pick of one frame does. + movesToMergedPick: true, }); document.addEventListener(CALLTREE_GO_TO_ROW, this._goToRowEvt); document.addEventListener('lv-find', this._findEvt); @@ -230,12 +209,8 @@ export class CalltreeView extends LitElement { document.removeEventListener('lv-find', this._findEvt); document.removeEventListener('lv-find-match', this._findEvt); document.removeEventListener('lv-find-close', this._findEvt); - this._inspectorRevealUnsubscribe?.(); - this._inspectorRevealUnsubscribe = null; - this._inspectorLocateUnsubscribe?.(); - this._inspectorLocateUnsubscribe = null; - this._selectionClearUnsubscribe?.(); - this._selectionClearUnsubscribe = null; + this._inspectorUnsubscribe?.(); + this._inspectorUnsubscribe = null; this._destroyCurrentTable(); } diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index 268a799f7..8b73f8ae8 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -17,7 +17,7 @@ import { limitTotals } from '../../../components/logOverviewMetrics.js'; import { eventBus, type StatementType } from '../../../core/events/EventBus.js'; import { apexLimitTimeSeries } from '../../timeline/optimised/apex-limit-series.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; +import { wireInspectorTab } from '../../../components/inspectorTab.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { isVisible } from '../../../core/utility/Util.js'; import { soslRowsMetric } from '../limits.js'; @@ -85,9 +85,7 @@ export class DatabaseView extends LitElement { }; findMap = {}; - private _offInspectorReveal: (() => void) | null = null; - private _offInspectorLocate: (() => void) | null = null; - private _offSelectionClear: (() => void) | null = null; + private _offInspector: (() => void) | null = null; /** Guards the selects this view makes on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); @@ -101,41 +99,24 @@ export class DatabaseView extends LitElement { document.addEventListener('lv-find-match', this._findHandler as EventListener); document.addEventListener('lv-find', this._findHandler as EventListener); - // Reveal an inspector row here, but only while the Database tab is the tab - // the inspector is showing. The eventIndex belongs to exactly one grid, so - // each is offered it in turn until one owns it. - this._offInspectorReveal = eventBus.on('inspector:reveal', (d) => { - if (d.source !== 'database') { - return; - } - const views = this._views; - this._echoGuard.run(() => { - const owner = views.find((view) => view?.selectByEventIndex(d.eventIndex)); - if (owner) { - views.filter((view) => view !== owner).forEach((view) => view?.deselectRows()); - } - }); - }); - - // Mark the statements the inspector points at, while the Database tab is the - // tab the inspector is showing. The eventIndex belongs to one grid, so the - // others simply find nothing to mark. - this._offInspectorLocate = eventBus.on( - 'inspector:locate', - inspectorLocateHandler('database', this._emphasis, (eventIndexes) => - this._markLocated(eventIndexes), - ), - ); - - // Escape (app-wide) deselects here. Only one grid holds the selection, and - // its report of the clear reaches the inspector the same way a click does. It - // also drops a mark held by a picked inspector row, which is no selection of - // any grid's own. - this._offSelectionClear = eventBus.on('selection:clear', (d) => { - if (d.source === 'database') { + this._offInspector = wireInspectorTab('database', this._emphasis, { + mark: (eventIndexes) => this._markLocated(eventIndexes), + // The eventIndex belongs to exactly one grid, so each is offered it in turn + // until one owns it. + reveal: (eventIndex) => { + const views = this._views; + this._echoGuard.run(() => { + const owner = views.find((view) => view?.selectByEventIndex(eventIndex)); + if (owner) { + views.filter((view) => view !== owner).forEach((view) => view?.deselectRows()); + } + }); + }, + clear: () => { + // Only one grid holds the selection, and its report of the clear reaches + // the inspector the same way a click does. this._views.forEach((view) => view?.deselectRows()); - this._markLocated(this._emphasis.pick([])); - } + }, }); } @@ -180,12 +161,8 @@ export class DatabaseView extends LitElement { document.removeEventListener('db-find-results', this._findResults as EventListener); document.removeEventListener('lv-find-match', this._findHandler as EventListener); document.removeEventListener('lv-find', this._findHandler as EventListener); - this._offInspectorReveal?.(); - this._offInspectorReveal = null; - this._offInspectorLocate?.(); - this._offInspectorLocate = null; - this._offSelectionClear?.(); - this._offSelectionClear = null; + this._offInspector?.(); + this._offInspector = null; } firstUpdated(): void { diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index 69327d10c..b4c62abf8 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -41,7 +41,7 @@ import { } from '../types/flamechart.types.js'; import type { SearchCursor } from '../types/search.types.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { inspectorLocateHandler } from '../../../components/inspectorLocate.js'; +import { wireInspectorTab } from '../../../components/inspectorTab.js'; import { isFrameOffscreen, toDetailSelection } from '../utils/detail-selection-sync.js'; import { extractExceptionMarkers, extractMarkers } from '../utils/marker-utils.js'; import { seekWindow } from '../utils/navigate-window.js'; @@ -70,9 +70,7 @@ export class ApexLogTimeline { private selectedEventForContextMenu: EventNode | null = null; private selectedMarkerForContextMenu: TimelineMarker | null = null; private eventBusUnsubscribe: (() => void) | null = null; - private inspectorRevealUnsubscribe: (() => void) | null = null; - private inspectorLocateUnsubscribe: (() => void) | null = null; - private selectionClearUnsubscribe: (() => void) | null = null; + private inspectorUnsubscribe: (() => void) | null = null; /** Guards the programmatic select made on the inspector's behalf. */ private echoGuard = new SelectionEchoGuard(); /** Frame last reported to the inspector as under the pointer. */ @@ -217,32 +215,14 @@ export class ApexLogTimeline { } }); - // Reveal an inspector row in the flame chart, but only while the timeline is - // the tab the inspector is showing. - this.inspectorRevealUnsubscribe = eventBus.on('inspector:reveal', (detail) => { - if (detail.source === 'timeline') { - this.selectFrameByEventIndex(detail.eventIndex); - } - }); - - // Dim the chart around the frames the inspector points at, while the timeline - // is the tab the inspector is showing. - this.inspectorLocateUnsubscribe = eventBus.on( - 'inspector:locate', - inspectorLocateHandler('timeline', this.emphasis, (eventIndexes) => - this.applyEmphasis(eventIndexes), - ), - ); - - // Escape (app-wide) deselects here; the chart reports the clear itself. - // The flame chart's own Escape (container focused) consumes the key first. - this.selectionClearUnsubscribe = eventBus.on('selection:clear', (detail) => { - if (detail.source === 'timeline') { + this.inspectorUnsubscribe = wireInspectorTab('timeline', this.emphasis, { + mark: (eventIndexes) => this.applyEmphasis(eventIndexes), + reveal: (eventIndex) => this.selectFrameByEventIndex(eventIndex), + clear: () => { + // The chart reports the clear itself. Its own Escape, with the container + // focused, consumes the key before this. this.flamechart.clearSelection(); - // A picked inspector row is no selection of the chart, and the clear - // above is silent when there was nothing selected, so drop the dim here. - this.pickEmphasis(undefined); - } + }, }); } @@ -400,17 +380,9 @@ export class ApexLogTimeline { this.eventBusUnsubscribe(); this.eventBusUnsubscribe = null; } - if (this.inspectorRevealUnsubscribe) { - this.inspectorRevealUnsubscribe(); - this.inspectorRevealUnsubscribe = null; - } - if (this.inspectorLocateUnsubscribe) { - this.inspectorLocateUnsubscribe(); - this.inspectorLocateUnsubscribe = null; - } - if (this.selectionClearUnsubscribe) { - this.selectionClearUnsubscribe(); - this.selectionClearUnsubscribe = null; + if (this.inspectorUnsubscribe) { + this.inspectorUnsubscribe(); + this.inspectorUnsubscribe = null; } this.flamechart.destroy(); From 7ca30f3d82b7193cd9231e59168458fcc4b3577a Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:18:01 +0100 Subject: [PATCH 07/61] fix(log-viewer): span the timeline across the whole log (#978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a log the size cap cut off, the header said 27.1s and the chart drew 10.8s. Three fixes follow from that one gap. ### Timeline length The chart's width came from the last frame the log recorded, not the log's own end. It now spans the whole log, so the truncation marker shades the part the log never recorded. ### Hot spots The log is a container, not code, but it holds unrecorded time as self time โ€” 16.3s on this log โ€” which put it top of the Inspector's hot spots and of the Analysis findings. It is now left out of both, as it already was everywhere else. ### Governor limits strip Where a log records nothing โ€” the size cap, or skipped lines โ€” the strip carried its last reading across the gap as though it had been measured. The gaps come from the log's own skip markers and ride on the metric series, so the strip has one source for them. The area fills, the over-100% band and the collapsed traffic light leave a gap blank, the step line holds its last level, and the tooltip names the reason and the range, such as `Max-Size-reached ยท 10.8s โ†’ 27.1s`. The strip's marker bands now take the layout the chart and the minimap share, so a point marker keeps a visible hairline and shading ends with its own marker instead of running on to the next one. ### Testing `pnpm lint` and `pnpm test` pass. New tests cover the range the conversion reports, the log's exclusion from hot spots, gap derivation from markers, the containment rule, and the area fill breaking at a gap rather than ramping across it. Closes #828 --- CHANGELOG.md | 3 + .../analysis/services/LogDiagnostics.ts | 4 + .../call-tree/utils/ExecutionHighlights.ts | 12 +- .../__tests__/ExecutionHighlights.test.ts | 28 +++- .../timeline/__tests__/marker-utils.test.ts | 44 +++++- .../timeline/__tests__/tree-converter.test.ts | 52 +++++++ .../timeline/optimised/ApexLogTimeline.ts | 9 +- .../timeline/optimised/TimelineEventIndex.ts | 3 + .../__tests__/MarkerProcessor.test.ts | 25 +++ .../optimised/markers/MarkerProcessor.ts | 26 +++- .../metric-strip/MetricStripOrchestrator.ts | 8 +- .../metric-strip/MetricStripRenderer.test.ts | 87 +++++++++++ .../metric-strip/MetricStripRenderer.ts | 146 ++++++++++++++---- .../MetricStripTooltipRenderer.test.ts | 51 ++++++ .../MetricStripTooltipRenderer.ts | 55 ++++++- .../metric-strip/MetricTierClassifier.ts | 2 + .../timeline/types/flamechart.types.ts | 12 ++ .../features/timeline/utils/marker-utils.ts | 30 +++- .../features/timeline/utils/tree-converter.ts | 6 +- 19 files changed, 551 insertions(+), 52 deletions(-) create mode 100644 log-viewer/src/features/timeline/__tests__/tree-converter.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d62b2b3a4..8b757dbf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐Ÿ› **Go to Code**: Match methods with namespace/`System`-qualified parameter types. ([#834]) - ๐Ÿ“ **Timeline height**: the Flame Chart stopped short of the bottom of its panel, leaving a strip of empty space; it now fills the panel and follows the Inspector as you resize or re-dock it. - ๐Ÿ—„๏ธ **Flow database usage**: SOQL and DML run by a Flow or Process Builder element went uncounted, because the log never reports it as a statement; the element's own usage is now counted and rolls up like any other. Needs `WORKFLOW` at `FINER` or above. ([#871]) +- ๐Ÿ“ **Timeline length**: the chart stopped at the last frame the log recorded, so it drew shorter than the log's own duration โ€” 10.8s of a 27.1s log where the size cap cut the log off. The chart now spans the whole log, and the truncation marker shades the part the log never recorded. ([#828]) +- ๐Ÿงญ **Hot spots**: the log itself topped the Inspector's hot spots, and the Analysis findings, whenever time went unrecorded โ€” the gap between frames lands on the log, which is a container and not code. It is now left out of both. +- ๐Ÿ“Š **Governor limits strip**: where a log records nothing โ€” it hit the maximum size, or lines were skipped โ€” the strip drew its last reading across the gap as though it had been measured. The area fills, the over-100% band and the collapsed traffic light now leave the gap blank, the step line holds its last level, and the tooltip names the reason and the range, such as `Max-Size-reached ยท 10.8s โ†’ 27.1s`. Truncation shading also ends with its marker instead of running on to the next one. ([#828]) ## [1.20.1] 2026-07-23 diff --git a/log-viewer/src/features/analysis/services/LogDiagnostics.ts b/log-viewer/src/features/analysis/services/LogDiagnostics.ts index cba273fe1..e30788a52 100644 --- a/log-viewer/src/features/analysis/services/LogDiagnostics.ts +++ b/log-viewer/src/features/analysis/services/LogDiagnostics.ts @@ -881,6 +881,10 @@ async function analyse(log: ApexLog): Promise { const selfTime = new Map(); let totalSelf = 0; for (const event of log.eventsById) { + // The log itself holds the gap time, and no call stands for it. + if (event === log) { + continue; + } switch (event.type) { case 'SOQL_EXECUTE_BEGIN': queries.push(event as SOQLExecuteBeginLine); diff --git a/log-viewer/src/features/call-tree/utils/ExecutionHighlights.ts b/log-viewer/src/features/call-tree/utils/ExecutionHighlights.ts index 6e459123f..749513665 100644 --- a/log-viewer/src/features/call-tree/utils/ExecutionHighlights.ts +++ b/log-viewer/src/features/call-tree/utils/ExecutionHighlights.ts @@ -88,7 +88,7 @@ export function computeExecutionHighlights(apexLog: ApexLog): ExecutionHighlight return { totalTime: apexLog.duration.total, ...computeHotPath(apexLog.children), - ...scanEvents(apexLog.eventsById), + ...scanEvents(apexLog), }; } @@ -199,19 +199,23 @@ function largestInstance(instances: LogEvent[]): LogEvent { * pass. Every instance counts, including the untimed ones, so the count divides * the self time honestly; signatures with no self time at all drop out at the * end. Total time counts the outermost instances only: recursion nests the same - * wall time inside itself, and `events` is in time order, so an instance that + * wall time inside itself, and `eventsById` is in time order, so an instance that * starts before the last counted one of its signature ended is inside it. The * call-stack route `Aggregation.ts` and `RowGrouper.ts` take with a `Multiset` * is not open to a flat pass, which never sees a frame close. * Truncation flags every unclosed frame in a cut-off chain, so only top-most * flagged events count as regions; the first one seen is the first in the log. */ -function scanEvents(events: LogEvent[]): Pick { +function scanEvents(apexLog: ApexLog): Pick { const spots = new Map(); let regionCount = 0; let firstEventIndex = -1; - for (const event of events) { + for (const event of apexLog.eventsById) { + // The log itself holds the gap time, and no call stands for it. + if (event === apexLog) { + continue; + } if (event.isTruncated && !event.parent?.isTruncated) { regionCount++; if (firstEventIndex < 0) { diff --git a/log-viewer/src/features/call-tree/utils/__tests__/ExecutionHighlights.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/ExecutionHighlights.test.ts index eb0f7c95a..b0af0cf48 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/ExecutionHighlights.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/ExecutionHighlights.test.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from '@jest/globals'; import type { ApexLog, LogEvent } from 'apex-log-parser'; import { computeExecutionHighlights, getExecutionHighlights } from '../ExecutionHighlights.js'; -let nextEventIndex = 0; +// The parser takes 0 for the log itself, so real events start at 1. +let nextEventIndex = 1; let nextStamp = 0; /** @@ -47,12 +48,20 @@ function createEvent( return event; } -function createLog(total: number): ApexLog { - return { - duration: { self: 0, total }, +/** + * The pseudo-root, holding the log's gap time as its own self time. It registers + * itself as `eventsById[0]` exactly as the parser does, so the pass has to skip it. + */ +function createLog(total: number, self = 0): ApexLog { + const log = { + text: 'LOG_ROOT', + eventIndex: 0, + duration: { self, total }, children: [], eventsById: [], } as unknown as ApexLog; + log.eventsById.push(log as unknown as LogEvent); + return log; } /** Register tree events on the flat lookup, the way the parser does. */ @@ -430,6 +439,17 @@ describe('computeExecutionHighlights hot spots', () => { expect(hotSpots).toEqual([]); }); + + it('never names the log itself, whatever gap time it holds', () => { + // A truncated log leaves most of its time unaccounted, so the pseudo-root + // outweighs every real call. It is a container, not code. + const log = createLog(1000, 900); + index(log, createEvent({ text: 'Work', self: 100, total: 100 })); + + const { hotSpots } = computeExecutionHighlights(log); + + expect(hotSpots.map((row) => row.text)).toEqual(['Work']); + }); }); describe('computeExecutionHighlights truncation', () => { diff --git a/log-viewer/src/features/timeline/__tests__/marker-utils.test.ts b/log-viewer/src/features/timeline/__tests__/marker-utils.test.ts index 35c1acbe3..a12a4b502 100644 --- a/log-viewer/src/features/timeline/__tests__/marker-utils.test.ts +++ b/log-viewer/src/features/timeline/__tests__/marker-utils.test.ts @@ -8,7 +8,8 @@ import { describe, expect, it } from '@jest/globals'; import type { ApexLog, LogEvent, LogIssue } from 'apex-log-parser'; -import { extractExceptionMarkers, extractMarkers } from '../utils/marker-utils.js'; +import type { TimelineMarker } from '../types/flamechart.types.js'; +import { extractExceptionMarkers, extractMarkers, noDataSpans } from '../utils/marker-utils.js'; function logWith(overrides: Partial): ApexLog { return { logIssues: [], exceptions: [], ...overrides } as unknown as ApexLog; @@ -91,3 +92,44 @@ describe('extractExceptionMarkers', () => { expect(extractExceptionMarkers(logWith({ exceptions: [] }))).toEqual([]); }); }); + +describe('noDataSpans', () => { + function marker(overrides: Partial): TimelineMarker { + return { + id: 'm', + type: 'skip', + startTime: 0, + summary: 'Skipped-Lines', + ...overrides, + } as TimelineMarker; + } + + it('reports a bounded skip as its own range, named by the marker', () => { + const spans = noDataSpans([ + marker({ startTime: 100, endTime: 500, summary: 'Max-Size-reached' }), + ]); + + expect(spans).toEqual([{ startTime: 100, endTime: 500, summary: 'Max-Size-reached' }]); + }); + + it('ignores a skip with no end, which is a moment and not a gap', () => { + expect(noDataSpans([marker({ startTime: 100 })])).toEqual([]); + expect(noDataSpans([marker({ startTime: 100, endTime: 100 })])).toEqual([]); + }); + + // An exception is a point in recorded time; the log kept running through it. + it('ignores markers that are not skips', () => { + const spans = noDataSpans([marker({ type: 'exception', startTime: 100, endTime: 500 })]); + + expect(spans).toEqual([]); + }); + + it('sorts by start time, since the markers are not globally sorted', () => { + const spans = noDataSpans([ + marker({ startTime: 800, endTime: 900 }), + marker({ startTime: 100, endTime: 500 }), + ]); + + expect(spans.map((span) => span.startTime)).toEqual([100, 800]); + }); +}); diff --git a/log-viewer/src/features/timeline/__tests__/tree-converter.test.ts b/log-viewer/src/features/timeline/__tests__/tree-converter.test.ts new file mode 100644 index 000000000..8265de47d --- /dev/null +++ b/log-viewer/src/features/timeline/__tests__/tree-converter.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Unit tests for the time range the unified conversion reports, which sets the + * chart's own width. + */ + +import { describe, expect, it } from '@jest/globals'; +import type { LogEvent } from 'apex-log-parser'; +import { logEventToTreeAndRects } from '../utils/tree-converter.js'; + +/** A root frame spanning `start` to `end`; equal stamps give it no duration. */ +function event(start: number, end: number): LogEvent { + const duration = end - start; + return { + text: 'event', + type: 'METHOD_ENTRY', + category: 'Apex', + duration: { self: duration, total: duration }, + timestamp: start, + exitStamp: end, + children: [], + } as unknown as LogEvent; +} + +const categories = new Set(['Apex']); + +describe('logEventToTreeAndRects totalDuration', () => { + it('reaches the last frame when the log ends with it', () => { + const { totalDuration } = logEventToTreeAndRects([event(0, 500)], categories, 500); + + expect(totalDuration).toBe(500); + }); + + it('reaches the log end when the frames stop short of it', () => { + // A truncated log: the last logged frame ends long before the log does, and + // the trailing FATAL_ERROR that closes it carries no duration of its own. + const frames = [event(0, 500), event(2000, 2000)]; + + const { totalDuration } = logEventToTreeAndRects(frames, categories, 2000); + + expect(totalDuration).toBe(2000); + }); + + it('still reaches the last frame when it outlives the given log end', () => { + const { totalDuration } = logEventToTreeAndRects([event(0, 900)], categories, 100); + + expect(totalDuration).toBe(900); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index b4c62abf8..aff6d398c 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -43,7 +43,7 @@ import type { SearchCursor } from '../types/search.types.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; import { wireInspectorTab } from '../../../components/inspectorTab.js'; import { isFrameOffscreen, toDetailSelection } from '../utils/detail-selection-sync.js'; -import { extractExceptionMarkers, extractMarkers } from '../utils/marker-utils.js'; +import { extractExceptionMarkers, extractMarkers, noDataSpans } from '../utils/marker-utils.js'; import { seekWindow } from '../utils/navigate-window.js'; import { logEventToTreeAndRects } from '../utils/tree-converter.js'; import { FlameChart } from './FlameChart.js'; @@ -117,6 +117,9 @@ export class ApexLogTimeline { // - TimelineEventIndex.calculateMaxDepth // - TimelineEventIndex.calculateTotalDuration // - RectangleCache.flattenEvents + // `exitStamp`, not `executionEndTime`: a trailing zero-duration event, such as the + // FATAL_ERROR closing a truncated log, still ends the log. + const logEndTime = this.apexLog.exitStamp; const { treeNodes, maps, @@ -126,7 +129,7 @@ export class ApexLogTimeline { maxDepth, totalDuration, preSorted, - } = logEventToTreeAndRects(this.events, categories); + } = logEventToTreeAndRects(this.events, categories, logEndTime); // Initialize FlameChart with Apex-specific callbacks and precomputed data await this.flamechart.init( @@ -203,7 +206,7 @@ export class ApexLogTimeline { // memoised per log and shared with the inspector's governor trend charts. const heatStripSeries = apexLimitTimeSeries(this.apexLog); this.flamechart.setHeatStripTimeSeries( - heatStripSeries.events.length > 0 ? heatStripSeries : null, + heatStripSeries.events.length > 0 ? { ...heatStripSeries, gaps: noDataSpans(markers) } : null, ); // Subscribe to EventBus for timeline navigation requests (from CalltreeView and raw-log entry). diff --git a/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts b/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts index 9b69fbe39..f574f6cc0 100644 --- a/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts +++ b/log-viewer/src/features/timeline/optimised/TimelineEventIndex.ts @@ -236,6 +236,9 @@ export class TimelineEventIndex { /** * Calculate total timeline duration. + * + * Fallback for callers with no precomputed range: it is not floored at the log's end, + * so a truncated log stops at its last frame. */ private calculateTotalDuration(events: LogEvent[]): number { if (events.length === 0) { diff --git a/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts index e7787541f..e7b45c986 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/MarkerProcessor.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from '@jest/globals'; import { layoutMarkerRects, + noDataSpanAt, markerDuration, type MarkerLayoutItem, } from '../markers/MarkerProcessor.js'; @@ -93,3 +94,27 @@ describe('layoutMarkerRects', () => { }); }); }); + +describe('noDataSpanAt', () => { + const spans = [ + { startTime: 100, endTime: 500, summary: 'Skipped-Lines' }, + { startTime: 800, endTime: 900, summary: 'Max-Size-reached' }, + ]; + + it('names the span covering the instant', () => { + expect(noDataSpanAt(spans, 300)?.summary).toBe('Skipped-Lines'); + expect(noDataSpanAt(spans, 850)?.summary).toBe('Max-Size-reached'); + }); + + // Half-open: the span ends the moment the log resumes, so its end is recorded time. + it('covers its start but not its end', () => { + expect(noDataSpanAt(spans, 100)?.summary).toBe('Skipped-Lines'); + expect(noDataSpanAt(spans, 500)).toBeUndefined(); + }); + + it('reports nothing between spans, or with no spans at all', () => { + expect(noDataSpanAt(spans, 600)).toBeUndefined(); + expect(noDataSpanAt([], 300)).toBeUndefined(); + expect(noDataSpanAt(undefined, 300)).toBeUndefined(); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts b/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts index 6f44d87e1..b8a477470 100644 --- a/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts +++ b/log-viewer/src/features/timeline/optimised/markers/MarkerProcessor.ts @@ -10,7 +10,7 @@ * used by both MeshMarkerRenderer and TimelineMarkerRenderer. */ -import type { TimelineMarker } from '../../types/flamechart.types.js'; +import type { NoDataSpan, TimelineMarker } from '../../types/flamechart.types.js'; import { SEVERITY_RANK } from '../../types/flamechart.types.js'; /** @@ -55,6 +55,30 @@ export function markerDuration(marker: Pick= span.startTime && timeNs < span.endTime) { + return span; + } + } + return undefined; +} + /** A resolved rectangle to draw. */ export interface MarkerDrawRect { x: number; diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts index e3b00ef50..f1494ef66 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts @@ -24,11 +24,13 @@ import * as PIXI from 'pixi.js'; import { destroyTimelineApp } from '../rendering/pixiApp.js'; +import { formatTimeRange } from '../../../../core/utility/Util.js'; import type { HeatStripTimeSeries, TimelineMarker, ViewportState, } from '../../types/flamechart.types.js'; +import { noDataSpanAt } from '../markers/MarkerProcessor.js'; import { MeshAxisRenderer } from '../time-axis/MeshAxisRenderer.js'; import { wheelZoomFactor } from '../ViewportUtils.js'; import { MetricStripRenderer } from './MetricStripRenderer.js'; @@ -458,7 +460,7 @@ export class MetricStripOrchestrator { // Render the step chart with markers this.renderer.render( - data ?? { points: [], classifiedMetrics: [], globalMaxPercent: 0, hasData: false }, + data ?? { points: [], classifiedMetrics: [], globalMaxPercent: 0, hasData: false, gaps: [] }, context.viewportState, context.totalDuration, context.markers, @@ -569,6 +571,10 @@ export class MetricStripOrchestrator { // Update tooltip - position below the metric strip const dataPoint = this.classifier.getDataPointAtTime(clampedTimeNs); if (dataPoint) { + const gap = noDataSpanAt(this.classifier.getData()?.gaps, clampedTimeNs); + this.tooltipRenderer?.setNoDataLabel( + gap ? `${gap.summary} ยท ${formatTimeRange(gap.startTime, gap.endTime)}` : null, + ); this.tooltipRenderer?.show( this.mouseX, this.mouseY, diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts new file mode 100644 index 000000000..338564c7c --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts @@ -0,0 +1,87 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { describe, expect, it } from '@jest/globals'; +import type { + MetricStripClassifiedMetric, + MetricStripDataPoint, + MetricStripProcessedData, + NoDataSpan, + ViewportState, +} from '../../types/flamechart.types.js'; +import { MetricStripRenderer } from './MetricStripRenderer.js'; + +const TOTAL_DURATION = 4000; + +const cpuTime: MetricStripClassifiedMetric = { + metricId: 'cpuTime', + displayName: 'CPU Time', + tier: 1, + globalMaxPercent: 0.5, + limit: 100, + color: 0xff0000, + priority: 0, + unit: '', +}; + +/** A reading of `percent` at `timestamp`. */ +function point(timestamp: number, percent: number): MetricStripDataPoint { + return { + timestamp, + values: new Map([['cpuTime', percent]]), + rawValues: new Map(), + tier3Max: 0, + }; +} + +function data(points: MetricStripDataPoint[], gaps: NoDataSpan[]): MetricStripProcessedData { + return { points, classifiedMetrics: [cpuTime], globalMaxPercent: 0.5, hasData: true, gaps }; +} + +const viewportState: ViewportState = { + zoom: 0.1, + offsetX: 0, + offsetY: 0, + displayWidth: 400, + displayHeight: 60, +} as ViewportState; + +/** How many separate shapes the area fill painted. */ +function areaFillCount(renderer: MetricStripRenderer): number { + // Index 2 of the render-order list is the area fill layer. + const graphics = renderer.getGraphics()[2]!; + return graphics.context.instructions.filter((i) => i.action === 'fill').length; +} + +describe('MetricStripRenderer area fills', () => { + const readings = [point(0, 0.5), point(1000, 0.5), point(3000, 0.5)]; + + it('paints one shape when the log recorded throughout', () => { + const renderer = new MetricStripRenderer(); + renderer.setHeight(60); + + renderer.render(data(readings, []), viewportState, TOTAL_DURATION); + + expect(areaFillCount(renderer)).toBe(1); + }); + + // One shape spanning the gap ramps straight across it, which reads as measured volume. + it('breaks the shape at a span the log recorded nothing in', () => { + const renderer = new MetricStripRenderer(); + renderer.setHeight(60); + + renderer.render( + data(readings, [{ startTime: 1500, endTime: 2500, summary: 'Skipped-Lines' }]), + viewportState, + TOTAL_DURATION, + ); + + // One shape up to the gap, one after it. + expect(areaFillCount(renderer)).toBe(2); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts index 1e50d8dbf..8b220f9d8 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts @@ -31,10 +31,24 @@ import { Graphics } from 'pixi.js'; import type { MetricStripDataPoint, MetricStripProcessedData, + NoDataSpan, TimelineMarker, ViewportState, } from '../../types/flamechart.types.js'; -import { MARKER_ALPHA, MARKER_COLORS } from '../../types/flamechart.types.js'; +import { + MARKER_ALPHA, + MARKER_BUCKET_PX, + MARKER_COLORS, + MARKER_GAP_PX, + MARKER_MIN_WIDTH_PX, +} from '../../types/flamechart.types.js'; +import { + layoutMarkerRects, + markerDuration, + noDataSpanAt, + sortMarkersByTimeAndSeverity, + type MarkerLayoutItem, +} from '../markers/MarkerProcessor.js'; import { BREACH_AREA_OPACITY, DANGER_ZONE_OPACITY, @@ -117,6 +131,9 @@ export class MetricStripRenderer { /** Effective Y-max for dynamic scaling. */ private effectiveYMax = METRIC_STRIP_Y_MAX_PERCENT; + /** Spans the log recorded nothing in; nothing measured is drawn inside one. */ + private noDataSpans: NoDataSpan[] = []; + /** Whether the metric strip is in collapsed mode. */ private isCollapsed = false; @@ -174,6 +191,33 @@ export class MetricStripRenderer { this.effectiveYMax = yMax; } + /** Whether the log recorded nothing at this instant. */ + private isNoData(timeNs: number): boolean { + return noDataSpanAt(this.noDataSpans, timeNs) !== undefined; + } + + /** + * Where a point's segment ends, or `null` when the point sits in a gap. + * + * A segment stops at the next point, at the range's end, or at the next gap โ€” whichever + * comes first โ€” so nothing measured is drawn across time the log did not record. + */ + private recordedSegmentEnd(timeNs: number, nextTimeNs: number): number | null { + if (this.noDataSpans.length === 0) { + return nextTimeNs; + } + let end = nextTimeNs; + for (const span of this.noDataSpans) { + if (timeNs >= span.startTime && timeNs < span.endTime) { + return null; + } + if (span.startTime > timeNs && span.startTime < end) { + end = span.startTime; + } + } + return end; + } + /** * Set the collapsed state. */ @@ -225,12 +269,15 @@ export class MetricStripRenderer { // Clear all graphics this.clear(); + // The gaps ride on the processed data, so the strip has one source for them. + this.noDataSpans = data.gaps; + const { displayWidth } = viewportState; const height = this.height; // Always render markers (background layer) - visible in both collapsed and expanded modes if (markers && markers.length > 0) { - this.renderMarkers(markers, viewportState, totalDuration); + this.renderMarkers(markers, viewportState); } // Note: Time grid lines are now rendered by MeshAxisRenderer in MetricStripOrchestrator @@ -243,7 +290,9 @@ export class MetricStripRenderer { return; } - // Render expanded view layers (back to front) + // Render expanded view layers (back to front). The fills and the breach band leave the + // unrecorded spans blank: a fill reads as measured volume and the band is a verdict. The + // step line carries its last reading across, because a governor total cannot fall. this.renderDangerZone(displayWidth, height); this.renderAreaFills(data, viewportState, totalDuration, height); this.renderStepChartLines(data, viewportState, totalDuration, height); @@ -342,7 +391,8 @@ export class MetricStripRenderer { let color = 0; let alpha = 0; - if (cachedResult) { + // A traffic light is a verdict, so the strip draws none over unrecorded time. + if (cachedResult && !this.isNoData(bucketStartTime)) { const maxPercent = this.getMaxPercentAtPoint(cachedResult.point); const colorInfo = getTrafficLightColor(maxPercent); color = colorInfo.color; @@ -395,42 +445,40 @@ export class MetricStripRenderer { /** * Render marker backgrounds as vertical colored bands. - * Follows the same pattern as minimap marker rendering. + * + * Shares the chart's and minimap's layout, so a point marker such as an exception keeps a + * visible hairline and a dense cluster collapses to one line. */ - private renderMarkers( - markers: TimelineMarker[], - viewportState: ViewportState, - totalDuration: number, - ): void { + private renderMarkers(markers: TimelineMarker[], viewportState: ViewportState): void { const { zoom, offsetX, displayWidth } = viewportState; const g = this.markerGraphics; - const gap = 1; - const halfGap = gap / 2; - - for (let i = 0; i < markers.length; i++) { - const marker = markers[i]!; - const startX = marker.startTime * zoom - offsetX; - const endTime = markers[i + 1]?.startTime ?? totalDuration; - const endX = endTime * zoom - offsetX; - - // Viewport culling - if (endX < 0 || startX > displayWidth) { + // Sorted by start time, which is start-X order too, as the layout requires. + const items: MarkerLayoutItem[] = []; + for (const marker of sortMarkersByTimeAndSeverity(markers)) { + const color = MARKER_COLORS[marker.type]; + if (color === undefined) { continue; } - const color = MARKER_COLORS[marker.type]; - if (color === undefined) { + const startX = marker.startTime * zoom - offsetX; + // A bounded marker shades its own range; an unbounded one is a point, as the chart + // and minimap draw it. Running on to the next marker shaded sections that recovered. + const exactWidth = markerDuration(marker) * zoom; + if (startX + exactWidth < 0 || startX > displayWidth) { continue; } - const gappedStartX = Math.max(0, startX + halfGap); - const gappedEndX = Math.min(displayWidth, endX - halfGap); - const gappedWidth = gappedEndX - gappedStartX; + items.push({ screenStartX: startX, exactWidth, color, alpha: MARKER_ALPHA }); + } - if (gappedWidth > 0) { - g.rect(gappedStartX, 0, gappedWidth, this.height); - g.fill({ color, alpha: MARKER_ALPHA }); + const rects = layoutMarkerRects(items, MARKER_MIN_WIDTH_PX, MARKER_GAP_PX, MARKER_BUCKET_PX); + for (const rect of rects) { + const x = Math.max(0, rect.x); + const width = Math.min(displayWidth, rect.x + rect.width) - x; + if (width > 0) { + g.rect(x, 0, width, this.height); + g.fill({ color: rect.color, alpha: rect.alpha }); } } } @@ -520,8 +568,16 @@ export class MetricStripRenderer { for (let i = 0; i < points.length; i++) { const point = points[i]!; - const segmentEnd = points[i + 1]?.timestamp ?? totalDuration; - + const nextTime = points[i + 1]?.timestamp ?? totalDuration; + const segmentEnd = this.recordedSegmentEnd(point.timestamp, nextTime); + + // A gap ends the run: one shape spanning it would ramp straight across the + // unrecorded time, which reads as measured volume. + if (segmentEnd === null) { + this.fillArea(g, pathPoints, baseY, color, alpha); + pathPoints.length = 0; + continue; + } if (segmentEnd < visibleStartTime) { continue; } @@ -540,18 +596,34 @@ export class MetricStripRenderer { pathPoints.push({ x: Math.max(0, x1), y }); pathPoints.push({ x: Math.min(displayWidth, x2), y }); + + // A gap cut the segment short, so the run ends here even though no reading fell in it. + if (segmentEnd < nextTime) { + this.fillArea(g, pathPoints, baseY, color, alpha); + pathPoints.length = 0; + } } + this.fillArea(g, pathPoints, baseY, color, alpha); + } + + /** Close one run of the area fill back to the baseline and paint it. */ + private fillArea( + g: Graphics, + pathPoints: Array<{ x: number; y: number }>, + baseY: number, + color: number, + alpha: number, + ): void { if (pathPoints.length < 2) { return; } - pathPoints.push({ x: pathPoints[pathPoints.length - 1]!.x, y: baseY }); - g.moveTo(pathPoints[0]!.x, pathPoints[0]!.y); for (let i = 1; i < pathPoints.length; i++) { g.lineTo(pathPoints[i]!.x, pathPoints[i]!.y); } + g.lineTo(pathPoints[pathPoints.length - 1]!.x, baseY); g.closePath(); g.fill({ color, alpha }); } @@ -695,8 +767,14 @@ export class MetricStripRenderer { for (let i = 0; i < data.points.length; i++) { const point = data.points[i]!; - const segmentEnd = data.points[i + 1]?.timestamp ?? totalDuration; + const segmentEnd = this.recordedSegmentEnd( + point.timestamp, + data.points[i + 1]?.timestamp ?? totalDuration, + ); + if (segmentEnd === null) { + continue; + } if (segmentEnd < visibleStartTime) { continue; } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts index 9ebfa5738..6cf96f5fd 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts @@ -228,4 +228,55 @@ describe('MetricStripTooltipRenderer', () => { expect(cpuEarly).toBeLessThan(soqlEarly); expect(cpuLate).toBeLessThan(soqlLate); }); + + describe('no data note', () => { + it('says nothing while the reading is the one at the cursor', () => { + renderer.setNoDataLabel(null); + renderer.show(100, 0, onePoint, oneMetric, 60); + + expect(panel().textContent).not.toContain('Max-Size-reached'); + }); + + it('sits under the rows', () => { + renderer.setNoDataLabel('Max-Size-reached ยท 10.8s โ†’ 27.1s'); + renderer.show(100, 0, onePoint, oneMetric, 60); + + const children = [...panel().children] as HTMLElement[]; + expect(children[children.length - 1]!.textContent).toBe('Max-Size-reached ยท 10.8s โ†’ 27.1s'); + }); + + // One reading covers the whole unrecorded region, so the note has to appear and clear + // without the data point changing, which is what skips the panel rebuild. + it('appears and clears on the same reading', () => { + renderer.show(100, 0, onePoint, oneMetric, 60); + expect(panel().textContent).not.toContain('Max-Size-reached'); + + renderer.setNoDataLabel('Max-Size-reached ยท 10.8s โ†’ 27.1s'); + renderer.show(140, 0, onePoint, oneMetric, 60); + expect(panel().textContent).toContain('Max-Size-reached ยท 10.8s โ†’ 27.1s'); + + renderer.setNoDataLabel(null); + renderer.show(180, 0, onePoint, oneMetric, 60); + expect(panel().textContent).not.toContain('Max-Size-reached'); + }); + + // The note is written once and never re-appended, so a later reading that grows the + // row pool has to insert its rows above it rather than under it. + it('stays last when a later reading adds a row', () => { + renderer.setNoDataLabel('Max-Size-reached ยท 10.8s โ†’ 27.1s'); + renderer.show(100, 0, onePoint, oneMetric, 60); + + const two = [metric('cpuTime', 'CPU Time', 0.9), metric('heapSize', 'Heap Size', 0.5)]; + const twoPoint: MetricStripDataPoint = { + ...onePoint, + values: new Map([ + ['cpuTime', 0.5], + ['heapSize', 0.5], + ]), + }; + renderer.show(100, 0, twoPoint, two, 60); + + expect(panel().lastElementChild?.textContent).toBe('Max-Size-reached ยท 10.8s โ†’ 27.1s'); + }); + }); }); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts index 905264d47..ea1a5867f 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts @@ -111,6 +111,15 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { /** Set once the panel's title exists. */ private titleNode: HTMLElement | null = null; + /** Set once the no-data note exists; hidden while the cursor is over recorded time. */ + private noDataNode: HTMLElement | null = null; + + /** The note asked for. */ + private noDataLabel: string | null = null; + + /** The note on the panel, so an unchanged one is never touched again. */ + private noDataShown: string | null = null; + constructor(htmlContainer: HTMLElement, options: MetricStripTooltipOptions = {}) { super(htmlContainer, { mode: 'below-anchor', offset: 8, padding: 4 }); @@ -125,6 +134,17 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { // No-op: metric strip uses universal colors } + /** + * Note that the log recorded nothing here, so the rows are the last reading it has. + * + * Applied by the next `show`, which is what appends the panel's title. + * + * @param label - Short note to show, or null while the reading is the one at the cursor + */ + public setNoDataLabel(label: string | null): void { + this.noDataLabel = label; + } + /** * Show the tooltip with metric data at the specified position. * @@ -163,6 +183,10 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { this.shownPoint = dataPoint; } + // After the rebuild, which appends the title: the note is the panel's last line. One + // reading covers a whole unrecorded span, so the note changes while the rows do not. + this.renderNoDataNote(); + this.showElement(); // The strip is the anchor: `below-anchor` keeps the panel clear of it, and batches the @@ -310,7 +334,8 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { const row = pooled ?? this.createRow(); if (!pooled) { this.rowPool.push(row); - this.tooltipElement.appendChild(row.root); + // Before the note, which is the panel's last line; `null` appends. + this.tooltipElement.insertBefore(row.root, this.noDataNode); } row.swatch.setAttribute('color', data.color); @@ -328,6 +353,34 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { } } + /** Writes the no-data note. Reads nothing back from the DOM: this runs per pointer move. */ + private renderNoDataNote(): void { + const label = this.noDataLabel; + if (label === this.noDataShown) { + return; + } + this.noDataShown = label; + + if (!label) { + if (this.noDataNode) { + this.noDataNode.style.display = 'none'; + // Cleared too: a hidden node still reads out of the panel's text. + this.noDataNode.textContent = ''; + } + return; + } + + let node = this.noDataNode; + if (!node) { + node = document.createElement('div'); + node.style.cssText = `margin-top:6px;font-style:italic;color:${TOOLTIP_CSS.descriptionForegroundMuted};`; + this.tooltipElement.appendChild(node); + this.noDataNode = node; + } + node.textContent = label; + node.style.display = 'block'; + } + /** One row's elements, in the order the grid lays them out. */ private createRow(): RowNodes { const root = document.createElement('div'); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts index 1d9f672c5..4fd92c4f2 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts @@ -76,6 +76,7 @@ export class MetricTierClassifier { classifiedMetrics: [], globalMaxPercent: 0, hasData: false, + gaps: timeSeries.gaps ?? [], }; return this.processedData; } @@ -101,6 +102,7 @@ export class MetricTierClassifier { classifiedMetrics, globalMaxPercent, hasData: points.length > 0, + gaps: timeSeries.gaps ?? [], }; return this.processedData; diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index cb6967422..66cfec32b 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -873,6 +873,16 @@ export interface HeatStripTimeSeries { metrics: Map; /** Time series events ordered by timestamp */ events: HeatStripEvent[]; + /** Spans the log recorded nothing in, so no reading is carried across them */ + gaps?: NoDataSpan[]; +} + +/** A span the log recorded nothing in, named by the marker that reports it. */ +export interface NoDataSpan { + startTime: number; + endTime: number; + /** The marker's own summary, so a reader is told the reason, not just the gap. */ + summary: string; } /** @@ -959,6 +969,8 @@ export interface MetricStripProcessedData { globalMaxPercent: number; /** Whether there's any data to render */ hasData: boolean; + /** Spans the log recorded nothing in, carried through from the series */ + gaps: NoDataSpan[]; } /** diff --git a/log-viewer/src/features/timeline/utils/marker-utils.ts b/log-viewer/src/features/timeline/utils/marker-utils.ts index c6e6bc2f4..b1e48f7a6 100644 --- a/log-viewer/src/features/timeline/utils/marker-utils.ts +++ b/log-viewer/src/features/timeline/utils/marker-utils.ts @@ -9,7 +9,7 @@ */ import type { ApexLog } from 'apex-log-parser'; -import type { TimelineMarker } from '../types/flamechart.types.js'; +import type { NoDataSpan, TimelineMarker } from '../types/flamechart.types.js'; import { isMarkerType, markerTypeForIssue } from '../types/flamechart.types.js'; /** @@ -107,6 +107,34 @@ export function extractExceptionMarkers(log: ApexLog): TimelineMarker[] { return markers; } +/** + * The spans the log recorded nothing in, earliest first. + * + * A `skip` marker with an end time is the log saying it stopped between two instants โ€” + * the size cap, or lines dropped mid-log. Anything measured, such as the governor strip, + * has no reading in that span and must not carry its last one across it. Markers without + * an end time are moments, not spans, so they report no gap. + * + * @param markers - Markers extracted from the log + * @returns Spans sorted by start time + */ +export function noDataSpans(markers: TimelineMarker[]): NoDataSpan[] { + const spans: NoDataSpan[] = []; + for (const marker of markers) { + if (marker.type !== 'skip' || marker.endTime === undefined) { + continue; + } + if (marker.endTime > marker.startTime) { + spans.push({ + startTime: marker.startTime, + endTime: marker.endTime, + summary: marker.summary, + }); + } + } + return spans.sort((a, b) => a.startTime - b.startTime); +} + /** * Validates a single marker. * Used for runtime validation and testing. diff --git a/log-viewer/src/features/timeline/utils/tree-converter.ts b/log-viewer/src/features/timeline/utils/tree-converter.ts index 686d9831c..0b41c8338 100644 --- a/log-viewer/src/features/timeline/utils/tree-converter.ts +++ b/log-viewer/src/features/timeline/utils/tree-converter.ts @@ -190,7 +190,7 @@ export interface UnifiedConversionResult { rectMap: Map; /** Maximum depth in tree (tracked during traversal) */ maxDepth: number; - /** Total duration in nanoseconds (tracked during traversal) */ + /** The range's end timestamp, not a duration: the later of the last frame and the log's end */ totalDuration: number; /** Whether rectsByCategory arrays are pre-sorted by timeStart (skip sorting in RectangleCache) */ preSorted: boolean; @@ -227,11 +227,13 @@ interface ConversionWorkItem { * * @param events - Array of LogEvent objects * @param categories - Set of valid categories for rectangle indexing + * @param logEndTime - The log's own end timestamp, which floors `totalDuration`. * @returns UnifiedConversionResult with all data structures */ export function logEventToTreeAndRects( events: LogEvent[], categories: Set, + logEndTime: number, ): UnifiedConversionResult { const maps: NavigationMaps = { originalMap: new Map(), @@ -256,7 +258,7 @@ export function logEventToTreeAndRects( // Metrics tracked during traversal let maxDepth = 0; - let totalDuration = 0; + let totalDuration = logEndTime; // Root result array const rootResult: TreeNode[] = []; From b9ed80d67faeea9a286a4fe0070b8ebcc2544113 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:19:20 +0100 Subject: [PATCH 08/61] fix(log-viewer): mark a caller row by the caller, not the calls it made (#980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked on #977.** Its commit shows in this diff until that merges. Review from `be6412ea` onward. # ๐Ÿ“ PR Overview Two bugs in the inspector's row mark, both about which rows light up. **A grid row told the inspector the wrong thing.** A row under the pointer emitted every call it counts, so a Bottom Up caller row marked the inspector rows for the leaf calls underneath it rather than for the caller itself. The forward direction stopped doing that in #973; the reverse direction was still on the old rule, so the two disagreed depending on which side you pointed at. **A mark could come back after being dropped.** The sweep reaches only the rows a table has attached. A row lit while on screen, then scrolled out, kept the class when the mark moved away, and the renderer re-attaches such a row without running the row formatter again, so the stale highlight returned with it. ## ๐Ÿ› ๏ธ Changes made - **`rowFrames(row, root, direction)`** โ€” a bottom-up caller row climbs to its own depth, `depthOf(_pathId) - 1` hops above each call it counts. A top-down row already sits at its frames' depth, so it climbs nothing. - **`LogStore.framesAbove`** โ€” the climb, next to `stackByEventIndex`, which already owned this parent-pointer walk. The inspector's `frameEventIndexes` now reads it too, so the two sides cannot drift. - **The direction is read at hover time**, from `directionOf(this.viewMode)`, which the sibling `_emitDetailSelection` already uses. No new parameter and no second source of truth. - **`litByHost`** โ€” what each table's mark has lit, whichever half lit it, so a new mark can un-light an element the renderer has since detached. - **The `detail:locate` doc** described the old rule; it now says what the protocol carries. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โœจ New feature โ€“ adds new functionality - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A. What changes is which rows carry the highlight. ## ๐Ÿ”— Related Issues Follows #973, which changed the forward direction, and #975, which shipped the mark mechanism the second fix corrects. ## โœ… Tests added? - [x] ๐Ÿ‘ yes - [ ] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help Three `rowFrames` cases and one for the detached row. Each guard was proven by reverting the code it covers: dropping the direction check makes a top-down row climb; removing the shared climb fails one `rowFrames` test **and** two `frameEventIndexes` tests, which also shows the inspector test runs the real method rather than a copy of it; restoring the old clearing fails the detached-row test. ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [x] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features - [ ] ๐Ÿ™… not needed No entry: both fixes correct the unreleased Inspector, so they belong to its existing entry. Ticked to record that it was considered. ## Anything else we need to know? [optional] **Where to start.** `LogStore.framesAbove` is the mechanism; `rowFrames` is the grid's use of it and `frameEventIndexes` the inspector's. **Why the mark has to remember what it lit.** Tabulator builds a row's element once (`Row.create()` is guarded by \`this.created\`) and \`Row.initialize()\` re-uses it, and both \`deinitialize()\` calls in Tabulator are inside \`reinitializeRows()\`, a column-layout path. So an ordinary scroll neither rebuilds the element nor re-runs the formatter: the class persists, and clearing it has to reach elements the query cannot see. **On the dedupe.** A merged row aggregates distinct caller frames that share a signature, so the climb is many-to-many and the answer can be as long as what was asked about. It is not safe to climb from one call and assume the rest agree. **Test plan.** - \`pnpm lint\` and \`pnpm test\`. - Call Tree, **Bottom Up**, Inspector open. Hover a bucket row, then a caller row one level down: the inspector mark moves up the stack with you rather than staying on the leaf calls. - Step two and three levels up: one frame per level. - Analysis: same, hovering a row under a method bucket. - **Aggregated** and **Time Order**: unchanged, since a row there already sits at its own frames' depth. - Hover an inspector row so a grid row lights, scroll that row out of view, move the pointer off the inspector row, then scroll back: no highlight. That is the second fix. - Both themes, Inspector docked at the side and at the bottom. **Known unrelated failure locally.** \`lana/src/services/__tests__/servicesRuntime.test.ts\` cannot resolve \`effect\` in a worktree not installed since #951. It passes in CI. **Follow-up, not in this PR.** `deriveCalls` already walks each call's parents inside `chainReaches` and stops exactly at the frame the row is, then keeps the index and throws the frame away, so `framesAbove` re-walks the same edges. Fusing the two means having `chainReaches` return the node it stopped at. --- .../components/__tests__/locatedRow.test.ts | 72 ++++++++++++++- .../__tests__/scopedCallTree.test.ts | 36 +++++--- log-viewer/src/components/locatedRow.ts | 88 +++++++++++++++++-- log-viewer/src/components/scopedCallTree.ts | 13 +-- log-viewer/src/core/events/EventBus.ts | 9 +- log-viewer/src/core/log/LogStore.ts | 26 ++++++ .../analysis/components/AnalysisView.ts | 9 +- .../call-tree/components/CalltreeView.ts | 11 ++- 8 files changed, 219 insertions(+), 45 deletions(-) diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index 8f2195c28..0934ef5ec 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -14,6 +14,7 @@ import { LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, + rowFrames, rowIndexStamper, rowPathId, stampRowPath, @@ -46,8 +47,8 @@ function bucketRow(ids: KeyPathIds, ...keys: string[]): RowComponent { }); } -function ev(text: string, parent: LogEvent | null): LogEvent { - return { type: 'METHOD_ENTRY', namespace: '', text, parent } as unknown as LogEvent; +function ev(text: string, parent: LogEvent | null, eventIndex?: number): LogEvent { + return { type: 'METHOD_ENTRY', namespace: '', text, parent, eventIndex } as unknown as LogEvent; } /** A table host holding a rendered row element per index, as the stamp leaves them. */ @@ -185,6 +186,21 @@ describe('LocatedRowMarker', () => { expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); }); + it('un-lights a row the renderer had detached when the mark moved', () => { + // The formatter does not run again for a row the renderer only re-attaches, + // so a class left on a detached element comes back with it. + const container = host(); + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); + const row = renderRow(container, 4); + + row.remove(); + marker.mark(container, [5]); + container.append(row); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + it('leaves a row alone where nothing has marked its table', () => { const row = renderRow(document.createElement('div'), 4); @@ -213,6 +229,58 @@ describe('LocatedRowMarker', () => { }); }); +describe('rowFrames', () => { + /** exec -> m1 -> soql, with the indexes the log answers about. */ + function log() { + const exec = ev('exec', null, 1); + const m1 = ev('m1', exec, 3); + const soql = ev('soql', m1, 5); + return { + soql, + apexLog: { eventsById: { 1: exec, 3: m1, 5: soql } } as unknown as ApexLog, + }; + } + + /** The bucket for `soql` and the caller row under it, as a bottom-up grid + * leaves them: the bucket holds the occurrences, the caller row derives its. */ + function rows(apexLog: ApexLog, soql: LogEvent) { + const paths = logStoreFor(apexLog).keyPathIds(); + const bucketPath = paths.step(ROOT_PATH_ID, paths.keyIdOf(soql)); + const bucket = rowComponent(document.createElement('div'), { + key: 'soql', + _pathId: bucketPath, + instances: [soql], + }); + const caller = rowComponent( + document.createElement('div'), + { key: 'm1', _pathId: paths.step(bucketPath, paths.keyIdOf(soql.parent!)) }, + bucket, + ); + return { bucket, caller }; + } + + it('names the caller a bottom-up row is, not the calls it counts', () => { + const { apexLog, soql } = log(); + const { caller } = rows(apexLog, soql); + + expect(rowFrames(caller, apexLog, 'callers')).toEqual([3]); + }); + + it('leaves a top-down row as the calls it counts, since it sits at their depth', () => { + const { apexLog, soql } = log(); + const { caller } = rows(apexLog, soql); + + expect(rowFrames(caller, apexLog, 'callees')).toEqual([5]); + }); + + it('names its own occurrences for the row a bottom-up tree is seeded from', () => { + const { apexLog, soql } = log(); + const { bucket } = rows(apexLog, soql); + + expect(rowFrames(bucket, apexLog, 'callers')).toEqual([5]); + }); +}); + describe('LocatedRowIds', () => { const root = ev('exec', null); const outerFrame = ev('outer', root); diff --git a/log-viewer/src/components/__tests__/scopedCallTree.test.ts b/log-viewer/src/components/__tests__/scopedCallTree.test.ts index a5addf4d5..3e782dc8a 100644 --- a/log-viewer/src/components/__tests__/scopedCallTree.test.ts +++ b/log-viewer/src/components/__tests__/scopedCallTree.test.ts @@ -58,22 +58,30 @@ const { KeyPathIds } = jest.requireActual( + '../../core/log/LogStore.js', +); jest.mock('../../core/log/LogStore.js', () => ({ - currentLogStore: () => ({ - log: root, - keyPathIds: () => paths, - eventByIndex: (i: number) => byId.get(i) ?? null, - // Mirrors LogStore.stackByEventIndex over the fixture's own index. - stackByEventIndex: (i: number) => { - const stack: FakeEvent[] = []; - for (let node = byId.get(i) ?? null; node && node !== root; node = node.parent) { - if (node.isParent) { - stack.push(node); + currentLogStore: () => { + const store = { + log: root, + keyPathIds: () => paths, + eventByIndex: (i: number) => byId.get(i) ?? null, + // Mirrors LogStore.stackByEventIndex over the fixture's own index. + stackByEventIndex: (i: number) => { + const stack: FakeEvent[] = []; + for (let node = byId.get(i) ?? null; node && node !== root; node = node.parent) { + if (node.isParent) { + stack.push(node); + } } - } - return stack.reverse(); - }, - }), + return stack.reverse(); + }, + }; + // The real climb, so what it answers about the fixture is under test rather + // than a second copy of it. It reads only `eventByIndex`. + return { ...store, framesAbove: LogStore.prototype.framesAbove.bind(store) }; + }, })); import { diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index 3cda53eec..07521f436 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -28,13 +28,42 @@ const NOTHING_WANTED: ReadonlySet = new Set(); */ const wantedByHost = new WeakMap>(); -/** Applies `wanted` to the rows a table has rendered. */ +/** + * What each table's mark has lit. + * + * A sweep can only reach the rows a table has attached, and the renderer keeps + * the element of a row scrolled out of view without running the formatter again + * when it comes back. So an element lit while on screen has to be remembered to + * be un-lit, or the old mark returns with it. + */ +const litByHost = new WeakMap>(); + +/** Lights `element`, and remembers it as `host`'s until the mark moves. */ +function light(host: HTMLElement, element: HTMLElement): void { + element.classList.add(LOCATED_ROW_CLASS); + (litByHost.get(host) ?? litByHost.set(host, new Set()).get(host)!).add(element); +} + +/** Drops `host`'s mark from every element it lit, attached or not. */ +function unlight(host: HTMLElement): void { + const lit = litByHost.get(host); + if (!lit) { + return; + } + for (const element of lit) { + element.classList.remove(LOCATED_ROW_CLASS); + } + lit.clear(); +} + +/** Lights the rows a table has rendered that `wanted` names. */ function sweep(host: HTMLElement, wanted: ReadonlySet): void { for (const element of host.querySelectorAll( `.tabulator-row[${ROW_INDEX_ATTRIBUTE}]`, )) { - const id = element.getAttribute(ROW_INDEX_ATTRIBUTE)!; - element.classList.toggle(LOCATED_ROW_CLASS, wanted.has(id)); + if (wanted.has(element.getAttribute(ROW_INDEX_ATTRIBUTE)!)) { + light(host, element); + } } } @@ -57,7 +86,11 @@ function stamp(row: RowComponent, id: number | string): void { for (let node: HTMLElement | null = element; node; node = node.parentElement) { const wanted = wantedByHost.get(node); if (wanted) { - element.classList.toggle(LOCATED_ROW_CLASS, wanted.has(stamped)); + if (wanted.has(stamped)) { + light(node, element); + } else { + element.classList.remove(LOCATED_ROW_CLASS); + } return; } } @@ -234,6 +267,50 @@ export function rowOccurrences(row: RowComponent, root: ApexLog | null): number[ return indexes; } +/** Held per row, for the same reason {@link derivedIndexes} is. Only a caller + * row ever climbs, so only a caller row's answer is in here. */ +const derivedCallerFrames = new WeakMap(); + +/** + * The frames a row is, which is what the inspector marks it by. + * + * A bottom-up caller row is one of the frames above a call, so it stands for the + * callers at its own depth rather than the calls they conducted. A top-down row + * sits at its own frames' depth, so there the two are the same, and so is a row + * that is one call. + * + * {@link rowOccurrences} stays the calls the row counts, which is what its + * totals describe. + * + * @param direction - the way the row's own table reads the tree + */ +export function rowFrames( + row: RowComponent, + root: ApexLog | null, + direction: SelectionView, +): number[] { + const data = rowCallData(row); + const store = direction === 'callers' && root ? logStoreFor(root) : null; + if (!store) { + return rowOccurrences(row, root); + } + const cached = derivedCallerFrames.get(data); + if (cached) { + return cached; + } + const levels = data._pathId === undefined ? 0 : store.keyPathIds().depthOf(data._pathId) - 1; + const conducted = rowOccurrences(row, root); + if (levels <= 0) { + return conducted; + } + const frames = store.framesAbove(conducted, levels); + if (frames.length) { + // Not kept where nothing climbed, for the reason `rowOccurrences` gives. + derivedCallerFrames.set(data, frames); + } + return frames; +} + /** * What a selected row tells the inspector: a merged row names every call it * counts, a Time Order row the one call it is, and no row nothing. @@ -349,12 +426,13 @@ export class LocatedRowMarker { if (this.host && this.host !== host) { // A view that switches tables would leave the one it left marked. wantedByHost.delete(this.host); - sweep(this.host, NOTHING_WANTED); + unlight(this.host); } this.host = host; if (!host) { return; } + unlight(host); const wanted = ids.length ? new Set(ids.map(String)) : NOTHING_WANTED; wantedByHost.set(host, wanted); sweep(host, wanted); diff --git a/log-viewer/src/components/scopedCallTree.ts b/log-viewer/src/components/scopedCallTree.ts index 30823a2d8..ad4341ca0 100644 --- a/log-viewer/src/components/scopedCallTree.ts +++ b/log-viewer/src/components/scopedCallTree.ts @@ -134,18 +134,7 @@ export function frameEventIndexes(row: Partial | undefined): number[] if (levels <= 0) { return conducted; } - // Thousands of calls sit under a handful of callers. - const own = new Set(); - for (const index of conducted) { - let frame = store.eventByIndex(index); - for (let up = levels; up > 0 && frame; up--) { - frame = frame.parent; - } - if (frame) { - own.add(frame.eventIndex); - } - } - return (row._frameIndexes = [...own]); + return (row._frameIndexes = store.framesAbove(conducted, levels)); } /** diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index 2bd3ff4fe..5698b7a96 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -102,10 +102,11 @@ interface EventMap { }; // The other direction: a frame in the tab's own view is under the pointer, so - // the inspector marks the rows that stand for it โ€” only where a row is already - // on screen. Nothing moves: no selection change, no scroll, no expand. A row - // that merges occurrences names them all, and the list is empty when the - // pointer leaves the frame. + // the inspector marks the rows that stand for it, only where a row is already + // on screen. Nothing moves: no selection change, no scroll, no expand. The + // list is the frames the row stands for, as `inspector:locate` is, so a + // bottom-up caller row names the callers at its own depth rather than the + // calls they conducted. It is empty when the pointer leaves the frame. 'detail:locate': { source: DetailSource; eventIndexes: readonly number[] }; } diff --git a/log-viewer/src/core/log/LogStore.ts b/log-viewer/src/core/log/LogStore.ts index 89b15c1ad..ad9c31041 100644 --- a/log-viewer/src/core/log/LogStore.ts +++ b/log-viewer/src/core/log/LogStore.ts @@ -35,6 +35,32 @@ export class LogStore { return this.log.eventsById[eventIndex] ?? null; } + /** + * The distinct frames `levels` parents above each of `eventIndexes`: what a + * merged row standing for its callers is, since a row at path depth D sits + * D - 1 hops above the calls it counts. + * + * How far the dedupe folds is the log's business. A call in a loop has one + * caller; a call made once per record has one caller each, so the answer can + * be as long as what was asked about. + * + * @param levels - hops to climb, at least one: the caller guards the rest so + * it can hand back the calls it already holds rather than a copy + */ + framesAbove(eventIndexes: readonly number[], levels: number): number[] { + const own = new Set(); + for (const index of eventIndexes) { + let frame = this.eventByIndex(index); + for (let up = levels; up > 0 && frame; up--) { + frame = frame.parent; + } + if (frame) { + own.add(frame.eventIndex); + } + } + return [...own]; + } + /** * The parent frames from the log's root down to `eventIndex`, the event itself * last. Empty if the log has no such event. diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index c471534dc..9fd8941ca 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -19,7 +19,7 @@ import { LocatedRowIds, LocatedRowMarker, rowDetailSelection, - rowOccurrences, + rowFrames, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; import { wireInspectorTab } from '../../../components/inspectorTab.js'; @@ -665,12 +665,13 @@ export class AnalysisView extends LitElement { }); }); - // Tell the inspector which calls the pointer is over, so it can mark the rows - // that stand for them; a bucket merges calls, so it names every call it counts. + // Tell the inspector which frames the pointer is over, so it can mark the + // rows that stand for them. A row under a bucket is one of its callers, so it + // names that caller rather than the calls it conducted. this.analysisTable.on('rowMouseEnter', (_e, row) => { eventBus.emit('detail:locate', { source: 'analysis', - eventIndexes: rowOccurrences(row, this.timelineRoot), + eventIndexes: rowFrames(row, this.timelineRoot, 'callers'), }); }); this.analysisTable.on('rowMouseLeave', () => { diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index c05e568b2..0bdf0e53c 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -69,7 +69,7 @@ import { LocatedRowMarker, rowDetailSelection, rowIndexStamper, - rowOccurrences, + rowFrames, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; import { wireInspectorTab } from '../../../components/inspectorTab.js'; @@ -1169,14 +1169,17 @@ export class CalltreeView extends LitElement { /** * Tell the inspector which frames the pointer is over, so it can mark the rows - * that stand for them. Nothing is picked and nothing moves. An - * Aggregated/Bottom-Up row merges many calls, so it names every call it counts. + * that stand for them. Nothing is picked and nothing moves. + * + * The direction is read as the pointer arrives: which way the table reads is + * what decides whether a merged row names its own frames or the calls they + * conducted. */ private _emitDetailLocate(table: Tabulator, source: DetailSource = 'calltree'): void { table.on('rowMouseEnter', (_e, row) => { eventBus.emit('detail:locate', { source, - eventIndexes: rowOccurrences(row, this.rootMethod), + eventIndexes: rowFrames(row, this.rootMethod, directionOf(this.viewMode)), }); }); table.on('rowMouseLeave', () => { From 09386e75ea77f3135b089fdac70892f92f8d3537 Mon Sep 17 00:00:00 2001 From: peternhale Date: Tue, 1 Sep 2026 10:58:48 -0600 Subject: [PATCH 09/61] refactor(lana): use URI-safe file access (#952) # PR overview Stack 2 of 4. Depends on #951. Makes log and workspace handling URI-native so Lana can operate against local and virtual filesystems. ## Changes made - Replace filesystem paths with VS Code Uri values or serialized URI strings. - Route reads, writes, existence checks, caching, and navigation through URI-safe services. - Support file, memfs, vscode-vfs, and other virtual workspace schemes. - Update log analysis, language detection, providers, source lookup, and workspace selection. - Update URI mocks and affected unit tests. ## Type of change - [x] Refactor ## Related issues related W-23939830 ## Validation - pnpm typecheck - Seven focused unit suites: 98 tests --------- Co-authored-by: Luke Cotter <81575432+lcottercertinia@users.noreply.github.com> --- lana/package.json | 3 +- lana/src/__tests__/helpers/test-builders.ts | 3 + lana/src/__tests__/mocks/vscode.ts | 50 +++-- lana/src/cache/LogEventCache.ts | 20 +- .../src/cache/__tests__/LogEventCache.test.ts | 24 ++- lana/src/codelenses/ShowAnalysisCodeLens.ts | 6 +- lana/src/commands/LogView.ts | 181 ++++++++++++------ lana/src/commands/RetrieveLogFile.ts | 18 +- lana/src/commands/ShowInLogAnalysis.ts | 18 +- lana/src/commands/ShowLogAnalysis.ts | 21 +- lana/src/commands/__tests__/LogView.test.ts | 91 +++++++++ .../__tests__/RetrieveLogFile.test.ts | 12 +- lana/src/decorations/RawLogLineDecoration.ts | 2 +- lana/src/display/Display.ts | 4 +- lana/src/display/OpenFileInPackage.ts | 6 +- lana/src/display/QuickPickWorkspace.ts | 27 ++- .../__tests__/OpenFileInPackage.test.ts | 10 +- lana/src/folding/RawLogFoldingProvider.ts | 9 +- .../__tests__/RawLogFoldingProvider.test.ts | 10 +- lana/src/hovers/RawLogHoverProvider.ts | 4 +- lana/src/language/ApexLogLanguageDetector.ts | 51 ++--- .../__tests__/ApexLogLanguageDetector.test.ts | 110 ++++++++++- lana/src/log-features/RawLogNavigation.ts | 12 +- lana/src/salesforce/codesymbol/SfdxProject.ts | 6 +- .../codesymbol/SfdxProjectReader.ts | 3 +- .../__tests__/SfdxProjectReader.test.ts | 6 +- lana/src/symbols/RawLogSymbolProvider.ts | 4 +- .../__tests__/RawLogSymbolProvider.test.ts | 2 +- lana/src/workspace/VSWorkspace.ts | 6 +- .../workspace/__tests__/VSWorkspace.test.ts | 6 +- lana/tsconfig.json | 2 +- pnpm-lock.yaml | 3 + 32 files changed, 510 insertions(+), 220 deletions(-) create mode 100644 lana/src/commands/__tests__/LogView.test.ts diff --git a/lana/package.json b/lana/package.json index 2a8ff26ad..a2e86a9a2 100644 --- a/lana/package.json +++ b/lana/package.json @@ -382,7 +382,8 @@ }, "dependencies": { "@apexdevtools/apex-parser": "5.1.0", - "effect": "^3.22.0" + "effect": "^3.22.0", + "vscode-uri": "^3.1.0" }, "devDependencies": { "@salesforce/vscode-services": "^67.12.0", diff --git a/lana/src/__tests__/helpers/test-builders.ts b/lana/src/__tests__/helpers/test-builders.ts index d6e3476ba..710660e5c 100644 --- a/lana/src/__tests__/helpers/test-builders.ts +++ b/lana/src/__tests__/helpers/test-builders.ts @@ -159,6 +159,7 @@ export function createMockApexLog(overrides: PartialApexLog = {}): ApexLog { export interface MockDisplay { output: jest.Mock; showErrorMessage: jest.Mock; + showFile: jest.Mock; showInformationMessage: jest.Mock; showWarningMessage: jest.Mock; } @@ -167,6 +168,7 @@ export function createMockDisplay(): MockDisplay { return { output: jest.fn(), showErrorMessage: jest.fn(), + showFile: jest.fn(), showInformationMessage: jest.fn(), showWarningMessage: jest.fn(), }; @@ -179,6 +181,7 @@ export interface MockContext { context: MockExtensionContext; display: MockDisplay; workspaces: { uri: { fsPath: string }; name: string }[]; + workspaceManager?: unknown; } /** diff --git a/lana/src/__tests__/mocks/vscode.ts b/lana/src/__tests__/mocks/vscode.ts index 9636105d5..9a435ecb5 100644 --- a/lana/src/__tests__/mocks/vscode.ts +++ b/lana/src/__tests__/mocks/vscode.ts @@ -12,6 +12,7 @@ // a drift from `@types/vscode` surfaces as ONE error at the factory, not at // every call site. import type { EndOfLine, TextDocument } from 'vscode'; +import { URI, Utils } from 'vscode-uri'; // Track subscriptions for cleanup const subscriptions: { dispose: jest.Mock }[] = []; @@ -110,36 +111,21 @@ export const ViewColumn = { } as const; export type ViewColumn = (typeof ViewColumn)[keyof typeof ViewColumn]; -// Mock Uri class +// Delegate URI semantics to vscode-uri so virtual URI tests match VS Code. export const Uri = { - file: jest.fn((path: string) => ({ - scheme: 'file', - authority: '', - path, - fsPath: path, - query: '', - fragment: '', - with: jest.fn(), - toString: jest.fn(() => `file://${path}`), - toJSON: jest.fn(() => ({ scheme: 'file', path, fsPath: path })), - })), - parse: jest.fn((value: string) => ({ - scheme: value.startsWith('file://') ? 'file' : 'unknown', - authority: '', - path: value.replace('file://', ''), - fsPath: value.replace('file://', ''), - query: '', - fragment: '', - with: jest.fn(), - toString: jest.fn(() => value), - })), - joinPath: jest.fn((base, ...pathSegments) => ({ - ...base, - path: [base.path, ...pathSegments].join('/'), - fsPath: [base.fsPath, ...pathSegments].join('/'), - })), + file: (path: string) => URI.file(path), + parse: (value: string) => URI.parse(value), + joinPath: (base: URI, ...pathSegments: string[]) => Utils.joinPath(base, ...pathSegments), }; +export class TabInputText { + readonly uri: ReturnType; + + constructor(uri: ReturnType) { + this.uri = uri; + } +} + // Mock RelativePattern (constructor used for glob searches) export const RelativePattern = jest.fn(); @@ -346,6 +332,12 @@ export const window = { replace: jest.fn(), })), createWebviewPanel: jest.fn(), + tabGroups: { + activeTabGroup: { activeTab: undefined as { input: unknown } | undefined }, + onDidChangeTabs: jest.fn((_listener: (event: unknown) => unknown) => ({ + dispose: jest.fn(), + })), + }, activeTextEditor: undefined as unknown, visibleTextEditors: [], onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })), @@ -372,6 +364,7 @@ export const commands = { // Mock languages export const languages = { + setTextDocumentLanguage: jest.fn().mockResolvedValue(undefined), registerFoldingRangeProvider: jest.fn((_selector, _provider) => { const disposable = { dispose: jest.fn() }; subscriptions.push(disposable); @@ -541,10 +534,12 @@ export const resetMocks = (): void => { // Reset workspace folders workspace.workspaceFolders = []; + workspace.textDocuments = []; // Reset active editor window.activeTextEditor = undefined; window.visibleTextEditors = []; + window.tabGroups.activeTabGroup.activeTab = undefined; }; // Export as default for module replacement @@ -554,6 +549,7 @@ export default { Selection, ViewColumn, Uri, + TabInputText, RelativePattern, FoldingRange, FoldingRangeKind, diff --git a/lana/src/cache/LogEventCache.ts b/lana/src/cache/LogEventCache.ts index 896ecedd6..5412da1e0 100644 --- a/lana/src/cache/LogEventCache.ts +++ b/lana/src/cache/LogEventCache.ts @@ -1,12 +1,12 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { readFile } from 'fs/promises'; import { workspace } from 'vscode'; import { parse, type ApexLog, type LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; +import { readFile } from '../services/salesforceServices.js'; export interface EventSearchResult { event: LogEvent; @@ -17,17 +17,17 @@ export class LogEventCache { private static readonly MAX_CACHE_SIZE = 10; private static cache = new Map(); - static async getApexLog(filePath: string): Promise { - const cached = LogEventCache.cache.get(filePath); + static async getApexLog(uriString: string): Promise { + const cached = LogEventCache.cache.get(uriString); if (cached) { // Move to end (most recently used) - LogEventCache.cache.delete(filePath); - LogEventCache.cache.set(filePath, cached); + LogEventCache.cache.delete(uriString); + LogEventCache.cache.set(uriString, cached); return cached; } try { - const content = await readFile(filePath, 'utf-8'); + const content = await readFile(uriString); const apexLog = parse(content); // Evict oldest if at capacity @@ -38,7 +38,7 @@ export class LogEventCache { } } - LogEventCache.cache.set(filePath, apexLog); + LogEventCache.cache.set(uriString, apexLog); return apexLog; } catch { return null; @@ -49,15 +49,15 @@ export class LogEventCache { return LogEventCache.searchEvents(apexLog.children, timestamp, 0); } - static clearCache(filePath: string): void { - LogEventCache.cache.delete(filePath); + static clearCache(uriString: string): void { + LogEventCache.cache.delete(uriString); } static apply(context: Context): void { context.context.subscriptions.push( workspace.onDidCloseTextDocument((doc) => { if (doc.languageId === 'apexlog') { - LogEventCache.clearCache(doc.uri.fsPath); + LogEventCache.clearCache(doc.uri.toString()); } }), ); diff --git a/lana/src/cache/__tests__/LogEventCache.test.ts b/lana/src/cache/__tests__/LogEventCache.test.ts index 8ca68198e..6a8964a20 100644 --- a/lana/src/cache/__tests__/LogEventCache.test.ts +++ b/lana/src/cache/__tests__/LogEventCache.test.ts @@ -2,7 +2,6 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { beforeEach, describe, expect, it } from '@jest/globals'; - import { workspace } from 'vscode'; import { @@ -12,18 +11,17 @@ import { } from '../../__tests__/helpers/test-builders.js'; import { LogEventCache } from '../LogEventCache.js'; -// Mock fs/promises -jest.mock('fs/promises', () => ({ - readFile: jest.fn(), -})); - // Mock apex-log-parser jest.mock('apex-log-parser', () => ({ parse: jest.fn(), })); import { parse } from 'apex-log-parser'; -import { readFile } from 'fs/promises'; +import { readFile } from '../../services/salesforceServices.js'; + +jest.mock('../../services/salesforceServices.js', () => ({ + readFile: jest.fn(), +})); const mockReadFile = readFile as jest.Mock; const mockParse = parse as jest.Mock; @@ -373,8 +371,8 @@ describe('LogEventCache', () => { await LogEventCache.getApexLog('/test/file.log'); // Capture the callback - let closeCallback: ((doc: { languageId: string; uri: { fsPath: string } }) => void) | null = - null; + let closeCallback: + ((doc: { languageId: string; uri: { toString: () => string } }) => void) | null = null; (workspace.onDidCloseTextDocument as jest.Mock).mockImplementationOnce((cb) => { closeCallback = cb; return { dispose: jest.fn() }; @@ -386,7 +384,7 @@ describe('LogEventCache', () => { // Simulate closing an apexlog document closeCallback!({ languageId: 'apexlog', - uri: { fsPath: '/test/file.log' }, + uri: { toString: () => '/test/file.log' }, }); // @ts-expect-error - accessing private static for testing @@ -401,8 +399,8 @@ describe('LogEventCache', () => { await LogEventCache.getApexLog('/test/file.log'); // Capture the callback - let closeCallback: ((doc: { languageId: string; uri: { fsPath: string } }) => void) | null = - null; + let closeCallback: + ((doc: { languageId: string; uri: { toString: () => string } }) => void) | null = null; (workspace.onDidCloseTextDocument as jest.Mock).mockImplementationOnce((cb) => { closeCallback = cb; return { dispose: jest.fn() }; @@ -414,7 +412,7 @@ describe('LogEventCache', () => { // Simulate closing a non-apexlog document closeCallback!({ languageId: 'javascript', - uri: { fsPath: '/test/file.log' }, + uri: { toString: () => '/test/file.log' }, }); // @ts-expect-error - accessing private static for testing diff --git a/lana/src/codelenses/ShowAnalysisCodeLens.ts b/lana/src/codelenses/ShowAnalysisCodeLens.ts index f215820fa..820a4a6b9 100644 --- a/lana/src/codelenses/ShowAnalysisCodeLens.ts +++ b/lana/src/codelenses/ShowAnalysisCodeLens.ts @@ -28,11 +28,7 @@ class ShowAnalysisCodeLens implements CodeLensProvider { } static apply(context: Context): void { - const docSelector = [ - { scheme: 'file', language: 'apexlog' }, - { scheme: 'file', pattern: '**/*.log' }, - { scheme: 'file', pattern: '**/*.txt' }, - ]; + const docSelector = [{ language: 'apexlog' }, { pattern: '**/*.log' }, { pattern: '**/*.txt' }]; const codeLensProviderDisposable = languages.registerCodeLensProvider( docSelector, diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index d025542fa..a1e87455c 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -1,16 +1,14 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { createReadStream, existsSync } from 'fs'; -import { writeFile } from 'fs/promises'; -import { homedir } from 'os'; -import { basename, dirname, join, parse } from 'path'; import { Uri, commands, window as vscWindow, workspace, type WebviewPanel } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; import { RawLogNavigation } from '../log-features/RawLogNavigation.js'; +import { fileOrFolderExists, readFile, writeFile } from '../services/salesforceServices.js'; import { PRIVATE_SECTIONS, getColumnOverrides, @@ -32,7 +30,7 @@ interface WebViewLogFileRequest { export class LogView { private static helpUrl = 'https://certinia.github.io/debug-log-analyzer/'; private static currentPanel: WebviewPanel | undefined; - private static currentLogPath: string | undefined; + private static currentLogUri: Uri | undefined; private static pendingNavigationTimestamp: number | undefined; static getCurrentView() { @@ -40,7 +38,11 @@ export class LogView { } static getLogPath() { - return LogView.currentLogPath; + return LogView.currentLogUri ? getLogDisplayPath(LogView.currentLogUri) : undefined; + } + + static getLogUri(): Uri | undefined { + return LogView.currentLogUri; } static setPendingNavigation(timestamp: number): void { @@ -50,22 +52,24 @@ export class LogView { static async createView( context: Context, beforeSendLog?: Promise, - logPath?: string, + logUri?: Uri, logData?: string, ): Promise { - const panel = WebView.apply('logFile', `Log: ${logPath ? basename(logPath) : 'Untitled'}`, [ - Uri.file(join(context.context.extensionPath, 'out')), - Uri.file(dirname(logPath || '')), + const logName = logUri ? Utils.basename(logUri) : 'Untitled'; + const logDir = logUri ? Utils.dirname(logUri) : context.context.extensionUri; + const panel = WebView.apply('logFile', `Log: ${logName}`, [ + Utils.joinPath(context.context.extensionUri, 'out'), + logDir, ]); this.currentPanel = panel; - this.currentLogPath = logPath; + this.currentLogUri = logUri; - const logViewerRoot = join(context.context.extensionPath, 'out'); - const index = join(logViewerRoot, 'index.html'); - const bundleUri = panel.webview.asWebviewUri(Uri.file(join(logViewerRoot, 'bundle.js'))); - const codiconUri = panel.webview.asWebviewUri(Uri.file(join(logViewerRoot, 'codicon.css'))); + const logViewerRoot = Utils.joinPath(context.context.extensionUri, 'out'); + const index = Utils.joinPath(logViewerRoot, 'index.html'); + const bundleUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'bundle.js')); + const codiconUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'codicon.css')); const indexSrc = await this.getFile(index); - panel.iconPath = Uri.file(join(logViewerRoot, 'certinia-icon-color.png')); + panel.iconPath = Utils.joinPath(logViewerRoot, 'certinia-icon-color.png'); panel.webview.html = indexSrc .replace(/bundle\.js/gi, bundleUri.toString(true)) .replace(/codicon\.css/gi, codiconUri.toString(true)); @@ -90,7 +94,7 @@ export class LogView { () => { configListener.dispose(); this.currentPanel = undefined; - this.currentLogPath = undefined; + this.currentLogUri = undefined; }, undefined, context.context.subscriptions, @@ -98,27 +102,31 @@ export class LogView { panel.webview.onDidReceiveMessage( async (msg: WebViewLogFileRequest) => { + if (!isWebViewLogFileRequest(msg)) { + return; + } const { cmd, requestId, payload } = msg; switch (cmd) { case 'fetchLog': { + if (!requestId) { + break; + } await beforeSendLog; - LogView.sendLog(requestId, panel, context, logPath, logData); + await LogView.sendLog(requestId, panel, context, logUri, logData); break; } case 'openPath': { - const filePath = payload as string; - if (filePath) { - context.display.showFile(filePath); + if (logUri) { + context.display.showFile(logUri); } break; } case 'openType': { - const symbol = payload as string; - if (symbol) { - await OpenFileInPackage.openFileForSymbol(context, symbol); + if (typeof payload === 'string' && payload) { + await OpenFileInPackage.openFileForSymbol(context, payload); } break; } @@ -148,8 +156,8 @@ export class LogView { } case 'updateConfig': { - const { section, value } = payload as { section: string; value: unknown }; - if (section) { + if (isConfigUpdate(payload)) { + const { section, value } = payload; if ((PRIVATE_SECTIONS as readonly string[]).includes(section)) { updatePrivateSection(context.context.globalState, section, value); } else { @@ -160,20 +168,16 @@ export class LogView { } case 'saveFile': { - const { fileContent, options } = payload as { - fileContent: string; - options: { defaultFileName?: string }; - }; - - if (fileContent && options?.defaultFileName) { + if (isSaveFileRequest(payload)) { + const { fileContent, options } = payload; const defaultWorkspace = (workspace.workspaceFolders || [])[0]; - const defaultDir = defaultWorkspace?.uri.path || homedir(); + const defaultDir = defaultWorkspace?.uri ?? context.context.extensionUri; const destinationFile = await vscWindow.showSaveDialog({ - defaultUri: Uri.file(join(defaultDir, options.defaultFileName)), + defaultUri: Utils.joinPath(defaultDir, options.defaultFileName), }); if (destinationFile) { - writeFile(destinationFile.fsPath, fileContent).catch((error) => { + writeFile(destinationFile, fileContent).catch((error) => { const msg = error instanceof Error ? error.message : String(error); vscWindow.showErrorMessage(`Unable to save file: ${msg}`); }); @@ -183,17 +187,15 @@ export class LogView { } case 'showError': { - const { text } = payload as { text: string }; - if (text) { - vscWindow.showErrorMessage(text); + if (isTextPayload(payload)) { + vscWindow.showErrorMessage(payload.text); } break; } case 'goToLogLine': { - const { timestamp } = payload as { timestamp: number }; - if (timestamp && LogView.currentLogPath) { - RawLogNavigation.goToLineByTimestamp(LogView.currentLogPath, timestamp); + if (isTimestampPayload(payload) && logUri) { + await RawLogNavigation.goToLineByTimestamp(logUri, payload.timestamp); } break; } @@ -226,36 +228,24 @@ export class LogView { return config; } - private static async getFile(filePath: string): Promise { - let data = ''; - return new Promise((resolve, reject) => { - createReadStream(filePath) - .on('error', (error) => { - reject(error); - }) - .on('data', (row) => { - data += row; - }) - .on('end', () => { - resolve(data); - }); - }); + private static async getFile(fileUri: Uri): Promise { + return readFile(fileUri); } - private static sendLog( + private static async sendLog( requestId: string, panel: WebviewPanel, context: Context, - logFilePath?: string, + logUri?: Uri, logData?: string, ) { - if (!logData && !existsSync(logFilePath || '')) { + if (!logData && logUri && !(await fileOrFolderExists(logUri))) { context.display.showErrorMessage('Log file could not be found.', { modal: true, }); + return; } - const filePath = parse(logFilePath || ''); const navigateToTimestamp = LogView.pendingNavigationTimestamp; LogView.pendingNavigationTimestamp = undefined; @@ -263,12 +253,79 @@ export class LogView { requestId, cmd: 'fetchLog', payload: { - logName: filePath.base, - logUri: logFilePath ? panel.webview.asWebviewUri(Uri.file(logFilePath)).toString(true) : '', - logPath: logFilePath, + logName: logUri ? Utils.basename(logUri) : '', + logUri: logUri ? panel.webview.asWebviewUri(logUri).toString(true) : '', + logPath: logUri ? getLogDisplayPath(logUri) : undefined, logData: logData, navigateToTimestamp, }, }); } } + +function getLogDisplayPath(logUri: Uri): string { + return ( + workspace.asRelativePath(logUri, true) || + (logUri.scheme === 'file' ? logUri.fsPath : logUri.path) + ); +} + +function isWebViewLogFileRequest(value: unknown): value is WebViewLogFileRequest { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).cmd === 'string' && + ((value as Record).requestId === undefined || + typeof (value as Record).requestId === 'string') + ); +} + +function isConfigUpdate(value: unknown): value is { section: string; value: unknown } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).section === 'string' && + Boolean((value as Record).section) + ); +} + +function isSaveFileRequest( + value: unknown, +): value is { fileContent: string; options: { defaultFileName: string } } { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const payload = value as Record; + const options = payload.options; + return ( + typeof payload.fileContent === 'string' && + Boolean(payload.fileContent) && + typeof options === 'object' && + options !== null && + !Array.isArray(options) && + typeof (options as Record).defaultFileName === 'string' && + Boolean((options as Record).defaultFileName) + ); +} + +function isTextPayload(value: unknown): value is { text: string } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).text === 'string' && + Boolean((value as Record).text) + ); +} + +function isTimestampPayload(value: unknown): value is { timestamp: number } { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + typeof (value as Record).timestamp === 'number' && + Number.isFinite((value as Record).timestamp) + ); +} diff --git a/lana/src/commands/RetrieveLogFile.ts b/lana/src/commands/RetrieveLogFile.ts index 656e12ad9..0b95528a4 100644 --- a/lana/src/commands/RetrieveLogFile.ts +++ b/lana/src/commands/RetrieveLogFile.ts @@ -1,7 +1,6 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { join } from 'path'; import { window, workspace, @@ -9,6 +8,7 @@ import { type QuickPickItem, type WebviewPanel, } from 'vscode'; +import { Utils } from 'vscode-uri'; import { appName } from '../AppSettings.js'; import type { Context } from '../Context.js'; @@ -56,8 +56,8 @@ export class RetrieveLogFile { return; } - const workspacePath = workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspacePath) { + const workspaceFolder = workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { throw new Error('No workspace selected'); } const loadingPicker = RetrieveLogFile.showLoadingPicker(); @@ -65,27 +65,27 @@ export class RetrieveLogFile { const logFiles = await salesforceServices.listLogs(); const logFileId = await RetrieveLogFile.getLogFile(logFiles); if (logFileId) { - const logFilePath = join( - workspacePath, + const logUri = Utils.joinPath( + workspaceFolder.uri, '.sfdx', 'tools', 'debug', 'logs', `${logFileId}.log`, ); - if (await salesforceServices.fileOrFolderExists(logFilePath)) { - return LogView.createView(context, Promise.resolve(), logFilePath); + if (await salesforceServices.fileOrFolderExists(logUri)) { + return LogView.createView(context, Promise.resolve(), logUri); } const logData = await salesforceServices.getLogBody(logFileId); this.assertRetrievedLog(logFileId, logData); try { - await salesforceServices.writeFile(logFilePath, logData); + await salesforceServices.writeFile(logUri, logData); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); context.display.output(`Unable to cache retrieved log: ${message}`, true); } - return LogView.createView(context, undefined, logFilePath, logData); + return LogView.createView(context, undefined, logUri, logData); } } finally { loadingPicker.dispose(); diff --git a/lana/src/commands/ShowInLogAnalysis.ts b/lana/src/commands/ShowInLogAnalysis.ts index 7a87a6bcf..b1a9879a6 100644 --- a/lana/src/commands/ShowInLogAnalysis.ts +++ b/lana/src/commands/ShowInLogAnalysis.ts @@ -1,7 +1,7 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import { window } from 'vscode'; +import { Uri, window } from 'vscode'; import type { Context } from '../Context.js'; import { Command } from './Command.js'; @@ -30,21 +30,21 @@ export class ShowInLogAnalysis { } const panel = LogView.getCurrentView(); - const logPath = LogView.getLogPath(); + const currentLogUri = LogView.getLogUri(); // If panel doesn't exist, open the log analysis view first if (!panel) { const activeEditor = window.activeTextEditor; - const logFilePath = filePath ?? activeEditor?.document.uri.fsPath; + const logUri = filePath ? Uri.parse(filePath) : activeEditor?.document.uri; - if (!logFilePath) { + if (!logUri) { context.display.showInformationMessage('No active Apex log file.'); return; } // Set pending navigation so it's sent after log is parsed LogView.setPendingNavigation(timestamp); - await LogView.createView(context, Promise.resolve(), logFilePath); + await LogView.createView(context, Promise.resolve(), logUri); return; // Navigation will happen via fetchLog payload } else { // Panel exists - reveal it first @@ -52,10 +52,14 @@ export class ShowInLogAnalysis { // Verify we're navigating to the same log const activeEditor = window.activeTextEditor; - if (logPath && activeEditor && activeEditor.document.uri.fsPath !== logPath) { + if ( + currentLogUri && + activeEditor && + activeEditor.document.uri.toString() !== currentLogUri.toString() + ) { // Different log file is active, open the active one LogView.setPendingNavigation(timestamp); - await LogView.createView(context, Promise.resolve(), activeEditor.document.uri.fsPath); + await LogView.createView(context, Promise.resolve(), activeEditor.document.uri); return; // Navigation will happen via fetchLog payload } } diff --git a/lana/src/commands/ShowLogAnalysis.ts b/lana/src/commands/ShowLogAnalysis.ts index 1f9028968..5e7344dbf 100644 --- a/lana/src/commands/ShowLogAnalysis.ts +++ b/lana/src/commands/ShowLogAnalysis.ts @@ -1,12 +1,11 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { existsSync } from 'fs'; -import type { Uri } from 'vscode'; -import { window } from 'vscode'; +import { TabInputText, window, type Uri } from 'vscode'; import { appName } from '../AppSettings.js'; import type { Context } from '../Context.js'; +import { fileOrFolderExists } from '../services/salesforceServices.js'; import { Command } from './Command.js'; import { LogView } from './LogView.js'; @@ -33,12 +32,13 @@ export class ShowLogAnalysis { } private static async command(context: Context, uri: Uri): Promise { - const filePath = uri?.fsPath || window?.activeTextEditor?.document.fileName || ''; - const fileContent = !existsSync(filePath) ? window?.activeTextEditor?.document.getText() : ''; + const activeTab = window.tabGroups.activeTabGroup.activeTab; + const logUri = + uri || + window.activeTextEditor?.document.uri || + (activeTab?.input instanceof TabInputText ? activeTab.input.uri : undefined); - if (filePath || fileContent) { - LogView.createView(context, Promise.resolve(), filePath, fileContent); - } else { + if (!logUri) { context.display.showErrorMessage( 'No file selected or the file is too large. Try again using the file explorer or text editor command.', ); @@ -46,5 +46,10 @@ export class ShowLogAnalysis { 'No file selected or the file is too large. Try again using the file explorer or text editor command.', ); } + + const fileContent = (await fileOrFolderExists(logUri)) + ? undefined + : window.activeTextEditor?.document.getText(); + await LogView.createView(context, Promise.resolve(), logUri, fileContent); } } diff --git a/lana/src/commands/__tests__/LogView.test.ts b/lana/src/commands/__tests__/LogView.test.ts new file mode 100644 index 000000000..08e758d2f --- /dev/null +++ b/lana/src/commands/__tests__/LogView.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { createMockContext } from '../../__tests__/helpers/test-builders.js'; +import { Uri, workspace } from '../../__tests__/mocks/vscode.js'; +import { WebView } from '../../display/WebView.js'; +import { readFile } from '../../services/salesforceServices.js'; +import { LogView } from '../LogView.js'; + +jest.mock('../../display/WebView.js', () => ({ + WebView: { apply: jest.fn() }, +})); +jest.mock('../../services/salesforceServices.js', () => ({ + fileOrFolderExists: jest.fn(), + readFile: jest.fn(), + writeFile: jest.fn(), +})); +jest.mock('../../workspace/AppConfig.js', () => ({ + PRIVATE_SECTIONS: [], + getColumnOverrides: jest.fn(() => ({})), + getColumnViews: jest.fn(() => ({})), + getConfig: jest.fn(() => ({ + timeline: {}, + callTree: { columnOverrides: {} }, + database: { + soql: { columnView: 'General', columnOverrides: {} }, + dml: { columnView: 'General', columnOverrides: {} }, + sosl: { columnView: 'General', columnOverrides: {} }, + }, + inspector: {}, + })), + getInspectorState: jest.fn(() => ({})), + sameConfig: jest.fn(() => true), + updateConfig: jest.fn(), + updatePrivateSection: jest.fn(), +})); + +const mockApplyWebView = WebView.apply as jest.Mock; +const mockReadFile = readFile as jest.Mock; + +describe('LogView', () => { + it('uses a display path in the payload and the captured URI for open actions', async () => { + let receiveMessage: ((message: unknown) => Promise) | undefined; + const postMessage = jest.fn().mockResolvedValue(true); + const panel = { + iconPath: undefined, + onDidDispose: jest.fn(() => ({ dispose: jest.fn() })), + reveal: jest.fn(), + webview: { + asWebviewUri: jest.fn((uri: { path: string }) => Uri.parse(`webview:${uri.path}`)), + html: '', + onDidReceiveMessage: jest.fn((listener: (message: unknown) => Promise) => { + receiveMessage = listener; + return { dispose: jest.fn() }; + }), + postMessage, + }, + }; + mockApplyWebView.mockReturnValue(panel as unknown as import('vscode').WebviewPanel); + mockReadFile.mockResolvedValue(''); + workspace.asRelativePath.mockReturnValue('workspace/logs/virtual.log'); + const context = createMockContext(); + const logUri = Uri.parse('memfs:/repository/logs/virtual.log'); + + await LogView.createView( + context as unknown as import('../../Context.js').Context, + Promise.resolve(), + logUri, + 'log body', + ); + await receiveMessage?.({ cmd: 'fetchLog', requestId: 'request-1' }); + + expect(postMessage).toHaveBeenCalledWith({ + requestId: 'request-1', + cmd: 'fetchLog', + payload: { + logName: 'virtual.log', + logUri: 'webview:/repository/logs/virtual.log', + logPath: 'workspace/logs/virtual.log', + logData: 'log body', + navigateToTimestamp: undefined, + }, + }); + + await receiveMessage?.({ cmd: 'openPath', payload: 'file:///untrusted.log' }); + + expect(context.display.showFile).toHaveBeenCalledWith(logUri); + }); +}); diff --git a/lana/src/commands/__tests__/RetrieveLogFile.test.ts b/lana/src/commands/__tests__/RetrieveLogFile.test.ts index 4d0e1f6d6..a84adf872 100644 --- a/lana/src/commands/__tests__/RetrieveLogFile.test.ts +++ b/lana/src/commands/__tests__/RetrieveLogFile.test.ts @@ -122,16 +122,16 @@ describe('RetrieveLogFile', () => { expect(mockGetLogBody).toHaveBeenCalledWith('selected-log'); expect(mockFileOrFolderExists).toHaveBeenCalledWith( - expect.stringContaining('selected-log.log'), + expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), ); expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining('selected-log.log'), + expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), 'log body', ); expect(mockCreateView).toHaveBeenCalledWith( context, undefined, - expect.stringContaining('selected-log.log'), + expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), 'log body', ); }); @@ -149,11 +149,11 @@ describe('RetrieveLogFile', () => { await command()(); expect(mockWriteFile).toHaveBeenCalledWith( - expect.stringContaining('/test/first-workspace'), + expect.objectContaining({ path: expect.stringContaining('/test/first-workspace') }), 'log body', ); expect(mockWriteFile).not.toHaveBeenCalledWith( - expect.stringContaining('/test/second-workspace'), + expect.objectContaining({ path: expect.stringContaining('/test/second-workspace') }), expect.anything(), ); }); @@ -171,7 +171,7 @@ describe('RetrieveLogFile', () => { expect(mockCreateView).toHaveBeenCalledWith( context, expect.any(Promise), - expect.stringContaining('cached-log.log'), + expect.objectContaining({ path: expect.stringContaining('cached-log.log') }), ); }); diff --git a/lana/src/decorations/RawLogLineDecoration.ts b/lana/src/decorations/RawLogLineDecoration.ts index daf8d264a..5a9048840 100644 --- a/lana/src/decorations/RawLogLineDecoration.ts +++ b/lana/src/decorations/RawLogLineDecoration.ts @@ -88,7 +88,7 @@ export class RawLogLineDecoration { } const timestamp = parseInt(match[1], 10); - const filePath = document.uri.fsPath; + const filePath = document.uri.toString(); const apexLog = await LogEventCache.getApexLog(filePath); if (!apexLog) { diff --git a/lana/src/display/Display.ts b/lana/src/display/Display.ts index 3a33b33d0..fe1c50fab 100644 --- a/lana/src/display/Display.ts +++ b/lana/src/display/Display.ts @@ -23,7 +23,7 @@ export class Display { window.showErrorMessage(s, options); } - showFile(path: string, options: TextDocumentShowOptions = {}): void { - commands.executeCommand('vscode.open', Uri.file(path.trim()), options); + showFile(uri: Uri | string, options: TextDocumentShowOptions = {}): void { + commands.executeCommand('vscode.open', typeof uri === 'string' ? Uri.parse(uri) : uri, options); } } diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index ab8f6ce48..482ee7479 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -1,8 +1,8 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { basename } from 'path'; import { Position, Selection, ViewColumn, workspace, type TextDocumentShowOptions } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; import { getMethodLine, parseApex } from '../salesforce/ApexParser/ApexSymbolLocator.js'; @@ -31,7 +31,7 @@ export class OpenFileInPackage { if (!symbolLocation.isExactMatch) { context.display.showErrorMessage( - `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${basename(uri.fsPath)}'`, + `Symbol '${symbolLocation.missingSymbol}' could not be found in file '${Utils.basename(uri)}'`, ); } const zeroIndexedLineNumber = symbolLocation.line - 1; @@ -44,7 +44,7 @@ export class OpenFileInPackage { selection: new Selection(pos, pos), }; - context.display.showFile(uri.fsPath, options); + context.display.showFile(uri, options); } catch (err) { const message = err instanceof Error ? err.message : String(err); context.display.showErrorMessage(`Unable to open '${symbolName}': ${message}`); diff --git a/lana/src/display/QuickPickWorkspace.ts b/lana/src/display/QuickPickWorkspace.ts index 0f81f10c6..16763b750 100644 --- a/lana/src/display/QuickPickWorkspace.ts +++ b/lana/src/display/QuickPickWorkspace.ts @@ -1,32 +1,47 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { parse } from 'path'; import { window } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; +import { VSWorkspace } from '../workspace/VSWorkspace.js'; import { Item, Options, QuickPick } from './QuickPick.js'; export class QuickPickWorkspace { - static async pickOrReturn(context: Context): Promise { + static async pickOrReturn(context: Context): Promise { const workspaceFolders = context.workspaceManager.workspaceFolders; if (workspaceFolders.length > 1) { const [workspace] = await QuickPick.pick( - workspaceFolders.map((ws) => new Item(ws.name(), ws.path(), '')), + workspaceFolders.map((ws) => new Item(ws.name(), ws.uri, '')), new Options('Select a workspace:'), ); if (workspace) { - return workspace.description; + const selectedWorkspace = workspaceFolders.find((ws) => ws.uri === workspace.description); + if (!selectedWorkspace) { + throw new Error('Selected workspace not found'); + } + return selectedWorkspace; } else { throw new Error('No workspace selected'); } } else if (workspaceFolders.length === 1) { - return workspaceFolders[0]?.path() || ''; + const selectedWorkspace = workspaceFolders[0]; + if (!selectedWorkspace) { + throw new Error('No workspace available'); + } + return selectedWorkspace; } else { if (window.activeTextEditor) { - return parse(window.activeTextEditor.document.fileName).dir; + const documentUri = window.activeTextEditor.document.uri; + const folderUri = Utils.dirname(documentUri); + return new VSWorkspace({ + uri: folderUri, + name: Utils.basename(folderUri), + index: 0, + }); } else { throw new Error('No workspace selected'); } diff --git a/lana/src/display/__tests__/OpenFileInPackage.test.ts b/lana/src/display/__tests__/OpenFileInPackage.test.ts index d76f6ec3a..6a0aa871e 100644 --- a/lana/src/display/__tests__/OpenFileInPackage.test.ts +++ b/lana/src/display/__tests__/OpenFileInPackage.test.ts @@ -81,7 +81,7 @@ describe('OpenFileInPackage.openFileForSymbol', () => { const { context, workspaceManager, display } = createContext(); workspaceManager.findSymbol.mockResolvedValue({ status: 'found', - uri: { fsPath: '/ws/force-app/MyClass.cls' }, + uri: { path: '/ws/force-app/MyClass.cls', fsPath: '/ws/force-app/MyClass.cls' }, }); mockGetMethodLine.mockReturnValue({ line: 12, character: 4, isExactMatch: true }); @@ -93,8 +93,8 @@ describe('OpenFileInPackage.openFileForSymbol', () => { ); expect(display.showErrorMessage).not.toHaveBeenCalled(); expect(display.showFile).toHaveBeenCalledTimes(1); - const [path, options] = display.showFile.mock.calls[0]; - expect(path).toBe('/ws/force-app/MyClass.cls'); + const [uri, options] = display.showFile.mock.calls[0]; + expect(uri).toEqual(expect.objectContaining({ fsPath: '/ws/force-app/MyClass.cls' })); // line is converted to zero-indexed; character used as-is expect(options.selection.start).toEqual(expect.objectContaining({ line: 11, character: 4 })); expect(options.viewColumn).toBe(-1); @@ -104,7 +104,7 @@ describe('OpenFileInPackage.openFileForSymbol', () => { const { context, workspaceManager, display } = createContext(); workspaceManager.findSymbol.mockResolvedValue({ status: 'found', - uri: { fsPath: '/ws/MyClass.cls' }, + uri: { path: '/ws/MyClass.cls', fsPath: '/ws/MyClass.cls' }, }); mockGetMethodLine.mockReturnValue({ line: 3, isExactMatch: true }); @@ -118,7 +118,7 @@ describe('OpenFileInPackage.openFileForSymbol', () => { const { context, workspaceManager, display } = createContext(); workspaceManager.findSymbol.mockResolvedValue({ status: 'found', - uri: { fsPath: '/ws/force-app/MyClass.cls' }, + uri: { path: '/ws/force-app/MyClass.cls', fsPath: '/ws/force-app/MyClass.cls' }, }); mockGetMethodLine.mockReturnValue({ line: 1, diff --git a/lana/src/folding/RawLogFoldingProvider.ts b/lana/src/folding/RawLogFoldingProvider.ts index a2998675c..e69ac74e6 100644 --- a/lana/src/folding/RawLogFoldingProvider.ts +++ b/lana/src/folding/RawLogFoldingProvider.ts @@ -28,8 +28,7 @@ class RawLogFoldingProvider implements FoldingRangeProvider { document: TextDocument, _context: FoldingContext, ): Promise { - const filePath = document.uri.fsPath; - const apexLog = await LogEventCache.getApexLog(filePath); + const apexLog = await LogEventCache.getApexLog(document.uri.toString()); if (!apexLog) { return []; @@ -87,11 +86,11 @@ class RawLogFoldingProvider implements FoldingRangeProvider { * unrelated action forces a re-evaluation. */ private warmAndSignal(document: TextDocument): void { - if (document.uri.scheme !== 'file' || !isApexLogContent(document)) { + if (!isApexLogContent(document)) { return; } - void LogEventCache.getApexLog(document.uri.fsPath).then((apexLog) => { + void LogEventCache.getApexLog(document.uri.toString()).then((apexLog) => { if (apexLog) { this.changeEmitter.fire(); } @@ -99,7 +98,7 @@ class RawLogFoldingProvider implements FoldingRangeProvider { } static apply(context: Context): void { - const docSelector = [{ scheme: 'file', language: 'apexlog' }]; + const docSelector = [{ language: 'apexlog' }]; const provider = new RawLogFoldingProvider(); context.context.subscriptions.push( diff --git a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts index 6012b0c89..610daef18 100644 --- a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts +++ b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts @@ -312,7 +312,7 @@ describe('RawLogFoldingProvider', () => { expect(languages.registerFoldingRangeProvider).toHaveBeenCalledTimes(1); expect(languages.registerFoldingRangeProvider).toHaveBeenCalledWith( - [{ scheme: 'file', language: 'apexlog' }], + [{ language: 'apexlog' }], expect.any(RawLogFoldingProvider), ); }); @@ -354,7 +354,7 @@ describe('RawLogFoldingProvider', () => { return { registeredProvider, openHandler, activeEditorHandler }; } - const flush = () => new Promise((resolve) => setImmediate(resolve)); + const flush = () => new Promise((resolve) => queueMicrotask(resolve)); it('warms the cache and fires onDidChangeFoldingRanges when an apex log opens', async () => { const { registeredProvider, openHandler } = applyAndCapture(); @@ -366,7 +366,7 @@ describe('RawLogFoldingProvider', () => { openHandler(doc); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('/test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith('file:///test/file.log'); expect(fired).toHaveBeenCalledTimes(1); }); @@ -380,7 +380,7 @@ describe('RawLogFoldingProvider', () => { activeEditorHandler({ document: doc }); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('/test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith('file:///test/file.log'); expect(fired).toHaveBeenCalledTimes(1); }); @@ -407,7 +407,7 @@ describe('RawLogFoldingProvider', () => { openHandler(doc); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('/test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith('file:///test/file.log'); expect(fired).not.toHaveBeenCalled(); }); }); diff --git a/lana/src/hovers/RawLogHoverProvider.ts b/lana/src/hovers/RawLogHoverProvider.ts index 8403eb510..0cd1b8dad 100644 --- a/lana/src/hovers/RawLogHoverProvider.ts +++ b/lana/src/hovers/RawLogHoverProvider.ts @@ -25,7 +25,7 @@ class RawLogHoverProvider implements HoverProvider { } const timestamp = parseInt(match[1], 10); - return this.buildHover(document.uri.fsPath, timestamp); + return this.buildHover(document.uri.toString(), timestamp); } private async buildHover(filePath: string, timestamp: number): Promise { @@ -51,7 +51,7 @@ class RawLogHoverProvider implements HoverProvider { } static apply(context: Context): void { - const docSelector = [{ scheme: 'file', language: 'apexlog' }]; + const docSelector = [{ language: 'apexlog' }]; const hoverProviderDisposable = languages.registerHoverProvider( docSelector, diff --git a/lana/src/language/ApexLogLanguageDetector.ts b/lana/src/language/ApexLogLanguageDetector.ts index b9a021172..f79cf4e92 100644 --- a/lana/src/language/ApexLogLanguageDetector.ts +++ b/lana/src/language/ApexLogLanguageDetector.ts @@ -1,9 +1,6 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { closeSync, openSync, readSync } from 'node:fs'; -import { extname } from 'node:path'; - import { TabInputText, commands, @@ -13,6 +10,7 @@ import { type TextDocument, type Uri, } from 'vscode'; +import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; @@ -21,6 +19,8 @@ const EXECUTION_STARTED = /^\d{2}:\d{2}:\d{2}\.\d{1,} \(\d+\)\|EXECUTION_STARTED const USER_INFO = /^\d{2}:\d{2}:\d{2}\.\d{1,} \(\d+\)\|USER_INFO\|/; const DETECT_EXTENSIONS = new Set(['.log', '.txt']); const MAX_LINES_TO_CHECK = 100; +const MAX_BYTES_TO_READ = 4096; +let contextUpdateGeneration = 0; export function isApexLogContent(doc: TextDocument): boolean { if (doc.lineCount === 0) { @@ -38,18 +38,9 @@ export function isApexLogContent(doc: TextDocument): boolean { return false; } -function isApexLogFile(fsPath: string): boolean { - let fd: number; - try { - fd = openSync(fsPath, 'r'); - } catch { - return false; - } - +export async function isApexLogFile(uri: Uri): Promise { try { - const buf = Buffer.alloc(4096); - const bytesRead = readSync(fd, buf, 0, 4096, 0); - const text = buf.toString('utf8', 0, bytesRead); + const text = await readFilePrefix(uri); const lines = text.split(/\r?\n/); const linesToCheck = Math.min(MAX_LINES_TO_CHECK, lines.length); @@ -60,13 +51,18 @@ function isApexLogFile(fsPath: string): boolean { } } return false; - } finally { - closeSync(fd); + } catch { + return false; } } +async function readFilePrefix(uri: Uri): Promise { + const bytes = await workspace.fs.readFile(uri); + return new TextDecoder().decode(bytes.subarray(0, MAX_BYTES_TO_READ)); +} + function hasDetectExtension(uri: Uri): boolean { - return DETECT_EXTENSIONS.has(extname(uri.fsPath).toLowerCase()); + return DETECT_EXTENSIONS.has(Utils.extname(uri).toLowerCase()); } function getActiveTabUri(): Uri | undefined { @@ -78,8 +74,9 @@ function getActiveTabUri(): Uri | undefined { } function updateContextKey(): void { + const generation = ++contextUpdateGeneration; const editor = window.activeTextEditor; - if (editor && editor.document.uri.scheme === 'file') { + if (editor) { const doc = editor.document; if (hasDetectExtension(doc.uri)) { const detected = isApexLogContent(doc); @@ -92,9 +89,19 @@ function updateContextKey(): void { // Fallback to tab API for large files where activeTextEditor is undefined const tabUri = getActiveTabUri(); - if (tabUri && tabUri.scheme === 'file' && hasDetectExtension(tabUri)) { - const detected = isApexLogFile(tabUri.fsPath); - commands.executeCommand('setContext', 'lana.isApexLog', detected); + if (tabUri && hasDetectExtension(tabUri)) { + const tabKey = tabUri.toString(); + void isApexLogFile(tabUri).then((detected) => { + const activeTabUri = getActiveTabUri(); + if ( + generation !== contextUpdateGeneration || + window.activeTextEditor || + activeTabUri?.toString() !== tabKey + ) { + return; + } + commands.executeCommand('setContext', 'lana.isApexLog', detected); + }); return; } @@ -132,7 +139,7 @@ export class ApexLogLanguageDetector { } function detectAndSetLanguage(doc: TextDocument): void { - if (doc.languageId === 'apexlog' || doc.uri.scheme !== 'file') { + if (doc.languageId === 'apexlog') { return; } diff --git a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts index 1689c05b1..67827c5bf 100644 --- a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts +++ b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts @@ -3,8 +3,21 @@ */ import { describe, expect, it } from '@jest/globals'; +import { createMockContext } from '../../__tests__/helpers/test-builders.js'; import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; -import { isApexLogContent } from '../ApexLogLanguageDetector.js'; +import { + TabInputText, + Uri, + commands, + languages, + window, + workspace, +} from '../../__tests__/mocks/vscode.js'; +import { + ApexLogLanguageDetector, + isApexLogContent, + isApexLogFile, +} from '../ApexLogLanguageDetector.js'; describe('isApexLogContent', () => { it('should detect standard log with settings header on line 1', () => { @@ -94,3 +107,98 @@ describe('isApexLogContent', () => { expect(isApexLogContent(doc)).toBe(false); }); }); + +describe('isApexLogFile', () => { + it('decodes only the first 4 KB returned by the filesystem provider', async () => { + const prefix = 'not an Apex log'.padEnd(4096, ' '); + workspace.fs.readFile.mockResolvedValue( + new TextEncoder().encode(`${prefix}09:45:31.888 (1000)|EXECUTION_STARTED`), + ); + const uri = Uri.file('/logs/large.log'); + + await expect(isApexLogFile(uri)).resolves.toBe(false); + + expect(workspace.fs.readFile).toHaveBeenCalledWith(uri); + }); + + it('uses the registered filesystem provider for an arbitrary URI scheme', async () => { + workspace.fs.readFile.mockResolvedValue( + new TextEncoder().encode('09:45:31.888 (1000)|EXECUTION_STARTED'), + ); + const uri = Uri.parse('git:/repository/logs/virtual.log'); + + await expect(isApexLogFile(uri)).resolves.toBe(true); + + expect(workspace.fs.readFile).toHaveBeenCalledWith(uri); + }); +}); + +describe('ApexLogLanguageDetector', () => { + it.each(['log', 'txt'])('detects .%s Apex logs from arbitrary URI schemes', (extension) => { + const doc = createMockTextDocument({ + languageId: 'plaintext', + lines: ['09:45:31.888 (1000)|EXECUTION_STARTED'], + }); + Object.defineProperty(doc, 'uri', { + value: Uri.parse(`git:/repository/logs/virtual.${extension}`), + }); + workspace.textDocuments = [doc]; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(languages.setTextDocumentLanguage).toHaveBeenCalledWith(doc, 'apexlog'); + }); + + it('retains the existing extension prefilter', () => { + const doc = createMockTextDocument({ + languageId: 'plaintext', + lines: ['09:45:31.888 (1000)|EXECUTION_STARTED'], + }); + Object.defineProperty(doc, 'uri', { value: Uri.parse('git:/repository/logs/virtual.json') }); + workspace.textDocuments = [doc]; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(languages.setTextDocumentLanguage).not.toHaveBeenCalled(); + }); + + it('does not publish a stale async result after the active tab changes', async () => { + let resolveSlowRead: ((bytes: Uint8Array) => void) | undefined; + const slowRead = new Promise((resolve) => { + resolveSlowRead = resolve; + }); + const slowUri = Uri.parse('memfs:/logs/slow.log'); + const fastUri = Uri.parse('memfs:/logs/fast.log'); + workspace.fs.readFile.mockImplementation((uri: { path: string }) => + uri.path === slowUri.path + ? slowRead + : Promise.resolve(new TextEncoder().encode('not an Apex log')), + ); + + let notifyTabsChanged: (() => void) | undefined; + window.tabGroups.onDidChangeTabs.mockImplementation((listener: (event: unknown) => void) => { + notifyTabsChanged = () => listener({}); + return { dispose: jest.fn() }; + }); + window.tabGroups.activeTabGroup.activeTab = { input: new TabInputText(slowUri) }; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + window.tabGroups.activeTabGroup.activeTab = { input: new TabInputText(fastUri) }; + notifyTabsChanged?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', false); + + resolveSlowRead?.(new TextEncoder().encode('09:45:31.888 (1000)|EXECUTION_STARTED')); + await slowRead; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(commands.executeCommand).not.toHaveBeenCalledWith('setContext', 'lana.isApexLog', true); + }); +}); diff --git a/lana/src/log-features/RawLogNavigation.ts b/lana/src/log-features/RawLogNavigation.ts index b14eba9b0..c37f84967 100644 --- a/lana/src/log-features/RawLogNavigation.ts +++ b/lana/src/log-features/RawLogNavigation.ts @@ -1,7 +1,9 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { Selection, Uri, commands, window, workspace } from 'vscode'; +import { Selection, commands, window, type Uri } from 'vscode'; + +import { readFile } from '../services/salesforceServices.js'; /** * Handles navigation within raw Apex log files. @@ -15,12 +17,10 @@ export class RawLogNavigation { * @param logPath - Path to the log file * @param timestamp - Nanosecond timestamp to find (from log event) */ - public static async goToLineByTimestamp(logPath: string, timestamp: number): Promise { + public static async goToLineByTimestamp(logUri: Uri, timestamp: number): Promise { try { - const uri = Uri.file(logPath); - // Read file (no normalization - avoids doubling memory for large files) - const text = new TextDecoder().decode(await workspace.fs.readFile(uri)); + const text = await readFile(logUri); // Find the exact timestamp pattern: (nanoseconds)| const index = text.indexOf(`(${timestamp})|`); @@ -45,7 +45,7 @@ export class RawLogNavigation { } // Open file with line selected (cursor ends up at end - VS Code limitation) - await commands.executeCommand('vscode.open', uri, { + await commands.executeCommand('vscode.open', logUri, { preview: false, selection: new Selection(lineNumber, 0, lineNumber, lineLength), }); diff --git a/lana/src/salesforce/codesymbol/SfdxProject.ts b/lana/src/salesforce/codesymbol/SfdxProject.ts index 182337a2f..a46b40c20 100644 --- a/lana/src/salesforce/codesymbol/SfdxProject.ts +++ b/lana/src/salesforce/codesymbol/SfdxProject.ts @@ -1,8 +1,8 @@ /* * Copyright (c) 2025 Certinia Inc. All rights reserved. */ -import path from 'path'; import { RelativePattern, type Uri, workspace } from 'vscode'; +import { Utils } from 'vscode-uri'; export interface PackageDirectory { readonly uri: Uri; @@ -45,7 +45,9 @@ export class SfdxProject { const classIndex = new Map(); for (const uri of allUris) { // uri.path is always '/'-separated (unlike fsPath), so posix basename is safe everywhere - const className = path.posix.basename(uri.path, '.cls').toLowerCase(); + const className = Utils.basename(uri) + .replace(/\.cls$/i, '') + .toLowerCase(); const uris = classIndex.get(className); if (uris) { uris.push(uri); diff --git a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts index 563d2dfac..5c1cc6d36 100644 --- a/lana/src/salesforce/codesymbol/SfdxProjectReader.ts +++ b/lana/src/salesforce/codesymbol/SfdxProjectReader.ts @@ -2,6 +2,7 @@ * Copyright (c) 2025 Certinia Inc. All rights reserved. */ import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode'; + import { SfdxProject } from './SfdxProject.js'; interface RawPackageDirectory { @@ -45,7 +46,7 @@ export async function getProjects(workspaceFolder: WorkspaceFolder): Promise ({ path, fsPath: path }) as Uri; +const joinPath = (base: string, ...segments: string[]): string => + [base, ...segments].join('/').replace(/\/[^/]+\/\.\.\//g, '/'); + /** Mock the workspace scan so each project file resolves to its own contents, in order. */ function mockProjectFiles(files: { uri: Uri; contents: string }[]): void { (workspace.findFiles as jest.Mock).mockResolvedValue(files.map((file) => file.uri)); @@ -30,7 +32,7 @@ describe('getProjects', () => { jest.clearAllMocks(); // Mirror the real Uri.joinPath: join segments and normalize '..' (Uri.joinPath as jest.Mock).mockImplementation((base: Uri, ...segments: string[]) => - fileUri(posix.join(base.path, ...segments)), + fileUri(joinPath(base.path, ...segments)), ); }); diff --git a/lana/src/symbols/RawLogSymbolProvider.ts b/lana/src/symbols/RawLogSymbolProvider.ts index 5eccb8e18..94f6d5753 100644 --- a/lana/src/symbols/RawLogSymbolProvider.ts +++ b/lana/src/symbols/RawLogSymbolProvider.ts @@ -29,7 +29,7 @@ class RawLogSymbolProvider implements DocumentSymbolProvider { document: TextDocument, _token: CancellationToken, ): Promise { - const apexLog = await LogEventCache.getApexLog(document.uri.fsPath); + const apexLog = await LogEventCache.getApexLog(document.uri.toString()); if (!apexLog) { return []; @@ -92,7 +92,7 @@ class RawLogSymbolProvider implements DocumentSymbolProvider { } static apply(context: Context): void { - const docSelector = [{ scheme: 'file', language: 'apexlog' }]; + const docSelector = [{ language: 'apexlog' }]; context.context.subscriptions.push( languages.registerDocumentSymbolProvider(docSelector, new RawLogSymbolProvider()), diff --git a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts index f7aad575c..1f25bd1b5 100644 --- a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts +++ b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts @@ -142,7 +142,7 @@ describe('RawLogSymbolProvider', () => { expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(1); expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledWith( - [{ scheme: 'file', language: 'apexlog' }], + [{ language: 'apexlog' }], expect.any(RawLogSymbolProvider), ); }); diff --git a/lana/src/workspace/VSWorkspace.ts b/lana/src/workspace/VSWorkspace.ts index 695e09a65..303184530 100644 --- a/lana/src/workspace/VSWorkspace.ts +++ b/lana/src/workspace/VSWorkspace.ts @@ -14,9 +14,11 @@ export class VSWorkspace { this.workspaceFolder = workspaceFolder; } - path(): string { - return this.workspaceFolder.uri.fsPath; + /** URI string for desktop and virtual web workspaces. */ + get uri(): string { + return this.workspaceFolder.uri.toString(); } + name(): string { return this.workspaceFolder.name; } diff --git a/lana/src/workspace/__tests__/VSWorkspace.test.ts b/lana/src/workspace/__tests__/VSWorkspace.test.ts index 866364b07..cbcb3b45c 100644 --- a/lana/src/workspace/__tests__/VSWorkspace.test.ts +++ b/lana/src/workspace/__tests__/VSWorkspace.test.ts @@ -24,9 +24,9 @@ describe('VSWorkspace', () => { vsWorkspace = new VSWorkspace(mockWorkspaceFolder); }); - describe('path', () => { - it('should return workspace folder path', () => { - expect(vsWorkspace.path()).toBe('/workspace'); + describe('uri', () => { + it('should expose the workspace folder URI', () => { + expect(vsWorkspace.workspaceFolder.uri.fsPath).toBe('/workspace'); }); }); diff --git a/lana/tsconfig.json b/lana/tsconfig.json index de63b3700..2bcd39cee 100644 --- a/lana/tsconfig.json +++ b/lana/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "WebWorker"], "esModuleInterop": true, "skipLibCheck": true, "target": "es2022", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d91202962..379692a4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,6 +118,9 @@ importers: effect: specifier: ^3.22.0 version: 3.22.1 + vscode-uri: + specifier: ^3.1.0 + version: 3.1.0 devDependencies: '@salesforce/vscode-services': specifier: ^67.12.0 From c42be6a2e50067aab155154e6abae522fe5a561b Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:06:03 +0100 Subject: [PATCH 10/61] fix(log-viewer): make a timeline resize smooth (#982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging the window or the panel edge made the Flame Chart flash, and the chart trailed a frame behind the drag. Four commits, one concern each. **The flash.** `renderer.resize` assigns `canvas.width`, which wipes the drawing buffer. `resize()` cleared all three canvases and booked the repaint for the *next* frame, so the frame between composited three blank canvases. It now draws before it returns. **The lag.** Observer callbacks are delivered after a frame's animation callbacks and after layout, so a resize deferred to the next frame always sized the canvas to the box the drag had already left. Because depth 0 sits at the canvas bottom inside an `overflow: hidden` box, a fast shrink clipped the bottom frames away rather than merely lagging. The observer already coalesces to one delivery per rendering opportunity, so the frame hop bought no batching, and nothing inside the observed element can change that element's height, so resizing from the callback cannot loop. **Two things that ran more than once.** A chevron toggle asked the host to relayout โ€” which draws โ€” and then asked it to draw again. And the minimap density cache, which is keyed by width, was cleared on every resize, so a height-only drag paid a full recompute per step. ### Measured, 100MB log | | before | after | | --- | --- | --- | | height drag | 23-25ms a step | **2-4ms** | | chevron toggle | 2 full renders | 1 | | load | a synchronous render inside `init()` | the booked render stands | A width drag is still 75-92ms: a new width is a genuine cache miss, and `computeDensitySlidingWindow` pushes every frame into every bucket it spans before `resolveCategoryFromSkyline` sorts per bucket. That is a separate piece of work and gets its own issue. ### Verification `tsc -b`, eslint and prettier clean; 1983 tests pass, and each commit compiles on its own. New `FlameChartResize.test.ts` (7) and `MetricStripToggle.test.ts` cover: drawing before returning, dropping a queued frame, re-booking one it could not draw, skipping an unchanged geometry, still drawing when only the minimap height moved, and rendering once per toggle. Each was checked by reverting its fix and confirming it fails. Checked by hand in the dev host on a 100MB log: no blank frame at any size, horizontal and vertical drags smooth, no blink on the chevron. Relates to #373. Merge this before the other two timeline PRs โ€” they touch the same `scheduleRender`/`render` block. --- CHANGELOG.md | 1 + .../features/timeline/optimised/FlameChart.ts | 114 ++++++++++--- .../__tests__/FlameChartResize.test.ts | 159 ++++++++++++++++++ .../__tests__/MetricStripToggle.test.ts | 47 ++++++ .../interaction/TimelineResizeHandler.ts | 24 +-- .../metric-strip/MetricStripOrchestrator.ts | 8 +- .../optimised/minimap/MinimapDensityQuery.ts | 26 +-- .../orchestrators/MinimapOrchestrator.ts | 4 +- 8 files changed, 324 insertions(+), 59 deletions(-) create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/MetricStripToggle.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b757dbf7..c6b5cdeb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐Ÿ“ **Timeline length**: the chart stopped at the last frame the log recorded, so it drew shorter than the log's own duration โ€” 10.8s of a 27.1s log where the size cap cut the log off. The chart now spans the whole log, and the truncation marker shades the part the log never recorded. ([#828]) - ๐Ÿงญ **Hot spots**: the log itself topped the Inspector's hot spots, and the Analysis findings, whenever time went unrecorded โ€” the gap between frames lands on the log, which is a container and not code. It is now left out of both. - ๐Ÿ“Š **Governor limits strip**: where a log records nothing โ€” it hit the maximum size, or lines were skipped โ€” the strip drew its last reading across the gap as though it had been measured. The area fills, the over-100% band and the collapsed traffic light now leave the gap blank, the step line holds its last level, and the tooltip names the reason and the range, such as `Max-Size-reached ยท 10.8s โ†’ 27.1s`. Truncation shading also ends with its marker instead of running on to the next one. ([#828]) +- โšก **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge โ€” it cleared its canvases in one frame and drew in the next, sized to a box the drag had already left. It now clears, sizes and draws in the frame the layout changed, and no longer recomputes the minimap for a change that cannot alter it. ## [1.20.1] 2026-07-23 diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 587c3ed80..51231349a 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -187,6 +187,9 @@ export class FlameChart { // Used to convert canvas-relative coordinates to container-relative for tooltip positioning private mainTimelineYOffset = 0; + /** The minimap height the last applied resize used, so a resize can tell nothing moved. */ + private appliedMinimapHeight: number | null = null; + // Cached culled rectangles (reused when viewport unchanged - Phase 3 optimization) // INVARIANT: These caches are invalidated when renderDirty.culling is set to true. // Any code that changes viewport state must call invalidateAll() or set culling dirty flag. @@ -425,11 +428,7 @@ export class FlameChart { * Clean up resources and remove event listeners. */ public destroy(): void { - // Stop render loop - if (this.renderLoopId !== null) { - cancelAnimationFrame(this.renderLoopId); - this.renderLoopId = null; - } + this.cancelScheduledRender(); // Clean up interaction handler if (this.interactionHandler) { @@ -723,33 +722,72 @@ export class FlameChart { private scheduleRender(): void { if (this.renderLoopId === null) { this.renderLoopId = requestAnimationFrame(() => { - if (this.state && this.state.needsRender) { - this.render(); - this.state.needsRender = false; - - // Setup ResizeObserver after first render to avoid double render on init. - // By this point, layout is finalized and ResizeObserver baseline matches rendered state. - if (this.resizeHandler && !this.resizeObserverActive) { - this.resizeHandler.setupResizeObserver(); - this.resizeObserverActive = true; - } - } + // Cleared before the render, not after: a listener that asks for a render from inside + // one then books the next frame rather than having its request swallowed by this one. this.renderLoopId = null; + + if (this.state?.needsRender) { + this.flushRender(); + } }); } } + /** Drop a render booked for the next frame. */ + private cancelScheduledRender(): void { + if (this.renderLoopId !== null) { + cancelAnimationFrame(this.renderLoopId); + this.renderLoopId = null; + } + } + + /** + * Draw everything in this frame, rather than booking the next one. + * + * For a caller that has already changed what is on screen and cannot leave the frame to + * composite the result โ€” a resize wipes each canvas as it sizes it. `invalidateAll` covers + * whatever a dropped render was waiting for. + */ + private renderNow(): void { + this.cancelScheduledRender(); + this.invalidateAll(); + this.flushRender(); + + // A frame was dropped to draw in this one. If the chart could not draw after all, that + // dropped frame is still owed. + if (this.state?.needsRender) { + this.scheduleRender(); + } + } + + /** + * Draw, then settle the loop: nothing is left pending and nothing renders twice. + * + * The one place a render is followed through, whether the frame loop asked for it or a + * caller drew straight away. + */ + private flushRender(): void { + this.render(); + + // Setup ResizeObserver after first render to avoid double render on init. + // By this point, layout is finalized and ResizeObserver baseline matches rendered state. + if (this.resizeHandler && !this.resizeObserverActive) { + this.resizeHandler.setupResizeObserver(); + this.resizeObserverActive = true; + } + } + /** * Handle window resize. * Calculates main timeline height by subtracting visible component heights. */ - public resize(newWidth: number, newHeight: number): void { + public resize(newWidth: number, newHeight: number): boolean { if (!this.app || !this.viewport || !this.container || !this.index) { - return; + return false; } if (newWidth <= 0 || newHeight <= 0) { - return; + return false; } const oldState = this.viewport.getState(); @@ -776,9 +814,22 @@ export class FlameChart { const mainTimelineHeight = newHeight - totalOverheadHeight; if (mainTimelineHeight <= 0) { - return; // Invalid state, skip resize + return false; // Invalid state, skip resize } + // Nothing moved, so no canvas is about to be wiped and a render already booked still + // stands. Load reaches here with the geometry init already set. The minimap is checked + // too: its height is a tenth of the container's, so it can move on its own while the main + // timeline keeps the height it had. + if ( + newWidth === oldWidth && + mainTimelineHeight === oldState.displayHeight && + minimapHeight === this.appliedMinimapHeight + ) { + return false; + } + this.appliedMinimapHeight = minimapHeight; + // Update offset for converting canvas-relative to container-relative coordinates this.mainTimelineYOffset = totalOverheadHeight; @@ -811,7 +862,10 @@ export class FlameChart { // Update viewport with main timeline dimensions only this.viewport.setStateForResize(newWidth, mainTimelineHeight, newZoom, newOffsetX, newOffsetY); - this.requestRender(); + // Each `renderer.resize` above wiped its canvas, so a scheduled render would leave this + // frame to composite three blank ones. + this.renderNow(); + return true; } /** @@ -959,6 +1013,9 @@ export class FlameChart { const minimapHeight = calculateMinimapHeight(height); const metricStripHeight = METRIC_STRIP_COLLAPSED_HEIGHT; + // This is the applied geometry, so a resize to the same size has nothing to do. + this.appliedMinimapHeight = minimapHeight; + // Create wrapper container with flexbox layout this.wrapper = document.createElement('div'); this.wrapper.style.cssText = 'display:flex;flex-direction:column;width:100%;height:100%'; @@ -1469,9 +1526,14 @@ export class FlameChart { } // Trigger full layout recalculation to resize main timeline // The container size doesn't change, but internal flexbox layout does - if (this.container) { - const { width, height } = this.container.getBoundingClientRect(); - this.resize(width, height); + if (!this.container) { + return; + } + const { width, height } = this.container.getBoundingClientRect(); + if (!this.resize(width, height)) { + // The strip resized its own canvas before asking, so it is blank until something + // draws. A resize that could not run leaves that to us. + this.requestRender(); } }, }); @@ -2192,6 +2254,10 @@ export class FlameChart { return; } + // Committed to drawing, so the request is served. A render that bailed above leaves the + // request standing, for a later frame to honour. + this.state!.needsRender = false; + const viewportState = this.viewport!.getState(); this.state!.viewport = viewportState; const dirty = this.state!.renderDirty; diff --git a/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts new file mode 100644 index 000000000..75b34ab63 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts @@ -0,0 +1,159 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * A resize must repaint in the same frame it clears in. PIXI's `renderer.resize` assigns + * `canvas.width`, which wipes the drawing buffer, so a repaint deferred to the next frame + * leaves this one to composite a blank canvas. + */ + +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { FlameChart } from '../FlameChart.js'; + +/** The private collaborators `resize` and `render` need, and nothing else. */ +function stubbedChart(displayHeight = 300): { + chart: FlameChart; + rendererResize: jest.Mock; + appRender: jest.Mock; +} { + const chart = new FlameChart(); + const rendererResize = jest.fn(); + const appRender = jest.fn(); + + const internals = chart as unknown as Record; + internals['app'] = { + renderer: { resize: rendererResize }, + screen: { height: 300 }, + render: appRender, + }; + internals['container'] = document.createElement('div'); + internals['index'] = { maxDepth: 1 }; + internals['worldContainer'] = { position: { set: jest.fn() } }; + internals['batchRenderer'] = { render: jest.fn(), clear: jest.fn() }; + internals['rectangleManager'] = { + getCulledRectangles: () => ({ visibleRects: new Map(), buckets: new Map() }), + }; + internals['viewport'] = { + getState: () => ({ + zoom: 1, + offsetX: 0, + offsetY: 0, + displayWidth: 400, + displayHeight, + }), + setStateForResize: jest.fn(), + }; + // The geometry init applied: 364 container - 60 minimap - 4 gap = the 300 below. + internals['appliedMinimapHeight'] = 60; + internals['state'] = { + viewport: null, + needsRender: false, + batchColorsCache: new Map(), + renderDirty: { + background: false, + culling: false, + eventRendering: false, + highlights: false, + overlays: false, + minimap: false, + metricStrip: false, + }, + }; + + return { chart, rendererResize, appRender }; +} + +describe('FlameChart.resize', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('paints before it returns, so the cleared canvas is never composited', () => { + const { chart, rendererResize, appRender } = stubbedChart(); + const raf = jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + + chart.resize(500, 400); + + // Both inside the one call: the clear and the paint share a frame. + expect(rendererResize).toHaveBeenCalled(); + expect(appRender).toHaveBeenCalled(); + // Nothing left for a later frame to do. + expect(raf).not.toHaveBeenCalled(); + }); + + // A resize that changes nothing has no canvas to wipe, so drawing now buys nothing and a + // render already booked still stands. Loading a log arrives here with the geometry unchanged. + it('does not draw when the geometry is unchanged', () => { + const { chart, rendererResize, appRender } = stubbedChart(); + + // 364 - 60 minimap - 4 gap = the 300 the viewport already reports, at the same width. + chart.resize(400, 364); + + expect(rendererResize).not.toHaveBeenCalled(); + expect(appRender).not.toHaveBeenCalled(); + }); + + it('still draws when the main timeline height changes at the same width', () => { + const { chart, appRender } = stubbedChart(); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + + chart.resize(400, 400); + + expect(appRender).toHaveBeenCalled(); + }); + + // The minimap is a tenth of the container, clamped, so it can move a pixel while the main + // timeline keeps the height it had. Skipping then leaves its canvas short of its box. + it('draws when only the minimap height moved', () => { + // 604 - 60 - 4 and 605 - 61 - 4 are both 540, so only the minimap changed. + const { chart, appRender } = stubbedChart(540); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + + expect(chart.resize(400, 605)).toBe(true); + expect(appRender).toHaveBeenCalled(); + }); + + // The metric strip resizes its own canvas before asking the host to relayout, so a resize + // that cannot run has to say so โ€” otherwise nothing draws the strip it just blanked. + it('reports whether it applied, so a caller can draw instead', () => { + const { chart } = stubbedChart(); + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + + // Smaller than the minimap and its gap, so no main timeline is left. + expect(chart.resize(400, 40)).toBe(false); + expect(chart.resize(400, 400)).toBe(true); + }); + + // The queued paint is dropped to draw in this frame. If the draw cannot happen, the paint + // is still owed, or the chart stays blank with nothing left to fill it. + it('books the dropped frame again when it cannot draw after all', () => { + const { chart, appRender } = stubbedChart(); + const internals = chart as unknown as Record; + // No rectangleManager, so `canRender` fails and `render` bails. + internals['rectangleManager'] = null; + (internals['state'] as { needsRender: boolean }).needsRender = true; + const raf = jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + + chart.resize(500, 400); + + expect(appRender).not.toHaveBeenCalled(); + expect(raf).toHaveBeenCalled(); + }); + + it('drops a render already queued, rather than painting twice', () => { + const { chart, appRender } = stubbedChart(); + const cancel = jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + (chart as unknown as Record)['renderLoopId'] = 7; + + chart.resize(500, 400); + + expect(cancel).toHaveBeenCalledWith(7); + expect(appRender).toHaveBeenCalledTimes(1); + expect((chart as unknown as Record)['renderLoopId']).toBeNull(); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/MetricStripToggle.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/MetricStripToggle.test.ts new file mode 100644 index 000000000..240da0965 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/MetricStripToggle.test.ts @@ -0,0 +1,47 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Collapsing or expanding the strip changes the main timeline's height, so the host relayouts + * and draws as part of that. Asking it to draw again is a second full render for one click. + */ + +import { describe, expect, it, jest } from '@jest/globals'; +import { + MetricStripOrchestrator, + type MetricStripOrchestratorCallbacks, +} from '../metric-strip/MetricStripOrchestrator.js'; + +function orchestrator(): { + strip: MetricStripOrchestrator; + onHeightChange: jest.Mock; + requestRender: jest.Mock; +} { + const onHeightChange = jest.fn(); + const requestRender = jest.fn(); + const callbacks = { + onZoomToRegion: jest.fn(), + onCursorMove: jest.fn(), + requestRender, + requestCursorRender: jest.fn(), + onHeightChange, + } as unknown as MetricStripOrchestratorCallbacks; + + return { strip: new MetricStripOrchestrator(callbacks), onHeightChange, requestRender }; +} + +describe('collapsing the metric strip', () => { + it('asks the host to relayout, and does not also ask it to draw', () => { + const { strip, onHeightChange, requestRender } = orchestrator(); + + strip.toggleCollapsed(); + + expect(onHeightChange).toHaveBeenCalledTimes(1); + expect(requestRender).not.toHaveBeenCalled(); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts index dcbc7e616..d50e959c1 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineResizeHandler.ts @@ -19,7 +19,6 @@ export class TimelineResizeHandler { private containerRef: HTMLElement; private renderer: IResizable | null = null; - private resizeDebounceFrameId: number | null = null; private lastResizeWidth: number; private lastResizeHeight: number; @@ -60,31 +59,22 @@ export class TimelineResizeHandler { return; // Skip if unchanged (covers initial callback case) } - // Update dimensions before debounce to prevent rapid duplicate checks this.lastResizeWidth = roundedWidth; this.lastResizeHeight = roundedHeight; - // Debounce actual resize handling to prevent flickering - if (this.resizeDebounceFrameId !== null) { - cancelAnimationFrame(this.resizeDebounceFrameId); - } - - this.resizeDebounceFrameId = requestAnimationFrame(() => { - this.renderer?.resize(roundedWidth, roundedHeight); - this.resizeDebounceFrameId = null; - }); + // Straight through, with no frame in between. Observer callbacks are delivered after + // this frame's animation callbacks, so a resize deferred to the next frame sizes the + // canvas to a box the drag has already left: one frame behind on every step of a drag, + // which reads as the chart sliding off the bottom edge until the drag stops. + // Nothing inside the observed element can change that element's height, so this cannot + // start an observer loop. + this.renderer?.resize(roundedWidth, roundedHeight); }); this.resizeObserver.observe(this.containerRef); } public destroy(): void { - // Clear any pending resize frame request - if (this.resizeDebounceFrameId !== null) { - cancelAnimationFrame(this.resizeDebounceFrameId); - this.resizeDebounceFrameId = null; - } - // Disconnect resize observer if (this.resizeObserver) { this.resizeObserver.disconnect(); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts index f1494ef66..7d27cb1b3 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts @@ -114,7 +114,7 @@ export interface MetricStripOrchestratorCallbacks { * * @param newHeight - New height in pixels */ - onHeightChange?: (newHeight: number) => void; + onHeightChange: (newHeight: number) => void; } /** @@ -414,9 +414,9 @@ export class MetricStripOrchestrator { this.renderer?.setHeight(newHeight); this.renderer?.setCollapsed(this.isCollapsed); - // Notify FlameChart to recalculate layout - this.callbacks.onHeightChange?.(newHeight); - this.callbacks.requestRender(); + // The host sizes the container for the new height and draws as part of that, so asking it + // to draw again would be a second full render for one click. + this.callbacks.onHeightChange(newHeight); } /** diff --git a/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts b/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts index a2b3ea9b8..cb3305859 100644 --- a/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts @@ -131,14 +131,14 @@ export class MinimapDensityQuery { private totalDuration: number; /** - * Simple cache for exact bucket count. - * Key: bucket count - * Value: computed density data + * The one density held, and the bucket count it was computed for. * - * Invalidated when data changes. No multi-resolution downscaling needed - * since we compute at exact bucket count with O(B ร— log N) complexity. + * One width at a time: every caller asks for the width on screen, and a density holds a + * bucket per pixel of it, so keeping the widths a drag passed through would cost more memory + * than the recompute it saves. */ - private densityCache: Map = new Map(); + private cachedBucketCount: number | null = null; + private cachedDensity: MinimapDensityData | null = null; /** Optional segment tree for O(Bร—log N) density computation. */ private segmentTree: TemporalSegmentTree | null = null; @@ -173,9 +173,8 @@ export class MinimapDensityQuery { bucketCount = Number.isFinite(bucketCount) ? Math.floor(bucketCount) : 0; // Fast path: exact match in cache - const cached = this.densityCache.get(bucketCount); - if (cached) { - return cached; + if (this.cachedDensity !== null && this.cachedBucketCount === bucketCount) { + return this.cachedDensity; } // Compute at exact bucket count using sliding window algorithm if tree available @@ -183,15 +182,20 @@ export class MinimapDensityQuery { ? this.computeDensitySlidingWindow(bucketCount) : this.computeDensity(bucketCount); - this.densityCache.set(bucketCount, density); + this.cachedBucketCount = bucketCount; + this.cachedDensity = density; return density; } /** * Invalidate cache (call when timeline data changes). + * + * A resize needs no call: the cache is keyed by width, so a new width misses on its own and + * a height change leaves the entry as true as it was. */ public invalidateCache(): void { - this.densityCache.clear(); + this.cachedBucketCount = null; + this.cachedDensity = null; } /** diff --git a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts index 3e54d7bcd..462b18ec2 100644 --- a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts @@ -282,9 +282,7 @@ export class MinimapOrchestrator { this.minimapViewport.resize(newWidth, newHeight); } - if (this.densityQuery) { - this.densityQuery.invalidateCache(); - } + // No density invalidation here: it is keyed by width (see MinimapDensityQuery). if (this.renderer) { this.renderer.invalidateStatic(); From 2fb6c43f2437c4919fd9de563028d8b7d514c3bc Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:07:34 +0100 Subject: [PATCH 11/61] fix(log-viewer): mark a row the renderer hands back without rebuilding it (#981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview The inspector's row mark reached a row two ways: a sweep of the rows attached when the mark moved, and the row formatter as a row is built. Neither reaches a row that was built earlier, detached when it scrolled out of view, and only then named by a mark. The renderer re-attaches such a row without re-running the formatter, so it comes back with no mark. Scroll to it, or sort with it just off screen, and the highlight is simply missing. The fix watches the arrival: a `MutationObserver` for `childList` on the element the renderer attaches rows to. A row entering the table is a child mutation, so a scroll and a structural render are one case, and a table destroyed and rebuilt into the same container is watched again rather than going quiet. ## ๐Ÿ› ๏ธ Changes made - **`watchRenders(host)`** โ€” one observer per marked table, established on the first non-empty mark. The callback re-sweeps only while a mark is set, and touches nothing but a class, so it cannot report itself back. - **Keyed on the row-holding element, not the container.** Several views destroy the table and build another in the same element; `CallStackDetail` does it on every event change. Keying on the container would leave the watch pointing at a destroyed table, and the old observer is disconnected when a new one takes over. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A. What changes is whether a row carries its highlight when the grid hands it back. ## ๐Ÿ”— Related Issues Completes the row mark from #975 and #980. ## โœ… Tests added? - [x] ๐Ÿ‘ yes - [ ] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help Two cases: a row named while detached, and a table rebuilt into the same container. Each guard was proven by reverting the code it covers โ€” no watch fails both; watching the spacer's `style` rather than the arrival fails both; keying so a rebuild is skipped fails the second. ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [x] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features - [ ] ๐Ÿ™… not needed No entry: this corrects the unreleased Inspector, so it belongs to its existing entry. Ticked to record that it was considered. ## Anything else we need to know? [optional] **Why not the signals that look more natural.** Two were tried and rejected with evidence: - **A scroll listener.** A structural render โ€” sort, filter, column show/hide, a tree re-expand of children already built โ€” re-attaches an initialised row with no formatter run and fires no scroll event at all. The renderer also documents \`scrollend\` as unreliable: "The RAF stability check deliberately replaces \`scrollend\`, which never fires while the scrollbar thumb is held still" (\`VirtualVerticalRenderer.ts:430\`). - **The virtual spacer paddings.** Reading their values is unsound, because \`ScrollAnchor\` writes them too, so a value can return to one already swept while the window has moved. Observing the write instead is no better: a CSSOM write of an unchanged value queues no mutation record at all, so a sort at the top of a table reports nothing. **Cost.** The observer fires when rows enter or leave, which is exactly when work is due, and only while a mark is set. A sweep reads what the table has attached โ€” the viewport plus at most \`OVERSCAN_MAX\` rows each side โ€” never the row count, and the renderer's idle prewarm builds cells with \`inFragment: true\` so it does not widen that. The callback runs in a microtask after the render, and touches only \`classList\`, so it forces no layout. **Test plan.** - \`pnpm lint\` and \`pnpm test\`. - Call Tree, Bottom Up, Inspector open. Click a caller row so grid rows mark, then **sort a column while at the top of the table**: the marks survive. That is the case a value-based signal missed. - Filter and clear it; collapse and re-expand a marked row's parent. - Scroll past rows never rendered and back. Then click a row, scroll a lit row out of view, press \`Escape\`, scroll back: no stale highlight. - Large log with a mark set: scroll hard and watch for jank. - Both themes, Inspector docked at the side and at the bottom. **Known unrelated failure locally.** \`lana/src/services/__tests__/servicesRuntime.test.ts\` cannot resolve \`effect\` in a worktree not installed since #951. It passes in CI. **Follow-up, not in this PR.** The renderer already knows which rows it attached: \`allAttached\` is built in \`_attachRanges\`. Dispatching that would make relighting O(rows attached) rather than O(rows rendered), and passing the \`Tabulator\` to \`mark()\` instead of its element โ€” every one of the eight owners already holds it โ€” would add disposal on \`tableDestroyed\` and let \`Find\` drop its own scroll listener onto the same event. --- .../components/__tests__/locatedRow.test.ts | 70 +++++++++++++++++-- log-viewer/src/components/locatedRow.ts | 50 +++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index 0934ef5ec..03c3ad57d 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -51,20 +51,40 @@ function ev(text: string, parent: LogEvent | null, eventIndex?: number): LogEven return { type: 'METHOD_ENTRY', namespace: '', text, parent, eventIndex } as unknown as LogEvent; } -/** A table host holding a rendered row element per index, as the stamp leaves them. */ +/** A table host holding a rendered row element per index, as the stamp leaves + * them, inside the holder and spacer element Tabulator mounts. */ function host(...indexes: number[]): HTMLElement { const element = document.createElement('div'); + const holder = document.createElement('div'); + holder.classList.add('tabulator-tableholder'); + const spacers = document.createElement('div'); + spacers.classList.add('tabulator-table'); + // The renderer always writes both spacers, so start where a rendered table is. + spacers.style.paddingTop = '0px'; + spacers.style.paddingBottom = '0px'; + holder.append(spacers); + element.append(holder); for (const index of indexes) { const row = document.createElement('div'); row.classList.add('tabulator-row'); stamp(rowComponent(row, { eventIndex: index })); - element.append(row); + spacers.append(row); } return element; } +/** + * Re-attaches a row the renderer had detached, which is what a scroll or a sort + * does with a row it has already built. Awaits the observer, which reports after + * the arrival. + */ +async function reattach(container: HTMLElement, row: HTMLElement): Promise { + container.querySelector('.tabulator-table')!.append(row); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + function rowFor(container: HTMLElement, index: number): HTMLElement { - return container.children[index] as HTMLElement; + return container.querySelectorAll('.tabulator-row')[index]!; } /** A row entering an already-mounted table, which is what the renderer does the @@ -72,7 +92,7 @@ function rowFor(container: HTMLElement, index: number): HTMLElement { function renderRow(container: HTMLElement, index: number): HTMLElement { const row = document.createElement('div'); row.classList.add('tabulator-row'); - container.append(row); + container.querySelector('.tabulator-table')!.append(row); stamp(rowComponent(row, { eventIndex: index })); return row; } @@ -196,13 +216,51 @@ describe('LocatedRowMarker', () => { row.remove(); marker.mark(container, [5]); - container.append(row); + container.querySelector('.tabulator-table')!.append(row); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + }); + it('marks a row the renderer had detached before the mark named it', async () => { + // Rendered once, so it will not be stamped again, then scrolled out of view: + // the renderer keeps the element and detaches it. + const container = host(); + const row = renderRow(container, 4); + row.remove(); + + // Only now does the mark name it, so neither half can reach it. + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); + + // Coming back is what re-reads the mark, whatever moved the window: this + // holds for a sort at the top of a table, which writes no spacer at all. + await reattach(container, row); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(true); + }); + + it('keeps watching a table rebuilt into the same container', async () => { + // Several views destroy the table and build another in the same element, so + // a watch held against the old one would go quiet for good. + const container = host(); + const marker = new LocatedRowMarker(); + marker.mark(container, [4]); + container.querySelector('.tabulator-tableholder')!.remove(); + const rebuilt = host(4); + container.append(rebuilt.querySelector('.tabulator-tableholder')!); + + marker.mark(container, [4]); + const row = renderRow(container, 9); + row.remove(); + marker.mark(container, [9]); + await reattach(container, row); + + expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(true); }); it('leaves a row alone where nothing has marked its table', () => { - const row = renderRow(document.createElement('div'), 4); + const row = renderRow(host(), 4); expect(row.classList.contains(LOCATED_ROW_CLASS)).toBe(false); }); diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index 07521f436..a132caee0 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -56,6 +56,53 @@ function unlight(host: HTMLElement): void { lit.clear(); } +/** + * The row-holding element each marked table is watched through. + * + * Held per host so a table rebuilt into the same container is watched again: the + * element belongs to the Tabulator instance, not to the container, and several + * views destroy and rebuild a table in place. + */ +const watchedByHost = new WeakMap(); + +/** + * Sweeps `host` again whenever rows enter its table, so a row coming back into + * view carries the mark as it stands. + * + * The renderer re-attaches a row it has already built without running the + * formatter again, so a row detached before the mark named it is out of reach of + * both halves: the sweep could not see it and the stamp will not run for it. That + * happens on a scroll and equally on a structural render, which fires no scroll + * event at all. + * + * Watched is the arrival itself, which is a child of the row-holding element and + * so cannot be missed. A signal read from the renderer's own bookkeeping can be: + * a scroll fires no event on a sort, and the virtual spacers are written with the + * value they already hold whenever the window does not move, which reports + * nothing at all. + */ +function watchRenders(host: HTMLElement): void { + const watching = watchedByHost.get(host); + const rows = host.querySelector('.tabulator-table'); + if (watching?.rows === rows) { + return; + } + watching?.observer.disconnect(); + if (!rows) { + watchedByHost.delete(host); + return; + } + const observer = new MutationObserver(() => { + const wanted = wantedByHost.get(host); + if (wanted?.size) { + // Only ever touches a class, so it cannot report itself back here. + sweep(host, wanted); + } + }); + observer.observe(rows, { childList: true }); + watchedByHost.set(host, { rows, observer }); +} + /** Lights the rows a table has rendered that `wanted` names. */ function sweep(host: HTMLElement, wanted: ReadonlySet): void { for (const element of host.querySelectorAll( @@ -436,6 +483,9 @@ export class LocatedRowMarker { const wanted = ids.length ? new Set(ids.map(String)) : NOTHING_WANTED; wantedByHost.set(host, wanted); sweep(host, wanted); + if (wanted.size) { + watchRenders(host); + } } /** Drop the mark, if one is set. */ From a1f71468852ca4a46eaa1a2b0877538b1207fea2 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:09:20 +0100 Subject: [PATCH 12/61] fix(log-viewer): keep grid search highlights in step with the rows on screen (#985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview Search in the grids drifted from what the screen showed. Rows brought in by a scroll, a sort or a filter arrived with no highlight in them; expanding or collapsing a tree row cleared the search outright; hidden columns were counted, so the total led to matches nobody could reach; and the match you were on read the same as the rest. The renderer now announces every attach and Find takes Tabulator's own scroll event, so every grid is covered whichever renderer it runs. The views compare the row order itself, and drop the search only where the match numbering stops describing the table. ## ๐Ÿ› ๏ธ Changes made - `VirtualVerticalRenderer` announces each attach, and `Find` rebuilds once per frame โ€” the rebuild reads every cell on screen. - `Find` also takes Tabulator's `scrollVertical`, which the stock renderer reports too, so the SOQL, DML and SOSL grids highlight on scroll again. - The search covers only the columns on show, and the highlights follow the same set. - New `onTableReshaped` helper: the row order is compared, so an expand is not read as a sort. It reads `dataSorting`, not `dataSorted`, which would make Tabulator build a row component per sorted row. - The views drop the search on a real sort, a grouping either way round, a filter change, or a column going on or off show. - The current match takes `editor.findMatchBackground`, the rest `editor.findMatchHighlightBackground`, foreground included. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [x] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ”— Related Issues related # ## โœ… Tests added? - [x] ๐Ÿ‘ yes `pnpm test`: 1582 tests, 131 suites. Each new test was proven by removing its fix. Manual, 19.7MB sample log: expand and collapse hold the count and light the rows they open; a column view change and a grouping turned off both clear the search; a hidden column contributes no match, and a shown one does. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ™… not needed --- .../analysis/components/AnalysisView.ts | 40 ++--- .../components/__tests__/AnalysisView.test.ts | 94 +++++++++-- .../call-tree/components/AggregatedTable.ts | 13 -- .../call-tree/components/BottomUpTable.ts | 13 -- .../call-tree/components/CalltreeView.ts | 81 +++++----- .../call-tree/components/TableShared.ts | 2 - .../call-tree/components/TimeOrderTable.ts | 12 -- log-viewer/src/styles/global.styles.ts | 5 +- log-viewer/src/tabulator/module/Find.ts | 82 ++++++---- .../tabulator/module/__tests__/Find.test.ts | 149 ++++++++++++++++++ .../module/__tests__/tableReshape.test.ts | 74 +++++++++ .../src/tabulator/module/tableReshape.ts | 62 ++++++++ .../renderer/VirtualVerticalRenderer.ts | 6 + .../VirtualVerticalRendererAttach.test.ts | 52 ++++++ 14 files changed, 541 insertions(+), 144 deletions(-) create mode 100644 log-viewer/src/tabulator/module/__tests__/Find.test.ts create mode 100644 log-viewer/src/tabulator/module/__tests__/tableReshape.test.ts create mode 100644 log-viewer/src/tabulator/module/tableReshape.ts diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 9fd8941ca..ceabdcb33 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -46,6 +46,8 @@ import { } from '../../call-tree/utils/CategoryColoring.js'; import { expandCollapseAll } from '../../call-tree/utils/ExpandCollapse.js'; +import { onTableReshaped } from '../../../tabulator/module/tableReshape.js'; + import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; // styles @@ -501,6 +503,9 @@ export class AnalysisView extends LitElement { _groupBy(event: Event) { const target = event.target as HTMLInputElement; + // Grouping renumbers the matches both ways round, and `dataGrouped` reports + // only the way that leaves the table grouped. + this._dropSearch(); const fieldName = target.value === 'Caller Namespace' ? 'callerNamespace' : target.value.toLowerCase(); if (this.analysisTable) { @@ -520,6 +525,7 @@ export class AnalysisView extends LitElement { if (!table) { return; } + this._dropSearch(); table.blockRedraw(); table.clearFilter(false); if (!this.filterState.showDetails) { @@ -613,18 +619,6 @@ export class AnalysisView extends LitElement { rootMethod, { showDetailsFilter: this._showDetailsFilter, - onFilterCacheClear: () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }, - onRenderStarted: () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }, rowFormatter: groupedRowFormatter, }, { @@ -636,19 +630,7 @@ export class AnalysisView extends LitElement { ); this.analysisTable = table; - this.analysisTable.on('dataSorted', () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }); - - this.analysisTable.on('dataGrouped', () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }); + onTableReshaped(this.analysisTable, () => this._dropSearch()); // Feed the inspector. Analysis rows merge many calls, so they // scope to every call they count. @@ -686,6 +668,14 @@ export class AnalysisView extends LitElement { document.dispatchEvent(new CustomEvent('lv-find-results', { detail: { totalMatches: 0 } })); } + /** Drop the search where its match numbering no longer describes the table. */ + _dropSearch() { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + } + _clearSearchHighlights() { this.findArgs.text = ''; this.findArgs.count = 0; diff --git a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts index 049b56726..94ade21ba 100644 --- a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts @@ -18,6 +18,12 @@ jest.mock('../../../call-tree/components/BottomUpTable.js', () => ({ return stub.rows; }, goToRow: (row: RowComponent) => stub.revealed.push(row), + clearFindHighlights: () => {}, + blockRedraw: () => {}, + restoreRedraw: () => {}, + clearFilter: () => {}, + addFilter: () => {}, + setSortedGroupBy: () => {}, }, // Left pending: the columns are applied on build, and none are set up here. tableBuilt: new Promise(() => {}), @@ -104,6 +110,23 @@ function findRow(rows: BottomUpRow[], text: string): BottomUpRow { return row; } +/** + * A view with its table rendered against `log`. + * + * The app hands the log down as a property, and a row's calls are read through + * the table that built it. The table mounts in a wrapper the view finds in its + * render root, which it has none of until it is updated, so one is stood in. + */ +function mountView(log: ApexLog): AnalysisView { + handlers.clear(); + stub = { rows: [], getRowsArgs: [], revealed: [] }; + const view = new AnalysisView(); + view.timelineRoot = log; + view.tableContainer = document.createElement('div'); + void view._renderAnalysis(log); + return view; +} + describe('analysis-view selection', () => { let view: AnalysisView; let log: ApexLog; @@ -113,18 +136,9 @@ describe('analysis-view selection', () => { beforeEach(() => { byEventIndex = []; - handlers.clear(); - stub = { rows: [], getRowsArgs: [], revealed: [] }; log = recursiveLog(); roots = toBottomUpTree(log.children, logStoreFor(log).keyPathIds()); - view = new AnalysisView(); - // The app hands the log down as a property, and a row's calls are read - // through the table that built it. - view.timelineRoot = log; - // The table mounts in a wrapper the view finds in its render root; it has - // none until it is updated, so stand one in. - view.tableContainer = document.createElement('div'); - void view._renderAnalysis(log); + view = mountView(log); seen = []; off = eventBus.on('detail:select', (detail) => seen.push(detail)); }); @@ -228,3 +242,63 @@ describe('analysis-view selection', () => { expect(seen).toEqual([{ source: 'analysis', selection: null, view: 'callers' }]); }); }); + +describe('analysis-view search lifetime', () => { + let view: AnalysisView; + + beforeEach(() => { + byEventIndex = []; + view = mountView(recursiveLog()); + // What a finished search leaves behind: matches, and nothing of its own in + // flight to guard against. + view.totalMatches = 3; + view.blockClearHighlights = false; + }); + + afterEach(() => { + view.disconnectedCallback(); + }); + + /** What Tabulator reports, which an expand repeats with the sort in force. */ + function sorted(dir: string): void { + (handlers.get('dataSorting') as (sorters: unknown[]) => void)([{ field: 'selfTime', dir }]); + } + + it('drops the search where a sort renumbers the matches', () => { + sorted('desc'); + + expect(view.totalMatches).toBe(0); + }); + + it('drops the search where a column goes off show, which it counted over', () => { + (handlers.get('columnVisibilityChanged') as () => void)(); + + expect(view.totalMatches).toBe(0); + }); + + it('drops the search where grouping is turned off, which reports no grouping', () => { + // Tabulator's dataGrouped fires only while the table stays grouped, so this + // is the way round it never reports. + view._groupBy({ target: { value: 'None' } } as unknown as Event); + + expect(view.totalMatches).toBe(0); + }); + + it('keeps the search where an expand orders the children it opened', () => { + sorted('desc'); + view.totalMatches = 3; + + // Expanding sorts each opened subtree through the same call, so the event + // arrives again with the sort the table already had. + sorted('desc'); + sorted('desc'); + + expect(view.totalMatches).toBe(3); + }); + + it('drops the search where a filter changes which rows there are', () => { + view._handleShowDetailsChange(); + + expect(view.totalMatches).toBe(0); + }); +}); diff --git a/log-viewer/src/features/call-tree/components/AggregatedTable.ts b/log-viewer/src/features/call-tree/components/AggregatedTable.ts index 8ea3b57d6..1e7b78623 100644 --- a/log-viewer/src/features/call-tree/components/AggregatedTable.ts +++ b/log-viewer/src/features/call-tree/components/AggregatedTable.ts @@ -182,19 +182,6 @@ export function createAggregatedTable( }); tableRef.current = table; - // 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', () => { - callbacks.onFilterCacheClear?.(); - callbacks.onRenderStarted(); - }); - const tableBuilt = new Promise((resolve) => { table.on('tableBuilt', () => { resolve(); diff --git a/log-viewer/src/features/call-tree/components/BottomUpTable.ts b/log-viewer/src/features/call-tree/components/BottomUpTable.ts index 1d432c776..95d875c72 100644 --- a/log-viewer/src/features/call-tree/components/BottomUpTable.ts +++ b/log-viewer/src/features/call-tree/components/BottomUpTable.ts @@ -246,19 +246,6 @@ export function createBottomUpTable( ...tabulatorOptionOverrides, }); - // Filter caches are cleared once per render via `renderStarted`. Row ids - // produced by `toBottomUpTree` 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', () => { - callbacks.onFilterCacheClear?.(); - callbacks.onRenderStarted(); - }); - const tableBuilt = new Promise((resolve) => { table.on('tableBuilt', () => { resolve(); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 0bdf0e53c..4669f1125 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -33,6 +33,7 @@ import { waitForNextFrame } from '../../../core/utility/FrameBudget.js'; import { inMsRange, type FilterRange } from '../../../tabulator/filters/MinMax.js'; import { withCodeDrivenExpand } from '../../../tabulator/module/expandOrigin.js'; +import { onTableReshaped } from '../../../tabulator/module/tableReshape.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; @@ -597,6 +598,9 @@ export class CalltreeView extends LitElement { _handleBottomUpGroupBy(event: Event) { const target = event.target as HTMLInputElement; this.bottomUpGroupBy = target.value; + // Grouping renumbers the matches both ways round, and `dataGrouped` reports + // only the way that leaves the table grouped. + this._dropSearch(); const fieldName = target.value === 'Caller Namespace' ? 'callerNamespace' : target.value.toLowerCase(); if (this.bottomUpTreeTable) { @@ -760,11 +764,8 @@ export class CalltreeView extends LitElement { return; } - this.debugOnlyFilterCache.clear(); - this.typeFilterCache.clear(); - this.namespaceFilterCache.clear(); - this.totalTimeFilterCache.clear(); - this.selfTimeFilterCache.clear(); + this._dropSearch(); + this._clearFilterCaches(); const filtersToAdd = []; @@ -1048,19 +1049,6 @@ export class CalltreeView extends LitElement { const { table, tableBuilt } = createTimeOrderTable(callTreeTableContainer, rootMethod, { 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) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }, onContextMenu: (e, row) => { if (window.getSelection()?.type === 'Range') { return; @@ -1072,6 +1060,7 @@ export class CalltreeView extends LitElement { rowFormatter: timeOrderRowFormatter, }); this.calltreeTable = table; + this._watchTable(table, true); await tableBuilt; this._initTableColumns(table); this._emitDetailSelection(table); @@ -1089,22 +1078,10 @@ export class CalltreeView extends LitElement { const { table, tableBuilt } = createAggregatedTable(container, rootMethod, { 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) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }, rowFormatter: groupedRowFormatter, }); this.aggregatedTreeTable = table; + this._watchTable(table, true); await tableBuilt; this._initTableColumns(table); this._emitDetailSelection(table); @@ -1122,12 +1099,6 @@ export class CalltreeView extends LitElement { rootMethod, { showDetailsFilter: this._showDetailsFilter, - onRenderStarted: () => { - if (!this.blockClearHighlights && this.totalMatches > 0) { - this._resetFindWidget(); - this._clearSearchHighlights(); - } - }, rowFormatter: groupedRowFormatter, }, { @@ -1137,6 +1108,7 @@ export class CalltreeView extends LitElement { }, ); this.bottomUpTreeTable = table; + this._watchTable(table, false); await tableBuilt; this._initTableColumns(table); this._emitDetailSelection(table); @@ -1219,6 +1191,41 @@ export class CalltreeView extends LitElement { document.dispatchEvent(new CustomEvent('lv-find-results', { detail: { totalMatches: 0 } })); } + /** Drop the search where its match numbering no longer describes the table. */ + private _dropSearch() { + if (!this.blockClearHighlights && this.totalMatches > 0) { + this._resetFindWidget(); + this._clearSearchHighlights(); + } + } + + /** + * Watch `table` for what a view has to answer per render. + * + * The filter caches are cleared once per render rather than on `dataFiltered`: + * row ids are unique within a build, so a cached `deepFilter` result stays + * valid across the cascaded filter passes Tabulator runs for each expanded + * subtree, which would otherwise fire `dataFiltered` several times per user + * action and defeat the cache. + * + * @param clearsFilterCaches - Bottom Up reads the caches but has never cleared + * them per render, so it keeps that behaviour here. + */ + private _watchTable(table: Tabulator, clearsFilterCaches: boolean) { + onTableReshaped(table, () => this._dropSearch()); + if (clearsFilterCaches) { + table.on('renderStarted', () => this._clearFilterCaches()); + } + } + + private _clearFilterCaches() { + this.debugOnlyFilterCache.clear(); + this.typeFilterCache.clear(); + this.namespaceFilterCache.clear(); + this.totalTimeFilterCache.clear(); + this.selfTimeFilterCache.clear(); + } + private _clearSearchHighlights() { this.findArgs.text = ''; this.findArgs.count = 0; diff --git a/log-viewer/src/features/call-tree/components/TableShared.ts b/log-viewer/src/features/call-tree/components/TableShared.ts index 9cb3fc633..37bfbed44 100644 --- a/log-viewer/src/features/call-tree/components/TableShared.ts +++ b/log-viewer/src/features/call-tree/components/TableShared.ts @@ -23,8 +23,6 @@ import { makeSumFieldAllVisible } from '../utils/BottomCalcs.js'; import { governorCostBreakdown, type GovernorCostRow } from '../utils/GovernorCost.js'; export interface TableCallbacks { - 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 2ad706ea4..05a4110ac 100644 --- a/log-viewer/src/features/call-tree/components/TimeOrderTable.ts +++ b/log-viewer/src/features/call-tree/components/TimeOrderTable.ts @@ -143,18 +143,6 @@ export function createTimeOrderTable( }); tableRef.current = table; - // 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', () => { - callbacks.onFilterCacheClear?.(); - callbacks.onRenderStarted(); - }); - table.on('rowContext', (e: UIEvent, row: RowComponent) => { callbacks.onContextMenu(e, row); }); diff --git a/log-viewer/src/styles/global.styles.ts b/log-viewer/src/styles/global.styles.ts index 4c6dee0aa..4e3e4505e 100644 --- a/log-viewer/src/styles/global.styles.ts +++ b/log-viewer/src/styles/global.styles.ts @@ -71,13 +71,14 @@ export const globalStyles = [ background-color: var(--vscode-scrollbarSlider-background); } + /* findMatch is the match you are on; findMatchHighlight is the rest. */ ::highlight(find-match) { - color: var(--vscode-editor-findMatchForeground); + color: var(--vscode-editor-findMatchHighlightForeground); background-color: var(--vscode-editor-findMatchHighlightBackground, yellow); } ::highlight(current-find-match) { - color: var(--vscode-editor-findMatchHighlightForeground); + color: var(--vscode-editor-findMatchForeground); background-color: var(--vscode-editor-findMatchBackground, #8b8000); } diff --git a/log-viewer/src/tabulator/module/Find.ts b/log-viewer/src/tabulator/module/Find.ts index d7cd7dcd6..b526866ec 100644 --- a/log-viewer/src/tabulator/module/Find.ts +++ b/log-viewer/src/tabulator/module/Find.ts @@ -27,6 +27,10 @@ export class Find extends Module { _cachedRegex: RegExp | null = null; _currentMatchIndex = 0; _matchIndexes: { [key: number]: RowComponent } = {}; + _highlightFrame: number | null = null; + /** The fields the last search covered, which the highlights follow so the two + * describe the same matches. */ + _searchedFields: Set = new Set(); // Headless formatter execution: single detached element (never in the document) // and a per-row-field text cache keyed by the stable row-data object reference. @@ -61,39 +65,33 @@ export class Find extends Module { }); this.table.on('renderComplete', () => { - if (this._findArgs?.text) { - this._applyHighlights(); - } + this._scheduleHighlights(); }); - // Virtual scroll doesn't fire renderComplete, so listen for scroll events - // to apply highlights to newly visible rows. Debounced to avoid blocking - // the main thread during fast scrolling, plus scrollend for instant final update. - const holder = this.table.element.querySelector('.tabulator-tableholder'); - if (holder) { - let rafId: number | null = null; - holder.addEventListener('scroll', () => { - if (!this._findArgs?.text) { - return; - } - if (rafId === null) { - rafId = requestAnimationFrame(() => { - rafId = null; - this._applyHighlights(); - }); - } - }); + // A row arrives with no highlight in it, and renderComplete does not report + // every arrival. A scroll brings rows in without one, on our renderer and on + // Tabulator's own. + this.table.on('scrollVertical', () => { + this._scheduleHighlights(); + }); - holder.addEventListener('scrollend', () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - if (this._findArgs?.text) { - this._applyHighlights(); - } - }); + // And a sort or a filter brings rows in without a scroll. Ours alone reports + // this, so a grid on Tabulator's renderer is covered by the scroll above. + this.subscribe('render-virtual-attach', () => { + this._scheduleHighlights(); + }); + } + + /** Re-apply once for the frame: a render can attach twice and complete once, + * and the rebuild reads every cell on screen. */ + _scheduleHighlights() { + if (this._highlightFrame !== null || !this._findArgs?.text) { + return; } + this._highlightFrame = requestAnimationFrame(() => { + this._highlightFrame = null; + this._applyHighlights(); + }); } async _find(findArgs: FindArgs) { @@ -145,6 +143,7 @@ export class Find extends Module { // columnManager.getRealColumns() is internal โ€” returns columnsByIndex, same order as getCells() const internalCols: Array<{ field: string; + visible: boolean; getComponent: () => ColumnComponent; getFieldValue: (data: object) => unknown; modules?: { @@ -159,6 +158,13 @@ export class Find extends Module { }; }> = this.table.columnManager?.getRealColumns?.() ?? []; + // columnsByIndex holds every column, shown or not: a hidden column's match + // cannot be seen, so counting it gives a total the user cannot reach and a + // number the highlights cannot line up with. + this._searchedFields = new Set( + internalCols.filter((col) => col.field && col.visible).map((col) => col.field), + ); + const len = flattenedRows.length; for (let i = 0; i < len; i++) { const row = flattenedRows[i]; @@ -177,7 +183,7 @@ export class Find extends Module { for (const col of internalCols) { const field = col.field; - if (!field) { + if (!field || !this._searchedFields.has(field)) { continue; } @@ -232,6 +238,12 @@ export class Find extends Module { } _applyHighlights() { + if (this._highlightFrame !== null) { + // Whatever asked for this frame is being answered now. + cancelAnimationFrame(this._highlightFrame); + this._highlightFrame = null; + } + // Lazy-init static Highlights if (!Find._findHighlight) { Find._findHighlight = new Highlight(); @@ -268,9 +280,19 @@ export class Find extends Module { } const data = row.getData(); + // A row the search visited and found nothing in cannot hold a match, so + // none of its cells are worth a text walk. A row built since the search + // ran has no list at all, and is walked. + if (data.highlightIndexes?.length === 0) { + continue; + } let matchIdx = 0; row.getCells().forEach((cell) => { + if (!this._searchedFields.has(cell.getField())) { + return; + } + const elem = cell.getElement(); // Build a flat text-node map so we can create Ranges that span across // adjacent elements (e.g. two s whose text forms a single match). diff --git a/log-viewer/src/tabulator/module/__tests__/Find.test.ts b/log-viewer/src/tabulator/module/__tests__/Find.test.ts new file mode 100644 index 000000000..3d51cd7f2 --- /dev/null +++ b/log-viewer/src/tabulator/module/__tests__/Find.test.ts @@ -0,0 +1,149 @@ +/** + * @jest-environment jsdom + */ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +// The tabulator ESM build doesn't load under jest, and the module registers +// itself on import. +jest.mock('tabulator-tables', () => ({ + Module: class { + table: unknown; + constructor(table: unknown) { + this.table = table; + } + registerTableOption() {} + registerTableFunction() {} + subscribe() {} + }, +})); + +import { waitForNextFrame } from '../../../core/utility/FrameBudget.js'; +import { Find } from '../Find.js'; + +function setup() { + const subscribed: Record void)[]> = {}; + const listened: string[] = []; + const tableEvents: Record void)[]> = {}; + const table = { + on: (event: string, callback: () => void) => { + (tableEvents[event] ??= []).push(callback); + }, + element: { + querySelector: () => ({ + addEventListener: (event: string) => { + listened.push(event); + }, + }), + }, + }; + const find = new Find(table as never); + find.subscribe = (event: string, callback: () => void) => { + (subscribed[event] ??= []).push(callback); + }; + find.initialize(); + + const applied = jest.fn(); + find._applyHighlights = applied; + const attach = () => subscribed['render-virtual-attach']?.forEach((fn) => fn()); + const scroll = () => tableEvents['scrollVertical']?.forEach((fn) => fn()); + const nextFrame = waitForNextFrame; + return { find, applied, attach, scroll, nextFrame, listened }; +} + +describe('Find highlights on the rows a render attaches', () => { + it('re-applies once for the frame, however many attaches it took', async () => { + const { find, applied, attach, nextFrame } = setup(); + find._findArgs = { text: 'a', count: 0, options: { matchCase: false } }; + + attach(); + attach(); + expect(applied).not.toHaveBeenCalled(); + await nextFrame(); + + expect(applied).toHaveBeenCalledTimes(1); + }); + + it('leaves the rows alone while nothing is being searched for', async () => { + const { applied, attach, nextFrame } = setup(); + + attach(); + await nextFrame(); + + expect(applied).not.toHaveBeenCalled(); + }); + + it("re-applies on a scroll, which is all a grid on Tabulator's renderer reports", async () => { + const { find, applied, scroll, nextFrame } = setup(); + find._findArgs = { text: 'a', count: 0, options: { matchCase: false } }; + + scroll(); + await nextFrame(); + + expect(applied).toHaveBeenCalledTimes(1); + }); + + it('takes the scroll from the table, not from the holder element', () => { + // Tabulator reports it for every renderer, and the holder is rebuilt. + const { listened } = setup(); + + expect(listened).toEqual([]); + }); +}); + +/** A one-row table whose columns hold `value`, some of them hidden. */ +function findOver(columns: Array<{ field: string; visible: boolean; value: string }>) { + const rowData = {}; + const rows = [{ getData: () => rowData }]; + const table = { + on: () => {}, + getGroups: () => [], + getRows: () => rows, + modules: {}, + options: {}, + columnManager: { + // Tabulator indexes every column here, shown or not. + getRealColumns: () => + columns.map((column) => ({ + field: column.field, + visible: column.visible, + getComponent: () => ({}), + getFieldValue: () => column.value, + })), + }, + }; + const find = new Find(table as never); + // CSS.highlights does not exist in jsdom, and the count is what is under test. + find._applyHighlights = () => {}; + return find; +} + +describe('Find counts what the table is showing', () => { + const search = { text: 'default', count: 1, options: { matchCase: false } }; + + it('leaves a hidden column out of the count', async () => { + const find = findOver([ + { field: 'text', visible: true, value: 'Account default' }, + { field: 'namespace', visible: false, value: 'default' }, + ]); + + const result = await find._find(search); + + // A match nobody can see is a total the user cannot reach, and a number the + // highlights cannot line up with. + expect(result.totalMatches).toBe(1); + }); + + it('counts the same column once it is shown', async () => { + const find = findOver([ + { field: 'text', visible: true, value: 'Account default' }, + { field: 'namespace', visible: true, value: 'default' }, + ]); + + const result = await find._find(search); + + expect(result.totalMatches).toBe(2); + }); +}); diff --git a/log-viewer/src/tabulator/module/__tests__/tableReshape.test.ts b/log-viewer/src/tabulator/module/__tests__/tableReshape.test.ts new file mode 100644 index 000000000..0251346b0 --- /dev/null +++ b/log-viewer/src/tabulator/module/__tests__/tableReshape.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it, jest } from '@jest/globals'; +import type { SorterFromTable, Tabulator } from 'tabulator-tables'; + +import { onTableReshaped } from '../tableReshape.js'; + +function setup() { + const handlers = new Map void>(); + const table = { + on: (event: string, handler: (payload: SorterFromTable[]) => void) => { + handlers.set(event, handler); + }, + } as unknown as Tabulator; + const changed = jest.fn(); + onTableReshaped(table, changed); + return { + changed, + events: [...handlers.keys()], + sorted: (...sorters: Array<[string, string]>) => + handlers.get('dataSorting')?.( + sorters.map(([field, dir]) => ({ field, dir }) as SorterFromTable), + ), + columnsChanged: () => handlers.get('columnVisibilityChanged')?.([]), + }; +} + +describe('onTableReshaped', () => { + it('reports an order the table did not have before', () => { + const { changed, sorted } = setup(); + + sorted(['selfTime', 'desc']); + expect(changed).toHaveBeenCalledTimes(1); + + sorted(['selfTime', 'asc']); + sorted(['name', 'asc']); + expect(changed).toHaveBeenCalledTimes(3); + }); + + it('stays quiet where the order is the one already in force', () => { + const { changed, sorted } = setup(); + sorted(['selfTime', 'desc']); + + // What expanding a tree row does: each opened subtree is ordered through the + // same call, so the event repeats the order the table has. + sorted(['selfTime', 'desc']); + sorted(['selfTime', 'desc']); + + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('tells a second sort column from the first alone', () => { + const { changed, sorted } = setup(); + sorted(['selfTime', 'desc']); + + sorted(['selfTime', 'desc'], ['name', 'asc']); + + expect(changed).toHaveBeenCalledTimes(2); + }); + + it('reports a column going on or off show, which the matches are counted over', () => { + const { changed, columnsChanged } = setup(); + + columnsChanged(); + + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('reads the sort as it starts, so no row component is built to report it', () => { + // A dataSorted subscriber makes Tabulator build a component per sorted row. + expect(setup().events).toEqual(['dataSorting', 'columnVisibilityChanged']); + }); +}); diff --git a/log-viewer/src/tabulator/module/tableReshape.ts b/log-viewer/src/tabulator/module/tableReshape.ts new file mode 100644 index 000000000..040ae3135 --- /dev/null +++ b/log-viewer/src/tabulator/module/tableReshape.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { SorterFromTable, Tabulator } from 'tabulator-tables'; + +/** Whether `sorters` is the order already recorded in `fields` and `dirs`. */ +function unchanged( + sorters: readonly SorterFromTable[], + fields: readonly string[], + dirs: readonly string[], +): boolean { + if (sorters.length !== fields.length) { + return false; + } + for (let i = 0; i < sorters.length; i++) { + if (sorters[i]!.field !== fields[i] || sorters[i]!.dir !== dirs[i]) { + return false; + } + } + return true; +} + +/** + * Call `changed` when `table` reshapes under the user: the rows come back in a + * different order, or a different set of columns is on show. + * + * A sort event does not say the order changed: expanding or collapsing a tree row + * orders that row's children through the same `Sort.sort` call, so the event fires + * once per opened subtree carrying the order the table already had. The order + * itself is what is compared, and compared without allocating, because an expand + * of a large tree fires this per subtree. + * + * Read from `dataSorting` rather than `dataSorted`: both fire from that one call + * with the same sorters, but a `dataSorted` subscriber makes Tabulator build a row + * component for every row it sorted, and those are cached on the rows. + * + * Tabulator's own `sort-changed` is truthful about a sort but reaches modules only, + * and fires for any `setSort` call, including one re-applying the order in force. + * + * Grouping is not here: `dataGrouped` fires only while the table is grouped, so + * turning grouping off reports nothing. The caller asks for the grouping it wants, + * so it knows both ways round. + */ +export function onTableReshaped(table: Tabulator, changed: () => void): void { + const fields: string[] = []; + const dirs: string[] = []; + table.on('dataSorting', (sorters) => { + if (unchanged(sorters, fields, dirs)) { + return; + } + fields.length = 0; + dirs.length = 0; + for (const sorter of sorters) { + fields.push(sorter.field); + dirs.push(sorter.dir); + } + changed(); + }); + table.on('columnVisibilityChanged', () => { + changed(); + }); +} diff --git a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts index 13df25eea..99a14c113 100644 --- a/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts +++ b/log-viewer/src/tabulator/renderer/VirtualVerticalRenderer.ts @@ -87,6 +87,7 @@ interface RendererBase { styleRow: (row: RowInternals, index: number) => void; // CoreFeature.dispatch โ†’ table.eventBus (internal event chain). Stock // fires 'render-virtual-fill' after every fill; GroupRows depends on it. + // Ours adds 'render-virtual-attach', after every attach. dispatch: (event: string) => void; } @@ -1229,6 +1230,11 @@ export class VirtualVerticalRenderer extends Renderer { this._setHeight(entry.index, h, entry.row.data); } } + + // Ours: rows are in the table. What decorates rendered rows needs this and + // not 'render-virtual-fill', which an incremental scroll tick skips, nor a + // scroll event, which a sort or a filter never fires. + self.dispatch('render-virtual-attach'); } private _detachAllRendered(): void { diff --git a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts index 4a35421c1..017455cf7 100644 --- a/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts +++ b/log-viewer/src/tabulator/renderer/__tests__/VirtualVerticalRendererAttach.test.ts @@ -277,6 +277,58 @@ describe('VirtualVerticalRenderer render-virtual-fill dispatch (stock contract)' }); }); +describe('VirtualVerticalRenderer render-virtual-attach dispatch', () => { + interface AttachDispatchInternals extends AttachRendererInternals { + table: { rowManager: { element: { scrollTop: number } }; eventBus: { dispatch: jest.Mock } }; + _renderWindow: () => void; + } + + function makeDispatchSetup(rowCount: number): { + rr: AttachDispatchInternals; + rows: AttachRowStub[]; + attachCalls: () => number; + fillCalls: () => number; + } { + const { r, rows } = makeAttachSetup(rowCount); + const rr = r as AttachDispatchInternals; + const dispatched = jest.fn(); + rr.table.eventBus.dispatch = dispatched; + const calls = (event: string) => () => + dispatched.mock.calls.filter((c) => c[0] === event).length; + return { + rr, + rows, + attachCalls: calls('render-virtual-attach'), + fillCalls: calls('render-virtual-fill'), + }; + } + + it('dispatches once per attach, and not for a range that attached nothing', () => { + const { rr, rows, attachCalls } = makeDispatchSetup(4); + rr._attachRanges(rows, [[0, 3]], 0); + expect(attachCalls()).toBe(1); + + rr._attachRanges(rows, [], 0); + expect(attachCalls()).toBe(1); + }); + + it('reports a scroll tick that fill deliberately skips', () => { + const { rr, attachCalls, fillCalls } = makeDispatchSetup(200); + rr._renderWindow(); + const filled = fillCalls(); + const attached = attachCalls(); + + // Incremental scroll: rows enter the window, but the window is not + // replaced, so the stock fill contract stays silent. + rr.table.rowManager.element.scrollTop = 60; + rr.inScrollDrivenRender = true; + rr._renderWindow(); + + expect(fillCalls()).toBe(filled); + expect(attachCalls()).toBe(attached + 1); + }); +}); + describe('VirtualVerticalRenderer PseudoRow (group row) tolerance', () => { it('attaches PseudoRow-shaped rows without measuring or throwing', () => { const { r, rows, tableElement } = makeAttachSetup(5); From 22010a37cf85edaf88ee9548b1413cf4a2648a86 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:30:13 +0100 Subject: [PATCH 13/61] fix(log-viewer): hold the pointer on the governor limits strip (#983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The governor limits strip lost its hover as soon as the pointer left the canvas, so the crosshair and the chevron highlight flickered along the strip's edges. The gaps either side of the strip are its own layout, not somewhere the hover should end. **What changed.** The hover engages when the pointer is on the strip and survives into the two gaps. Each of the three boxes has its own listener, so the box that heard the move is the whole test โ€” no rectangle maths, and no cached rect to go stale after a resize. `strip-pointer.ts` is now chevron-only: `holdsStripHover` and its `HOVER_BAND` band went with the cached rect, because a band that guesses where the strip ends is exactly what the listener already knows. **The cursor.** A click centres the crosshair and the wheel zooms it, and the toggle column does neither, so the toggle column now reads as a pointer over its whole width rather than a crosshair that does nothing. `canvasRect`, `getCanvasRect()`, `mouseX` and `mouseY` are gone; the handlers read `event.offsetX` directly, which the gaps can share because they sit on the strip's left edge. ### Verification `tsc -b`, eslint and prettier clean; 1980 tests pass. By hand in the dev host: the crosshair holds along the whole strip including the gaps, the chevron highlights only over the chevron, and the pointer changes over the toggle column. Relates to #373. Merge after the resize PR โ€” both touch `MetricStripOrchestrator`. --- CHANGELOG.md | 1 + .../features/timeline/optimised/FlameChart.ts | 8 +- .../optimised/__tests__/strip-pointer.test.ts | 54 +++++++ .../metric-strip/MetricStripOrchestrator.ts | 134 ++++++++++++------ .../metric-strip/MetricStripRenderer.ts | 22 ++- .../optimised/metric-strip/strip-pointer.ts | 64 +++++++++ 6 files changed, 228 insertions(+), 55 deletions(-) create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/strip-pointer.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/metric-strip/strip-pointer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b5cdeb4..e8e3d83a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐Ÿ“ **Timeline length**: the chart stopped at the last frame the log recorded, so it drew shorter than the log's own duration โ€” 10.8s of a 27.1s log where the size cap cut the log off. The chart now spans the whole log, and the truncation marker shades the part the log never recorded. ([#828]) - ๐Ÿงญ **Hot spots**: the log itself topped the Inspector's hot spots, and the Analysis findings, whenever time went unrecorded โ€” the gap between frames lands on the log, which is a container and not code. It is now left out of both. - ๐Ÿ“Š **Governor limits strip**: where a log records nothing โ€” it hit the maximum size, or lines were skipped โ€” the strip drew its last reading across the gap as though it had been measured. The area fills, the over-100% band and the collapsed traffic light now leave the gap blank, the step line holds its last level, and the tooltip names the reason and the range, such as `Max-Size-reached ยท 10.8s โ†’ 27.1s`. Truncation shading also ends with its marker instead of running on to the next one. ([#828]) +- ๐Ÿ–ฑ๏ธ **Governor limits strip**: the strip is 15px tall when collapsed, so reading across it lost the tooltip on the smallest vertical wobble. The hover now holds until the pointer is clear of the strip. Hovering the chevron blanks the tooltip only over the arrow, not across the whole 20px column, and the pointer reads as a crosshair over the data, where a click centres and the wheel zooms. - โšก **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge โ€” it cleared its canvases in one frame and drew in the next, sized to a box the drag had already left. It now clears, sizes and draws in the frame the layout changed, and no longer recomputes the minimap for a change that cannot alter it. ## [1.20.1] 2026-07-23 diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 51231349a..55bb5f1dd 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -179,6 +179,7 @@ export class FlameChart { private metricStripOrchestrator: MetricStripOrchestrator | null = null; private metricStripDiv: HTMLElement | null = null; // HTML container for metric strip canvas private metricStripGapDiv: HTMLElement | null = null; // Gap element below metric strip + private minimapGapDiv: HTMLElement | null = null; // Gap element above metric strip // Cursor line renderer for main timeline (bidirectional cursor mirroring) private cursorLineRenderer: CursorLineRenderer | null = null; @@ -1027,6 +1028,7 @@ export class FlameChart { // Gap element between minimap and metric strip const minimapGapDiv = document.createElement('div'); + this.minimapGapDiv = minimapGapDiv; minimapGapDiv.style.cssText = `height:${MINIMAP_GAP}px;width:100%;flex-shrink:0;background:transparent`; // Metric strip container (fixed height) @@ -1338,7 +1340,7 @@ export class FlameChart { onMinimapResetZoom: () => this.resetZoom(), // Metric strip keyboard callbacks (delegated to viewport via animation) - isInMetricStripArea: () => this.metricStripOrchestrator?.isMouseInMetricStripArea() ?? false, + isInMetricStripArea: () => this.metricStripOrchestrator?.holdsHover() ?? false, onMetricStripPanViewport: (delta) => this.handleAnimatedPanViewport(delta), onMetricStripPanDepth: (delta) => this.handleAnimatedPanDepth(delta), onMetricStripZoom: (dir) => this.handleAnimatedZoom(dir), @@ -1539,10 +1541,12 @@ export class FlameChart { }); // Initialize the orchestrator + // The gaps either side are the strip's hover band, so it hears the pointer there too. await this.metricStripOrchestrator.init( this.metricStripDiv, displayWidth, this.index.totalDuration, + [this.minimapGapDiv, this.metricStripGapDiv], ); // Focus container on metric strip mousedown for keyboard support @@ -2464,7 +2468,7 @@ export class FlameChart { } // Get cursor time from metric strip or minimap (for bidirectional sync) - const cursorTimeNs = this.metricStripOrchestrator.isMouseInMetricStripArea() + const cursorTimeNs = this.metricStripOrchestrator.holdsHover() ? this.metricStripOrchestrator.getCursorTimeNs() : (this.minimapOrchestrator?.getCursorTimeNs() ?? null); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/strip-pointer.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/strip-pointer.test.ts new file mode 100644 index 000000000..8dd628325 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/strip-pointer.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Unit tests for the metric strip's collapse chevron hit area. + */ + +import { describe, expect, it } from '@jest/globals'; +import { chevronBox, isOverChevron } from '../metric-strip/strip-pointer.js'; + +describe('isOverChevron', () => { + // The glyph is 5x10 collapsed and 10x5 expanded, drawn from x=6, y=2.5. + it('covers the arrow that is drawn', () => { + expect(isOverChevron(8, 7, true)).toBe(true); + expect(isOverChevron(8, 5, false)).toBe(true); + }); + + it('does not claim the whole 20px toggle column', () => { + // Past the collapsed arrow's right edge, still inside the column. + expect(isOverChevron(17, 7, true)).toBe(false); + // Below the expanded arrow, still inside the strip. + expect(isOverChevron(8, 30, false)).toBe(false); + }); + + it('follows the arrow that the collapsed state draws', () => { + // x=17 is inside the expanded arrow's reach but past the collapsed one's. + expect(isOverChevron(17, 5, false)).toBe(true); + expect(isOverChevron(17, 5, true)).toBe(false); + // y=13 is inside the tall collapsed arrow but below the short expanded one. + expect(isOverChevron(8, 13, true)).toBe(true); + expect(isOverChevron(8, 13, false)).toBe(false); + }); +}); + +// The renderer draws inside this box, so the hit test cannot drift from the drawn arrow. +describe('chevronBox', () => { + it('turns the arrow on its side when the strip expands', () => { + const shut = chevronBox(true); + const open = chevronBox(false); + + expect(shut.height).toBe(open.width); + expect(shut.width).toBe(open.height); + expect(shut.x).toBe(open.x); + expect(shut.y).toBe(open.y); + }); + + it('stays inside the toggle column it shares with the click target', () => { + for (const box of [chevronBox(true), chevronBox(false)]) { + expect(box.x).toBeGreaterThan(0); + expect(box.x + box.width).toBeLessThan(20); + } + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts index 7d27cb1b3..5e42fdf2b 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts @@ -36,6 +36,7 @@ import { wheelZoomFactor } from '../ViewportUtils.js'; import { MetricStripRenderer } from './MetricStripRenderer.js'; import { MetricStripTooltipRenderer } from './MetricStripTooltipRenderer.js'; import { MetricTierClassifier } from './MetricTierClassifier.js'; +import { isOverChevron } from './strip-pointer.js'; import { getMetricStripColors, METRIC_STRIP_COLLAPSED_HEIGHT, @@ -156,9 +157,10 @@ export class MetricStripOrchestrator { // STATE // ============================================================================ private cursorTimeNs: number | null = null; - private isMouseInMetricStrip = false; - private mouseX = 0; - private mouseY = 0; + /** Whether the strip holds the pointer, its gaps included. */ + private isHoverEngaged = false; + /** The boxes the pointer is heard in: the strip, and the gap either side of it. */ + private pointerTargets: HTMLElement[] = []; private totalDuration = 0; private callbacks: MetricStripOrchestratorCallbacks; /** Last viewport state received during render (for wheel handler). */ @@ -180,11 +182,13 @@ export class MetricStripOrchestrator { * @param metricStripDiv - HTML element to render metric strip into * @param width - Canvas width * @param totalDuration - Total timeline duration in nanoseconds + * @param gapDivs - The gap boxes either side of the strip that its hover survives into */ public async init( metricStripDiv: HTMLElement, width: number, totalDuration: number, + gapDivs: (HTMLElement | null)[] = [], ): Promise { this.htmlContainer = metricStripDiv; this.totalDuration = totalDuration; @@ -240,6 +244,10 @@ export class MetricStripOrchestrator { // Initialize tooltip renderer this.tooltipRenderer = new MetricStripTooltipRenderer(metricStripDiv); + this.pointerTargets = [metricStripDiv, ...gapDivs].filter( + (div): div is HTMLElement => div !== null, + ); + // Setup interaction handler this.setupInteractionHandler(); } @@ -251,14 +259,17 @@ export class MetricStripOrchestrator { // Remove event listeners if (this.app?.canvas) { const canvas = this.app.canvas as HTMLCanvasElement; - canvas.removeEventListener('mouseenter', this.handleMouseEnter); - canvas.removeEventListener('mouseleave', this.handleMouseLeave); - canvas.removeEventListener('mousemove', this.handleMouseMove); canvas.removeEventListener('click', this.handleClick); canvas.removeEventListener('wheel', this.handleWheel); canvas.removeEventListener('dblclick', this.handleDoubleClick); } + for (const target of this.pointerTargets) { + target.removeEventListener('mousemove', this.handleMouseMove); + target.removeEventListener('mouseleave', this.handleTargetLeave); + } + this.pointerTargets = []; + if (this.tooltipRenderer) { this.tooltipRenderer.destroy(); this.tooltipRenderer = null; @@ -366,10 +377,13 @@ export class MetricStripOrchestrator { } /** - * Check if mouse is currently in the metric strip area. + * Whether the strip holds the pointer's hover, its gaps included. + * + * A hidden strip holds nothing: it can be hidden without a pointer move, so no mouse leave + * is guaranteed to have released it. */ - public isMouseInMetricStripArea(): boolean { - return this.isMouseInMetricStrip; + public holdsHover(): boolean { + return this.isHoverEngaged && this.getIsVisible(); } /** @@ -520,46 +534,93 @@ export class MetricStripOrchestrator { const canvas = this.app.canvas as HTMLCanvasElement; - canvas.addEventListener('mouseenter', this.handleMouseEnter); - canvas.addEventListener('mouseleave', this.handleMouseLeave); - canvas.addEventListener('mousemove', this.handleMouseMove); canvas.addEventListener('click', this.handleClick); canvas.addEventListener('wheel', this.handleWheel, { passive: false }); canvas.addEventListener('dblclick', this.handleDoubleClick); + + // Moves come from the strip and the gaps either side, so the hover survives leaving a + // 15px canvas without hearing every move over the chart. + for (const target of this.pointerTargets) { + target.addEventListener('mousemove', this.handleMouseMove); + target.addEventListener('mouseleave', this.handleTargetLeave); + } } - private handleMouseEnter = (): void => { - this.isMouseInMetricStrip = true; + /** + * Leaving one box is not leaving the band: the strip and its gaps are separate elements, + * so crossing between them fires a leave the hover has to survive. + */ + private handleTargetLeave = (event: MouseEvent): void => { + const to = event.relatedTarget; + if (to instanceof Node && this.pointerTargets.some((target) => target.contains(to))) { + return; + } + this.releaseHover(); }; - private handleMouseLeave = (): void => { - this.isMouseInMetricStrip = false; + /** + * Show one cursor across every box the hover reaches. + * + * The gaps hold the hover as much as the strip does, so a cursor set on the canvas alone + * reverts to an arrow the moment the pointer strays into one. + */ + private setPointerCursor(cursor: string): void { + for (const target of this.pointerTargets) { + target.style.cursor = cursor; + } + if (this.app?.canvas) { + (this.app.canvas as HTMLCanvasElement).style.cursor = cursor; + } + } + + /** Give up the hover: no cursor line, no tooltip, no crosshair. */ + private releaseHover(): void { + if (!this.isHoverEngaged) { + return; + } + this.isHoverEngaged = false; this.cursorTimeNs = null; this.callbacks.onCursorMove(null); this.tooltipRenderer?.hide(); + this.renderer?.setToggleHovered(false); + this.setPointerCursor(''); this.callbacks.requestCursorRender(); - }; + } private handleMouseMove = (event: MouseEvent): void => { if (!this.app?.canvas || !this.classifier || !this.lastViewportState) { return; } - const canvas = this.app.canvas as HTMLCanvasElement; - const rect = canvas.getBoundingClientRect(); - this.mouseX = event.clientX - rect.left; - this.mouseY = event.clientY - rect.top; + if (!this.getIsVisible()) { + return; + } + + // The hover starts on the strip and survives into the gap either side. Each of the three + // boxes has its own listener, so the box that heard the move is the whole test. + const onStrip = event.currentTarget === this.htmlContainer; + if (!onStrip && !this.isHoverEngaged) { + return; + } + this.isHoverEngaged = true; + + // The gaps share the strip's left edge and the canvas fills it, so X needs no rect. + const offsetX = event.offsetX; - // Check if hovering over toggle area - const isOverToggle = this.mouseX < METRIC_STRIP_TOGGLE_WIDTH; + // The arrow, for the tooltip and the glyph highlight. The column around it stays a + // forgiving click target, so the cursor follows the column, not the arrow. + const isOverToggle = onStrip && isOverChevron(offsetX, event.offsetY, this.isCollapsed); this.renderer?.setToggleHovered(isOverToggle); - // Update cursor style - canvas.style.cursor = isOverToggle ? 'pointer' : 'default'; + // Crosshair over the data: a click centres it and the wheel zooms it. The toggle column + // does neither, so it reads as a pointer over its whole width. + this.setPointerCursor(offsetX < METRIC_STRIP_TOGGLE_WIDTH ? 'pointer' : 'crosshair'); // Update cursor position using stored viewport state - const timeNs = (this.mouseX + this.lastViewportState.offsetX) / this.lastViewportState.zoom; - const clampedTimeNs = Math.max(0, Math.min(this.totalDuration, timeNs)); + const clampedTimeNs = this.screenXToTime(offsetX); + if (clampedTimeNs === null) { + return; + } this.cursorTimeNs = clampedTimeNs; this.callbacks.onCursorMove(clampedTimeNs); @@ -576,8 +637,8 @@ export class MetricStripOrchestrator { gap ? `${gap.summary} ยท ${formatTimeRange(gap.startTime, gap.endTime)}` : null, ); this.tooltipRenderer?.show( - this.mouseX, - this.mouseY, + offsetX, + event.offsetY, dataPoint.point, this.classifier.getClassifiedMetrics(), this.getHeight(), @@ -595,18 +656,15 @@ export class MetricStripOrchestrator { return; } - const canvas = this.app.canvas as HTMLCanvasElement; - const rect = canvas.getBoundingClientRect(); - const clickX = event.clientX - rect.left; - // Click on toggle area (left edge) or Shift+click anywhere toggles collapsed state - if (clickX < METRIC_STRIP_TOGGLE_WIDTH || event.shiftKey) { + if (event.offsetX < METRIC_STRIP_TOGGLE_WIDTH || event.shiftKey) { this.toggleCollapsed(); return; } // Process pan immediately using stored viewport state - const clickTimeNs = (clickX + this.lastViewportState.offsetX) / this.lastViewportState.zoom; + const clickTimeNs = + (event.offsetX + this.lastViewportState.offsetX) / this.lastViewportState.zoom; // Keep current zoom level, just center on clicked time const visibleDuration = this.lastViewportState.displayWidth / this.lastViewportState.zoom; @@ -639,12 +697,8 @@ export class MetricStripOrchestrator { return; } - const canvas = this.app.canvas as HTMLCanvasElement; - const rect = canvas.getBoundingClientRect(); - const clickX = event.clientX - rect.left; - // Ignore double-clicks on toggle area (left edge) - if (clickX < METRIC_STRIP_TOGGLE_WIDTH) { + if (event.offsetX < METRIC_STRIP_TOGGLE_WIDTH) { return; } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts index 8b220f9d8..aa20ba511 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts @@ -61,6 +61,7 @@ import { METRIC_STRIP_Y_MAX_PERCENT, type MetricStripColors, } from './metric-strip-colors.js'; +import { chevronBox } from './strip-pointer.js'; // Re-export toggle width for use by orchestrator export { METRIC_STRIP_TOGGLE_WIDTH }; @@ -69,13 +70,6 @@ export { METRIC_STRIP_TOGGLE_WIDTH }; // CONSTANTS // ============================================================================ -/** Toggle icon left padding in pixels */ -const TOGGLE_ICON_PADDING_X = 6; -/** Toggle icon top padding in pixels */ -const TOGGLE_ICON_PADDING_Y = 2.5; -/** Toggle icon size in pixels */ -const TOGGLE_ICON_SIZE = 5; - /** Limit line dash length in pixels */ const LIMIT_LINE_DASH = 8; /** Limit line gap length in pixels */ @@ -309,17 +303,19 @@ export class MetricStripRenderer { const g = this.toggleGraphics; const iconColor = this.isToggleHovered ? this.toggleIconHoverColor : this.toggleIconColor; + const box = chevronBox(this.isCollapsed); + if (this.isCollapsed) { // โ–ถ (right-pointing triangle) - g.moveTo(TOGGLE_ICON_PADDING_X, TOGGLE_ICON_PADDING_Y); - g.lineTo(TOGGLE_ICON_PADDING_X + TOGGLE_ICON_SIZE, TOGGLE_ICON_PADDING_Y + TOGGLE_ICON_SIZE); - g.lineTo(TOGGLE_ICON_PADDING_X, TOGGLE_ICON_PADDING_Y + TOGGLE_ICON_SIZE * 2); + g.moveTo(box.x, box.y); + g.lineTo(box.x + box.width, box.y + box.height / 2); + g.lineTo(box.x, box.y + box.height); g.closePath(); } else { // โ–ผ (down-pointing triangle) - g.moveTo(TOGGLE_ICON_PADDING_X, TOGGLE_ICON_PADDING_Y); - g.lineTo(TOGGLE_ICON_PADDING_X + TOGGLE_ICON_SIZE * 2, TOGGLE_ICON_PADDING_Y); - g.lineTo(TOGGLE_ICON_PADDING_X + TOGGLE_ICON_SIZE, TOGGLE_ICON_PADDING_Y + TOGGLE_ICON_SIZE); + g.moveTo(box.x, box.y); + g.lineTo(box.x + box.width, box.y); + g.lineTo(box.x + box.width / 2, box.y + box.height); g.closePath(); } g.fill({ color: iconColor, alpha: 1.0 }); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/strip-pointer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/strip-pointer.ts new file mode 100644 index 000000000..849a6b184 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/metric-strip/strip-pointer.ts @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Where the metric strip's pointer counts as on its collapse chevron. + * Kept apart from the orchestrator so the rule can be read and tested on its own. + */ + +/** Chevron left padding in pixels */ +const TOGGLE_ICON_PADDING_X = 6; +/** Chevron top padding in pixels */ +const TOGGLE_ICON_PADDING_Y = 2.5; +/** Chevron arm length in pixels */ +const TOGGLE_ICON_SIZE = 5; + +/** How far outside the chevron still counts as over it, in pixels. */ +const CHEVRON_HIT_PAD = 3; + +/** A box on the strip, in strip coordinates. */ +export interface StripBox { + x: number; + y: number; + width: number; + height: number; +} + +/** + * The collapse chevron's box: โ–ถ when collapsed, โ–ผ when expanded. + * + * The one source for the arrow โ€” the renderer draws inside this box and the hit test reads + * it, so a change to the glyph cannot leave the two disagreeing. + * + * @param isCollapsed - Which arrow is drawn + */ +export function chevronBox(isCollapsed: boolean): StripBox { + return { + x: TOGGLE_ICON_PADDING_X, + y: TOGGLE_ICON_PADDING_Y, + width: isCollapsed ? TOGGLE_ICON_SIZE : TOGGLE_ICON_SIZE * 2, + height: isCollapsed ? TOGGLE_ICON_SIZE * 2 : TOGGLE_ICON_SIZE, + }; +} + +/** + * Whether the pointer is over the collapse chevron. + * + * The arrow, not the 20px column it sits in: the column is a forgiving click target, but + * hovering it must not blank the tooltip four times wider than the arrow drawn. + * + * @param offsetX - Pointer X relative to the strip's left edge + * @param offsetY - Pointer Y relative to the strip's top edge + * @param isCollapsed - Which arrow is drawn + */ +export function isOverChevron(offsetX: number, offsetY: number, isCollapsed: boolean): boolean { + const box = chevronBox(isCollapsed); + + return ( + offsetX >= box.x - CHEVRON_HIT_PAD && + offsetX <= box.x + box.width + CHEVRON_HIT_PAD && + offsetY >= box.y - CHEVRON_HIT_PAD && + offsetY <= box.y + box.height + CHEVRON_HIT_PAD + ); +} From 687273433c4db7bb1a5e50d04eb509a95320c157 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:34:12 +0100 Subject: [PATCH 14/61] fix(log-viewer): outline a selected frame instead of dimming the chart (#984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a frame dimmed the rest of the chart, and the hover wash and the tooltip went stale whenever the frames moved under a still pointer. Chrome DevTools' Flame Chart is the reference: a select outlines, a hover washes, and dimming is reserved for a search or an insight โ€” the cases where the chart is telling you a set of frames does not match. **Select outlines.** A click now outlines the frame and leaves the rest of the chart at full strength. Dimming stays for the inspector's match sets, which is what it is for. **The wash follows the frames.** Panning, zooming and resizing all move frames under a stationary pointer, and the last hit answer expires the moment they do. It is now re-derived straight after culling โ€” one rule that covers every viewport write, instead of one per input. The re-hit has to happen there: at the end of `render()` it sat inside the animation frame, so the repaint it asked for was swallowed by the frame already running. **A drag points at nothing.** A held button means the pointer is operating the view, not pointing at a frame, so any drag โ€” pan, measure, area zoom or minimap resize โ€” clears the wash and the tooltip, and the first render after it ends washes whatever it settled on. Wheel and keyboard moves keep the hover live, because the pointer is still resting on content. This also fixes a mirror defect nobody had reported: a measure or area-zoom drag used to freeze a stale wash that then slid away from the pointer. **One clear.** `pickEmphasis(undefined)` read as picking nothing; it is now `clearEmphasis()`. And `HoverHighlightRenderer` skips its `clear()` when no wash is on screen, because PIXI marks the `Graphics` dirty on every clear. ### Verification `tsc -b`, eslint and prettier clean; 1993 tests pass. New `HoverRehit.test.ts`, `HoverTracker.test.ts` and `HoverWash.test.ts`; `chart-select-dim.test.ts` rewritten to record each emphasis call. The re-hit placement was proven by moving it back and watching the suite fail. By hand in the dev host on a 100MB log: select outlines with no dim, the wash tracks the frames through a pan and a zoom, no wash or tooltip during any drag, and both return on the first move after it. Relates to #373. Merge last โ€” it touches the same `scheduleRender`/`render` block as the resize PR, so it needs a rebase once that lands. --- CHANGELOG.md | 1 + .../__tests__/chart-select-dim.test.ts | 102 +++++++++++++ .../timeline/optimised/ApexLogTimeline.ts | 32 +++-- .../features/timeline/optimised/FlameChart.ts | 71 ++++++++- .../optimised/__tests__/HoverRehit.test.ts | 136 ++++++++++++++++++ .../optimised/__tests__/HoverTracker.test.ts | 102 +++++++++++++ .../optimised/__tests__/HoverWash.test.ts | 64 +++++++++ .../optimised/interaction/HoverTracker.ts | 84 +++++++++++ .../interaction/TimelineInteractionHandler.ts | 20 +++ .../optimised/rendering/HighlightRenderer.ts | 111 ++++++++------ .../rendering/HoverHighlightRenderer.ts | 89 ++++++++++++ .../timeline/types/flamechart.types.ts | 6 + 12 files changed, 757 insertions(+), 61 deletions(-) create mode 100644 log-viewer/src/features/timeline/__tests__/chart-select-dim.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/HoverTracker.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/interaction/HoverTracker.ts create mode 100644 log-viewer/src/features/timeline/optimised/rendering/HoverHighlightRenderer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e3d83a7..6c80d6525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ๐Ÿ“Š **Governor limits strip**: where a log records nothing โ€” it hit the maximum size, or lines were skipped โ€” the strip drew its last reading across the gap as though it had been measured. The area fills, the over-100% band and the collapsed traffic light now leave the gap blank, the step line holds its last level, and the tooltip names the reason and the range, such as `Max-Size-reached ยท 10.8s โ†’ 27.1s`. Truncation shading also ends with its marker instead of running on to the next one. ([#828]) - ๐Ÿ–ฑ๏ธ **Governor limits strip**: the strip is 15px tall when collapsed, so reading across it lost the tooltip on the smallest vertical wobble. The hover now holds until the pointer is clear of the strip. Hovering the chevron blanks the tooltip only over the arrow, not across the whole 20px column, and the pointer reads as a crosshair over the data, where a click centres and the wheel zooms. - โšก **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge โ€” it cleared its canvases in one frame and drew in the next, sized to a box the drag had already left. It now clears, sizes and draws in the frame the layout changed, and no longer recomputes the minimap for a change that cannot alter it. +- ๐ŸŽฏ **Timeline highlight**: clicking a frame greyed out the whole rest of the chart, which reads as a filter and not a selection. A click now selects and leaves every other frame in its own colour, and the frame under the pointer washes as you move across. The Inspector and the search still grey out what they do not match. ## [1.20.1] 2026-07-23 diff --git a/log-viewer/src/features/timeline/__tests__/chart-select-dim.test.ts b/log-viewer/src/features/timeline/__tests__/chart-select-dim.test.ts new file mode 100644 index 000000000..e90dd19b8 --- /dev/null +++ b/log-viewer/src/features/timeline/__tests__/chart-select-dim.test.ts @@ -0,0 +1,102 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Who dims the chart. A click on the chart selects and dims nothing; the inspector's own + * select keeps its dim. Chrome DevTools dims for a search or a filter, never for a select. + */ + +import { describe, expect, it, jest } from '@jest/globals'; +import { ApexLogTimeline } from '../optimised/ApexLogTimeline.js'; + +/** What the emphasis was asked to do, in order: an eventIndex to mark, or 'clear'. */ +type EmphasisCall = number | 'clear'; + +function timelineWithSpy(): { + select: (eventIndex: number | undefined) => void; + selectMarker: (eventIndex: number | undefined) => void; + revealFromInspector: (eventIndex: number) => void; + emphasised: () => EmphasisCall[]; +} { + const timeline = new ApexLogTimeline(); + const internals = timeline as unknown as Record; + let calls: EmphasisCall[] = []; + + internals['flamechart'] = { + locateByEventNodes: jest.fn(), + // The inspector's select re-enters handleSelect, as the real chart does. + selectByEventNode: () => { + handleSelect.call(timeline, null); + return true; + }, + getViewportManager: () => null, + }; + internals['pickEmphasis'] = (eventIndex: number) => calls.push(eventIndex); + internals['clearEmphasis'] = () => calls.push('clear'); + // One root event, so the inspector's reveal can resolve it. + const event = { eventIndex: 4, timestamp: 0, duration: { total: 10 }, parent: null }; + internals['apexLog'] = { eventsById: { 4: event } }; + + const handleSelect = internals['handleSelect'] as (node: unknown) => void; + const handleMarkerSelect = internals['handleMarkerSelect'] as (marker: unknown) => void; + const reveal = internals['selectFrameByEventIndex'] as (eventIndex: number) => void; + + return { + select: (eventIndex) => { + calls = []; + handleSelect.call(timeline, eventIndex === undefined ? null : { original: { eventIndex } }); + }, + selectMarker: (eventIndex) => { + calls = []; + handleMarkerSelect.call(timeline, eventIndex === undefined ? null : { eventIndex }); + }, + revealFromInspector: (eventIndex) => { + calls = []; + reveal.call(timeline, eventIndex); + }, + emphasised: () => calls, + }; +} + +describe('who dims the chart', () => { + it('dims nothing when a frame is clicked on the chart', () => { + const timeline = timelineWithSpy(); + + timeline.select(7); + + expect(timeline.emphasised()).toEqual(['clear']); + }); + + it('dims nothing when a marker is clicked on the chart', () => { + const timeline = timelineWithSpy(); + + timeline.selectMarker(7); + + expect(timeline.emphasised()).toEqual(['clear']); + }); + + // The inspector marks the frame it asked for, and that mark is what dims the rest. The + // select it drives clears first, so the mark has to land after it. + it('marks the frame the inspector reveals, after the select it drives', () => { + const timeline = timelineWithSpy(); + + timeline.revealFromInspector(4); + + expect(timeline.emphasised()).toEqual(['clear', 4]); + }); + + // The chart is the source of truth once clicked, so it drops the mark left behind. + it('drops the inspector mark when the chart is then clicked', () => { + const timeline = timelineWithSpy(); + + timeline.revealFromInspector(4); + timeline.select(9); + + expect(timeline.emphasised()).toEqual(['clear']); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index aff6d398c..2545dbe59 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -250,6 +250,10 @@ export class ApexLogTimeline { return; } + // The inspector asked for this frame, so its mark is what dims the rest. Set after the + // run: the select inside it clears the mark, as any chart select does. + this.pickEmphasis(eventIndex); + const bounds = this.flamechart.getViewportManager()?.getBounds(); if ( bounds && @@ -281,13 +285,14 @@ export class ApexLogTimeline { this.flamechart.locateByEventNodes(nodes); } - /** - * The frame the emphasis rests on between pointer moves: the selected one, or - * none once the selection is cleared. Markers that map to no log event, and - * frames the parser gave no index, count as none. - */ - private pickEmphasis(eventIndex: number | undefined): void { - this.applyEmphasis(this.emphasis.pick(eventIndex === undefined ? [] : [eventIndex])); + /** Rest the emphasis on one frame, until something else picks or clears it. */ + private pickEmphasis(eventIndex: number): void { + this.applyEmphasis(this.emphasis.pick([eventIndex])); + } + + /** Drop the resting emphasis, so nothing dims. */ + private clearEmphasis(): void { + this.applyEmphasis(this.emphasis.pick([])); } /** @@ -589,7 +594,7 @@ export class ApexLogTimeline { // A click on empty space drops a mark left by a picked inspector row, which // the chart's own clear says nothing about when it held no selection. if (!eventNode && !marker) { - this.pickEmphasis(undefined); + this.clearEmphasis(); } // Frame and marker clicks are handled by FlameChart's selection system @@ -603,11 +608,10 @@ export class ApexLogTimeline { * Use J key for explicit "jump to call tree" action. */ private handleSelect(eventNode: EventNode | null): void { - // The emphasis follows every select, including the one made on the - // inspector's behalf โ€” the guard holds back the echo, not the dim. - const selected = (eventNode as (EventNode & { original?: LogEvent }) | null)?.original - ?.eventIndex; - this.pickEmphasis(selected); + // A select says what to look at, so nothing dims and any mark a picked inspector row + // left behind is dropped. Chrome dims for a search, never for a select. The inspector + // re-marks its own frame after the select it asked for. + this.clearEmphasis(); if (this.echoGuard.suppressed) { return; @@ -657,7 +661,7 @@ export class ApexLogTimeline { * Handle marker selection change from FlameChart. */ private handleMarkerSelect(marker: TimelineMarker | null): void { - this.pickEmphasis(marker?.eventIndex); + this.clearEmphasis(); if (!marker) { // Marker selection cleared - hide tooltip diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 55bb5f1dd..5a36fd6be 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -13,6 +13,8 @@ import type { LogEvent } from 'apex-log-parser'; import * as PIXI from 'pixi.js'; +import { HoverTracker } from './interaction/HoverTracker.js'; +import { HoverHighlightRenderer } from './rendering/HoverHighlightRenderer.js'; import { destroyTimelineApp } from './rendering/pixiApp.js'; import type { EditorColors, @@ -149,6 +151,9 @@ export class FlameChart { // Text label renderer (used in normal mode, shared with search orchestrator) private textLabelRenderer: TextLabelRenderer | null = null; + private hoverHighlightRenderer: HoverHighlightRenderer | null = null; + private readonly hoverTracker = new HoverTracker(); + private worldContainer: PIXI.Container | null = null; private axisContainer: PIXI.Container | null = null; private markerContainer: PIXI.Container | null = null; @@ -443,6 +448,11 @@ export class FlameChart { this.keyboardHandler = null; } + if (this.hoverHighlightRenderer) { + this.hoverHighlightRenderer.destroy(); + this.hoverHighlightRenderer = null; + } + // Clean up selection orchestrator if (this.selectionOrchestrator) { this.selectionOrchestrator.destroy(); @@ -917,6 +927,7 @@ export class FlameChart { this.cursorLineRenderer?.setColor(colors.cursorForeground); this.searchOrchestrator?.setHighlightColor(colors.findMatchBackground); this.selectionOrchestrator?.setHighlightColor(colors.findMatchBackground); + this.hoverHighlightRenderer?.setColor(colors.editorForeground); this.measurementOrchestrator?.setColors( colors.selectionBackground, // Same fallback the orchestrator applies at init: many themes leave @@ -1136,6 +1147,11 @@ export class FlameChart { this.worldContainer.scale.set(1, -1); stage.addChild(this.worldContainer); + this.hoverHighlightRenderer = new HoverHighlightRenderer( + this.worldContainer, + this.options.editorColors?.editorForeground, + ); + this.uiContainer = new PIXI.Container(); this.uiContainer.position.set(0, 0); this.uiContainer.scale.set(1, 1); @@ -1158,14 +1174,20 @@ export class FlameChart { }, { onViewportChange: () => { - this.requestRender(); - if (this.callbacks.onViewportChange && this.viewport) { - this.callbacks.onViewportChange(this.viewport.getState()); - } + this.notifyViewportChange(); }, onMouseMove: (x: number, y: number) => { this.handleMouseMove(x, y); }, + onPointerPosition: (x: number, y: number, panning: boolean) => { + // A drag reports no mouse move, so this is the only fresh position during one. + this.hoverTracker.setPointer(x, y); + if (panning) { + // A drag held against a pan limit moves no frames, so nothing else would ask. + this.hoverTracker.invalidateHit(); + this.requestHoverRender(); + } + }, onClick: (x: number, y: number, modifiers?: ModifierKeys) => { this.handleClick(x, y, modifiers); }, @@ -1173,6 +1195,8 @@ export class FlameChart { this.handleDoubleClick(x, y); }, onMouseLeave: () => { + this.hoverTracker.clearPointer(); + this.requestHoverRender(); // Notify callback that mouse left (clears tooltip) if (this.callbacks.onMouseMove) { this.callbacks.onMouseMove(0, 0, null, null); @@ -1698,6 +1722,11 @@ export class FlameChart { maxDepth, ); + // A marker takes the pointer, so no frame is washed under it. + if (this.hoverTracker.setHovered(eventNode && !marker ? { node: eventNode, depth } : null)) { + this.requestHoverRender(); + } + // Update cursor style based on hit test if (this.interactionHandler) { this.interactionHandler.updateCursor(eventNode !== null || marker !== null); @@ -1979,6 +2008,16 @@ export class FlameChart { // RENDER INVALIDATION (Phase 3 optimization) // ============================================================================ + /** Request a wash-only render: one phase, for a pointer that crossed into a new frame. */ + private requestHoverRender(): void { + if (!this.state) { + return; + } + this.state.renderDirty.overlays = true; + this.state.needsRender = true; + this.scheduleRender(); + } + /** * Invalidate all render phases (full render needed). * Used when viewport changes (zoom, pan). @@ -2292,12 +2331,34 @@ export class FlameChart { this.hitDetector?.setVisibleRects(visibleRects); this.hitDetector?.setBuckets(buckets); dirty.culling = false; + + // The frames just moved, so the last answer about what the pointer is over has expired. + // Deriving it here covers every viewport write - pan, zoom, resize - with one rule. + this.hoverTracker.invalidateHit(); } else { // Reuse cached culling results visibleRects = this.cachedVisibleRects; buckets = this.cachedBuckets; } + // Ask again now the hit test is fresh, and before the phases that draw: the wash and the + // tooltip then follow the pointer in this frame rather than the next. A measure or area + // zoom drag owns the pointer and draws its own overlay, so it is left alone until it ends + // - the hit stays marked stale, and is asked for on the first render after that. + if (this.interactionHandler?.isPointerDragging()) { + // A drag is moving the view, not pointing at a frame, so nothing is hovered. The hit + // stays marked stale, and the first render after the drag washes what it settled on. + if (this.hoverTracker.setHovered(null)) { + dirty.overlays = true; + this.callbacks.onMouseMove?.(0, 0, null, null); + } + } else { + const stale = this.hoverTracker.takeStaleHit(); + if (stale) { + this.handleMouseMove(stale.x, stale.y); + } + } + // Phase 3: Render events and labels (search mode vs normal mode) if (dirty.eventRendering) { const searchContext = { viewportState, visibleRects, buckets }; @@ -2426,6 +2487,8 @@ export class FlameChart { * Render overlays (measurement, cursor line). */ private renderOverlays(viewportState: ViewportState): void { + this.hoverHighlightRenderer?.render(viewportState, this.hoverTracker.getHovered()); + // Measurement and area zoom overlays this.measurementOrchestrator?.render({ viewportState }); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts new file mode 100644 index 000000000..598b0d62d --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/HoverRehit.test.ts @@ -0,0 +1,136 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * A pan or a zoom moves the frames under a still pointer, and neither reports a mouse move. + * The chart asks the hit test again inside the render that moved them, before the phase that + * draws the wash โ€” asking afterwards cannot reach the screen, because the render loop clears + * `needsRender` on the way out and books no further frame. + */ + +import { describe, expect, it, jest } from '@jest/globals'; +import { FlameChart } from '../FlameChart.js'; + +const HIT_NODE = { id: '0-0', timestamp: 0, duration: 10, depth: 0, original: { eventIndex: 1 } }; + +/** The private collaborators one `render()` needs to reach the wash, and nothing else. */ +function stubbedChart(): { chart: FlameChart; hoverRender: jest.Mock; hitTest: jest.Mock } { + const chart = new FlameChart(); + const hoverRender = jest.fn(); + const hitTest = jest.fn(() => ({ eventNode: HIT_NODE, marker: null })); + + const internals = chart as unknown as Record; + internals['app'] = { + renderer: { resize: jest.fn() }, + screen: { height: 300 }, + render: jest.fn(), + }; + internals['container'] = document.createElement('div'); + internals['index'] = { maxDepth: 1 }; + internals['worldContainer'] = { position: { set: jest.fn() } }; + internals['batchRenderer'] = { render: jest.fn(), clear: jest.fn() }; + internals['rectangleManager'] = { + getCulledRectangles: () => ({ visibleRects: new Map(), buckets: new Map() }), + }; + internals['hitDetector'] = { setVisibleRects: jest.fn(), setBuckets: jest.fn(), hitTest }; + internals['hoverHighlightRenderer'] = { render: hoverRender }; + internals['viewport'] = { + getState: () => ({ zoom: 1, offsetX: 0, offsetY: 0, displayWidth: 400, displayHeight: 300 }), + screenYToDepth: () => 0, + }; + internals['state'] = { + viewport: null, + needsRender: false, + batchColorsCache: new Map(), + renderDirty: { + background: false, + culling: true, + eventRendering: false, + highlights: false, + overlays: false, + minimap: false, + metricStrip: false, + }, + }; + + return { chart, hoverRender, hitTest }; +} + +describe('the hover wash after the frames move', () => { + it('washes the frame now under the pointer, in the render that moved it', () => { + const { chart, hoverRender } = stubbedChart(); + const internals = chart as unknown as Record; + const tracker = internals['hoverTracker'] as { + setPointer: (x: number, y: number) => void; + invalidateHit: () => void; + }; + tracker.setPointer(40, 10); + tracker.invalidateHit(); + + (internals['render'] as () => void).call(chart); + + // Not null: the re-hit ran early enough for this render's overlay phase to read it. + expect(hoverRender).toHaveBeenCalledTimes(1); + expect(hoverRender.mock.calls[0]?.[1]).toEqual({ node: HIT_NODE, depth: 0 }); + }); + + // A drag moves the view or draws its own overlay. Washing a frame the pointer never chose, + // and a tooltip churning through frames as they slide past, are both noise. + it('washes nothing while a drag owns the pointer, and asks once it ends', () => { + const { chart, hitTest, hoverRender } = stubbedChart(); + const internals = chart as unknown as Record; + let dragging = true; + internals['interactionHandler'] = { + isPointerDragging: () => dragging, + updateCursor: jest.fn(), + }; + const onMouseMove = jest.fn(); + internals['callbacks'] = { onMouseMove }; + const tracker = internals['hoverTracker'] as { + setPointer: (x: number, y: number) => void; + invalidateHit: () => void; + setHovered: (frame: unknown) => boolean; + }; + tracker.setHovered({ node: HIT_NODE, depth: 0 }); + tracker.setPointer(40, 10); + tracker.invalidateHit(); + + (internals['render'] as () => void).call(chart); + + expect(hitTest).not.toHaveBeenCalled(); + // Cleared, not frozen: a wash left behind would slide away with the frame under it. + expect(hoverRender).toHaveBeenCalledWith(expect.anything(), null); + // And the tooltip goes with it. + expect(onMouseMove).toHaveBeenCalledWith(0, 0, null, null); + + // The hit stayed marked stale, so the first render after the drag picks it up. + dragging = false; + (internals['state'] as { renderDirty: Record }).renderDirty['culling'] = true; + (internals['render'] as () => void).call(chart); + expect(hitTest).toHaveBeenCalled(); + }); + + // Culling is what moves the frames, so a render that reuses it leaves the answer standing. + it('does not ask again on a render that reuses the culled frames', () => { + const { chart, hitTest } = stubbedChart(); + const internals = chart as unknown as Record; + (internals['hoverTracker'] as { setPointer: (x: number, y: number) => void }).setPointer( + 40, + 10, + ); + const state = internals['state'] as { renderDirty: Record }; + state.renderDirty['culling'] = false; + state.renderDirty['overlays'] = true; + internals['cachedVisibleRects'] = new Map(); + internals['cachedBuckets'] = new Map(); + + (internals['render'] as () => void).call(chart); + + expect(hitTest).not.toHaveBeenCalled(); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/HoverTracker.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/HoverTracker.test.ts new file mode 100644 index 000000000..4115838b6 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/HoverTracker.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * What the pointer is over, and when that answer has to be worked out again. + */ + +import { describe, expect, it } from '@jest/globals'; +import type { EventNode, HoveredFrame } from '../../types/flamechart.types.js'; +import { HoverTracker } from '../interaction/HoverTracker.js'; + +/** The hit test builds a fresh EventNode every time; `original` is the stable identity. */ +function hit(original: object, depth = 0): HoveredFrame { + return { + node: { id: '0-0', timestamp: 0, duration: 10, type: 'M', text: 'm', original } as EventNode, + depth, + }; +} + +describe('HoverTracker hovered frame', () => { + const frame = {}; + + it('reports a change once, so a sweep repaints per frame crossed', () => { + const tracker = new HoverTracker(); + + expect(tracker.setHovered(hit(frame))).toBe(true); + expect(tracker.setHovered(hit(frame))).toBe(false); + expect(tracker.getHovered()?.node.original).toBe(frame); + }); + + it('tells apart two frames sharing a timestamp and a depth', () => { + const tracker = new HoverTracker(); + const sibling = {}; + + tracker.setHovered(hit(frame)); + + // Same id, different log event: a zero-duration sibling under a coarse clock. + expect(tracker.setHovered(hit(sibling))).toBe(true); + }); + + it('reports the change to nothing hovered', () => { + const tracker = new HoverTracker(); + tracker.setHovered(hit(frame)); + + expect(tracker.setHovered(null)).toBe(true); + expect(tracker.setHovered(null)).toBe(false); + expect(tracker.getHovered()).toBeNull(); + }); + + // `original` is optional on EventNode, and two frames sharing `undefined` are not the same + // frame. Reading them as changed repaints needlessly; reading them as equal sticks the wash. + it('treats frames with no log event as different frames', () => { + const tracker = new HoverTracker(); + + expect(tracker.setHovered({ node: { id: 'a' } as never, depth: 0 })).toBe(true); + expect(tracker.setHovered({ node: { id: 'b' } as never, depth: 1 })).toBe(true); + }); +}); + +describe('HoverTracker stale hits', () => { + it('asks again after the frames move under a still pointer', () => { + const tracker = new HoverTracker(); + tracker.setPointer(40, 10); + + tracker.invalidateHit(); + + expect(tracker.takeStaleHit()).toEqual({ x: 40, y: 10 }); + // Once only: the answer now stands. + expect(tracker.takeStaleHit()).toBeNull(); + }); + + it('has nothing to ask about before the pointer has been anywhere', () => { + const tracker = new HoverTracker(); + + tracker.invalidateHit(); + + expect(tracker.takeStaleHit()).toBeNull(); + }); + + // A move reports its own hit, so moving alone leaves nothing to ask about. + it('does not ask again for a move on its own', () => { + const tracker = new HoverTracker(); + + tracker.setPointer(10, 10); + tracker.setPointer(20, 10); + + expect(tracker.takeStaleHit()).toBeNull(); + }); + + it('drops everything when the pointer leaves the chart', () => { + const tracker = new HoverTracker(); + tracker.setPointer(40, 10); + tracker.invalidateHit(); + tracker.setHovered(hit({})); + + tracker.clearPointer(); + + expect(tracker.getHovered()).toBeNull(); + expect(tracker.takeStaleHit()).toBeNull(); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts new file mode 100644 index 000000000..1ca203c33 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/HoverWash.test.ts @@ -0,0 +1,64 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * The hover wash: a fill under the pointer, and no outline โ€” the outline belongs to the + * selection, so the two read apart when both are on screen. + */ + +import { describe, expect, it } from '@jest/globals'; +import { Graphics } from 'pixi.js'; +import { TIMELINE_CONSTANTS, type ViewportState } from '../../types/flamechart.types.js'; +import { renderHighlight, renderWash } from '../rendering/HighlightRenderer.js'; + +const viewport: ViewportState = { + zoom: 1, + offsetX: 0, + offsetY: 0, + displayWidth: 800, + displayHeight: 300, +} as ViewportState; + +/** The actions the graphics recorded, in order. */ +function actions(graphics: Graphics): string[] { + return graphics.context.instructions.map((instruction) => instruction.action); +} + +describe('renderWash', () => { + it('fills without stroking, unlike the selection highlight', () => { + const wash = new Graphics(); + renderWash(wash, 0, 100, 0, viewport, 0xffffff, 0.12); + + expect(actions(wash)).toEqual(['fill']); + + // The selection over the same frame strokes as well. + const selection = new Graphics(); + renderHighlight(selection, 0, 100, 0, viewport, { sourceColor: 0xffffff }); + expect(actions(selection)).toContain('stroke'); + }); + + it('covers the frame it is washing, gapped as the frame is drawn', () => { + const wash = new Graphics(); + renderWash(wash, 200, 100, 2, viewport, 0xffffff, 0.12); + + const gap = TIMELINE_CONSTANTS.RECT_GAP; + const bounds = wash.context.bounds; + expect(bounds.minX).toBeCloseTo(200 + gap / 2); + expect(bounds.maxX).toBeCloseTo(200 + 100 - gap / 2); + expect(bounds.minY).toBeCloseTo(2 * TIMELINE_CONSTANTS.EVENT_HEIGHT + gap / 2); + }); + + // A frame thinner than a few pixels still has to show a hover. + it('widens a frame too thin to see', () => { + const wash = new Graphics(); + renderWash(wash, 500, 0.5, 0, viewport, 0xffffff, 0.12); + + const bounds = wash.context.bounds; + expect(bounds.maxX - bounds.minX).toBeGreaterThanOrEqual(6); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/interaction/HoverTracker.ts b/log-viewer/src/features/timeline/optimised/interaction/HoverTracker.ts new file mode 100644 index 000000000..a74f927ab --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/interaction/HoverTracker.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * HoverTracker - owns what the pointer is over + * + * Two facts, kept together because they only make sense together: the frame under the + * pointer, and where the pointer is. A pan or a zoom moves the frames without moving the + * pointer, so the second is what lets the first be worked out again. + */ + +import type { HoveredFrame } from '../../types/flamechart.types.js'; + +/** What tells one hovered frame from another: its log event, or the node when it has none. */ +function frameKey(frame: HoveredFrame | null): unknown { + if (!frame) { + return null; + } + return frame.node.original ?? frame.node; +} + +export class HoverTracker { + private hovered: HoveredFrame | null = null; + private pointer: { x: number; y: number } | null = null; + private staleHit = false; + + /** The frame under the pointer, or null. */ + public getHovered(): HoveredFrame | null { + return this.hovered; + } + + /** + * Record the frame under the pointer. + * + * @param hovered - The frame and its depth, or null for none + * @returns Whether it changed, and so needs drawing again + */ + public setHovered(hovered: HoveredFrame | null): boolean { + // By the log event, not the node: the hit test builds a fresh EventNode every time, and + // its id is only a timestamp and a depth, which two zero-duration siblings share. A node + // with no log event falls back to itself, which reads as changed - the safe answer. + if (frameKey(hovered) === frameKey(this.hovered)) { + return false; + } + this.hovered = hovered; + return true; + } + + /** + * Record where the pointer is. + * + * @param x - Canvas X + * @param y - Canvas Y + */ + public setPointer(x: number, y: number): void { + this.pointer = { x, y }; + } + + /** The pointer left the chart: nothing is hovered and nothing is worth asking about. */ + public clearPointer(): void { + this.pointer = null; + this.staleHit = false; + this.hovered = null; + } + + /** The frames moved under the pointer, so what it is over has changed. */ + public invalidateHit(): void { + this.staleHit = this.pointer !== null; + } + + /** + * The position to ask the hit test about, once and once only. + * + * @returns The pointer position, or null when the last answer still stands + */ + public takeStaleHit(): { x: number; y: number } | null { + if (!this.staleHit || !this.pointer) { + return null; + } + this.staleHit = false; + return this.pointer; + } +} diff --git a/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts index 2dd6da3c7..deb780b26 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/TimelineInteractionHandler.ts @@ -44,6 +44,14 @@ export interface InteractionCallbacks { /** Called when mouse position changes over timeline. */ onMouseMove?: (x: number, y: number) => void; + /** + * Called for every move over the timeline, a drag included. + * + * `onMouseMove` is held back while dragging, so what the pointer is over cannot be worked + * out from it. This reports the position alone, and whether a pan drag is in progress. + */ + onPointerPosition?: (x: number, y: number, panning: boolean) => void; + /** Called when mouse clicks on timeline. */ onClick?: (x: number, y: number, modifiers?: ModifierKeys) => void; @@ -354,6 +362,16 @@ export class TimelineInteractionHandler { return this.activeMode !== null && this.activeMode.isActive; } + /** + * Whether a drag owns the pointer: a pan, or a measure, area zoom or resize. + * + * A drag moves the view or draws its own overlay, and reports no mouse move while it runs, so + * the pointer is not pointing at content and nothing should react to what it is over. + */ + public isPointerDragging(): boolean { + return this.isDragging || this.isDragModeActive(); + } + // ============================================================================ // EVENT LISTENER SETUP // ============================================================================ @@ -658,6 +676,8 @@ export class TimelineInteractionHandler { const screenX = event.clientX - rect.left; const screenY = event.clientY - rect.top; + this.callbacks.onPointerPosition?.(screenX, screenY, this.isDragging); + // Handle active drag mode (measure, area zoom, or resize) if (this.isDragModeActive()) { // Check drag threshold and update position diff --git a/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts index 09515defd..befe11068 100644 --- a/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/rendering/HighlightRenderer.ts @@ -27,6 +27,46 @@ export interface HighlightColors { */ export const MIN_HIGHLIGHT_WIDTH = 6; +/** + * Wash a frame: one fill over the frame's own gapped bounds. + * + * A frame thinner than {@link MIN_HIGHLIGHT_WIDTH} widens to it, centred on the frame, so a + * sub-pixel frame can still be seen. + * + * @param graphics - PixiJS Graphics to draw to + * @param timestamp - Event start time in nanoseconds + * @param duration - Event duration in nanoseconds + * @param depth - Event depth (0-indexed) + * @param viewport - Current viewport state + * @param color - Wash color (0xRRGGBB) + * @param alpha - Wash alpha + */ +export function renderWash( + graphics: PIXI.Graphics, + timestamp: number, + duration: number, + depth: number, + viewport: ViewportState, + color: number, + alpha: number, +): void { + const screenX = timestamp * viewport.zoom; + const screenWidth = duration * viewport.zoom; + + // Must match rectangle rendering in EventBatchRenderer: x + halfGap, width - gap. + const halfGap = TIMELINE_CONSTANTS.RECT_GAP / 2; + const y = depth * TIMELINE_CONSTANTS.EVENT_HEIGHT + halfGap; + const height = TIMELINE_CONSTANTS.EVENT_HEIGHT - TIMELINE_CONSTANTS.RECT_GAP; + + if (screenWidth < MIN_HIGHLIGHT_WIDTH) { + const centeredX = screenX + screenWidth / 2 - MIN_HIGHLIGHT_WIDTH / 2; + graphics.rect(centeredX, y, MIN_HIGHLIGHT_WIDTH, height); + } else { + graphics.rect(screenX + halfGap, y, screenWidth - TIMELINE_CONSTANTS.RECT_GAP, height); + } + graphics.fill({ color, alpha }); +} + /** * Render a highlight rectangle with true alpha transparency. * Creates a "yellow glass" tint effect where the frame color shows through. @@ -52,53 +92,38 @@ export function renderHighlight( viewport: ViewportState, colors: HighlightColors, ): void { - // Calculate screen position from event data and current viewport - const screenX = timestamp * viewport.zoom; const screenWidth = duration * viewport.zoom; - const screenY = depth * TIMELINE_CONSTANTS.EVENT_HEIGHT; - const screenHeight = TIMELINE_CONSTANTS.EVENT_HEIGHT; + const isNarrow = screenWidth < MIN_HIGHLIGHT_WIDTH; - // Pre-calculate gap values (must match rectangle rendering in EventBatchRenderer) - const halfGap = TIMELINE_CONSTANTS.RECT_GAP / 2; - const gappedHeight = screenHeight - TIMELINE_CONSTANTS.RECT_GAP; + renderWash( + graphics, + timestamp, + duration, + depth, + viewport, + colors.sourceColor, + isNarrow ? 0.6 : 0.3, + ); - // Calculate event center point (always accurate regardless of zoom) - const eventCenterX = screenX + screenWidth / 2; - - // Enforce minimum visible size for highlight - const visibleWidth = Math.max(screenWidth, MIN_HIGHLIGHT_WIDTH); - - // Center the minimum-size highlight on the actual event position - const centeredX = eventCenterX - visibleWidth / 2; - - // Calculate gapped dimensions to match rectangle rendering exactly - // Rectangle renderer uses: x + halfGap, y + halfGap, width - gap, height - gap - const gappedWidth = Math.max(2, screenWidth - TIMELINE_CONSTANTS.RECT_GAP); - const rectX = screenX + halfGap; - const rectY = screenY + halfGap; - - if (screenWidth < MIN_HIGHLIGHT_WIDTH) { - // Small event: use minimum width, centered on event, more opaque for visibility - graphics.rect(centeredX, rectY, visibleWidth, gappedHeight); - graphics.fill({ color: colors.sourceColor, alpha: 0.6 }); - } else { - // Normal event: overlay + border - // Overlay fill with true alpha transparency (frame color shows through) - // Uses gapped bounds to match rectangle rendering exactly - graphics.rect(rectX, rectY, gappedWidth, gappedHeight); - graphics.fill({ color: colors.sourceColor, alpha: 0.3 }); - - // Border at FULL bounds (before gap adjustment) so stroke extends outside - // Canvas strokes are center-aligned: half inside, half outside the path - // With 2px stroke at full bounds, the border extends 1px outside the rectangle - // This matches Chrome DevTools selection highlight behavior - graphics.rect(screenX, screenY, screenWidth, screenHeight); - graphics.stroke({ - width: 2, - color: colors.sourceColor, - alpha: 0.9, - }); + if (isNarrow) { + return; } + + // Border at FULL bounds (before gap adjustment) so stroke extends outside + // Canvas strokes are center-aligned: half inside, half outside the path + // With 2px stroke at full bounds, the border extends 1px outside the rectangle + // This matches Chrome DevTools selection highlight behavior + graphics.rect( + timestamp * viewport.zoom, + depth * TIMELINE_CONSTANTS.EVENT_HEIGHT, + screenWidth, + TIMELINE_CONSTANTS.EVENT_HEIGHT, + ); + graphics.stroke({ + width: 2, + color: colors.sourceColor, + alpha: 0.9, + }); } /** diff --git a/log-viewer/src/features/timeline/optimised/rendering/HoverHighlightRenderer.ts b/log-viewer/src/features/timeline/optimised/rendering/HoverHighlightRenderer.ts new file mode 100644 index 000000000..dc7922720 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/rendering/HoverHighlightRenderer.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * HoverHighlightRenderer - washes the frame under the pointer + * + * Chrome DevTools washes the hovered entry and keeps the outline for the selection, so a + * hover and a select still read apart when both are on screen. The wash sits above the + * frames and below the selection highlight. + */ + +import * as PIXI from 'pixi.js'; +import type { HoveredFrame, ViewportState } from '../../types/flamechart.types.js'; +import { renderWash } from './HighlightRenderer.js'; + +/** How much of the wash colour reaches the frame beneath it. */ +const WASH_ALPHA = 0.22; + +/** Fallback wash colour, used until the host reports its theme. */ +const DEFAULT_WASH_COLOR = 0xcccccc; + +export class HoverHighlightRenderer { + private graphics: PIXI.Graphics; + private color: number; + /** Whether a wash is on screen, so a clear can be skipped when there is nothing to clear. */ + private drawn = false; + + /** + * @param container - PixiJS container to add graphics to (worldContainer) + * @param washColor - Resolved wash color (0xRRGGBB) + */ + constructor(container: PIXI.Container, washColor?: number) { + this.graphics = new PIXI.Graphics(); + // Above the frames (0), below the search highlights (1, 2) and the selection (3): a + // hover is momentary, so anything the user asked for outranks it. + this.graphics.zIndex = 0.5; + container.addChild(this.graphics); + + this.color = washColor ?? DEFAULT_WASH_COLOR; + } + + /** + * Wash the hovered frame, or clear when nothing is hovered. + * + * Stateless: the hovered frame is passed in on every render. + * + * @param viewport - Viewport state for transforms + * @param hovered - The frame under the pointer and its depth, or null + */ + public render(viewport: ViewportState, hovered: HoveredFrame | null): void { + // PIXI marks the Graphics dirty on every clear, so an empty wash must not clear at all. + if (!hovered && !this.drawn) { + return; + } + + this.graphics.clear(); + this.drawn = false; + + if (!hovered) { + return; + } + + renderWash( + this.graphics, + hovered.node.timestamp, + hovered.node.duration, + hovered.depth, + viewport, + this.color, + WASH_ALPHA, + ); + this.drawn = true; + } + + /** + * Update the wash colour after a theme change. + * + * @param washColor - Resolved wash color (0xRRGGBB) + */ + public setColor(washColor: number): void { + this.color = washColor; + } + + /** Destroy renderer and cleanup resources. */ + public destroy(): void { + this.graphics.destroy(); + } +} diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index 66cfec32b..7cf15e3d5 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -114,6 +114,12 @@ export interface EventNode { original?: unknown; } +/** The frame under the pointer, and the row it is on. */ +export interface HoveredFrame { + node: EventNode; + depth: number; +} + /** * Tree node wrapper for hierarchical event structures. * Enables generic tree traversal without assuming specific From 2c52fec9819fd8088615ccfa4cc6fb0521e65dfa Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:36:06 +0100 Subject: [PATCH 15/61] fix(lana): read files through workspace.fs, not Salesforce Services (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # PR overview Follow-up to #952. Plain file I/O went through `@salesforce/vscode-services`' `FsService`, which throws until `initServices()` has run โ€” and nothing runs it outside `RetrieveLogFile`. /cc @peternhale โ€” raising this here rather than on #952 so your stack keeps moving. `#953`'s `"type": "module"` question (below) is still yours; it is not touched here. ## The bug `LogView.getFile()` read the extension's **own bundled `out/index.html`** through `FsService`. `getServicesApi()` throws `Salesforce Services is not initialized.` unless `initServices()` has run, and the only path to it is `ensureServicesAvailable()` from `RetrieveLogFile.ts`. So `createView` rejected and the analysis view never opened, in any session where Retrieve Log had not been run first. `LogEventCache.getApexLog` hit the same throw and swallowed it in its own `catch { return null }`, silently disabling folding, document symbols, sticky scroll and the cursor-line decoration. `ShowLogAnalysis` and `RawLogNavigation` were affected too. Why CI stayed green: every suite covering these paths `jest.mock`ed `../../services/salesforceServices.js` โ€” mocking out the module that throws. ## Changes made - Add `lana/src/fs/workspaceFs.ts` โ€” `readFileText` / `writeFileText` / `fileOrFolderExists` over `workspace.fs`. URI-native, works unchanged in the web extension host, needs no other extension and no initialisation. This is already the majority pattern from #952 (`ApexLogLanguageDetector`, `SfdxProjectReader`); the service-based file I/O was the outlier. - Point `LogView`, `ShowLogAnalysis`, `RawLogNavigation` and `LogEventCache` at it. - `LogEventCache.getApexLog` now takes a `Uri` rather than a URI string, since `workspace.fs` needs one. The cache stays keyed on `uri.toString()`, so the four callers just drop `.toString()`. - Delete the now-unused `salesforceServices.readFile` and its probe in `isSalesforceServicesApi`. Salesforce Services keeps `listLogs`, `getLogBody` and the `RetrieveLogFile` write, which are genuine org operations behind `ensureServicesAvailable()`. - Stop mocking the file-I/O layer in the affected suites; they drive `workspace.fs` instead, so reintroducing the dependency fails loudly. `Main.ts` is unchanged โ€” activation stays decoupled from Salesforce Services, and is now correct rather than broken. ## Type of change - [x] Bug fix ## Related issues related W-23939830 ## Validation - `tsc -b lana` clean; `eslint lana/src` clean - 329 tests pass across 20 suites - Regression proof: reverting `LogView.ts` and `salesforceServices.ts` to their merged state makes `LogView.test.ts` fail with `Salesforce Services is not initialized.`; restoring them passes. The suite now also asserts the webview HTML is actually rewritten, which it never checked before. ## Deliberately not in scope Kept to the one blocking defect so it can land quickly. To follow: - The detector's `workspace.fs.readFile` reads the whole file to decode 4 KB (0.056ms -> 2.9ms and a 163MB RSS peak on the 19.7MB sample, on every tab-change event) โ€” the thread on #952 is still open. - Dropping the `scheme: 'file'` selectors means `warmAndSignal` now eagerly parses diff sides. - The save dialog defaults into the extension's install directory when no workspace folder is open. - The webview still sends a now-ignored `openPath` payload. - New suites for `RawLogNavigation` and `ShowLogAnalysis`, which have none. --- lana/src/cache/LogEventCache.ts | 17 ++-- .../src/cache/__tests__/LogEventCache.test.ts | 87 ++++++++++--------- lana/src/commands/LogView.ts | 6 +- lana/src/commands/ShowLogAnalysis.ts | 2 +- lana/src/commands/__tests__/LogView.test.ts | 21 +++-- lana/src/decorations/RawLogLineDecoration.ts | 2 +- lana/src/folding/RawLogFoldingProvider.ts | 4 +- .../__tests__/RawLogFoldingProvider.test.ts | 12 ++- lana/src/fs/workspaceFs.ts | 35 ++++++++ lana/src/hovers/RawLogHoverProvider.ts | 10 ++- lana/src/log-features/RawLogNavigation.ts | 4 +- lana/src/services/salesforceServices.ts | 5 -- lana/src/services/servicesRuntime.ts | 1 - lana/src/symbols/RawLogSymbolProvider.ts | 2 +- 14 files changed, 127 insertions(+), 81 deletions(-) create mode 100644 lana/src/fs/workspaceFs.ts diff --git a/lana/src/cache/LogEventCache.ts b/lana/src/cache/LogEventCache.ts index 5412da1e0..1f07fb043 100644 --- a/lana/src/cache/LogEventCache.ts +++ b/lana/src/cache/LogEventCache.ts @@ -1,12 +1,12 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { workspace } from 'vscode'; +import { workspace, type Uri } from 'vscode'; import { parse, type ApexLog, type LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; -import { readFile } from '../services/salesforceServices.js'; +import { readFileText } from '../fs/workspaceFs.js'; export interface EventSearchResult { event: LogEvent; @@ -17,17 +17,18 @@ export class LogEventCache { private static readonly MAX_CACHE_SIZE = 10; private static cache = new Map(); - static async getApexLog(uriString: string): Promise { - const cached = LogEventCache.cache.get(uriString); + static async getApexLog(uri: Uri): Promise { + const key = uri.toString(); + const cached = LogEventCache.cache.get(key); if (cached) { // Move to end (most recently used) - LogEventCache.cache.delete(uriString); - LogEventCache.cache.set(uriString, cached); + LogEventCache.cache.delete(key); + LogEventCache.cache.set(key, cached); return cached; } try { - const content = await readFile(uriString); + const content = await readFileText(uri); const apexLog = parse(content); // Evict oldest if at capacity @@ -38,7 +39,7 @@ export class LogEventCache { } } - LogEventCache.cache.set(uriString, apexLog); + LogEventCache.cache.set(key, apexLog); return apexLog; } catch { return null; diff --git a/lana/src/cache/__tests__/LogEventCache.test.ts b/lana/src/cache/__tests__/LogEventCache.test.ts index 6a8964a20..4f2fa72cb 100644 --- a/lana/src/cache/__tests__/LogEventCache.test.ts +++ b/lana/src/cache/__tests__/LogEventCache.test.ts @@ -2,7 +2,7 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { beforeEach, describe, expect, it } from '@jest/globals'; -import { workspace } from 'vscode'; +import { Uri, workspace } from 'vscode'; import { createMockApexLog, @@ -17,13 +17,12 @@ jest.mock('apex-log-parser', () => ({ })); import { parse } from 'apex-log-parser'; -import { readFile } from '../../services/salesforceServices.js'; -jest.mock('../../services/salesforceServices.js', () => ({ - readFile: jest.fn(), -})); - -const mockReadFile = readFile as jest.Mock; +// The file-I/O layer is deliberately not mocked out. Stubbing the whole module is +// what let getApexLog read through a service that throws until another extension +// initialises it, with the failure swallowed by its own catch. +const mockReadFile = workspace.fs.readFile as jest.Mock; +const readsText = (text: string) => new TextEncoder().encode(text); const mockParse = parse as jest.Mock; describe('LogEventCache', () => { @@ -37,16 +36,16 @@ describe('LogEventCache', () => { describe('cache behavior', () => { it('should return cached ApexLog on subsequent calls', async () => { const mockApexLog = createMockApexLog({ size: 1000 }); - mockReadFile.mockResolvedValueOnce('log content'); + mockReadFile.mockResolvedValueOnce(readsText('log content')); mockParse.mockReturnValueOnce(mockApexLog); // First call - should read and parse - const result1 = await LogEventCache.getApexLog('/test/file.log'); + const result1 = await LogEventCache.getApexLog(Uri.file('/test/file.log')); expect(result1).toBe(mockApexLog); expect(mockReadFile).toHaveBeenCalledTimes(1); // Second call - should return cached - const result2 = await LogEventCache.getApexLog('/test/file.log'); + const result2 = await LogEventCache.getApexLog(Uri.file('/test/file.log')); expect(result2).toBe(mockApexLog); expect(mockReadFile).toHaveBeenCalledTimes(1); // Still 1 }); @@ -55,28 +54,30 @@ describe('LogEventCache', () => { const log1 = createMockApexLog({ size: 100 }); const log2 = createMockApexLog({ size: 200 }); - mockReadFile.mockResolvedValueOnce('content1').mockResolvedValueOnce('content2'); + mockReadFile + .mockResolvedValueOnce(readsText('content1')) + .mockResolvedValueOnce(readsText('content2')); mockParse.mockReturnValueOnce(log1).mockReturnValueOnce(log2); - await LogEventCache.getApexLog('/test/file1.log'); - await LogEventCache.getApexLog('/test/file2.log'); + await LogEventCache.getApexLog(Uri.file('/test/file1.log')); + await LogEventCache.getApexLog(Uri.file('/test/file2.log')); // Access file1 again - should move to end - await LogEventCache.getApexLog('/test/file1.log'); + await LogEventCache.getApexLog(Uri.file('/test/file1.log')); // @ts-expect-error - accessing private static for testing const keys = Array.from(LogEventCache.cache.keys()); - expect(keys).toEqual(['/test/file2.log', '/test/file1.log']); + expect(keys).toEqual(['file:///test/file2.log', 'file:///test/file1.log']); }); it('should evict oldest entry when cache reaches MAX_CACHE_SIZE', async () => { // Create 11 logs to trigger eviction (MAX_CACHE_SIZE is 10) for (let i = 0; i < 11; i++) { const mockLog = createMockApexLog({ size: i * 100 }); - mockReadFile.mockResolvedValueOnce(`content${i}`); + mockReadFile.mockResolvedValueOnce(readsText(`content${i}`)); mockParse.mockReturnValueOnce(mockLog); - await LogEventCache.getApexLog(`/test/file${i}.log`); + await LogEventCache.getApexLog(Uri.file(`/test/file${i}.log`)); } // @ts-expect-error - accessing private static for testing @@ -85,30 +86,30 @@ describe('LogEventCache', () => { // First file should be evicted // @ts-expect-error - accessing private static for testing - const hasFirst = LogEventCache.cache.has('/test/file0.log'); + const hasFirst = LogEventCache.cache.has('file:///test/file0.log'); expect(hasFirst).toBe(false); // Last file should exist // @ts-expect-error - accessing private static for testing - const hasLast = LogEventCache.cache.has('/test/file10.log'); + const hasLast = LogEventCache.cache.has('file:///test/file10.log'); expect(hasLast).toBe(true); }); it('should return null when file read fails', async () => { mockReadFile.mockRejectedValueOnce(new Error('File not found')); - const result = await LogEventCache.getApexLog('/test/nonexistent.log'); + const result = await LogEventCache.getApexLog(Uri.file('/test/nonexistent.log')); expect(result).toBeNull(); }); it('should return null when parse fails', async () => { - mockReadFile.mockResolvedValueOnce('invalid content'); + mockReadFile.mockResolvedValueOnce(readsText('invalid content')); mockParse.mockImplementationOnce(() => { throw new Error('Parse error'); }); - const result = await LogEventCache.getApexLog('/test/invalid.log'); + const result = await LogEventCache.getApexLog(Uri.file('/test/invalid.log')); expect(result).toBeNull(); }); @@ -314,41 +315,43 @@ describe('LogEventCache', () => { describe('clearCache', () => { it('should remove specific entry from cache', async () => { const mockApexLog = createMockApexLog(); - mockReadFile.mockResolvedValueOnce('content'); + mockReadFile.mockResolvedValueOnce(readsText('content')); mockParse.mockReturnValueOnce(mockApexLog); - await LogEventCache.getApexLog('/test/file.log'); + await LogEventCache.getApexLog(Uri.file('/test/file.log')); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(true); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(true); - LogEventCache.clearCache('/test/file.log'); + LogEventCache.clearCache('file:///test/file.log'); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(false); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(false); }); it('should not affect other cached entries', async () => { const log1 = createMockApexLog({ size: 100 }); const log2 = createMockApexLog({ size: 200 }); - mockReadFile.mockResolvedValueOnce('content1').mockResolvedValueOnce('content2'); + mockReadFile + .mockResolvedValueOnce(readsText('content1')) + .mockResolvedValueOnce(readsText('content2')); mockParse.mockReturnValueOnce(log1).mockReturnValueOnce(log2); - await LogEventCache.getApexLog('/test/file1.log'); - await LogEventCache.getApexLog('/test/file2.log'); + await LogEventCache.getApexLog(Uri.file('/test/file1.log')); + await LogEventCache.getApexLog(Uri.file('/test/file2.log')); - LogEventCache.clearCache('/test/file1.log'); + LogEventCache.clearCache('file:///test/file1.log'); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file1.log')).toBe(false); + expect(LogEventCache.cache.has('file:///test/file1.log')).toBe(false); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file2.log')).toBe(true); + expect(LogEventCache.cache.has('file:///test/file2.log')).toBe(true); }); it('should handle clearing non-existent entry gracefully', () => { expect(() => { - LogEventCache.clearCache('/test/nonexistent.log'); + LogEventCache.clearCache('file:///test/nonexistent.log'); }).not.toThrow(); }); }); @@ -366,9 +369,9 @@ describe('LogEventCache', () => { it('should clear cache when apexlog document is closed', async () => { // Setup cache const mockApexLog = createMockApexLog(); - mockReadFile.mockResolvedValueOnce('content'); + mockReadFile.mockResolvedValueOnce(readsText('content')); mockParse.mockReturnValueOnce(mockApexLog); - await LogEventCache.getApexLog('/test/file.log'); + await LogEventCache.getApexLog(Uri.file('/test/file.log')); // Capture the callback let closeCallback: @@ -384,19 +387,19 @@ describe('LogEventCache', () => { // Simulate closing an apexlog document closeCallback!({ languageId: 'apexlog', - uri: { toString: () => '/test/file.log' }, + uri: { toString: () => 'file:///test/file.log' }, }); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(false); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(false); }); it('should not clear cache when non-apexlog document is closed', async () => { // Setup cache const mockApexLog = createMockApexLog(); - mockReadFile.mockResolvedValueOnce('content'); + mockReadFile.mockResolvedValueOnce(readsText('content')); mockParse.mockReturnValueOnce(mockApexLog); - await LogEventCache.getApexLog('/test/file.log'); + await LogEventCache.getApexLog(Uri.file('/test/file.log')); // Capture the callback let closeCallback: @@ -412,11 +415,11 @@ describe('LogEventCache', () => { // Simulate closing a non-apexlog document closeCallback!({ languageId: 'javascript', - uri: { toString: () => '/test/file.log' }, + uri: { toString: () => 'file:///test/file.log' }, }); // @ts-expect-error - accessing private static for testing - expect(LogEventCache.cache.has('/test/file.log')).toBe(true); + expect(LogEventCache.cache.has('file:///test/file.log')).toBe(true); }); }); }); diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index a1e87455c..4dde9879d 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -8,7 +8,7 @@ import type { Context } from '../Context.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; import { RawLogNavigation } from '../log-features/RawLogNavigation.js'; -import { fileOrFolderExists, readFile, writeFile } from '../services/salesforceServices.js'; +import { fileOrFolderExists, readFileText, writeFileText } from '../fs/workspaceFs.js'; import { PRIVATE_SECTIONS, getColumnOverrides, @@ -177,7 +177,7 @@ export class LogView { }); if (destinationFile) { - writeFile(destinationFile, fileContent).catch((error) => { + writeFileText(destinationFile, fileContent).catch((error) => { const msg = error instanceof Error ? error.message : String(error); vscWindow.showErrorMessage(`Unable to save file: ${msg}`); }); @@ -229,7 +229,7 @@ export class LogView { } private static async getFile(fileUri: Uri): Promise { - return readFile(fileUri); + return readFileText(fileUri); } private static async sendLog( diff --git a/lana/src/commands/ShowLogAnalysis.ts b/lana/src/commands/ShowLogAnalysis.ts index 5e7344dbf..c6cb77269 100644 --- a/lana/src/commands/ShowLogAnalysis.ts +++ b/lana/src/commands/ShowLogAnalysis.ts @@ -5,7 +5,7 @@ import { TabInputText, window, type Uri } from 'vscode'; import { appName } from '../AppSettings.js'; import type { Context } from '../Context.js'; -import { fileOrFolderExists } from '../services/salesforceServices.js'; +import { fileOrFolderExists } from '../fs/workspaceFs.js'; import { Command } from './Command.js'; import { LogView } from './LogView.js'; diff --git a/lana/src/commands/__tests__/LogView.test.ts b/lana/src/commands/__tests__/LogView.test.ts index 08e758d2f..ad6284f58 100644 --- a/lana/src/commands/__tests__/LogView.test.ts +++ b/lana/src/commands/__tests__/LogView.test.ts @@ -6,17 +6,11 @@ import { describe, expect, it } from '@jest/globals'; import { createMockContext } from '../../__tests__/helpers/test-builders.js'; import { Uri, workspace } from '../../__tests__/mocks/vscode.js'; import { WebView } from '../../display/WebView.js'; -import { readFile } from '../../services/salesforceServices.js'; import { LogView } from '../LogView.js'; jest.mock('../../display/WebView.js', () => ({ WebView: { apply: jest.fn() }, })); -jest.mock('../../services/salesforceServices.js', () => ({ - fileOrFolderExists: jest.fn(), - readFile: jest.fn(), - writeFile: jest.fn(), -})); jest.mock('../../workspace/AppConfig.js', () => ({ PRIVATE_SECTIONS: [], getColumnOverrides: jest.fn(() => ({})), @@ -38,7 +32,10 @@ jest.mock('../../workspace/AppConfig.js', () => ({ })); const mockApplyWebView = WebView.apply as jest.Mock; -const mockReadFile = readFile as jest.Mock; +// The file-I/O layer is deliberately not mocked out: createView reads its own +// bundled index.html, and mocking that module away is what hid it reading +// through a service that throws unless another extension has initialised it. +const mockReadFile = workspace.fs.readFile as unknown as jest.Mock; describe('LogView', () => { it('uses a display path in the payload and the captured URI for open actions', async () => { @@ -59,7 +56,9 @@ describe('LogView', () => { }, }; mockApplyWebView.mockReturnValue(panel as unknown as import('vscode').WebviewPanel); - mockReadFile.mockResolvedValue(''); + mockReadFile.mockResolvedValue( + new TextEncoder().encode(''), + ); workspace.asRelativePath.mockReturnValue('workspace/logs/virtual.log'); const context = createMockContext(); const logUri = Uri.parse('memfs:/repository/logs/virtual.log'); @@ -70,6 +69,12 @@ describe('LogView', () => { logUri, 'log body', ); + // createView must resolve and rewrite the bundled index.html. It read that + // file through a service needing another extension's initialisation, so it + // rejected before the webview had any content. + expect(panel.webview.html).toContain('webview:/test/extension/out/bundle.js'); + expect(panel.webview.html).not.toContain('src="bundle.js"'); + await receiveMessage?.({ cmd: 'fetchLog', requestId: 'request-1' }); expect(postMessage).toHaveBeenCalledWith({ diff --git a/lana/src/decorations/RawLogLineDecoration.ts b/lana/src/decorations/RawLogLineDecoration.ts index 5a9048840..e50c7a71e 100644 --- a/lana/src/decorations/RawLogLineDecoration.ts +++ b/lana/src/decorations/RawLogLineDecoration.ts @@ -90,7 +90,7 @@ export class RawLogLineDecoration { const timestamp = parseInt(match[1], 10); const filePath = document.uri.toString(); - const apexLog = await LogEventCache.getApexLog(filePath); + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { this.clearDecorations(editor); return; diff --git a/lana/src/folding/RawLogFoldingProvider.ts b/lana/src/folding/RawLogFoldingProvider.ts index e69ac74e6..e36ec5455 100644 --- a/lana/src/folding/RawLogFoldingProvider.ts +++ b/lana/src/folding/RawLogFoldingProvider.ts @@ -28,7 +28,7 @@ class RawLogFoldingProvider implements FoldingRangeProvider { document: TextDocument, _context: FoldingContext, ): Promise { - const apexLog = await LogEventCache.getApexLog(document.uri.toString()); + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { return []; @@ -90,7 +90,7 @@ class RawLogFoldingProvider implements FoldingRangeProvider { return; } - void LogEventCache.getApexLog(document.uri.toString()).then((apexLog) => { + void LogEventCache.getApexLog(document.uri).then((apexLog) => { if (apexLog) { this.changeEmitter.fire(); } diff --git a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts index 610daef18..e9e9dd911 100644 --- a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts +++ b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts @@ -366,7 +366,9 @@ describe('RawLogFoldingProvider', () => { openHandler(doc); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('file:///test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith( + expect.objectContaining({ scheme: 'file', path: '/test/file.log' }), + ); expect(fired).toHaveBeenCalledTimes(1); }); @@ -380,7 +382,9 @@ describe('RawLogFoldingProvider', () => { activeEditorHandler({ document: doc }); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('file:///test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith( + expect.objectContaining({ scheme: 'file', path: '/test/file.log' }), + ); expect(fired).toHaveBeenCalledTimes(1); }); @@ -407,7 +411,9 @@ describe('RawLogFoldingProvider', () => { openHandler(doc); await flush(); - expect(mockGetApexLog).toHaveBeenCalledWith('file:///test/file.log'); + expect(mockGetApexLog).toHaveBeenCalledWith( + expect.objectContaining({ scheme: 'file', path: '/test/file.log' }), + ); expect(fired).not.toHaveBeenCalled(); }); }); diff --git a/lana/src/fs/workspaceFs.ts b/lana/src/fs/workspaceFs.ts new file mode 100644 index 000000000..f8669247d --- /dev/null +++ b/lana/src/fs/workspaceFs.ts @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { workspace, type Uri } from 'vscode'; + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +/** + * Reads a file as UTF-8 text. + * + * One decode pass, one string: no normalisation and no intermediate copy, so a + * multi-hundred-MB log does not double its peak memory here. + */ +export async function readFileText(uri: Uri): Promise { + return decoder.decode(await workspace.fs.readFile(uri)); +} + +export async function writeFileText(uri: Uri, content: string): Promise { + await workspace.fs.writeFile(uri, encoder.encode(content)); +} + +/** + * `workspace.fs` has no `exists`, so `stat` is the idiom. Any failure โ€” missing, + * unreadable, or no provider for the scheme โ€” answers the question every caller + * is really asking: can this be read? + */ +export async function fileOrFolderExists(uri: Uri): Promise { + try { + await workspace.fs.stat(uri); + return true; + } catch { + return false; + } +} diff --git a/lana/src/hovers/RawLogHoverProvider.ts b/lana/src/hovers/RawLogHoverProvider.ts index 0cd1b8dad..4b9f839a6 100644 --- a/lana/src/hovers/RawLogHoverProvider.ts +++ b/lana/src/hovers/RawLogHoverProvider.ts @@ -9,6 +9,7 @@ import { type Position, type ProviderResult, type TextDocument, + type Uri, } from 'vscode'; import { LogEventCache } from '../cache/LogEventCache.js'; @@ -25,14 +26,15 @@ class RawLogHoverProvider implements HoverProvider { } const timestamp = parseInt(match[1], 10); - return this.buildHover(document.uri.toString(), timestamp); + return this.buildHover(document.uri, timestamp); } - private async buildHover(filePath: string, timestamp: number): Promise { - const args = encodeURIComponent(JSON.stringify({ timestamp, filePath })); + private async buildHover(uri: Uri, timestamp: number): Promise { + // A command URI argument must be JSON, so the URI travels as a string here. + const args = encodeURIComponent(JSON.stringify({ timestamp, filePath: uri.toString() })); const commandUri = `command:lana.showInLogAnalysis?${args}`; - const apexLog = await LogEventCache.getApexLog(filePath); + const apexLog = await LogEventCache.getApexLog(uri); const result = apexLog ? LogEventCache.findEventByTimestamp(apexLog, timestamp) : null; const metricParts = result ? buildMetricParts(result.event) : []; diff --git a/lana/src/log-features/RawLogNavigation.ts b/lana/src/log-features/RawLogNavigation.ts index c37f84967..7ca9aec3c 100644 --- a/lana/src/log-features/RawLogNavigation.ts +++ b/lana/src/log-features/RawLogNavigation.ts @@ -3,7 +3,7 @@ */ import { Selection, commands, window, type Uri } from 'vscode'; -import { readFile } from '../services/salesforceServices.js'; +import { readFileText } from '../fs/workspaceFs.js'; /** * Handles navigation within raw Apex log files. @@ -20,7 +20,7 @@ export class RawLogNavigation { public static async goToLineByTimestamp(logUri: Uri, timestamp: number): Promise { try { // Read file (no normalization - avoids doubling memory for large files) - const text = await readFile(logUri); + const text = await readFileText(logUri); // Find the exact timestamp pattern: (nanoseconds)| const index = text.indexOf(`(${timestamp})|`); diff --git a/lana/src/services/salesforceServices.ts b/lana/src/services/salesforceServices.ts index 28cc2112d..fc311f2e3 100644 --- a/lana/src/services/salesforceServices.ts +++ b/lana/src/services/salesforceServices.ts @@ -29,11 +29,6 @@ export function getLogBody(logId: string): Promise { return getRuntime().runPromise(ApexLogService.getLogBody(logId)); } -export function readFile(uri: Uri | string): Promise { - const { FsService } = getServicesApi().services; - return getRuntime().runPromise(FsService.readFile(uri)); -} - export function writeFile(uri: Uri | string, content: string): Promise { const { FsService } = getServicesApi().services; return getRuntime().runPromise(FsService.safeWriteFile(uri, content)); diff --git a/lana/src/services/servicesRuntime.ts b/lana/src/services/servicesRuntime.ts index 9ccbec1f0..ac515981d 100644 --- a/lana/src/services/servicesRuntime.ts +++ b/lana/src/services/servicesRuntime.ts @@ -88,7 +88,6 @@ export function isSalesforceServicesApi(value: unknown): value is SalesforceVSCo isObject(dependencies) && typeof getProperty(apexLogService, 'listLogs') === 'function' && typeof getProperty(apexLogService, 'getLogBody') === 'function' && - typeof getProperty(fsService, 'readFile') === 'function' && typeof getProperty(fsService, 'safeWriteFile') === 'function' && typeof getProperty(fsService, 'fileOrFolderExists') === 'function' ); diff --git a/lana/src/symbols/RawLogSymbolProvider.ts b/lana/src/symbols/RawLogSymbolProvider.ts index 94f6d5753..726430c05 100644 --- a/lana/src/symbols/RawLogSymbolProvider.ts +++ b/lana/src/symbols/RawLogSymbolProvider.ts @@ -29,7 +29,7 @@ class RawLogSymbolProvider implements DocumentSymbolProvider { document: TextDocument, _token: CancellationToken, ): Promise { - const apexLog = await LogEventCache.getApexLog(document.uri.toString()); + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { return []; From 545670305144b6eef76a6063151b696180ca4656 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:14:36 +0100 Subject: [PATCH 16/61] fix(log-viewer): point the inspector at one row, not the whole chain above it (#991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview Pointing the inspector at one frame lit its bucket **and** one row per caller depth, so a single frame read as a whole chain selected. A move could also land on a row the pointer had already left, because a move waits on a render and a pointer crossing inspector rows asks for one per row. A bottom-up row is the frame at its own depth, so a frame now marks the rows its own key heads: its bucket, and any caller row for it under another bucket. The wiring holds one move, and a new one abandons the one before it. **Stacked on #985 โ€” merge that first.** ## ๐Ÿ› ๏ธ Changes made - `pathsEndingIn` names the rows a frame stands for; `pathIdsOf` becomes `pathIdOf`, top-down only. - The paths a key heads are indexed as they are minted, so the mark costs what it returns rather than a scan of every path the log has minted. - One move at a time: `wireInspectorTab` holds an `AbortController`, and the two views that await check it before they scroll. The mark is never dropped, only the move. - A caller row's calls and its frames come from one walk of its bucket, held in one cache โ€” the walk that decides membership already stands on the row's own frame. - `LogStore.framesAbove` keeps its remaining caller in the inspector's scoped tree. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [x] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ”— Related Issues related # ## โœ… Tests added? - [x] ๐Ÿ‘ yes `pnpm test`: 1588 tests, 131 suites. Each new test was proven by removing its fix. Measured in a browser on the 19.7MB sample log, Bottom-Up with a bucket expanded to four callers: hovering that bucket's frame marks 1 row, hovering a caller frame marks its own 1 row, and a frame no rendered row stands for marks none. Before, the first of those marked five. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ™… not needed --- .../components/__tests__/inspectorTab.test.ts | 36 ++++++- .../components/__tests__/locatedRow.test.ts | 18 ++-- log-viewer/src/components/inspectorTab.ts | 15 ++- log-viewer/src/components/locatedRow.ts | 94 +++++++++++++------ .../src/core/log/__tests__/keyPathIds.test.ts | 69 ++++++++++---- log-viewer/src/core/log/keyPathIds.ts | 79 +++++++++------- .../analysis/components/AnalysisView.ts | 8 +- .../call-tree/components/CalltreeView.ts | 6 +- .../utils/__tests__/Aggregation.test.ts | 2 +- 9 files changed, 227 insertions(+), 100 deletions(-) diff --git a/log-viewer/src/components/__tests__/inspectorTab.test.ts b/log-viewer/src/components/__tests__/inspectorTab.test.ts index 404d823ae..ab711369f 100644 --- a/log-viewer/src/components/__tests__/inspectorTab.test.ts +++ b/log-viewer/src/components/__tests__/inspectorTab.test.ts @@ -16,9 +16,14 @@ describe('wireInspectorTab', () => { }); /** A Call Tree-like view: it records what it marked, moved to and cleared. */ - function wire(movesToMergedPick = true, reveal?: (eventIndex: number) => Promise) { + function wire( + movesToMergedPick = true, + reveal?: (eventIndex: number, signal: AbortSignal) => Promise, + ) { const marks: Array = []; const revealed: number[] = []; + /** The signal each move was given, so a test can read which were abandoned. */ + const signals: AbortSignal[] = []; /** Marks and moves in the order they arrived, which the two lists cannot show. */ const order: string[] = []; let clears = 0; @@ -27,10 +32,11 @@ describe('wireInspectorTab', () => { marks.push(eventIndexes); order.push('mark'); }, - reveal: (eventIndex) => { + reveal: (eventIndex, signal) => { order.push('move'); + signals.push(signal); if (reveal) { - return reveal(eventIndex); + return reveal(eventIndex, signal); } revealed.push(eventIndex); }, @@ -39,7 +45,7 @@ describe('wireInspectorTab', () => { }, movesToMergedPick, }); - return { marks, revealed, order, clears: () => clears }; + return { marks, revealed, order, signals, clears: () => clears }; } it('leaves an event for another tab alone', () => { @@ -121,6 +127,28 @@ describe('wireInspectorTab', () => { expect(view.marks).toEqual([[4]]); }); + it('abandons a move the next one replaces, and keeps both marks', () => { + const view = wire(); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [9], sticky: true }); + + // The pointer crossing rows asks for a move per row, and the one it stops on + // is the one that scrolls. + expect(view.signals.map((signal) => signal.aborted)).toEqual([true, false]); + expect(view.marks).toEqual([[9]]); + }); + + it('abandons the move in flight when the view goes away', () => { + const view = wire(); + + eventBus.emit('inspector:reveal', { source: 'calltree', eventIndex: 4 }); + off?.(); + off = null; + + expect(view.signals[0]?.aborted).toBe(true); + }); + it('stops answering once unsubscribed', () => { const view = wire(); diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index 03c3ad57d..de5858cef 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -345,13 +345,17 @@ describe('LocatedRowIds', () => { const frame = ev('inner', outerFrame); const log = { eventsById: { 5: frame } } as unknown as ApexLog; - it('builds the paths of the rows the frames belong to', () => { - const found = new LocatedRowIds().idsFor(log, [5], 'callers'); - - // The log's own table, so a row stamped from it reaches the same ids. - const expected = new Set(); - logStoreFor(log).keyPathIds().pathIdsOf(frame, 'callers', expected); - expect(found).toEqual([...expected]); + it('builds the paths of the rows the frames stand for', () => { + const paths = logStoreFor(log).keyPathIds(); + // The row the frame is, and a row for it under another bucket. + const own = paths.step(ROOT_PATH_ID, paths.keyIdOf(frame)); + const under = paths.step(paths.step(ROOT_PATH_ID, paths.keyId('other')), paths.keyIdOf(frame)); + // The row of the caller above it, which stands for the caller. + paths.step(own, paths.keyIdOf(outerFrame)); + + expect(new LocatedRowIds().idsFor(log, [5], 'callers').slice().sort()).toEqual( + [own, under].sort(), + ); }); it('reuses what it built for the frames it was last asked about', () => { diff --git a/log-viewer/src/components/inspectorTab.ts b/log-viewer/src/components/inspectorTab.ts index 253fc91cc..5cde7bf58 100644 --- a/log-viewer/src/components/inspectorTab.ts +++ b/log-viewer/src/components/inspectorTab.ts @@ -12,8 +12,11 @@ export interface InspectorTabSync { /** * Move to one frame. A rejection is the view's own to report: it says the view * cannot reach the frame, and the mark still says where the frame is. + * + * @param signal - aborted once a later move has replaced this one. A view that + * waits on anything checks it before it scrolls. */ - reveal: (eventIndex: number) => void | Promise; + reveal: (eventIndex: number, signal: AbortSignal) => void | Promise; /** Drop the view's own selection, for the app-wide Escape. */ clear: () => void; @@ -40,9 +43,15 @@ export function wireInspectorTab( emphasis: InspectorEmphasis, sync: InspectorTabSync, ): () => void { + let moving: AbortController | null = null; const move = (eventIndex: number): void => { + // A move waits on a render, and a pointer crossing rows asks for several, so + // the last one asked for is the one that scrolls. Only the move is abandoned: + // the mark of the move that is dropped went on before it. + moving?.abort(); + moving = new AbortController(); // The view reports its own failure; the mark stands either way. - void Promise.resolve(sync.reveal(eventIndex)).catch(() => {}); + void Promise.resolve(sync.reveal(eventIndex, moving.signal)).catch(() => {}); }; const offs = [ @@ -66,6 +75,8 @@ export function wireInspectorTab( ]; return () => { + moving?.abort(); + moving = null; for (const off of offs) { off(); } diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index a132caee0..dc22dde7b 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -6,6 +6,7 @@ import type { ApexLog, LogEvent } from 'apex-log-parser'; import type { RowComponent } from 'tabulator-tables'; import type { DetailSelection, SelectionView } from '../core/events/EventBus.js'; +import { ROOT_PATH_ID } from '../core/log/keyPathIds.js'; import { logStoreFor } from '../core/log/LogStore.js'; import { eventByEventIndex } from '../core/utility/EventSearch.js'; @@ -250,35 +251,70 @@ function rowCallOccurrences(row: RowComponent, root: ApexLog | null): LogEvent[] if (data.key === undefined) { return data.originalData ? [data.originalData] : NO_CALLS; } - return deriveCalls(row, data, root); + return derivedRowOf(row, data, root).calls; } +/** What a derived row's bucket answers: the calls whose chain runs through the + * row, and the frames the row is at its own depth. */ +interface DerivedRow { + calls: LogEvent[]; + frames: number[]; +} + +const NOTHING_DERIVED: DerivedRow = { calls: NO_CALLS, frames: [] }; + +/** Held per row: a pointer sweep re-enters rows and the click that follows a + * hover asks again, but deriving reads every occurrence the root bucket holds. */ +const derivedRows = new WeakMap(); + /** - * The root bucket's calls whose own chain runs through the row. + * The root bucket's calls whose own chain runs through the row, and the frames + * the row stands for. + * + * One walk per occurrence answers both: the walk that decides whether a chain + * reaches the row stands on the row's own frame when it does. * * The table is the log's that built the rows: a path id is minted per log, so * another log's table would answer about a path of its own. */ -function deriveCalls(row: RowComponent, data: CallRow, root: ApexLog | null): LogEvent[] { +function derivedRowOf(row: RowComponent, data: CallRow, root: ApexLog | null): DerivedRow { + const cached = derivedRows.get(data); + if (cached) { + return cached; + } const pathId = data._pathId; if (pathId === undefined || !root) { - return NO_CALLS; + return NOTHING_DERIVED; } const paths = logStoreFor(root).keyPathIds(); const instances = rootBucketOf(row, data)?.instances; if (!instances?.length) { - return NO_CALLS; + return NOTHING_DERIVED; + } + const calls: LogEvent[] = []; + const own = new Set(); + for (const event of instances) { + const node = paths.chainNodeAt(event, pathId); + if (node) { + calls.push(event); + own.add(node.eventIndex); + } + } + const derived = { calls, frames: [...own] }; + if (calls.length) { + // Not kept where nothing derived, for the reason `rowOccurrences` gives. + derivedRows.set(data, derived); } - return instances.filter((event) => paths.chainReaches(event, pathId)); + return derived; } /** * The path ids that the frames `eventIndexes` name stand for, so a grid whose * rows merge occurrences can mark them. * - * Every occurrence is walked: occurrences of one frame sit under distinct parent - * frames, so there is no cheaper set to walk, and only the paths they produce - * repeat. + * A bottom-up row is the frame at its own depth, so a frame stands for the rows + * its own key heads, wherever they sit; the chain above it holds its callers' + * rows. A top-down row sits on the frame's own chain, so one id names it. */ function pathIdsForEvents( root: ApexLog, @@ -286,11 +322,21 @@ function pathIdsForEvents( direction: SelectionView, ): number[] { const paths = logStoreFor(root).keyPathIds(); - const found = new Set(); + const events: LogEvent[] = []; for (const eventIndex of eventIndexes) { const event = eventByEventIndex(root, eventIndex); if (event) { - paths.pathIdsOf(event, direction, found); + events.push(event); + } + } + if (direction === 'callers') { + return paths.pathsEndingIn(new Set(events.map((event) => paths.keyIdOf(event)))); + } + const found = new Set(); + for (const event of events) { + const pathId = paths.pathIdOf(event); + if (pathId !== undefined) { + found.add(pathId); } } return [...found]; @@ -314,10 +360,6 @@ export function rowOccurrences(row: RowComponent, root: ApexLog | null): number[ return indexes; } -/** Held per row, for the same reason {@link derivedIndexes} is. Only a caller - * row ever climbs, so only a caller row's answer is in here. */ -const derivedCallerFrames = new WeakMap(); - /** * The frames a row is, which is what the inspector marks it by. * @@ -337,25 +379,15 @@ export function rowFrames( direction: SelectionView, ): number[] { const data = rowCallData(row); - const store = direction === 'callers' && root ? logStoreFor(root) : null; - if (!store) { + const pathId = data._pathId; + if (direction !== 'callers' || !root || pathId === undefined) { return rowOccurrences(row, root); } - const cached = derivedCallerFrames.get(data); - if (cached) { - return cached; - } - const levels = data._pathId === undefined ? 0 : store.keyPathIds().depthOf(data._pathId) - 1; - const conducted = rowOccurrences(row, root); - if (levels <= 0) { - return conducted; - } - const frames = store.framesAbove(conducted, levels); - if (frames.length) { - // Not kept where nothing climbed, for the reason `rowOccurrences` gives. - derivedCallerFrames.set(data, frames); + if (logStoreFor(root).keyPathIds().parentOf(pathId) === ROOT_PATH_ID) { + // A row at the depth of its own calls stands for them. + return rowOccurrences(row, root); } - return frames; + return derivedRowOf(row, data, root).frames; } /** diff --git a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts index ba3127779..63466efe6 100644 --- a/log-viewer/src/core/log/__tests__/keyPathIds.test.ts +++ b/log-viewer/src/core/log/__tests__/keyPathIds.test.ts @@ -61,6 +61,26 @@ describe('KeyPathIds', () => { expect(ids.reaches(inner, pathFor(ids, 'Z'))).toBe(false); }); + describe('chainNodeAt', () => { + const root = ev(1, 'exec', null); + const outer = ev(2, 'outer', root); + const inner = ev(3, 'inner', outer); + + it('names the frame the row sits at, which is what a caller row stands for', () => { + const leafRow = pathFor(ids, 'METHOD_ENTRY||inner'); + const callerRow = pathFor(ids, 'METHOD_ENTRY||inner', 'METHOD_ENTRY||outer'); + + expect(ids.chainNodeAt(inner, leafRow)).toBe(inner); + expect(ids.chainNodeAt(inner, callerRow)).toBe(outer); + }); + + it('answers for a path the chain misses with nothing', () => { + const elsewhere = pathFor(ids, 'METHOD_ENTRY||Z'); + + expect(ids.chainNodeAt(inner, elsewhere)).toBeNull(); + }); + }); + it('reads back how many keys a path stands for, and none for the empty one', () => { expect(ids.depthOf(pathFor(ids, 'A', 'B', 'C'))).toBe(3); expect(ids.depthOf(ROOT_PATH_ID)).toBe(0); @@ -117,34 +137,47 @@ describe('KeyPathIds', () => { }); }); - describe('pathIdsOf', () => { + describe('pathIdOf', () => { const root = ev(1, 'exec', null); const outer = ev(2, 'outer', root); const inner = ev(3, 'inner', outer); - /** The ids the walk adds, in the order it adds them. */ - function found(event: LogEvent, direction: 'callers' | 'callees'): number[] { - const into = new Set(); - ids.pathIdsOf(event, direction, into); - return [...into]; + it('names one row in a top-down view, at the depth the frame ran at', () => { + expect(ids.pathIdOf(inner)).toBe(pathFor(ids, 'METHOD_ENTRY||outer', 'METHOD_ENTRY||inner')); + }); + + it('leaves the log root out, as it heads no row', () => { + expect(ids.pathIdOf(root)).toBeUndefined(); + }); + }); + + describe('pathsEndingIn', () => { + /** The ids a key heads, over the paths the table has been asked for. */ + function found(...keys: string[]): number[] { + return ids.pathsEndingIn(new Set(keys.map((key) => ids.keyId(key)))); } - it('names one row in a top-down view, at the depth the frame ran at', () => { - expect(found(inner, 'callees')).toEqual([ - pathFor(ids, 'METHOD_ENTRY||outer', 'METHOD_ENTRY||inner'), - ]); + it('names the rows a frame is, and not the rows of the callers above it', () => { + const own = pathFor(ids, 'A'); + const elsewhere = pathFor(ids, 'B', 'A'); + // A row for the caller of A, which stands for that caller and not for A. + pathFor(ids, 'A', 'C'); + + expect(found('A').sort()).toEqual([own, elsewhere].sort()); }); - it('names a row per caller depth in a bottom-up view', () => { - // The frame heads a row on its own, and one under each caller above it. - expect(found(inner, 'callers')).toEqual([ - pathFor(ids, 'METHOD_ENTRY||inner'), - pathFor(ids, 'METHOD_ENTRY||inner', 'METHOD_ENTRY||outer'), - ]); + it('answers for several frames at once, which one pointed-at row can name', () => { + const a = pathFor(ids, 'A'); + const b = pathFor(ids, 'B'); + + expect(found('A', 'B').sort()).toEqual([a, b].sort()); }); - it('leaves the log root out, as it heads a row in neither view', () => { - expect(found(root, 'callers')).toEqual([]); + it('leaves the empty path out, since no row stands for it, and asks nothing of no keys', () => { + pathFor(ids, 'A'); + + expect(found()).toEqual([]); + expect(found('Z')).toEqual([]); }); }); }); diff --git a/log-viewer/src/core/log/keyPathIds.ts b/log-viewer/src/core/log/keyPathIds.ts index 290a35014..c37d9e85b 100644 --- a/log-viewer/src/core/log/keyPathIds.ts +++ b/log-viewer/src/core/log/keyPathIds.ts @@ -3,7 +3,6 @@ */ import type { LogEvent } from 'apex-log-parser'; -import type { SelectionView } from '../events/EventBus.js'; import { getEventKey, getStackKey } from './eventKeys.js'; /** The path every chain starts from, which no row stands for. */ @@ -19,9 +18,9 @@ export const ROOT_PATH_ID = 0; * than by the calls, and makes matching an integer test. * * One invariant holds it together: a row's id is the interned chain of the - * frames the row holds. {@link pathIdsOf} names the rows a frame belongs to and - * {@link chainReaches} asks whether a frame's chain runs through one, so the two - * chain directions stay separate spaces here rather than in every caller. + * frames the row holds. {@link pathIdOf} and {@link pathsEndingIn} name the rows + * a frame stands for, one direction each, and {@link chainNodeAt} reads a row's + * own frame back out of a chain. * * One table per log, held by `LogStore`: an id means nothing to another log. */ @@ -39,7 +38,10 @@ export class KeyPathIds { private children: Array | undefined> = [new Map()]; private parents: number[] = [ROOT_PATH_ID]; private keyOf: number[] = [-1]; - /** One frame's chain, reused: {@link pathIdsOf} never yields, so one is enough. + /** The paths each key heads, which is how a frame finds the rows it stands + * for. Filled as paths are minted, so the answer costs what it holds. */ + private pathsByKey: number[][] = []; + /** One frame's chain, reused: {@link pathIdOf} never yields, so one is enough. * Per table rather than per module, so two logs cannot share the buffer. */ private chain: number[] = []; @@ -86,28 +88,12 @@ export class KeyPathIds { } /** - * The ids naming the rows a frame belongs to in a merged view, added to `into`. - * - * A top-down row sits at the frame's own depth, so one id names it. A - * bottom-up row is the frame plus however many of its callers the chain shows, - * so every prefix names a row the frame heads โ€” which is why one frame marks - * several rows there. - * - * The log root heads no row in either view, so the walk stops below it. + * The id naming the row a frame sits in, top-down, or undefined for the log + * root, which heads no row. */ - public pathIdsOf(event: LogEvent, direction: SelectionView, into: Set): void { + public pathIdOf(event: LogEvent): number | undefined { if (!event.parent) { - return; - } - if (direction === 'callers') { - // The parent walk is already innermost first, which is the order these ids - // compose in, so nothing is collected on the way. - let id = ROOT_PATH_ID; - for (let node: LogEvent | null = event; node?.parent; node = node.parent) { - id = this.step(id, this.keyIdOf(node)); - into.add(id); - } - return; + return undefined; } const chain = this.chain; chain.length = 0; @@ -118,31 +104,54 @@ export class KeyPathIds { for (let depth = chain.length - 1; depth >= 0; depth--) { id = this.step(id, chain[depth]!); } - into.add(id); + return id; } /** - * True where the frame's own chain of callers runs through `pathId`: what tells - * the calls a bottom-up caller row holds from the rest of its bucket's. + * The paths whose own key is one of `keyIds`: the rows that stand for those + * frames in a bottom-up view, wherever they sit. + * + * A bottom-up row is the frame at its own depth, so a frame heads the bucket + * for it and any caller row for it under another bucket. The chains above it + * are other frames' rows, which is why they are not here. + */ + public pathsEndingIn(keyIds: ReadonlySet): number[] { + const found: number[] = []; + for (const keyId of keyIds) { + // A path holds one key, so no path is reached twice. + const paths = this.pathsByKey[keyId]; + if (paths) { + found.push(...paths); + } + } + return found; + } + + /** + * The frame in the chain that `pathId` names, or null where the chain does not + * run through it: the caller a bottom-up row is, at the depth the row sits at. + * + * The walk that decides membership stands on that frame when it gets there, so + * a caller row's frames come out of it rather than out of a second climb. * * Reads without minting, unlike {@link step}: a query that grew the table would * leave a node behind for every frame it was asked about. Ids only rise as a * chain deepens, so the walk stops once it passes the depth asked about. */ - public chainReaches(event: LogEvent, pathId: number): boolean { + public chainNodeAt(event: LogEvent, pathId: number): LogEvent | null { let id = ROOT_PATH_ID; for (let node: LogEvent | null = event; node?.parent; node = node.parent) { const next = this.children[id]?.get(this.keyIdOf(node)); if (next === undefined || next > pathId) { // Never minted, so no row stands for it; or past the row's own depth. - return false; + return null; } id = next; if (id === pathId) { - return true; + return node; } } - return false; + return null; } /** @@ -164,6 +173,7 @@ export class KeyPathIds { this.children.push(undefined); this.parents.push(parentPathId); this.keyOf.push(keyId); + (this.pathsByKey[keyId] ??= []).push(id); } return id; } @@ -200,6 +210,11 @@ export class KeyPathIds { return id === pathId; } + /** The path `pathId` extends, {@link ROOT_PATH_ID} for a row of its own depth. */ + public parentOf(pathId: number): number { + return this.parents[pathId]!; + } + /** How many keys `pathId` stands for: the depth of the row it names, 0 at the * root. */ public depthOf(pathId: number): number { diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index ceabdcb33..b38fe3239 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -174,7 +174,7 @@ export class AnalysisView extends LitElement { mark: (eventIndexes) => this._markLocated(eventIndexes), // An inspector finding names one event; the grid holds it in the bucket for // its method, so that bucket is what gets revealed. - reveal: (eventIndex) => this._revealEventIndex(eventIndex), + reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), clear: () => { // The table reports the clear itself, which is what reaches the inspector. this.analysisTable?.deselectRow(); @@ -219,7 +219,7 @@ export class AnalysisView extends LitElement { * inspector keeps the findings it was clicked in rather than being rebuilt around * the row it just asked for. */ - private async _revealEventIndex(eventIndex: number): Promise { + private async _revealEventIndex(eventIndex: number, signal: AbortSignal): Promise { const table = this.analysisTable; const root = this.timelineRoot; if (!table || !root) { @@ -244,6 +244,10 @@ export class AnalysisView extends LitElement { await this.updateComplete; } + if (signal.aborted) { + return; + } + await this._echoGuard.runAsync(() => //@ts-expect-error This is a custom function added in by RowNavigation custom module table.goToRow(match, { scrollIfVisible: false, focusRow: false }), diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 4669f1125..cdecf1c32 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -180,7 +180,7 @@ export class CalltreeView extends LitElement { this._inspectorUnsubscribe = wireInspectorTab('calltree', this._emphasis, { mark: (eventIndexes) => this._markLocated(eventIndexes), - reveal: (eventIndex) => this._revealEventIndex(eventIndex), + reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), clear: () => { // The table reports the clear itself, which is what reaches the inspector. for (const table of this._tables) { @@ -902,14 +902,14 @@ export class CalltreeView extends LitElement { * switch, no view-mode change and no focus steal, unlike {@link _goToRow}. * Focus stays where the click was, which is the inspector. */ - private async _revealEventIndex(eventIndex: number): Promise { + private async _revealEventIndex(eventIndex: number, signal: AbortSignal): Promise { const table = this._getActiveTable(); if (!table) { return; } const treeRow = await this._findRowFor(table, eventIndex); - if (!treeRow) { + if (!treeRow || signal.aborted) { return; } diff --git a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts index 4634b4652..5100079cd 100644 --- a/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts +++ b/log-viewer/src/features/call-tree/utils/__tests__/Aggregation.test.ts @@ -1062,7 +1062,7 @@ describe('bottom-up caller row scope', () => { /** The root bucket's calls whose own chain of callers runs through the row, * which is what the grid counts the row's totals from. */ function derivedFor(paths: KeyPathIds, root: BottomUpRow, row: BottomUpRow): LogEvent[] { - return root.instances.filter((event) => paths.chainReaches(event, row._pathId)); + return root.instances.filter((event) => paths.chainNodeAt(event, row._pathId) !== null); } it('counts one call per occurrence the row derives', () => { From cacf9cc8b5e8a073432ba2d5a9a8772ef1a78003 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:41:53 +0100 Subject: [PATCH 17/61] fix(lana): follow-up fixes for the Salesforce Services migration (#969) Follow-ups to #951. - Restore the full Apex log list. `ApexLogService.listLogs` needs an explicit limit and the migration passed none, so the picker showed 25 logs. - Stop loading Salesforce Services on shutdown. `deactivate()` imported the services module to dispose it, so every window close pulled in a 129 KB chunk even when no log was retrieved. - Open the log panel before retrieving the log. The panel appears at once again, and a large log streams from disk instead of crossing the webview message channel. - Remove `docs/pr-951-952-review-findings.md`. 1982 tests pass; type check, lint and format are clean. --- docs/pr-951-952-review-findings.md | 299 ------------------ lana/src/Main.ts | 4 +- lana/src/__tests__/Main.test.ts | 7 +- lana/src/commands/LogView.ts | 31 +- lana/src/commands/RetrieveLogFile.ts | 36 ++- .../__tests__/RetrieveLogFile.test.ts | 30 +- lana/src/services/salesforceServices.ts | 9 +- 7 files changed, 78 insertions(+), 338 deletions(-) delete mode 100644 docs/pr-951-952-review-findings.md diff --git a/docs/pr-951-952-review-findings.md b/docs/pr-951-952-review-findings.md deleted file mode 100644 index b1dbe2289..000000000 --- a/docs/pr-951-952-review-findings.md +++ /dev/null @@ -1,299 +0,0 @@ -# PR 951 and 952 review findings - -## Scope - -Review of upstream PRs: - -- [#951 โ€” `refactor(lana): use Salesforce Services`](https://github.com/certinia/debug-log-analyzer/pull/951) -- [#952 โ€” `refactor(lana): use URI-safe file access`](https://github.com/certinia/debug-log-analyzer/pull/952) - -Checked 2026-08-26. Review state at discovery time: - -- PR 951: 14 open review threads; changes requested. -- PR 952: 5 open review threads; review required. -- No general PR comments or review-body findings; all findings are inline threads. - -## Summary - -Do not merge either PR unchanged. - -Most lifecycle, caching, URI, menu, race, and display fixes belong in Log Analyzer and can start immediately. Correct multi-root org selection, complete log listing, reliable published types, and eliminating the consumer-owned Effect runtime require Salesforce Services changes. - -## Remediation status - -Checkpoint: 2026-08-26, commit `5e70601e` on `ph/W-23939830-services-upstream`. - -| Area | Status | Result or next step | -| --------------------------------------- | ----------- | ------------------------------------------------------------------------- | -| PR 951 lazy Services activation | Complete | Retrieve Log initializes Services; local analysis activation does not. | -| PR 951 missing/incompatible Services UX | Complete | Install/update action; unexpected activation failures preserved. | -| PR 951 runtime API validation | Complete | Checks Apex log, filesystem, and prebuilt-context exports. | -| PR 951 cached-log reuse | Complete | Cache hit skips body retrieval and write. | -| PR 951 access-denied matching | Complete | Handles joined, spaced, repeated-space, case, and surrounding whitespace. | -| PR 951 declaration dependency placement | Complete | `@salesforce/vscode-services` moved to `devDependencies`. | -| PR 951 activation bundle split | Complete | Salesforce bridge and Effect runtime emitted as lazy chunks. | -| Log Analyzer filesystem ownership | Deferred | Keep Salesforce `FsService` for now. | -| PR 951 workspace-scoped org retrieval | Blocked | Requires Services workspace/org targeting. | -| PR 951 complete log listing | Blocked | Requires Services pagination or optional limit. | -| PR 951 self-contained declarations | Blocked | Requires a corrected Services npm package. | -| PR 951 shared runtime/Promise boundary | Blocked | Requires a Services export. | -| PR 952 remediation | Not started | Begin only after PR 951 review/branch update. | - -Verification at checkpoint: - -- `pnpm test:ci`: 139 suites, 1,845 tests passed. -- `pnpm build`: passed, including typecheck and production bundles. -- Changed files: ESLint, Prettier, and `git diff --check` passed. -- Full `pnpm lint`: still obstructed because `eslint .` traverses generated `.vscode-test-web` sources despite the ignore entry. - -## Dependency matrix - -`Requires Services` means the complete Log Analyzer fix depends on a new or corrected Salesforce Services release. - -| Log Analyzer change | Requires Services | Reason | -| ------------------------------------------------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------- | -| Lazy-load Services from Retrieve Log | No | Log Analyzer controls activation and command flow. | -| Remove hard `extensionDependencies` and show install/update prompt | No | Log Analyzer manifest and UX. | -| Use `vscode.workspace.fs` for Log Analyzer files | No | VS Code API; no Services dependency needed. | -| Dynamically import the Services bridge | No | Log Analyzer build and module boundary. | -| Move `@salesforce/vscode-services` to `devDependencies` | No | Package contains compile-time declarations. | -| Guard missing/outdated API by checking required exports | No | Structural guard can ship now. | -| Use an explicit Services API version/capability contract | Yes | Services does not publish one. | -| Restore cached-log reuse | No | Log Analyzer retrieval flow. | -| Fix access-denied matching | No | Log Analyzer response validation. | -| Keep cache path tied to the selected workspace | No | Log Analyzer already owns workspace selection and the cache URI. | -| Retrieve logs from the selected workspace's org | Yes | `ApexLogService` resolves the Services-selected/default org. | -| Use a workspace-aware Services debug-log directory | Yes | `ProjectService.getDebugLogsFolder()` has no workspace argument. | -| Replace the 25-log cap with full listing or Load More | Yes | `ApexLogService.listLogs()` needs pagination or an optional limit. | -| Compile against trustworthy Services types | Yes | Published declarations reference files absent from the npm package. | -| Remove Log Analyzer's bundled Effect runtime | Yes | Services must expose its runtime or Promise-returning wrappers. | -| Remove URI-scheme allowlist | No | Log Analyzer detection policy. | -| Restore command-palette visibility condition | No | Log Analyzer manifest. | -| Suppress stale async language-detection results | No | Log Analyzer request coordination. | -| Read only the first 4 KB of large local files | No | Desktop `file:` implementation can perform a bounded read. | -| Read a bounded prefix from virtual/web files | Partial | Generic `workspace.fs.readFile()` returns the entire file; an efficient provider or Services API is needed. | -| Make `logPath` display-only and retain `logUri` for behavior | No | Log Analyzer/webview contract. | -| Use the `WebWorker` TypeScript library | No | Log Analyzer compiler configuration. | -| Restore regression tests | No | Log Analyzer test suite. | - -## Log Analyzer changes - -### 1. Isolate Salesforce-only behavior - -- Remove eager `initServices()` from extension activation. -- Initialize Services from Retrieve Log only. -- Cache the initialization promise to deduplicate concurrent calls. -- Remove the hard extension dependency. -- On missing or incompatible Services, offer an install/update action. -- Keep deactivation safe when Services was never initialized. -- Dynamically import the bridge so local analysis does not load Effect. - -Local log analysis, parsing, decorations, navigation, and webview display must work without Salesforce extensions installed or active. - -### 2. Own local file I/O - -Status: deferred. PR 951 continues to use Salesforce `FsService`. - -Use `vscode.workspace.fs` for Log Analyzer files: - -- read and decode text; -- create parent directories and write encoded text; -- check existence with `stat`; -- save exported files; -- open and navigate using the original `Uri`. - -Do not route local analysis through Salesforce `FsService`. This couples all file analysis to Services initialization and defeats lazy activation. - -### 3. Restore retrieval behavior - -- Build the cache URI from the workspace selected by `QuickPickWorkspace`. -- Check the cache before calling `getLogBody()`. -- Cache hit: open the local URI without downloading or writing. -- Cache miss: retrieve, validate, write, then open. -- Cache-write failure: report to the output channel but analyze the retrieved body. -- Match access-denied bodies with `/^access\s*denied$/i` after trimming. -- Test `AccessDenied`, `Access denied`, cache hit, cache miss, and write failure. - -Do not replace the 25-log default with an arbitrary huge number. That hides truncation and remains incomplete. - -### 4. Preserve multi-root consistency - -Log Analyzer can immediately ensure the selected workspace controls the cache location. It cannot make Services query that workspace's org with the current API. - -Until Services supports workspace/org targeting, choose one explicit interim behavior: - -1. Block merge to preserve existing multi-root behavior; preferred. -2. Document and enforce first-workspace-only retrieval; behavior regression. - -Never query workspace B's org and cache the result under workspace A. - -### 5. Guard the Services boundary - -- Treat extension exports as `unknown` until validated. -- Check `services`, `prebuiltServicesDependencies`, Apex log methods, and required `FsService` methods before use. -- Show an actionable incompatible-version message. -- Move the npm declaration package to `devDependencies`. -- After corrected types are published, pin or constrain to the first compatible release. - -Structural checks are an interim compatibility mechanism, not a substitute for a Services-owned API version. - -### 6. Fix URI and language detection - -- Remove the fixed `file`/`vscode-vfs`/`memfs` scheme allowlist. -- Detect active documents by content and extension. -- Let registered filesystem providers determine whether fallback reads succeed. -- Restore `resourceLangId == apexlog || lana.isApexLog` on the command-palette contribution. -- Increment a generation counter for every context update. -- Apply an async result only when its generation and URI are still current. -- Add a deferred-promise test for switching from a slow log to a fast non-log file. - -Debouncing alone does not prevent stale results. - -### 7. Preserve large-file performance - -The proposed `workspace.fs.readFile(uri)` followed by `bytes.subarray(0, 4096)` avoids decoding the full file but still reads the full file. - -Recommended behavior: - -- Desktop `file:` URI: true 4 KB read. -- Virtual/web URI: provider read with 4 KB decode; avoid repeated reads through result caching where safe. -- Future: use a bounded Services/provider read when available. - -Large-file tests should verify stale-result suppression and bounded local reads, not only content matching. - -### 8. Keep URI and display path separate - -- `logUri`: authoritative identity for fetch, open, navigation, and webview resource conversion. -- `logPath`: display text only; prefer `workspace.asRelativePath(logUri, true)`. -- Ignore webview-supplied paths for opening files; use the captured trusted URI. -- Test file and non-file URIs. - -### 9. Compiler configuration - -- Remove Node types after Node-only imports leave the shared source. -- Replace `DOM` with `WebWorker` for the web extension host. -- Keep strict TypeScript settings. - -The current source typechecks with `ES2022,WebWorker`. - -## Salesforce Services changes - -### 1. Workspace-scoped services - -Current `WorkspaceService`, `ConfigService`, and `ProjectService` resolve `workspaceFolders[0]`. Add explicit workspace inputs where behavior can vary by root. - -Required APIs: - -- workspace-aware config/default-org resolution; -- `ProjectService.getDebugLogsFolder(workspaceUri)`; -- Apex-log operations targeting a workspace, username, org, or connection. - -`ApexLogService.listLogs()` and `getLogBody()` must use the same explicit target for one retrieval flow. - -### 2. Complete log listing - -Current v67.12 behavior defaults `listLogs()` to 25 records and always emits `LIMIT`. - -Provide one of: - -- optional limit with no `LIMIT` when omitted; -- paged results with continuation; -- cursor/load-more API. - -Pagination is preferred for predictable memory and UI behavior. - -### 3. Correct published declarations - -The installed `@salesforce/vscode-services` 67.13.3 `out/index.d.ts` exports from `../../salesforcedx-vscode-services/out/src/index`, which is absent from the published package. `skipLibCheck` masks the break and weakens the consumer contract. - -Publish self-contained declarations and add a package smoke test that installs the tarball in an isolated TypeScript consumer. - -### 4. Version and capability contract - -Export an API version or capability object. Consumers need to distinguish: - -- extension missing; -- extension too old; -- required service absent; -- compatible API. - -VS Code extension dependencies do not enforce the npm declaration version or a minimum runtime API version. - -### 5. Shared runtime or Promise boundary - -Services already owns the built service context and an internal runtime. Export either: - -- the prebuilt runtime; or -- stable Promise-returning wrappers for public operations. - -This avoids every consumer bundling Effect and reconstructing a `ManagedRuntime` over the exported context. - -### 6. Optional bounded-read API - -For large virtual/web resources, consider `FsService.readFilePrefix(uri, maxBytes)` or an equivalent provider capability. This is not required for Log Analyzer's local-file fix, but it is required for efficient bounded detection across all supported schemes. - -## Review-thread disposition - -### PR 951 - -| Review topic | Disposition | Owner | -| ------------------------------------------- | ----------------------------------------------------------------------- | --------------------------- | -| Launch configuration isolation | Resolve; no code change | None | -| Hard-coded `.sfdx/tools/debug/logs` | Keep selected-workspace construction until Services accepts a workspace | Services, then Log Analyzer | -| Types package in runtime dependencies | Move to `devDependencies` | Log Analyzer | -| Runtime/declaration version drift | Structural guard now; version contract later | Both | -| Broken declaration package | Fix published package | Services | -| 25-log regression | Add pagination/optional limit, then consume it | Services, then Log Analyzer | -| Access-denied regex | Fix regex and tests | Log Analyzer | -| Cached log always downloaded | Restore existence check | Log Analyzer | -| Multi-root org mismatch | Add workspace/org target API | Services, then Log Analyzer | -| Eager activation failure | Lazy initialization and install/update UX | Log Analyzer | -| Use `workspace.fs` | Accept for Log Analyzer-owned files | Log Analyzer | -| Consumer-owned Effect runtime/bundle growth | Export runtime or Promise wrappers | Services | -| Temporary Node types | Remove in PR 952 when Node imports leave | Log Analyzer | -| `DOM` versus `WebWorker` | Use `WebWorker` | Log Analyzer | - -### PR 952 - -| Review topic | Disposition | Owner | -| ------------------------- | ------------------------------------------------------------------------ | ---------------------------------------- | -| URI-scheme checks | Remove allowlist; detect by content | Log Analyzer | -| Command always visible | Restore `when` clause | Log Analyzer | -| Full large-file read | True bounded local read; virtual fallback; Services enhancement optional | Log Analyzer; Services for full coverage | -| Async context-key race | Generation/URI guard and regression test | Log Analyzer | -| URI shown as display path | Separate display path from authoritative URI | Log Analyzer | - -## Delivery order - -Parallel tracks: - -1. Log Analyzer-only fixes: lifecycle, file I/O, cache, regex, URI detection, race, menu, display, compiler config, tests. -2. Services fixes: declarations, workspace/org targeting, pagination, capability version, shared runtime. - -Integration after a Services release: - -1. Update the Log Analyzer declaration dependency. -2. Set the minimum API capability/version. -3. Pass the selected workspace/org through every Apex-log operation. -4. Add Load More or complete listing. -5. Replace the local Effect runtime with the exported runtime/Promise boundary. -6. Run typecheck, lint, unit tests, production build, desktop extension tests, and web extension tests. - -## Acceptance criteria - -- [x] PR 951 local log analysis activates without Salesforce extensions. -- [x] Retrieve Log offers actionable install/update errors. -- [ ] Selected workspace controls both org and cache location. -- [ ] More than 25 logs are reachable without an arbitrary cap. -- [x] Cached logs are not downloaded again. -- [ ] 100 MB+ local logs are not fully read for detection. -- [ ] Switching tabs cannot publish stale `lana.isApexLog` state. -- [ ] URI-backed logs retain correct open/navigation behavior and readable titles. -- [ ] Published Services declarations typecheck in an isolated consumer. -- [ ] Log Analyzer does not bundle a second Effect runtime after the Services runtime API lands. - -## Source references - -- [Salesforce Services v67.12 `ApexLogService`](https://github.com/forcedotcom/salesforcedx-vscode/blob/v67.12.0/packages/salesforcedx-vscode-services/src/core/apexLogService.ts) -- [Salesforce Services v67.12 `ProjectService`](https://github.com/forcedotcom/salesforcedx-vscode/blob/v67.12.0/packages/salesforcedx-vscode-services/src/core/projectService.ts) -- [Salesforce Services repository](https://github.com/forcedotcom/salesforcedx-vscode) diff --git a/lana/src/Main.ts b/lana/src/Main.ts index fb964e909..8222bef92 100644 --- a/lana/src/Main.ts +++ b/lana/src/Main.ts @@ -12,8 +12,6 @@ export function activate(extensionContext: ExtensionContext) { context = new Context(extensionContext, new Display()); } -export async function deactivate() { +export function deactivate() { context = null; - const { disposeServices } = await import('./services/servicesRuntime.js'); - await disposeServices(); } diff --git a/lana/src/__tests__/Main.test.ts b/lana/src/__tests__/Main.test.ts index c4c126840..1ef26067c 100644 --- a/lana/src/__tests__/Main.test.ts +++ b/lana/src/__tests__/Main.test.ts @@ -24,7 +24,6 @@ const mockInitServices = initServices as jest.Mock; describe('Main', () => { beforeEach(() => { jest.clearAllMocks(); - mockDisposeServices.mockResolvedValue(undefined); }); it('activates without initializing Salesforce Services', () => { @@ -37,9 +36,9 @@ describe('Main', () => { expect(mockInitServices).not.toHaveBeenCalled(); }); - it('disposes Salesforce Services during deactivation', async () => { - await deactivate(); + it('deactivates without loading the Salesforce Services chunk', () => { + deactivate(); - expect(mockDisposeServices).toHaveBeenCalledWith(); + expect(mockDisposeServices).not.toHaveBeenCalled(); }); }); diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 4dde9879d..e7d130811 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -51,7 +51,7 @@ export class LogView { static async createView( context: Context, - beforeSendLog?: Promise, + beforeSendLog?: Promise, logUri?: Uri, logData?: string, ): Promise { @@ -112,8 +112,14 @@ export class LogView { if (!requestId) { break; } - await beforeSendLog; - await LogView.sendLog(requestId, panel, context, logUri, logData); + try { + // A retrieve that resolves to a body could not be cached, so send it inline. + const retrievedLog = await beforeSendLog; + await LogView.sendLog(requestId, panel, context, logUri, retrievedLog || logData); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + context.display.showErrorMessage(`Error loading logfile: ${errorMessage}`); + } break; } @@ -239,11 +245,16 @@ export class LogView { logUri?: Uri, logData?: string, ) { - if (!logData && logUri && !(await fileOrFolderExists(logUri))) { - context.display.showErrorMessage('Log file could not be found.', { - modal: true, - }); - return; + // Caching can fail, so only advertise a URI the webview and navigation can read. + const cachedUri = logUri && (await fileOrFolderExists(logUri)) ? logUri : undefined; + if (!cachedUri) { + LogView.currentLogUri = undefined; + if (!logData) { + context.display.showErrorMessage('Log file could not be found.', { + modal: true, + }); + return; + } } const navigateToTimestamp = LogView.pendingNavigationTimestamp; @@ -254,8 +265,8 @@ export class LogView { cmd: 'fetchLog', payload: { logName: logUri ? Utils.basename(logUri) : '', - logUri: logUri ? panel.webview.asWebviewUri(logUri).toString(true) : '', - logPath: logUri ? getLogDisplayPath(logUri) : undefined, + logUri: cachedUri ? panel.webview.asWebviewUri(cachedUri).toString(true) : '', + logPath: cachedUri ? getLogDisplayPath(cachedUri) : undefined, logData: logData, navigateToTimestamp, }, diff --git a/lana/src/commands/RetrieveLogFile.ts b/lana/src/commands/RetrieveLogFile.ts index 0b95528a4..ef8c9541e 100644 --- a/lana/src/commands/RetrieveLogFile.ts +++ b/lana/src/commands/RetrieveLogFile.ts @@ -34,6 +34,8 @@ class DebugLogItem extends Item { } export class RetrieveLogFile { + private static servicesDisposalRegistered = false; + static apply(context: Context): void { new Command('retrieveLogFile', 'Log: Retrieve Apex Log And Show Analysis', () => RetrieveLogFile.safeCommand(context), @@ -56,6 +58,17 @@ export class RetrieveLogFile { return; } + // Disposal is registered here, not in deactivate(), so shutdown never loads this chunk + // when the command was not used. + if (!RetrieveLogFile.servicesDisposalRegistered) { + RetrieveLogFile.servicesDisposalRegistered = true; + context.context.subscriptions.push({ + dispose: () => { + salesforceServices.disposeServices().catch(() => {}); + }, + }); + } + const workspaceFolder = workspace.workspaceFolders?.[0]; if (!workspaceFolder) { throw new Error('No workspace selected'); @@ -77,15 +90,20 @@ export class RetrieveLogFile { return LogView.createView(context, Promise.resolve(), logUri); } - const logData = await salesforceServices.getLogBody(logFileId); - this.assertRetrievedLog(logFileId, logData); - try { - await salesforceServices.writeFile(logUri, logData); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - context.display.output(`Unable to cache retrieved log: ${message}`, true); - } - return LogView.createView(context, undefined, logUri, logData); + // Open the panel first and retrieve behind it. The body only crosses the webview + // message channel when it could not be cached, so the webview streams it from disk. + const retrieveLog = (async (): Promise => { + const logData = await salesforceServices.getLogBody(logFileId); + RetrieveLogFile.assertRetrievedLog(logFileId, logData); + try { + await salesforceServices.writeFile(logUri, logData); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + context.display.output(`Unable to cache retrieved log: ${message}`, true); + return logData; + } + })(); + return LogView.createView(context, retrieveLog, logUri); } } finally { loadingPicker.dispose(); diff --git a/lana/src/commands/__tests__/RetrieveLogFile.test.ts b/lana/src/commands/__tests__/RetrieveLogFile.test.ts index a84adf872..690b9f52f 100644 --- a/lana/src/commands/__tests__/RetrieveLogFile.test.ts +++ b/lana/src/commands/__tests__/RetrieveLogFile.test.ts @@ -75,6 +75,11 @@ const log = (id: string, startTime = '2024-01-01T00:00:00.000Z', durationMillise Status: 'Success', }); +/** The deferred retrieve handed to LogView.createView as its beforeSendLog promise. */ +function retrieveLogPromise(): Promise { + return mockCreateView.mock.calls[0]?.[1] as Promise; +} + describe('RetrieveLogFile', () => { beforeEach(() => { jest.clearAllMocks(); @@ -120,6 +125,13 @@ describe('RetrieveLogFile', () => { RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); await command()(); + expect(mockCreateView).toHaveBeenCalledWith( + context, + expect.any(Promise), + expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), + ); + // A cached log is streamed from disk, so the body is never sent to the webview. + await expect(retrieveLogPromise()).resolves.toBeUndefined(); expect(mockGetLogBody).toHaveBeenCalledWith('selected-log'); expect(mockFileOrFolderExists).toHaveBeenCalledWith( expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), @@ -128,12 +140,6 @@ describe('RetrieveLogFile', () => { expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), 'log body', ); - expect(mockCreateView).toHaveBeenCalledWith( - context, - undefined, - expect.objectContaining({ path: expect.stringContaining('selected-log.log') }), - 'log body', - ); }); it('uses the first workspace selected by Salesforce Services for the cache', async () => { @@ -186,14 +192,20 @@ describe('RetrieveLogFile', () => { expect(context.display.showErrorMessage).not.toHaveBeenCalled(); }); - it('still opens a retrieved log when cache writing fails', async () => { + it('sends the log body inline when cache writing fails', async () => { mockListLogs.mockResolvedValue([log('selected-log')]); mockPick.mockResolvedValue([{ logId: 'selected-log' }]); mockWriteFile.mockRejectedValue(new Error('read-only workspace')); const context = createMockContext(); RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); await command()(); + expect(mockCreateView).toHaveBeenCalled(); + await expect(retrieveLogPromise()).resolves.toBe('log body'); + expect(context.display.output).toHaveBeenCalledWith( + expect.stringContaining('Unable to cache retrieved log'), + true, + ); }); it('sorts logs newest first before presenting them', async () => { @@ -244,9 +256,7 @@ describe('RetrieveLogFile', () => { const context = createMockContext(); RetrieveLogFile.apply(context as unknown as import('../../Context.js').Context); await command()(); - expect(context.display.showErrorMessage).toHaveBeenCalledWith( - expect.stringContaining('Salesforce denied access'), - ); + await expect(retrieveLogPromise()).rejects.toThrow('Salesforce denied access'); }, ); }); diff --git a/lana/src/services/salesforceServices.ts b/lana/src/services/salesforceServices.ts index fc311f2e3..e1292dc18 100644 --- a/lana/src/services/salesforceServices.ts +++ b/lana/src/services/salesforceServices.ts @@ -3,9 +3,12 @@ */ import type { Uri } from 'vscode'; -import { ensureServicesAvailable, getRuntime, getServicesApi } from './servicesRuntime.js'; +import { getRuntime, getServicesApi } from './servicesRuntime.js'; -export { ensureServicesAvailable }; +export { disposeServices, ensureServicesAvailable } from './servicesRuntime.js'; + +/** The previous LogService query set no LIMIT, so it returned a full Tooling API page. */ +const MAX_LOG_RECORDS = 2000; /* eslint-disable @typescript-eslint/naming-convention -- Salesforce API field names are case-sensitive. */ export interface ApexLogListItem { @@ -19,7 +22,7 @@ export interface ApexLogListItem { } /* eslint-enable @typescript-eslint/naming-convention */ -export function listLogs(limit = 25): Promise { +export function listLogs(limit = MAX_LOG_RECORDS): Promise { const { ApexLogService } = getServicesApi().services; return getRuntime().runPromise(ApexLogService.listLogs(limit)); } From 76c5b995cd8229fda0f56ab532e58f0ba6e52f0f Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:45:19 +0100 Subject: [PATCH 18/61] perf(log-viewer): stop re-rendering the grid header on every row selection (#994) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview An arrow key and a row click both blocked the table's redraw around the selection change. Selecting a row sets a class and reports the change, while restoring a blocked redraw re-aligns the header and re-renders every column, so each keystroke paid a write/read/write layout cycle per column plus a renderer resize โ€” on grids of 500k rows and up to 14 columns. The four key bindings that paid it also repeated the same preamble four times, so the rule they share now has one home. **Stacked on #991 โ€” merge that first.** ## ๐Ÿ› ๏ธ Changes made - No redraw block around a selection change, on the keys or on a click: nothing in the webview listens for the redraw-block events, so it only bought that work. - One `keyedTable` for "is this key mine": the option, and the body as the event target. The rule is unchanged, and it now reads the option through the table's own typed options, which drops the last `@ts-expect-error` in the file and the todo that asked for it. - `previousRow` and `nextRow` differed only in which way they step, so they share a factory; the deselect, select and keep-in-view tail is one call. - Docs corrected: `takeFocusBack` said the bindings answer only while the body is the target as its own justification, the class header described a different module and an option that does not exist, and `collapseRow` pointed at a comment `expandRow` did not have. ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [x] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ”— Related Issues related # ## โœ… Tests added? - [x] ๐Ÿ‘ yes `pnpm test`: 1596 tests, 131 suites. The bindings had one test between them and now have nine, covering both siblings, the option gate, a key from the tree control, the `dataTree` guard, stepping into a child and out to a parent, and the code-driven collapse. Two were proven by removing the rule they pin. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ™… not needed --------- Co-authored-by: Luke Cotter <81575432+lcottercertinia@users.noreply.github.com> --- .../tabulator/module/RowKeyboardNavigation.ts | 173 ++++++++---------- .../__tests__/RowKeyboardNavigation.test.ts | 125 +++++++++++++ 2 files changed, 198 insertions(+), 100 deletions(-) diff --git a/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts b/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts index 6fabb154a..eb66784b5 100644 --- a/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts +++ b/log-viewer/src/tabulator/module/RowKeyboardNavigation.ts @@ -13,10 +13,54 @@ import { isCodeDrivenExpand, withCodeDrivenExpand } from './expandOrigin.js'; import { tableHolder } from './tableHolder.js'; // todo: make this generic and support opening grouped rows too then use on DB view. -// todo: remove the '@ts-expect-error' + fix the types file const rowNavOptionName = 'rowKeyboardNavigation' as const; +/** + * The table a key is for, or null where the key is not this module's to answer. + * + * Only from the body, which is what holds the selection the keys move. Tabulator + * binds them on the table's root, and the tree control has a `tabIndex` of its + * own, so a key can arrive from a control belonging to a row other than the + * selected one. + */ +function keyedTable(module: Module, e: KeyboardEvent): Tabulator | null { + const table = module.table; + if (!table.options[rowNavOptionName]) { + return null; + } + return e.target === tableHolder(table.element) ? table : null; +} + +/** A binding that moves the selection one row, whichever way `pick` steps. */ +function siblingAction(pick: (row: RowComponent) => RowComponent | false) { + return function (this: Module, e: KeyboardEvent) { + const table = keyedTable(this, e); + if (!table) { + return; + } + e.preventDefault(); + const row = table.getSelectedRows()[0]; + const target = row && pick(row); + if (row && target) { + moveSelection(row, target); + } + }; +} + +/** + * Move the selection from one row to another, and keep it in view. + * + * No redraw block around it, here or on a click: selecting a row only sets a + * class, while restoring a blocked redraw re-aligns and re-renders every column + * header. + */ +function moveSelection(from: RowComponent, to: RowComponent): void { + from.deselect(); + to.select(); + to.getElement().scrollIntoView({ block: 'nearest' }); +} + declare module 'tabulator-tables' { interface Options { /** Enable this module's key bindings on the table (registered below). */ @@ -25,11 +69,13 @@ declare module 'tabulator-tables' { } /** - * Enable RowNavigation by importing the class and calling - * Tabulator.registerModule(RowNavigation); before the first instantiation of the table. - * To disable RowNavigation set rowNavigation to false in table options. - * To disbale individual key binings set previousRow, nextRow,expandRow, collapseRow to false - * in keybings e.g keybindings: { previousRow: false }, + * Arrow-key travel over a table's rows: up and down move the selection, right + * and left open and close a tree row, and step into and out of it. + * + * Register the module before the first table is built, and set + * `rowKeyboardNavigation` on the tables that want it. A single binding is + * dropped through Tabulator's own `keybindings` option, e.g. + * `keybindings: { previousRow: false }`. */ export class RowKeyboardNavigation extends Module { static moduleName = 'rowKeyboardNavigation'; @@ -76,10 +122,9 @@ export class RowKeyboardNavigation extends Module { } /** - * Tabulator gives the tree control its own `tabIndex`, so working it moves - * focus onto the control. The key bindings only answer while the table body is - * the event target, so without this the arrows scroll the table instead of - * moving down it. + * Working the tree control leaves focus on the control, which the next render + * can take away with the row. Focus is put back on the body, which the table + * keeps, so the keys keep arriving. */ private takeFocusBack(): void { tableHolder(this.localTable.element)?.focus({ preventScroll: true }); @@ -90,124 +135,52 @@ export class RowKeyboardNavigation extends Module { if (type === 'Range') { return; } - this.localTable.blockRedraw(); for (const row of this.localTable.getSelectedRows()) { row.deselect(); } row.toggleSelect(); - this.localTable.restoreRedraw(); } private static getModuleExtensions() { return { keybindings: { actions: { - previousRow: function (e: KeyboardEvent) { - // @ts-expect-error see types todo - if (!this.options(rowNavOptionName)) { - return; - } - const targetElem = e.target as HTMLElement; - if (!targetElem.classList.contains('tabulator-tableholder')) { - return; - } - e.preventDefault(); - // @ts-expect-error this.table exists - const table = this.table as Tabulator; - const row = table.getSelectedRows()[0]; - const previousRow = row?.getPrevRow(); - if (row && previousRow) { - table.blockRedraw(); - row.deselect(); - previousRow.select(); - table.restoreRedraw(); - previousRow.getElement().scrollIntoView({ block: 'nearest' }); - } - }, - nextRow: function (e: KeyboardEvent) { - // @ts-expect-error see types todo - if (!this.options(rowNavOptionName)) { - return; - } - - const targetElem = e.target as HTMLElement; - if (!targetElem.classList.contains('tabulator-tableholder')) { - return; - } - e.preventDefault(); - // @ts-expect-error this.table exists - const table = this.table as Tabulator; - const row = table.getSelectedRows()[0]; - const nextRow = row?.getNextRow(); - if (row && nextRow) { - table.blockRedraw(); - row.deselect(); - nextRow.select(); - table.restoreRedraw(); - nextRow.getElement().scrollIntoView({ block: 'nearest' }); - } - }, - expandRow: function (e: KeyboardEvent) { - // @ts-expect-error see types todo - if (!this.options(rowNavOptionName)) { - return; - } - - const targetElem = e.target as HTMLElement; - if (!targetElem.classList.contains('tabulator-tableholder')) { - return; - } - // @ts-expect-error this.table exists - const table = this.table as Tabulator; - const row = table.getSelectedRows()[0]; - if (!row || !table.options.dataTree) { + previousRow: siblingAction((row) => row.getPrevRow()), + nextRow: siblingAction((row) => row.getNextRow()), + expandRow: function (this: Module, e: KeyboardEvent) { + const table = keyedTable(this, e); + const row = table?.getSelectedRows()[0]; + if (!table || !row || !table.options.dataTree) { return; } e.preventDefault(); if (row.isTreeExpanded()) { - const nextRow = row?.getNextRow(); + const nextRow = row.getNextRow(); if (nextRow && nextRow.getTreeParent() === row) { - table.blockRedraw(); - row.deselect(); - nextRow.select(); - table.restoreRedraw(); - nextRow.getElement().scrollIntoView({ block: 'nearest' }); + moveSelection(row, nextRow); } } else { + // Declared as the code's own, so the expand does not read as the + // user reaching for the tree control. withCodeDrivenExpand(() => row.treeExpand()); } }, - collapseRow: function (e: KeyboardEvent) { - // @ts-expect-error see types todo - if (!this.options(rowNavOptionName)) { - return; - } - - const targetElem = e.target as HTMLElement; - if (!targetElem.classList.contains('tabulator-tableholder')) { - return; - } - // @ts-expect-error this.table exists - const table = this.table as Tabulator; - const row = table.getSelectedRows()[0]; - if (!row || !table.options.dataTree) { + collapseRow: function (this: Module, e: KeyboardEvent) { + const table = keyedTable(this, e); + const row = table?.getSelectedRows()[0]; + if (!table || !row || !table.options.dataTree) { return; } e.preventDefault(); if (!row.isTreeExpanded()) { - const prevRow = row?.getTreeParent(); - if (prevRow) { - table.blockRedraw(); - row.deselect(); - prevRow.select(); - table.restoreRedraw(); - prevRow.getElement().scrollIntoView({ block: 'nearest' }); + const parentRow = row.getTreeParent(); + if (parentRow) { + moveSelection(row, parentRow); } } else { - // Declared like `expandRow`'s: the collapse is the code's, so it - // must not read as the user reaching for the tree control. + // The code's own, as `expandRow`'s is. withCodeDrivenExpand(() => row.treeCollapse()); } }, diff --git a/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts b/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts index c53f0c266..04e422857 100644 --- a/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts +++ b/log-viewer/src/tabulator/module/__tests__/RowKeyboardNavigation.test.ts @@ -92,3 +92,128 @@ describe('RowKeyboardNavigation', () => { expect(holder.focus).not.toHaveBeenCalled(); }); }); + +describe('RowKeyboardNavigation key bindings', () => { + const actions = RowKeyboardNavigation.moduleExtensions.keybindings.actions; + + /** A table of three rows with the middle one selected, as a key finds it. */ + function keyed({ + rowNav = true, + dataTree = false, + expanded = false, + }: { rowNav?: boolean; dataTree?: boolean; expanded?: boolean } = {}) { + const rowOf = (name: string) => { + const scrollIntoView = jest.fn(); + return { + name, + scrollIntoView, + select: jest.fn(), + deselect: jest.fn(), + getElement: () => ({ scrollIntoView }), + }; + }; + const previous = rowOf('previous'); + const next = rowOf('next'); + const parent = rowOf('parent'); + const current = { + ...rowOf('current'), + getPrevRow: () => previous, + getNextRow: () => next, + getTreeParent: () => parent, + isTreeExpanded: () => expanded, + treeExpand: jest.fn(), + treeCollapse: jest.fn(), + }; + // The child of the selected row, which an expanded row steps into. + Object.assign(next, { getTreeParent: () => current }); + const body = {}; + const table = { + options: { rowKeyboardNavigation: rowNav, dataTree }, + element: { querySelector: () => body }, + getSelectedRows: () => [current], + }; + const scope = { table } as unknown as never; + const press = (action: keyof typeof actions, target: unknown = body) => { + const event = { target, preventDefault: jest.fn() } as unknown as KeyboardEvent; + actions[action].call(scope, event); + return event; + }; + return { previous, next, parent, current, body, press }; + } + + it('moves the selection down and keeps the row it lands on in view', () => { + const { current, next, press } = keyed(); + + const event = press('nextRow'); + + expect(next.select).toHaveBeenCalled(); + expect(current.deselect).toHaveBeenCalled(); + expect(next.scrollIntoView).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('moves the selection up the same way', () => { + const { previous, press } = keyed(); + + press('previousRow'); + + expect(previous.select).toHaveBeenCalled(); + expect(previous.scrollIntoView).toHaveBeenCalled(); + }); + + it('takes no key where the table did not ask for row navigation', () => { + const { next, press } = keyed({ rowNav: false }); + + const event = press('nextRow'); + + expect(next.select).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('leaves a key from the tree control alone, which belongs to another row', () => { + // The control carries its own tabIndex, so it can hold focus while a row + // elsewhere is the selected one. + const { current, next, press } = keyed({ dataTree: true }); + + const event = press('nextRow', { control: true }); + press('expandRow', { control: true }); + + expect(next.select).not.toHaveBeenCalled(); + expect(current.treeExpand).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it('opens a closed tree row, and leaves a flat table alone', () => { + const tree = keyed({ dataTree: true }); + tree.press('expandRow'); + expect(tree.current.treeExpand).toHaveBeenCalled(); + + const flat = keyed(); + flat.press('expandRow'); + expect(flat.current.treeExpand).not.toHaveBeenCalled(); + }); + + it('steps into the first child of a row already open', () => { + const { next, press } = keyed({ dataTree: true, expanded: true }); + + press('expandRow'); + + expect(next.select).toHaveBeenCalled(); + }); + + it('steps out to the parent of a closed row', () => { + const { parent, press } = keyed({ dataTree: true }); + + press('collapseRow'); + + expect(parent.select).toHaveBeenCalled(); + }); + + it('closes a row that is open, as the code rather than the user', () => { + const { current, press } = keyed({ dataTree: true, expanded: true }); + + press('collapseRow'); + + expect(current.treeCollapse).toHaveBeenCalled(); + }); +}); From ff7ddf3610363685ed04325f4e1ea2ebf2f59ac2 Mon Sep 17 00:00:00 2001 From: peternhale Date: Wed, 2 Sep 2026 08:59:20 -0600 Subject: [PATCH 19/61] feat(lana): add VS Code web entrypoint (#953) # PR overview Stack 3 of 4. Depends on #952. Adds the VS Code Web extension entrypoint and browser bundles. ## Changes made - Declare the browser entrypoint and virtual-workspace capabilities. - Share activation and disposal behavior between desktop and browser extension hosts. - Add CommonJS browser bundles to Rollup and Rolldown. - Preserve the existing desktop extension entrypoint and bundle. ## Type of change - [x] Feature - [x] Chore ## Related issues related W-23939830 ## Validation - pnpm build - Desktop and browser extension bundles generated successfully --- lana/package.json | 14 +++++++++++--- lana/src/Main.web.ts | 5 +++++ rolldown.config.ts | 16 ++++++++++++++++ rollup.config.mjs | 30 ++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 lana/src/Main.web.ts diff --git a/lana/package.json b/lana/package.json index a2e86a9a2..64e048272 100644 --- a/lana/package.json +++ b/lana/package.json @@ -29,8 +29,8 @@ "profiling", "soql" ], - "type": "module", "main": "out/Main.js", + "browser": "out/web/Main.web.js", "icon": "./certinia-icon-color.png", "galleryBanner": { "color": "#000000", @@ -52,9 +52,16 @@ "categories": [ "Other" ], + "capabilities": { + "virtualWorkspaces": true, + "untrustedWorkspaces": { + "supported": true + } + }, "activationEvents": [ "onLanguage:apexlog", - "onStartupFinished" + "onStartupFinished", + "onFileSystem:memfs" ], "contributes": { "commands": [ @@ -378,7 +385,8 @@ ] }, "scripts": { - "vscode:prepublish": "pnpm -w run build && pnpm -w run copy:package-docs" + "vscode:prepublish": "pnpm -w run build && pnpm -w run copy:package-docs", + "vscode:bundle": "pnpm -w run build" }, "dependencies": { "@apexdevtools/apex-parser": "5.1.0", diff --git a/lana/src/Main.web.ts b/lana/src/Main.web.ts new file mode 100644 index 000000000..effc1281d --- /dev/null +++ b/lana/src/Main.web.ts @@ -0,0 +1,5 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +export { activate, context, deactivate } from './Main.js'; diff --git a/rolldown.config.ts b/rolldown.config.ts index 9e8b916f0..14db24c25 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -35,6 +35,22 @@ export default defineConfig([ external: ['vscode'], }, + { + input: { Main: './lana/src/Main.web.ts' }, + output: { + format: 'cjs', + dir: './lana/out/web', + entryFileNames: 'Main.web.js', + chunkFileNames: 'lana-[name].js', + sourcemap: false, + keepNames: true, + minify: production, + }, + tsconfig: production ? './lana/tsconfig.json' : './lana/tsconfig-dev.json', + platform: 'browser', + external: ['vscode'], + plugins: [nodePolyfills()], + }, { input: { bundle: './log-viewer/src/Main.ts' }, output: [ diff --git a/rollup.config.mjs b/rollup.config.mjs index 10807fc7d..347752b4a 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -60,6 +60,36 @@ export default [ ), ], }, + { + input: './lana/src/Main.web.ts', + output: { + format: 'cjs', + dir: './lana/out/web', + entryFileNames: 'Main.web.js', + chunkFileNames: 'lana-[name].js', + sourcemap: false, + }, + external: ['vscode'], + plugins: [ + nodeResolve({ browser: true, preferBuiltins: false }), + commonjs(), + json(), + nodePolyfills(), + swc( + defineRollupSwcOption({ + include: /\.[mc]?[jt]sx?$/, + exclude: /node_modules/, + tsconfig: production ? './lana/tsconfig.json' : './lana/tsconfig-dev.json', + jsc: { + minify: { + compress: production ? { keep_classnames: true, keep_fnames: true } : false, + mangle: production ? { keep_classnames: true } : false, + }, + }, + }), + ), + ], + }, { input: { bundle: './log-viewer/src/Main.ts' }, // @vscode-elements ships tsc output with the inline `(this && this.__decorate)` helper. From c32d0ff61dbf240fdb8c7524fbf3c6bdf66cfdaf Mon Sep 17 00:00:00 2001 From: peternhale Date: Wed, 2 Sep 2026 09:53:57 -0600 Subject: [PATCH 20/61] test(lana): cover VS Code web host (#954) # PR overview Stack 4 of 4. Depends on #953. Adds automated VS Code Web coverage and local browser-host tooling. ## Changes made - Add Playwright coverage that opens a sample log from Explorer in VS Code Web. - Verify the analysis webview and populated Call Tree render. - Add the local headless VS Code Web server and serve:web workflow. - Run the web E2E suite for pull requests and retain Playwright diagnostics as CI artifacts. - Keep Playwright output isolated from Jest, ESLint, and source control. ## Type of change - [x] Test - [x] Chore ## Related issues related W-23939830 ## Validation - pnpm typecheck - pnpm test:ci - pnpm lint - pnpm build - pnpm test:e2e:web: 1 passed - Fork CI: all six jobs passed --- .github/workflows/ci.yml | 39 +- .gitignore | 2 + eslint.config.mjs | 2 + jest.config.js | 6 +- lana/test/playwright/playwright.config.web.ts | 11 + .../playwright/specs/logAnalysis.web.spec.ts | 18 + lana/test/playwright/support/logAnalysis.ts | 28 + lana/test/playwright/support/logWorkspace.ts | 14 + lana/test/playwright/support/paths.ts | 6 + lana/test/playwright/tsconfig.json | 7 + lana/test/playwright/web/headlessServer.ts | 35 + package.json | 6 + pnpm-lock.yaml | 986 +++++++++++++++++- tsconfig.json | 7 +- 14 files changed, 1156 insertions(+), 11 deletions(-) create mode 100644 lana/test/playwright/playwright.config.web.ts create mode 100644 lana/test/playwright/specs/logAnalysis.web.spec.ts create mode 100644 lana/test/playwright/support/logAnalysis.ts create mode 100644 lana/test/playwright/support/logWorkspace.ts create mode 100644 lana/test/playwright/support/paths.ts create mode 100644 lana/test/playwright/tsconfig.json create mode 100644 lana/test/playwright/web/headlessServer.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c57a3b386..e8bd50442 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main, release/**] pull_request: - branches: [main, release/**] workflow_dispatch: # Least-privilege default: CI only needs to read the repository. @@ -58,6 +57,44 @@ jobs: - name: Tests run: pnpm exec jest --selectProjects ${{ matrix.project }} --runInBand + e2e: + name: Test (web e2e) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6.0.9 + with: + version: 10 + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: '24' + cache: 'pnpm' + - name: Install Packages + run: pnpm run ci:install + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + - name: Tests + run: pnpm run test:e2e:web + - name: Upload Playwright HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-web + path: lana/playwright-report/web + if-no-files-found: ignore + retention-days: 14 + - name: Upload Playwright test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results-web + path: lana/test-results/web + if-no-files-found: ignore + retention-days: 14 + build: name: Verify VSCode Package Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index cf6bcf740..626818bb5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ *.tsbuildinfo /.vscode-test-web scripts/measure/out/ +/lana/test-results +/lana/playwright-report diff --git a/eslint.config.mjs b/eslint.config.mjs index 3b58c42cb..4d79900c7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,8 @@ export default defineConfig( '**/out/', '**/coverage/', '**/.docusaurus/', + '**/.vscode-test-web/', + '**/playwright-report/', // only TypeScript is linted; without this, `eslint .` selects js/mjs/cjs // by default and scans them with no rules '**/*.js', diff --git a/jest.config.js b/jest.config.js index 82d65196a..434bb33ba 100644 --- a/jest.config.js +++ b/jest.config.js @@ -18,7 +18,11 @@ const defaultConfig = { }, ], }, - testPathIgnorePatterns: ['/node_modules/', '/out/'], + testPathIgnorePatterns: [ + '/node_modules/', + '/out/', + '/test/playwright/', + ], testMatch: ['**/?(*.)+(spec|test).ts'], extensionsToTreatAsEsm: ['.ts', '.tsx'], }; diff --git a/lana/test/playwright/playwright.config.web.ts b/lana/test/playwright/playwright.config.web.ts new file mode 100644 index 000000000..838c88f98 --- /dev/null +++ b/lana/test/playwright/playwright.config.web.ts @@ -0,0 +1,11 @@ +import { createWebConfig } from '@salesforce/playwright-vscode-ext'; +import type { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + ...createWebConfig({ testDir: './specs', workers: 1, fullyParallel: false }), + outputDir: '../../test-results/web', + reporter: [['html', { open: 'never', outputFolder: '../../playwright-report/web' }]], + testMatch: '**/*.web.spec.ts', +}; + +export default config; diff --git a/lana/test/playwright/specs/logAnalysis.web.spec.ts b/lana/test/playwright/specs/logAnalysis.web.spec.ts new file mode 100644 index 000000000..3a23117ff --- /dev/null +++ b/lana/test/playwright/specs/logAnalysis.web.spec.ts @@ -0,0 +1,18 @@ +import { test } from '@playwright/test'; +import { + closeWelcomeTabs, + waitForExtensionsActivated, + waitForVSCodeWorkbench, + waitForWorkspaceReady, +} from '@salesforce/playwright-vscode-ext'; + +import { assertLogAnalysisRenders, openLogAnalysis } from '../support/logAnalysis'; + +test('opens a sample log and renders its analysis in VS Code Web', async ({ page }) => { + await waitForVSCodeWorkbench(page); + await waitForWorkspaceReady(page); + await closeWelcomeTabs(page); + await waitForExtensionsActivated(page); + await openLogAnalysis(page); + await assertLogAnalysisRenders(page); +}); diff --git a/lana/test/playwright/support/logAnalysis.ts b/lana/test/playwright/support/logAnalysis.ts new file mode 100644 index 000000000..ca94f33d5 --- /dev/null +++ b/lana/test/playwright/support/logAnalysis.ts @@ -0,0 +1,28 @@ +import { expect, type Page } from '@playwright/test'; +import { + executeCommandWithCommandPalette, + hasContent, + openFileFromExplorerTree, + webviewActiveFrame, +} from '@salesforce/playwright-vscode-ext'; + +import { SAMPLE_LOG_NAME } from './logWorkspace'; + +export const assertLogAnalysisRenders = async (page: Page): Promise => { + const analysis = await webviewActiveFrame(page, hasContent('log-viewer'), { + timeout: 60_000, + }); + + const flameChart = analysis.locator('timeline-flame-chart'); + await expect(flameChart).toBeVisible({ timeout: 120_000 }); + + await analysis.locator('vscode-tab-header').filter({ hasText: 'Call Tree' }).click(); + const callTree = analysis.locator('call-tree-view'); + await expect(callTree).toBeVisible(); + await expect(callTree.locator('.tabulator-row').first()).toBeVisible({ timeout: 120_000 }); +}; + +export const openLogAnalysis = async (page: Page): Promise => { + await openFileFromExplorerTree(page, SAMPLE_LOG_NAME); + await executeCommandWithCommandPalette(page, 'Log: Show Apex Log Analysis'); +}; diff --git a/lana/test/playwright/support/logWorkspace.ts b/lana/test/playwright/support/logWorkspace.ts new file mode 100644 index 000000000..998210bb7 --- /dev/null +++ b/lana/test/playwright/support/logWorkspace.ts @@ -0,0 +1,14 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { createTestWorkspace } from '@salesforce/playwright-vscode-ext'; + +import { sampleLogPath } from './paths'; + +export const SAMPLE_LOG_NAME = 'sample-log.log'; + +export const createLogWorkspace = async (): Promise => { + const workspaceDir = await createTestWorkspace(); + await fs.copyFile(sampleLogPath, path.join(workspaceDir, SAMPLE_LOG_NAME)); + return workspaceDir; +}; diff --git a/lana/test/playwright/support/paths.ts b/lana/test/playwright/support/paths.ts new file mode 100644 index 000000000..0512c248c --- /dev/null +++ b/lana/test/playwright/support/paths.ts @@ -0,0 +1,6 @@ +import path from 'node:path'; + +export const repoRoot = path.resolve(__dirname, '../../../..'); +export const extensionRoot = path.join(repoRoot, 'lana'); +export const sampleLogPath = path.join(repoRoot, 'sample-app', 'debug-logs', 'sample-log.log'); +export const vscodeWebTestPath = path.join(repoRoot, '.vscode-test-web'); diff --git a/lana/test/playwright/tsconfig.json b/lana/test/playwright/tsconfig.json new file mode 100644 index 000000000..5df1edcd7 --- /dev/null +++ b/lana/test/playwright/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["./**/*.ts"] +} diff --git a/lana/test/playwright/web/headlessServer.ts b/lana/test/playwright/web/headlessServer.ts new file mode 100644 index 000000000..2b5797e78 --- /dev/null +++ b/lana/test/playwright/web/headlessServer.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises'; + +import { open } from '@vscode/test-web'; + +import { createLogWorkspace } from '../support/logWorkspace'; +import { extensionRoot, vscodeWebTestPath } from '../support/paths'; + +// Lana declares Salesforce Services as an extension dependency, but VS Code Web +// needs the test server to explicitly provision it for a development extension. +const SERVICES_EXTENSION_ID = 'salesforce.salesforcedx-vscode-services'; + +const start = async (): Promise => { + const workspaceDir = await createLogWorkspace(); + const server = await open({ + browserType: 'none', + quality: 'stable', + commit: process.env.PLAYWRIGHT_WEB_VSCODE_COMMIT, + port: Number(process.env.PORT) || 3001, + printServerLog: true, + verbose: true, + extensionDevelopmentPath: extensionRoot, + extensionIds: [{ id: SERVICES_EXTENSION_ID }], + folderPath: workspaceDir, + testRunnerDataDir: vscodeWebTestPath, + }); + + const shutdown = (): void => { + server.dispose(); + void fs.rm(workspaceDir, { recursive: true, force: true }).finally(() => process.exit(0)); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +}; + +void start(); diff --git a/package.json b/package.json index 0e6f17765..48d4bf7e1 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,19 @@ "private": true, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.60.0", "@prettier/plugin-oxc": "^0.2.2", "@rolldown/plugin-node-polyfills": "^1.0.3", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "^29.0.3", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", + "@salesforce/playwright-vscode-ext": "^1.3.10", "@swc/core": "^1.15.47", "@swc/helpers": "^0.5.23", "@swc/jest": "^0.2.39", "@types/jest": "^30.0.0", + "@vscode/test-web": "^0.0.81", "concurrently": "^10.0.4", "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", @@ -26,6 +29,7 @@ "rollup-plugin-copy": "^3.5.0", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-swc3": "^0.12.1", + "tsx": "^4.21.0", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-7": "npm:typescript@^7.0.2", "typescript-eslint": "^8.65.0" @@ -47,6 +51,8 @@ "lint": "concurrently -r -g 'eslint . --cache --cache-location node_modules/.cache/eslint/' 'prettier --cache **/*.{ts,css,md,mdx,scss} --check --experimental-cli' 'pnpm run typecheck'", "test": "jest", "test:ci": "jest --runInBand", + "test:e2e:web": "pnpm build && playwright test --config=lana/test/playwright/playwright.config.web.ts", + "serve:web": "pnpm build && tsx lana/test/playwright/web/headlessServer.ts", "ci:install": "pnpm install --frozen-lockfile --prefer-offline --ignore-scripts", "prettier-format": "prettier '**/*.ts' --cache --write --experimental-cli" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 379692a4a..9caa65a41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@1.21.7)) + '@playwright/test': + specifier: ^1.60.0 + version: 1.62.1 '@prettier/plugin-oxc': specifier: ^0.2.2 version: 0.2.2 @@ -49,6 +52,9 @@ importers: '@rollup/plugin-node-resolve': specifier: ^16.0.3 version: 16.0.3(rollup@4.62.3) + '@salesforce/playwright-vscode-ext': + specifier: ^1.3.10 + version: 1.3.11 '@swc/core': specifier: ^1.15.47 version: 1.15.47(@swc/helpers@0.5.23) @@ -61,6 +67,9 @@ importers: '@types/jest': specifier: ^30.0.0 version: 30.0.0 + '@vscode/test-web': + specifier: ^0.0.81 + version: 0.0.81 concurrently: specifier: ^10.0.4 version: 10.0.4 @@ -100,6 +109,9 @@ importers: rollup-plugin-swc3: specifier: ^0.12.1 version: 0.12.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(rollup@4.62.3) + tsx: + specifier: ^4.21.0 + version: 4.23.12 typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' @@ -1617,6 +1629,162 @@ packages: '@emnapi/wasi-threads@2.0.1': resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1957,6 +2125,16 @@ packages: peerDependencies: tslib: '2' + '@koa/cors@5.0.0': + resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==} + engines: {node: '>= 14.0.0'} + + '@koa/router@15.7.0': + resolution: {integrity: sha512-WaAlk4TOl/O0rhTpOR0l052gz03syPMmI6Pe2gd7v3ubjfv5UcSGcnb0Y/J5NNC/ln+5FiUqPJTc/a15I+XqAA==} + engines: {node: '>= 20'} + peerDependencies: + koa: ^2.0.0 || ^3.0.0 + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -2539,6 +2717,15 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@playwright/browser-chromium@1.62.1': + resolution: {integrity: sha512-DU/t4TSqHvAc+uFMt972forQYqBTh/ul7lZ8U81HYGyxnf6vSPPz9NzuE0OR3x/elqvvGm+n4gcny+QSKT+FDw==} + engines: {node: '>=20'} + + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -2907,6 +3094,9 @@ packages: resolution: {integrity: sha512-VwxhSH/8PQ5YmPAdhmLaJGCgR0n/pOm8T2y+/odMFthd5SA0xsO/kTdaRLFnFQAxxNj1nYtNHRb+BoMuAatzew==} engines: {node: '>=22.0.0'} + '@salesforce/playwright-vscode-ext@1.3.11': + resolution: {integrity: sha512-3gqNjZY6nvzkfos01g0n7d2TfxSxPURv+yw7zq79IZV0bq3KopakSgkBsoWMGyAiUFdvfoe6+Nx3m10vVtGwQA==} + '@salesforce/source-deploy-retrieve@13.2.0': resolution: {integrity: sha512-4R3Sd6it/oX8IRm5JGvi6fba0318VVHz/bgsDFEchHo8ed73LU021nWJJ5hBcveHdW+MUJioO9rU7MCOYLh1uA==} engines: {node: '>=22.0.0'} @@ -3730,6 +3920,15 @@ packages: '@vscode/codicons@0.0.45': resolution: {integrity: sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==} + '@vscode/test-electron@3.1.0': + resolution: {integrity: sha512-CRqv5u+YYoseuNVJ6Tyo4k0sF0mx4qnKMihRB0PjsUF8Dc0WKtCXo6CNL6nWWm5esfFQsQA/pejMj4ZbpJVLTw==} + engines: {node: '>=22'} + + '@vscode/test-web@0.0.81': + resolution: {integrity: sha512-qAYNX1mf4hE0L3T/186J8AH+Z7Inm81OHMACkkyKE2J6HJZlXou0OgABkSvd8gt0BiPjI+V+xkduAaQ8Kcjexg==} + engines: {node: '>=20'} + hasBin: true + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -3827,6 +4026,10 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -3963,6 +4166,14 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-jest@30.4.1: resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4028,6 +4239,43 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.0: + resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.2: + resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4045,6 +4293,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + basic-ftp@5.3.1: resolution: {integrity: sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==} engines: {node: '>=10.0.0'} @@ -4091,6 +4343,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserify-zlib@0.1.4: + resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -4277,6 +4532,10 @@ packages: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} @@ -4416,6 +4675,10 @@ packages: resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} engines: {node: '>= 0.6'} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -4439,6 +4702,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookies@0.9.1: + resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} + engines: {node: '>= 0.8'} + copy-text-to-clipboard@3.2.2: resolution: {integrity: sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==} engines: {node: '>=12'} @@ -4637,6 +4904,14 @@ packages: supports-color: optional: true + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4664,6 +4939,9 @@ packages: babel-plugin-macros: optional: true + deep-equal@1.0.1: + resolution: {integrity: sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -4714,6 +4992,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -4726,6 +5007,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -4793,6 +5078,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -4894,6 +5182,11 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5036,6 +5329,9 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -5073,6 +5369,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -5215,6 +5514,10 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -5234,6 +5537,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5347,6 +5655,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gunzip-maybe@1.4.2: + resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} + hasBin: true + gzip-size@6.0.0: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} @@ -5459,9 +5771,17 @@ packages: htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-assert@1.5.0: + resolution: {integrity: sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w==} + engines: {node: '>= 0.8'} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@1.6.3: + resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} + engines: {node: '>= 0.6'} + http-errors@1.8.1: resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} engines: {node: '>= 0.6'} @@ -5477,6 +5797,10 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + http-proxy-middleware@4.2.0: resolution: {integrity: sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==} engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0} @@ -5493,6 +5817,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + httpxy@0.5.5: resolution: {integrity: sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==} @@ -5577,6 +5905,9 @@ packages: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -5637,6 +5968,9 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-deflate@1.0.0: + resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -5667,6 +6001,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-gzip@1.0.0: + resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} + engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} @@ -5687,6 +6025,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} @@ -5754,6 +6096,14 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-unsafe@2.0.2: resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==} @@ -6054,6 +6404,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keygrip@1.1.0: + resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} + engines: {node: '>= 0.6'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6068,6 +6422,28 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + koa-compose@4.1.0: + resolution: {integrity: sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw==} + + koa-morgan@1.0.1: + resolution: {integrity: sha512-JOUdCNlc21G50afBXfErUrr1RKymbgzlrO5KURY+wmDG1Uvd2jmxUJcHgylb/mYXy2SjiNZyYim/ptUBGsIi3A==} + + koa-mount@4.2.0: + resolution: {integrity: sha512-2iHQc7vbA9qLeVq5gKAYh3m5DOMMlMfIKjW/REPAS18Mf63daCJHHVXY9nbu7ivrnYn5PiPC4CE523Tf5qvjeQ==} + engines: {node: '>= 7.6.0'} + + koa-send@5.0.1: + resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==} + engines: {node: '>= 8'} + + koa-static@5.0.0: + resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==} + engines: {node: '>= 7.6.0'} + + koa@3.2.1: + resolution: {integrity: sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==} + engines: {node: '>= 18'} + latest-version@7.0.0: resolution: {integrity: sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==} engines: {node: '>=14.16'} @@ -6230,6 +6606,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6532,6 +6912,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -6614,6 +6998,10 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + morgan@1.11.0: + resolution: {integrity: sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==} + engines: {node: '>= 0.8.0'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -6766,6 +7154,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -6790,6 +7182,10 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + oxc-parser@0.139.0: resolution: {integrity: sha512-cf1TKZN+zc0lwqigeyXKzKVk5+vNRe99Or2+wVJsXLdlhJgC+gsIniYDfj/ZEBzCJ8Xm21ZG6YtMbR262CcS2w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6865,6 +7261,9 @@ packages: resolution: {integrity: sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==} engines: {node: '>=14.16'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -6950,6 +7349,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7001,6 +7403,16 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -7462,6 +7874,15 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + proxy-agent@6.5.0: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} @@ -7469,9 +7890,15 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pumpify@1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7703,6 +8130,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-path@1.4.0: + resolution: {integrity: sha512-i1xevIst/Qa+nA9olDxLWnLk8YZbi8R/7JPbCMcgyWaFR6bKWaexgJgEB5oc2PKMjYdrHynyz0NY+if+H98t1w==} + engines: {node: '>= 0.8'} + resolve-pathname@3.0.0: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} @@ -7725,6 +8156,10 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -7852,11 +8287,6 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} - engines: {node: '>=10'} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -7895,6 +8325,9 @@ packages: setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.1.0: + resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -8048,6 +8481,16 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -8168,6 +8611,15 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + terser-webpack-plugin@5.6.1: resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} engines: {node: '>= 10.13.0'} @@ -8225,6 +8677,9 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thingies@2.6.1: resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==} engines: {node: '>=10.18'} @@ -8234,6 +8689,9 @@ packages: thread-stream@3.2.0: resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -8339,6 +8797,15 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsscmp@1.0.6: + resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} + engines: {node: '>=0.6.x'} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + tsyringe@4.10.0: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} @@ -8742,6 +9209,10 @@ packages: xmlcreate@2.0.4: resolution: {integrity: sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -11382,6 +11853,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@10.8.0(jiti@1.21.7))': dependencies: eslint: 10.8.0(jiti@1.21.7) @@ -11848,6 +12397,20 @@ snapshots: '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) tslib: 2.8.1 + '@koa/cors@5.0.0': + dependencies: + vary: 1.1.2 + + '@koa/router@15.7.0(koa@3.2.1)': + dependencies: + debug: 4.4.3 + http-errors: 2.0.1 + koa: 3.2.1 + koa-compose: 4.1.0 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + '@leichtgewicht/ip-codec@2.0.5': {} '@lit-labs/ssr-dom-shim@1.6.0': {} @@ -12420,6 +12983,14 @@ snapshots: '@pkgr/core@0.3.6': {} + '@playwright/browser-chromium@1.62.1': + dependencies: + playwright-core: 1.62.1 + + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -12717,6 +13288,18 @@ snapshots: dependencies: '@salesforce/ts-types': 3.0.1 + '@salesforce/playwright-vscode-ext@1.3.11': + dependencies: + '@playwright/test': 1.62.1 + '@vscode/test-electron': 3.1.0 + '@vscode/test-web': 0.0.81 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - kerberos + - react-native-b4a + - supports-color + '@salesforce/source-deploy-retrieve@13.2.0': dependencies: '@salesforce/core': 9.1.4 @@ -13522,6 +14105,40 @@ snapshots: '@vscode/codicons@0.0.45': {} + '@vscode/test-electron@3.1.0': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + jszip: 3.10.1 + ora: 8.2.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@vscode/test-web@0.0.81': + dependencies: + '@koa/cors': 5.0.0 + '@koa/router': 15.7.0(koa@3.2.1) + '@playwright/browser-chromium': 1.62.1 + gunzip-maybe: 1.4.2 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + koa: 3.2.1 + koa-morgan: 1.0.1 + koa-mount: 4.2.0 + koa-static: 5.0.0 + minimist: 1.2.8 + playwright: 1.62.1 + tar-fs: 3.1.3 + tinyglobby: 0.2.17 + vscode-uri: 3.1.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - kerberos + - react-native-b4a + - supports-color + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -13640,6 +14257,8 @@ snapshots: agent-base@7.1.4: {} + agent-base@9.0.0: {} + aggregate-error@3.1.0: dependencies: clean-stack: 2.2.0 @@ -13777,6 +14396,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + b4a@1.8.1: {} + babel-jest@30.4.1(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -13878,6 +14499,35 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.9.1: {} + + bare-fs@4.8.0: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.5.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.2: + dependencies: + bare-path: 3.1.1 + base64-js@1.5.1: {} base64url@3.0.1: {} @@ -13886,6 +14536,10 @@ snapshots: baseline-browser-mapping@2.11.7: {} + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + basic-ftp@5.3.1: {} batch@0.6.1: {} @@ -13955,6 +14609,10 @@ snapshots: dependencies: fill-range: 7.1.1 + browserify-zlib@0.1.4: + dependencies: + pako: 0.2.9 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.31 @@ -14177,6 +14835,10 @@ snapshots: dependencies: restore-cursor: 3.1.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} cli-table3@0.6.5: @@ -14307,6 +14969,8 @@ snapshots: content-disposition@0.5.2: {} + content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -14319,6 +14983,11 @@ snapshots: cookie@0.7.2: {} + cookies@0.9.1: + dependencies: + depd: 2.0.0 + keygrip: 1.1.0 + copy-text-to-clipboard@3.2.2: {} copy-webpack-plugin@11.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): @@ -14529,6 +15198,10 @@ snapshots: dependencies: ms: 2.0.0 + debug@3.2.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -14545,6 +15218,8 @@ snapshots: dedent@1.7.2: {} + deep-equal@1.0.1: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -14588,12 +15263,16 @@ snapshots: delayed-stream@1.0.0: {} + delegates@1.0.0: {} + depd@1.1.2: {} depd@2.0.0: {} dequal@2.0.3: {} + destroy@1.2.0: {} + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -14671,6 +15350,13 @@ snapshots: duplexer@0.1.2: {} + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + earcut@3.0.2: {} eastasianwidth@0.2.0: {} @@ -14763,6 +15449,35 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.3 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-goat@4.0.0: {} @@ -14918,6 +15633,12 @@ snapshots: eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} execa@5.1.1: @@ -14990,6 +15711,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -15150,6 +15873,8 @@ snapshots: fraction.js@5.3.4: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-extra@10.1.0: @@ -15172,6 +15897,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -15325,6 +16053,15 @@ snapshots: graceful-fs@4.2.11: {} + gunzip-maybe@1.4.2: + dependencies: + browserify-zlib: 0.1.4 + is-deflate: 1.0.0 + is-gzip: 1.0.0 + peek-stream: 1.1.3 + pumpify: 1.5.1 + through2: 2.0.5 + gzip-size@6.0.0: dependencies: duplexer: 0.1.2 @@ -15525,8 +16262,20 @@ snapshots: domutils: 3.2.2 entities: 4.5.0 + http-assert@1.5.0: + dependencies: + deep-equal: 1.0.1 + http-errors: 1.8.1 + http-cache-semantics@4.2.0: {} + http-errors@1.6.3: + dependencies: + depd: 1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.0 + statuses: 1.5.0 + http-errors@1.8.1: dependencies: depd: 1.1.2 @@ -15552,6 +16301,15 @@ snapshots: transitivePeerDependencies: - supports-color + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + http-proxy-middleware@4.2.0: dependencies: debug: 4.4.3 @@ -15579,6 +16337,15 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + httpxy@0.5.5: {} human-signals@2.1.0: {} @@ -15636,6 +16403,8 @@ snapshots: once: 1.4.0 wrappy: 1.0.2 + inherits@2.0.3: {} + inherits@2.0.4: {} ini@1.3.8: {} @@ -15699,6 +16468,8 @@ snapshots: is-decimal@2.0.1: {} + is-deflate@1.0.0: {} + is-docker@2.2.1: {} is-docker@3.0.0: {} @@ -15715,6 +16486,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-gzip@1.0.0: {} + is-hexadecimal@2.0.1: {} is-in-ssh@1.0.0: {} @@ -15730,6 +16503,8 @@ snapshots: is-interactive@1.0.0: {} + is-interactive@2.0.0: {} + is-module@1.0.0: {} is-network-error@1.3.2: {} @@ -15772,6 +16547,10 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-unsafe@2.0.2: {} is-wsl@2.2.0: @@ -16113,7 +16892,7 @@ snapshots: jest-message-util: 30.4.1 jest-util: 30.4.1 pretty-format: 30.4.1 - semver: 7.8.1 + semver: 7.8.5 synckit: 0.11.13 transitivePeerDependencies: - supports-color @@ -16323,6 +17102,10 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + keygrip@1.1.0: + dependencies: + tsscmp: 1.0.6 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -16335,6 +17118,57 @@ snapshots: kleur@3.0.3: {} + koa-compose@4.1.0: {} + + koa-morgan@1.0.1: + dependencies: + morgan: 1.11.0 + transitivePeerDependencies: + - supports-color + + koa-mount@4.2.0: + dependencies: + debug: 4.4.3 + koa-compose: 4.1.0 + transitivePeerDependencies: + - supports-color + + koa-send@5.0.1: + dependencies: + debug: 4.4.3 + http-errors: 1.8.1 + resolve-path: 1.4.0 + transitivePeerDependencies: + - supports-color + + koa-static@5.0.0: + dependencies: + debug: 3.2.7 + koa-send: 5.0.1 + transitivePeerDependencies: + - supports-color + + koa@3.2.1: + dependencies: + accepts: 1.3.8 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookies: 0.9.1 + delegates: 1.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + fresh: 0.5.2 + http-assert: 1.5.0 + http-errors: 2.0.1 + koa-compose: 4.1.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + latest-version@7.0.0: dependencies: package-json: 8.1.1 @@ -16477,6 +17311,11 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -17071,6 +17910,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -17141,6 +17982,16 @@ snapshots: minipass@7.1.3: {} + morgan@1.11.0: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.4.1 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + mrmime@2.0.1: {} ms@2.0.0: {} @@ -17274,6 +18125,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -17317,6 +18172,18 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + oxc-parser@0.139.0: dependencies: '@oxc-project/types': 0.139.0 @@ -17418,6 +18285,8 @@ snapshots: registry-url: 6.0.1 semver: 7.8.5 + pako@0.2.9: {} + pako@1.0.11: {} param-case@3.0.4: @@ -17504,6 +18373,12 @@ snapshots: path-type@4.0.0: {} + peek-stream@1.1.3: + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -17586,6 +18461,14 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-attribute-case-insensitive@7.0.1(postcss@8.5.25): @@ -18091,6 +18974,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent-negotiate@1.1.0: {} + proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 @@ -18106,11 +18991,22 @@ snapshots: proxy-from-env@1.1.0: {} + pump@2.0.1: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + pumpify@1.5.1: + dependencies: + duplexify: 3.7.1 + inherits: 2.0.4 + pump: 2.0.1 + punycode@2.3.1: {} pupa@3.3.0: @@ -18410,6 +19306,11 @@ snapshots: resolve-from@5.0.0: {} + resolve-path@1.4.0: + dependencies: + http-errors: 1.6.3 + path-is-absolute: 1.0.1 + resolve-pathname@3.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -18434,6 +19335,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.12.0: {} reusify@1.1.0: {} @@ -18608,8 +19514,6 @@ snapshots: semver@6.3.1: {} - semver@7.8.1: {} - semver@7.8.5: {} send@1.2.1: @@ -18680,6 +19584,8 @@ snapshots: setimmediate@1.0.5: {} + setprototypeof@1.1.0: {} + setprototypeof@1.2.0: {} sha.js@2.4.12: @@ -18827,6 +19733,19 @@ snapshots: std-env@3.10.0: {} + stdin-discarder@0.2.2: {} + + stream-shift@1.0.3: {} + + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-argv@0.3.2: {} string-length@4.0.2: @@ -18947,6 +19866,36 @@ snapshots: tapable@2.3.3: {} + tar-fs@3.1.3: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.8.0 + bare-path: 3.1.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.0 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -18981,6 +19930,12 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thingies@2.6.1(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -18989,6 +19944,11 @@ snapshots: dependencies: real-require: 0.2.0 + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + through@2.3.8: {} thunky@1.1.0: {} @@ -19068,6 +20028,14 @@ snapshots: tslib@2.8.1: {} + tsscmp@1.0.6: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tsyringe@4.10.0: dependencies: tslib: 1.14.1 @@ -19641,6 +20609,8 @@ snapshots: xmlcreate@2.0.4: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/tsconfig.json b/tsconfig.json index 32be5fba7..9003ae994 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,9 @@ { "files": [], - "references": [{ "path": "./apex-log-parser" }, { "path": "./log-viewer" }, { "path": "./lana" }] + "references": [ + { "path": "./apex-log-parser" }, + { "path": "./log-viewer" }, + { "path": "./lana" }, + { "path": "./lana/test/playwright" } + ] } From ac87cb2bbdbaad05f6acd9e7cd1c91030d078e54 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:00:12 +0100 Subject: [PATCH 21/61] test(lana): fix the web e2e log fixture (#1000) # PR overview Follow-up to #954. Fix web e2e + trims the job. - Replace the e2e workspace log with a 28 line fixture, exempt from LFS. - Upload the Playwright report and traces only when the e2e fails, and keep them 7 days not 14. ## Why the e2e failed `.gitattributes` keeps every `*.log` in LFS. The `e2e` job checks out without `lfs: true`, so `createLogWorkspace()` copied a 130 byte pointer into the test workspace and not text e.g ``` version https://git-lfs.github.com/spec/v1 oid sha256:f5fbbb9b17e9b614ac08e0e1bfd48aeb227e6c5cfba197b882e5d34a939a3bd3 size 19739334 ``` That is not an Apex log, so `lana.isApexLog` stayed false and `Log: Show Apex Log Analysis` never appeared in the command palette. All three attempts failed the same way. A fixture is better than `lfs: true`: no LFS bandwidth on every run, and the job gets faster. `sample-app/debug-logs/sample-log.log` is unchanged and still available for manual work. ## The fixture A slice of `sample-log.log`: header, `USER_INFO`, `EXECUTION_STARTED`, `CODE_UNIT_STARTED` and the `AccountService.getRevenue()` subtree. 19,739,334 bytes to 2,418. It parses into a 14 node tree over 5 levels with no log issues, so the Call Tree assertion has plenty of rows: ``` LOG_ROOT EXECUTION_STARTED execute_anonymous_apex AccountService.AccountService() AccountService.createAccountsAndContacts() AccountService.getRevenue() AccountService.getDayValue(Date) System.Math.mod(Integer, Integer) ... ``` Drop `120_000` timeouts to `30_000` ## Type of change - [x] Bug fix - [x] Test - [x] Chore --- .gitattributes | 3 +++ .github/workflows/ci.yml | 8 +++--- lana/test/playwright/fixtures/apex-log.log | 28 ++++++++++++++++++++ lana/test/playwright/support/logAnalysis.ts | 8 +++--- lana/test/playwright/support/logWorkspace.ts | 6 ++--- lana/test/playwright/support/paths.ts | 8 +++++- 6 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 lana/test/playwright/fixtures/apex-log.log diff --git a/.gitattributes b/.gitattributes index 9d4b5349c..9bf1e5b69 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ *.log filter=lfs diff=lfs merge=lfs -text + +# e2e fixture: small enough to keep in git, and LFS pointers are not Apex logs +lana/test/playwright/fixtures/*.log -filter diff merge text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8bd50442..eabd9e3f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,21 +79,21 @@ jobs: - name: Tests run: pnpm run test:e2e:web - name: Upload Playwright HTML report - if: always() + if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report-web path: lana/playwright-report/web if-no-files-found: ignore - retention-days: 14 + retention-days: 7 - name: Upload Playwright test results - if: always() + if: failure() uses: actions/upload-artifact@v4 with: name: playwright-test-results-web path: lana/test-results/web if-no-files-found: ignore - retention-days: 14 + retention-days: 7 build: name: Verify VSCode Package Build diff --git a/lana/test/playwright/fixtures/apex-log.log b/lana/test/playwright/fixtures/apex-log.log new file mode 100644 index 000000000..700f59e32 --- /dev/null +++ b/lana/test/playwright/fixtures/apex-log.log @@ -0,0 +1,28 @@ +64.0 APEX_CODE,FINE;APEX_PROFILING,FINE;CALLOUT,FINEST;DATA_ACCESS,INFO;DB,FINEST;NBA,FINE;SYSTEM,FINE;VALIDATION,INFO;VISUALFORCE,FINE;WAVE,FINE;WORKFLOW,FINE +Execute Anonymous: AccountService.createAccountsAndContacts(); +10:29:24.6 (6297619)|USER_INFO|[EXTERNAL]|005Ea00000R6orz|first-last@example.com|(GMT-07:00) Pacific Daylight Time (America/Los_Angeles)|GMT-07:00 +10:29:24.6 (6329577)|EXECUTION_STARTED +10:29:24.6 (6337821)|CODE_UNIT_STARTED|[EXTERNAL]|execute_anonymous_apex +10:29:24.6 (6957638)|SYSTEM_MODE_ENTER|false +10:29:24.6 (84434204)|METHOD_ENTRY|[5]|01pEa00000CgcGT|AccountService.AccountService() +10:29:24.6 (84539933)|SYSTEM_METHOD_ENTRY|[7]|com.salesforce.api.interop.apex.bcl.DateMethods.newInstance(Integer, Integer, Integer) +10:29:24.6 (84645761)|SYSTEM_METHOD_EXIT|[7]|com.salesforce.api.interop.apex.bcl.DateMethods.newInstance(Integer, Integer, Integer) +10:29:24.6 (84669076)|METHOD_EXIT|[5]|AccountService +10:29:24.6 (84707278)|METHOD_ENTRY|[1]|01pEa00000CgcGT|AccountService.createAccountsAndContacts() +10:29:24.6 (84756747)|METHOD_ENTRY|[11]|01pEa00000CgcGT|AccountService.getRevenue() +10:29:24.6 (84822141)|SYSTEM_METHOD_ENTRY|[120]|com.salesforce.api.interop.apex.bcl.DateMethods.today() +10:29:24.6 (84859046)|SYSTEM_METHOD_EXIT|[120]|com.salesforce.api.interop.apex.bcl.DateMethods.today() +10:29:24.6 (84869926)|METHOD_ENTRY|[120]|01pEa00000CgcGT|AccountService.getDayValue(Date) +10:29:24.6 (84890620)|SYSTEM_METHOD_ENTRY|[142]|com.salesforce.api.interop.apex.bcl.DateMethods.daysBetween(Date) +10:29:24.6 (84904980)|SYSTEM_METHOD_EXIT|[142]|com.salesforce.api.interop.apex.bcl.DateMethods.daysBetween(Date) +10:29:24.6 (84949994)|SYSTEM_METHOD_ENTRY|[1]|Math.Math() +10:29:24.6 (84998791)|SYSTEM_METHOD_EXIT|[1]|Math +10:29:24.6 (85010745)|METHOD_ENTRY|[143]||System.Math.mod(Integer, Integer) +10:29:24.6 (86075910)|METHOD_EXIT|[143]||System.Math.mod(Integer, Integer) +10:29:24.6 (86151658)|METHOD_EXIT|[120]|01pEa00000CgcGT|AccountService.getDayValue(Date) +10:29:24.6 (86198738)|SYSTEM_METHOD_ENTRY|[120]|Decimal.multiply(Decimal) +10:29:24.6 (86225666)|SYSTEM_METHOD_EXIT|[120]|Decimal.multiply(Decimal) +10:29:24.6 (86232986)|METHOD_EXIT|[11]|01pEa00000CgcGT|AccountService.getRevenue() +10:29:24.6 (86300000)|METHOD_EXIT|[1]|01pEa00000CgcGT|AccountService.createAccountsAndContacts() +10:29:24.6 (86400000)|CODE_UNIT_FINISHED|execute_anonymous_apex +10:29:24.6 (86500000)|EXECUTION_FINISHED diff --git a/lana/test/playwright/support/logAnalysis.ts b/lana/test/playwright/support/logAnalysis.ts index ca94f33d5..dd397e5d6 100644 --- a/lana/test/playwright/support/logAnalysis.ts +++ b/lana/test/playwright/support/logAnalysis.ts @@ -6,7 +6,7 @@ import { webviewActiveFrame, } from '@salesforce/playwright-vscode-ext'; -import { SAMPLE_LOG_NAME } from './logWorkspace'; +import { LOG_FILE_NAME } from './logWorkspace'; export const assertLogAnalysisRenders = async (page: Page): Promise => { const analysis = await webviewActiveFrame(page, hasContent('log-viewer'), { @@ -14,15 +14,15 @@ export const assertLogAnalysisRenders = async (page: Page): Promise => { }); const flameChart = analysis.locator('timeline-flame-chart'); - await expect(flameChart).toBeVisible({ timeout: 120_000 }); + await expect(flameChart).toBeVisible({ timeout: 30_000 }); await analysis.locator('vscode-tab-header').filter({ hasText: 'Call Tree' }).click(); const callTree = analysis.locator('call-tree-view'); await expect(callTree).toBeVisible(); - await expect(callTree.locator('.tabulator-row').first()).toBeVisible({ timeout: 120_000 }); + await expect(callTree.locator('.tabulator-row').first()).toBeVisible({ timeout: 30_000 }); }; export const openLogAnalysis = async (page: Page): Promise => { - await openFileFromExplorerTree(page, SAMPLE_LOG_NAME); + await openFileFromExplorerTree(page, LOG_FILE_NAME); await executeCommandWithCommandPalette(page, 'Log: Show Apex Log Analysis'); }; diff --git a/lana/test/playwright/support/logWorkspace.ts b/lana/test/playwright/support/logWorkspace.ts index 998210bb7..cd7805f0a 100644 --- a/lana/test/playwright/support/logWorkspace.ts +++ b/lana/test/playwright/support/logWorkspace.ts @@ -3,12 +3,12 @@ import path from 'node:path'; import { createTestWorkspace } from '@salesforce/playwright-vscode-ext'; -import { sampleLogPath } from './paths'; +import { fixtureLogPath } from './paths'; -export const SAMPLE_LOG_NAME = 'sample-log.log'; +export const LOG_FILE_NAME = 'apex-log.log'; export const createLogWorkspace = async (): Promise => { const workspaceDir = await createTestWorkspace(); - await fs.copyFile(sampleLogPath, path.join(workspaceDir, SAMPLE_LOG_NAME)); + await fs.copyFile(fixtureLogPath, path.join(workspaceDir, LOG_FILE_NAME)); return workspaceDir; }; diff --git a/lana/test/playwright/support/paths.ts b/lana/test/playwright/support/paths.ts index 0512c248c..dc1a9320a 100644 --- a/lana/test/playwright/support/paths.ts +++ b/lana/test/playwright/support/paths.ts @@ -2,5 +2,11 @@ import path from 'node:path'; export const repoRoot = path.resolve(__dirname, '../../../..'); export const extensionRoot = path.join(repoRoot, 'lana'); -export const sampleLogPath = path.join(repoRoot, 'sample-app', 'debug-logs', 'sample-log.log'); +export const fixtureLogPath = path.join( + extensionRoot, + 'test', + 'playwright', + 'fixtures', + 'apex-log.log', +); export const vscodeWebTestPath = path.join(repoRoot, '.vscode-test-web'); From 02899741e3fb9f9da267acf9788e4b1a25e9532a Mon Sep 17 00:00:00 2001 From: peternhale Date: Wed, 2 Sep 2026 12:15:37 -0600 Subject: [PATCH 22/61] build: add reproducible VSIX package command (#957) --- DEVELOPING.md | 3 +- lana/package.json | 5 +- lana/test/playwright/support/paths.ts | 17 +- pnpm-lock.yaml | 999 ++++++++++++++++++++++++++ rolldown.config.ts | 2 +- rollup.config.mjs | 2 +- 6 files changed, 1022 insertions(+), 6 deletions(-) diff --git a/DEVELOPING.md b/DEVELOPING.md index cdba1057e..5a9d2852d 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -123,8 +123,7 @@ pnpm install 2. Package the extension: ```zsh -cd lana -vsce package --no-dependencies +pnpm --filter lana run build:vsix ``` This command will create a `.vsix` file that you can distribute or install locally. diff --git a/lana/package.json b/lana/package.json index 64e048272..39cd0e3b5 100644 --- a/lana/package.json +++ b/lana/package.json @@ -29,8 +29,9 @@ "profiling", "soql" ], + "type": "module", "main": "out/Main.js", - "browser": "out/web/Main.web.js", + "browser": "out/web/Main.web.cjs", "icon": "./certinia-icon-color.png", "galleryBanner": { "color": "#000000", @@ -385,6 +386,7 @@ ] }, "scripts": { + "build:vsix": "vsce package --no-dependencies", "vscode:prepublish": "pnpm -w run build && pnpm -w run copy:package-docs", "vscode:bundle": "pnpm -w run build" }, @@ -398,6 +400,7 @@ "@types/jest": "^30.0.0", "@types/node": "~22.20.1", "@types/vscode": "~1.102.0", + "@vscode/vsce": "^3.9.2", "typescript": "npm:@typescript/typescript6@^6.0.2" } } diff --git a/lana/test/playwright/support/paths.ts b/lana/test/playwright/support/paths.ts index dc1a9320a..262fd59d4 100644 --- a/lana/test/playwright/support/paths.ts +++ b/lana/test/playwright/support/paths.ts @@ -1,6 +1,21 @@ +import { existsSync } from 'node:fs'; import path from 'node:path'; -export const repoRoot = path.resolve(__dirname, '../../../..'); +const findRepoRoot = (startDirectory: string): string => { + let directory = startDirectory; + + while (!existsSync(path.join(directory, 'pnpm-workspace.yaml'))) { + const parentDirectory = path.dirname(directory); + if (parentDirectory === directory) { + throw new Error(`Could not find the repository root from ${startDirectory}`); + } + directory = parentDirectory; + } + + return directory; +}; + +export const repoRoot = findRepoRoot(process.cwd()); export const extensionRoot = path.join(repoRoot, 'lana'); export const fixtureLogPath = path.join( extensionRoot, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9caa65a41..3f218bf99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: '@types/vscode': specifier: ~1.102.0 version: 1.102.0 + '@vscode/vsce': + specifier: ^3.9.2 + version: 3.9.2 typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' @@ -338,6 +341,12 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@azu/format-text@1.0.2': + resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} + + '@azu/style-format@1.0.1': + resolution: {integrity: sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==} + '@azure-rest/core-client@2.8.0': resolution: {integrity: sha512-F1ybHeN+++QhyFCF/ehLUEvrOB6fehPdFBFtGdj0C3B2lpQ9zkPiO5JDgsqc6IfjuUe6b3dAbXK0a7+VgSGfhw==} engines: {node: '>=22.0.0'} @@ -354,6 +363,10 @@ packages: resolution: {integrity: sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==} engines: {node: '>=22.0.0'} + '@azure/core-process@1.0.0': + resolution: {integrity: sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==} + engines: {node: '>=22.0.0'} + '@azure/core-rest-pipeline@1.25.0': resolution: {integrity: sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==} engines: {node: '>=22.0.0'} @@ -366,6 +379,10 @@ packages: resolution: {integrity: sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==} engines: {node: '>=22.0.0'} + '@azure/identity@4.13.2': + resolution: {integrity: sha512-NXL2/pCJctLxgw8bvrwwgge743kEq8LBT+O1pmV0vyUwetzFPH9auP6jhkU/cgZCPPtWoewAe3ncaGCgPo07fA==} + engines: {node: '>=22.0.0'} + '@azure/logger@1.4.0': resolution: {integrity: sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==} engines: {node: '>=22.0.0'} @@ -374,6 +391,22 @@ packages: resolution: {integrity: sha512-q266CqBoQDp4UcUvPXZ4NYSvl5aTDqiiUu7pDWqqtxL0nCvPkwZT8/vKOhhi8cB0pnL/ojc3basyV9lbS+jbPQ==} engines: {node: '>=22.0.0'} + '@azure/msal-browser@5.20.0': + resolution: {integrity: sha512-mtOKr708E/E/+qhI50QufmhR8GS5C84IeFGEaH/guMOaPXT9B/obEt8TVzX5U8j5OEUgukn0PrRmwVKaigr2wA==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.13.0': + resolution: {integrity: sha512-rOAy0KUcyBbdwVJ+f3uPpthXatFLLZN+/KWAsTLzk1aB23Xl9DRmmXYwSvBFOZyXj4jUQQ5FKxxRkhAFW1fOow==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.14.0': + resolution: {integrity: sha512-A4rb55hI86Q9tBl/+jBj7TMz7iX2RFgQs/nExFzcAtoI/BFRVdaH5SL/MivrYD7qvweMpN8AgVvVMHV8UBYxew==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.6.0': + resolution: {integrity: sha512-uFY9NxrWHw8PwZx7gAX6PDn+9vdfS05+levc/kwkx77IkjfaldnQbbcQzzDIZ5Hq5Zdr6/z92oAIoRWKp6MnOA==} + engines: {node: '>=20'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -3116,6 +3149,51 @@ packages: '@salesforce/vscode-services@67.13.3': resolution: {integrity: sha512-45EukRQHuDT54Eafa+McrUofz1iXSCoMueXdTmwLj6JmXchL4wxoHHBOYv+x7yRAhdiiSfOxdySZ0Zqf+1SwwQ==} + '@secretlint/config-creator@10.2.2': + resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/config-loader@10.2.2': + resolution: {integrity: sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/core@10.2.2': + resolution: {integrity: sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==} + engines: {node: '>=20.0.0'} + + '@secretlint/formatter@10.2.2': + resolution: {integrity: sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==} + engines: {node: '>=20.0.0'} + + '@secretlint/node@10.2.2': + resolution: {integrity: sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==} + engines: {node: '>=20.0.0'} + + '@secretlint/profiler@10.2.2': + resolution: {integrity: sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==} + + '@secretlint/resolver@10.2.2': + resolution: {integrity: sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==} + + '@secretlint/secretlint-formatter-sarif@10.2.2': + resolution: {integrity: sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==} + + '@secretlint/secretlint-rule-no-dotenv@10.2.2': + resolution: {integrity: sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==} + engines: {node: '>=20.0.0'} + + '@secretlint/secretlint-rule-preset-recommend@10.2.2': + resolution: {integrity: sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==} + engines: {node: '>=20.0.0'} + + '@secretlint/source-creator@10.2.2': + resolution: {integrity: sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==} + engines: {node: '>=20.0.0'} + + '@secretlint/types@10.2.2': + resolution: {integrity: sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==} + engines: {node: '>=20.0.0'} + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -3139,6 +3217,10 @@ packages: resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} + '@sindresorhus/merge-streams@2.3.0': + resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} + engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -3415,6 +3497,22 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} + '@textlint/ast-node-types@15.8.0': + resolution: {integrity: sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==} + + '@textlint/linter-formatter@15.8.0': + resolution: {integrity: sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w==} + engines: {node: '>=20.18.0'} + + '@textlint/module-interop@15.8.0': + resolution: {integrity: sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg==} + + '@textlint/resolver@15.8.0': + resolution: {integrity: sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA==} + + '@textlint/types@15.8.0': + resolution: {integrity: sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==} + '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} @@ -3533,6 +3631,9 @@ packages: '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} @@ -3563,6 +3664,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/sarif@2.1.7': + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -3929,6 +4033,59 @@ packages: engines: {node: '>=20'} hasBin: true + '@vscode/vsce-sign-alpine-arm64@2.0.6': + resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} + cpu: [arm64] + os: [alpine] + + '@vscode/vsce-sign-alpine-x64@2.0.6': + resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==} + cpu: [x64] + os: [alpine] + + '@vscode/vsce-sign-darwin-arm64@2.0.6': + resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==} + cpu: [arm64] + os: [darwin] + + '@vscode/vsce-sign-darwin-x64@2.0.6': + resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==} + cpu: [x64] + os: [darwin] + + '@vscode/vsce-sign-linux-arm64@2.0.6': + resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==} + cpu: [arm64] + os: [linux] + + '@vscode/vsce-sign-linux-arm@2.0.6': + resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==} + cpu: [arm] + os: [linux] + + '@vscode/vsce-sign-linux-x64@2.0.6': + resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==} + cpu: [x64] + os: [linux] + + '@vscode/vsce-sign-win32-arm64@2.0.6': + resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==} + cpu: [arm64] + os: [win32] + + '@vscode/vsce-sign-win32-x64@2.0.6': + resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==} + cpu: [x64] + os: [win32] + + '@vscode/vsce-sign@2.1.0': + resolution: {integrity: sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==} + + '@vscode/vsce@3.9.2': + resolution: {integrity: sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==} + engines: {node: '>= 20'} + hasBin: true + '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -4077,6 +4234,10 @@ packages: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-html-community@0.0.8: resolution: {integrity: sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==} engines: {'0': node >= 0.8.0} @@ -4141,6 +4302,10 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -4166,6 +4331,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + azure-devops-node-api@12.5.0: + resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -4311,6 +4479,10 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + binaryextensions@6.11.0: + resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} + engines: {node: '>=4'} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -4324,6 +4496,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boundary@2.0.0: + resolution: {integrity: sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==} + boxen@6.2.1: resolution: {integrity: sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4359,6 +4534,9 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -4498,6 +4676,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -4575,6 +4756,10 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + cockatiel@3.2.1: + resolution: {integrity: sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==} + engines: {node: '>=16'} + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -4615,6 +4800,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -5090,6 +5279,10 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + editions@6.22.0: + resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} + engines: {ecmascript: '>= es5', node: '>=4'} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -5154,6 +5347,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} @@ -5344,6 +5541,10 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect@30.4.1: resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -5522,6 +5723,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -5595,6 +5799,9 @@ packages: gifuct-js@2.1.2: resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + github-slugger@1.5.0: resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} @@ -5617,6 +5824,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -5637,6 +5848,10 @@ packages: resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + globby@14.1.0: + resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -5726,6 +5941,14 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + hosted-git-info@7.0.2: + resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} + engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -5897,6 +6120,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + infima@0.2.0-alpha.45: resolution: {integrity: sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==} engines: {node: '>=12'} @@ -6163,6 +6390,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + istextorbinary@9.5.0: + resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} + engines: {node: '>=4'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -6408,6 +6639,9 @@ packages: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} + keytar@7.9.0: + resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6539,6 +6773,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + lint-staged@17.2.0: resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} engines: {node: '>=22.22.1'} @@ -6596,6 +6833,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} @@ -6631,9 +6871,17 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lru-cache@7.18.3: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} @@ -6661,6 +6909,10 @@ packages: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} + markdown-it@14.3.1: + resolution: {integrity: sha512-4Ej49aYTDFIQ+uBkfX8GBvJGccoARxxPep+7aWTs55ozbjQJpW9M26Fe53vnGgvLeVzva/amzjQQaQu9w0vMhA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -6728,6 +6980,9 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} @@ -6903,6 +7158,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + mime@2.6.0: resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} engines: {node: '>=4.0.0'} @@ -6998,6 +7258,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + morgan@1.11.0: resolution: {integrity: sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==} engines: {node: '>= 0.8.0'} @@ -7037,6 +7300,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -7067,6 +7333,13 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-abi@3.96.0: + resolution: {integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==} + engines: {node: '>=10'} + + node-addon-api@4.3.0: + resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -7088,6 +7361,14 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} + node-sarif-builder@3.4.0: + resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} + engines: {node: '>=20'} + + normalize-package-data@6.0.2: + resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} + engines: {node: ^16.14.0 || >=18.0.0} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -7158,6 +7439,10 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -7230,6 +7515,10 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-map@7.0.7: + resolution: {integrity: sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==} + engines: {node: '>=18'} + p-queue@6.6.2: resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} engines: {node: '>=8'} @@ -7281,9 +7570,16 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse-numeric-range@1.3.0: resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + parse-semver@1.1.1: + resolution: {integrity: sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==} + parse-svg-path@0.2.0: resolution: {integrity: sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==} @@ -7336,6 +7632,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + path-to-regexp@1.9.0: resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} @@ -7349,9 +7649,16 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + path-type@6.0.0: + resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} + engines: {node: '>=18'} + peek-stream@1.1.3: resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -7413,6 +7720,13 @@ packages: engines: {node: '>=20'} hasBin: true + pluralize@2.0.0: + resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -7812,6 +8126,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -7899,6 +8219,10 @@ packages: pumpify@1.5.1: resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -7946,6 +8270,9 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + rc-config-loader@4.1.4: + resolution: {integrity: sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -8000,6 +8327,14 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + read-pkg@9.0.1: + resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} + engines: {node: '>=18'} + + read@1.0.7: + resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} + engines: {node: '>=0.8'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -8268,6 +8603,11 @@ packages: search-insights@2.17.3: resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + secretlint@10.2.2: + resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} + engines: {node: '>=20.0.0'} + hasBin: true + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -8283,6 +8623,10 @@ packages: resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} engines: {node: '>=12'} + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -8412,6 +8756,14 @@ packages: resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} engines: {node: '>=12'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -8455,6 +8807,18 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -8555,6 +8919,9 @@ packages: strnum@2.4.2: resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==} + structured-source@4.0.0: + resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -8579,6 +8946,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -8604,6 +8975,10 @@ packages: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + tabulator-tables@6.5.2: resolution: {integrity: sha512-cRL3xsaaf5RzND8KPbn4C9Ce5tzyiQUE01E/10zN50MjdRKl/Gl5nXkzLN6cvEyRfWHCPuqBF92y50CBw20WYA==} @@ -8611,15 +8986,26 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + tar-fs@3.1.3: resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + terminal-link@4.0.0: + resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} + engines: {node: '>=18'} + terser-webpack-plugin@5.6.1: resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} engines: {node: '>= 10.13.0'} @@ -8680,6 +9066,13 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + textextensions@6.11.0: + resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} + engines: {node: '>=4'} + thingies@2.6.1: resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==} engines: {node: '>=10.18'} @@ -8734,6 +9127,10 @@ packages: resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} hasBin: true + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -8813,6 +9210,10 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: + resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} + engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -8833,6 +9234,10 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -8841,6 +9246,9 @@ packages: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} + typed-rest-client@1.8.11: + resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} + typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} @@ -8861,6 +9269,12 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + underscore@1.13.8: + resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8888,6 +9302,14 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -8947,6 +9369,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + url-loader@4.1.1: resolution: {integrity: sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==} engines: {node: '>= 10.13.0'} @@ -8971,6 +9396,9 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + value-equal@1.0.1: resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} @@ -8978,6 +9406,10 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + version-range@4.15.0: + resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} + engines: {node: '>=4'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -9175,6 +9607,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -9195,6 +9631,10 @@ packages: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} + xml2js@0.5.0: + resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} + engines: {node: '>=4.0.0'} + xml2js@0.6.2: resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} engines: {node: '>=4.0.0'} @@ -9220,6 +9660,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -9241,6 +9684,13 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@3.4.0: + resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} + engines: {node: '>=12'} + + yazl@2.5.1: + resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -9404,6 +9854,12 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@azu/format-text@1.0.2': {} + + '@azu/style-format@1.0.1': + dependencies: + '@azu/format-text': 1.0.2 + '@azure-rest/core-client@2.8.0': dependencies: '@azure/abort-controller': 2.2.0 @@ -9439,6 +9895,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/core-process@1.0.0': {} + '@azure/core-rest-pipeline@1.25.0': dependencies: '@azure/abort-controller': 2.2.0 @@ -9463,6 +9921,23 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/identity@4.13.2': + dependencies: + '@azure/abort-controller': 2.2.0 + '@azure/core-auth': 1.11.0 + '@azure/core-client': 1.11.0 + '@azure/core-process': 1.0.0 + '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-tracing': 1.4.0 + '@azure/core-util': 1.14.0 + '@azure/logger': 1.4.0 + '@azure/msal-browser': 5.20.0 + '@azure/msal-node': 5.6.0 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@azure/logger@1.4.0': dependencies: '@typespec/ts-http-runtime': 0.3.8 @@ -9489,6 +9964,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@azure/msal-browser@5.20.0': + dependencies: + '@azure/msal-common': 16.14.0 + + '@azure/msal-common@16.13.0': {} + + '@azure/msal-common@16.14.0': {} + + '@azure/msal-node@5.6.0': + dependencies: + '@azure/msal-common': 16.13.0 + jsonwebtoken: 9.0.3 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -13362,6 +13850,80 @@ snapshots: - '@types/node' - supports-color + '@secretlint/config-creator@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + + '@secretlint/config-loader@10.2.2': + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + ajv: 8.20.0 + debug: 4.4.3 + rc-config-loader: 4.1.4 + transitivePeerDependencies: + - supports-color + + '@secretlint/core@10.2.2': + dependencies: + '@secretlint/profiler': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3 + structured-source: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@secretlint/formatter@10.2.2': + dependencies: + '@secretlint/resolver': 10.2.2 + '@secretlint/types': 10.2.2 + '@textlint/linter-formatter': 15.8.0 + '@textlint/module-interop': 15.8.0 + '@textlint/types': 15.8.0 + chalk: 5.6.2 + debug: 4.4.3 + pluralize: 8.0.0 + strip-ansi: 7.2.0 + table: 6.9.0 + terminal-link: 4.0.0 + transitivePeerDependencies: + - supports-color + + '@secretlint/node@10.2.2': + dependencies: + '@secretlint/config-loader': 10.2.2 + '@secretlint/core': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/profiler': 10.2.2 + '@secretlint/source-creator': 10.2.2 + '@secretlint/types': 10.2.2 + debug: 4.4.3 + p-map: 7.0.7 + transitivePeerDependencies: + - supports-color + + '@secretlint/profiler@10.2.2': {} + + '@secretlint/resolver@10.2.2': {} + + '@secretlint/secretlint-formatter-sarif@10.2.2': + dependencies: + node-sarif-builder: 3.4.0 + + '@secretlint/secretlint-rule-no-dotenv@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + + '@secretlint/secretlint-rule-preset-recommend@10.2.2': {} + + '@secretlint/source-creator@10.2.2': + dependencies: + '@secretlint/types': 10.2.2 + istextorbinary: 9.5.0 + + '@secretlint/types@10.2.2': {} + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -13378,6 +13940,8 @@ snapshots: '@sindresorhus/is@5.6.0': {} + '@sindresorhus/merge-streams@2.3.0': {} + '@sinonjs/commons@3.0.1': dependencies: type-detect: 4.0.8 @@ -13630,6 +14194,34 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@textlint/ast-node-types@15.8.0': {} + + '@textlint/linter-formatter@15.8.0': + dependencies: + '@azu/format-text': 1.0.2 + '@azu/style-format': 1.0.1 + '@textlint/module-interop': 15.8.0 + '@textlint/resolver': 15.8.0 + '@textlint/types': 15.8.0 + debug: 4.4.3 + js-yaml: 4.3.0 + lodash: 4.18.1 + pluralize: 2.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + table: 6.9.0 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + '@textlint/module-interop@15.8.0': {} + + '@textlint/resolver@15.8.0': {} + + '@textlint/types@15.8.0': + dependencies: + '@textlint/ast-node-types': 15.8.0 + '@tootallnate/quickjs-emscripten@0.23.0': {} '@tybys/wasm-util@0.10.3': @@ -13780,6 +14372,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/normalize-package-data@2.4.4': {} + '@types/prismjs@1.26.6': {} '@types/qs@6.15.1': {} @@ -13817,6 +14411,8 @@ snapshots: dependencies: '@types/node': 22.20.1 + '@types/sarif@2.1.7': {} + '@types/sax@1.2.7': dependencies: '@types/node': 22.20.1 @@ -14139,6 +14735,81 @@ snapshots: - react-native-b4a - supports-color + '@vscode/vsce-sign-alpine-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-alpine-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-darwin-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-darwin-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-arm@2.0.6': + optional: true + + '@vscode/vsce-sign-linux-x64@2.0.6': + optional: true + + '@vscode/vsce-sign-win32-arm64@2.0.6': + optional: true + + '@vscode/vsce-sign-win32-x64@2.0.6': + optional: true + + '@vscode/vsce-sign@2.1.0': + optionalDependencies: + '@vscode/vsce-sign-alpine-arm64': 2.0.6 + '@vscode/vsce-sign-alpine-x64': 2.0.6 + '@vscode/vsce-sign-darwin-arm64': 2.0.6 + '@vscode/vsce-sign-darwin-x64': 2.0.6 + '@vscode/vsce-sign-linux-arm': 2.0.6 + '@vscode/vsce-sign-linux-arm64': 2.0.6 + '@vscode/vsce-sign-linux-x64': 2.0.6 + '@vscode/vsce-sign-win32-arm64': 2.0.6 + '@vscode/vsce-sign-win32-x64': 2.0.6 + + '@vscode/vsce@3.9.2': + dependencies: + '@azure/identity': 4.13.2 + '@secretlint/node': 10.2.2 + '@secretlint/secretlint-formatter-sarif': 10.2.2 + '@secretlint/secretlint-rule-no-dotenv': 10.2.2 + '@secretlint/secretlint-rule-preset-recommend': 10.2.2 + '@vscode/vsce-sign': 2.1.0 + azure-devops-node-api: 12.5.0 + chalk: 4.1.2 + cheerio: 1.2.0 + cockatiel: 3.2.1 + commander: 12.1.0 + form-data: 4.0.6 + glob: 13.0.6 + hosted-git-info: 4.1.0 + jsonc-parser: 3.3.1 + leven: 3.1.0 + markdown-it: 14.3.1 + mime: 1.6.0 + minimatch: 10.2.5 + parse-semver: 1.1.1 + read: 1.0.7 + secretlint: 10.2.2 + semver: 7.8.5 + tmp: 0.2.7 + typed-rest-client: 1.8.11 + url-join: 4.0.1 + xml2js: 0.5.0 + yauzl: 3.4.0 + yazl: 2.5.1 + optionalDependencies: + keytar: 7.9.0 + transitivePeerDependencies: + - supports-color + '@webassemblyjs/ast@1.14.1': dependencies: '@webassemblyjs/helper-numbers': 1.13.2 @@ -14328,6 +14999,10 @@ snapshots: dependencies: type-fest: 0.21.3 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-html-community@0.0.8: {} ansi-regex@5.0.1: {} @@ -14375,6 +15050,8 @@ snapshots: dependencies: tslib: 2.8.1 + astral-regex@2.0.0: {} + astring@1.9.0: {} async-lock@1.4.1: {} @@ -14396,6 +15073,11 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + azure-devops-node-api@12.5.0: + dependencies: + tunnel: 0.0.6 + typed-rest-client: 1.8.11 + b4a@1.8.1: {} babel-jest@30.4.1(@babel/core@7.29.7): @@ -14548,6 +15230,10 @@ snapshots: binary-extensions@2.3.0: {} + binaryextensions@6.11.0: + dependencies: + editions: 6.22.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -14575,6 +15261,8 @@ snapshots: boolbase@1.0.0: {} + boundary@2.0.0: {} + boxen@6.2.1: dependencies: ansi-align: 3.0.1 @@ -14633,6 +15321,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@0.2.13: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -14813,6 +15503,9 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: + optional: true + chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} @@ -14877,6 +15570,8 @@ snapshots: co@4.6.0: {} + cockatiel@3.2.1: {} + collapse-white-space@2.1.0: {} collect-v8-coverage@1.0.3: {} @@ -14905,6 +15600,8 @@ snapshots: commander@10.0.1: {} + commander@12.1.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -15365,6 +16062,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + editions@6.22.0: + dependencies: + version-range: 4.15.0 + ee-first@1.1.1: {} effect@3.22.1: @@ -15414,6 +16115,8 @@ snapshots: entities@7.0.1: {} + environment@1.1.0: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 @@ -15655,6 +16358,9 @@ snapshots: exit-x@0.2.2: {} + expand-template@2.0.3: + optional: true + expect@30.4.1: dependencies: '@jest/expect-utils': 30.4.1 @@ -15877,6 +16583,9 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: + optional: true + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -15955,6 +16664,9 @@ snapshots: dependencies: js-binary-schema-parser: 2.0.3 + github-from-package@0.0.0: + optional: true + github-slugger@1.5.0: {} glob-parent@5.1.2: @@ -15978,6 +16690,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -16019,6 +16737,15 @@ snapshots: merge2: 1.4.1 slash: 4.0.0 + globby@14.1.0: + dependencies: + '@sindresorhus/merge-streams': 2.3.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + path-type: 6.0.0 + slash: 5.1.0 + unicorn-magic: 0.3.0 + gopd@1.2.0: {} got@11.8.6: @@ -16200,6 +16927,14 @@ snapshots: dependencies: react-is: 16.13.1 + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + hosted-git-info@7.0.2: + dependencies: + lru-cache: 10.4.3 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -16396,6 +17131,8 @@ snapshots: indent-string@4.0.0: {} + index-to-position@1.2.0: {} + infima@0.2.0-alpha.45: {} inflight@1.0.6: @@ -16620,6 +17357,12 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + istextorbinary@9.5.0: + dependencies: + binaryextensions: 6.11.0 + editions: 6.22.0 + textextensions: 6.11.0 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -17106,6 +17849,12 @@ snapshots: dependencies: tsscmp: 1.0.6 + keytar@7.9.0: + dependencies: + node-addon-api: 4.3.0 + prebuild-install: 7.1.3 + optional: true + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -17242,6 +17991,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + lint-staged@17.2.0: dependencies: picomatch: 4.0.5 @@ -17302,6 +18055,8 @@ snapshots: lodash.once@4.1.1: {} + lodash.truncate@4.4.2: {} + lodash.uniq@4.5.0: {} lodash@4.18.1: {} @@ -17332,10 +18087,16 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lru-cache@7.18.3: {} lunr-languages@1.20.0: {} @@ -17358,6 +18119,15 @@ snapshots: markdown-extensions@2.0.0: {} + markdown-it@14.3.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -17554,6 +18324,8 @@ snapshots: mdn-data@2.0.30: {} + mdurl@2.1.0: {} + media-typer@1.1.1: {} memfs@4.38.1: @@ -17906,6 +18678,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + mime@2.6.0: {} mimic-fn@2.1.0: {} @@ -17982,6 +18756,9 @@ snapshots: minipass@7.1.3: {} + mkdirp-classic@0.5.3: + optional: true + morgan@1.11.0: dependencies: basic-auth: 2.0.1 @@ -18030,6 +18807,9 @@ snapshots: nanoid@3.3.16: {} + napi-build-utils@2.0.0: + optional: true + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -18049,6 +18829,14 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abi@3.96.0: + dependencies: + semver: 7.8.5 + optional: true + + node-addon-api@4.3.0: + optional: true + node-addon-api@7.1.1: optional: true @@ -18070,6 +18858,17 @@ snapshots: node-releases@2.0.51: {} + node-sarif-builder@3.4.0: + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.4.0 + + normalize-package-data@6.0.2: + dependencies: + hosted-git-info: 7.0.2 + semver: 7.8.5 + validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} normalize-url@6.1.0: {} @@ -18129,6 +18928,13 @@ snapshots: dependencies: mimic-function: 5.0.1 + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -18243,6 +19049,8 @@ snapshots: dependencies: aggregate-error: 3.1.0 + p-map@7.0.7: {} + p-queue@6.6.2: dependencies: eventemitter3: 4.0.7 @@ -18315,8 +19123,18 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse-numeric-range@1.3.0: {} + parse-semver@1.1.1: + dependencies: + semver: 5.7.2 + parse-svg-path@0.2.0: {} parse5-htmlparser2-tree-adapter@7.1.0: @@ -18363,6 +19181,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.3 + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + path-to-regexp@1.9.0: dependencies: isarray: 0.0.1 @@ -18373,12 +19196,16 @@ snapshots: path-type@4.0.0: {} + path-type@6.0.0: {} + peek-stream@1.1.3: dependencies: buffer-from: 1.1.2 duplexify: 3.7.1 through2: 2.0.5 + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -18469,6 +19296,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pluralize@2.0.0: {} + + pluralize@8.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-attribute-case-insensitive@7.0.1(postcss@8.5.25): @@ -18914,6 +19745,22 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + optional: true + prelude-ls@1.2.1: {} prettier@3.9.6: {} @@ -19007,6 +19854,8 @@ snapshots: inherits: 2.0.4 pump: 2.0.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} pupa@3.3.0: @@ -19045,6 +19894,15 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + rc-config-loader@4.1.4: + dependencies: + debug: 4.4.3 + js-yaml: 4.3.0 + json5: 2.2.3 + require-from-string: 2.0.2 + transitivePeerDependencies: + - supports-color + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -19107,6 +19965,18 @@ snapshots: react@19.2.8: {} + read-pkg@9.0.1: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 6.0.2 + parse-json: 8.3.0 + type-fest: 4.41.0 + unicorn-magic: 0.1.0 + + read@1.0.7: + dependencies: + mute-stream: 0.0.8 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -19496,6 +20366,18 @@ snapshots: search-insights@2.17.3: {} + secretlint@10.2.2: + dependencies: + '@secretlint/config-creator': 10.2.2 + '@secretlint/formatter': 10.2.2 + '@secretlint/node': 10.2.2 + '@secretlint/profiler': 10.2.2 + debug: 4.4.3 + globby: 14.1.0 + read-pkg: 9.0.1 + transitivePeerDependencies: + - supports-color + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -19512,6 +20394,8 @@ snapshots: dependencies: semver: 7.8.5 + semver@5.7.2: {} + semver@6.3.1: {} semver@7.8.5: {} @@ -19673,6 +20557,14 @@ snapshots: slash@4.0.0: {} + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + smart-buffer@4.2.0: {} snake-case@3.0.4: @@ -19717,6 +20609,20 @@ snapshots: space-separated-tokens@2.0.2: {} + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + split2@4.2.0: {} sprintf-js@1.0.3: {} @@ -19812,6 +20718,10 @@ snapshots: dependencies: anynum: 1.0.1 + structured-source@4.0.0: + dependencies: + boundary: 2.0.0 + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -19836,6 +20746,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + supports-preserve-symlinks-flag@1.0.0: {} svg-parser@2.0.4: {} @@ -19862,10 +20777,26 @@ snapshots: dependencies: '@pkgr/core': 0.3.6 + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + tabulator-tables@6.5.2: {} tapable@2.3.3: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + optional: true + tar-fs@3.1.3: dependencies: pump: 3.0.4 @@ -19878,6 +20809,15 @@ snapshots: - bare-buffer - react-native-b4a + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -19896,6 +20836,11 @@ snapshots: - bare-abort-controller - react-native-b4a + terminal-link@4.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 3.2.0 + terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -19936,6 +20881,12 @@ snapshots: transitivePeerDependencies: - react-native-b4a + text-table@0.2.0: {} + + textextensions@6.11.0: + dependencies: + editions: 6.22.0 + thingies@2.6.1(tslib@2.8.1): dependencies: tslib: 2.8.1 @@ -19980,6 +20931,8 @@ snapshots: dependencies: tldts-core: 7.4.3 + tmp@0.2.7: {} + tmpl@1.0.5: {} to-buffer@1.2.2: @@ -20044,6 +20997,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + tunnel@0.0.6: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -20056,6 +21011,8 @@ snapshots: type-fest@2.19.0: {} + type-fest@4.41.0: {} + type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -20068,6 +21025,12 @@ snapshots: es-errors: 1.3.0 is-typed-array: 1.1.15 + typed-rest-client@1.8.11: + dependencies: + qs: 6.15.3 + tunnel: 0.0.6 + underscore: 1.13.8 + typedarray-to-buffer@3.1.5: dependencies: is-typedarray: 1.0.0 @@ -20108,6 +21071,10 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + uc.micro@2.1.0: {} + + underscore@1.13.8: {} + undici-types@6.21.0: {} undici@8.9.0: {} @@ -20125,6 +21092,10 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.1.0: {} + + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -20240,6 +21211,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@4.0.1: {} + url-loader@4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): dependencies: loader-utils: 2.0.4 @@ -20261,10 +21234,17 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + value-equal@1.0.1: {} vary@1.1.2: {} + version-range@4.15.0: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -20583,6 +21563,10 @@ snapshots: ws@8.21.1: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 @@ -20598,6 +21582,11 @@ snapshots: xml-naming@0.3.0: {} + xml2js@0.5.0: + dependencies: + sax: 1.6.1 + xmlbuilder: 11.0.1 + xml2js@0.6.2: dependencies: sax: 1.6.1 @@ -20615,6 +21604,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@2.9.0: {} yargs-parser@21.1.1: {} @@ -20640,6 +21631,14 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + yauzl@3.4.0: + dependencies: + pend: 1.2.0 + + yazl@2.5.1: + dependencies: + buffer-crc32: 0.2.13 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/rolldown.config.ts b/rolldown.config.ts index 14db24c25..0e446c0ce 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -40,7 +40,7 @@ export default defineConfig([ output: { format: 'cjs', dir: './lana/out/web', - entryFileNames: 'Main.web.js', + entryFileNames: 'Main.web.cjs', chunkFileNames: 'lana-[name].js', sourcemap: false, keepNames: true, diff --git a/rollup.config.mjs b/rollup.config.mjs index 347752b4a..a7f586abb 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -65,7 +65,7 @@ export default [ output: { format: 'cjs', dir: './lana/out/web', - entryFileNames: 'Main.web.js', + entryFileNames: 'Main.web.cjs', chunkFileNames: 'lana-[name].js', sourcemap: false, }, From 29f3e518524d508b9e5f0853cb9935cdde9e2c29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:28:55 +0100 Subject: [PATCH 23/61] build(deps-dev): bump the development-dependencies group across 1 directory with 9 updates (#993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the development-dependencies group with 9 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core) | `1.15.47` | `1.16.1` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `10.0.4` | `10.0.5` | | [eslint](https://github.com/eslint/eslint) | `10.8.0` | `10.9.1` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.2.0` | `17.3.0` | | [rolldown](https://github.com/rolldown/rolldown/tree/HEAD/packages/rolldown) | `1.2.1` | `1.2.6` | | [rollup](https://github.com/rollup/rollup) | `4.62.3` | `4.63.0` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.65.0` | `8.68.0` | | [@salesforce/vscode-services](https://github.com/forcedotcom/salesforcedx-vscode) | `67.13.3` | `67.15.0` | | [sass](https://github.com/sass/dart-sass) | `1.102.0` | `1.103.1` | Updates `@swc/core` from 1.15.47 to 1.16.1
Changelog

Sourced from @โ€‹swc/core's changelog.

[1.16.1] - 2026-08-19

Bug Fixes

[1.16.0] - 2026-08-14

Bug Fixes

  • (encoding) Fix incorrect fields count (#11905) (6fb4ca1)

    • BREAKING: Fix incorrect fields count (#11905)
  • (es/ast) Prevent mutable reference escape (#12088) (592f559)

  • (es/ast) Fix panic on JSX surrogate entities (#11803) (d21de47)

    • BREAKING: fix panic on JSX surrogate entities (#11803)
  • (es/es2015) Preserve this in static field parameters (#12085) (5b758ed)

  • (es/minifier) Remove unused variable initializer cycles (#12106) (0421534)

  • (es/minifier) Bound arguments parameter injection (#12053) (46d6f41)

  • (es/preset-env) Lower unsupported async generators (#12086) (3a144b1)

  • (hstr) Avoid references to uninitialized bytes (#12087) (68f0983)

  • (plugin) Make raw byte reconstruction unsafe (#12089) (83ab4ed)

  • (plugin/runner) Write Wasmer cache atomically (#12100) (3c4f404)

... (truncated)

Commits
  • 490c7d8 chore: Publish 1.16.1 with swc_core v77.0.2
  • 7e4d782 chore: Publish 1.16.1-nightly-20260819.1 with swc_core v77.0.2
  • ae2117a chore: Publish 1.16.0 with swc_core v77.0.0
  • 99671f1 chore: Publish 1.16.0-nightly-20260814.1 with swc_core v77.0.0
  • 394c7c9 refactor(es/ast)!: introduce FunctionBody (#12096)
  • 9ae902e refactor(es/ast)!: use Function for object accessors (#12077)
  • 1687c0f refactor(es/ast)!: split TypeScript this parameters (#12075)
  • See full diff in compare view

Updates `concurrently` from 10.0.4 to 10.0.5
Release notes

Sourced from concurrently's releases.

v10.0.5

What's Changed

New Contributors

Full Changelog: https://github.com/open-cli-tools/concurrently/compare/v10.0.4...v10.0.5

Commits
  • 1b8cbeb 10.0.5
  • 544dba0 docs: make linter happy
  • 667b701 deps: update several dev deps
  • f67c57c vscode: use installed TS version
  • dbb5617 fix: expand wildcards from package.json5 when package.json is missing (#608)
  • 9f90a1a fix: correctly output non-ASCII text on Windows (#604)
  • 94415cc ci: fix publishing to latest/backport
  • See full diff in compare view

Updates `eslint` from 10.8.0 to 10.9.1
Release notes

Sourced from eslint's releases.

v10.9.1

Bug Fixes

  • 1e641c9 fix: no-loss-of-precision false positive with trailing decimal point (#21251) (Aleksandr Shoronov)

Documentation

  • ad74a8d docs: add deprecation steps for EOL package versions (#21248) (Francesco Trotta)

Chores

v10.9.0

Features

  • 08de88e feat: handle underflow in no-loss-of-precision (#21218) (Rithish S)
  • 55db479 feat: add checkConditionalExpressions to no-unmodified-loop-condition (#21175) (sethamus)

Bug Fixes

  • 2ba3025 fix: prevent unsafe no-var autofix with hoisted functions (#21213) (sethamus)
  • 8e69622 fix: Prevent no-var autofix when var is shadowed by catch parameter (#21204) (Yang Hyeonjong)
  • 684b579 fix: prefer-template invalid autofix creates a tagged template call (#21207) (๊น€์ฑ„์˜)

Documentation

  • 9ef407a docs: use eslint.config.* wherever config file names are listed (#21216) (Marry (Subin Yang))
  • 87f66f4 docs: Update README (GitHub Actions Bot)
  • 585ef37 docs: update architecture documentation (#21112) (Francesco Trotta)
  • f3993b0 docs: Update README (GitHub Actions Bot)
  • ffc87d6 docs: fix broken links in Further Reading sections (#21203) (Minsu)
  • 1a761e1 docs: update moved JSX specification links (#21198) (Imran Mustafa)
  • 4d00ca4 docs: update ESLint peer dependency to ^10.0.0 in shareable configs (#21202) (lumir)
  • 510d1a2 docs: Update README (GitHub Actions Bot)

Chores

  • 899dbf1 chore: update github/codeql-action action to v4.37.7 (#21243) (renovate[bot])
  • 9aa3873 chore: update ecosystem plugins (#21235) (ESLint Bot)
  • dc1e7a8 chore: update ecosystem plugins (#21208) (ESLint Bot)
  • f878d21 ci: bump pnpm/action-setup from 6.0.9 to 6.0.10 (#21200) (dependabot[bot])
  • 4891e50 ci: bump github/codeql-action from 4.37.4 to 4.37.6 (#21199) (dependabot[bot])

v10.8.1

Bug Fixes

  • 18eb0a7 fix: prevent ASI hazard in no-unused-labels autofix (#21173) (dongkyu lee)
  • 151ba3f fix: false positives in getter-return and accessor-pairs (#21163) (Grit)
  • 6898df9 fix: ignore meta-property names in id-denylist (#21166) (Pixel)
  • 4d7db66 fix: ignore meta-property names in id-match (#21167) (Pixel)
  • 677214e fix: handle ASI hazards in no-unused-vars removeVar suggestion (#20935) (kuldeep kumar)

Documentation

  • 7d0cbf8 docs: Update README (GitHub Actions Bot)
  • 0a05812 docs: add missing backticks to no-duplicate-imports.js (#21183) (Lee Daeun)
  • 678c90b docs: Update README (GitHub Actions Bot)
  • 8a10424 docs: Update README (GitHub Actions Bot)

... (truncated)

Commits

Updates `lint-staged` from 17.2.0 to 17.3.0
Release notes

Sourced from lint-staged's releases.

v17.3.0

Minor Changes

  • #1825 16b3f74 - It is now possible to run multiple tasks in parallel for a single glob by configuring it with an array of tasks (which run sequentially), and then placing another array inside it (where the tasks will run in parallel). The following demonstrates the order tasks will start in:

    {
    "*.ts": ["first", "second",
    ["third", "third"], "fourth"]
    }
    

    As a concrete example, lint-staged's own configuration is:

    /** @type {import('./lib/index.js').Configuration}
    */
    export default {
      "*": [
        [
          "oxfmt --check --no-error-on-unmatched-pattern",
          "oxlint --no-error-on-unmatched-pattern",
        ],
      ],
      "*.ts": () => "tsc",
    };
    

    which means:

    1. for all staged files, run the two commands in parallel with staged filenames appended, for example:
      • oxfmt --check --no-error-on-unmatched-pattern lib/index.js
      • oxlint --no-error-on-unmatched-pattern lib/index.js
    2. additionally, if any *.ts files are staged, run tsc without appending any arguments
    3. The two sets of commands also run in parallel

Patch Changes

  • #1829 15f7e53 - During an in-progress merge, files that are unchanged from the branch being merged are now skipped. Technically, files are only included if there are staged changes against both HEAD and MERGE_HEAD.
Changelog

Sourced from lint-staged's changelog.

17.3.0

Minor Changes

  • #1825 16b3f74 - It is now possible to run multiple tasks in parallel for a single glob by configuring it with an array of tasks (which run sequentially), and then placing another array inside it (where the tasks will run in parallel). The following demonstrates the order tasks will start in:

    {
    "*.ts": ["first", "second",
    ["third", "third"], "fourth"]
    }
    

    As a concrete example, lint-staged's own configuration is:

    /** @type {import('./lib/index.js').Configuration}
    */
    export default {
      '*': [
    ['oxfmt --check --no-error-on-unmatched-pattern', 'oxlint
    --no-error-on-unmatched-pattern'],
      ],
      '*.ts': () => 'tsc',
    }
    

    which means:

    1. for all staged files, run the two commands in parallel with staged filenames appended, for example:
      • oxfmt --check --no-error-on-unmatched-pattern lib/index.js
      • oxlint --no-error-on-unmatched-pattern lib/index.js
    2. additionally, if any *.ts files are staged, run tsc without appending any arguments
    3. The two sets of commands also run in parallel

Patch Changes

  • #1829 15f7e53 - During an in-progress merge, files that are unchanged from the branch being merged are now skipped. Technically, files are only included if there are staged changes against both HEAD and MERGE_HEAD.
Commits
  • d153443 Merge pull request #1828 from lint-staged/changeset-release/main
  • 5162c14 chore(changeset): release
  • a4db9a4 Merge pull request #1831 from lint-staged/linter-updates
  • ea96cab style: enable oxlint "suspicious" category
  • 2fae007 style: add @e18e/eslint-plugin
  • 2280c38 Merge pull request #1829 from lint-staged/fix-merge-conflict-files
  • 1453ae6 test: relax assertion so that it passes in worktree
  • 15f7e53 fix: lint only files changed against HEAD and MERGE_HEAD, during a merge
  • dedfc31 Merge pull request #1825 from lint-staged/parallel-tasks-inside-sequence
  • 286e25c feat: allow running parallel tasks by nesting arrays
  • Additional commits viewable in compare view

Updates `rolldown` from 1.2.1 to 1.2.6
Release notes

Sourced from rolldown's releases.

v1.2.6

[1.2.6] - 2026-08-26

๐Ÿš€ Features

๐Ÿ› Bug Fixes

๐Ÿšœ Refactor

๐Ÿ“š Documentation

โšก Performance

๐Ÿงช Testing

โš™๏ธ Miscellaneous Tasks

... (truncated)

Changelog

Sourced from rolldown's changelog.

[1.2.6] - 2026-08-26

๐Ÿš€ Features

๐Ÿ› Bug Fixes

๐Ÿšœ Refactor

๐Ÿ“š Documentation

โšก Performance

๐Ÿงช Testing

โš™๏ธ Miscellaneous Tasks

... (truncated)

Commits
  • 5375362 release: v1.2.6 (#10784)
  • cba0a90 test: use an absolute filename for transform tsconfig path (#10782)
  • 2ba4bdf feat(minify): support property name mangling (#10374)
  • 224f40b fix(dev): register an empty exports object for a module without exports (#10772)
  • 4f052e4 fix: name the module when a codeSplitting group callback returns a wrong ty...
  • f86be54 perf: batch codeSplitting group test / name calls (#10745)
  • 4f81096 perf: enable compiler cache via module.enableCompileCache() (#10678)
  • 68e968b feat: add tsconfig: string option to transform (#10727)
  • 874bc9a fix: clear resolution cache when TsconfigCache::clear is called (#10726)
  • 32d4865 feat(rolldown_plugin_vite_transform): add tsconfig option (#10725)
  • Additional commits viewable in compare view

Updates `rollup` from 4.62.3 to 4.63.0
Release notes

Sourced from rollup's releases.

v4.63.0

4.63.0

2026-08-25

Features

  • Allow to analyze function return values in many more cases (#6065)

Pull Requests

v4.62.5

4.62.5

2026-08-20

Bug Fixes

  • Resolve an issue where compact mode could result in invalid module concatenations (#6468)

Pull Requests

v4.62.4

4.62.4

2026-08-01

Bug Fixes

  • Resolve a regression when using Rollup on older Linux distributions (#6467)

Pull Requests

... (truncated)

Changelog

Sourced from rollup's changelog.

4.63.0

2026-08-25

Features

  • Allow to analyze function return values in many more cases (#6065)

Pull Requests

4.62.5

2026-08-20

Bug Fixes

  • Resolve an issue where compact mode could result in invalid module concatenations (#6468)

Pull Requests

4.62.4

2026-08-01

Bug Fixes

  • Resolve a regression when using Rollup on older Linux distributions (#6467)

Pull Requests

... (truncated)

Commits

Updates `typescript-eslint` from 8.65.0 to 8.68.0
Release notes

Sourced from typescript-eslint's releases.

v8.68.0

8.68.0 (2026-08-24)

๐Ÿš€ Features

  • eslint-plugin: [strict-void-return] add fix suggestions (#12086)
  • utils: support ESLint rule meta.languages (#12663)

๐Ÿฉน Fixes

  • eslint-plugin: [unified-signatures] deduplicate types in report (#12656)
  • eslint-plugin: [return-await] prevent autofix from breaking code in arrow-functions (#12707)
  • eslint-plugin: [unified-signatures] report identical signatures (#12678)
  • eslint-plugin: [no-unnecessary-type-assertion] prevent stack overflow in recursive types (#12711)
  • eslint-plugin: [no-floating-promises] setting ignoreVoid: false results in false negative in ArrowFunctionExpression (#12646)
  • eslint-plugin: [no-empty-object-type] ignore suggestions that result in invalid interfaces and export defaults (#12739)
  • website: playground crashes on extends configs (#12608)
  • website: account for thanks.dev and out-of-band donors in sponsors list (#12735)

โค๏ธ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

v8.67.0

8.67.0 (2026-08-10)

๐Ÿš€ Features

  • typescript-eslint: export basic globs for using tseslint (#12105)

โค๏ธ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

v8.66.0

... (truncated)

Changelog

Sourced from typescript-eslint's changelog.

8.68.0 (2026-08-24)

This was a version bump only for typescript-eslint to align it with other projects, there were no code changes.

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

8.67.0 (2026-08-10)

๐Ÿš€ Features

  • typescript-eslint: export basic globs for using tseslint (#12105)

โค๏ธ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

8.66.0 (2026-08-03)

This was a version bump only for typescript-eslint to align it with other projects, there were no code changes.

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Commits

Updates `@salesforce/vscode-services` from 67.13.3 to 67.15.0
Release notes

Sourced from @โ€‹salesforce/vscode-services's releases.

salesforcedx-vscode v67.15.0 (Nightly develop 20260826)

salesforcedx-vscode v67.15.0

Installation

Download the VSIX file and install via VS Code.

โš ๏ธ This is a pre-release version ๐ŸŒ™ Nightly build from 20260826

Release v67.14.0

67.14.0 - August 26, 2026

Added

salesforcedx-vscode-lwc

  • We added f... _Description has been truncated_ Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lana/package.json | 2 +- log-viewer/package.json | 2 +- package.json | 14 +- pnpm-lock.yaml | 3459 +++++++++++++++++++-------------------- 4 files changed, 1697 insertions(+), 1780 deletions(-) diff --git a/lana/package.json b/lana/package.json index 39cd0e3b5..ddc3876f9 100644 --- a/lana/package.json +++ b/lana/package.json @@ -396,7 +396,7 @@ "vscode-uri": "^3.1.0" }, "devDependencies": { - "@salesforce/vscode-services": "^67.12.0", + "@salesforce/vscode-services": "^67.15.0", "@types/jest": "^30.0.0", "@types/node": "~22.20.1", "@types/vscode": "~1.102.0", diff --git a/log-viewer/package.json b/log-viewer/package.json index f63643459..04426cb4b 100644 --- a/log-viewer/package.json +++ b/log-viewer/package.json @@ -21,7 +21,7 @@ "@types/jest": "^30.0.0", "@types/tabulator-tables": "^6.3.6", "lightningcss": "^1.33.0", - "sass": "^1.102.0", + "sass": "^1.103.1", "typescript": "npm:@typescript/typescript6@^6.0.2" } } diff --git a/package.json b/package.json index 48d4bf7e1..c8210c4c0 100644 --- a/package.json +++ b/package.json @@ -11,28 +11,28 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@salesforce/playwright-vscode-ext": "^1.3.10", - "@swc/core": "^1.15.47", + "@swc/core": "^1.16.1", "@swc/helpers": "^0.5.23", "@swc/jest": "^0.2.39", "@types/jest": "^30.0.0", "@vscode/test-web": "^0.0.81", - "concurrently": "^10.0.4", - "eslint": "^10.8.0", + "concurrently": "^10.0.5", + "eslint": "^10.9.1", "eslint-config-prettier": "^10.1.8", "husky": "^9.1.7", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", - "lint-staged": "^17.2.0", + "lint-staged": "^17.3.0", "prettier": "^3.9.6", - "rolldown": "^1.2.1", - "rollup": "^4.62.3", + "rolldown": "^1.2.6", + "rollup": "^4.63.0", "rollup-plugin-copy": "^3.5.0", "rollup-plugin-polyfill-node": "^0.13.0", "rollup-plugin-swc3": "^0.12.1", "tsx": "^4.21.0", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-7": "npm:typescript@^7.0.2", - "typescript-eslint": "^8.65.0" + "typescript-eslint": "^8.68.0" }, "scripts": { "preinstall": "npx only-allow pnpm", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f218bf99..11d55ad0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,7 +30,7 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.8.0(jiti@1.21.7)) + version: 10.0.1(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2)) '@playwright/test': specifier: ^1.60.0 version: 1.62.1 @@ -42,73 +42,73 @@ importers: version: 1.0.3 '@rollup/plugin-alias': specifier: ^6.0.0 - version: 6.0.0(rollup@4.62.3) + version: 6.0.0(rollup@4.63.0) '@rollup/plugin-commonjs': specifier: ^29.0.3 - version: 29.0.3(rollup@4.62.3) + version: 29.0.3(rollup@4.63.0) '@rollup/plugin-json': specifier: ^6.1.0 - version: 6.1.0(rollup@4.62.3) + version: 6.1.0(rollup@4.63.0) '@rollup/plugin-node-resolve': specifier: ^16.0.3 - version: 16.0.3(rollup@4.62.3) + version: 16.0.3(rollup@4.63.0) '@salesforce/playwright-vscode-ext': specifier: ^1.3.10 - version: 1.3.11 + version: 1.3.11(supports-color@10.2.2) '@swc/core': - specifier: ^1.15.47 - version: 1.15.47(@swc/helpers@0.5.23) + specifier: ^1.16.1 + version: 1.16.1(@swc/helpers@0.5.23) '@swc/helpers': specifier: ^0.5.23 version: 0.5.23 '@swc/jest': specifier: ^0.2.39 - version: 0.2.39(@swc/core@1.15.47(@swc/helpers@0.5.23)) + version: 0.2.39(@swc/core@1.16.1(@swc/helpers@0.5.23)) '@types/jest': specifier: ^30.0.0 version: 30.0.0 '@vscode/test-web': specifier: ^0.0.81 - version: 0.0.81 + version: 0.0.81(supports-color@10.2.2) concurrently: - specifier: ^10.0.4 - version: 10.0.4 + specifier: ^10.0.5 + version: 10.0.5 eslint: - specifier: ^10.8.0 - version: 10.8.0(jiti@1.21.7) + specifier: ^10.9.1 + version: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.8.0(jiti@1.21.7)) + version: 10.1.8(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2)) husky: specifier: ^9.1.7 version: 9.1.7 jest: specifier: ^30.4.2 - version: 30.4.2(@types/node@22.20.1) + version: 30.4.2(@types/node@22.20.1)(supports-color@10.2.2) jest-environment-jsdom: specifier: ^30.4.1 - version: 30.4.1 + version: 30.4.1(supports-color@10.2.2) lint-staged: - specifier: ^17.2.0 - version: 17.2.0 + specifier: ^17.3.0 + version: 17.3.0 prettier: specifier: ^3.9.6 version: 3.9.6 rolldown: - specifier: ^1.2.1 - version: 1.2.1 + specifier: ^1.2.6 + version: 1.2.6 rollup: - specifier: ^4.62.3 - version: 4.62.3 + specifier: ^4.63.0 + version: 4.63.0 rollup-plugin-copy: specifier: ^3.5.0 version: 3.5.0 rollup-plugin-polyfill-node: specifier: ^0.13.0 - version: 0.13.0(rollup@4.62.3) + version: 0.13.0(rollup@4.63.0) rollup-plugin-swc3: specifier: ^0.12.1 - version: 0.12.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(rollup@4.62.3) + version: 0.12.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(rollup@4.63.0) tsx: specifier: ^4.21.0 version: 4.23.12 @@ -119,8 +119,8 @@ importers: specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 typescript-eslint: - specifier: ^8.65.0 - version: 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) + specifier: ^8.68.0 + version: 8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) lana: dependencies: @@ -135,8 +135,8 @@ importers: version: 3.1.0 devDependencies: '@salesforce/vscode-services': - specifier: ^67.12.0 - version: 67.13.3(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(@types/node@22.20.1) + specifier: ^67.15.0 + version: 67.15.0(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(@types/node@22.20.1)(supports-color@10.2.2) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -148,7 +148,7 @@ importers: version: 1.102.0 '@vscode/vsce': specifier: ^3.9.2 - version: 3.9.2 + version: 3.9.2(supports-color@10.2.2) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' @@ -157,16 +157,16 @@ importers: dependencies: '@docusaurus/core': specifier: ^3.10.2 - version: 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/faster': specifier: ^3.10.2 - version: 3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25) + version: 3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25) '@docusaurus/preset-classic': specifier: ^3.10.2 - version: 3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3) + version: 3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@10.2.2) '@easyops-cn/docusaurus-search-local': specifier: ^0.55.3 - version: 0.55.3(a4dd080eac6571e0807a8fbf8306816b) + version: 0.55.3(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@docusaurus/theme-common@3.10.2(0af196d69d8319b7dee05168d1f5fc43))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@mdx-js/react': specifier: ^3.1.1 version: 3.1.1(@types/react@19.2.17)(react@19.2.8) @@ -185,13 +185,13 @@ importers: devDependencies: '@docusaurus/module-type-aliases': specifier: ^3.10.2 - version: 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/tsconfig': specifier: ^3.10.2 version: 3.10.2 '@docusaurus/types': specifier: ^3.10.2 - version: 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' @@ -233,8 +233,8 @@ importers: specifier: ^1.33.0 version: 1.33.0 sass: - specifier: ^1.102.0 - version: 1.102.0 + specifier: ^1.103.1 + version: 1.103.1 typescript: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' @@ -1635,9 +1635,6 @@ packages: '@emnapi/core@1.11.3': resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} - '@emnapi/core@2.0.0-alpha.3': - resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} - '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -1647,9 +1644,6 @@ packages: '@emnapi/runtime@1.11.3': resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} - '@emnapi/runtime@2.0.0-alpha.3': - resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} - '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -1659,9 +1653,6 @@ packages: '@emnapi/wasi-threads@1.2.3': resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} - '@emnapi/wasi-threads@2.0.1': - resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -1818,8 +1809,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -2030,12 +2021,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jsforce/jsforce-node@3.10.19': - resolution: {integrity: sha512-k7i2Tntu1fLvkMtRcKDFU64/Fr2M692ECtbwIGX6hcOh5mj+jrMa1tlvcdwffxAMl+lPYCXnY2bjErxWmP84zA==} - engines: {node: '>=22'} - - '@jsforce/jsforce-node@3.10.22': - resolution: {integrity: sha512-4TLjnvTlBW59NmNSsRRV5dDEepQGfBKVD0WWQlJUJwkU0d5FFxo8GbfNmCOXkjjYAe1wv7SuvMzJKcLZHU6qyw==} + '@jsforce/jsforce-node@3.10.23': + resolution: {integrity: sha512-sepBNb9Bt0vSdXqZqq97p/EP8NJjjOnDQoRmiH2Lcm/nsznQsrsUttoNmNxfxrRubuJTIL9m4H1I7uOb8HrgRg==} engines: {node: '>=22'} '@jsonjoy.com/base64@1.1.2': @@ -2237,6 +2224,13 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -2249,13 +2243,6 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.2.1': - resolution: {integrity: sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - peerDependencies: - '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 - '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 - '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} engines: {node: '>= 16'} @@ -2304,24 +2291,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@node-rs/jieba-linux-arm64-musl@1.10.4': resolution: {integrity: sha512-Y/tiJ1+HeS5nnmLbZOE+66LbsPOHZ/PUckAYVeLlQfpygLEpLYdlh0aPpS5uiaWMjAXYZYdFkpZHhxDmSLpwpw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@node-rs/jieba-linux-x64-gnu@1.10.4': resolution: {integrity: sha512-WZO8ykRJpWGE9MHuZpy1lu3nJluPoeB+fIJJn5CWZ9YTVhNDWoCF4i/7nxz1ntulINYGQ8VVuCU9LD86Mek97g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@node-rs/jieba-linux-x64-musl@1.10.4': resolution: {integrity: sha512-uBBD4S1rGKcgCyAk6VCKatEVQb6EDD5I40v/DxODi5CuZVCANi9m5oee/MQbAoaX7RydA2f0OSCE9/tcwXEwUg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@node-rs/jieba-wasm32-wasi@1.10.4': resolution: {integrity: sha512-Y2umiKHjuIJy0uulNDz9SDYHdfq5Hmy7jY5nORO99B4pySKkcrMjpeVrmWXJLIsEKLJwcCXHxz8tjwU5/uhz0A==} @@ -2539,48 +2530,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.139.0': resolution: {integrity: sha512-u9e884ChAVRmIZ1jr/m46S96FoDQnruFjISLi4Y0i6Wu/JUUmIiw7+umLyXILJsPfUuqnN5BJLe23t07+Y6+IA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.139.0': resolution: {integrity: sha512-Z9tU2b3GJfAXOdirQmz4gZQkQkVjy53i77gf91l0733MQKa/qtk73KQQE2GzDtMqim+HyjpzvemmqzBtH2IJUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.139.0': resolution: {integrity: sha512-RyUbr7hzPK84YDWKs77PRYk9VBwWbsbuYsQzQWiSmLnARXTg2zntLPGfCH/1wpfUYdmGkp/6SsqTXSsYdw9Jgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.139.0': resolution: {integrity: sha512-dcQhjtcDvtR8BgkUpt03Yz5SzxdzYvTigenIJOEsiSX6G2t6yybEMxGWjp0dOuYGror0BaqcZfTyXR58amCEig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.139.0': resolution: {integrity: sha512-iuGrxysV4rGUymdKpn7bgZQ6Vix8Bi/6D/rp71HYIzphq6NKrsBhsGOYsSZte+uBFL43tXh7Xr7TM72sGliJNA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.139.0': resolution: {integrity: sha512-NxJdZZyaa2JLLvNfH/iJQXfCNfKcPMylwY4ObMpBVmW4Nq+RyUpVVQXrMMelcQ6rwx3nuhF1Iga8n+eoAKGCIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.139.0': resolution: {integrity: sha512-ePxBvvtzISmSsJ0RIj8FNikSCn58i1jtccj7XR4U9Li4iSzhkFyYlnJ51cQTUwkairz8WMTD4SpKoot8RyTnQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.139.0': resolution: {integrity: sha512-b/c2+mPXMOxG5x16n8yf9cjor/ntQQScmYnSmLEWIWJ4rfXd5dokMxx0kliSLA+YAGq6DD3K9BWi+aFXHiiV1w==} @@ -2614,89 +2613,89 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxc-project/types@0.142.0': - resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} '@peculiar/asn1-cms@2.8.0': @@ -2778,90 +2777,98 @@ packages: resolution: {integrity: sha512-yscDcAuDtvOfE0c8i8UVP+hjK43VIPpwfMv+73mdkpy12rZWnk5Kl6N6pUneQmaZ3PKTg611zemBHFpMjp9ZnQ==} engines: {node: '>=14'} - '@rolldown/binding-android-arm64@1.2.1': - resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} + '@rolldown/binding-android-arm-eabi@1.2.6': + resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.6': + resolution: {integrity: sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.1': - resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} + '@rolldown/binding-darwin-arm64@1.2.6': + resolution: {integrity: sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.1': - resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} + '@rolldown/binding-darwin-x64@1.2.6': + resolution: {integrity: sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.1': - resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} + '@rolldown/binding-freebsd-x64@1.2.6': + resolution: {integrity: sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': - resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': + resolution: {integrity: sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.1': - resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} + '@rolldown/binding-linux-arm64-gnu@1.2.6': + resolution: {integrity: sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.1': - resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} + '@rolldown/binding-linux-arm64-musl@1.2.6': + resolution: {integrity: sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.1': - resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} + '@rolldown/binding-linux-ppc64-gnu@1.2.6': + resolution: {integrity: sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.1': - resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.6': + resolution: {integrity: sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.1': - resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} + '@rolldown/binding-linux-x64-gnu@1.2.6': + resolution: {integrity: sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.1': - resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} + '@rolldown/binding-linux-x64-musl@1.2.6': + resolution: {integrity: sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.1': - resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} + '@rolldown/binding-openharmony-arm64@1.2.6': + resolution: {integrity: sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.2.1': - resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} - engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - - '@rolldown/binding-win32-arm64-msvc@1.2.1': - resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} + '@rolldown/binding-win32-arm64-msvc@1.2.6': + resolution: {integrity: sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.1': - resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} + '@rolldown/binding-win32-x64-msvc@1.2.6': + resolution: {integrity: sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2926,128 +2933,141 @@ packages: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.62.3': - resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + '@rollup/rollup-android-arm-eabi@4.63.0': + resolution: {integrity: sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.62.3': - resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + '@rollup/rollup-android-arm64@4.63.0': + resolution: {integrity: sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.62.3': - resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + '@rollup/rollup-darwin-arm64@4.63.0': + resolution: {integrity: sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.62.3': - resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + '@rollup/rollup-darwin-x64@4.63.0': + resolution: {integrity: sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.62.3': - resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + '@rollup/rollup-freebsd-arm64@4.63.0': + resolution: {integrity: sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.62.3': - resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + '@rollup/rollup-freebsd-x64@4.63.0': + resolution: {integrity: sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.62.3': - resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + resolution: {integrity: sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.62.3': - resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + resolution: {integrity: sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.62.3': - resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + '@rollup/rollup-linux-arm64-gnu@4.63.0': + resolution: {integrity: sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.62.3': - resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + '@rollup/rollup-linux-arm64-musl@4.63.0': + resolution: {integrity: sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==} cpu: [arm64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.62.3': - resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + '@rollup/rollup-linux-loong64-gnu@4.63.0': + resolution: {integrity: sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==} cpu: [loong64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.62.3': - resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + '@rollup/rollup-linux-loong64-musl@4.63.0': + resolution: {integrity: sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==} cpu: [loong64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.62.3': - resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + resolution: {integrity: sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==} cpu: [ppc64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.62.3': - resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + '@rollup/rollup-linux-ppc64-musl@4.63.0': + resolution: {integrity: sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==} cpu: [ppc64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.62.3': - resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + resolution: {integrity: sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.62.3': - resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + '@rollup/rollup-linux-riscv64-musl@4.63.0': + resolution: {integrity: sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==} cpu: [riscv64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.62.3': - resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + '@rollup/rollup-linux-s390x-gnu@4.63.0': + resolution: {integrity: sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.62.3': - resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + '@rollup/rollup-linux-x64-gnu@4.63.0': + resolution: {integrity: sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.62.3': - resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + '@rollup/rollup-linux-x64-musl@4.63.0': + resolution: {integrity: sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==} cpu: [x64] os: [linux] + libc: [musl] - '@rollup/rollup-openbsd-x64@4.62.3': - resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + '@rollup/rollup-openbsd-x64@4.63.0': + resolution: {integrity: sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.62.3': - resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + '@rollup/rollup-openharmony-arm64@4.63.0': + resolution: {integrity: sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.62.3': - resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + '@rollup/rollup-win32-arm64-msvc@4.63.0': + resolution: {integrity: sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.62.3': - resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + '@rollup/rollup-win32-ia32-msvc@4.63.0': + resolution: {integrity: sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.62.3': - resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + '@rollup/rollup-win32-x64-gnu@4.63.0': + resolution: {integrity: sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.62.3': - resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + '@rollup/rollup-win32-x64-msvc@4.63.0': + resolution: {integrity: sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==} cpu: [x64] os: [win32] @@ -3065,21 +3085,25 @@ packages: resolution: {integrity: sha512-C8owWG+yvo7X0oVLIXetkoJhIFBP1LYNcAQqtgLmJnQLQDklGuP83dKC+zISGQWpjawHfZ1ER96vLgoTrxKZdw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@1.7.12': resolution: {integrity: sha512-i51WWI64aRpsfSki6rN0aepPqXkVfS+vZM7+4bWDcmnhUmdMvhIPcYg0QRk3DtyJnu33jqNLM0WHY78k00NyfA==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@1.7.12': resolution: {integrity: sha512-MSos0FuPEefqo9V92ULd5hggKG29EkSNg1zDcypy0OkpsKh5pfjVxTLYFXgTcVyFoUQQbdG8zFBzYbwmJ8V4ew==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@1.7.12': resolution: {integrity: sha512-JcAMVKXOnjfpC3coWjCFPWD3Yl8RBw6a+IXQQ8mfRlHaHMIiOv8IfZqx15XRxMUn49CtP7Z0Na8iiAg2aKrcfw==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-wasm32-wasi@1.7.12': resolution: {integrity: sha512-n+ZqP6ZMc0nhOgvadg5VhEs9ojtbES80AcWeFnmGkbzIszvGSO63GKNiRkXtjJ9KFuRzytbbmsCqkUVH+Tywxg==} @@ -3115,12 +3139,8 @@ packages: '@rspack/lite-tapable@1.1.0': resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} - '@salesforce/core@9.1.0': - resolution: {integrity: sha512-ID9g4YH0yZBeWI0eKJv2IJRRN6ZuNjwpIOjEZm9a79Gzxa3IITsV4PCvD8q7JA3PKqiln57p8yJE458Kn5KW1g==} - engines: {node: '>=22.0.0'} - - '@salesforce/core@9.1.4': - resolution: {integrity: sha512-S4VZ0xstYOAs5dwt7EDGkuFZA8rddy0wmuPTGjsIhgAPzuq3I5lKyBNwqXMMxdhBcWi+P4cXccHLpOYivgdrXQ==} + '@salesforce/core@9.1.7': + resolution: {integrity: sha512-6ecm8k3RQ5lLZ8ecv4kuY9ORpdZ5vceQO/QEDkEIfg+XbT3k10lMztX6/Q0lM6EmeNGT8xqwbdHaYvcKhCwyDQ==} engines: {node: '>=22.0.0'} '@salesforce/kit@4.0.0': @@ -3130,24 +3150,24 @@ packages: '@salesforce/playwright-vscode-ext@1.3.11': resolution: {integrity: sha512-3gqNjZY6nvzkfos01g0n7d2TfxSxPURv+yw7zq79IZV0bq3KopakSgkBsoWMGyAiUFdvfoe6+Nx3m10vVtGwQA==} - '@salesforce/source-deploy-retrieve@13.2.0': - resolution: {integrity: sha512-4R3Sd6it/oX8IRm5JGvi6fba0318VVHz/bgsDFEchHo8ed73LU021nWJJ5hBcveHdW+MUJioO9rU7MCOYLh1uA==} + '@salesforce/source-deploy-retrieve@13.3.0': + resolution: {integrity: sha512-mwiffD4Z2VnmU9OqCZr/E7SjhkVhgRv8SkVcG3AneRiMZI2nb9DHyJZhqDm/yzDuoCCBZnxEXTBj8h6onX0QqQ==} engines: {node: '>=22.0.0'} - '@salesforce/source-tracking@8.1.0': - resolution: {integrity: sha512-ookx5YVI4ddEkNWwSkqPrXU5MKXkcuGig0rW7FzBtDnpIZU8q3+elilIJ5PI/YXBAIpUZl69Alsjh6+Bl7ZqeA==} + '@salesforce/source-tracking@8.1.2': + resolution: {integrity: sha512-NxiXTgnKskwjRjc4RglpWbe0ri/6g08zyeiMnoBYuzcwz0Emb2loHduenSGw9vFgbfjE8vfnuqAF2MW3BlZDcg==} engines: {node: '>=22.0.0'} - '@salesforce/ts-types@3.0.1': - resolution: {integrity: sha512-NWkveMYT2I3O7EAYwWHi6/ba2YUwp9MFi/zJzA+czK5MVqJfdD6FZbiQrTbALZNvFzgFSdq9BvHm6ygRmtROzw==} + '@salesforce/ts-types@3.1.0': + resolution: {integrity: sha512-ynzIcFj6JVL3PcbxKlHfJ9dtGQUqZvND3APrfPjUPB63FeSN+HK12pYhpJV49wrjPiig0FYFFwhmSYq2OSQoRA==} engines: {node: '>=22.0.0'} '@salesforce/types@1.8.0': resolution: {integrity: sha512-sliQcoI0XeR3YYUElIV3z93l7ZL9lDtnegVGbknBFbQKjN/oxH/PQSiM4imnXnModhFQSoe/V3mGXniASoLNvA==} engines: {node: '>=18'} - '@salesforce/vscode-services@67.13.3': - resolution: {integrity: sha512-45EukRQHuDT54Eafa+McrUofz1iXSCoMueXdTmwLj6JmXchL4wxoHHBOYv+x7yRAhdiiSfOxdySZ0Zqf+1SwwQ==} + '@salesforce/vscode-services@67.15.0': + resolution: {integrity: sha512-uHzyMFd7Yu4PbGv7XJ7dohpx6Sk3qVQLkJepnPVrKPdzQ4vy2jkKv3P5+P7uOO6CblHWTkCdVLBilI7oNXHf0g==} '@secretlint/config-creator@10.2.2': resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} @@ -3323,53 +3343,119 @@ packages: cpu: [arm64] os: [darwin] + '@swc/core-darwin-arm64@1.16.1': + resolution: {integrity: sha512-zlJblJ8ncErD43lKdxjbUaUskJQf+LxiPXYcWXD8/8ZMV+7uuAT+CwjciLXpyZBd5Pq/S726bMpeeAwSeL1hhg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + '@swc/core-darwin-x64@1.15.47': resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} engines: {node: '>=10'} cpu: [x64] os: [darwin] + '@swc/core-darwin-x64@1.16.1': + resolution: {integrity: sha512-IN0BmPWb0YAh/17mmlWB/HDBtTw2MfuW4hulf/tQAgTQBRH17l+z499bNJLK6LizSjqs0P7V+jU38Zj+vJC1DA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + '@swc/core-linux-arm-gnueabihf@1.15.47': resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} engines: {node: '>=10'} cpu: [arm] os: [linux] + '@swc/core-linux-arm-gnueabihf@1.16.1': + resolution: {integrity: sha512-EYgrx2YOCQ2Twz2S793kqNjPkpvYVUPzzR95bIb7by+VQcyaai4lZZ2iz/tZvcFVKSNcN3/JTKwx+aBn2ZL52A==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + '@swc/core-linux-arm64-gnu@1.15.47': resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-gnu@1.16.1': + resolution: {integrity: sha512-moyKm0YZlHdHohzm1YwgAyesqnE853rO0REMfJLFAova51wF9BNi+3ZW2PeS7Vqvn6HeJuepLpAHbBdZctxpHA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.47': resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] + + '@swc/core-linux-arm64-musl@1.16.1': + resolution: {integrity: sha512-kKGBO9wdapiSzuf5ZzZ2fYtlu1BNSYtIIUxvH1ir/gcelTOREEHGDCLTDFx/2Knf878nU11A40z7LxwasEFxqA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] '@swc/core-linux-ppc64-gnu@1.15.47': resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] + libc: [glibc] + + '@swc/core-linux-ppc64-gnu@1.16.1': + resolution: {integrity: sha512-nZ6qahtLxC3PM54cWOQZHxt4lTCF/3J4LIoWWzz6v7A+rLs8Dx54anYQf7mH3eIi8KlNpgKci/ie8ZSqFN8O7A==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] '@swc/core-linux-s390x-gnu@1.15.47': resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} engines: {node: '>=10'} cpu: [s390x] os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.16.1': + resolution: {integrity: sha512-4ji5PNzhYq193Z4/4xUaSoNJza6iCkDJSzhetrbB6KOYxsr+kxtQr8ePWhMJUiMt6JUWtXaZ1PYT8FhtED+nGA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] '@swc/core-linux-x64-gnu@1.15.47': resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.16.1': + resolution: {integrity: sha512-VJQxqrisHV+B394IgrOu8YsIIXZgffnf5tO+yc9Z/hoUpuZEvuQTjWwlnpZdpyD+0nx6LTD1/3k646JYm43yJA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.47': resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] + + '@swc/core-linux-x64-musl@1.16.1': + resolution: {integrity: sha512-r9oV1mwxxsIGcLV1IQ/tw76MW3doatKze1QFWuC+a7QqJUkhY/bKTSVk6NpKKUGm2LDsE33Va8VqSClfA7vSiQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.47': resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} @@ -3377,18 +3463,36 @@ packages: cpu: [arm64] os: [win32] + '@swc/core-win32-arm64-msvc@1.16.1': + resolution: {integrity: sha512-6huNRessoBLxWEqBm5zJXyCQ27TO7anvkdiuQ5MDO4CJni0nOXEqKtV9RllQ2TdyENKKsUMXVnIfW2hIXx/R5Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + '@swc/core-win32-ia32-msvc@1.15.47': resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} engines: {node: '>=10'} cpu: [ia32] os: [win32] + '@swc/core-win32-ia32-msvc@1.16.1': + resolution: {integrity: sha512-OVKJFUzphrGmsh+BGtcZDesx0YryV7/Yvy5XGgTqnrZfjnyfcr5uaqYQugCckdIlupc5Vs3XtDjRAj12z4ZPlw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + '@swc/core-win32-x64-msvc@1.15.47': resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} engines: {node: '>=10'} cpu: [x64] os: [win32] + '@swc/core-win32-x64-msvc@1.16.1': + resolution: {integrity: sha512-Bt+VIhWYCGk4urklnkkteLUOeLv1VxigwTCeB/xC6rBZxY6IIKdDwCJf6on3E3SUGsIqmQS6QqtuJQc1VxF4Aw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + '@swc/core@1.15.47': resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} engines: {node: '>=10'} @@ -3398,6 +3502,15 @@ packages: '@swc/helpers': optional: true + '@swc/core@1.16.1': + resolution: {integrity: sha512-nUaeu91O5QZKrQdaDCHd402ogUIoNOOjpkZNq0UomWK0G6gDaGmLhvddF1/3BXf5O8aLyo6ZPY/aMDWvaJQ/hg==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -3427,36 +3540,42 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/html-linux-arm64-musl@1.15.47': resolution: {integrity: sha512-NyXRQiVBkeutgCwCxUUaFqZdDmpZONs7Zz3AZGoY35s9GwXF412Nt22fK/e7lO/sM6G/+S3rOom/0xTmUQSK6A==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/html-linux-ppc64-gnu@1.15.47': resolution: {integrity: sha512-rQ+XLJHFzu0e4M5p1+8kF2FKzI7WO9KPPji87OuicHZVrDCEqg7/jSGu3hys9CjIQIYVfR+tAFuZz3Q1/jOoNA==} engines: {node: '>=10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@swc/html-linux-s390x-gnu@1.15.47': resolution: {integrity: sha512-w6bitHrllrE3lqmf14cT3spmWhZHGts1O08O4adzxztSPto7O5GM3IerqZm/NtsqlxNsfbVHnhaNXxXWe/8Q+Q==} engines: {node: '>=10'} cpu: [s390x] os: [linux] + libc: [glibc] '@swc/html-linux-x64-gnu@1.15.47': resolution: {integrity: sha512-FTA7E29gcyadd71prg8sJUVacYrIhAXS8L7p48yCfgcNOKquofN1TDOrHVOyYMoxplL3FUwwbN+AZxWO7QfWPQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/html-linux-x64-musl@1.15.47': resolution: {integrity: sha512-g+K1GKUrj+S9o8Qsgii2g7ooMzZ750R4IAqH5CVElIA5FTMAx7KGnu9ga6aEnRwwLYxY3s1k/nN+ls0NEk240g==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/html-win32-arm64-msvc@1.15.47': resolution: {integrity: sha512-5Iw3i00JdTySi+ccc2CM5bxY+8TZivQmcTqUC6mUdAY0/KYBgSe1/L294UJQ4OTLQqNDOYXY9jdb070zZUX7jg==} @@ -3489,6 +3608,9 @@ packages: '@swc/types@0.1.27': resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -3709,63 +3831,63 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.68.0': + resolution: {integrity: sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.68.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.68.0': + resolution: {integrity: sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.68.0': + resolution: {integrity: sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.68.0': + resolution: {integrity: sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.68.0': + resolution: {integrity: sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.68.0': + resolution: {integrity: sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + '@typescript-eslint/types@8.68.0': + resolution: {integrity: sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/typescript-estree@8.68.0': + resolution: {integrity: sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.68.0': + resolution: {integrity: sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.68.0': + resolution: {integrity: sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript/typescript-aix-ppc64@7.0.2': @@ -3945,51 +4067,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -4137,6 +4269,7 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -4212,9 +4345,6 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -4451,11 +4581,6 @@ packages: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} - baseline-browser-mapping@2.10.31: - resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} - engines: {node: '>=6.0.0'} - hasBin: true - baseline-browser-mapping@2.11.7: resolution: {integrity: sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==} engines: {node: '>=6.0.0'} @@ -4507,13 +4632,17 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} - brace-expansion@2.1.1: - resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -4521,11 +4650,6 @@ packages: browserify-zlib@0.1.4: resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.28.7: resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -4615,9 +4739,6 @@ packages: caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001793: - resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} - caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} @@ -4837,8 +4958,8 @@ packages: resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} engines: {node: '>= 0.8.0'} - concurrently@10.0.4: - resolution: {integrity: sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==} + concurrently@10.0.5: + resolution: {integrity: sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==} engines: {node: '>=22'} hasBin: true @@ -4914,6 +5035,9 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -5068,8 +5192,8 @@ packages: csv-parse@5.6.0: resolution: {integrity: sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==} - csv-stringify@6.8.0: - resolution: {integrity: sha512-Ya0OOHb6XbgPaZKH7dcdmwXe3azUS0TwmlbVdS75HoAhnHApSkiQcfEJoC/s5WwGsrXwOjBDr3FTmCZPjkssgg==} + csv-stringify@6.8.3: + resolution: {integrity: sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==} data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} @@ -5289,9 +5413,6 @@ packages: effect@3.22.1: resolution: {integrity: sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==} - electron-to-chromium@1.5.360: - resolution: {integrity: sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==} - electron-to-chromium@1.5.398: resolution: {integrity: sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==} @@ -5438,8 +5559,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.8.0: - resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + eslint@10.9.1: + resolution: {integrity: sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -5589,11 +5710,8 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - - fast-uri@4.0.0: - resolution: {integrity: sha512-l90y339r2DkZs/ldcWQXcwTjkbp/NbuJDGYoQ3awBgaT3GXOFkm3OkVpz6Z86TywYcya0eVP2r1kTV90f3krGQ==} + fast-uri@4.1.3: + resolution: {integrity: sha512-7+72G6vLt7jjNas8SmSATx2qeyRIjxeqO3i4IkmDTxlqYZRKANhOe1bnovcp4WZmvsYrp60WyqPyHqgRiX0yXw==} fast-xml-builder@1.3.1: resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==} @@ -5685,8 +5803,8 @@ packages: resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} hasBin: true - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -6085,6 +6203,10 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + image-size@2.0.2: resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} engines: {node: '>=16.x'} @@ -6365,8 +6487,8 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} - isomorphic-git@1.41.7: - resolution: {integrity: sha512-ZCyMNg0eLUmuhGXdVQFi56fM93kwIqqlUSVnWbgzf1oDYidrQbWfrf+hEvn2E8xyo191zRPQyDCIlwg6YJ6isQ==} + isomorphic-git@1.41.9: + resolution: {integrity: sha512-WOh4ujm5mznHphzQvwj9bVj+pQpV0oZ8H4lAnh4dSaie+lN/lJ2rb6rNOOcP+2m4z8IOQp186TkEULD28NMBJg==} engines: {node: '>=14.17'} hasBin: true @@ -6588,8 +6710,8 @@ packages: engines: {node: '>=6'} hasBin: true - jsforce@3.10.22: - resolution: {integrity: sha512-q4ZJTFyl2CDPnsGCsx/LttHMtbR1PEX+iOvpQMXDgJ0E7Lf0itMXTMhTnwOFIyAhZ26PNY5zNCs88STqE8iGCw==} + jsforce@3.10.23: + resolution: {integrity: sha512-29TEs2YygHNv/1QVeofcVHb8feX6AiwIeQBEySYEBTMoc4rPaXDZ5OydPoM7XbfrxmAMU8OQYc/dWT6YQHZE8w==} engines: {node: '>=22'} hasBin: true @@ -6731,24 +6853,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} @@ -6776,8 +6902,8 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - lint-staged@17.2.0: - resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} + lint-staged@17.3.0: + resolution: {integrity: sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==} engines: {node: '>=22.22.1'} hasBin: true @@ -7198,6 +7324,10 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -7354,9 +7484,6 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.44: - resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} - node-releases@2.0.51: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} @@ -7670,6 +7797,10 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -8164,8 +8295,8 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - process-warning@5.0.0: - resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} process@0.11.10: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} @@ -8350,8 +8481,8 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - readdirp@5.0.0: - resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} real-require@0.2.0: @@ -8503,8 +8634,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.2.1: - resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} + rolldown@1.2.6: + resolution: {integrity: sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -8529,8 +8660,8 @@ packages: peerDependencies: rollup: ^2.0.0 || ^3.0.0 || ^4.0.0 - rollup@4.62.3: - resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + rollup@4.63.0: + resolution: {integrity: sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -8573,8 +8704,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sass@1.102.0: - resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==} + sass@1.103.1: + resolution: {integrity: sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==} engines: {node: '>=20.19.0'} hasBin: true @@ -9101,8 +9232,8 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -9116,15 +9247,15 @@ packages: tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} - tldts-core@7.4.3: - resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} + tldts-core@7.4.11: + resolution: {integrity: sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg==} tldts@6.1.86: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tldts@7.4.3: - resolution: {integrity: sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==} + tldts@7.4.11: + resolution: {integrity: sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw==} hasBin: true tmp@0.2.7: @@ -9154,8 +9285,8 @@ packages: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} tr46@5.1.1: @@ -9252,8 +9383,8 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.68.0: + resolution: {integrity: sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -9278,6 +9409,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + undici@8.9.0: resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} engines: {node: '>=22.19.0'} @@ -9422,6 +9557,9 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -9860,13 +9998,13 @@ snapshots: dependencies: '@azu/format-text': 1.0.2 - '@azure-rest/core-client@2.8.0': + '@azure-rest/core-client@2.8.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-auth': 1.11.0(supports-color@10.2.2) + '@azure/core-rest-pipeline': 1.25.0(supports-color@10.2.2) '@azure/core-tracing': 1.4.0 - '@typespec/ts-http-runtime': 0.3.8 + '@typespec/ts-http-runtime': 0.3.8(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -9875,36 +10013,36 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/core-auth@1.11.0': + '@azure/core-auth@1.11.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-util': 1.14.0 + '@azure/core-util': 1.14.0(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/core-client@1.11.0': + '@azure/core-client@1.11.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-auth': 1.11.0(supports-color@10.2.2) + '@azure/core-rest-pipeline': 1.25.0(supports-color@10.2.2) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@10.2.2) + '@azure/logger': 1.4.0(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color '@azure/core-process@1.0.0': {} - '@azure/core-rest-pipeline@1.25.0': + '@azure/core-rest-pipeline@1.25.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 + '@azure/core-auth': 1.11.0(supports-color@10.2.2) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 - '@typespec/ts-http-runtime': 0.3.8 + '@azure/core-util': 1.14.0(supports-color@10.2.2) + '@azure/logger': 1.4.0(supports-color@10.2.2) + '@typespec/ts-http-runtime': 0.3.8(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -9913,24 +10051,24 @@ snapshots: dependencies: tslib: 2.8.1 - '@azure/core-util@1.14.0': + '@azure/core-util@1.14.0(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@typespec/ts-http-runtime': 0.3.8 + '@typespec/ts-http-runtime': 0.3.8(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/identity@4.13.2': + '@azure/identity@4.13.2(supports-color@10.2.2)': dependencies: '@azure/abort-controller': 2.2.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.0 + '@azure/core-auth': 1.11.0(supports-color@10.2.2) + '@azure/core-client': 1.11.0(supports-color@10.2.2) '@azure/core-process': 1.0.0 - '@azure/core-rest-pipeline': 1.25.0 + '@azure/core-rest-pipeline': 1.25.0(supports-color@10.2.2) '@azure/core-tracing': 1.4.0 - '@azure/core-util': 1.14.0 - '@azure/logger': 1.4.0 + '@azure/core-util': 1.14.0(supports-color@10.2.2) + '@azure/logger': 1.4.0(supports-color@10.2.2) '@azure/msal-browser': 5.20.0 '@azure/msal-node': 5.6.0 open: 10.2.0 @@ -9938,20 +10076,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/logger@1.4.0': + '@azure/logger@1.4.0(supports-color@10.2.2)': dependencies: - '@typespec/ts-http-runtime': 0.3.8 + '@typespec/ts-http-runtime': 0.3.8(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color - '@azure/monitor-opentelemetry-exporter@1.0.0-beta.44': + '@azure/monitor-opentelemetry-exporter@1.0.0-beta.44(supports-color@10.2.2)': dependencies: - '@azure-rest/core-client': 2.8.0 - '@azure/core-auth': 1.11.0 - '@azure/core-client': 1.11.0 - '@azure/core-rest-pipeline': 1.25.0 - '@azure/core-util': 1.14.0 + '@azure-rest/core-client': 2.8.0(supports-color@10.2.2) + '@azure/core-auth': 1.11.0(supports-color@10.2.2) + '@azure/core-client': 1.11.0(supports-color@10.2.2) + '@azure/core-rest-pipeline': 1.25.0(supports-color@10.2.2) + '@azure/core-util': 1.14.0(supports-color@10.2.2) '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.220.0 '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) @@ -9991,20 +10129,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -10027,36 +10165,36 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.2 + browserslist: 4.28.7 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: @@ -10064,26 +10202,26 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -10093,27 +10231,27 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-wrap-function': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/helper-wrap-function': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -10126,10 +10264,10 @@ snapshots: '@babel/helper-validator-option@7.29.7': {} - '@babel/helper-wrap-function@7.29.7': + '@babel/helper-wrap-function@7.29.7(supports-color@10.2.2)': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -10143,648 +10281,648 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7)': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/template': 7.29.7 - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-systemjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) - '@babel/traverse': 7.29.7 + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-constant-elements@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-constant-elements@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-development@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx-development@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-regenerator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-spread@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7)': + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/preset-env@7.29.7(@babel/core@7.29.7)': + '@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7) - '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7) - '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-systemjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-spread': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@10.2.2)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) core-js-compat: 3.49.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7)': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/types': 7.29.7 esutils: 2.0.3 - '@babel/preset-react@7.29.7(@babel/core@7.29.7)': + '@babel/preset-react@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -10800,7 +10938,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -10808,7 +10946,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/template': 7.29.7 '@babel/types': 7.29.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -11155,53 +11293,19 @@ snapshots: - '@algolia/client-search' - algoliasearch - '@docusaurus/babel@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@babel/core': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) - '@babel/preset-react': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@babel/runtime': 7.29.7 - '@babel/traverse': 7.29.7 - '@docusaurus/logger': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - babel-plugin-dynamic-import-node: 2.3.3 - fs-extra: 11.4.0 - tslib: 2.8.1 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/core' - - '@swc/css' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - react - - react-dom - - supports-color - - uglify-js - - webpack-cli - - '@docusaurus/babel@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/babel@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/generator': 7.29.7 - '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) - '@babel/preset-react': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-react': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/runtime': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@docusaurus/logger': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) babel-plugin-dynamic-import-node: 2.3.3 fs-extra: 11.4.0 tslib: 2.8.1 @@ -11223,34 +11327,34 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/bundler@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/bundler@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@docusaurus/babel': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@docusaurus/babel': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/cssnano-preset': 3.10.2 '@docusaurus/logger': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - babel-loader: 9.2.1(@babel/core@7.29.7)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + babel-loader: 9.2.1(@babel/core@7.29.7(supports-color@10.2.2))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) clean-css: 5.3.3 - copy-webpack-plugin: 11.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - css-loader: 6.11.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + copy-webpack-plugin: 11.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) + css-loader: 6.11.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) + css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) cssnano: 6.1.2(postcss@8.5.25) - file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) html-minifier-terser: 7.2.0 - mini-css-extract-plugin: 2.10.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - null-loader: 4.0.1(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + mini-css-extract-plugin: 2.10.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) + null-loader: 4.0.1(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) postcss: 8.5.25 - postcss-loader: 7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + postcss-loader: 7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) postcss-preset-env: 10.6.1(postcss@8.5.25) - terser-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + terser-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25) - webpackbar: 7.0.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) + webpackbar: 7.0.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) optionalDependencies: - '@docusaurus/faster': 3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25) + '@docusaurus/faster': 3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25) transitivePeerDependencies: - '@minify-html/node' - '@parcel/css' @@ -11268,15 +11372,15 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/core@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/core@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/babel': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/bundler': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/babel': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/bundler': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.8) boxen: 6.2.1 chalk: 4.1.2 @@ -11292,7 +11396,7 @@ snapshots: execa: 5.1.1 fs-extra: 11.4.0 html-tags: 3.3.1 - html-webpack-plugin: 5.6.8(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + html-webpack-plugin: 5.6.8(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) leven: 3.1.0 lodash: 4.18.1 open: 8.4.2 @@ -11302,7 +11406,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.8)' - react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) react-router: 5.3.4(react@19.2.8) react-router-config: 5.1.1(react-router@5.3.4(react@19.2.8))(react@19.2.8) react-router-dom: 5.3.4(react@19.2.8) @@ -11311,12 +11415,12 @@ snapshots: tinypool: 1.1.1 tslib: 2.8.1 update-notifier: 6.0.2 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) webpack-bundle-analyzer: 4.10.2 - webpack-dev-server: 6.0.0(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + webpack-dev-server: 6.0.0(supports-color@10.2.2)(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) webpack-merge: 6.0.1 optionalDependencies: - '@docusaurus/faster': 3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25) + '@docusaurus/faster': 3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25) transitivePeerDependencies: - '@minify-html/node' - '@parcel/css' @@ -11345,18 +11449,18 @@ snapshots: postcss-sort-media-queries: 5.2.0(postcss@8.5.25) tslib: 2.8.1 - '@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25)': + '@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25)': dependencies: - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@rspack/core': 1.7.12(@swc/helpers@0.5.23) '@swc/core': 1.15.47(@swc/helpers@0.5.23) '@swc/html': 1.15.47 browserslist: 4.28.7 lightningcss: 1.33.0 semver: 7.8.5 - swc-loader: 0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + swc-loader: 0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) tslib: 2.8.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(lightningcss@1.33.0)(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - '@minify-html/node' - '@swc/css' @@ -11375,34 +11479,34 @@ snapshots: chalk: 4.1.2 tslib: 2.8.1 - '@docusaurus/mdx-loader@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/mdx-loader@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: '@docusaurus/logger': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@mdx-js/mdx': 3.1.1 + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) '@slorber/remark-comment': 1.0.0 escape-html: 1.0.3 estree-util-value-to-estree: 3.5.0 - file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) fs-extra: 11.4.0 image-size: 2.0.2 - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@10.2.2) mdast-util-to-string: 4.0.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) rehype-raw: 7.0.0 - remark-directive: 3.0.1 + remark-directive: 3.0.1(supports-color@10.2.2) remark-emoji: 4.0.1 - remark-frontmatter: 5.0.0 - remark-gfm: 4.0.1 + remark-frontmatter: 5.0.0(supports-color@10.2.2) + remark-gfm: 4.0.1(supports-color@10.2.2) stringify-object: 3.3.0 tslib: 2.8.1 unified: 11.0.5 unist-util-visit: 5.1.0 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) vfile: 6.0.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - '@minify-html/node' - '@swc/core' @@ -11419,9 +11523,9 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/module-type-aliases@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/module-type-aliases@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@types/history': 4.7.11 '@types/react': 19.2.17 '@types/react-router-config': 5.0.11 @@ -11446,17 +11550,17 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/plugin-content-blog@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-content-blog@3.10.2(88f62e69ce637549add1e67713d8dfbd)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/theme-common': 3.10.2(0af196d69d8319b7dee05168d1f5fc43) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) cheerio: 1.0.0-rc.12 combine-promises: 1.2.0 feed: 4.2.2 @@ -11469,7 +11573,7 @@ snapshots: tslib: 2.8.1 unist-util-visit: 5.1.0 utility-types: 3.11.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -11493,17 +11597,17 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/theme-common': 3.10.2(0af196d69d8319b7dee05168d1f5fc43) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.4.0 @@ -11514,7 +11618,7 @@ snapshots: schema-dts: 1.1.5 tslib: 2.8.1 utility-types: 3.11.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -11538,18 +11642,18 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-content-pages@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-content-pages@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) fs-extra: 11.4.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -11573,12 +11677,12 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-css-cascade-layers@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-css-cascade-layers@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - '@docusaurus/faster' @@ -11605,11 +11709,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-debug@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-debug@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) fs-extra: 11.4.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -11638,11 +11742,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-analytics@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-google-analytics@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -11669,11 +11773,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-gtag@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-google-gtag@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -11700,11 +11804,11 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-google-tag-manager@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-google-tag-manager@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 @@ -11731,14 +11835,14 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-sitemap@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-sitemap@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/logger': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) fs-extra: 11.4.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -11767,18 +11871,18 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/plugin-svgr@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/plugin-svgr@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2) - '@svgr/webpack': 8.1.0(@typescript/typescript6@6.0.2) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) + '@svgr/webpack': 8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) tslib: 2.8.1 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - '@docusaurus/faster' - '@mdx-js/react' @@ -11802,23 +11906,23 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/preset-classic@3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)': - dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-blog': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-pages': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-css-cascade-layers': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-debug': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-google-analytics': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-google-gtag': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-google-tag-manager': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-sitemap': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-svgr': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-classic': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-search-algolia': 3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3) - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/preset-classic@3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@10.2.2)': + dependencies: + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-content-blog': 3.10.2(88f62e69ce637549add1e67713d8dfbd) + '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-content-pages': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-css-cascade-layers': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-debug': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-google-analytics': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-google-gtag': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-google-tag-manager': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-sitemap': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-svgr': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/theme-classic': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/theme-common': 3.10.2(0af196d69d8319b7dee05168d1f5fc43) + '@docusaurus/theme-search-algolia': 3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@10.2.2) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: @@ -11852,21 +11956,21 @@ snapshots: '@types/react': 19.2.17 react: 19.2.8 - '@docusaurus/theme-classic@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/theme-classic@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/logger': 3.10.2 - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-blog': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-pages': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-content-blog': 3.10.2(88f62e69ce637549add1e67713d8dfbd) + '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-content-pages': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/theme-common': 3.10.2(0af196d69d8319b7dee05168d1f5fc43) '@docusaurus/theme-translations': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.8) clsx: 2.1.1 copy-text-to-clipboard: 3.2.2 @@ -11904,13 +12008,13 @@ snapshots: - utf-8-validate - webpack-cli - '@docusaurus/theme-common@3.10.2(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/theme-common@3.10.2(0af196d69d8319b7dee05168d1f5fc43)': dependencies: - '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/mdx-loader': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/module-type-aliases': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@types/history': 4.7.11 '@types/react': 19.2.17 '@types/react-router-config': 5.0.11 @@ -11937,17 +12041,17 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/theme-search-algolia@3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)': + '@docusaurus/theme-search-algolia@3.10.2(@algolia/client-search@5.56.0)(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@types/react@19.2.17)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3)(supports-color@10.2.2)': dependencies: '@algolia/autocomplete-core': 1.19.9(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3) '@docsearch/react': 4.7.0(@algolia/client-search@5.56.0)(@types/react@19.2.17)(algoliasearch@5.56.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(search-insights@2.17.3) - '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/core': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@docusaurus/logger': 3.10.2 - '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/theme-common': 3.10.2(0af196d69d8319b7dee05168d1f5fc43) '@docusaurus/theme-translations': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) algoliasearch: 5.56.0 algoliasearch-helper: 3.29.2(algoliasearch@5.56.0) clsx: 2.1.1 @@ -11991,39 +12095,9 @@ snapshots: '@docusaurus/tsconfig@3.10.2': {} - '@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@mdx-js/mdx': 3.1.1 - '@types/history': 4.7.11 - '@types/mdast': 4.0.4 - '@types/react': 19.2.17 - commander: 5.1.0 - joi: 17.13.4 - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' - utility-types: 3.11.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25) - webpack-merge: 5.10.0 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/core' - - '@swc/css' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - supports-color - - uglify-js - - webpack-cli - - '@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@mdx-js/mdx': 3.1.1 + '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) '@types/history': 4.7.11 '@types/mdast': 4.0.4 '@types/react': 19.2.17 @@ -12033,7 +12107,7 @@ snapshots: react-dom: 19.2.8(react@19.2.8) react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' utility-types: 3.11.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) webpack-merge: 5.10.0 transitivePeerDependencies: - '@minify-html/node' @@ -12051,31 +12125,9 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils-common@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - tslib: 2.8.1 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/core' - - '@swc/css' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - react - - react-dom - - supports-color - - uglify-js - - webpack-cli - - '@docusaurus/utils-common@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/utils-common@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - '@minify-html/node' @@ -12095,11 +12147,11 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils-validation@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/utils-validation@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: '@docusaurus/logger': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) fs-extra: 11.4.0 joi: 17.13.4 js-yaml: 4.3.0 @@ -12123,56 +12175,15 @@ snapshots: - uglify-js - webpack-cli - '@docusaurus/utils@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@11ty/gray-matter': 1.0.0 - '@docusaurus/logger': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - escape-string-regexp: 4.0.0 - execa: 5.1.1 - file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - fs-extra: 11.4.0 - github-slugger: 1.5.0 - globby: 11.1.0 - jiti: 1.21.7 - js-yaml: 4.3.0 - lodash: 4.18.1 - micromatch: 4.0.8 - p-queue: 6.6.2 - prompts: 2.4.2 - resolve-pathname: 3.0.0 - tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - utility-types: 3.11.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25) - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/core' - - '@swc/css' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - react - - react-dom - - supports-color - - uglify-js - - webpack-cli - - '@docusaurus/utils@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@docusaurus/utils@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: '@11ty/gray-matter': 1.0.0 '@docusaurus/logger': 3.10.2 - '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/types': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) escape-string-regexp: 4.0.0 execa: 5.1.1 - file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) fs-extra: 11.4.0 github-slugger: 1.5.0 globby: 11.1.0 @@ -12184,9 +12195,9 @@ snapshots: prompts: 2.4.2 resolve-pathname: 3.0.0 tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) utility-types: 3.11.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - '@minify-html/node' - '@swc/core' @@ -12212,20 +12223,20 @@ snapshots: cssesc: 3.0.0 immediate: 3.3.0 - '@easyops-cn/docusaurus-search-local@0.55.3(a4dd080eac6571e0807a8fbf8306816b)': + '@easyops-cn/docusaurus-search-local@0.55.3(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@docusaurus/theme-common@3.10.2(0af196d69d8319b7dee05168d1f5fc43))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: - '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/theme-common': 3.10.2(@docusaurus/plugin-content-docs@3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/helpers@0.5.23)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@typescript/typescript6@6.0.2)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/plugin-content-docs': 3.10.2(@docusaurus/faster@3.10.2(@docusaurus/types@3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2))(@swc/helpers@0.5.23)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(postcss@8.5.25))(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8))(@rspack/core@1.7.12(@swc/helpers@0.5.23))(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(@typescript/typescript6@6.0.2)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/theme-common': 3.10.2(0af196d69d8319b7dee05168d1f5fc43) '@docusaurus/theme-translations': 3.10.2 - '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@docusaurus/utils': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-common': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) + '@docusaurus/utils-validation': 3.10.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) '@easyops-cn/autocomplete.js': 0.38.1 '@node-rs/jieba': 1.10.4 cheerio: 1.2.0 clsx: 2.1.1 comlink: 4.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 klaw-sync: 6.0.0 lunr: 2.3.9 @@ -12257,13 +12268,13 @@ snapshots: - utf-8-validate - webpack-cli - '@effect/opentelemetry@0.63.0(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@3.22.1)': + '@effect/opentelemetry@0.63.0(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@3.22.1)': dependencies: '@effect/platform': 0.96.3(effect@3.22.1) '@opentelemetry/api': 1.9.1 '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-node': 2.8.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-web': 2.8.0(@opentelemetry/api@1.9.1) @@ -12295,12 +12306,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@2.0.0-alpha.3': - dependencies: - '@emnapi/wasi-threads': 2.0.1 - tslib: 2.8.1 - optional: true - '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -12316,11 +12321,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/runtime@2.0.0-alpha.3': - dependencies: - tslib: 2.8.1 - optional: true - '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -12336,11 +12336,6 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@2.0.1': - dependencies: - tslib: 2.8.1 - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true @@ -12419,18 +12414,18 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.8.0(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.10.1(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))': dependencies: - eslint: 10.8.0(jiti@1.21.7) + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 10.2.6 transitivePeerDependencies: - supports-color @@ -12442,9 +12437,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.8.0(jiti@1.21.7))': + '@eslint/js@10.0.1(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))': optionalDependencies: - eslint: 10.8.0(jiti@1.21.7) + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) '@eslint/object-schema@3.0.5': {} @@ -12512,13 +12507,13 @@ snapshots: jest-util: 30.4.1 slash: 3.0.0 - '@jest/core@30.4.2': + '@jest/core@30.4.2(supports-color@10.2.2)': dependencies: '@jest/console': 30.4.1 '@jest/pattern': 30.4.0 - '@jest/reporters': 30.4.1 + '@jest/reporters': 30.4.1(supports-color@10.2.2) '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 + '@jest/transform': 30.4.1(supports-color@10.2.2) '@jest/types': 30.4.1 '@types/node': 22.20.1 ansi-escapes: 4.3.2 @@ -12528,15 +12523,15 @@ snapshots: fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 jest-changed-files: 30.4.1 - jest-config: 30.4.2(@types/node@22.20.1) + jest-config: 30.4.2(@types/node@22.20.1)(supports-color@10.2.2) jest-haste-map: 30.4.1 jest-message-util: 30.4.1 jest-regex-util: 30.4.0 jest-resolve: 30.4.1 - jest-resolve-dependencies: 30.4.2 - jest-runner: 30.4.2 - jest-runtime: 30.4.2 - jest-snapshot: 30.4.1 + jest-resolve-dependencies: 30.4.2(supports-color@10.2.2) + jest-runner: 30.4.2(supports-color@10.2.2) + jest-runtime: 30.4.2(supports-color@10.2.2) + jest-snapshot: 30.4.1(supports-color@10.2.2) jest-util: 30.4.1 jest-validate: 30.4.1 jest-watcher: 30.4.1 @@ -12554,7 +12549,7 @@ snapshots: '@jest/diff-sequences@30.4.0': {} - '@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0)': + '@jest/environment-jsdom-abstract@30.4.1(jsdom@26.1.0(supports-color@10.2.2))': dependencies: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 @@ -12563,7 +12558,7 @@ snapshots: '@types/node': 22.20.1 jest-mock: 30.4.1 jest-util: 30.4.1 - jsdom: 26.1.0 + jsdom: 26.1.0(supports-color@10.2.2) '@jest/environment@30.4.1': dependencies: @@ -12576,10 +12571,10 @@ snapshots: dependencies: '@jest/get-type': 30.1.0 - '@jest/expect@30.4.1': + '@jest/expect@30.4.1(supports-color@10.2.2)': dependencies: expect: 30.4.1 - jest-snapshot: 30.4.1 + jest-snapshot: 30.4.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -12594,10 +12589,10 @@ snapshots: '@jest/get-type@30.1.0': {} - '@jest/globals@30.4.1': + '@jest/globals@30.4.1(supports-color@10.2.2)': dependencies: '@jest/environment': 30.4.1 - '@jest/expect': 30.4.1 + '@jest/expect': 30.4.1(supports-color@10.2.2) '@jest/types': 30.4.1 jest-mock: 30.4.1 transitivePeerDependencies: @@ -12608,12 +12603,12 @@ snapshots: '@types/node': 22.20.1 jest-regex-util: 30.4.0 - '@jest/reporters@30.4.1': + '@jest/reporters@30.4.1(supports-color@10.2.2)': dependencies: '@bcoe/v8-coverage': 0.2.3 '@jest/console': 30.4.1 '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 + '@jest/transform': 30.4.1(supports-color@10.2.2) '@jest/types': 30.4.1 '@jridgewell/trace-mapping': 0.3.31 '@types/node': 22.20.1 @@ -12623,9 +12618,9 @@ snapshots: glob: 10.5.0 graceful-fs: 4.2.11 istanbul-lib-coverage: 3.2.2 - istanbul-lib-instrument: 6.0.3 + istanbul-lib-instrument: 6.0.3(supports-color@10.2.2) istanbul-lib-report: 3.0.1 - istanbul-lib-source-maps: 5.0.6 + istanbul-lib-source-maps: 5.0.6(supports-color@10.2.2) istanbul-reports: 3.2.0 jest-message-util: 30.4.1 jest-util: 30.4.1 @@ -12671,12 +12666,12 @@ snapshots: jest-haste-map: 30.4.1 slash: 3.0.0 - '@jest/transform@30.4.1': + '@jest/transform@30.4.1(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@jest/types': 30.4.1 '@jridgewell/trace-mapping': 0.3.31 - babel-plugin-istanbul: 7.0.1 + babel-plugin-istanbul: 7.0.1(supports-color@10.2.2) chalk: 4.1.2 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 @@ -12733,28 +12728,16 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jsforce/jsforce-node@3.10.19': - dependencies: - '@sindresorhus/is': 4.6.0 - base64url: 3.0.1 - csv-parse: 5.6.0 - csv-stringify: 6.8.0 - faye: 1.4.1 - form-data: 4.0.6 - multistream: 3.1.0 - undici: 8.9.0 - xml2js: 0.6.2 - - '@jsforce/jsforce-node@3.10.22': + '@jsforce/jsforce-node@3.10.23': dependencies: '@sindresorhus/is': 4.6.0 base64url: 3.0.1 csv-parse: 5.6.0 - csv-stringify: 6.8.0 + csv-stringify: 6.8.3 faye: 1.4.1 form-data: 4.0.6 multistream: 3.1.0 - undici: 8.9.0 + undici: 8.10.0 xml2js: 0.6.2 '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': @@ -12889,9 +12872,9 @@ snapshots: dependencies: vary: 1.1.2 - '@koa/router@15.7.0(koa@3.2.1)': + '@koa/router@15.7.0(koa@3.2.1)(supports-color@10.2.2)': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 koa: 3.2.1 koa-compose: 4.1.0 @@ -12911,7 +12894,7 @@ snapshots: dependencies: '@lit-labs/ssr-dom-shim': 1.6.0 - '@mdx-js/mdx@3.1.1': + '@mdx-js/mdx@3.1.1(supports-color@10.2.2)': dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -12923,14 +12906,14 @@ snapshots: estree-util-is-identifier-name: 3.0.0 estree-util-scope: 1.0.0 estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 + hast-util-to-jsx-runtime: 2.3.6(supports-color@10.2.2) markdown-extensions: 2.0.0 recma-build-jsx: 1.0.0 recma-jsx: 1.0.1(acorn@8.16.0) recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.1 - remark-parse: 11.0.0 + rehype-recma: 1.0.0(supports-color@10.2.2) + remark-mdx: 3.1.1(supports-color@10.2.2) + remark-parse: 11.0.0(supports-color@10.2.2) remark-rehype: 11.1.2 source-map: 0.7.6 unified: 11.0.5 @@ -12990,6 +12973,9 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': optional: true + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.11.3 @@ -13018,13 +13004,6 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@tybys/wasm-util': 0.10.3 - optional: true - '@noble/hashes@1.4.0': {} '@nodable/entities@3.0.0': {} @@ -13305,67 +13284,63 @@ snapshots: '@oxc-project/types@0.139.0': {} - '@oxc-project/types@0.142.0': {} - - '@parcel/watcher-android-arm64@2.5.6': - optional: true + '@oxc-project/types@0.147.0': {} - '@parcel/watcher-darwin-arm64@2.5.6': + '@parcel/watcher-android-arm64@2.6.0': optional: true - '@parcel/watcher-darwin-x64@2.5.6': + '@parcel/watcher-darwin-arm64@2.6.0': optional: true - '@parcel/watcher-freebsd-x64@2.5.6': + '@parcel/watcher-darwin-x64@2.6.0': optional: true - '@parcel/watcher-linux-arm-glibc@2.5.6': + '@parcel/watcher-freebsd-x64@2.6.0': optional: true - '@parcel/watcher-linux-arm-musl@2.5.6': + '@parcel/watcher-linux-arm-glibc@2.6.0': optional: true - '@parcel/watcher-linux-arm64-glibc@2.5.6': + '@parcel/watcher-linux-arm-musl@2.6.0': optional: true - '@parcel/watcher-linux-arm64-musl@2.5.6': + '@parcel/watcher-linux-arm64-glibc@2.6.0': optional: true - '@parcel/watcher-linux-x64-glibc@2.5.6': + '@parcel/watcher-linux-arm64-musl@2.6.0': optional: true - '@parcel/watcher-linux-x64-musl@2.5.6': + '@parcel/watcher-linux-x64-glibc@2.6.0': optional: true - '@parcel/watcher-win32-arm64@2.5.6': + '@parcel/watcher-linux-x64-musl@2.6.0': optional: true - '@parcel/watcher-win32-ia32@2.5.6': + '@parcel/watcher-win32-arm64@2.6.0': optional: true - '@parcel/watcher-win32-x64@2.5.6': + '@parcel/watcher-win32-x64@2.6.0': optional: true - '@parcel/watcher@2.5.6': + '@parcel/watcher@2.6.0': dependencies: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.5 + picomatch: 4.0.7 optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 optional: true '@peculiar/asn1-cms@2.8.0': @@ -13497,66 +13472,62 @@ snapshots: dependencies: oxc-parser: 0.139.0 - '@rolldown/binding-android-arm64@1.2.1': + '@rolldown/binding-android-arm-eabi@1.2.6': optional: true - '@rolldown/binding-darwin-arm64@1.2.1': + '@rolldown/binding-android-arm64@1.2.6': optional: true - '@rolldown/binding-darwin-x64@1.2.1': + '@rolldown/binding-darwin-arm64@1.2.6': optional: true - '@rolldown/binding-freebsd-x64@1.2.1': + '@rolldown/binding-darwin-x64@1.2.6': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + '@rolldown/binding-freebsd-x64@1.2.6': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.1': + '@rolldown/binding-linux-arm-gnueabihf@1.2.6': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.1': + '@rolldown/binding-linux-arm64-gnu@1.2.6': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.1': + '@rolldown/binding-linux-arm64-musl@1.2.6': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.1': + '@rolldown/binding-linux-ppc64-gnu@1.2.6': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.1': + '@rolldown/binding-linux-s390x-gnu@1.2.6': optional: true - '@rolldown/binding-linux-x64-musl@1.2.1': + '@rolldown/binding-linux-x64-gnu@1.2.6': optional: true - '@rolldown/binding-openharmony-arm64@1.2.1': + '@rolldown/binding-linux-x64-musl@1.2.6': optional: true - '@rolldown/binding-wasm32-wasi@1.2.1': - dependencies: - '@emnapi/core': 2.0.0-alpha.3 - '@emnapi/runtime': 2.0.0-alpha.3 - '@napi-rs/wasm-runtime': 1.2.1(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) + '@rolldown/binding-openharmony-arm64@1.2.6': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.1': + '@rolldown/binding-win32-arm64-msvc@1.2.6': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.1': + '@rolldown/binding-win32-x64-msvc@1.2.6': optional: true '@rolldown/plugin-node-polyfills@1.0.3': {} '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-alias@6.0.0(rollup@4.62.3)': + '@rollup/plugin-alias@6.0.0(rollup@4.63.0)': optionalDependencies: - rollup: 4.62.3 + rollup: 4.63.0 - '@rollup/plugin-commonjs@29.0.3(rollup@4.62.3)': + '@rollup/plugin-commonjs@29.0.3(rollup@4.63.0)': dependencies: - '@rollup/pluginutils': 5.4.0(rollup@4.62.3) + '@rollup/pluginutils': 5.4.0(rollup@4.63.0) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.4) @@ -13564,113 +13535,113 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.4 optionalDependencies: - rollup: 4.62.3 + rollup: 4.63.0 - '@rollup/plugin-inject@5.0.5(rollup@4.62.3)': + '@rollup/plugin-inject@5.0.5(rollup@4.63.0)': dependencies: - '@rollup/pluginutils': 5.4.0(rollup@4.62.3) + '@rollup/pluginutils': 5.4.0(rollup@4.63.0) estree-walker: 2.0.2 magic-string: 0.30.21 optionalDependencies: - rollup: 4.62.3 + rollup: 4.63.0 - '@rollup/plugin-json@6.1.0(rollup@4.62.3)': + '@rollup/plugin-json@6.1.0(rollup@4.63.0)': dependencies: - '@rollup/pluginutils': 5.4.0(rollup@4.62.3) + '@rollup/pluginutils': 5.4.0(rollup@4.63.0) optionalDependencies: - rollup: 4.62.3 + rollup: 4.63.0 - '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.3)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.63.0)': dependencies: - '@rollup/pluginutils': 5.4.0(rollup@4.62.3) + '@rollup/pluginutils': 5.4.0(rollup@4.63.0) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.12 optionalDependencies: - rollup: 4.62.3 + rollup: 4.63.0 - '@rollup/pluginutils@5.4.0(rollup@4.62.3)': + '@rollup/pluginutils@5.4.0(rollup@4.63.0)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: - rollup: 4.62.3 + rollup: 4.63.0 - '@rollup/rollup-android-arm-eabi@4.62.3': + '@rollup/rollup-android-arm-eabi@4.63.0': optional: true - '@rollup/rollup-android-arm64@4.62.3': + '@rollup/rollup-android-arm64@4.63.0': optional: true - '@rollup/rollup-darwin-arm64@4.62.3': + '@rollup/rollup-darwin-arm64@4.63.0': optional: true - '@rollup/rollup-darwin-x64@4.62.3': + '@rollup/rollup-darwin-x64@4.63.0': optional: true - '@rollup/rollup-freebsd-arm64@4.62.3': + '@rollup/rollup-freebsd-arm64@4.63.0': optional: true - '@rollup/rollup-freebsd-x64@4.62.3': + '@rollup/rollup-freebsd-x64@4.63.0': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.62.3': + '@rollup/rollup-linux-arm-musleabihf@4.63.0': optional: true - '@rollup/rollup-linux-arm64-gnu@4.62.3': + '@rollup/rollup-linux-arm64-gnu@4.63.0': optional: true - '@rollup/rollup-linux-arm64-musl@4.62.3': + '@rollup/rollup-linux-arm64-musl@4.63.0': optional: true - '@rollup/rollup-linux-loong64-gnu@4.62.3': + '@rollup/rollup-linux-loong64-gnu@4.63.0': optional: true - '@rollup/rollup-linux-loong64-musl@4.62.3': + '@rollup/rollup-linux-loong64-musl@4.63.0': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.62.3': + '@rollup/rollup-linux-ppc64-gnu@4.63.0': optional: true - '@rollup/rollup-linux-ppc64-musl@4.62.3': + '@rollup/rollup-linux-ppc64-musl@4.63.0': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.62.3': + '@rollup/rollup-linux-riscv64-gnu@4.63.0': optional: true - '@rollup/rollup-linux-riscv64-musl@4.62.3': + '@rollup/rollup-linux-riscv64-musl@4.63.0': optional: true - '@rollup/rollup-linux-s390x-gnu@4.62.3': + '@rollup/rollup-linux-s390x-gnu@4.63.0': optional: true - '@rollup/rollup-linux-x64-gnu@4.62.3': + '@rollup/rollup-linux-x64-gnu@4.63.0': optional: true - '@rollup/rollup-linux-x64-musl@4.62.3': + '@rollup/rollup-linux-x64-musl@4.63.0': optional: true - '@rollup/rollup-openbsd-x64@4.62.3': + '@rollup/rollup-openbsd-x64@4.63.0': optional: true - '@rollup/rollup-openharmony-arm64@4.62.3': + '@rollup/rollup-openharmony-arm64@4.63.0': optional: true - '@rollup/rollup-win32-arm64-msvc@4.62.3': + '@rollup/rollup-win32-arm64-msvc@4.63.0': optional: true - '@rollup/rollup-win32-ia32-msvc@4.62.3': + '@rollup/rollup-win32-ia32-msvc@4.63.0': optional: true - '@rollup/rollup-win32-x64-gnu@4.62.3': + '@rollup/rollup-win32-x64-gnu@4.63.0': optional: true - '@rollup/rollup-win32-x64-msvc@4.62.3': + '@rollup/rollup-win32-x64-msvc@4.63.0': optional: true '@rspack/binding-darwin-arm64@1.7.12': @@ -13728,33 +13699,11 @@ snapshots: '@rspack/lite-tapable@1.1.0': {} - '@salesforce/core@9.1.0': - dependencies: - '@jsforce/jsforce-node': 3.10.19 - '@salesforce/kit': 4.0.0 - '@salesforce/ts-types': 3.0.1 - ajv: 8.20.0 - change-case: 4.1.2 - fast-levenshtein: 3.0.0 - faye: 1.4.1 - form-data: 4.0.6 - js2xmlparser: 4.0.2 - jsonwebtoken: 9.0.3 - jszip: 3.10.1 - memfs: 4.38.1 - pino: 9.14.0 - pino-abstract-transport: 1.2.0 - pino-pretty: 11.3.0 - proper-lockfile: 4.1.2 - semver: 7.8.5 - ts-retry-promise: 0.8.1 - zod: 4.4.3 - - '@salesforce/core@9.1.4': + '@salesforce/core@9.1.7': dependencies: - '@jsforce/jsforce-node': 3.10.22 + '@jsforce/jsforce-node': 3.10.23 '@salesforce/kit': 4.0.0 - '@salesforce/ts-types': 3.0.1 + '@salesforce/ts-types': 3.1.0 ajv: 8.20.0 change-case: 4.1.2 fast-levenshtein: 3.0.0 @@ -13774,13 +13723,13 @@ snapshots: '@salesforce/kit@4.0.0': dependencies: - '@salesforce/ts-types': 3.0.1 + '@salesforce/ts-types': 3.1.0 - '@salesforce/playwright-vscode-ext@1.3.11': + '@salesforce/playwright-vscode-ext@1.3.11(supports-color@10.2.2)': dependencies: '@playwright/test': 1.62.1 - '@vscode/test-electron': 3.1.0 - '@vscode/test-web': 0.0.81 + '@vscode/test-electron': 3.1.0(supports-color@10.2.2) + '@vscode/test-web': 0.0.81(supports-color@10.2.2) transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -13788,11 +13737,11 @@ snapshots: - react-native-b4a - supports-color - '@salesforce/source-deploy-retrieve@13.2.0': + '@salesforce/source-deploy-retrieve@13.3.0(supports-color@10.2.2)': dependencies: - '@salesforce/core': 9.1.4 + '@salesforce/core': 9.1.7 '@salesforce/kit': 4.0.0 - '@salesforce/ts-types': 3.0.1 + '@salesforce/ts-types': 3.1.0 '@salesforce/types': 1.8.0 fast-levenshtein: 3.0.0 fast-xml-parser: 5.11.0 @@ -13802,47 +13751,47 @@ snapshots: jszip: 3.10.1 mime: 2.6.0 minimatch: 9.0.9 - proxy-agent: 6.5.0 + proxy-agent: 6.5.0(supports-color@10.2.2) yaml: 2.9.0 transitivePeerDependencies: - supports-color - '@salesforce/source-tracking@8.1.0': + '@salesforce/source-tracking@8.1.2(supports-color@10.2.2)': dependencies: - '@salesforce/core': 9.1.4 + '@salesforce/core': 9.1.7 '@salesforce/kit': 4.0.0 - '@salesforce/source-deploy-retrieve': 13.2.0 - '@salesforce/ts-types': 3.0.1 + '@salesforce/source-deploy-retrieve': 13.3.0(supports-color@10.2.2) + '@salesforce/ts-types': 3.1.0 fast-xml-parser: 5.11.0 graceful-fs: 4.2.11 - isomorphic-git: 1.41.7 + isomorphic-git: 1.41.9 ts-retry-promise: 0.8.1 transitivePeerDependencies: - supports-color - '@salesforce/ts-types@3.0.1': {} + '@salesforce/ts-types@3.1.0': {} '@salesforce/types@1.8.0': {} - '@salesforce/vscode-services@67.13.3(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(@types/node@22.20.1)': + '@salesforce/vscode-services@67.15.0(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(@types/node@22.20.1)(supports-color@10.2.2)': dependencies: - '@azure/monitor-opentelemetry-exporter': 1.0.0-beta.44 - '@effect/opentelemetry': 0.63.0(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@3.22.1) + '@azure/monitor-opentelemetry-exporter': 1.0.0-beta.44(supports-color@10.2.2) + '@effect/opentelemetry': 0.63.0(@effect/platform@0.96.3(effect@3.22.1))(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.8.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@3.22.1) '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-http': 0.219.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-node': 2.8.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-web': 2.8.0(@opentelemetry/api@1.9.1) - '@salesforce/core': 9.1.0 - '@salesforce/source-deploy-retrieve': 13.2.0 - '@salesforce/source-tracking': 8.1.0 + '@salesforce/core': 9.1.7 + '@salesforce/source-deploy-retrieve': 13.3.0(supports-color@10.2.2) + '@salesforce/source-tracking': 8.1.2(supports-color@10.2.2) '@types/vscode': 1.102.0 effect: 3.22.1 - jsforce: 3.10.22(@types/node@22.20.1) - vscode-uri: 3.1.0 + jsforce: 3.10.23(@types/node@22.20.1) + vscode-uri: 3.2.0 transitivePeerDependencies: - '@effect/platform' - '@opentelemetry/resources' @@ -13854,35 +13803,35 @@ snapshots: dependencies: '@secretlint/types': 10.2.2 - '@secretlint/config-loader@10.2.2': + '@secretlint/config-loader@10.2.2(supports-color@10.2.2)': dependencies: '@secretlint/profiler': 10.2.2 '@secretlint/resolver': 10.2.2 '@secretlint/types': 10.2.2 ajv: 8.20.0 - debug: 4.4.3 - rc-config-loader: 4.1.4 + debug: 4.4.3(supports-color@10.2.2) + rc-config-loader: 4.1.4(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@secretlint/core@10.2.2': + '@secretlint/core@10.2.2(supports-color@10.2.2)': dependencies: '@secretlint/profiler': 10.2.2 '@secretlint/types': 10.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) structured-source: 4.0.0 transitivePeerDependencies: - supports-color - '@secretlint/formatter@10.2.2': + '@secretlint/formatter@10.2.2(supports-color@10.2.2)': dependencies: '@secretlint/resolver': 10.2.2 '@secretlint/types': 10.2.2 - '@textlint/linter-formatter': 15.8.0 + '@textlint/linter-formatter': 15.8.0(supports-color@10.2.2) '@textlint/module-interop': 15.8.0 '@textlint/types': 15.8.0 chalk: 5.6.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) pluralize: 8.0.0 strip-ansi: 7.2.0 table: 6.9.0 @@ -13890,15 +13839,15 @@ snapshots: transitivePeerDependencies: - supports-color - '@secretlint/node@10.2.2': + '@secretlint/node@10.2.2(supports-color@10.2.2)': dependencies: - '@secretlint/config-loader': 10.2.2 - '@secretlint/core': 10.2.2 - '@secretlint/formatter': 10.2.2 + '@secretlint/config-loader': 10.2.2(supports-color@10.2.2) + '@secretlint/core': 10.2.2(supports-color@10.2.2) + '@secretlint/formatter': 10.2.2(supports-color@10.2.2) '@secretlint/profiler': 10.2.2 '@secretlint/source-creator': 10.2.2 '@secretlint/types': 10.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) p-map: 7.0.7 transitivePeerDependencies: - supports-color @@ -13968,54 +13917,54 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.7)': + '@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) - '@svgr/babel-preset@8.1.0(@babel/core@7.29.7)': + '@svgr/babel-preset@8.1.0(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7 - '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.7) - '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.7) - '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.7) - '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.29.7) - '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.29.7) - '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.29.7) - '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.7) - '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.29.7(supports-color@10.2.2)) - '@svgr/core@8.1.0(@typescript/typescript6@6.0.2)': + '@svgr/core@8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.7(supports-color@10.2.2)) camelcase: 6.3.0 cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2) snake-case: 3.0.4 @@ -14028,35 +13977,35 @@ snapshots: '@babel/types': 7.29.7 entities: 4.5.0 - '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2))': + '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@svgr/babel-preset': 8.1.0(@babel/core@7.29.7) - '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@svgr/babel-preset': 8.1.0(@babel/core@7.29.7(supports-color@10.2.2)) + '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) '@svgr/hast-util-to-babel-ast': 8.0.0 svg-parser: 2.0.4 transitivePeerDependencies: - supports-color - '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2))(@typescript/typescript6@6.0.2)': + '@svgr/plugin-svgo@8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2))(@typescript/typescript6@6.0.2)': dependencies: - '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2) + '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2) deepmerge: 4.3.1 svgo: 3.3.4 transitivePeerDependencies: - typescript - '@svgr/webpack@8.1.0(@typescript/typescript6@6.0.2)': + '@svgr/webpack@8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-constant-elements': 7.29.7(@babel/core@7.29.7) - '@babel/preset-env': 7.29.7(@babel/core@7.29.7) - '@babel/preset-react': 7.29.7(@babel/core@7.29.7) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2) - '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2)) - '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2))(@typescript/typescript6@6.0.2) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-react-constant-elements': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-react': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@svgr/core': 8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2))(supports-color@10.2.2) + '@svgr/plugin-svgo': 8.1.0(@svgr/core@8.1.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2))(@typescript/typescript6@6.0.2) transitivePeerDependencies: - supports-color - typescript @@ -14064,39 +14013,75 @@ snapshots: '@swc/core-darwin-arm64@1.15.47': optional: true + '@swc/core-darwin-arm64@1.16.1': + optional: true + '@swc/core-darwin-x64@1.15.47': optional: true + '@swc/core-darwin-x64@1.16.1': + optional: true + '@swc/core-linux-arm-gnueabihf@1.15.47': optional: true + '@swc/core-linux-arm-gnueabihf@1.16.1': + optional: true + '@swc/core-linux-arm64-gnu@1.15.47': optional: true + '@swc/core-linux-arm64-gnu@1.16.1': + optional: true + '@swc/core-linux-arm64-musl@1.15.47': optional: true + '@swc/core-linux-arm64-musl@1.16.1': + optional: true + '@swc/core-linux-ppc64-gnu@1.15.47': optional: true + '@swc/core-linux-ppc64-gnu@1.16.1': + optional: true + '@swc/core-linux-s390x-gnu@1.15.47': optional: true + '@swc/core-linux-s390x-gnu@1.16.1': + optional: true + '@swc/core-linux-x64-gnu@1.15.47': optional: true + '@swc/core-linux-x64-gnu@1.16.1': + optional: true + '@swc/core-linux-x64-musl@1.15.47': optional: true + '@swc/core-linux-x64-musl@1.16.1': + optional: true + '@swc/core-win32-arm64-msvc@1.15.47': optional: true + '@swc/core-win32-arm64-msvc@1.16.1': + optional: true + '@swc/core-win32-ia32-msvc@1.15.47': optional: true + '@swc/core-win32-ia32-msvc@1.16.1': + optional: true + '@swc/core-win32-x64-msvc@1.15.47': optional: true + '@swc/core-win32-x64-msvc@1.16.1': + optional: true + '@swc/core@1.15.47(@swc/helpers@0.5.23)': dependencies: '@swc/counter': 0.1.3 @@ -14116,6 +14101,25 @@ snapshots: '@swc/core-win32-x64-msvc': 1.15.47 '@swc/helpers': 0.5.23 + '@swc/core@1.16.1(@swc/helpers@0.5.23)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.16.1 + '@swc/core-darwin-x64': 1.16.1 + '@swc/core-linux-arm-gnueabihf': 1.16.1 + '@swc/core-linux-arm64-gnu': 1.16.1 + '@swc/core-linux-arm64-musl': 1.16.1 + '@swc/core-linux-ppc64-gnu': 1.16.1 + '@swc/core-linux-s390x-gnu': 1.16.1 + '@swc/core-linux-x64-gnu': 1.16.1 + '@swc/core-linux-x64-musl': 1.16.1 + '@swc/core-win32-arm64-msvc': 1.16.1 + '@swc/core-win32-ia32-msvc': 1.16.1 + '@swc/core-win32-x64-msvc': 1.16.1 + '@swc/helpers': 0.5.23 + '@swc/counter@0.1.3': {} '@swc/helpers@0.5.23': @@ -14175,10 +14179,10 @@ snapshots: '@swc/html-win32-ia32-msvc': 1.15.47 '@swc/html-win32-x64-msvc': 1.15.47 - '@swc/jest@0.2.39(@swc/core@1.15.47(@swc/helpers@0.5.23))': + '@swc/jest@0.2.39(@swc/core@1.16.1(@swc/helpers@0.5.23))': dependencies: '@jest/create-cache-key-function': 30.4.1 - '@swc/core': 1.15.47(@swc/helpers@0.5.23) + '@swc/core': 1.16.1(@swc/helpers@0.5.23) '@swc/counter': 0.1.3 jsonc-parser: 3.3.1 @@ -14186,6 +14190,10 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -14196,14 +14204,14 @@ snapshots: '@textlint/ast-node-types@15.8.0': {} - '@textlint/linter-formatter@15.8.0': + '@textlint/linter-formatter@15.8.0(supports-color@10.2.2)': dependencies: '@azu/format-text': 1.0.2 '@azu/style-format': 1.0.1 '@textlint/module-interop': 15.8.0 '@textlint/resolver': 15.8.0 '@textlint/types': 15.8.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) js-yaml: 4.3.0 lodash: 4.18.1 pluralize: 2.0.0 @@ -14362,7 +14370,7 @@ snapshots: '@types/minimatch@6.0.0': dependencies: - minimatch: 10.2.5 + minimatch: 10.2.6 '@types/ms@2.1.0': {} @@ -14454,74 +14462,74 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7))': + '@typescript-eslint/eslint-plugin@8.68.0(@typescript-eslint/parser@8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2))(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) - '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.8.0(jiti@1.21.7) - ignore: 7.0.5 + '@typescript-eslint/parser': 8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/type-utils': 8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) + '@typescript-eslint/utils': 8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) + '@typescript-eslint/visitor-keys': 8.68.0 + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) + ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7))': + '@typescript-eslint/parser@8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - eslint: 10.8.0(jiti@1.21.7) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/project-service@8.68.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 + '@typescript-eslint/tsconfig-utils': 8.68.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.68.0 + debug: 4.4.3(supports-color@10.2.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.68.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 - '@typescript-eslint/tsconfig-utils@8.65.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/tsconfig-utils@8.68.0(@typescript/typescript6@6.0.2)': dependencies: typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7))': + '@typescript-eslint/type-utils@8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) - debug: 4.4.3 - eslint: 10.8.0(jiti@1.21.7) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) + '@typescript-eslint/utils': 8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.65.0': {} + '@typescript-eslint/types@8.68.0': {} - '@typescript-eslint/typescript-estree@8.65.0(@typescript/typescript6@6.0.2)': + '@typescript-eslint/typescript-estree@8.68.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2)': dependencies: - '@typescript-eslint/project-service': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/tsconfig-utils': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - minimatch: 10.2.5 + '@typescript-eslint/project-service': 8.68.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) + '@typescript-eslint/tsconfig-utils': 8.68.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/visitor-keys': 8.68.0 + debug: 4.4.3(supports-color@10.2.2) + minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) @@ -14529,20 +14537,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7))': + '@typescript-eslint/utils@8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2) - eslint: 10.8.0(jiti@1.21.7) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2)) + '@typescript-eslint/scope-manager': 8.68.0 + '@typescript-eslint/types': 8.68.0 + '@typescript-eslint/typescript-estree': 8.68.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.68.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.68.0 eslint-visitor-keys: 5.0.1 '@typescript/typescript-aix-ppc64@7.0.2': @@ -14609,10 +14617,10 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 - '@typespec/ts-http-runtime@0.3.8': + '@typespec/ts-http-runtime@0.3.8(supports-color@10.2.2)': dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -14701,28 +14709,28 @@ snapshots: '@vscode/codicons@0.0.45': {} - '@vscode/test-electron@3.1.0': + '@vscode/test-electron@3.1.0(supports-color@10.2.2)': dependencies: - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) jszip: 3.10.1 ora: 8.2.0 semver: 7.8.5 transitivePeerDependencies: - supports-color - '@vscode/test-web@0.0.81': + '@vscode/test-web@0.0.81(supports-color@10.2.2)': dependencies: '@koa/cors': 5.0.0 - '@koa/router': 15.7.0(koa@3.2.1) + '@koa/router': 15.7.0(koa@3.2.1)(supports-color@10.2.2) '@playwright/browser-chromium': 1.62.1 gunzip-maybe: 1.4.2 - http-proxy-agent: 9.1.0 - https-proxy-agent: 9.1.0 + http-proxy-agent: 9.1.0(supports-color@10.2.2) + https-proxy-agent: 9.1.0(supports-color@10.2.2) koa: 3.2.1 - koa-morgan: 1.0.1 - koa-mount: 4.2.0 - koa-static: 5.0.0 + koa-morgan: 1.0.1(supports-color@10.2.2) + koa-mount: 4.2.0(supports-color@10.2.2) + koa-static: 5.0.0(supports-color@10.2.2) minimist: 1.2.8 playwright: 1.62.1 tar-fs: 3.1.3 @@ -14774,10 +14782,10 @@ snapshots: '@vscode/vsce-sign-win32-arm64': 2.0.6 '@vscode/vsce-sign-win32-x64': 2.0.6 - '@vscode/vsce@3.9.2': + '@vscode/vsce@3.9.2(supports-color@10.2.2)': dependencies: - '@azure/identity': 4.13.2 - '@secretlint/node': 10.2.2 + '@azure/identity': 4.13.2(supports-color@10.2.2) + '@secretlint/node': 10.2.2(supports-color@10.2.2) '@secretlint/secretlint-formatter-sarif': 10.2.2 '@secretlint/secretlint-rule-no-dotenv': 10.2.2 '@secretlint/secretlint-rule-preset-recommend': 10.2.2 @@ -14797,7 +14805,7 @@ snapshots: minimatch: 10.2.5 parse-semver: 1.1.1 read: 1.0.7 - secretlint: 10.2.2 + secretlint: 10.2.2(supports-color@10.2.2) semver: 7.8.5 tmp: 0.2.7 typed-rest-client: 1.8.11 @@ -14935,17 +14943,17 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ajv-formats@2.1.1(ajv@8.18.0): + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: - ajv: 8.18.0 + ajv: 8.20.0 ajv-keywords@3.5.2(ajv@6.15.0): dependencies: ajv: 6.15.0 - ajv-keywords@5.1.0(ajv@8.18.0): + ajv-keywords@5.1.0(ajv@8.20.0): dependencies: - ajv: 8.18.0 + ajv: 8.20.0 fast-deep-equal: 3.1.3 ajv@6.15.0: @@ -14955,17 +14963,10 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 4.0.0 + fast-uri: 4.1.3 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -15080,36 +15081,36 @@ snapshots: b4a@1.8.1: {} - babel-jest@30.4.1(@babel/core@7.29.7): + babel-jest@30.4.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 - '@jest/transform': 30.4.1 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@jest/transform': 30.4.1(supports-color@10.2.2) '@types/babel__core': 7.20.5 - babel-plugin-istanbul: 7.0.1 - babel-preset-jest: 30.4.0(@babel/core@7.29.7) + babel-plugin-istanbul: 7.0.1(supports-color@10.2.2) + babel-preset-jest: 30.4.0(@babel/core@7.29.7(supports-color@10.2.2)) chalk: 4.1.2 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: - supports-color - babel-loader@9.2.1(@babel/core@7.29.7)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + babel-loader@9.2.1(@babel/core@7.29.7(supports-color@10.2.2))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) find-cache-dir: 4.0.0 schema-utils: 4.3.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) babel-plugin-dynamic-import-node@2.3.3: dependencies: object.assign: 4.1.7 - babel-plugin-istanbul@7.0.1: + babel-plugin-istanbul@7.0.1(supports-color@10.2.2): dependencies: '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 - istanbul-lib-instrument: 6.0.3 + istanbul-lib-instrument: 6.0.3(supports-color@10.2.2) test-exclude: 6.0.0 transitivePeerDependencies: - supports-color @@ -15118,62 +15119,62 @@ snapshots: dependencies: '@types/babel__core': 7.20.5 - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7): + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7): + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) core-js-compat: 3.49.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) - '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) - '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) - '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) - '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) - '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) - - babel-preset-jest@30.4.0(@babel/core@7.29.7): - dependencies: - '@babel/core': 7.29.7 + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7(supports-color@10.2.2)): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7(supports-color@10.2.2)) + + babel-preset-jest@30.4.0(@babel/core@7.29.7(supports-color@10.2.2)): + dependencies: + '@babel/core': 7.29.7(supports-color@10.2.2) babel-plugin-jest-hoist: 30.4.0 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2)) bail@2.0.2: {} @@ -15214,8 +15215,6 @@ snapshots: base64url@3.0.1: {} - baseline-browser-mapping@2.10.31: {} - baseline-browser-mapping@2.11.7: {} basic-auth@2.0.1: @@ -15240,11 +15239,11 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@2.3.0: + body-parser@2.3.0(supports-color@10.2.2): dependencies: bytes: 3.1.2 content-type: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 @@ -15285,7 +15284,7 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@2.1.1: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 @@ -15293,6 +15292,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -15301,14 +15304,6 @@ snapshots: dependencies: pako: 0.2.9 - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.31 - caniuse-lite: 1.0.30001793 - electron-to-chromium: 1.5.360 - node-releases: 2.0.44 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - browserslist@4.28.7: dependencies: baseline-browser-mapping: 2.11.7 @@ -15408,8 +15403,6 @@ snapshots: lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001793: {} - caniuse-lite@1.0.30001806: {} capital-case@1.0.4: @@ -15501,7 +15494,7 @@ snapshots: chokidar@5.0.0: dependencies: - readdirp: 5.0.0 + readdirp: 5.1.1 chownr@1.1.4: optional: true @@ -15620,11 +15613,11 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.8.1(supports-color@10.2.2): dependencies: bytes: 3.1.2 compressible: 2.0.18 - debug: 2.6.9 + debug: 2.6.9(supports-color@10.2.2) negotiator: 0.6.4 on-headers: 1.1.0 safe-buffer: 5.2.1 @@ -15632,7 +15625,7 @@ snapshots: transitivePeerDependencies: - supports-color - concurrently@10.0.4: + concurrently@10.0.5: dependencies: chalk: 5.6.2 rxjs: 7.8.2 @@ -15687,7 +15680,7 @@ snapshots: copy-text-to-clipboard@3.2.2: {} - copy-webpack-plugin@11.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + copy-webpack-plugin@11.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: fast-glob: 3.3.3 glob-parent: 6.0.2 @@ -15695,7 +15688,7 @@ snapshots: normalize-path: 3.0.0 schema-utils: 4.3.3 serialize-javascript: 7.0.7 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) core-js-compat@3.49.0: dependencies: @@ -15705,6 +15698,8 @@ snapshots: core-js@3.49.0: {} + core-js@3.50.0: {} + core-util-is@1.0.3: {} cosmiconfig@8.3.6(@typescript/typescript6@6.0.2): @@ -15748,7 +15743,7 @@ snapshots: postcss-selector-parser: 7.1.4 postcss-value-parser: 4.2.0 - css-loader@6.11.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + css-loader@6.11.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: icss-utils: 5.1.0(postcss@8.5.25) postcss: 8.5.25 @@ -15760,9 +15755,9 @@ snapshots: semver: 7.8.5 optionalDependencies: '@rspack/core': 1.7.12(@swc/helpers@0.5.23) - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) - css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 cssnano: 6.1.2(postcss@8.5.25) @@ -15770,9 +15765,12 @@ snapshots: postcss: 8.5.25 schema-utils: 4.3.3 serialize-javascript: 7.0.7 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) optionalDependencies: clean-css: 5.3.3 + csso: 5.0.5 + esbuild: 0.28.2 + lightningcss: 1.33.0 css-prefers-color-scheme@10.0.0(postcss@8.5.25): dependencies: @@ -15878,7 +15876,7 @@ snapshots: csv-parse@5.6.0: {} - csv-stringify@6.8.0: {} + csv-stringify@6.8.3: {} data-uri-to-buffer@6.0.2: {} @@ -15891,17 +15889,23 @@ snapshots: debounce@1.2.1: {} - debug@2.6.9: + debug@2.6.9(supports-color@10.2.2): dependencies: ms: 2.0.0 + optionalDependencies: + supports-color: 10.2.2 - debug@3.2.7: + debug@3.2.7(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -16073,8 +16077,6 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 - electron-to-chromium@1.5.360: {} - electron-to-chromium@1.5.398: {} emittery@0.13.1: {} @@ -16203,9 +16205,9 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@10.8.0(jiti@1.21.7)): + eslint-config-prettier@10.1.8(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2)): dependencies: - eslint: 10.8.0(jiti@1.21.7) + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) eslint-scope@5.1.1: dependencies: @@ -16223,11 +16225,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.8.0(jiti@1.21.7): + eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.8.0(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.2 @@ -16237,7 +16239,7 @@ snapshots: '@types/estree': 1.0.9 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -16252,7 +16254,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -16370,20 +16372,20 @@ snapshots: jest-mock: 30.4.1 jest-util: 30.4.1 - express@5.2.1: + express@5.2.1(supports-color@10.2.2): dependencies: accepts: 2.0.0 - body-parser: 2.3.0 + body-parser: 2.3.0(supports-color@10.2.2) content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1 + finalhandler: 2.1.1(supports-color@10.2.2) fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -16394,9 +16396,9 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.3.0 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 + router: 2.2.0(supports-color@10.2.2) + send: 1.2.1(supports-color@10.2.2) + serve-static: 2.2.1(supports-color@10.2.2) statuses: 2.0.2 type-is: 2.1.0 vary: 1.1.2 @@ -16437,9 +16439,7 @@ snapshots: fast-safe-stringify@2.1.1: {} - fast-uri@3.1.2: {} - - fast-uri@4.0.0: {} + fast-uri@4.1.3: {} fast-xml-builder@1.3.1: dependencies: @@ -16475,7 +16475,7 @@ snapshots: csprng: 0.1.2 faye-websocket: 0.11.4 safe-buffer: 5.2.1 - tough-cookie: 6.0.1 + tough-cookie: 6.0.2 tunnel-agent: 0.6.0 fb-watchman@2.0.2: @@ -16502,19 +16502,19 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - finalhandler@2.1.1: + finalhandler@2.1.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -16547,12 +16547,12 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.4 keyv: 4.5.4 flat@5.0.2: {} - flatted@3.4.2: {} + flatted@3.4.4: {} for-each@0.3.5: dependencies: @@ -16652,11 +16652,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - get-uri@6.0.5: + get-uri@6.0.5(supports-color@10.2.2): dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -16842,7 +16842,7 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 - hast-util-to-estree@3.1.3: + hast-util-to-estree@3.1.3(supports-color@10.2.2): dependencies: '@types/estree': 1.0.9 '@types/estree-jsx': 1.0.5 @@ -16852,9 +16852,9 @@ snapshots: estree-util-attach-comments: 3.0.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -16863,7 +16863,7 @@ snapshots: transitivePeerDependencies: - supports-color - hast-util-to-jsx-runtime@2.3.6: + hast-util-to-jsx-runtime@2.3.6(supports-color@10.2.2): dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.4 @@ -16872,9 +16872,9 @@ snapshots: devlop: 1.1.0 estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) property-information: 7.1.0 space-separated-tokens: 2.0.2 style-to-js: 1.1.21 @@ -16965,7 +16965,7 @@ snapshots: html-void-elements@3.0.0: {} - html-webpack-plugin@5.6.8(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + html-webpack-plugin@5.6.8(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 @@ -16974,7 +16974,7 @@ snapshots: tapable: 2.3.3 optionalDependencies: '@rspack/core': 1.7.12(@swc/helpers@0.5.23) - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) htmlparser2@10.1.0: dependencies: @@ -17029,25 +17029,25 @@ snapshots: http-parser-js@0.5.10: {} - http-proxy-agent@7.0.2: + http-proxy-agent@7.0.2(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - http-proxy-agent@9.1.0: + http-proxy-agent@9.1.0(supports-color@10.2.2): dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos - supports-color - http-proxy-middleware@4.2.0: + http-proxy-middleware@4.2.0(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) httpxy: 0.5.5 is-glob: 4.0.3 is-plain-obj: 4.1.0 @@ -17065,17 +17065,17 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@9.1.0: + https-proxy-agent@9.1.0(supports-color@10.2.2): dependencies: agent-base: 9.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) proxy-agent-negotiate: 1.1.0 transitivePeerDependencies: - kerberos @@ -17107,6 +17107,8 @@ snapshots: ignore@7.0.5: {} + ignore@7.0.6: {} + image-size@2.0.2: {} immediate@3.0.6: {} @@ -17312,7 +17314,7 @@ snapshots: isobject@3.0.1: {} - isomorphic-git@1.41.7: + isomorphic-git@1.41.9: dependencies: async-lock: 1.4.1 clean-git-ref: 2.0.1 @@ -17328,9 +17330,9 @@ snapshots: istanbul-lib-coverage@3.2.2: {} - istanbul-lib-instrument@6.0.3: + istanbul-lib-instrument@6.0.3(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 @@ -17344,10 +17346,10 @@ snapshots: make-dir: 4.0.0 supports-color: 7.2.0 - istanbul-lib-source-maps@5.0.6: + istanbul-lib-source-maps@5.0.6(supports-color@10.2.2): dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -17375,10 +17377,10 @@ snapshots: jest-util: 30.4.1 p-limit: 3.1.0 - jest-circus@30.4.2: + jest-circus@30.4.2(supports-color@10.2.2): dependencies: '@jest/environment': 30.4.1 - '@jest/expect': 30.4.1 + '@jest/expect': 30.4.1(supports-color@10.2.2) '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 '@types/node': 22.20.1 @@ -17389,8 +17391,8 @@ snapshots: jest-each: 30.4.1 jest-matcher-utils: 30.4.1 jest-message-util: 30.4.1 - jest-runtime: 30.4.2 - jest-snapshot: 30.4.1 + jest-runtime: 30.4.2(supports-color@10.2.2) + jest-snapshot: 30.4.1(supports-color@10.2.2) jest-util: 30.4.1 p-limit: 3.1.0 pretty-format: 30.4.1 @@ -17401,15 +17403,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.4.2(@types/node@22.20.1): + jest-cli@30.4.2(@types/node@22.20.1)(supports-color@10.2.2): dependencies: - '@jest/core': 30.4.2 + '@jest/core': 30.4.2(supports-color@10.2.2) '@jest/test-result': 30.4.1 '@jest/types': 30.4.1 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.4.2(@types/node@22.20.1) + jest-config: 30.4.2(@types/node@22.20.1)(supports-color@10.2.2) jest-util: 30.4.1 jest-validate: 30.4.1 yargs: 17.7.2 @@ -17420,25 +17422,25 @@ snapshots: - supports-color - ts-node - jest-config@30.4.2(@types/node@22.20.1): + jest-config@30.4.2(@types/node@22.20.1)(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@jest/get-type': 30.1.0 '@jest/pattern': 30.4.0 '@jest/test-sequencer': 30.4.1 '@jest/types': 30.4.1 - babel-jest: 30.4.1(@babel/core@7.29.7) + babel-jest: 30.4.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) chalk: 4.1.2 ci-info: 4.4.0 deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.4.2 + jest-circus: 30.4.2(supports-color@10.2.2) jest-docblock: 30.4.0 jest-environment-node: 30.4.1 jest-regex-util: 30.4.0 jest-resolve: 30.4.1 - jest-runner: 30.4.2 + jest-runner: 30.4.2(supports-color@10.2.2) jest-util: 30.4.1 jest-validate: 30.4.1 parse-json: 5.2.0 @@ -17470,11 +17472,11 @@ snapshots: jest-util: 30.4.1 pretty-format: 30.4.1 - jest-environment-jsdom@30.4.1: + jest-environment-jsdom@30.4.1(supports-color@10.2.2): dependencies: '@jest/environment': 30.4.1 - '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0) - jsdom: 26.1.0 + '@jest/environment-jsdom-abstract': 30.4.1(jsdom@26.1.0(supports-color@10.2.2)) + jsdom: 26.1.0(supports-color@10.2.2) transitivePeerDependencies: - bufferutil - supports-color @@ -17525,7 +17527,7 @@ snapshots: chalk: 4.1.2 graceful-fs: 4.2.11 jest-util: 30.4.1 - picomatch: 4.0.4 + picomatch: 4.0.7 pretty-format: 30.4.1 slash: 3.0.0 stack-utils: 2.0.6 @@ -17542,10 +17544,10 @@ snapshots: jest-regex-util@30.4.0: {} - jest-resolve-dependencies@30.4.2: + jest-resolve-dependencies@30.4.2(supports-color@10.2.2): dependencies: jest-regex-util: 30.4.0 - jest-snapshot: 30.4.1 + jest-snapshot: 30.4.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -17560,12 +17562,12 @@ snapshots: slash: 3.0.0 unrs-resolver: 1.12.2 - jest-runner@30.4.2: + jest-runner@30.4.2(supports-color@10.2.2): dependencies: '@jest/console': 30.4.1 '@jest/environment': 30.4.1 '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 + '@jest/transform': 30.4.1(supports-color@10.2.2) '@jest/types': 30.4.1 '@types/node': 22.20.1 chalk: 4.1.2 @@ -17578,7 +17580,7 @@ snapshots: jest-leak-detector: 30.4.1 jest-message-util: 30.4.1 jest-resolve: 30.4.1 - jest-runtime: 30.4.2 + jest-runtime: 30.4.2(supports-color@10.2.2) jest-util: 30.4.1 jest-watcher: 30.4.1 jest-worker: 30.4.1 @@ -17587,14 +17589,14 @@ snapshots: transitivePeerDependencies: - supports-color - jest-runtime@30.4.2: + jest-runtime@30.4.2(supports-color@10.2.2): dependencies: '@jest/environment': 30.4.1 '@jest/fake-timers': 30.4.1 - '@jest/globals': 30.4.1 + '@jest/globals': 30.4.1(supports-color@10.2.2) '@jest/source-map': 30.0.1 '@jest/test-result': 30.4.1 - '@jest/transform': 30.4.1 + '@jest/transform': 30.4.1(supports-color@10.2.2) '@jest/types': 30.4.1 '@types/node': 22.20.1 chalk: 4.1.2 @@ -17607,26 +17609,26 @@ snapshots: jest-mock: 30.4.1 jest-regex-util: 30.4.0 jest-resolve: 30.4.1 - jest-snapshot: 30.4.1 + jest-snapshot: 30.4.1(supports-color@10.2.2) jest-util: 30.4.1 slash: 3.0.0 strip-bom: 4.0.0 transitivePeerDependencies: - supports-color - jest-snapshot@30.4.1: + jest-snapshot@30.4.1(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/generator': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/types': 7.29.7 '@jest/expect-utils': 30.4.1 '@jest/get-type': 30.1.0 '@jest/snapshot-utils': 30.4.1 - '@jest/transform': 30.4.1 + '@jest/transform': 30.4.1(supports-color@10.2.2) '@jest/types': 30.4.1 - babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7(supports-color@10.2.2)) chalk: 4.1.2 expect: 30.4.1 graceful-fs: 4.2.11 @@ -17647,7 +17649,7 @@ snapshots: chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 - picomatch: 4.0.5 + picomatch: 4.0.7 jest-util@30.4.1: dependencies: @@ -17656,7 +17658,7 @@ snapshots: chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.4 + picomatch: 4.0.7 jest-validate@30.4.1: dependencies: @@ -17699,12 +17701,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.4.2(@types/node@22.20.1): + jest@30.4.2(@types/node@22.20.1)(supports-color@10.2.2): dependencies: - '@jest/core': 30.4.2 + '@jest/core': 30.4.2(supports-color@10.2.2) '@jest/types': 30.4.1 import-local: 3.2.0 - jest-cli: 30.4.2(@types/node@22.20.1) + jest-cli: 30.4.2(@types/node@22.20.1)(supports-color@10.2.2) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -17741,14 +17743,14 @@ snapshots: dependencies: xmlcreate: 2.0.4 - jsdom@26.1.0: + jsdom@26.1.0(supports-color@10.2.2): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 decimal.js: 10.6.0 html-encoding-sniffer: 4.0.0 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.23 parse5: 7.3.0 @@ -17770,22 +17772,22 @@ snapshots: jsesc@3.1.0: {} - jsforce@3.10.22(@types/node@22.20.1): + jsforce@3.10.23(@types/node@22.20.1): dependencies: '@babel/runtime': 7.29.7 '@babel/runtime-corejs3': 7.29.7 '@sindresorhus/is': 4.6.0 base64url: 3.0.1 commander: 4.1.1 - core-js: 3.49.0 + core-js: 3.50.0 csv-parse: 5.6.0 - csv-stringify: 6.8.0 + csv-stringify: 6.8.3 faye: 1.4.1 form-data: 4.0.6 inquirer: 8.2.7(@types/node@22.20.1) multistream: 3.1.0 open: 7.4.2 - undici: 8.9.0 + undici: 8.10.0 xml2js: 0.6.2 transitivePeerDependencies: - '@types/node' @@ -17869,31 +17871,31 @@ snapshots: koa-compose@4.1.0: {} - koa-morgan@1.0.1: + koa-morgan@1.0.1(supports-color@10.2.2): dependencies: - morgan: 1.11.0 + morgan: 1.11.0(supports-color@10.2.2) transitivePeerDependencies: - supports-color - koa-mount@4.2.0: + koa-mount@4.2.0(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) koa-compose: 4.1.0 transitivePeerDependencies: - supports-color - koa-send@5.0.1: + koa-send@5.0.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) http-errors: 1.8.1 resolve-path: 1.4.0 transitivePeerDependencies: - supports-color - koa-static@5.0.0: + koa-static@5.0.0(supports-color@10.2.2): dependencies: - debug: 3.2.7 - koa-send: 5.0.1 + debug: 3.2.7(supports-color@10.2.2) + koa-send: 5.0.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -17995,11 +17997,11 @@ snapshots: dependencies: uc.micro: 2.1.0 - lint-staged@17.2.0: + lint-staged@17.3.0: dependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 string-argv: 0.3.2 - tinyexec: 1.2.4 + tinyexec: 1.3.0 optionalDependencies: yaml: 2.9.0 @@ -18132,13 +18134,13 @@ snapshots: math-intrinsics@1.1.0: {} - mdast-util-directive@3.1.0: + mdast-util-directive@3.1.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -18153,14 +18155,14 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.3: + mdast-util-from-markdown@2.0.3(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.2 + micromark: 4.0.2(supports-color@10.2.2) micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-string: 2.0.1 micromark-util-normalize-identifier: 2.0.1 @@ -18170,12 +18172,12 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-frontmatter@2.0.1: + mdast-util-frontmatter@2.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 escape-string-regexp: 5.0.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 micromark-extension-frontmatter: 2.0.0 transitivePeerDependencies: @@ -18189,67 +18191,67 @@ snapshots: mdast-util-find-and-replace: 3.0.2 micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.1.0: + mdast-util-gfm-footnote@2.1.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color - mdast-util-gfm-strikethrough@2.0.0: + mdast-util-gfm-strikethrough@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-table@2.0.0: + mdast-util-gfm-table@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm-task-list-item@2.0.0: + mdast-util-gfm-task-list-item@2.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.1.0: + mdast-util-gfm@3.1.0(supports-color@10.2.2): dependencies: - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-gfm-footnote: 2.1.0(supports-color@10.2.2) + mdast-util-gfm-strikethrough: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-table: 2.0.0(supports-color@10.2.2) + mdast-util-gfm-task-list-item: 2.0.0(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-expression@2.0.1: + mdast-util-mdx-expression@2.0.1(supports-color@10.2.2): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.2.0: + mdast-util-mdx-jsx@3.2.0(supports-color@10.2.2): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -18257,7 +18259,7 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 parse-entities: 4.0.2 stringify-entities: 4.0.4 @@ -18266,23 +18268,23 @@ snapshots: transitivePeerDependencies: - supports-color - mdast-util-mdx@3.0.0: + mdast-util-mdx@3.0.0(supports-color@10.2.2): dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) + mdast-util-mdx-expression: 2.0.1(supports-color@10.2.2) + mdast-util-mdx-jsx: 3.2.0(supports-color@10.2.2) + mdast-util-mdxjs-esm: 2.0.1(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdxjs-esm@2.0.1: + mdast-util-mdxjs-esm@2.0.1(supports-color@10.2.2): dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -18496,8 +18498,8 @@ snapshots: micromark-extension-mdxjs@3.0.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) micromark-extension-mdx-expression: 3.0.1 micromark-extension-mdx-jsx: 3.0.2 micromark-extension-mdx-md: 2.0.0 @@ -18633,10 +18635,10 @@ snapshots: micromark-util-types@2.0.2: {} - micromark@4.0.2: + micromark@4.0.2(supports-color@10.2.2): dependencies: '@types/debug': 4.1.12 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -18658,7 +18660,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 mime-db@1.33.0: {} @@ -18692,23 +18694,27 @@ snapshots: mimic-response@4.0.0: {} - mini-css-extract-plugin@2.10.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + mini-css-extract-plugin@2.10.2(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: schema-utils: 4.3.3 tapable: 2.3.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.5: dependencies: brace-expansion: 5.0.6 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.1 + brace-expansion: 2.1.4 minimist@1.2.8: {} @@ -18716,42 +18722,22 @@ snapshots: dependencies: minimist: 1.2.8 - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.49.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) optionalDependencies: '@swc/core': 1.15.47(@swc/helpers@0.5.23) '@swc/html': 1.15.47 - lightningcss: 1.33.0 - postcss: 8.5.25 - - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - terser: 5.49.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) - optionalDependencies: - '@swc/core': 1.15.47(@swc/helpers@0.5.23) clean-css: 5.3.3 cssnano: 6.1.2(postcss@8.5.25) + csso: 5.0.5 + esbuild: 0.28.2 html-minifier-terser: 7.2.0 - postcss: 8.5.25 - - minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - terser: 5.49.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) - optionalDependencies: - '@swc/core': 1.15.47(@swc/helpers@0.5.23) + lightningcss: 1.33.0 postcss: 8.5.25 minipass@7.1.3: {} @@ -18759,10 +18745,10 @@ snapshots: mkdirp-classic@0.5.3: optional: true - morgan@1.11.0: + morgan@1.11.0(supports-color@10.2.2): dependencies: basic-auth: 2.0.1 - debug: 2.6.9 + debug: 2.6.9(supports-color@10.2.2) depd: 2.0.0 on-finished: 2.4.1 on-headers: 1.1.0 @@ -18854,8 +18840,6 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.44: {} - node-releases@2.0.51: {} node-sarif-builder@3.4.0: @@ -18885,11 +18869,11 @@ snapshots: dependencies: boolbase: 1.0.0 - null-loader@4.0.1(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + null-loader@4.0.1(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: loader-utils: 2.0.4 schema-utils: 3.3.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) nwsapi@2.2.23: {} @@ -19066,16 +19050,16 @@ snapshots: p-try@2.2.0: {} - pac-proxy-agent@7.2.0: + pac-proxy-agent@7.2.0(supports-color@10.2.2): dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3 - get-uri: 6.0.5 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + get-uri: 6.0.5(supports-color@10.2.2) + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.5 + socks-proxy-agent: 8.0.5(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -19212,6 +19196,8 @@ snapshots: picomatch@4.0.5: {} + picomatch@4.0.7: {} + pify@4.0.1: {} pino-abstract-transport@1.2.0: @@ -19249,7 +19235,7 @@ snapshots: on-exit-leak-free: 2.1.2 pino-abstract-transport: 2.0.0 pino-std-serializers: 7.1.0 - process-warning: 5.0.0 + process-warning: 5.1.0 quick-format-unescaped: 4.0.4 real-require: 0.2.0 safe-stable-stringify: 2.5.0 @@ -19444,13 +19430,13 @@ snapshots: '@csstools/utilities': 2.0.0(postcss@8.5.25) postcss: 8.5.25 - postcss-loader@7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + postcss-loader@7.3.4(@typescript/typescript6@6.0.2)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: cosmiconfig: 8.3.6(@typescript/typescript6@6.0.2) jiti: 1.21.7 postcss: 8.5.25 semver: 7.8.5 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - typescript @@ -19789,7 +19775,7 @@ snapshots: process-nextick-args@2.0.1: {} - process-warning@5.0.0: {} + process-warning@5.1.0: {} process@0.11.10: {} @@ -19823,16 +19809,16 @@ snapshots: proxy-agent-negotiate@1.1.0: {} - proxy-agent@6.5.0: + proxy-agent@6.5.0(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) lru-cache: 7.18.3 - pac-proxy-agent: 7.2.0 + pac-proxy-agent: 7.2.0(supports-color@10.2.2) proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.5 + socks-proxy-agent: 8.0.5(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -19894,9 +19880,9 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 - rc-config-loader@4.1.4: + rc-config-loader@4.1.4(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) js-yaml: 4.3.0 json5: 2.2.3 require-from-string: 2.0.2 @@ -19927,11 +19913,11 @@ snapshots: dependencies: react: 19.2.8 - react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + react-loadable-ssr-addon-v5-slorber@1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.8))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@babel/runtime': 7.29.7 react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.8)' - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) react-router-config@5.1.1(react-router@5.3.4(react@19.2.8))(react@19.2.8): dependencies: @@ -20005,7 +19991,7 @@ snapshots: dependencies: picomatch: 4.0.5 - readdirp@5.0.0: {} + readdirp@5.1.1: {} real-require@0.2.0: {} @@ -20075,20 +20061,20 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 - rehype-recma@1.0.0: + rehype-recma@1.0.0(supports-color@10.2.2): dependencies: '@types/estree': 1.0.9 '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3 + hast-util-to-estree: 3.1.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color relateurl@0.2.7: {} - remark-directive@3.0.1: + remark-directive@3.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-directive: 3.1.0 + mdast-util-directive: 3.1.0(supports-color@10.2.2) micromark-extension-directive: 3.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -20102,37 +20088,37 @@ snapshots: node-emoji: 2.2.0 unified: 11.0.5 - remark-frontmatter@5.0.0: + remark-frontmatter@5.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-frontmatter: 2.0.1 + mdast-util-frontmatter: 2.0.1(supports-color@10.2.2) micromark-extension-frontmatter: 2.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-gfm@4.0.1: + remark-gfm@4.0.1(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.1.0 + mdast-util-gfm: 3.1.0(supports-color@10.2.2) micromark-extension-gfm: 3.0.0 - remark-parse: 11.0.0 + remark-parse: 11.0.0(supports-color@10.2.2) remark-stringify: 11.0.0 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-mdx@3.1.1: + remark-mdx@3.1.1(supports-color@10.2.2): dependencies: - mdast-util-mdx: 3.0.0 + mdast-util-mdx: 3.0.0(supports-color@10.2.2) micromark-extension-mdxjs: 3.0.0 transitivePeerDependencies: - supports-color - remark-parse@11.0.0: + remark-parse@11.0.0(supports-color@10.2.2): dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 + mdast-util-from-markdown: 2.0.3(supports-color@10.2.2) micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: @@ -20214,26 +20200,26 @@ snapshots: reusify@1.1.0: {} - rolldown@1.2.1: + rolldown@1.2.6: dependencies: - '@oxc-project/types': 0.142.0 + '@oxc-project/types': 0.147.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.2.1 - '@rolldown/binding-darwin-arm64': 1.2.1 - '@rolldown/binding-darwin-x64': 1.2.1 - '@rolldown/binding-freebsd-x64': 1.2.1 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 - '@rolldown/binding-linux-arm64-gnu': 1.2.1 - '@rolldown/binding-linux-arm64-musl': 1.2.1 - '@rolldown/binding-linux-ppc64-gnu': 1.2.1 - '@rolldown/binding-linux-s390x-gnu': 1.2.1 - '@rolldown/binding-linux-x64-gnu': 1.2.1 - '@rolldown/binding-linux-x64-musl': 1.2.1 - '@rolldown/binding-openharmony-arm64': 1.2.1 - '@rolldown/binding-wasm32-wasi': 1.2.1 - '@rolldown/binding-win32-arm64-msvc': 1.2.1 - '@rolldown/binding-win32-x64-msvc': 1.2.1 + '@rolldown/binding-android-arm-eabi': 1.2.6 + '@rolldown/binding-android-arm64': 1.2.6 + '@rolldown/binding-darwin-arm64': 1.2.6 + '@rolldown/binding-darwin-x64': 1.2.6 + '@rolldown/binding-freebsd-x64': 1.2.6 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.6 + '@rolldown/binding-linux-arm64-gnu': 1.2.6 + '@rolldown/binding-linux-arm64-musl': 1.2.6 + '@rolldown/binding-linux-ppc64-gnu': 1.2.6 + '@rolldown/binding-linux-s390x-gnu': 1.2.6 + '@rolldown/binding-linux-x64-gnu': 1.2.6 + '@rolldown/binding-linux-x64-musl': 1.2.6 + '@rolldown/binding-openharmony-arm64': 1.2.6 + '@rolldown/binding-win32-arm64-msvc': 1.2.6 + '@rolldown/binding-win32-x64-msvc': 1.2.6 rollup-plugin-copy@3.5.0: dependencies: @@ -20243,60 +20229,61 @@ snapshots: globby: 10.0.1 is-plain-object: 3.0.1 - rollup-plugin-polyfill-node@0.13.0(rollup@4.62.3): + rollup-plugin-polyfill-node@0.13.0(rollup@4.63.0): dependencies: - '@rollup/plugin-inject': 5.0.5(rollup@4.62.3) - rollup: 4.62.3 + '@rollup/plugin-inject': 5.0.5(rollup@4.63.0) + rollup: 4.63.0 - rollup-plugin-swc3@0.12.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(rollup@4.62.3): + rollup-plugin-swc3@0.12.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(rollup@4.63.0): dependencies: '@dual-bundle/import-meta-resolve': 4.2.1 '@fastify/deepmerge': 2.0.2 - '@rollup/pluginutils': 5.4.0(rollup@4.62.3) - '@swc/core': 1.15.47(@swc/helpers@0.5.23) + '@rollup/pluginutils': 5.4.0(rollup@4.63.0) + '@swc/core': 1.16.1(@swc/helpers@0.5.23) get-tsconfig: 4.14.0 - rollup: 4.62.3 - rollup-preserve-directives: 1.1.3(rollup@4.62.3) + rollup: 4.63.0 + rollup-preserve-directives: 1.1.3(rollup@4.63.0) - rollup-preserve-directives@1.1.3(rollup@4.62.3): + rollup-preserve-directives@1.1.3(rollup@4.63.0): dependencies: magic-string: 0.30.21 - rollup: 4.62.3 + rollup: 4.63.0 - rollup@4.62.3: + rollup@4.63.0: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.3 - '@rollup/rollup-android-arm64': 4.62.3 - '@rollup/rollup-darwin-arm64': 4.62.3 - '@rollup/rollup-darwin-x64': 4.62.3 - '@rollup/rollup-freebsd-arm64': 4.62.3 - '@rollup/rollup-freebsd-x64': 4.62.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 - '@rollup/rollup-linux-arm-musleabihf': 4.62.3 - '@rollup/rollup-linux-arm64-gnu': 4.62.3 - '@rollup/rollup-linux-arm64-musl': 4.62.3 - '@rollup/rollup-linux-loong64-gnu': 4.62.3 - '@rollup/rollup-linux-loong64-musl': 4.62.3 - '@rollup/rollup-linux-ppc64-gnu': 4.62.3 - '@rollup/rollup-linux-ppc64-musl': 4.62.3 - '@rollup/rollup-linux-riscv64-gnu': 4.62.3 - '@rollup/rollup-linux-riscv64-musl': 4.62.3 - '@rollup/rollup-linux-s390x-gnu': 4.62.3 - '@rollup/rollup-linux-x64-gnu': 4.62.3 - '@rollup/rollup-linux-x64-musl': 4.62.3 - '@rollup/rollup-openbsd-x64': 4.62.3 - '@rollup/rollup-openharmony-arm64': 4.62.3 - '@rollup/rollup-win32-arm64-msvc': 4.62.3 - '@rollup/rollup-win32-ia32-msvc': 4.62.3 - '@rollup/rollup-win32-x64-gnu': 4.62.3 - '@rollup/rollup-win32-x64-msvc': 4.62.3 + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.0 + '@rollup/rollup-android-arm64': 4.63.0 + '@rollup/rollup-darwin-arm64': 4.63.0 + '@rollup/rollup-darwin-x64': 4.63.0 + '@rollup/rollup-freebsd-arm64': 4.63.0 + '@rollup/rollup-freebsd-x64': 4.63.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.0 + '@rollup/rollup-linux-arm-musleabihf': 4.63.0 + '@rollup/rollup-linux-arm64-gnu': 4.63.0 + '@rollup/rollup-linux-arm64-musl': 4.63.0 + '@rollup/rollup-linux-loong64-gnu': 4.63.0 + '@rollup/rollup-linux-loong64-musl': 4.63.0 + '@rollup/rollup-linux-ppc64-gnu': 4.63.0 + '@rollup/rollup-linux-ppc64-musl': 4.63.0 + '@rollup/rollup-linux-riscv64-gnu': 4.63.0 + '@rollup/rollup-linux-riscv64-musl': 4.63.0 + '@rollup/rollup-linux-s390x-gnu': 4.63.0 + '@rollup/rollup-linux-x64-gnu': 4.63.0 + '@rollup/rollup-linux-x64-musl': 4.63.0 + '@rollup/rollup-openbsd-x64': 4.63.0 + '@rollup/rollup-openharmony-arm64': 4.63.0 + '@rollup/rollup-win32-arm64-msvc': 4.63.0 + '@rollup/rollup-win32-ia32-msvc': 4.63.0 + '@rollup/rollup-win32-x64-gnu': 4.63.0 + '@rollup/rollup-win32-x64-msvc': 4.63.0 fsevents: 2.3.3 - router@2.2.0: + router@2.2.0(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -20333,13 +20320,13 @@ snapshots: safer-buffer@2.1.2: {} - sass@1.102.0: + sass@1.103.1: dependencies: chokidar: 5.0.0 immutable: 5.1.9 source-map-js: 1.2.1 optionalDependencies: - '@parcel/watcher': 2.5.6 + '@parcel/watcher': 2.6.0 sax@1.6.1: {} @@ -20360,19 +20347,19 @@ snapshots: schema-utils@4.3.3: dependencies: '@types/json-schema': 7.0.15 - ajv: 8.18.0 - ajv-formats: 2.1.1(ajv@8.18.0) - ajv-keywords: 5.1.0(ajv@8.18.0) + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) search-insights@2.17.3: {} - secretlint@10.2.2: + secretlint@10.2.2(supports-color@10.2.2): dependencies: '@secretlint/config-creator': 10.2.2 - '@secretlint/formatter': 10.2.2 - '@secretlint/node': 10.2.2 + '@secretlint/formatter': 10.2.2(supports-color@10.2.2) + '@secretlint/node': 10.2.2(supports-color@10.2.2) '@secretlint/profiler': 10.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) globby: 14.1.0 read-pkg: 9.0.1 transitivePeerDependencies: @@ -20400,9 +20387,9 @@ snapshots: semver@7.8.5: {} - send@1.2.1: + send@1.2.1(supports-color@10.2.2): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -20436,11 +20423,11 @@ snapshots: path-to-regexp: 3.3.0 range-parser: 1.2.0 - serve-index@1.9.2: + serve-index@1.9.2(supports-color@10.2.2): dependencies: accepts: 1.3.8 batch: 0.6.1 - debug: 2.6.9 + debug: 2.6.9(supports-color@10.2.2) escape-html: 1.0.3 http-errors: 1.8.1 mime-types: 2.1.35 @@ -20448,12 +20435,12 @@ snapshots: transitivePeerDependencies: - supports-color - serve-static@2.2.1: + serve-static@2.2.1(supports-color@10.2.2): dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1 + send: 1.2.1(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -20572,10 +20559,10 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 - socks-proxy-agent@8.0.5: + socks-proxy-agent@8.0.5(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -20765,11 +20752,11 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 - swc-loader@0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + swc-loader@0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@swc/core': 1.15.47(@swc/helpers@0.5.23) '@swc/counter': 0.1.3 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) symbol-tree@3.2.4: {} @@ -20841,24 +20828,28 @@ snapshots: ansi-escapes: 7.3.0 supports-hyperlinks: 3.2.0 - terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + terser-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) optionalDependencies: '@swc/core': 1.15.47(@swc/helpers@0.5.23) + '@swc/html': 1.15.47 clean-css: 5.3.3 cssnano: 6.1.2(postcss@8.5.25) + csso: 5.0.5 + esbuild: 0.28.2 html-minifier-terser: 7.2.0 + lightningcss: 1.33.0 postcss: 8.5.25 terser@5.48.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -20910,7 +20901,7 @@ snapshots: tiny-warning@1.0.3: {} - tinyexec@1.2.4: {} + tinyexec@1.3.0: {} tinyglobby@0.2.17: dependencies: @@ -20921,15 +20912,15 @@ snapshots: tldts-core@6.1.86: {} - tldts-core@7.4.3: {} + tldts-core@7.4.11: {} tldts@6.1.86: dependencies: tldts-core: 6.1.86 - tldts@7.4.3: + tldts@7.4.11: dependencies: - tldts-core: 7.4.3 + tldts-core: 7.4.11 tmp@0.2.7: {} @@ -20953,9 +20944,9 @@ snapshots: dependencies: tldts: 6.1.86 - tough-cookie@6.0.1: + tough-cookie@6.0.2: dependencies: - tldts: 7.4.3 + tldts: 7.4.11 tr46@5.1.1: dependencies: @@ -21035,13 +21026,13 @@ snapshots: dependencies: is-typedarray: 1.0.0 - typescript-eslint@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)): + typescript-eslint@8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) - '@typescript-eslint/parser': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) - '@typescript-eslint/typescript-estree': 8.65.0(@typescript/typescript6@6.0.2) - '@typescript-eslint/utils': 8.65.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@1.21.7)) - eslint: 10.8.0(jiti@1.21.7) + '@typescript-eslint/eslint-plugin': 8.68.0(@typescript-eslint/parser@8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2))(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) + '@typescript-eslint/parser': 8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.68.0(@typescript/typescript6@6.0.2)(supports-color@10.2.2) + '@typescript-eslint/utils': 8.68.0(@typescript/typescript6@6.0.2)(eslint@10.9.1(jiti@1.21.7)(supports-color@10.2.2))(supports-color@10.2.2) + eslint: 10.9.1(jiti@1.21.7)(supports-color@10.2.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -21077,6 +21068,8 @@ snapshots: undici-types@6.21.0: {} + undici@8.10.0: {} + undici@8.9.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -21170,12 +21163,6 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.7): dependencies: browserslist: 4.28.7 @@ -21213,14 +21200,14 @@ snapshots: url-join@4.0.1: {} - url-loader@4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + url-loader@4.1.1(file-loader@6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) optionalDependencies: - file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + file-loader: 6.2.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) util-deprecate@1.0.2: {} @@ -21262,6 +21249,8 @@ snapshots: vscode-uri@3.1.0: {} + vscode-uri@3.2.0: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -21300,18 +21289,18 @@ snapshots: - bufferutil - utf-8-validate - webpack-dev-middleware@8.1.0(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + webpack-dev-middleware@8.1.0(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: memfs: 4.64.0(tslib@2.8.1) mime-types: 3.0.2 range-parser: 1.3.0 schema-utils: 4.3.3 optionalDependencies: - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - tslib - webpack-dev-server@6.0.0(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + webpack-dev-server@6.0.0(supports-color@10.2.2)(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -21323,23 +21312,23 @@ snapshots: ansi-html-community: 0.0.8 bonjour-service: 1.4.4 chokidar: 5.0.0 - compression: 1.8.1 + compression: 1.8.1(supports-color@10.2.2) connect-history-api-fallback: 2.0.0 - express: 5.2.1 + express: 5.2.1(supports-color@10.2.2) graceful-fs: 4.2.11 - http-proxy-middleware: 4.2.0 + http-proxy-middleware: 4.2.0(supports-color@10.2.2) ipaddr.js: 2.4.0 launch-editor: 2.14.1 open: 11.0.0 p-retry: 8.0.0 schema-utils: 4.3.3 selfsigned: 5.5.0 - serve-index: 1.9.2 + serve-index: 1.9.2(supports-color@10.2.2) tinyglobby: 0.2.17 - webpack-dev-middleware: 8.1.0(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + webpack-dev-middleware: 8.1.0(tslib@2.8.1)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) ws: 8.21.1 optionalDependencies: - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) transitivePeerDependencies: - bufferutil - supports-color @@ -21360,79 +21349,7 @@ snapshots: webpack-sources@3.5.1: {} - webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(lightningcss@1.33.0)(postcss@8.5.25): - dependencies: - '@types/estree': 1.0.9 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.18.0 - browserslist: 4.28.7 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.5 - es-module-lexer: 2.3.1 - eslint-scope: 5.1.1 - events: 3.3.0 - graceful-fs: 4.2.11 - mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.3 - watchpack: 2.5.2 - webpack-sources: 3.5.1 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/core' - - '@swc/css' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - uglify-js - - webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25): - dependencies: - '@types/estree': 1.0.9 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.18.0 - browserslist: 4.28.7 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.5 - es-module-lexer: 2.3.1 - eslint-scope: 5.1.1 - events: 3.3.0 - graceful-fs: 4.2.11 - mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(html-minifier-terser@7.2.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.3 - watchpack: 2.5.2 - webpack-sources: 3.5.1 - transitivePeerDependencies: - - '@minify-html/node' - - '@swc/core' - - '@swc/css' - - '@swc/html' - - clean-css - - cssnano - - csso - - esbuild - - html-minifier-terser - - lightningcss - - postcss - - uglify-js - - webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25): + webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -21448,7 +21365,7 @@ snapshots: events: 3.3.0 graceful-fs: 4.2.11 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)) + minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 @@ -21468,7 +21385,7 @@ snapshots: - postcss - uglify-js - webpackbar@7.0.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25)): + webpackbar@7.0.0(@rspack/core@1.7.12(@swc/helpers@0.5.23))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25)): dependencies: ansis: 3.17.0 consola: 3.4.2 @@ -21476,7 +21393,7 @@ snapshots: std-env: 3.10.0 optionalDependencies: '@rspack/core': 1.7.12(@swc/helpers@0.5.23) - webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(postcss@8.5.25) + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.23))(@swc/html@1.15.47)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.25))(csso@5.0.5)(esbuild@0.28.2)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.25) websocket-driver@0.7.5: dependencies: From 4e8722fba2ea37783501738d91ff153050157253 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:34:55 +0100 Subject: [PATCH 24/61] build(deps): bump the production-dependencies group across 1 directory with 3 updates (#990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the production-dependencies group with 3 updates in the / directory: [@apexdevtools/apex-parser](https://github.com/apex-dev-tools/apex-parser), [vscode-uri](https://github.com/microsoft/vscode-uri) and [pixi.js](https://github.com/pixijs/pixijs). Updates `@apexdevtools/apex-parser` from 5.1.0 to 5.2.0
    Release notes

    Sourced from @โ€‹apexdevtools/apex-parser's releases.

    v5.2.0

    What's Changed

    New Contributors

    Full Changelog: https://github.com/apex-dev-tools/apex-parser/compare/v5.1.0...v5.2.0

    Changelog

    Sourced from @โ€‹apexdevtools/apex-parser's changelog.

    5.2.0 - 2026-08-21

    • Tighten the annotation grammar to reject constructs that are inherited from Java but are not legal Apex
      • (SOURCE BREAKING) ElementValueArrayInitializerContext is no longer generated, AnnotationContext.qualifiedName() becomes id(), and ElementValueContext exposes only literal(). Tree-walking consumers referencing these need updating. This ships as a minor version, not a major one; grammar changes of this kind are routine here and major bumps are reserved for build-environment or large-scale changes
      • annotation matches id in place of qualifiedName; @Schema.AuraEnabled is now a syntax error, matching the platform (Unexpected token '.'). Apex has no user-defined annotations, so a namespace-qualified form has never been legal
      • elementValue matches literal in place of expression, so a bare identifier value such as @AuraEnabled(cacheable=foo) is now a syntax error, as it is on the platform
      • Nested annotations and array initialiser values (label={'a','b'}) are no longer accepted, and the elementValueArrayInitializer rule is removed. elementValue is now non-recursive
      • The optional COMMA separator between annotation parameters is deliberately kept, and is now documented in the grammar as the one exception. The platform separates parameters by whitespace alone, but rejecting the comma form at parse time reproduces the platform compiler's own failure mode, where a member-level annotation is recovered as a constructor declaration and the rest of the file is lost to cascading errors
      • Values the platform rejects for type reasons, such as @AuraEnabled(cacheable=0) and cacheable=null, still parse. That is intended, so a consumer can diagnose the value precisely instead of losing the file to a syntax error
      • Add annotation parameter test coverage to both the maven and npm targets
    • Add https://github.com/apex-dev-tools/apex-parser/blob/main/doc/SalesforceDifferences.md, recording deliberate differences between what this grammar accepts and what the Salesforce platform compiler accepts, so they are not mistakenly "corrected" later. The first entry is the annotation parameter comma separator above
    • Support the SOSL WITH SPELL_CORRECTION = { true | false } clause, e.g. [FIND :term IN ALL FIELDS RETURNING Account WITH SPELL_CORRECTION = false]; an Apex bind variable (:expr) is also accepted in place of the literal
    • Support the SOSL WITH HIGHLIGHT clause, e.g. [FIND 'salesforce' IN ALL FIELDS RETURNING Account(Name, Description) WITH HIGHLIGHT]
    • New HIGHLIGHT and SPELL_CORRECTION lexer tokens; both are also accepted as identifiers (id/anyId), so existing code using them as names is unaffected
    • Fix the dataCategoryName grammar rule so parenthesized SOQL data category lists close with RPAREN; previously, valid multi-category WITH DATA CATEGORY filters failed to parse
    • Fix WITH DATA CATEGORY filters with more than one selection. filteringExpression joined selections with the AND token, which is the Java && operator, not the SOQL and keyword. In SOSL this was a parse error; in SOQL the trailing selections were silently left unconsumed
    Commits
    • 3c9d2a9 Merge pull request #152 from apex-dev-tools/ao/adt-45
    • 1d1bcd3 chore: release 5.2.0
    • 30374e7 Merge pull request #149 from apex-dev-tools/ao/adt-44
    • 710f181 docs: record deliberate differences from platform behaviour
    • a6cfe93 fix: reject annotation forms that are not legal Apex
    • 6f7ff78 Merge pull request #147 from apex-dev-tools/ao/adt-43
    • 584c80c Merge pull request #148 from apex-dev-tools/dependabot/npm_and_yarn/npm/npm-m...
    • eec9864 chore(deps-dev): bump the npm-minor-patch group in /npm with 3 updates
    • 7cd3451 feat: support SOSL WITH SPELL_CORRECTION and WITH HIGHLIGHT
    • 3b02a35 Merge pull request #142 from rickroesler/fix/data-category-name-rparen
    • Additional commits viewable in compare view

    Updates `vscode-uri` from 3.1.0 to 3.2.0
    Release notes

    Sourced from vscode-uri's releases.

    v3.2.0

    Changes:

    • #65: chore: bump minor version to 3.2.0
    • #59: Restore the default export
    • #63: Bump brace-expansion from 2.1.1 to 2.1.4
    • #64: Bump js-yaml from 4.3.0 to 4.3.1
    • #62: Bump fast-uri from 3.1.4 to 3.1.5
    • #61: Bump js-yaml from 4.2.0 to 4.3.0
    • #60: Bump fast-uri from 3.1.2 to 3.1.4
    • #58: chore: bump patch version to 3.1.1
    • #57: Use Yarn resolutions to resolve remaining mocha audit alerts
    • #56: Bump fast-uri from 3.1.0 to 3.1.2
    • #55: Bump picomatch from 2.3.1 to 2.3.2
    • #54: Bump webpack from 5.94.0 to 5.104.1
    • #52: Bump glob from 10.3.10 to 10.5.0
    • #51: Bump js-yaml from 4.1.0 to 4.1.1
    • #50: chore: bump action and node versions
    • #49: Bump serialize-javascript from 6.0.1 to 6.0.2

    This list of changes was auto generated.

    Commits
    • c225237 Merge pull request #65 from microsoft/agents/version-bump-and-pr
    • 1524a35 chore: bump minor version to 3.2.0
    • b402e95 Merge pull request #59 from remcohaszing/restore-default-export
    • 67ce68f Fix the ESM emit
    • f2623f2 Bump brace-expansion from 2.1.1 to 2.1.4 (#63)
    • a345108 Bump js-yaml from 4.3.0 to 4.3.1 (#64)
    • fdb6abd Bump fast-uri from 3.1.4 to 3.1.5 (#62)
    • a8445fc Bump js-yaml from 4.2.0 to 4.3.0 (#61)
    • 2b9a45a Bump fast-uri from 3.1.2 to 3.1.4 (#60)
    • b24df9d Restore the default export
    • Additional commits viewable in compare view

    Updates `pixi.js` from 8.19.0 to 8.20.1
    Release notes

    Sourced from pixi.js's releases.

    v8.20.1

    ๐Ÿ’พ Download

    Installation:

    npm install pixi.js@8.20.1
    

    Development Build:

    Production Build:

    Documentation:

    Changed

    https://github.com/pixijs/pixijs/compare/v8.20.0...v8.20.1

    ๐Ÿ› Fixed

    ๐Ÿงน Chores

    • chore: bump @xmldom/xmldom to 0.8.15 by @โ€‹Zyie in pixijs/pixijs#12162
      • Removes the npm warn deprecated @xmldom/xmldom@0.8.14 warning printed when installing pixi.js.

    New Contributors

    Full Changelog: https://github.com/pixijs/pixijs/compare/v8.20.0...v8.20.1

    v8.20.0

    ๐Ÿ’พ Download

    Installation:

    npm install pixi.js@8.20.0
    

    Development Build:

    Production Build:

    ... (truncated)

    Commits

    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lana/package.json | 4 ++-- log-viewer/package.json | 4 ++-- pnpm-lock.yaml | 53 ++++++++++++++++++++--------------------- 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/lana/package.json b/lana/package.json index ddc3876f9..125e87b2b 100644 --- a/lana/package.json +++ b/lana/package.json @@ -391,9 +391,9 @@ "vscode:bundle": "pnpm -w run build" }, "dependencies": { - "@apexdevtools/apex-parser": "5.1.0", + "@apexdevtools/apex-parser": "5.2.0", "effect": "^3.22.0", - "vscode-uri": "^3.1.0" + "vscode-uri": "^3.2.0" }, "devDependencies": { "@salesforce/vscode-services": "^67.15.0", diff --git a/log-viewer/package.json b/log-viewer/package.json index 04426cb4b..c5e6f4732 100644 --- a/log-viewer/package.json +++ b/log-viewer/package.json @@ -8,13 +8,13 @@ "#vscode-elements/*.js": "@vscode-elements/elements/dist/*/index.js" }, "dependencies": { - "@apexdevtools/apex-parser": "5.1.0", + "@apexdevtools/apex-parser": "5.2.0", "@lit/context": "^1.1.6", "@vscode-elements/elements": "^2.5.1", "@vscode/codicons": "^0.0.45", "antlr4": "4.13.2", "lit": "^3.3.3", - "pixi.js": "^8.19.0", + "pixi.js": "^8.20.1", "tabulator-tables": "^6.5.2" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11d55ad0a..3115fb597 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,14 +125,14 @@ importers: lana: dependencies: '@apexdevtools/apex-parser': - specifier: 5.1.0 - version: 5.1.0 + specifier: 5.2.0 + version: 5.2.0 effect: specifier: ^3.22.0 version: 3.22.1 vscode-uri: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.2.0 + version: 3.2.0 devDependencies: '@salesforce/vscode-services': specifier: ^67.15.0 @@ -199,8 +199,8 @@ importers: log-viewer: dependencies: '@apexdevtools/apex-parser': - specifier: 5.1.0 - version: 5.1.0 + specifier: 5.2.0 + version: 5.2.0 '@lit/context': specifier: ^1.1.6 version: 1.1.6 @@ -217,8 +217,8 @@ importers: specifier: ^3.3.3 version: 3.3.3 pixi.js: - specifier: ^8.19.0 - version: 8.19.0 + specifier: ^8.20.1 + version: 8.20.1 tabulator-tables: specifier: ^6.5.2 version: 6.5.2 @@ -332,8 +332,8 @@ packages: resolution: {integrity: sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==} engines: {node: '>= 14.0.0'} - '@apexdevtools/apex-parser@5.1.0': - resolution: {integrity: sha512-DPThB5oMnJ9b62weMpufGQfJjtZ+5UHP5Ne8nOqsDDCAVP3P2yRL80p1qX5BpPxyOowhTJ9RF/3qhat0vCrv4g==} + '@apexdevtools/apex-parser@5.2.0': + resolution: {integrity: sha512-6OwE46CAi6Y8q1Wz1k5dMrQR/awPqI4ryXcgYZaVx4tMUbfv4+n2ezUF7Tm0EOypXleuPwKqaZuDxlP486HXlA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} bundledDependencies: - antlr4 @@ -4263,13 +4263,12 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - '@webgpu/types@0.1.70': - resolution: {integrity: sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==} + '@webgpu/types@0.1.72': + resolution: {integrity: sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==} - '@xmldom/xmldom@0.8.13': - resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} engines: {node: '>=10.0.0'} - deprecated: this version has critical issues, please update to the latest version '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -5394,8 +5393,8 @@ packages: duplexify@3.7.1: resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} - earcut@3.0.2: - resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + earcut@3.2.3: + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -7826,8 +7825,8 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - pixi.js@8.19.0: - resolution: {integrity: sha512-pq1O6emA/GFjjeF+8d3Pb5t7knD8FsnfWGqQcRjYjsqFZ7QdzG1XgjLDUu0DFJRbafjV5+g8iNLFBx0b9649lg==} + pixi.js@8.20.1: + resolution: {integrity: sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==} pkg-dir@4.2.0: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} @@ -9982,7 +9981,7 @@ snapshots: dependencies: '@algolia/client-common': 5.56.0 - '@apexdevtools/apex-parser@5.1.0': {} + '@apexdevtools/apex-parser@5.2.0': {} '@asamuzakjp/css-color@3.2.0': dependencies: @@ -14894,9 +14893,9 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 - '@webgpu/types@0.1.70': {} + '@webgpu/types@0.1.72': {} - '@xmldom/xmldom@0.8.13': {} + '@xmldom/xmldom@0.8.15': {} '@xtuc/ieee754@1.2.0': {} @@ -16058,7 +16057,7 @@ snapshots: readable-stream: 2.3.8 stream-shift: 1.0.3 - earcut@3.0.2: {} + earcut@3.2.3: {} eastasianwidth@0.2.0: {} @@ -19244,13 +19243,13 @@ snapshots: pirates@4.0.7: {} - pixi.js@8.19.0: + pixi.js@8.20.1: dependencies: '@pixi/colord': 2.9.6 '@types/earcut': 3.0.0 - '@webgpu/types': 0.1.70 - '@xmldom/xmldom': 0.8.13 - earcut: 3.0.2 + '@webgpu/types': 0.1.72 + '@xmldom/xmldom': 0.8.15 + earcut: 3.2.3 eventemitter3: 5.0.4 gifuct-js: 2.1.2 ismobilejs: 1.1.1 From b477f671ef785d867de17c706507b1bea5ce1833 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:28:18 +0100 Subject: [PATCH 25/61] build(deps): bump the github-actions group across 1 directory with 2 updates (#927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 2 updates in the / directory: [pnpm/action-setup](https://github.com/pnpm/action-setup) and [github/codeql-action](https://github.com/github/codeql-action). Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
    Release notes

    Sourced from pnpm/action-setup's releases.

    v6.0.10

    What's Changed

    New Contributors

    Full Changelog: https://github.com/pnpm/action-setup/compare/v6...v6.0.10

    Commits

    Updates `github/codeql-action` from 4.37.4 to 4.37.9
    Release notes

    Sourced from github/codeql-action's releases.

    v4.37.9

    • Update default CodeQL bundle version to 2.26.4. #4106

    v4.37.8

    No user facing changes.

    v4.37.7

    • Update default CodeQL bundle version to 2.26.3. #4085

    v4.37.6

    • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

    v4.37.5

    • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061
    Changelog

    Sourced from github/codeql-action's changelog.

    4.37.9 - 26 Aug 2026

    • Update default CodeQL bundle version to 2.26.4. #4106

    4.37.8 - 21 Aug 2026

    No user facing changes.

    4.37.7 - 13 Aug 2026

    • Update default CodeQL bundle version to 2.26.3. #4085

    4.37.6 - 04 Aug 2026

    • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

    4.37.5 - 03 Aug 2026

    • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061
    Commits
    • cdf488f Merge pull request #4107 from github/update-v4.37.9-920ba7cd1
    • 7243f38 Update changelog for v4.37.9
    • 920ba7c Merge pull request #4106 from github/update-bundle/codeql-bundle-v2.26.4
    • ecfa6e1 Add changelog note
    • adcdf4a Update default bundle to codeql-bundle-v2.26.4
    • 486fec2 Merge pull request #4099 from github/update-supported-enterprise-server-versions
    • 134624c Merge pull request #4101 from github/dependabot/npm_and_yarn/npm-minor-457d82...
    • ff43db8 Merge pull request #4103 from github/mergeback/v4.37.8-to-main-db488dde
    • 4605e03 Rebuild
    • 099c869 Update changelog and version after v4.37.8
    • Additional commits viewable in compare view

    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cd-prerelease.yml | 2 +- .github/workflows/ci.yml | 8 ++++---- .github/workflows/codeql.yml | 4 ++-- .github/workflows/publish-gh-pages.yml | 2 +- .github/workflows/publish.yml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/cd-prerelease.yml b/.github/workflows/cd-prerelease.yml index a064bfafb..22f80bd60 100644 --- a/.github/workflows/cd-prerelease.yml +++ b/.github/workflows/cd-prerelease.yml @@ -63,7 +63,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@v6.0.10 with: version: 10 - name: Set up Node diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eabd9e3f4..b40d931da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.0.10 with: version: 10 - name: Set up Node @@ -44,7 +44,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.0.10 with: version: 10 - name: Set up Node @@ -64,7 +64,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.0.10 with: version: 10 - name: Set up Node @@ -102,7 +102,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.0.10 with: version: 10 - name: Set up Node diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f51fa864d..a1b3b78dc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -59,12 +59,12 @@ jobs: # JavaScript/TypeScript is analyzed straight from source, so no build is needed # (build-mode: none skips the Autobuild dependency-install/build attempt). - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.9 with: languages: ${{ matrix.language }} build-mode: none - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.9 with: category: '/language:${{matrix.language}}' diff --git a/.github/workflows/publish-gh-pages.yml b/.github/workflows/publish-gh-pages.yml index 792bd9545..cad03f61c 100644 --- a/.github/workflows/publish-gh-pages.yml +++ b/.github/workflows/publish-gh-pages.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false # ๐Ÿ‘‡ Build steps - name: pnpm setup - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@v6.0.10 with: version: 10 - name: Set up Node diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c43806188..9ea451415 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.9 + - uses: pnpm/action-setup@v6.0.10 with: version: 10 - name: Set up Node From a1bd27a5c18f6e7e52529caaed114a10cbb7c180 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:08:55 +0100 Subject: [PATCH 26/61] build(deps): bump actions/upload-artifact from 4 to 7 (#1002) Bumps `actions/upload-artifact` from v4 to v7 in `ci.yml`. Dependabot did not propose it: the steps landed yesterday in #954, and the `github-actions` schedule is weekly. ## Why v4 runs on Node 20. The runners force it onto Node 24 and warn on every run: ``` Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: actions/upload-artifact@v4 ``` ## Breaking changes across the three majors | Version | Change | Effect here | |---|---|---| | v5 | Node 24 support | none | | v6 | `runs.using: node24`, needs runner 2.327.1 or later | none, `ubuntu-latest` has it | | v7 | ESM; new opt-in `archive` input | none, see below | v7 adds `archive`. With `archive: false` the action ignores `name` and takes a single file only. Both steps upload a directory and keep the default, so `name` still applies. All four inputs (`name`, `path`, `if-no-files-found`, `retention-days`) are unchanged and valid in v7. `@v7` follows the style of the other `actions/*` entries, which float on the major. ## Verify `prettier --check .github/workflows/ci.yml` passes. Both steps run `if: failure()`, so CI green on this PR does not exercise them; a red run does. --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b40d931da..dc93571a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,7 +80,7 @@ jobs: run: pnpm run test:e2e:web - name: Upload Playwright HTML report if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report-web path: lana/playwright-report/web @@ -88,7 +88,7 @@ jobs: retention-days: 7 - name: Upload Playwright test results if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-test-results-web path: lana/test-results/web From 5744ac1eb526ccd03faea2fb82e7756fbcef7942 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:09:13 +0100 Subject: [PATCH 27/61] ci(dependabot): drop the dead salesforce group (#1003) Removes the `salesforce` group from `.github/dependabot.yml`. It guards a coupling that no longer exists. ## Why The group was added in #907 for this reason: > exception: `@salesforce/apex-node` majors require the matching `@salesforce/core` major, so these move in lockstep incl. majors #951 (`refactor(lana): use Salesforce Services`) moved `lana` onto `@salesforce/vscode-services`. Neither `@salesforce/core` nor `@salesforce/apex-node` is a direct dependency now. `@salesforce/core@9.1.7` is left in the lockfile only under `@salesforce/vscode-services` -> `@salesforce/source-deploy-retrieve`, and dependabot does not raise version updates for transitive dependencies. ## What is left Both remaining `@salesforce/*` packages are dev dependencies, and their versions are independent: | Package | Range | Where | |---|---|---| | `@salesforce/playwright-vscode-ext` | `^1.3.10` | root | | `@salesforce/vscode-services` | `^67.15.0` | `lana/` | `development-dependencies` already covers them. `@salesforce/vscode-services` 67.12 -> 67.15 arrived there in #993. ## Effect `@salesforce/*` majors now arrive as their own PR, which is what the comment above `groups:` already asks for: > majors are deliberately left out of the minor/patch groups so each one still arrives as its own PR So this makes the file consistent rather than changing policy. ## Verify `prettier --check .github/dependabot.yml` passes. Config-only, so CI does not exercise it; the next scheduled dependabot run is the real check. --- .github/dependabot.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 71b66007b..d419957c8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,10 +12,6 @@ updates: # majors are deliberately left out of the minor/patch groups so each one still # arrives as its own PR - they need reading and are reverted individually groups: - # exception: @salesforce/apex-node majors require the matching - # @salesforce/core major, so these move in lockstep incl. majors - salesforce: - patterns: ['@salesforce/*'] production-dependencies: dependency-type: 'production' update-types: ['minor', 'patch'] From cb91a8ac3aab028fc47b3e5b9b072c716b1b31d8 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:17:45 +0100 Subject: [PATCH 28/61] fix(lana): bundle the web extension as a single file (#1001) # PR overview Follow-up to #953 and #957. `Log: Retrieve Apex Log And Show Analysis` cannot work in the web extension host, because the web bundle is split across two files. ## The problem The web extension host does not use Node's loader. It fetches the entry point as text and wraps it: ```js initFn = new Function('module', 'exports', 'require', fullSource); ``` The `require` it supplies resolves only `'vscode'`. `importScripts` is blocked, and `require` and `define` are set to `undefined` in the worker. The docs say it plainly: "Importing or requiring other modules is not supported... the code must be packaged to a single file." Both web builds emitted two files: ``` lana/out/web/ Main.web.cjs lana-salesforceServices.js ``` `RetrieveLogFile.ts:56` does `await import('../services/salesforceServices.js')`, which rollup lowers to `require("./lana-salesforceServices.js")`. That require throws in the web host. It sits in the lazy path near the end of the file, not at the top, so the extension still activates and only Retrieve is affected. ## Changes made - `rollup.config.mjs`: `inlineDynamicImports: true` on the web output. - `rolldown.config.ts`: `codeSplitting: false` on the web output. Rolldown deprecates `inlineDynamicImports` in favour of this name, hence the difference. - Drop `chunkFileNames` from both, now that neither emits a chunk. ## Type of change - [x] Bug fix ## Validation Built both paths, production mode: | | before | after | |---|---|---| | rollup `lana/out/web/` | 2 files | **1 file**, `Main.web.cjs`, 806,071 bytes | | rolldown `lana/out/web/` | 2 files | **1 file**, `Main.web.cjs` | | requires in the entry | `require("vscode")` + `require("./lana-salesforceServices.js")` | **`require("vscode")` only** | The desktop entry is unchanged and still ESM. Not validated: I have not run this in a live web host, so the fix is verified against the emitted bundle and the documented loader, not observed. The web e2e in CI exercises `Log: Show Apex Log Analysis`, not Retrieve, so it will not catch this either way. ## Related Considered and rejected in the same area: dropping `nodePolyfills()` from the web target. The build succeeds without it, but 11 `process.` references survive, and while most are guarded by `typeof process`, `path.resolve()`'s shim calls a bare `process.cwd()`. Removing the plugin would turn a working shim into a latent `ReferenceError`, so it stays. --- rolldown.config.ts | 4 +++- rollup.config.mjs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/rolldown.config.ts b/rolldown.config.ts index 0e446c0ce..5ed24514f 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -41,7 +41,9 @@ export default defineConfig([ format: 'cjs', dir: './lana/out/web', entryFileNames: 'Main.web.cjs', - chunkFileNames: 'lana-[name].js', + // The web extension host resolves only require('vscode'), so a split + // bundle cannot load its own chunks. + codeSplitting: false, sourcemap: false, keepNames: true, minify: production, diff --git a/rollup.config.mjs b/rollup.config.mjs index a7f586abb..cd3c301cd 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -66,7 +66,9 @@ export default [ format: 'cjs', dir: './lana/out/web', entryFileNames: 'Main.web.cjs', - chunkFileNames: 'lana-[name].js', + // The web extension host resolves only require('vscode'), so a split + // bundle cannot load its own chunks. + inlineDynamicImports: true, sourcemap: false, }, external: ['vscode'], From fbeb67517adac61e8fa4ebec1dafe815efd49541 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:18:52 +0100 Subject: [PATCH 29/61] fix(lana): follow-ups to the URI-safe file access migration (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # PR overview The remaining findings from the #952 review, after #988 took the blocking one. Five independent commits; any can be dropped without affecting the others. ## Changes made **Stop reading the whole log to set the context key.** The tab fallback only runs when VS Code refused to open the file as a document, so sniffing it pulled the entire file through `workspace.fs`, which has no ranged read. Measured on the 19.7MB sample log: **0.056ms to 2.9ms and a 163MB RSS peak, on every tab event** โ€” and worse over the provider RPC in the web host, where a 100MB log allocates 100MB inside a browser tab. It now decides from the `.log`/`.txt` extension there, which makes the branch synchronous and retires the generation guards added for the async read. Closes the thread left open on #952. **Only parse a log the user is viewing as a text tab.** Dropping the `scheme: 'file'` selectors in #952 was right and is not reverted here, but it let `warmAndSignal` parse either side of a diff โ€” a full read and parse of a log being diffed, evicting real entries from the 10-item cache. New `isOpenAsTextTab` gates the UI-driven callers (folding warm and provider, document symbols, the line decoration, the code lens). Explicit commands stay ungated since they can run with no tab open. **Stop the save dialog defaulting to the extension directory.** With no workspace folder open it offered to save inside `~/.vscode/extensions/financialforce.lana-*/`. **Drop the ignored `openPath` payload.** The extension uses its captured log URI, not the display path the webview sends. **Ban node builtins and Salesforce Services file I/O in `lana/src`.** Both failures are silent: the web bundle stubs node builtins to empty modules so an import only fails at runtime in the web host, and Salesforce Services throws until `ensureServicesAvailable()` has run โ€” which `LogEventCache` swallowed, silently disabling folding, symbols, sticky scroll and the line decoration until #988. ## Behaviour changes to be aware of - The command is now offered on a very large `.txt`/`.log` that is not an Apex log, where it reports a parse error. That is the trade for never doing a full read per tab event, and it only applies to files too big to open as a document. - The code lens no longer appears on either side of a diff. Clicking it would have parsed the log, which is the work being avoided. - Folding warms on `onDidChangeTabs` rather than `onDidOpenTextDocument`, which fires before the tab model updates and would make the gate reject a legitimate open. The tab change is also the repair path if a folding request loses the race. ## Type of change - [x] Bug fix - [x] Performance ## Related issues related W-23939830 ## Validation - `tsc -b lana` clean, `eslint` clean - **2104 tests across 163 suites** pass - New `TabState` suite covers the diff-side case in both directions and pins that the gate is not a scheme check, so a `memfs:` log in a normal tab still works - New detector case asserts `workspace.fs.readFile` is never called when there is no text document - The lint rule was verified against a probe file importing both banned kinds ## Still needs a manual check `DocumentSymbolProvider` has no change event, so it has no repair path if a symbols request ever loses the tab-model race. Opening a log fresh from the explorer in a cold window should populate the Outline and pin sticky scroll. --------- Co-authored-by: Luke Cotter <81575432+lcottercertinia@users.noreply.github.com> --- eslint.config.mjs | 31 +++++++ lana/src/__tests__/mocks/vscode.ts | 18 ++++ lana/src/codelenses/ShowAnalysisCodeLens.ts | 3 +- lana/src/commands/LogView.ts | 8 +- lana/src/decorations/RawLogLineDecoration.ts | 6 ++ lana/src/editor/TabState.ts | 30 +++++++ lana/src/editor/__tests__/TabState.test.ts | 66 ++++++++++++++ lana/src/folding/RawLogFoldingProvider.ts | 19 +++- .../__tests__/RawLogFoldingProvider.test.ts | 28 ++++-- lana/src/hovers/RawLogHoverProvider.ts | 5 ++ lana/src/language/ApexLogLanguageDetector.ts | 57 ++---------- .../__tests__/ApexLogLanguageDetector.test.ts | 89 ++++++++----------- lana/src/symbols/RawLogSymbolProvider.ts | 5 ++ .../__tests__/RawLogSymbolProvider.test.ts | 9 +- log-viewer/src/components/LogTitle.ts | 2 +- 15 files changed, 257 insertions(+), 119 deletions(-) create mode 100644 lana/src/editor/TabState.ts create mode 100644 lana/src/editor/__tests__/TabState.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d79900c7..5e44574f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,9 @@ import { defineConfig, globalIgnores } from 'eslint/config'; import prettierConfig from 'eslint-config-prettier/flat'; import tseslint from 'typescript-eslint'; +const NO_NODE_BUILTINS = + 'lana also runs in the VS Code web extension host, where the bundler stubs these to empty modules and the failure only shows at runtime. Use the vscode API or vscode-uri Utils.'; + export default defineConfig( globalIgnores([ // agent worktrees/scratch: nested repo copies that would otherwise be @@ -98,4 +101,32 @@ export default defineConfig( eqeqeq: 'warn', }, }, + { + files: ['lana/src/**/*.ts'], + ignores: [ + 'lana/src/commands/RetrieveLogFile.ts', + 'lana/src/commands/__tests__/RetrieveLogFile.test.ts', + 'lana/src/services/**', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + // Bare names only: a `patterns` glob would also catch our own lana/src/fs/. + paths: ['fs', 'fs/promises', 'os', 'path', 'crypto', 'child_process'].map((name) => ({ + name, + message: NO_NODE_BUILTINS, + })), + patterns: [ + { group: ['node:*'], message: NO_NODE_BUILTINS }, + { + group: ['**/services/salesforceServices*'], + message: + 'Salesforce Services is for org operations and throws until ensureServicesAvailable() has run. Use lana/src/fs/workspaceFs.ts for file I/O.', + }, + ], + }, + ], + }, + }, ); diff --git a/lana/src/__tests__/mocks/vscode.ts b/lana/src/__tests__/mocks/vscode.ts index 9a435ecb5..9e0042484 100644 --- a/lana/src/__tests__/mocks/vscode.ts +++ b/lana/src/__tests__/mocks/vscode.ts @@ -126,6 +126,16 @@ export class TabInputText { } } +export class TabInputTextDiff { + readonly original: ReturnType; + readonly modified: ReturnType; + + constructor(original: ReturnType, modified: ReturnType) { + this.original = original; + this.modified = modified; + } +} + // Mock RelativePattern (constructor used for glob searches) export const RelativePattern = jest.fn(); @@ -334,6 +344,7 @@ export const window = { createWebviewPanel: jest.fn(), tabGroups: { activeTabGroup: { activeTab: undefined as { input: unknown } | undefined }, + all: [] as { tabs: { input: unknown }[] }[], onDidChangeTabs: jest.fn((_listener: (event: unknown) => unknown) => ({ dispose: jest.fn(), })), @@ -540,6 +551,12 @@ export const resetMocks = (): void => { window.activeTextEditor = undefined; window.visibleTextEditors = []; window.tabGroups.activeTabGroup.activeTab = undefined; + window.tabGroups.all = []; +}; + +/** Arranges open tabs for isOpenAsTextTab; one group is enough for most tests. */ +export const setOpenTabs = (...inputs: unknown[]): void => { + window.tabGroups.all = [{ tabs: inputs.map((input) => ({ input })) }]; }; // Export as default for module replacement @@ -550,6 +567,7 @@ export default { ViewColumn, Uri, TabInputText, + TabInputTextDiff, RelativePattern, FoldingRange, FoldingRangeKind, diff --git a/lana/src/codelenses/ShowAnalysisCodeLens.ts b/lana/src/codelenses/ShowAnalysisCodeLens.ts index 820a4a6b9..d1a74311b 100644 --- a/lana/src/codelenses/ShowAnalysisCodeLens.ts +++ b/lana/src/codelenses/ShowAnalysisCodeLens.ts @@ -1,6 +1,7 @@ import { CodeLens, Range, languages, type CodeLensProvider, type TextDocument } from 'vscode'; import type { Context } from '../Context.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import { ShowLogAnalysis } from '../commands/ShowLogAnalysis.js'; import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; @@ -12,7 +13,7 @@ class ShowAnalysisCodeLens implements CodeLensProvider { } async provideCodeLenses(document: TextDocument): Promise { - if (!isApexLogContent(document)) { + if (!isOpenAsTextTab(document.uri) || !isApexLogContent(document)) { return []; } diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index e7d130811..095ebd669 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -177,9 +177,13 @@ export class LogView { if (isSaveFileRequest(payload)) { const { fileContent, options } = payload; const defaultWorkspace = (workspace.workspaceFolders || [])[0]; - const defaultDir = defaultWorkspace?.uri ?? context.context.extensionUri; const destinationFile = await vscWindow.showSaveDialog({ - defaultUri: Utils.joinPath(defaultDir, options.defaultFileName), + // With no workspace folder, let VS Code pick its own last-used location: + // the extension's install directory is wrong, and os.homedir() is a web + // polyfill that reports '/'. + defaultUri: defaultWorkspace + ? Utils.joinPath(defaultWorkspace.uri, options.defaultFileName) + : undefined, }); if (destinationFile) { diff --git a/lana/src/decorations/RawLogLineDecoration.ts b/lana/src/decorations/RawLogLineDecoration.ts index e50c7a71e..1bca5ce01 100644 --- a/lana/src/decorations/RawLogLineDecoration.ts +++ b/lana/src/decorations/RawLogLineDecoration.ts @@ -15,6 +15,7 @@ import type { LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; import { LogEventCache } from '../cache/LogEventCache.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; import { buildMetricParts, formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; @@ -90,6 +91,11 @@ export class RawLogLineDecoration { const timestamp = parseInt(match[1], 10); const filePath = document.uri.toString(); + if (!isOpenAsTextTab(document.uri)) { + this.clearDecorations(editor); + return; + } + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { this.clearDecorations(editor); diff --git a/lana/src/editor/TabState.ts b/lana/src/editor/TabState.ts new file mode 100644 index 000000000..51701d42e --- /dev/null +++ b/lana/src/editor/TabState.ts @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { TabInputText, window, type Uri } from 'vscode'; + +/** + * True when `uri` is open as a plain text tab. + * + * A URI can back a TextDocument the user is not reading: either side of a diff, + * a notebook cell, a custom editor's backing document. Those fire + * onDidOpenTextDocument and appear in workspace.textDocuments like any other + * open, so work that costs a full read and parse must be gated on this โ€” + * diffing a log should not parse it. + * + * Deliberately not a scheme check: a scheme names the filesystem provider, not + * how the resource is shown, and a memfs: or vscode-vfs: log in a normal tab is + * a normal open. Allow-list rather than a diff deny-list so tab kinds this build + * has never seen count as "not viewing", the safe direction for a gate. + */ +export function isOpenAsTextTab(uri: Uri): boolean { + const key = uri.toString(); + for (const group of window.tabGroups.all) { + for (const tab of group.tabs) { + if (tab.input instanceof TabInputText && tab.input.uri.toString() === key) { + return true; + } + } + } + return false; +} diff --git a/lana/src/editor/__tests__/TabState.test.ts b/lana/src/editor/__tests__/TabState.test.ts new file mode 100644 index 000000000..6ce194c1f --- /dev/null +++ b/lana/src/editor/__tests__/TabState.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + TabInputText, + TabInputTextDiff, + Uri, + setOpenTabs, + window, +} from '../../__tests__/mocks/vscode.js'; +import { isOpenAsTextTab } from '../TabState.js'; + +describe('isOpenAsTextTab', () => { + it('is true for a URI open as a plain text tab', () => { + const uri = Uri.file('/logs/run.log'); + setOpenTabs(new TabInputText(uri)); + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is true when the tab is in a non-active group', () => { + const uri = Uri.file('/logs/run.log'); + window.tabGroups.all = [ + { tabs: [{ input: new TabInputText(Uri.file('/other.log')) }] }, + { tabs: [{ input: new TabInputText(uri) }] }, + ]; + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is false for a URI shown only as a diff side', () => { + const original = Uri.parse('git:/repo/run.log'); + const modified = Uri.file('/repo/run.log'); + setOpenTabs(new TabInputTextDiff(original, modified)); + + expect(isOpenAsTextTab(original)).toBe(false); + expect(isOpenAsTextTab(modified)).toBe(false); + }); + + it('is true when the same URI is open in a diff and in a normal tab', () => { + const uri = Uri.file('/repo/run.log'); + setOpenTabs(new TabInputTextDiff(Uri.parse('git:/repo/run.log'), uri), new TabInputText(uri)); + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is not a scheme check: a memfs log in a normal tab counts', () => { + const uri = Uri.parse('memfs:/logs/virtual.log'); + setOpenTabs(new TabInputText(uri)); + + expect(isOpenAsTextTab(uri)).toBe(true); + }); + + it('is false for tab kinds it does not recognise', () => { + const uri = Uri.file('/logs/run.log'); + setOpenTabs({}); + + expect(isOpenAsTextTab(uri)).toBe(false); + }); + + it('is false when no tabs are open', () => { + expect(isOpenAsTextTab(Uri.file('/logs/run.log'))).toBe(false); + }); +}); diff --git a/lana/src/folding/RawLogFoldingProvider.ts b/lana/src/folding/RawLogFoldingProvider.ts index e36ec5455..decbfa8eb 100644 --- a/lana/src/folding/RawLogFoldingProvider.ts +++ b/lana/src/folding/RawLogFoldingProvider.ts @@ -17,6 +17,7 @@ import type { LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; import { LogEventCache } from '../cache/LogEventCache.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; import { TIMESTAMP_REGEX } from '../log-utils.js'; @@ -28,6 +29,10 @@ class RawLogFoldingProvider implements FoldingRangeProvider { document: TextDocument, _context: FoldingContext, ): Promise { + if (!isOpenAsTextTab(document.uri)) { + return []; + } + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { @@ -86,7 +91,7 @@ class RawLogFoldingProvider implements FoldingRangeProvider { * unrelated action forces a re-evaluation. */ private warmAndSignal(document: TextDocument): void { - if (!isApexLogContent(document)) { + if (!isOpenAsTextTab(document.uri) || !isApexLogContent(document)) { return; } @@ -104,11 +109,17 @@ class RawLogFoldingProvider implements FoldingRangeProvider { context.context.subscriptions.push( provider.changeEmitter, languages.registerFoldingRangeProvider(docSelector, provider), - workspace.onDidOpenTextDocument((doc) => { - provider.warmAndSignal(doc); + // Not onDidOpenTextDocument: it fires before the tab model is updated, so the + // gate would reject a legitimate open. A tab change is also the repair path โ€” + // a folding request that lost the race is re-requested by the next fire(). + window.tabGroups.onDidChangeTabs(() => { + const editor = window.activeTextEditor; + if (editor) { + provider.warmAndSignal(editor.document); + } }), // Reopening a closed editor often re-attaches the retained document model - // without re-firing onDidOpenTextDocument, so also signal on editor activation. + // without re-firing the tab change, so also signal on editor activation. window.onDidChangeActiveTextEditor((editor) => { if (editor) { provider.warmAndSignal(editor.document); diff --git a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts index e9e9dd911..9f7313d7e 100644 --- a/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts +++ b/lana/src/folding/__tests__/RawLogFoldingProvider.test.ts @@ -10,7 +10,12 @@ import { createMockContext, createMockLogEvent, } from '../../__tests__/helpers/test-builders.js'; -import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; +import { + TabInputText, + Uri, + createMockTextDocument, + setOpenTabs, +} from '../../__tests__/mocks/vscode.js'; import { LogEventCache } from '../../cache/LogEventCache.js'; import { RawLogFoldingProvider } from '../RawLogFoldingProvider.js'; @@ -29,6 +34,8 @@ describe('RawLogFoldingProvider', () => { beforeEach(() => { provider = new RawLogFoldingProvider(); mockGetApexLog.mockReset(); + // The provider only works for a document the user has open as a text tab. + setOpenTabs(new TabInputText(Uri.file('/test/file.log'))); }); describe('provideFoldingRanges', () => { @@ -317,12 +324,15 @@ describe('RawLogFoldingProvider', () => { ); }); - it('should register an onDidOpenTextDocument listener', () => { + it('warms on tab changes, not on document open', () => { const mockContext = createMockContext(); RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); - expect(workspace.onDidOpenTextDocument).toHaveBeenCalledTimes(1); + // onDidOpenTextDocument fires before the tab model updates, so isOpenAsTextTab + // would reject a legitimate open. + expect(window.tabGroups.onDidChangeTabs).toHaveBeenCalledTimes(1); + expect(workspace.onDidOpenTextDocument).not.toHaveBeenCalled(); }); it('should add disposables to context subscriptions', () => { @@ -330,7 +340,7 @@ describe('RawLogFoldingProvider', () => { RawLogFoldingProvider.apply(mockContext as unknown as import('../../Context.js').Context); - // emitter + folding provider registration + open listener + active-editor listener + // emitter + folding provider registration + tab listener + active-editor listener expect(mockContext.context.subscriptions.length).toBe(4); }); }); @@ -345,12 +355,18 @@ describe('RawLogFoldingProvider', () => { const registeredProvider = (languages.registerFoldingRangeProvider as jest.Mock).mock .calls[0]?.[1] as RawLogFoldingProvider; - const openHandler = (workspace.onDidOpenTextDocument as jest.Mock).mock.calls[0]?.[0] as ( - doc: unknown, + const tabsHandler = (window.tabGroups.onDidChangeTabs as jest.Mock).mock.calls[0]?.[0] as ( + event: unknown, ) => void; const activeEditorHandler = (window.onDidChangeActiveTextEditor as jest.Mock).mock .calls[0]?.[0] as (editor: unknown) => void; + // The tab handler reads the active editor rather than taking a document. + const openHandler = (doc: unknown) => { + window.activeTextEditor = { document: doc } as typeof window.activeTextEditor; + tabsHandler({}); + }; + return { registeredProvider, openHandler, activeEditorHandler }; } diff --git a/lana/src/hovers/RawLogHoverProvider.ts b/lana/src/hovers/RawLogHoverProvider.ts index 4b9f839a6..719802760 100644 --- a/lana/src/hovers/RawLogHoverProvider.ts +++ b/lana/src/hovers/RawLogHoverProvider.ts @@ -13,6 +13,7 @@ import { } from 'vscode'; import { LogEventCache } from '../cache/LogEventCache.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import type { Context } from '../Context.js'; import { buildMetricParts, TIMESTAMP_REGEX } from '../log-utils.js'; @@ -25,6 +26,10 @@ class RawLogHoverProvider implements HoverProvider { return null; } + if (!isOpenAsTextTab(document.uri)) { + return null; + } + const timestamp = parseInt(match[1], 10); return this.buildHover(document.uri, timestamp); } diff --git a/lana/src/language/ApexLogLanguageDetector.ts b/lana/src/language/ApexLogLanguageDetector.ts index f79cf4e92..0c301ea87 100644 --- a/lana/src/language/ApexLogLanguageDetector.ts +++ b/lana/src/language/ApexLogLanguageDetector.ts @@ -19,8 +19,6 @@ const EXECUTION_STARTED = /^\d{2}:\d{2}:\d{2}\.\d{1,} \(\d+\)\|EXECUTION_STARTED const USER_INFO = /^\d{2}:\d{2}:\d{2}\.\d{1,} \(\d+\)\|USER_INFO\|/; const DETECT_EXTENSIONS = new Set(['.log', '.txt']); const MAX_LINES_TO_CHECK = 100; -const MAX_BYTES_TO_READ = 4096; -let contextUpdateGeneration = 0; export function isApexLogContent(doc: TextDocument): boolean { if (doc.lineCount === 0) { @@ -38,29 +36,6 @@ export function isApexLogContent(doc: TextDocument): boolean { return false; } -export async function isApexLogFile(uri: Uri): Promise { - try { - const text = await readFilePrefix(uri); - const lines = text.split(/\r?\n/); - - const linesToCheck = Math.min(MAX_LINES_TO_CHECK, lines.length); - for (let i = 0; i < linesToCheck; i++) { - const line = lines[i] ?? ''; - if (APEXLOG_HEADER.test(line) || EXECUTION_STARTED.test(line) || USER_INFO.test(line)) { - return true; - } - } - return false; - } catch { - return false; - } -} - -async function readFilePrefix(uri: Uri): Promise { - const bytes = await workspace.fs.readFile(uri); - return new TextDecoder().decode(bytes.subarray(0, MAX_BYTES_TO_READ)); -} - function hasDetectExtension(uri: Uri): boolean { return DETECT_EXTENSIONS.has(Utils.extname(uri).toLowerCase()); } @@ -74,38 +49,20 @@ function getActiveTabUri(): Uri | undefined { } function updateContextKey(): void { - const generation = ++contextUpdateGeneration; const editor = window.activeTextEditor; if (editor) { const doc = editor.document; - if (hasDetectExtension(doc.uri)) { - const detected = isApexLogContent(doc); - commands.executeCommand('setContext', 'lana.isApexLog', detected); - return; - } - commands.executeCommand('setContext', 'lana.isApexLog', false); + const detected = hasDetectExtension(doc.uri) && isApexLogContent(doc); + commands.executeCommand('setContext', 'lana.isApexLog', detected); return; } - // Fallback to tab API for large files where activeTextEditor is undefined + // No text document, so the only way here is a file VS Code refused to open as one. + // Sniffing it means pulling the whole file through workspace.fs, which has no ranged + // read: a full read and allocation on every tab event, over an RPC in the web host. + // Trust the extension instead and accept offering the command on a large non-Apex file. const tabUri = getActiveTabUri(); - if (tabUri && hasDetectExtension(tabUri)) { - const tabKey = tabUri.toString(); - void isApexLogFile(tabUri).then((detected) => { - const activeTabUri = getActiveTabUri(); - if ( - generation !== contextUpdateGeneration || - window.activeTextEditor || - activeTabUri?.toString() !== tabKey - ) { - return; - } - commands.executeCommand('setContext', 'lana.isApexLog', detected); - }); - return; - } - - commands.executeCommand('setContext', 'lana.isApexLog', false); + commands.executeCommand('setContext', 'lana.isApexLog', !!tabUri && hasDetectExtension(tabUri)); } export class ApexLogLanguageDetector { diff --git a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts index 67827c5bf..4b4f8a506 100644 --- a/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts +++ b/lana/src/language/__tests__/ApexLogLanguageDetector.test.ts @@ -13,11 +13,7 @@ import { window, workspace, } from '../../__tests__/mocks/vscode.js'; -import { - ApexLogLanguageDetector, - isApexLogContent, - isApexLogFile, -} from '../ApexLogLanguageDetector.js'; +import { ApexLogLanguageDetector, isApexLogContent } from '../ApexLogLanguageDetector.js'; describe('isApexLogContent', () => { it('should detect standard log with settings header on line 1', () => { @@ -108,31 +104,6 @@ describe('isApexLogContent', () => { }); }); -describe('isApexLogFile', () => { - it('decodes only the first 4 KB returned by the filesystem provider', async () => { - const prefix = 'not an Apex log'.padEnd(4096, ' '); - workspace.fs.readFile.mockResolvedValue( - new TextEncoder().encode(`${prefix}09:45:31.888 (1000)|EXECUTION_STARTED`), - ); - const uri = Uri.file('/logs/large.log'); - - await expect(isApexLogFile(uri)).resolves.toBe(false); - - expect(workspace.fs.readFile).toHaveBeenCalledWith(uri); - }); - - it('uses the registered filesystem provider for an arbitrary URI scheme', async () => { - workspace.fs.readFile.mockResolvedValue( - new TextEncoder().encode('09:45:31.888 (1000)|EXECUTION_STARTED'), - ); - const uri = Uri.parse('git:/repository/logs/virtual.log'); - - await expect(isApexLogFile(uri)).resolves.toBe(true); - - expect(workspace.fs.readFile).toHaveBeenCalledWith(uri); - }); -}); - describe('ApexLogLanguageDetector', () => { it.each(['log', 'txt'])('detects .%s Apex logs from arbitrary URI schemes', (extension) => { const doc = createMockTextDocument({ @@ -166,39 +137,49 @@ describe('ApexLogLanguageDetector', () => { expect(languages.setTextDocumentLanguage).not.toHaveBeenCalled(); }); - it('does not publish a stale async result after the active tab changes', async () => { - let resolveSlowRead: ((bytes: Uint8Array) => void) | undefined; - const slowRead = new Promise((resolve) => { - resolveSlowRead = resolve; - }); - const slowUri = Uri.parse('memfs:/logs/slow.log'); - const fastUri = Uri.parse('memfs:/logs/fast.log'); - workspace.fs.readFile.mockImplementation((uri: { path: string }) => - uri.path === slowUri.path - ? slowRead - : Promise.resolve(new TextEncoder().encode('not an Apex log')), + it('sets the key from the extension alone when there is no text document', () => { + window.tabGroups.activeTabGroup.activeTab = { + input: new TabInputText(Uri.parse('memfs:/logs/huge.log')), + }; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, ); - let notifyTabsChanged: (() => void) | undefined; - window.tabGroups.onDidChangeTabs.mockImplementation((listener: (event: unknown) => void) => { - notifyTabsChanged = () => listener({}); - return { dispose: jest.fn() }; - }); - window.tabGroups.activeTabGroup.activeTab = { input: new TabInputText(slowUri) }; + expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', true); + }); + + it('never reads the file when there is no text document', () => { + window.tabGroups.activeTabGroup.activeTab = { + input: new TabInputText(Uri.parse('memfs:/logs/huge.log')), + }; + + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); + + expect(workspace.fs.readFile).not.toHaveBeenCalled(); + }); + + it('clears the key for a non-log extension in the tab fallback', () => { + window.tabGroups.activeTabGroup.activeTab = { + input: new TabInputText(Uri.parse('memfs:/notes.json')), + }; ApexLogLanguageDetector.apply( createMockContext() as unknown as import('../../Context.js').Context, ); - window.tabGroups.activeTabGroup.activeTab = { input: new TabInputText(fastUri) }; - notifyTabsChanged?.(); - await new Promise((resolve) => setTimeout(resolve, 0)); expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', false); + }); + + it('clears the key when the active tab is not a text tab', () => { + window.tabGroups.activeTabGroup.activeTab = { input: {} }; - resolveSlowRead?.(new TextEncoder().encode('09:45:31.888 (1000)|EXECUTION_STARTED')); - await slowRead; - await new Promise((resolve) => setTimeout(resolve, 0)); + ApexLogLanguageDetector.apply( + createMockContext() as unknown as import('../../Context.js').Context, + ); - expect(commands.executeCommand).not.toHaveBeenCalledWith('setContext', 'lana.isApexLog', true); + expect(commands.executeCommand).toHaveBeenLastCalledWith('setContext', 'lana.isApexLog', false); }); }); diff --git a/lana/src/symbols/RawLogSymbolProvider.ts b/lana/src/symbols/RawLogSymbolProvider.ts index 726430c05..7713b91c2 100644 --- a/lana/src/symbols/RawLogSymbolProvider.ts +++ b/lana/src/symbols/RawLogSymbolProvider.ts @@ -16,6 +16,7 @@ import type { LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; import { LogEventCache } from '../cache/LogEventCache.js'; +import { isOpenAsTextTab } from '../editor/TabState.js'; import { formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; /** @@ -29,6 +30,10 @@ class RawLogSymbolProvider implements DocumentSymbolProvider { document: TextDocument, _token: CancellationToken, ): Promise { + if (!isOpenAsTextTab(document.uri)) { + return []; + } + const apexLog = await LogEventCache.getApexLog(document.uri); if (!apexLog) { diff --git a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts index 1f25bd1b5..2e0035ea9 100644 --- a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts +++ b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts @@ -10,7 +10,12 @@ import { createMockContext, createMockLogEvent, } from '../../__tests__/helpers/test-builders.js'; -import { createMockTextDocument } from '../../__tests__/mocks/vscode.js'; +import { + TabInputText, + Uri, + createMockTextDocument, + setOpenTabs, +} from '../../__tests__/mocks/vscode.js'; import { LogEventCache } from '../../cache/LogEventCache.js'; import { RawLogSymbolProvider } from '../RawLogSymbolProvider.js'; @@ -28,6 +33,8 @@ describe('RawLogSymbolProvider', () => { beforeEach(() => { provider = new RawLogSymbolProvider(); mockGetApexLog.mockReset(); + // The provider only works for a document the user has open as a text tab. + setOpenTabs(new TabInputText(Uri.file('/test/file.log'))); }); describe('provideDocumentSymbols', () => { diff --git a/log-viewer/src/components/LogTitle.ts b/log-viewer/src/components/LogTitle.ts index 196c3b600..49daeb01a 100644 --- a/log-viewer/src/components/LogTitle.ts +++ b/log-viewer/src/components/LogTitle.ts @@ -85,6 +85,6 @@ export class LogTitle extends LitElement { } _goToLog() { - vscodeMessenger.send('openPath', this.logPath); + vscodeMessenger.send('openPath'); } } From d60bc4963622c3b232e8882873895babe72d1726 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:29:44 +0100 Subject: [PATCH 30/61] build(deps-dev): bump lint-staged from 17.3.0 to 17.4.1 in the development-dependencies group (#1005) Bumps the development-dependencies group with 1 update: [lint-staged](https://github.com/lint-staged/lint-staged). Updates `lint-staged` from 17.3.0 to 17.4.1
    Release notes

    Sourced from lint-staged's releases.

    v17.4.1

    17.4.1

    Patch Changes

    • #1840 efe5b63 - This is a version-bump-only release because the previous version 17.4.0 was not published to npmjs.com due to problems with GitHub Actions and Changesets.

    17.4.0

    Minor Changes

    • #1836 90ec282 - Added a new defineConfig helper for type-checking the lint-staged configuration:

      // lint-staged.config.ts
      

      import { defineConfig } from 'lint-staged/config'

      export default defineConfig({ '*.js': ['prettier --check', 'eslint'], })

    • #1832 510a27c - Added a new flag --all to make lint-staged include all files tracked by Git, instead of only staged.

      By default lint-staged only runs tasks on files that include staged changes (hence the name). Use this flag to include all files tracked in Git version control (standard exclusions apply). Using this flag implies the --no-stash flag, disabling the automatic backup, and the --allow-empty flag so that lint-staged doesn't fail when there are no changes after running. This makes it easier to run npx lint-staged --all on a clean state, for example in CI.

    Patch Changes

    • #1838 69bec99 - The behavior of the automatic backup stash has been improved when running lint-staged in multiple worktrees in parallel. You should still avoid running multiple instances of lint-staged in parallel in the same tree, because some of the Git operations are locking and might lead to data loss.

    • #1839 5e5bdd2 - Parsing of lint-staged CLI flags and Node.js API options has been rewritten to avoid inconsistent behavior between the two.

    Changelog

    Sourced from lint-staged's changelog.

    17.4.1

    Patch Changes

    • #1840 efe5b63 - This is a version-bump-only release because the previous version 17.4.0 was not published to npmjs.com due to problems with GitHub Actions and Changesets.

    17.4.0

    Minor Changes

    • #1836 90ec282 - Added a new defineConfig helper for type-checking the lint-staged configuration:

      // lint-staged.config.ts
      

      import { defineConfig } from 'lint-staged/config'

      export default defineConfig({ '*.js': ['prettier --check', 'eslint'], })

    • #1832 510a27c - Added a new flag --all to make lint-staged include all files tracked by Git, instead of only staged.

      By default lint-staged only runs tasks on files that include staged changes (hence the name). Use this flag to include all files tracked in Git version control (standard exclusions apply). Using this flag implies the --no-stash flag, disabling the automatic backup, and the --allow-empty flag so that lint-staged doesn't fail when there are no changes after running. This makes it easier to run npx lint-staged --all on a clean state, for example in CI.

    Patch Changes

    • #1838 69bec99 - The behavior of the automatic backup stash has been improved when running lint-staged in multiple worktrees in parallel. You should still avoid running multiple instances of lint-staged in parallel in the same tree, because some of the Git operations are locking and might lead to data loss.

    • #1839 5e5bdd2 - Parsing of lint-staged CLI flags and Node.js API options has been rewritten to avoid inconsistent behavior between the two.

    Commits
    • d0c1517 Merge pull request #1841 from lint-staged/changeset-release/main
    • f061335 chore(changeset): release
    • d2721af Merge pull request #1840 from lint-staged/updates
    • efe5b63 ci: update Changesets action because it failed to publish
    • cd76ce3 build: update dependencies
    • ea195e1 Merge pull request #1837 from lint-staged/changeset-release/main
    • a6a0d61 chore(changeset): release
    • 0a09098 Merge pull request #1832 from lint-staged/add-all-flag
    • 7fd685b fix: further fix parsing options logic
    • 510a27c feat: add --all flag for including all files tracked by Git instead of just...
    • Additional commits viewable in compare view

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=lint-staged&package-manager=npm_and_yarn&previous-version=17.3.0&new-version=17.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- pnpm-lock.yaml | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index c8210c4c0..7b96c20e9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "husky": "^9.1.7", "jest": "^30.4.2", "jest-environment-jsdom": "^30.4.1", - "lint-staged": "^17.3.0", + "lint-staged": "^17.4.1", "prettier": "^3.9.6", "rolldown": "^1.2.6", "rollup": "^4.63.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3115fb597..76b106ae9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,8 +89,8 @@ importers: specifier: ^30.4.1 version: 30.4.1(supports-color@10.2.2) lint-staged: - specifier: ^17.3.0 - version: 17.3.0 + specifier: ^17.4.1 + version: 17.4.1 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -6901,8 +6901,8 @@ packages: linkify-it@5.0.2: resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - lint-staged@17.3.0: - resolution: {integrity: sha512-woZS3vNe3UKqBaLPvbLOtKRY4tLANpWQhom12MGWqC8Mh1lCOO+WgSwmX2amjJAqTY9BkXYW87fCUH5H9Ph6xw==} + lint-staged@17.4.1: + resolution: {integrity: sha512-FmJeudcalbSfg1du+JCfvi5vS6Qt08KgbfLWiHinbef+2JJwUZwAWVoaO1AcJVUTWPfk0t30PMQNwPAeCzYQ+Q==} engines: {node: '>=22.22.1'} hasBin: true @@ -15024,7 +15024,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 4.0.5 + picomatch: 4.0.7 anynum@1.0.1: {} @@ -16485,9 +16485,9 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 feed@4.2.2: dependencies: @@ -17501,7 +17501,7 @@ snapshots: jest-regex-util: 30.4.0 jest-util: 30.4.1 jest-worker: 30.4.1 - picomatch: 4.0.5 + picomatch: 4.0.7 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 @@ -17996,7 +17996,7 @@ snapshots: dependencies: uc.micro: 2.1.0 - lint-staged@17.3.0: + lint-staged@17.4.1: dependencies: picomatch: 4.0.7 string-argv: 0.3.2 @@ -18659,7 +18659,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 4.0.5 + picomatch: 4.0.7 mime-db@1.33.0: {} @@ -19988,7 +19988,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 4.0.5 + picomatch: 4.0.7 readdirp@5.1.1: {} @@ -20904,8 +20904,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinypool@1.1.1: {} From 161a5c91c16ba10e995dd878b977d174c4b3c9a4 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:30:12 +0100 Subject: [PATCH 31/61] ci: gate merges on one aggregate job (#998) ## What Adds a `CI Gate` job to `ci.yml`. It needs `verify_files`, `tests` and `build`, and fails if any of them failed, was cancelled or was skipped. ## Why A branch ruleset names each required status check exactly. Today that means adding, renaming or splitting a CI job also needs a ruleset edit, which is easy to forget and silently drops a check. With a gate job the ruleset needs only `CI / CI Gate`, and the job list stays in `ci.yml` where it belongs. `if: always()` matters: without it a failed dependency leaves the gate **skipped**, and GitHub counts a skipped check as a pass. ## After merge - Switch the ruleset to require `CI / CI Gate` and drop the per-job entries. - CodeQL is a separate workflow, so a gate cannot cover it. `CodeQL / Analyze (javascript-typescript)` stays required on its own. - Adding a job to `ci.yml` now means adding it to the gate's `needs` list. --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc93571a6..9ca5cd1f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,3 +118,16 @@ jobs: run: | cd lana vsce package --no-dependencies + + gate: + # The single required status check: the ruleset never needs updating when jobs change. + name: CI Gate + needs: [verify_files, tests, build] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check job results + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') + run: | + echo "A required job did not succeed." + exit 1 From 19a5f3ca89c349e5a5f9e9825c1d4d3f0ebb2968 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:30:36 +0100 Subject: [PATCH 32/61] chore: run the dev host in a Lana-only profile (#992) ## What F5 (`Run Extension` and `Run Extension (Worktree)`) now starts the dev host in the `lana-dev` VS Code profile. That profile holds one extension - `salesforce.salesforcedx-vscode-services`, which Lana depends on - so the dev host loads it and Lana alone. ## Why The dev host loaded every installed extension. The published `financialforce.lana` could then win the log view, so a reviewer saw the released UI instead of the build under test. `--disable-extensions` cannot fix this. One `--user-data-dir` holds one VS Code process, and extension enablement is per process, so a window opened while VS Code is running inherits that instance's extensions and the flag is dropped. Extension enablement *is* per profile, so `--profile` works in the running instance, keeps the real settings and machine ID, and needs no second user-data-dir. ## Setup, once per machine ``` code --profile lana-dev --install-extension salesforce.salesforcedx-vscode-services ``` A machine without the profile gets an empty one, so the dev host starts with Lana but no services extension. The command above fixes that, and is documented under Commands in `AGENTS.md`. Settings Sync carries the profile between machines. ## Also The comments principle in `AGENTS.md` now names its failure modes: restating code, narrating an edit, docblocks on private helpers. ## Verified Dev host started with the shipped args. `exthost.log` activations: ``` salesforce.salesforcedx-vscode-services, startup: true FinancialForce.lana, startup: false ``` Plus VS Code built-ins only. --- .vscode/lana-dev.code-profile | 4 ++++ .vscode/launch.json | 4 +++- AGENTS.md | 7 ++++++- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 .vscode/lana-dev.code-profile diff --git a/.vscode/lana-dev.code-profile b/.vscode/lana-dev.code-profile new file mode 100644 index 000000000..5f9febb48 --- /dev/null +++ b/.vscode/lana-dev.code-profile @@ -0,0 +1,4 @@ +{ + "name": "lana-dev", + "extensions": "[{\"identifier\":{\"id\":\"salesforce.salesforcedx-vscode-services\"}}]" +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 1de64e263..59a97c485 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,7 @@ "name": "Run Extension", "type": "extensionHost", "request": "launch", - "args": ["--extensionDevelopmentPath=${workspaceFolder}/lana"], + "args": ["--profile=lana-dev", "--extensionDevelopmentPath=${workspaceFolder}/lana"], "outFiles": ["${workspaceFolder}/lana/out/**/*.js"], "localRoot": "${workspaceFolder}/lana" }, @@ -21,6 +21,7 @@ "type": "extensionHost", "request": "launch", "args": [ + "--profile=lana-dev", "--extensionDevelopmentPath=${workspaceFolder}/${input:worktree}/lana" ], "outFiles": ["${workspaceFolder}/${input:worktree}/lana/out/**/*.js"], @@ -31,6 +32,7 @@ "type": "extensionHost", "request": "launch", "args": [ + "--profile=lana-dev", "--extensionDevelopmentPath=${workspaceFolder}", "--extensionTestsPath=${workspaceFolder}/lana/out/test/suite/index" ], diff --git a/AGENTS.md b/AGENTS.md index a6bf633fb..0430fb704 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,9 @@ Always use pnpm. - `pnpm lint` โ€” type + lint check - `pnpm prettier-format` โ€” auto-format +**Dev host** โ€” launch with `code-insiders --profile lana-dev --extensionDevelopmentPath=$PWD/lana`, +use the CLI of the launched editor, `code-insiders` or `code` + **Compilers** โ€” `typecheck` = native TS7 (`tsc`); `typecheck:tsc6` = classic 6.0 (`tsc6`). Keep the `@typescript/typescript6` alias + `tsc6`: `typescript-eslint` and Docusaurus need the TS โ‰ค6.0 API (lands in TS 7.1). Don't remove until typescript-eslint supports TS7. @@ -34,7 +37,9 @@ the TS โ‰ค6.0 API (lands in TS 7.1). Don't remove until typescript-eslint suppor - **Performance** โ€” handle large logs (50MB+, 500k+ lines) without blocking the UI. - **UX** โ€” discoverable, accessible, actionable errors. - **Testing** โ€” features and bug fixes ship with tests; CI blocks failures. -- **Comments** โ€” only what the code cannot say, one short line, and only where needed. +- **Comments** โ€” only the non-obvious: a why, a gotcha, an invariant. One terse line. + Never restate code or narrate an edit โ€” in doubt, no comment. JSDoc exported + functions, not private helpers. ## Critical boundary From 502ce635b7e5098814cfcd81e87d3dbef42b5429 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:31:23 +0100 Subject: [PATCH 33/61] refactor(log-viewer): find a grid's body through the one helper that names it (#995) Nine places re-derived a grid's scrolling body with the same `querySelector('.tabulator-tableholder')`. Four of them cast the result with `as HTMLElement`, which hid that the query can return nothing, so the class name was copied around and no caller handled a missing body. All nine now call `tableHolder()`, the helper added in #965 and already used by the call tree detail panel and the row keyboard navigation module. After this change the class name appears only in that helper, the grid stylesheet and one test fixture. ## Changes - `AnalysisView`, `CalltreeView`: the expand and collapse buttons focus the body through the helper. - `ScrollAnchor`, `AnchoringPolicy`, `RowNavigation`: same for the modules that hold the body in a field. - The DML, SOQL and SOSL views drop the `as HTMLElement` cast, so each handler now handles a missing body. - The DML and SOSL views cache the body, as the SOQL view and their own `_getTable()` already do. This removes a `querySelector` per render, and `renderComplete` runs about once per frame while a pane is dragged. The cache is safe because `_appendTableWhenVisible()` returns early when the table exists, so each of the three views builds one table, with one body element. ## Test plan - `pnpm test`: 1629 tests in 138 suites pass. - `pnpm lint`: clean. - Dev host, `sample-app/debug-logs/sample-log.log`: 1. Call Tree: click a row, then press the arrow keys. The selection moves and the view scrolls to it. 2. Expand a node with its twisty, then press the arrow keys again. The selection still moves. 3. Database: scroll the SOQL grid, then expand a row. The grid does not jump. 4. Repeat step 3 in the DML and SOSL grids. 5. Analysis: click a finding that reveals a hidden row. The grid takes focus and the arrow keys work. 6. Repeat in the other theme, then in the side dock and the bottom dock. --- .../src/features/analysis/components/AnalysisView.ts | 3 ++- .../src/features/call-tree/components/CalltreeView.ts | 5 +++-- log-viewer/src/features/database/components/DMLView.ts | 9 ++++++--- log-viewer/src/features/database/components/SOQLView.ts | 9 ++++++--- log-viewer/src/features/database/components/SOSLView.ts | 9 ++++++--- log-viewer/src/tabulator/module/AnchoringPolicy.ts | 3 ++- log-viewer/src/tabulator/module/RowNavigation.ts | 5 +++-- log-viewer/src/tabulator/module/ScrollAnchor.ts | 3 ++- 8 files changed, 30 insertions(+), 16 deletions(-) diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index b38fe3239..9334f11cc 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -47,6 +47,7 @@ import { import { expandCollapseAll } from '../../call-tree/utils/ExpandCollapse.js'; import { onTableReshaped } from '../../../tabulator/module/tableReshape.js'; +import { tableHolder } from '../../../tabulator/module/tableHolder.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; @@ -553,7 +554,7 @@ export class AnalysisView extends LitElement { } table.blockRedraw(); expandCollapseAll(table.getRows(), expand); - table.element?.querySelector('.tabulator-tableholder')?.focus(); + tableHolder(table.element)?.focus(); table.restoreRedraw(); } diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index cdecf1c32..837fb2751 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -34,6 +34,7 @@ import { waitForNextFrame } from '../../../core/utility/FrameBudget.js'; import { inMsRange, type FilterRange } from '../../../tabulator/filters/MinMax.js'; import { withCodeDrivenExpand } from '../../../tabulator/module/expandOrigin.js'; import { onTableReshaped } from '../../../tabulator/module/tableReshape.js'; +import { tableHolder } from '../../../tabulator/module/tableHolder.js'; import dataGridStyles from '../../../tabulator/style/DataGrid.scss'; @@ -839,7 +840,7 @@ export class CalltreeView extends LitElement { } table.blockRedraw(); expandCollapseAll(table.getRows(), true); - table.element?.querySelector('.tabulator-tableholder')?.focus(); + tableHolder(table.element)?.focus(); table.restoreRedraw(); } @@ -850,7 +851,7 @@ export class CalltreeView extends LitElement { } table.blockRedraw(); expandCollapseAll(table.getRows(), false); - table.element?.querySelector('.tabulator-tableholder')?.focus(); + tableHolder(table.element)?.focus(); table.restoreRedraw(); } diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index 403ff57fd..ad22fb2d7 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -35,6 +35,7 @@ import { // Tabulator custom modules, imports + styles import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; +import { tableHolder } from '../../../tabulator/module/tableHolder.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'; @@ -724,8 +725,7 @@ export class DMLView extends LitElement { }); this.dmlTable.on('tableBuilt', () => { - const holder = this._getTableHolder(); - holder.style.overflowAnchor = 'none'; + this._getTableHolder()?.style.setProperty('overflow-anchor', 'none'); //@ts-expect-error This is a custom function added in the GroupSort custom module this.dmlTable?.setSortedGroupBy('dml'); if (this.dmlTable) { @@ -739,6 +739,9 @@ export class DMLView extends LitElement { this.dmlTable.on('renderComplete', () => { const holder = this._getTableHolder(); + if (!holder) { + return; + } const table = this._getTable(); holder.style.minHeight = Math.min(holder.clientHeight, table.clientHeight) + 'px'; }); @@ -794,7 +797,7 @@ export class DMLView extends LitElement { } _getTableHolder() { - this.holder = this.dmlTable?.element.querySelector('.tabulator-tableholder') as HTMLElement; + this.holder ??= tableHolder(this.dmlTable?.element); return this.holder; } diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index ccb63a920..a111d623f 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -44,6 +44,7 @@ import { // Tabulator custom modules, imports + styles import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; +import { tableHolder } from '../../../tabulator/module/tableHolder.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'; @@ -875,8 +876,7 @@ export class SOQLView extends LitElement { }); this.soqlTable.on('tableBuilt', () => { - const holder = this._getTableHolder(); - holder.style.overflowAnchor = 'none'; + this._getTableHolder()?.style.setProperty('overflow-anchor', 'none'); //@ts-expect-error This is a custom function added in the GroupSort custom module this.soqlTable?.setSortedGroupBy('soql'); if (this.soqlTable) { @@ -911,6 +911,9 @@ export class SOQLView extends LitElement { this.soqlTable.on('renderComplete', () => { const holder = this._getTableHolder(); + if (!holder) { + return; + } const table = this._getTable(); holder.style.minHeight = Math.min(holder.clientHeight, table.clientHeight) + 'px'; }); @@ -945,7 +948,7 @@ export class SOQLView extends LitElement { } _getTableHolder() { - this.holder ??= this.soqlTable?.element.querySelector('.tabulator-tableholder') as HTMLElement; + this.holder ??= tableHolder(this.soqlTable?.element); return this.holder; } diff --git a/log-viewer/src/features/database/components/SOSLView.ts b/log-viewer/src/features/database/components/SOSLView.ts index a6629c3a9..2c41443a7 100644 --- a/log-viewer/src/features/database/components/SOSLView.ts +++ b/log-viewer/src/features/database/components/SOSLView.ts @@ -37,6 +37,7 @@ import { // Tabulator custom modules, imports + styles import NumberAccessor from '../../../tabulator/dataaccessor/Number.js'; +import { tableHolder } from '../../../tabulator/module/tableHolder.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'; @@ -672,8 +673,7 @@ export class SOSLView extends LitElement { }); this.soslTable.on('tableBuilt', () => { - const holder = this._getTableHolder(); - holder.style.overflowAnchor = 'none'; + this._getTableHolder()?.style.setProperty('overflow-anchor', 'none'); //@ts-expect-error This is a custom function added in the GroupSort custom module this.soslTable?.setSortedGroupBy('sosl'); if (this.soslTable) { @@ -686,6 +686,9 @@ export class SOSLView extends LitElement { this.soslTable.on('renderComplete', () => { const holder = this._getTableHolder(); + if (!holder) { + return; + } const table = this._getTable(); holder.style.minHeight = Math.min(holder.clientHeight, table.clientHeight) + 'px'; }); @@ -741,7 +744,7 @@ export class SOSLView extends LitElement { } _getTableHolder() { - this.holder = this.soslTable?.element.querySelector('.tabulator-tableholder') as HTMLElement; + this.holder ??= tableHolder(this.soslTable?.element); return this.holder; } diff --git a/log-viewer/src/tabulator/module/AnchoringPolicy.ts b/log-viewer/src/tabulator/module/AnchoringPolicy.ts index 9ec3bdb0b..9292f7620 100644 --- a/log-viewer/src/tabulator/module/AnchoringPolicy.ts +++ b/log-viewer/src/tabulator/module/AnchoringPolicy.ts @@ -2,6 +2,7 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { Module, type RowComponent, type Tabulator } from 'tabulator-tables'; +import { tableHolder } from './tableHolder.js'; const anchoringPolicyOption = 'anchoringPolicy' as const; @@ -69,7 +70,7 @@ export class AnchoringPolicy extends Module { initialize() { // @ts-expect-error not in types if (this.options(anchoringPolicyOption)) { - this.tableHolder = this.table.element.querySelector('.tabulator-tableholder'); + this.tableHolder = tableHolder(this.table.element); this.table.on('renderStarted', () => this._captureAnchor()); this.table.on('renderComplete', () => this._genericRestore()); this.table.on('dataTreeRowExpanded', (row: RowComponent) => this._preciseRestore(row)); diff --git a/log-viewer/src/tabulator/module/RowNavigation.ts b/log-viewer/src/tabulator/module/RowNavigation.ts index 753c84eb4..599feb9f0 100644 --- a/log-viewer/src/tabulator/module/RowNavigation.ts +++ b/log-viewer/src/tabulator/module/RowNavigation.ts @@ -5,6 +5,7 @@ import type { Tabulator } from 'tabulator-tables'; import { Module, type RowComponent } from 'tabulator-tables'; import { withCodeDrivenExpand } from './expandOrigin.js'; +import { tableHolder } from './tableHolder.js'; type GoToRowOptions = { scrollIfVisible: boolean; focusRow: boolean }; export class RowNavigation extends Module { static moduleName = 'rowNavigation'; @@ -43,7 +44,7 @@ export class RowNavigation extends Module { const { focusRow } = opts; const table = this.table; - this.tableHolder ??= table.element.querySelector('.tabulator-tableholder') as HTMLElement; + this.tableHolder ??= tableHolder(table.element); table.blockRedraw(); @@ -76,7 +77,7 @@ export class RowNavigation extends Module { table.restoreRedraw(); if (focusRow) { - this.tableHolder.focus(); + this.tableHolder?.focus(); } await this._waitForRenderComplete(); diff --git a/log-viewer/src/tabulator/module/ScrollAnchor.ts b/log-viewer/src/tabulator/module/ScrollAnchor.ts index 89d7385d5..520487f5a 100644 --- a/log-viewer/src/tabulator/module/ScrollAnchor.ts +++ b/log-viewer/src/tabulator/module/ScrollAnchor.ts @@ -2,6 +2,7 @@ * Copyright (c) 2024 Certinia Inc. All rights reserved. */ import { Module, type RowComponent, type Tabulator } from 'tabulator-tables'; +import { tableHolder } from './tableHolder.js'; const scrollAnchorOption = 'scrollAnchor' as const; @@ -63,7 +64,7 @@ export class ScrollAnchor extends Module { initialize() { // @ts-expect-error not in types if (this.options(scrollAnchorOption)) { - this.tableHolder = this.table.element.querySelector('.tabulator-tableholder'); + this.tableHolder = tableHolder(this.table.element); this.table.on('dataTreeRowExpanded', () => this._onTreeToggle()); this.table.on('dataTreeRowCollapsed', () => this._onTreeToggle()); From adaa13745e84c71bb533342d2940bc941b091ccc Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:32:13 +0100 Subject: [PATCH 34/61] perf(log-viewer): build the minimap skyline once per log, not once per pixel (#996) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging the timeline's width cost **75-92ms a step** on a 95MB log, all of it in `MinimapDensityQuery`. A height-only drag was 2-4ms, because the density cache is keyed by width and hit. The minimap paints one bar per pixel column, coloured by the category that was **on top** longest in that column โ€” the log seen from above โ€” weighted so `DML`/`SOQL` stay visible under shallower children. That semantic is unchanged. The cost was that "which frame is on top at time *t*" was recomputed **inside every bucket, on every width**, though it is a property of the log and does not depend on the minimap's width at all. It is now built once per log as `MinimapSkylineIndex` โ€” a stack sweep into typed arrays โ€” and every width walks it. ## Results 95MB log, 431,297 frames, 862,567 segments: | | before | after | | --- | --- | --- | | width-drag step, median | 100.40ms | **3.72ms** | | width-drag step, worst | โ€” | 9.04ms | | whole 200px drag | 22.16s | **0.74s** | | theme switch | full recompute | free | | frame objects built at init | 431k (~73ms) | **0** | 19MB committed sample: 20.62ms โ†’ **0.84ms** a step. One bucket per display pixel is unchanged, so a wider screen still shows more detail. ## Proof the picture did not change `pnpm measure minimap --digest` prints a CSV row per bucket (`width,bucket,eventCount,maxDepth, dominantCategory`) across five widths. Against the branch point it differs in **0 of 5,035 rows**, on the 19MB sample and on the 95MB log. Both logs also report **0 violations** and exactly **2.00 segments per frame**, so the sweep's containment guards never fire on real data. There is one deliberate behaviour change the digest does not show, because neither log triggers it: a frame ending exactly on a bucket boundary still counts toward that bucket's opacity but no longer contributes a segment there, so the bucket draws no bar. The old code drew a full-height bar one pixel past the frame's end. ## How it works Segments **tile** the timeline โ€” segment `i` spans `[segmentStarts[i], segmentStarts[i+1])` โ€” so a walk needs no bounds test and no segment-end array. A stretch with nothing running is a segment with category id 0. The sweep reads `PrecomputedRect` directly, so no per-event object is allocated for it, and `TemporalSegmentTree` carries no minimap state. It sweeps once into a `2N + 2` bound (at most two segments a frame, plus a trailing gap and a closer) and trims to views; measured slack across four real logs is 14 to 2,709 bytes. ## Measured trade-offs, recorded so they are not retried - **Frame bounds are copied into typed arrays** (6.6MB) rather than read off the rectangles. Reading the scattered rectangles costs 10-15ms a call against 1ms, and `countFrames` runs on every width change โ€” it would put a resize step inside the frame budget. - **`subarray` views, not `slice`.** Retained slack is 311 bytes on the 95MB log; copying to exact arrays would peak 9MB higher to reclaim it. - **Sorting** stays a plain `Array.sort`. Keyed indices measured 26-47ms, an explicit k-way merge 4-22ms, and the conversion's own traversal order 43ms, against 12ms shipped โ€” it is cheap because each category group arrives time-ordered, so it merges a few runs. Per-depth runs are *not* time-ordered (40% inversions), so a depth-run merge is invalid. - **The `onTopOrder` scratch** costs 0.73ms a step and buys the documented first-on-top tie-break. ## Also fixed here Reviewing this branch surfaced a defect in the resize guard added by #982, so it is fixed in its own commit with a regression test. `resize`'s "nothing moved" guard read the main timeline height โ€” the container less the minimap, the metric strip and their gaps โ€” so a change *inside* that overhead hid itself. The strip appearing adds 15 + 4, and a container growing by the same 19px leaves the difference unchanged; the minimap's height is clamped at 60 below ~605px, so it does not move either. Every value the guard compared was equal while the layout had changed, so `mainTimelineYOffset` kept the offset for a layout without the strip and **every hit test and tooltip sat 19px out** โ€” 65px on collapse/expand โ€” until some unrelated resize. The next `ResizeObserver` delivery compared equal too, so it did not self-correct. The overhead is now compared for itself, added as an extra term rather than replacing the height checks, so it can only apply a resize the old guard skipped and never skip one it applied. ## Accepted cost Building the skyline is ~73ms on the first minimap draw, over the 50ms synchronous budget in `.claude/rules/log-viewer.md`. Taken deliberately: it is paid once per log, against the ~100ms it used to cost on *every pixel* of a width drag. Recorded in the file header so it is not read as an oversight. ## Follow-ups, not in this PR - One shared category id table: the same nameโ†’index map is built in `MinimapSkylineIndex`, `BucketColorResolver`, `TemporalSegmentTree` and per-call in `HitDetector`. - `resolveDominantCategory` duplicates `BucketColorResolver`'s priority tie-break. - `mergeManagedPackageEvents` can extend an event's `exitStamp` past a sibling's, which is what the sweep's `violations` counter exists to detect. Nothing surfaces it in the app today. ## Testing - `pnpm lint`, `pnpm test` โ€” 2,107 tests, 163 suites, green. - New `MinimapSkylineIndex` suite covering gaps, the tail after the last frame, equal starts, containment, same-depth overlap, zero duration and an empty log. - Checked by hand in the dev host, light and dark: same bands and heights, colours track a slow width drag without flicker, a height-only drag recomputes nothing, and a theme switch recolours without changing shape. --- CHANGELOG.md | 1 + .../__tests__/minimap-density.test.ts | 159 ++--- .../src/features/timeline/optimised/CLAUDE.md | 29 +- .../features/timeline/optimised/FlameChart.ts | 20 +- .../timeline/optimised/RectangleCache.ts | 14 +- .../timeline/optimised/TemporalSegmentTree.ts | 243 +------- .../__tests__/FlameChartResize.test.ts | 24 + .../__tests__/MinimapSkylineIndex.test.ts | 149 +++++ .../optimised/minimap/MinimapDensityQuery.ts | 554 ++++-------------- .../optimised/minimap/MinimapSkylineIndex.ts | 326 +++++++++++ .../orchestrators/MinimapOrchestrator.ts | 19 +- package.json | 2 +- scripts/measure/call-tree.ts | 100 ++++ scripts/measure/harness.ts | 76 +++ scripts/measure/measure.ts | 183 +++--- scripts/measure/minimap.ts | 115 ++++ scripts/tsconfig.json | 14 + 17 files changed, 1123 insertions(+), 905 deletions(-) create mode 100644 log-viewer/src/features/timeline/optimised/__tests__/MinimapSkylineIndex.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/minimap/MinimapSkylineIndex.ts create mode 100644 scripts/measure/call-tree.ts create mode 100644 scripts/measure/harness.ts create mode 100644 scripts/measure/minimap.ts create mode 100644 scripts/tsconfig.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c80d6525..85a8d4538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Help & documentation** and **Report an issue** move into a `โ€ขโ€ขโ€ข` menu, which also holds the values and controls the header drops as the window narrows. - โ™ป๏ธ Replace `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). - โšก **Go to Code**: Faster in large projects โ€” ~6ร— to ~10ร— faster ([#834]). +- โšก **Timeline minimap**: ~25ร— faster and uses less memory. ### Fixed diff --git a/log-viewer/src/features/timeline/__tests__/minimap-density.test.ts b/log-viewer/src/features/timeline/__tests__/minimap-density.test.ts index d033ed4af..a52d709da 100644 --- a/log-viewer/src/features/timeline/__tests__/minimap-density.test.ts +++ b/log-viewer/src/features/timeline/__tests__/minimap-density.test.ts @@ -5,17 +5,15 @@ /** * Unit tests for MinimapDensityQuery * - * Tests category resolution for minimap coloring, ensuring: - * - Long-spanning parent frames are not skipped during frame collection - * - Skyline (on-top time) algorithm correctly identifies dominant category - * - Both fallback and segment tree paths produce consistent results + * Tests category resolution for minimap coloring: that a long-spanning parent + * holds every bucket its children do not, and that the skyline's on-top time + * picks the dominant category. */ import { describe, expect, it } from '@jest/globals'; import type { LogEvent } from 'apex-log-parser'; import { MinimapDensityQuery } from '../optimised/minimap/MinimapDensityQuery.js'; import type { PrecomputedRect } from '../optimised/RectangleCache.js'; -import { TemporalSegmentTree } from '../optimised/TemporalSegmentTree.js'; /** * Helper to create a mock PrecomputedRect. @@ -25,7 +23,6 @@ function createRect( timeStart: number, timeEnd: number, depth: number, - selfDuration?: number, ): PrecomputedRect { const duration = timeEnd - timeStart; return { @@ -34,7 +31,7 @@ function createRect( timeEnd, depth, duration, - selfDuration: selfDuration ?? duration, + selfDuration: duration, category, x: 0, y: 0, @@ -44,32 +41,26 @@ function createRect( }; } -/** - * Build rectsByCategory from a flat list of rects. - */ -function buildRectsByCategory(rects: PrecomputedRect[]): Map { - const map = new Map(); +function buildQuery(rects: PrecomputedRect[], totalDuration: number): MinimapDensityQuery { + const rectsByCategory = new Map(); for (const rect of rects) { - let arr = map.get(rect.category); - if (!arr) { - arr = []; - map.set(rect.category, arr); + let grouped = rectsByCategory.get(rect.category); + if (!grouped) { + grouped = []; + rectsByCategory.set(rect.category, grouped); } - arr.push(rect); + grouped.push(rect); } - return map; + // maxDepth only reaches MinimapDensityData.globalMaxDepth; the sweep derives its own. + return new MinimapDensityQuery([...rectsByCategory.values()], totalDuration, 0); } describe('MinimapDensityQuery', () => { describe('category resolution with long-spanning parent frames', () => { /** - * Regression test: A long parent Method frame spanning many buckets - * with a short DML child in the middle. - * - * The parent Method frame must be included in all overlapping buckets - * for correct skyline computation. If frames are collected via binary - * search on timeEnd (with timeStart-sorted data), long-spanning parent - * frames can be skipped, causing incorrect coloring. + * Regression test: a long parent Method frame spanning many buckets, with a + * short DML child in the middle. The parent must hold every bucket the child + * does not. * * Layout: * depth 0: |-------- Method (0-1000) --------| @@ -78,35 +69,14 @@ describe('MinimapDensityQuery', () => { * Expected: Buckets outside DML range should be Method (green). * Bucket covering DML range should be DML (brown) due to weight. */ - const rects = [ - createRect('Method', 0, 1000, 0, 600), // parent, selfDuration excludes DML child time - createRect('DML', 300, 400, 1, 100), - ]; - - it('should show Method in buckets outside DML range (fallback path)', () => { - const rectsByCategory = buildRectsByCategory(rects); - const query = new MinimapDensityQuery(rectsByCategory, 1000, 1); + const rects = [createRect('Method', 0, 1000, 0), createRect('DML', 300, 400, 1)]; + it('should show Method in buckets outside DML range', () => { // 10 buckets: each covers 100ns // Bucket 0 [0-100]: only Method โ†’ Method // Bucket 3 [300-400]: Method + DML โ†’ DML wins (2.5x weight) // Bucket 9 [900-1000]: only Method โ†’ Method - const result = query.query(10); - - expect(result.buckets[0]!.dominantCategory).toBe('Method'); - expect(result.buckets[1]!.dominantCategory).toBe('Method'); - expect(result.buckets[9]!.dominantCategory).toBe('Method'); - - // DML bucket: DML at depth 1 is deeper, with 2.5x weight - expect(result.buckets[3]!.dominantCategory).toBe('DML'); - }); - - it('should show Method in buckets outside DML range (segment tree path)', () => { - const rectsByCategory = buildRectsByCategory(rects); - const segmentTree = new TemporalSegmentTree(rectsByCategory); - const query = new MinimapDensityQuery(rectsByCategory, 1000, 1, segmentTree); - - const result = query.query(10); + const result = buildQuery(rects, 1000).query(10); // These buckets must be Method - the parent frame spans all of them expect(result.buckets[0]!.dominantCategory).toBe('Method'); @@ -114,25 +84,17 @@ describe('MinimapDensityQuery', () => { expect(result.buckets[5]!.dominantCategory).toBe('Method'); expect(result.buckets[9]!.dominantCategory).toBe('Method'); - // DML bucket + // DML bucket: DML at depth 1 is deeper, with 2.5x weight expect(result.buckets[3]!.dominantCategory).toBe('DML'); }); - it('should produce consistent results between fallback and segment tree paths', () => { - const rectsByCategory = buildRectsByCategory(rects); - const segmentTree = new TemporalSegmentTree(rectsByCategory); + it('counts every frame in each bucket it spans', () => { + const result = buildQuery(rects, 1000).query(10); - const fallbackQuery = new MinimapDensityQuery(rectsByCategory, 1000, 1); - const treeQuery = new MinimapDensityQuery(rectsByCategory, 1000, 1, segmentTree); - - const fallbackResult = fallbackQuery.query(10); - const treeResult = treeQuery.query(10); - - for (let i = 0; i < 10; i++) { - expect(treeResult.buckets[i]!.dominantCategory).toBe( - fallbackResult.buckets[i]!.dominantCategory, - ); - } + // The parent alone outside the child's range, both where they overlap. + expect(result.buckets[0]!.eventCount).toBe(1); + expect(result.buckets[3]!.eventCount).toBe(2); + expect(result.buckets[9]!.eventCount).toBe(1); }); }); @@ -143,22 +105,18 @@ describe('MinimapDensityQuery', () => { * depth 1: |-------- Method (0-1000) ------------| * depth 2: |-- SOQL (200-300) --| |-- DML (600-700) --| * - * This tests that parent frames at multiple depths are all correctly - * collected even when short children exist between them. + * Parent frames at several depths must all hold their own buckets, even with + * short children between them. */ it('should resolve Method where no SOQL/DML children exist', () => { const rects = [ - createRect('Code Unit', 0, 1000, 0, 0), // code unit has 0 self duration (all children) - createRect('Method', 0, 1000, 1, 800), // method covers most of the time - createRect('SOQL', 200, 300, 2, 100), - createRect('DML', 600, 700, 2, 100), + createRect('Code Unit', 0, 1000, 0), + createRect('Method', 0, 1000, 1), + createRect('SOQL', 200, 300, 2), + createRect('DML', 600, 700, 2), ]; - const rectsByCategory = buildRectsByCategory(rects); - const segmentTree = new TemporalSegmentTree(rectsByCategory); - const query = new MinimapDensityQuery(rectsByCategory, 1000, 2, segmentTree); - - const result = query.query(10); + const result = buildQuery(rects, 1000).query(10); // Bucket 0 [0-100]: Code Unit + Method โ†’ Method wins (deeper) expect(result.buckets[0]!.dominantCategory).toBe('Method'); @@ -174,14 +132,25 @@ describe('MinimapDensityQuery', () => { }); }); + it('recomputes only when the width changes', () => { + const rects = [createRect('Method', 0, 1000, 0), createRect('DML', 300, 400, 1)]; + const query = buildQuery(rects, 1000); + + const first = query.query(10); + expect(query.query(10)).toBe(first); + + // A different width is a different picture, so it cannot answer from the one held. + const wider = query.query(11); + expect(wider).not.toBe(first); + + // Only one is held, so the first width has to be computed again. + expect(query.query(10)).not.toBe(first); + }); + describe('edge cases', () => { it('should handle single frame spanning all buckets', () => { const rects = [createRect('Method', 0, 1000, 0)]; - const rectsByCategory = buildRectsByCategory(rects); - const segmentTree = new TemporalSegmentTree(rectsByCategory); - const query = new MinimapDensityQuery(rectsByCategory, 1000, 0, segmentTree); - - const result = query.query(5); + const result = buildQuery(rects, 1000).query(5); for (const bucket of result.buckets) { expect(bucket.dominantCategory).toBe('Method'); @@ -189,10 +158,7 @@ describe('MinimapDensityQuery', () => { }); it('should handle empty timeline', () => { - const rectsByCategory = new Map(); - const query = new MinimapDensityQuery(rectsByCategory, 0, 0); - - const result = query.query(10); + const result = buildQuery([], 0).query(10); expect(result.buckets).toHaveLength(0); }); }); @@ -203,29 +169,22 @@ describe('MinimapDensityQuery', () => { // "Invalid array length" and aborted timeline init. const rects = [createRect('Method', 0, 1000, 0)]; - it('floors a fractional bucket count instead of throwing (segment tree path)', () => { - const rectsByCategory = buildRectsByCategory(rects); - const segmentTree = new TemporalSegmentTree(rectsByCategory); - const query = new MinimapDensityQuery(rectsByCategory, 1000, 0, segmentTree); - - const fractional = query.query(10.6); - const floored = query.query(10); + it('floors a fractional bucket count to the integer count', () => { + // Two queries, not one: 10.6 and 10 floor to the same width, so a single + // query would answer the second call from its cache and compare an object + // with itself. + const fractional = buildQuery(rects, 1000).query(10.6); + const floored = buildQuery(rects, 1000).query(10); expect(fractional.buckets).toHaveLength(10); expect(fractional.buckets).toEqual(floored.buckets); - }); - - it('floors a fractional bucket count instead of throwing (fallback path)', () => { - const rectsByCategory = buildRectsByCategory(rects); - const query = new MinimapDensityQuery(rectsByCategory, 1000, 0); - expect(() => query.query(1536.6667)).not.toThrow(); - expect(query.query(1536.6667).buckets).toHaveLength(1536); + // A real 150% DPI width. + expect(buildQuery(rects, 1000).query(1536.6667).buckets).toHaveLength(1536); }); it('treats a non-finite bucket count as empty instead of throwing', () => { - const rectsByCategory = buildRectsByCategory(rects); - const query = new MinimapDensityQuery(rectsByCategory, 1000, 0); + const query = buildQuery(rects, 1000); expect(() => query.query(Number.NaN)).not.toThrow(); expect(query.query(Number.NaN).buckets).toHaveLength(0); diff --git a/log-viewer/src/features/timeline/optimised/CLAUDE.md b/log-viewer/src/features/timeline/optimised/CLAUDE.md index 69d0b0060..43164a8bd 100644 --- a/log-viewer/src/features/timeline/optimised/CLAUDE.md +++ b/log-viewer/src/features/timeline/optimised/CLAUDE.md @@ -62,23 +62,19 @@ The metric strip supports collapsed (heat-style) and expanded (step chart) views ### Available Query Methods -| Method | Use Case | Complexity | -| -------------------------------------- | ------------------------------ | ------------ | -| `query(viewport)` | Viewport culling for rendering | O(k log n) | -| `queryEventsInRegion(time, depth)` | Hit testing, spatial lookups | O(log n + k) | -| `queryBucketStats(timeStart, timeEnd)` | Minimap density computation | O(log n) | +| Method | Use Case | Complexity | +| ---------------------------------- | ------------------------------ | ------------ | +| `query(viewport)` | Viewport culling for rendering | O(k log n) | +| `queryEventsInRegion(time, depth)` | Hit testing, spatial lookups | O(log n + k) | ### Access Pattern ```typescript -// Via RectangleCache (preferred) const events = rectangleCache.queryEventsInRegion(timeStart, timeEnd, depthStart, depthEnd); - -// Direct tree access (for specialized queries) -const tree = rectangleCache.getSegmentTree(); -const stats = tree.queryBucketStats(timeStart, timeEnd); ``` +`RectangleCache` fronts the tree; nothing reaches past it. + ### Why Not TimelineEventIndex? `TimelineEventIndex.findEventsInRegion()` does O(n) full tree traversal. Use it only as a fallback when TemporalSegmentTree is unavailable. @@ -151,6 +147,19 @@ The minimap uses standard screen coordinates but maps depths to match the main t This ensures the minimap's viewport lens correctly shows which depth range is visible - when scrolled to show parent frames (depth 0), the lens highlights the BOTTOM of the chart area. +### Minimap Density Architecture + +The minimap colours each pixel column by the category on top longest in it โ€” the log seen from above. + +- `MinimapSkylineIndex` holds that as typed segments, built on the first minimap draw straight from + `RectangleCache`'s rectangles. Which frame is on top does not depend on the minimap's width, so it + is never rebuilt or invalidated. +- `MinimapDensityQuery` walks those segments against the buckets, one bucket per pixel of the + display width, and holds the one width it last computed. Nothing invalidates that either: a new + width misses on its own, and a theme change cannot alter a category name. +- Colour is resolved at draw time by `MinimapRenderer` from `batchColors`, so density outlives a + theme switch. + ## Minimap Interactions The minimap supports intuitive drag interactions for viewport control: diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 5a36fd6be..819504dfc 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -196,6 +196,12 @@ export class FlameChart { /** The minimap height the last applied resize used, so a resize can tell nothing moved. */ private appliedMinimapHeight: number | null = null; + /** + * Overhead the last applied layout sat under, so a change inside it is not hidden + * by the main timeline height staying the same. + */ + private appliedOverheadHeight: number | null = null; + // Cached culled rectangles (reused when viewport unchanged - Phase 3 optimization) // INVARIANT: These caches are invalidated when renderDirty.culling is set to true. // Any code that changes viewport state must call invalidateAll() or set culling dirty flag. @@ -832,14 +838,22 @@ export class FlameChart { // stands. Load reaches here with the geometry init already set. The minimap is checked // too: its height is a tenth of the container's, so it can move on its own while the main // timeline keeps the height it had. + // + // The overhead is checked for itself, not through `mainTimelineHeight`: that is the + // container less the overhead, so the metric strip appearing while the container grows by + // the same 19px leaves it unchanged, and the minimap's height is clamped over most of the + // range. Skipping there left `mainTimelineYOffset` short and put every hit test and + // tooltip out by the strip's height. if ( newWidth === oldWidth && mainTimelineHeight === oldState.displayHeight && - minimapHeight === this.appliedMinimapHeight + minimapHeight === this.appliedMinimapHeight && + totalOverheadHeight === this.appliedOverheadHeight ) { return false; } this.appliedMinimapHeight = minimapHeight; + this.appliedOverheadHeight = totalOverheadHeight; // Update offset for converting canvas-relative to container-relative coordinates this.mainTimelineYOffset = totalOverheadHeight; @@ -900,8 +914,7 @@ export class FlameChart { // Rebuild batch colors cache (used by bucket color resolution) this.state.batchColorsCache = this.buildBatchColorsCache(this.state.batches); - // Invalidate minimap static content to re-render with new colors - this.minimapOrchestrator?.invalidateCache(); + this.minimapOrchestrator?.invalidateStatic(); // Request re-render this.requestRender(); @@ -1027,6 +1040,7 @@ export class FlameChart { // This is the applied geometry, so a resize to the same size has nothing to do. this.appliedMinimapHeight = minimapHeight; + this.appliedOverheadHeight = minimapHeight + MINIMAP_GAP + metricStripHeight + METRIC_STRIP_GAP; // Create wrapper container with flexbox layout this.wrapper = document.createElement('div'); diff --git a/log-viewer/src/features/timeline/optimised/RectangleCache.ts b/log-viewer/src/features/timeline/optimised/RectangleCache.ts index b247d143a..d8131ee76 100644 --- a/log-viewer/src/features/timeline/optimised/RectangleCache.ts +++ b/log-viewer/src/features/timeline/optimised/RectangleCache.ts @@ -186,8 +186,8 @@ export class RectangleCache { } /** - * Get spatial index of rectangles by category. - * Used for search functionality and segment tree construction. + * Get the rectangles, grouped by category and each ascending by timeStart. + * Read by the legacy culler and by the minimap, which sweeps them for its skyline. * * @returns Map of category to rectangles */ @@ -215,16 +215,6 @@ export class RectangleCache { return this.segmentTree.queryEventsInRegion(timeStart, timeEnd, depthStart, depthEnd); } - /** - * Get the underlying segment tree for direct queries. - * Used by MinimapDensityQuery for O(Bร—log N) density computation. - * - * @returns The TemporalSegmentTree instance - */ - public getSegmentTree(): TemporalSegmentTree { - return this.segmentTree; - } - // ============================================================================ // PRIVATE METHODS // ============================================================================ diff --git a/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts b/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts index aadf0c9b6..a20acba68 100644 --- a/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts +++ b/log-viewer/src/features/timeline/optimised/TemporalSegmentTree.ts @@ -15,7 +15,6 @@ * Key capabilities: * - Viewport culling: query() returns visible rectangles and buckets * - Spatial queries: queryEventsInRegion() for hit testing (O(log n + k)) - * - Density stats: queryBucketStats() for minimap visualization (O(log n)) * * Key concepts: * - Leaf nodes represent individual events @@ -23,7 +22,6 @@ * - Query traversal stops at nodes where nodeSpan <= threshold (2px / zoom) * - Pre-computed category stats enable instant bucket color resolution * - * Memory usage: ~175MB for 500k events (tree + original rectangles) * Build time: O(n log n) for sorting + O(n) for tree construction * Query time: O(k log n) where k = number of visible nodes */ @@ -54,18 +52,6 @@ const PRIORITY_MAP = new Map( BUCKET_CONSTANTS.CATEGORY_PRIORITY.map((cat, index) => [cat, index]), ); -/** - * Frame data for minimap density computation. - * Pre-sorted by timeStart for efficient sliding window algorithms. - */ -export interface SkylineFrame { - timeStart: number; - timeEnd: number; - depth: number; - category: string; - selfDuration: number; -} - /** * TemporalSegmentTree * @@ -83,6 +69,24 @@ type AggregationBucket = { dominantCategory: string; // Resolved after all nodes aggregated }; +/** Group rectangles by depth, for a caller that has not already done it. */ +function groupByDepth( + rectsByCategory: Map, +): Map { + const rectsByDepth = new Map(); + for (const rects of rectsByCategory.values()) { + for (const rect of rects) { + let depthRects = rectsByDepth.get(rect.depth); + if (!depthRects) { + depthRects = []; + rectsByDepth.set(rect.depth, depthRects); + } + depthRects.push(rect); + } + } + return rectsByDepth; +} + export class TemporalSegmentTree { /** Tree root per depth level: Map */ private treesByDepth: Map = new Map(); @@ -90,18 +94,6 @@ export class TemporalSegmentTree { /** Maximum depth in the tree */ private maxDepth = 0; - /** - * Unsorted frames collected during tree construction. - * Sorting is deferred to first getAllFramesSorted() call. - */ - private unsortedFrames: SkylineFrame[] | null = null; - - /** - * Cached sorted frames for minimap density computation. - * Lazily sorted on first access to defer ~25ms sort cost to minimap render. - */ - private cachedSortedFrames: SkylineFrame[] | null = null; - /** * Build segment trees from pre-computed rectangles. * @@ -237,26 +229,6 @@ export class TemporalSegmentTree { return this.maxDepth; } - /** - * Get all frames sorted by timeStart for minimap density computation. - * Frames are collected during tree construction but sorting is deferred - * to first access to avoid blocking init when minimap isn't immediately visible. - * - * Performance: Lazy sorting defers ~25ms cost to first minimap render, - * reducing init time when minimap isn't immediately needed. - * - * @returns Array of SkylineFrame sorted by timeStart - */ - public getAllFramesSorted(): SkylineFrame[] { - // Lazy sort on first access - if (!this.cachedSortedFrames && this.unsortedFrames) { - this.cachedSortedFrames = this.unsortedFrames; - this.cachedSortedFrames.sort((a, b) => a.timeStart - b.timeStart); - this.unsortedFrames = null; // Release reference - } - return this.cachedSortedFrames ?? []; - } - /** * Query events within a specific time and depth region. * Used for hit testing when bucket eventRefs are empty. @@ -317,124 +289,6 @@ export class TemporalSegmentTree { } } - /** - * Stats returned from queryBucketStats for minimap density computation. - */ - public queryBucketStats( - timeStart: number, - timeEnd: number, - ): { - maxDepth: number; - eventCount: number; - selfDurationSum: number; - categoryWeights: Map; - frames: SkylineFrame[]; - } { - let maxDepth = 0; - let eventCount = 0; - let selfDurationSum = 0; - const categoryWeights = new Map(); - const frames: SkylineFrame[] = []; - - // Query each depth level - for (const [depth, tree] of this.treesByDepth) { - this.aggregateStatsFromNode( - tree, - timeStart, - timeEnd, - depth, - categoryWeights, - frames, - (d, count, selfDur) => { - if (d > maxDepth) { - maxDepth = d; - } - eventCount += count; - selfDurationSum += selfDur; - }, - ); - } - - return { maxDepth, eventCount, selfDurationSum, categoryWeights, frames }; - } - - /** - * Aggregate stats from a tree node for minimap density computation. - * Collects frame references for skyline computation. - */ - private aggregateStatsFromNode( - node: SegmentNode, - queryStart: number, - queryEnd: number, - depth: number, - categoryWeights: Map, - frames: SkylineFrame[], - onStats: (depth: number, count: number, selfDuration: number) => void, - ): void { - // Early exit: no overlap - if (node.timeEnd <= queryStart || node.timeStart >= queryEnd) { - return; - } - - // Leaf node: aggregate its stats - if (node.isLeaf && node.rectRef) { - const rect = node.rectRef; - - // Calculate visible time within query range - const overlapStart = Math.max(rect.timeStart, queryStart); - const overlapEnd = Math.min(rect.timeEnd, queryEnd); - const visibleTime = overlapEnd - overlapStart; - - // Calculate overlap ratio for proportional self-duration attribution - const rectDuration = rect.timeEnd - rect.timeStart; - const overlapRatio = rectDuration > 0 ? visibleTime / rectDuration : 0; - const proportionalSelfDuration = rect.selfDuration * overlapRatio; - - // Depthยฒ weighting for category dominance (still used for fallback stats) - const depthWeight = (depth + 1) * (depth + 1); - const weightedTime = proportionalSelfDuration * depthWeight; - - // Update category weights - const category = rect.category; - const existing = categoryWeights.get(category); - if (existing) { - existing.weightedTime += weightedTime; - if (depth > existing.maxDepth) { - existing.maxDepth = depth; - } - } else { - categoryWeights.set(category, { weightedTime, maxDepth: depth }); - } - - // Collect frame for density computation - frames.push({ - timeStart: rect.timeStart, - timeEnd: rect.timeEnd, - depth, - category, - selfDuration: rect.selfDuration, - }); - - onStats(depth, 1, proportionalSelfDuration); - return; - } - - // Branch node: recurse into children - if (node.children) { - for (const child of node.children) { - this.aggregateStatsFromNode( - child, - queryStart, - queryEnd, - depth, - categoryWeights, - frames, - onStats, - ); - } - } - } - // ========================================================================== // TREE BUILDING // ========================================================================== @@ -442,10 +296,7 @@ export class TemporalSegmentTree { /** * Build segment trees for all depth levels. * - * PERF optimizations: - * - Uses pre-grouped rectsByDepth when available (~12ms saved) - * - Collects frames for minimap during iteration (avoids O(N) traversal) - * - Defers frame sorting to first getAllFramesSorted() call (~25ms saved) + * Uses pre-grouped rectsByDepth when available (~12ms saved). * * @param rectsByCategory - Rectangles grouped by category * @param preGroupedByDepth - Optional pre-grouped by depth from unified conversion @@ -454,65 +305,17 @@ export class TemporalSegmentTree { rectsByCategory: Map, preGroupedByDepth?: Map, ): void { - // Collect frames during iteration (avoids separate tree traversal later) - const allFrames: SkylineFrame[] = []; - - // Use pre-grouped rectsByDepth if available, otherwise group from category map - let rectsByDepth: Map; - - if (preGroupedByDepth) { - // PERF: Use pre-grouped data (~12ms saved by skipping grouping iteration) - rectsByDepth = preGroupedByDepth; - - // Still need to collect frames and track maxDepth - for (const [depth, rects] of rectsByDepth) { - this.maxDepth = Math.max(this.maxDepth, depth); - for (const rect of rects) { - allFrames.push({ - timeStart: rect.timeStart, - timeEnd: rect.timeEnd, - depth: rect.depth, - category: rect.category, - selfDuration: rect.selfDuration, - }); - } - } - } else { - // Fallback: Group all rectangles by depth - rectsByDepth = new Map(); - - for (const rects of rectsByCategory.values()) { - for (const rect of rects) { - let depthRects = rectsByDepth.get(rect.depth); - if (!depthRects) { - depthRects = []; - rectsByDepth.set(rect.depth, depthRects); - } - depthRects.push(rect); - this.maxDepth = Math.max(this.maxDepth, rect.depth); - - // Collect frame directly (eliminates recursive tree traversal in getAllFramesSorted) - allFrames.push({ - timeStart: rect.timeStart, - timeEnd: rect.timeEnd, - depth: rect.depth, - category: rect.category, - selfDuration: rect.selfDuration, - }); - } - } - } + const rectsByDepth = preGroupedByDepth ?? groupByDepth(rectsByCategory); - // Build tree for each depth for (const [depth, rects] of rectsByDepth) { + if (depth > this.maxDepth) { + this.maxDepth = depth; + } const tree = this.buildTreeForDepth(rects, depth); if (tree) { this.treesByDepth.set(depth, tree); } } - - // PERF: Defer sorting to first getAllFramesSorted() call (~25ms saved at init) - this.unsortedFrames = allFrames; } /** diff --git a/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts index 75b34ab63..c0e77fc96 100644 --- a/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts +++ b/log-viewer/src/features/timeline/optimised/__tests__/FlameChartResize.test.ts @@ -50,6 +50,7 @@ function stubbedChart(displayHeight = 300): { }; // The geometry init applied: 364 container - 60 minimap - 4 gap = the 300 below. internals['appliedMinimapHeight'] = 60; + internals['appliedOverheadHeight'] = 64; internals['state'] = { viewport: null, needsRender: false, @@ -118,6 +119,29 @@ describe('FlameChart.resize', () => { expect(appRender).toHaveBeenCalled(); }); + // The metric strip appearing adds 15 + 4 to the overhead, so a container that grows by the + // same 19px leaves the main timeline height alone. The minimap is clamped at 60 across both, + // so every value the guard used to compare was unchanged while the overhead moved by 19. + it('draws when the overhead moved but the main timeline height did not', () => { + const { chart, appRender } = stubbedChart(); + const internals = chart as unknown as Record; + internals['metricStripOrchestrator'] = { + getIsVisible: () => true, + getHeight: () => 15, + resize: jest.fn(), + holdsHover: () => false, + getCursorTimeNs: () => null, + render: jest.fn(), + }; + jest.spyOn(window, 'requestAnimationFrame').mockReturnValue(1); + + // 383 - 60 - 4 - 15 - 4 = the 300 the viewport already reports, at the same width. + expect(chart.resize(400, 383)).toBe(true); + expect(appRender).toHaveBeenCalled(); + // Stale at 64, every hit test and tooltip would sit 19px out. + expect(internals['mainTimelineYOffset']).toBe(83); + }); + // The metric strip resizes its own canvas before asking the host to relayout, so a resize // that cannot run has to say so โ€” otherwise nothing draws the strip it just blanked. it('reports whether it applied, so a caller can draw instead', () => { diff --git a/log-viewer/src/features/timeline/optimised/__tests__/MinimapSkylineIndex.test.ts b/log-viewer/src/features/timeline/optimised/__tests__/MinimapSkylineIndex.test.ts new file mode 100644 index 000000000..356edacd2 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/__tests__/MinimapSkylineIndex.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { describe, expect, it } from '@jest/globals'; + +import { MinimapSkylineIndex, type SkylineFrame } from '../minimap/MinimapSkylineIndex.js'; + +function frame(category: string, timeStart: number, timeEnd: number, depth: number): SkylineFrame { + return { category, timeStart, timeEnd, depth }; +} + +/** The index as a readable list of `[start, end, depth, category]`. */ +function segments(index: MinimapSkylineIndex): [number, number, number, string][] { + const out: [number, number, number, string][] = []; + for (let i = 0; i < index.segmentCount; i++) { + out.push([ + index.segmentStarts[i]!, + index.segmentStarts[i + 1]!, + index.segmentDepths[i]!, + index.categoryNames[index.segmentCategories[i]!]!, + ]); + } + return out; +} + +function build(frames: SkylineFrame[], totalDuration = 1000): MinimapSkylineIndex { + // One group, in whatever order the test wrote it: the index orders its own input. + return new MinimapSkylineIndex([frames], totalDuration); +} + +describe('MinimapSkylineIndex', () => { + it('splits the timeline where the frame on top changes', () => { + // depth 0: |------------ Method (0-1000) ------------| + // depth 1: |-- DML (300-400) --| + const index = build([frame('Method', 0, 1000, 0), frame('DML', 300, 400, 1)]); + + expect(segments(index)).toEqual([ + [0, 300, 0, 'Method'], + [300, 400, 1, 'DML'], + [400, 1000, 0, 'Method'], + ]); + expect(index.violations).toBe(0); + }); + + it('leaves a gap where no frame runs', () => { + const index = build([frame('Method', 0, 200, 0), frame('SOQL', 600, 700, 0)]); + + expect(segments(index)).toEqual([ + [0, 200, 0, 'Method'], + [200, 600, 0, ''], + [600, 700, 0, 'SOQL'], + [700, 1000, 0, ''], + ]); + }); + + it('leaves the stretch after the last frame as a gap', () => { + const index = build([frame('Method', 0, 200, 0)], 1000); + + // Closing the Method segment at 1000 instead would colour the rest of the + // minimap with a frame that had already ended. + expect(segments(index)).toEqual([ + [0, 200, 0, 'Method'], + [200, 1000, 0, ''], + ]); + }); + + it('orders segments by time with no overlap', () => { + const frames: SkylineFrame[] = []; + for (let root = 0; root < 40; root++) { + const start = root * 25; + frames.push(frame('Apex', start, start + 20, 0)); + frames.push(frame('SOQL', start + 5, start + 8, 1)); + frames.push(frame('DML', start + 10, start + 19, 1)); + frames.push(frame('System', start + 11, start + 14, 2)); + } + const index = build(frames, 1000); + + expect(index.segmentCount).toBeGreaterThan(40); + expect(index.violations).toBe(0); + for (let i = 0; i < index.segmentCount; i++) { + expect(index.segmentStarts[i]!).toBeLessThan(index.segmentStarts[i + 1]!); + } + }); + + it('keeps a parent that starts at the same time as its child', () => { + // The parent must still be on top after the child ends, or a frame spanning + // the whole log is lost. + const index = build([frame('Method', 0, 1000, 0), frame('SOQL', 0, 100, 1)], 1000); + + expect(segments(index)).toEqual([ + [0, 100, 1, 'SOQL'], + [100, 1000, 0, 'Method'], + ]); + expect(index.violations).toBe(0); + }); + + it('contains a frame that outlives its parent', () => { + // depth 0: |-- Method (0-100) --| + // depth 1: |------ SOQL (0-200) ------| + const index = build([frame('Method', 0, 100, 0), frame('SOQL', 0, 200, 1)], 200); + + // The child is cut to its parent's end rather than reaching past it. + expect(segments(index)).toEqual([ + [0, 100, 1, 'SOQL'], + [100, 200, 0, ''], + ]); + expect(index.violations).toBe(1); + }); + + it('keeps the first of two frames that overlap at the same depth', () => { + const index = build([frame('Method', 0, 100, 0), frame('SOQL', 50, 150, 0)], 150); + + expect(segments(index)).toEqual([ + [0, 100, 0, 'Method'], + [100, 150, 0, ''], + ]); + expect(index.violations).toBe(1); + }); + + it('ignores a frame with no duration', () => { + const index = build([frame('Method', 0, 1000, 0), frame('DML', 500, 500, 1)], 1000); + + expect(segments(index)).toEqual([[0, 1000, 0, 'Method']]); + expect(index.violations).toBe(1); + }); + + it('names every category it saw, including one outside the known set', () => { + const index = build([frame('Method', 0, 500, 0), frame('DML', 100, 200, 1)]); + + expect(index.categoryNames[0]).toBe(''); + expect(index.categoryNames.slice(1).sort()).toEqual(['DML', 'Method']); + }); + + it('counts a frame in every bucket it spans', () => { + const index = build([frame('Method', 0, 1000, 0), frame('DML', 300, 400, 1)]); + + // Ten buckets of 100. The child adds a second count over 300-400, and a + // frame ending on a boundary counts in the bucket after it. + expect(Array.from(index.countFrames(10, 100))).toEqual([1, 1, 1, 2, 2, 1, 1, 1, 1, 1]); + }); + + it('handles a log with no frames', () => { + const index = build([], 1000); + + expect(segments(index)).toEqual([[0, 1000, 0, '']]); + expect(index.violations).toBe(0); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts b/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts index cb3305859..09a461e26 100644 --- a/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.ts @@ -5,50 +5,33 @@ /** * MinimapDensityQuery * - * Computes density data for the minimap visualization by leveraging - * the existing RectangleCache's spatial index. + * One bucket per pixel of the minimap's width, each carrying the three things + * the renderer draws with: stack depth as the bar's height, frame count as its + * opacity, and a category as its colour. * - * The minimap displays a heatmap where: - * - Height = normalized stack depth (maxDepth at bucket / global maxDepth) - * - Opacity = event count (logarithmic scale) - * - Color = dominant category color + * Colour comes from the skyline, the log seen from above: at each instant the + * deepest frame is the visible one, so a category earns a bucket by the time it + * spends on top. Weights let a database operation still read through a shallower + * child covering it. * - * Performance requirements: - * - Cache density data (only recompute on data change) - * - <50ms cold query, <0.1ms cached - * - No allocations in render loop - * - * Category Resolution: Skyline (On-Top Time) Algorithm - * At each moment within a bucket, the deepest frame is "on top" (visible). - * This correctly handles parent frames whose self-duration is concentrated - * at edges (not covered by children), rather than evenly distributed. - * - * Formula: - * onTopTime[category] = sum of time each category is deepest in the bucket * score[category] = onTopTime[category] ร— CATEGORY_WEIGHTS[category] * winner = argmax(score) * - * Example: SOQL at depth 2 covers 0-100ms with Apex child at depth 3 covering 30-80ms - * - SOQL is on-top at 0-30ms and 80-100ms = 50ms total (50%) - * - Apex is on-top at 30-80ms = 50ms total (50%) - * - With weights: SOQL = 50% ร— 2.5 = 125, Apex = 50% ร— 1.0 = 50 - * - SOQL wins because its weighted score is higher + * The skyline itself does not depend on the width, so it is built once per log + * (`MinimapSkylineIndex`) and every width walks it. + * + * Building it is ~73ms on a 95MB log, on the first minimap draw, which is over the + * 50ms synchronous budget in `.claude/rules/log-viewer.md`. Accepted: it is paid + * once per log, against the ~100ms it used to cost on every pixel of a width drag. */ import type { BucketCategoryPriority } from '../../types/flamechart.types.js'; -import type { PrecomputedRect } from '../RectangleCache.js'; -import type { SkylineFrame, TemporalSegmentTree } from '../TemporalSegmentTree.js'; +import { GAP_CATEGORY, MinimapSkylineIndex, type SkylineFrame } from './MinimapSkylineIndex.js'; /** * Single density bucket for minimap visualization. */ export interface MinimapDensityBucket { - /** Bucket start time in nanoseconds. */ - timeStart: number; - - /** Bucket end time in nanoseconds. */ - timeEnd: number; - /** Highest depth at this time range (for height calculation). */ maxDepth: number; @@ -57,36 +40,27 @@ export interface MinimapDensityBucket { /** Dominant category for color resolution. */ dominantCategory: string; - - /** Sum of self-durations for events in this bucket (for sparkline). */ - selfDurationSum: number; } /** * Complete density data for minimap rendering. + * + * A bucket carries no time range: the renderer takes a bar's X from the bucket's + * index, so the times were derivable and unread. */ export interface MinimapDensityData { - /** Array of density buckets (one per minimap pixel approximately). */ + /** One bucket per pixel of the minimap's width. */ buckets: MinimapDensityBucket[]; /** Global maximum depth across entire timeline. */ globalMaxDepth: number; - - /** Global maximum event count in any bucket (for opacity normalization). */ - maxEventCount: number; - - /** Total duration of timeline in nanoseconds. */ - totalDuration: number; } /** * Category weights for importance-based resolution. * DML/SOQL are boosted to highlight database operations even when partially * covered by less important children. Other categories have uniform weight - * so depth becomes the deciding factor among them. - * - * Balance: DML at 2.5x means it can win over a Method child 1-2 levels deeper, - * but a child 5+ levels deeper will still dominate (depthยฒ wins at larger gaps). + * so on-top time becomes the deciding factor among them. */ const CATEGORY_WEIGHTS: Partial> = { DML: 2.5, @@ -100,64 +74,52 @@ const CATEGORY_WEIGHTS: Partial> = { }; /** - * Event type for skyline sweep-line algorithm. - * 0 = frame start (enter), 1 = frame end (exit) - */ -const SkylineEventType = { - Enter: 0, - Exit: 1, -} as const; - -type SkylineEventType = (typeof SkylineEventType)[keyof typeof SkylineEventType]; - -/** - * Skyline event for sweep-line algorithm. - * Reused via object pool to avoid allocations. + * What a bucket with nothing on top reports, as the old sweep's default did. + * + * Never drawn: such a bucket also has `maxDepth` 0, so `MinimapRenderer` skips its + * bar. Kept while this change is measured against the old sweep bucket by bucket; + * once that is done a gap can report nothing. */ -interface SkylineEvent { - time: number; - type: SkylineEventType; - frame: SkylineFrame; -} +const DEFAULT_CATEGORY = 'Apex'; export class MinimapDensityQuery { - /** All rectangles grouped by category from RectangleCache. */ - private rectsByCategory: Map; - - /** Global maximum depth across timeline. */ - private globalMaxDepth: number; - - /** Total duration in nanoseconds. */ - private totalDuration: number; - /** * The one density held, and the bucket count it was computed for. * * One width at a time: every caller asks for the width on screen, and a density holds a * bucket per pixel of it, so keeping the widths a drag passed through would cost more memory * than the recompute it saves. + * + * Nothing invalidates it, and nothing needs to: a new width misses on its own, a height + * change leaves the entry as true as it was, a theme change cannot alter a category name, + * and new data means a new query object. */ private cachedBucketCount: number | null = null; private cachedDensity: MinimapDensityData | null = null; - /** Optional segment tree for O(Bร—log N) density computation. */ - private segmentTree: TemporalSegmentTree | null = null; + /** The log seen from above, built on the first query and never rebuilt. */ + private index: MinimapSkylineIndex | null = null; + + /** + * The frames the skyline is built from, in groups each ascending by `timeStart`. + * Owned by RectangleCache, which holds them for the timeline's life regardless. + */ + private readonly frameGroups: readonly (readonly SkylineFrame[])[]; + private readonly totalDuration: number; + private readonly globalMaxDepth: number; constructor( - rectsByCategory: Map, + frameGroups: readonly (readonly SkylineFrame[])[], totalDuration: number, maxDepth: number, - segmentTree?: TemporalSegmentTree, ) { - this.rectsByCategory = rectsByCategory; + this.frameGroups = frameGroups; this.totalDuration = totalDuration; this.globalMaxDepth = maxDepth; - this.segmentTree = segmentTree ?? null; } /** * Query density data for minimap visualization. - * Computes at exact bucket count using O(B ร— log N) tree queries. * Results are cached for the exact bucket count requested. * * @param bucketCount - Number of density buckets (typically display width) @@ -169,7 +131,7 @@ export class MinimapDensityQuery { // A fractional/NaN count crashes the Array/typed-array constructors below // with "Invalid array length" (the `<= 0` guards do not catch it), which // aborts timeline init and leaves interaction handlers unwired. Normalise - // here, the single entry point feeding both compute paths and the cache. + // here, the single entry point feeding both the compute and the cache. bucketCount = Number.isFinite(bucketCount) ? Math.floor(bucketCount) : 0; // Fast path: exact match in cache @@ -177,406 +139,122 @@ export class MinimapDensityQuery { return this.cachedDensity; } - // Compute at exact bucket count using sliding window algorithm if tree available - const density = this.segmentTree - ? this.computeDensitySlidingWindow(bucketCount) - : this.computeDensity(bucketCount); - + const density = this.computeDensity(bucketCount); this.cachedBucketCount = bucketCount; this.cachedDensity = density; return density; } /** - * Invalidate cache (call when timeline data changes). + * Shape of the skyline, for `pnpm measure minimap`. Builds it if no query has. * - * A resize needs no call: the cache is keyed by width, so a new width misses on its own and - * a height change leaves the entry as true as it was. + * `violations` should be 0 on a well-formed log, so it is the tripwire on a real one. */ - public invalidateCache(): void { - this.cachedBucketCount = null; - this.cachedDensity = null; - } - - /** - * Update underlying data (call when timeline changes). - */ - public setData( - rectsByCategory: Map, - totalDuration: number, - maxDepth: number, - segmentTree?: TemporalSegmentTree, - ): void { - this.rectsByCategory = rectsByCategory; - this.totalDuration = totalDuration; - this.globalMaxDepth = maxDepth; - this.segmentTree = segmentTree ?? null; - this.invalidateCache(); + public stats(): { segmentCount: number; violations: number } { + const index = this.ensureSkyline(); + return { segmentCount: index.segmentCount, violations: index.violations }; } - // ============================================================================ - // PRIVATE: DENSITY COMPUTATION - // ============================================================================ - /** - * Compute density data by aggregating rectangles into buckets. - * Fallback when segment tree is not available. - * Uses skyline (on-top time) algorithm for category resolution: - * At each moment, the deepest frame is "on top" and its category accumulates time. + * Compute density data by walking the skyline against the buckets. + * + * Both are ordered by time, so one pass over each: per bucket, every segment + * overlapping it adds its own length to that category's total, and the deepest + * segment gives the bar its height. A segment reaching past the bucket's end is + * left for the next bucket, so each is visited once plus once per boundary it + * crosses. The walk itself allocates nothing; the buckets it fills are the output. * * @param bucketCount - Number of output buckets * @returns MinimapDensityData */ private computeDensity(bucketCount: number): MinimapDensityData { if (bucketCount <= 0 || this.totalDuration <= 0) { - return { - buckets: [], - globalMaxDepth: this.globalMaxDepth, - maxEventCount: 0, - totalDuration: this.totalDuration, - }; + return { buckets: [], globalMaxDepth: this.globalMaxDepth }; } - // Pre-allocate bucket aggregation arrays - const bucketTimeWidth = this.totalDuration / bucketCount; - const maxDepths = new Uint16Array(bucketCount); - const eventCounts = new Uint32Array(bucketCount); - const selfDurationSums = new Float64Array(bucketCount); + const skyline = this.ensureSkyline(); + const { segmentCount, segmentStarts, segmentDepths, segmentCategories, categoryNames } = + skyline; - // Collect frames per bucket for skyline computation - const framesPerBucket: SkylineFrame[][] = new Array(bucketCount); - for (let i = 0; i < bucketCount; i++) { - framesPerBucket[i] = []; + // One slot per category id, so the argmax needs no string lookup. Under 5KB: + // the ids are interned per log, and no log has reached ten categories. + const weights = new Float64Array(categoryNames.length); + for (let id = 1; id < categoryNames.length; id++) { + weights[id] = CATEGORY_WEIGHTS[categoryNames[id] as BucketCategoryPriority] ?? 1.0; } + const accumulated = new Float64Array(categoryNames.length); + const onTopOrder = new Int32Array(categoryNames.length); - // Single pass through all rectangles - for (const rects of this.rectsByCategory.values()) { - for (const rect of rects) { - // Determine which bucket(s) this rect overlaps - const startBucket = Math.floor(rect.timeStart / bucketTimeWidth); - const endBucket = Math.floor(rect.timeEnd / bucketTimeWidth); - - // Clamp to valid bucket range - const firstBucket = Math.max(0, startBucket); - const lastBucket = Math.min(bucketCount - 1, endBucket); - - // Pre-calculate rect duration for overlap ratio - const rectDuration = rect.timeEnd - rect.timeStart; - - // Create frame for skyline computation - const frame: SkylineFrame = { - timeStart: rect.timeStart, - timeEnd: rect.timeEnd, - depth: rect.depth, - category: rect.category, - selfDuration: rect.selfDuration, - }; - - // Aggregate into each overlapping bucket - for (let b = firstBucket; b <= lastBucket; b++) { - // Update max depth - if (rect.depth > maxDepths[b]!) { - maxDepths[b] = rect.depth; - } - - // Increment event count - eventCounts[b]!++; - - // Calculate overlap ratio for proportional self-duration attribution (for sparkline) - const bucketStart = b * bucketTimeWidth; - const bucketEnd = (b + 1) * bucketTimeWidth; - const overlapStart = Math.max(rect.timeStart, bucketStart); - const overlapEnd = Math.min(rect.timeEnd, bucketEnd); - const visibleTime = overlapEnd - overlapStart; - const overlapRatio = rectDuration > 0 ? visibleTime / rectDuration : 0; - const proportionalSelfDuration = rect.selfDuration * overlapRatio; - selfDurationSums[b]! += proportionalSelfDuration; - - // Collect frame for skyline computation - framesPerBucket[b]!.push(frame); - } - } - } - - // Build output buckets and find max event count - let maxEventCount = 0; + const bucketTimeWidth = this.totalDuration / bucketCount; + const eventCounts = skyline.countFrames(bucketCount, bucketTimeWidth); const buckets: MinimapDensityBucket[] = new Array(bucketCount); + let segment = 0; for (let i = 0; i < bucketCount; i++) { - const eventCount = eventCounts[i]!; - if (eventCount > maxEventCount) { - maxEventCount = eventCount; - } - const bucketStart = i * bucketTimeWidth; const bucketEnd = (i + 1) * bucketTimeWidth; - // Resolve dominant category using skyline (on-top time) algorithm - const dominantCategory = this.resolveCategoryFromSkyline( - framesPerBucket[i]!, - bucketStart, - bucketEnd, - ); - - buckets[i] = { - timeStart: bucketStart, - timeEnd: bucketEnd, - maxDepth: maxDepths[i]!, - eventCount, - dominantCategory, - selfDurationSum: selfDurationSums[i]!, - }; - } - - return { - buckets, - globalMaxDepth: this.globalMaxDepth, - maxEventCount, - totalDuration: this.totalDuration, - }; - } - - /** - * Compute density data using sliding window algorithm on pre-sorted frames. - * Uses skyline (on-top time) algorithm for category resolution: - * At each moment, the deepest frame is "on top" and its category accumulates time. - * - * Performance improvement over computeDensityFromTree(): - * - Previous: O(B ร— D ร— log N) tree queries per bucket = ~90ms - * - New: O(N) single pass + O(B ร— k) skyline computation = ~10-20ms - * (where k = avg frames per bucket, much smaller than N) - * - * @param bucketCount - Number of output buckets - * @returns MinimapDensityData - */ - private computeDensitySlidingWindow(bucketCount: number): MinimapDensityData { - if (bucketCount <= 0 || this.totalDuration <= 0 || !this.segmentTree) { - return { - buckets: [], - globalMaxDepth: this.globalMaxDepth, - maxEventCount: 0, - totalDuration: this.totalDuration, - }; - } - - const frames = this.segmentTree.getAllFramesSorted(); - const bucketTimeWidth = this.totalDuration / bucketCount; - - // Pre-allocate bucket arrays - const maxDepths = new Uint16Array(bucketCount); - const eventCounts = new Uint32Array(bucketCount); - const selfDurationSums = new Float64Array(bucketCount); - - // Collect frames per bucket for skyline computation - const framesPerBucket: SkylineFrame[][] = new Array(bucketCount); - for (let i = 0; i < bucketCount; i++) { - framesPerBucket[i] = []; - } - - // Single pass: compute maxDepth, eventCount, selfDurationSums, and collect frames - for (const frame of frames) { - const startBucket = Math.max(0, Math.floor(frame.timeStart / bucketTimeWidth)); - const endBucket = Math.min(bucketCount - 1, Math.floor(frame.timeEnd / bucketTimeWidth)); - - // Pre-calculate frame duration for overlap ratio - const frameDuration = frame.timeEnd - frame.timeStart; - - for (let b = startBucket; b <= endBucket; b++) { - // Update maxDepth - if (frame.depth > maxDepths[b]!) { - maxDepths[b] = frame.depth; + // The segments tile, so the segment holding `bucketStart` starts at or before + // it: this is the left-hand partial, with no clamp needed. + let from = bucketStart; + let maxDepth = 0; + let ordered = 0; + + // `from` already holds `segmentStarts[segment]` once the walk has advanced, + // and equals `bucketStart` on entry, which the segments tile at or before. + while (from < bucketEnd && segment < segmentCount) { + const segmentEnd = segmentStarts[segment + 1]!; + const to = segmentEnd < bucketEnd ? segmentEnd : bucketEnd; + const category = segmentCategories[segment]!; + + if (category !== GAP_CATEGORY) { + // First on top wins a tie, which is the order the old sweep's map held. + if (accumulated[category] === 0) { + onTopOrder[ordered++] = category; + } + accumulated[category]! += to - from; + const depth = segmentDepths[segment]!; + if (depth > maxDepth) { + maxDepth = depth; + } } - // Increment event count - eventCounts[b]!++; - - // Calculate overlap ratio for proportional self-duration attribution (for sparkline) - const bucketStart = b * bucketTimeWidth; - const bucketEnd = (b + 1) * bucketTimeWidth; - const overlapStart = Math.max(frame.timeStart, bucketStart); - const overlapEnd = Math.min(frame.timeEnd, bucketEnd); - const visibleTime = overlapEnd - overlapStart; - const overlapRatio = frameDuration > 0 ? visibleTime / frameDuration : 0; - const proportionalSelfDuration = frame.selfDuration * overlapRatio; - selfDurationSums[b]! += proportionalSelfDuration; - - // Collect frame for skyline computation - framesPerBucket[b]!.push(frame); + if (segmentEnd > bucketEnd) { + break; // Straddles the end: the next bucket starts on this same segment. + } + from = segmentEnd; + segment++; } - } - // Build output buckets - const buckets: MinimapDensityBucket[] = new Array(bucketCount); - let maxEventCount = 0; - - for (let i = 0; i < bucketCount; i++) { - const eventCount = eventCounts[i]!; - if (eventCount > maxEventCount) { - maxEventCount = eventCount; + let winner = GAP_CATEGORY; + let best = -1; + for (let at = 0; at < ordered; at++) { + const category = onTopOrder[at]!; + const score = accumulated[category]! * weights[category]!; + if (score > best) { + best = score; + winner = category; + } + accumulated[category] = 0; } - const bucketStart = i * bucketTimeWidth; - const bucketEnd = (i + 1) * bucketTimeWidth; - - // Resolve dominant category using skyline (on-top time) algorithm - const dominantCategory = this.resolveCategoryFromSkyline( - framesPerBucket[i]!, - bucketStart, - bucketEnd, - ); - buckets[i] = { - timeStart: bucketStart, - timeEnd: bucketEnd, - maxDepth: maxDepths[i]!, - eventCount, - dominantCategory, - selfDurationSum: selfDurationSums[i]!, + maxDepth, + eventCount: eventCounts[i]!, + dominantCategory: winner === GAP_CATEGORY ? DEFAULT_CATEGORY : categoryNames[winner]!, }; } - return { - buckets, - globalMaxDepth: this.globalMaxDepth, - maxEventCount, - totalDuration: this.totalDuration, - }; + return { buckets, globalMaxDepth: this.globalMaxDepth }; } - // ============================================================================ - // SKYLINE ALGORITHM: On-Top Time Category Resolution - // ============================================================================ - /** - * Compute dominant category using skyline (on-top time) algorithm. - * - * At each moment within the bucket, the deepest frame is "on top" (visible). - * This correctly handles the case where a parent frame's self-duration is - * concentrated at the edges (parts not covered by children), not evenly - * distributed across the frame's time range. - * - * Algorithm: Sweep-line with depth tracking - * 1. Create enter/exit events for each frame - * 2. Sort events by time - * 3. Sweep through, tracking which frame is deepest at each moment - * 4. Accumulate on-top time per category - * 5. Apply CATEGORY_WEIGHTS to determine winner + * The skyline, built on the first query. * - * PERF: Uses Set for O(1) add/remove instead of indexOf+splice (O(kยฒ) โ†’ O(k)). - * PERF: Tracks max depth incrementally to avoid rescanning on every frame exit. - * - * @param frames - Frames overlapping this bucket - * @param bucketStart - Bucket start time - * @param bucketEnd - Bucket end time - * @returns Dominant category for this bucket + * Not in the constructor: the sort and the sweep are the timeline's largest + * synchronous costs, and a log whose minimap is never drawn should not pay them. */ - private resolveCategoryFromSkyline( - frames: SkylineFrame[], - bucketStart: number, - bucketEnd: number, - ): string { - // Fast path: no frames - if (frames.length === 0) { - return 'Apex'; - } - - // Fast path: single frame - if (frames.length === 1) { - return frames[0]!.category; - } - - // Fast path: all same category - no need to compute skyline - const firstCategory = frames[0]!.category; - let allSameCategory = true; - for (let i = 1; i < frames.length; i++) { - if (frames[i]!.category !== firstCategory) { - allSameCategory = false; - break; - } - } - if (allSameCategory) { - return firstCategory; - } - - // Build sweep-line events - const events: SkylineEvent[] = []; - for (const frame of frames) { - // Clamp frame to bucket bounds - const clampedStart = Math.max(frame.timeStart, bucketStart); - const clampedEnd = Math.min(frame.timeEnd, bucketEnd); - - if (clampedStart < clampedEnd) { - events.push({ time: clampedStart, type: SkylineEventType.Enter, frame }); - events.push({ time: clampedEnd, type: SkylineEventType.Exit, frame }); - } - } - - // Sort events by time, exits before enters at same time - events.sort((a, b) => { - if (a.time !== b.time) { - return a.time - b.time; - } - // Process exits before enters at the same time - return a.type - b.type; - }); - - // Sweep through events tracking on-top time per category - // PERF: Use Set for O(1) add/remove instead of array indexOf+splice - const onTopTime = new Map(); - const activeFrames = new Set(); - let currentMaxDepth = -1; - let currentDeepestFrame: SkylineFrame | null = null; - let lastTime = bucketStart; - - for (const event of events) { - const currentTime = event.time; - - // Accumulate on-top time for the deepest frame since lastTime - if (currentDeepestFrame && currentTime > lastTime) { - const duration = currentTime - lastTime; - const existing = onTopTime.get(currentDeepestFrame.category) ?? 0; - onTopTime.set(currentDeepestFrame.category, existing + duration); - } - - lastTime = currentTime; - - // Update active frames - if (event.type === SkylineEventType.Enter) { - activeFrames.add(event.frame); - // Update max depth tracking if this frame is deeper - if (event.frame.depth > currentMaxDepth) { - currentMaxDepth = event.frame.depth; - currentDeepestFrame = event.frame; - } - } else { - activeFrames.delete(event.frame); // O(1) instead of O(k) - // Only recompute max if we removed the deepest frame - if (event.frame === currentDeepestFrame) { - currentMaxDepth = -1; - currentDeepestFrame = null; - for (const f of activeFrames) { - if (f.depth > currentMaxDepth) { - currentMaxDepth = f.depth; - currentDeepestFrame = f; - } - } - } - } - } - - // Apply category weights and find winner - let winningCategory = 'Apex'; - let winningScore = -1; - - for (const [category, time] of onTopTime) { - const weight = CATEGORY_WEIGHTS[category as BucketCategoryPriority] ?? 1.0; - const score = time * weight; - if (score > winningScore) { - winningScore = score; - winningCategory = category; - } - } - - return winningCategory; + private ensureSkyline(): MinimapSkylineIndex { + return (this.index ??= new MinimapSkylineIndex(this.frameGroups, this.totalDuration)); } } diff --git a/log-viewer/src/features/timeline/optimised/minimap/MinimapSkylineIndex.ts b/log-viewer/src/features/timeline/optimised/minimap/MinimapSkylineIndex.ts new file mode 100644 index 000000000..725448001 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/minimap/MinimapSkylineIndex.ts @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * MinimapSkylineIndex + * + * The log seen from above: over each stretch of time, which frame is on top and + * how deep it is. The deepest frame at an instant is the one you would see + * looking down on the flame chart, so that frame's category is what should + * colour the minimap there. + * + * Built once per log, and never invalidated. Which frame is on top at a given + * time does not depend on how wide the minimap is, so a resize walks this index + * instead of rebuilding it. + * + * The segments tile the timeline: segment `i` spans + * `[segmentStarts[i], segmentStarts[i + 1])`, and a stretch with no frame + * running is a segment of its own with category id 0. So a walk needs no bounds + * test beyond the segment count, and no segment-end array. + * + * It also keeps every frame's bounds, which is what `countFrames` needs: both are + * the same width-independent, once-per-log projection of the log. + */ + +/** + * What the sweep reads off a frame. `PrecomputedRect` satisfies it, so the minimap + * builds straight from the rectangles rather than from objects made for it. + */ +export interface SkylineFrame { + timeStart: number; + timeEnd: number; + depth: number; + category: string; +} + +/** Category id 0 means no frame is on top, so a zeroed buffer is never wrong. */ +export const GAP_CATEGORY = 0; + +/** Ids are held in a Uint8Array, and id 0 is the gap. */ +const MAX_CATEGORIES = 255; + +/** Depths are held in a Uint16Array. Real logs reach ~30. */ +const MAX_DEPTH = 65535; + +/** What the sweep produces: the segments, and how many of them are real. */ +interface Swept { + starts: Float64Array; + depths: Uint16Array; + categories: Uint8Array; + segments: number; + violations: number; +} + +/** + * Every frame in one array, in the order the sweep needs: by start, then by depth + * so a parent precedes the child that starts with it. Get that order wrong and the + * sweep drops the parent as an overlap, losing a frame that may span the whole log. + * + * A new array of references, so the caller's own arrays are never reordered. Cheap + * because each group arrives time-ordered, so this merges a handful of runs rather + * than sorting from scratch: 12ms for 431k frames, against 43ms for the same frames + * in the conversion's own order. + */ +function ordered(groups: readonly (readonly SkylineFrame[])[]): SkylineFrame[] { + let total = 0; + for (const group of groups) { + total += group.length; + } + + // Pre-sized and filled by index. Growing by push costs 1.6ms and 1.5MB of copies + // at 431k frames, and `push(...group)` overflows the stack on a group that size. + const frames: SkylineFrame[] = new Array(total); + let at = 0; + for (const group of groups) { + for (const frame of group) { + frames[at++] = frame; + } + } + + frames.sort((a, b) => a.timeStart - b.timeStart || a.depth - b.depth); + return frames; +} + +export class MinimapSkylineIndex { + /** Segment boundaries, ascending. Length `segmentCount + 1`. */ + public readonly segmentStarts: Float64Array; + + /** Depth of the frame on top of each segment, 0 in a gap. */ + public readonly segmentDepths: Uint16Array; + + /** Category id of the frame on top of each segment, 0 in a gap. */ + public readonly segmentCategories: Uint8Array; + + /** Category name per id, appended to as the sweep meets them. Index 0 is the gap. */ + public readonly categoryNames: string[] = ['']; + + /** + * Frames the sweep contained or dropped: one that outlived its parent, one + * that overlapped a frame at the same or a shallower depth, or one with no + * duration. Zero on a well-formed log; asserted by the tests, and printed by + * `pnpm measure minimap` as a tripwire on real logs. + */ + public readonly violations: number; + + /** + * Frame bounds as parallel arrays, for `countFrames`. + * + * A copy of numbers the rectangles already hold, so this looks like 6.6MB to + * reclaim. It is not: `countFrames` runs on every width change, and reading the + * scattered rectangles instead measures 10-15ms a call against 1ms, which would + * put a resize step inside the frame budget. + */ + private readonly frameStarts: Float64Array; + private readonly frameEnds: Float64Array; + + private readonly categoryIds = new Map(); + + /** + * @param groups - Frames in groups, each ascending by `timeStart`. + * @param totalDuration - The log's end timestamp, which the last segment reaches. + */ + constructor(groups: readonly (readonly SkylineFrame[])[], totalDuration: number) { + const frames = ordered(groups); + const count = frames.length; + this.frameStarts = new Float64Array(count); + this.frameEnds = new Float64Array(count); + let deepest = 0; + for (let i = 0; i < count; i++) { + const frame = frames[i]!; + this.frameStarts[i] = frame.timeStart; + this.frameEnds[i] = frame.timeEnd; + if (frame.depth > deepest) { + deepest = frame.depth; + } + } + + // Depth strictly increases up the stack, so it can hold no more entries than + // there are depths. Taken from the frames rather than passed in, so the sweep + // cannot overflow on a caller's stale figure. + const swept = this.sweep(frames, totalDuration, Math.min(deepest, MAX_DEPTH) + 2); + this.violations = swept.violations; + + this.segmentStarts = swept.starts.subarray(0, swept.segments + 1); + this.segmentDepths = swept.depths.subarray(0, swept.segments); + this.segmentCategories = swept.categories.subarray(0, swept.segments); + } + + /** Number of segments. `segmentStarts` holds one more, to close the last. */ + public get segmentCount(): number { + return this.segmentDepths.length; + } + + /** + * Count the frames overlapping each bucket, by difference array. + * + * A frame adds one to the bucket it starts in and takes one back after the + * bucket it ends in, so a running total gives every bucket its count in one + * pass. + * + * A frame ending exactly on a boundary counts in the bucket after it, as the + * per-bucket collect this replaced did. The segment walk does not, so such a + * bucket reports a count at depth 0 and draws no bar. + */ + public countFrames(bucketCount: number, bucketTimeWidth: number): Uint32Array { + const { frameStarts, frameEnds } = this; + const deltas = new Int32Array(bucketCount + 1); + const lastBucket = bucketCount - 1; + + for (let i = 0; i < frameStarts.length; i++) { + let first = Math.floor(frameStarts[i]! / bucketTimeWidth); + if (first < 0) { + first = 0; + } + let last = Math.floor(frameEnds[i]! / bucketTimeWidth); + if (last > lastBucket) { + last = lastBucket; + } + if (last < first) { + continue; + } + deltas[first]!++; + deltas[last + 1]!--; + } + + const counts = new Uint32Array(bucketCount); + let running = 0; + for (let b = 0; b < bucketCount; b++) { + running += deltas[b]!; + counts[b] = running; + } + return counts; + } + + /** + * Sweep the frames, emitting one segment per stretch with the same frame on top. + * + * Frames nest, so the deepest active frame is always the one pushed last: the + * sweep drains every frame that has ended before pushing the next, which is + * what makes a frame's own end instant belong to whatever follows it. + * + * @param capacity - Stack depth the frames can reach. + */ + private sweep(frames: readonly SkylineFrame[], totalDuration: number, capacity: number): Swept { + // At most two segments a frame - the stretch before it, and its own end - plus + // the trailing gap and the closer. Swept once into that bound and trimmed to a + // view, rather than swept twice to size exactly: measured slack over four real + // logs is 14 to 2,709 bytes, because a frame emits exactly two segments unless + // a same-category sibling at its own depth abuts it to the nanosecond. Copying + // into exact arrays would peak 9MB higher to reclaim that. + const bound = 2 * frames.length + 2; + const starts = new Float64Array(bound); + const depths = new Uint16Array(bound); + const categories = new Uint8Array(bound); + + const stackEnd = new Float64Array(capacity); + const stackDepth = new Uint16Array(capacity); + const stackCategory = new Uint8Array(capacity); + + let stackTop = 0; + let segments = 0; + let violations = 0; + let emittedTo = 0; + let lastDepth = -1; + let lastCategory = -1; + + const emit = (to: number, depth: number, category: number): void => { + if (to <= emittedTo) { + return; + } + // A run with the same frame on top is one segment, however many frames + // started and ended alongside it. + if (depth === lastDepth && category === lastCategory) { + emittedTo = to; + return; + } + starts[segments] = emittedTo; + depths[segments] = depth; + categories[segments] = category; + segments++; + emittedTo = to; + lastDepth = depth; + lastCategory = category; + }; + + /** Retire what has ended by `until`. `<=` because a frame is not on top at its own end. */ + const drainTo = (until: number): void => { + while (stackTop > 0 && stackEnd[stackTop - 1]! <= until) { + const at = stackTop - 1; + emit(stackEnd[at]!, stackDepth[at]!, stackCategory[at]!); + stackTop--; + } + }; + + for (const frame of frames) { + const start = frame.timeStart; + // Clamped here, not on the store: the stack's strict-increase invariant and + // its capacity are both in clamped units, so comparing raw depths could push + // two equal-after-clamp frames and run past the end. + const depth = frame.depth > MAX_DEPTH ? MAX_DEPTH : frame.depth; + drainTo(start); + + // The stretch before this frame belongs to whatever encloses it, or to a gap. + if (start > emittedTo) { + const enclosing = stackTop - 1; + emit( + start, + stackTop > 0 ? stackDepth[enclosing]! : 0, + stackTop > 0 ? stackCategory[enclosing]! : GAP_CATEGORY, + ); + } + + if (frame.timeEnd <= start) { + violations++; + continue; + } + + // A frame no deeper than the one it overlaps cannot be on top of it. Today's + // sweep gives that frame the same outcome, by keeping the deepest. + if (stackTop > 0 && depth <= stackDepth[stackTop - 1]!) { + violations++; + continue; + } + + // Contain the frame in whatever it sits inside, so `stackEnd` stays + // non-increasing upwards and the stack keeps its order. + let end = frame.timeEnd; + if (stackTop > 0 && end > stackEnd[stackTop - 1]!) { + end = stackEnd[stackTop - 1]!; + violations++; + } + + stackEnd[stackTop] = end; + stackDepth[stackTop] = depth; + stackCategory[stackTop] = this.idFor(frame.category); + stackTop++; + } + + drainTo(Infinity); + + // The stretch after the last frame is a gap of its own. Closing the last real + // segment on it instead would credit that time to the frame's category. + emit(totalDuration, 0, GAP_CATEGORY); + + starts[segments] = emittedTo; + return { starts, depths, categories, segments, violations }; + } + + /** + * Intern a category name. Categories come from log events, so the set is open; + * a log with more than 255 of them shares the last id, which only costs colour. + */ + private idFor(category: string): number { + const known = this.categoryIds.get(category); + if (known !== undefined) { + return known; + } + if (this.categoryNames.length > MAX_CATEGORIES) { + return MAX_CATEGORIES; + } + const id = this.categoryNames.length; + this.categoryNames.push(category); + this.categoryIds.set(category, id); + return id; + } +} diff --git a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts index 462b18ec2..7b568a1a6 100644 --- a/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/orchestrators/MinimapOrchestrator.ts @@ -180,7 +180,7 @@ export class MinimapOrchestrator { * @param width - Canvas width * @param height - Full container height (minimap height calculated from this) * @param index - Timeline event index for duration/depth info - * @param rectangleManager - For density query and segment tree + * @param rectangleManager - Supplies the rectangles the density query walks * @param viewport - Main timeline viewport (for reading state only) */ public async init( @@ -217,12 +217,10 @@ export class MinimapOrchestrator { // Initialize minimap manager (state and coordinate transforms) this.minimapViewport = new MinimapViewport(index.totalDuration, index.maxDepth, width, height); - // Initialize density query (leverages segment tree for O(B x log N) performance) this.densityQuery = new MinimapDensityQuery( - rectangleManager.getRectsByCategory(), + [...rectangleManager.getRectsByCategory().values()], index.totalDuration, index.maxDepth, - rectangleManager.getSegmentTree(), ); // Create minimap container on stage @@ -284,9 +282,7 @@ export class MinimapOrchestrator { // No density invalidation here: it is keyed by width (see MinimapDensityQuery). - if (this.renderer) { - this.renderer.invalidateStatic(); - } + this.invalidateStatic(); } // ============================================================================ @@ -334,11 +330,12 @@ export class MinimapOrchestrator { } /** - * Invalidate the density cache. - * Call when timeline data changes. + * Redraw the static content after a theme change. + * + * The density is not touched: it carries category names, and the renderer resolves a + * colour from them at draw time. */ - public invalidateCache(): void { - this.densityQuery?.invalidateCache(); + public invalidateStatic(): void { this.renderer?.invalidateStatic(); } diff --git a/package.json b/package.json index 7b96c20e9..3098fc0b5 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "watch": "rm -rf lana/out && rollup -w -c rollup.config.mjs", "watch:fast": "rolldown -w -c rolldown.config.ts", "copy:package-docs": "node ./scripts/copy-package-docs.mjs", - "typecheck": "tsc -b", + "typecheck": "tsc -b && tsc -p scripts/tsconfig.json", "typecheck:tsc6": "tsc6 -b", "measure": "rolldown -c scripts/measure/rolldown.config.ts && node --expose-gc --max-old-space-size=12288 scripts/measure/out/measure.mjs", "lint": "concurrently -r -g 'eslint . --cache --cache-location node_modules/.cache/eslint/' 'prettier --cache **/*.{ts,css,md,mdx,scss} --check --experimental-cli' 'pnpm run typecheck'", diff --git a/scripts/measure/call-tree.ts b/scripts/measure/call-tree.ts new file mode 100644 index 000000000..0780a00eb --- /dev/null +++ b/scripts/measure/call-tree.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Times the call tree builds and the inspector's row mark. + */ +import type { ApexLog } from 'apex-log-parser'; + +import { LocatedRowIds } from '../../log-viewer/src/components/locatedRow.js'; +import { + buildWholeLogCallTree, + rowIdsByPath, + type ScopedRow, +} from '../../log-viewer/src/components/scopedCallTree.js'; +import { LogStore, setCurrentLog } from '../../log-viewer/src/core/log/LogStore.js'; +import { + toAggregatedCallTree, + toBottomUpTree, +} from '../../log-viewer/src/features/call-tree/utils/Aggregation.js'; +import { line, time } from './harness.js'; + +// The builds slice themselves against this; resolving at once measures the work +// rather than the frames it would hand back on screen. +const yieldSlice = () => Promise.resolve(); + +interface Shape { + nodes: number; + /** Event indexes the rows store between them: what a row per occurrence costs. + * A row that derives its own stores none. */ + indexes: number; + fattest: ScopedRow | null; +} + +function shapeOf(rows: readonly ScopedRow[]): Shape { + const shape: Shape = { nodes: 0, indexes: 0, fattest: null }; + const stack = [...rows]; + while (stack.length) { + const row = stack.pop()!; + shape.nodes += 1; + const held = row.eventIndexes?.length ?? 0; + shape.indexes += held; + if (held > (shape.fattest?.eventIndexes?.length ?? 0)) { + shape.fattest = row; + } + if (row._children) { + for (const child of row._children) { + stack.push(child); + } + } + } + return shape; +} + +export async function measureCallTree(log: ApexLog): Promise { + setCurrentLog(log); + + const scoped = await time('buildWholeLogCallTree', () => buildWholeLogCallTree({ yieldSlice })); + if (!scoped) { + throw new Error('nothing in scope'); + } + + const bottomUp = (await time('inspector bottomUp() rows', () => + scoped.bottomUp({ yieldSlice }), + ))!; + const aggregated = (await time('inspector aggregated() rows', () => + scoped.aggregated({ yieldSlice }), + ))!; + const timeOrder = (await time('inspector timeOrder() rows', () => + scoped.timeOrder({ yieldSlice }), + ))!; + + const shape = shapeOf(bottomUp); + const counts = ({ nodes, indexes }: Shape) => `${nodes} nodes, ${indexes} indexes held`; + line('bottom-up', counts(shape)); + line('aggregated', counts(shapeOf(aggregated))); + line('time-order', `${counts(shapeOf(timeOrder))}\n`); + + const byPathBottomUp = await time('rowIdsByPath bottom-up', () => rowIdsByPath(bottomUp)); + const byPathAggregated = await time('rowIdsByPath aggregated', () => rowIdsByPath(aggregated)); + console.log(`map entries: bottom-up ${byPathBottomUp.size}, aggregated ${byPathAggregated.size}`); + + // The mark, on the worst row there is: the frames a picked bucket counts, + // translated into the ids of every row they name. + const picked = shape.fattest?.eventIndexes ?? []; + console.log(`\nfattest row: ${picked.length} occurrences of "${shape.fattest?.text ?? ''}"`); + const ids = new LocatedRowIds(); + await time('mark callers (first)', () => ids.idsFor(log, picked, 'callers')); + await time('mark callers (same pick)', () => ids.idsFor(log, picked, 'callers')); + await time('mark callees', () => new LocatedRowIds().idsFor(log, picked, 'callees')); + + // The Call Tree tab's own grouped builds, for comparison with the inspector's. + console.log(''); + // A store of its own, so the inspector's builds above have not warmed the key + // table. Only the first build below is cold; the second reads what the first + // interned, as it does on screen where every view shares one table. + const gridPaths = new LogStore(log).keyPathIds(); + await time('grid toAggregatedCallTree', () => toAggregatedCallTree(log.children, gridPaths)); + await time('grid toBottomUpTree', () => toBottomUpTree(log.children, gridPaths)); +} diff --git a/scripts/measure/harness.ts b/scripts/measure/harness.ts new file mode 100644 index 000000000..e67fbcc48 --- /dev/null +++ b/scripts/measure/harness.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Timing and heap helpers shared by the measure scripts. + * + * Heap figures need `--expose-gc`, which the `measure` scripts pass. + */ + +const gc = globalThis.gc as (() => void) | undefined; + +/** Width of the label column, so every reported line aligns into one table. */ +const LABEL_WIDTH = 32; + +/** Width of the millisecond column, shared by `report` and `ms`. */ +const MS_WIDTH = 7; + +if (!gc) { + console.warn('no --expose-gc: heap figures include uncollected litter\n'); +} + +/** Heap after a collection, so a figure is what the step retained, not its litter. */ +export function heapMb(): number { + gc?.(); + return Math.round(retainedMb()); +} + +/** + * Heap plus typed-array bytes, without collecting. + * + * `heapUsed` alone misses every typed array: their backing stores are counted in + * `arrayBuffers`. The minimap's skyline is 15.6MB of typed arrays, so leaving them + * out reports its cost as zero. + */ +function retainedMb(): number { + const usage = process.memoryUsage(); + return (usage.heapUsed + usage.arrayBuffers) / 1048576; +} + +/** Fractional milliseconds: a density query lands under 10ms, a cached one under 0.1ms. */ +export const nowMs = (): number => Number(process.hrtime.bigint()) / 1e6; + +/** Two decimals, in `report`'s number column: a cached query lands under 0.1ms. */ +export const ms = (value: number): string => value.toFixed(2).padStart(MS_WIDTH); + +/** One reported line, in the same label column as `report`. */ +export function line(label: string, text: string): void { + console.log(`${label.padEnd(LABEL_WIDTH)} ${text}`); +} + +export function report(label: string, milliseconds: number, before: number): void { + const rounded = String(Math.round(milliseconds)).padStart(MS_WIDTH); + line(label, `${rounded}ms heap ${before} -> ${heapMb()}MB`); +} + +/** + * Time a step, and report what it retained. + * + * The `after` figure collects, so it is retention rather than litter - which means + * it lands just before the next step and costs that step ~26% in first-touch. So + * compare a line against the same line on another branch, never against a budget. + */ +export async function time(label: string, body: () => T | Promise): Promise { + const before = Math.round(retainedMb()); + const start = nowMs(); + const out = await body(); + report(label, nowMs() - start, before); + return out; +} + +/** Report a usage error the way every other bad input is reported, and stop. */ +export function die(message: string): never { + console.error(message); + process.exit(1); +} diff --git a/scripts/measure/measure.ts b/scripts/measure/measure.ts index f15e32033..3d0dc3da2 100644 --- a/scripts/measure/measure.ts +++ b/scripts/measure/measure.ts @@ -3,134 +3,97 @@ */ /** - * Times the call tree builds and the inspector's row mark on a real log. + * Times the paths that have to hold up on a large log. * - * The whole path is free of the DOM, so it runs under Node: a browser cannot - * profile a 100MB log without its own parse blocking the tools. + * They are free of the DOM and of PixiJS, so they run under Node: a browser + * cannot profile a 100MB log without its own parse blocking the tools. * - * pnpm measure + * pnpm measure every area, on the committed sample log + * pnpm measure minimap one area + * pnpm measure --log another log + * pnpm measure minimap --digest CSV of what the minimap would draw * - * Heap figures need `--expose-gc`, which the `measure` script passes. No log is - * committed: the path is always an argument. + * One area per feature that has a performance budget, and one number each for + * what a user waits on. Heap figures need `--expose-gc`, which the `measure` + * script passes. */ import { readFileSync } from 'node:fs'; +import { parseArgs } from 'node:util'; -import { parse } from 'apex-log-parser'; +import { type ApexLog, parse } from 'apex-log-parser'; -import { LocatedRowIds } from '../../log-viewer/src/components/locatedRow.js'; -import { - buildWholeLogCallTree, - rowIdsByPath, - type ScopedRow, -} from '../../log-viewer/src/components/scopedCallTree.js'; -import { LogStore, setCurrentLog } from '../../log-viewer/src/core/log/LogStore.js'; -import { - toAggregatedCallTree, - toBottomUpTree, -} from '../../log-viewer/src/features/call-tree/utils/Aggregation.js'; +import { measureCallTree } from './call-tree.js'; +import { die, time } from './harness.js'; +import { digestMinimap, measureMinimap } from './minimap.js'; -const gc = globalThis.gc as (() => void) | undefined; +/** The one log every measurement runs over, so the numbers compare across branches. */ +const SAMPLE_LOG = 'sample-app/debug-logs/sample-log.log'; -/** Heap after a collection, so a figure is what the step retained, not its litter. */ -function heapMb(): number { - gc?.(); - return Math.round(process.memoryUsage().heapUsed / 1048576); -} - -const now = (): number => Number(process.hrtime.bigint() / 1000000n); -// The builds slice themselves against this; resolving at once measures the work -// rather than the frames it would hand back on screen. -const yieldSlice = () => Promise.resolve(); +const USAGE = 'usage: pnpm measure [area...] [--log ] [--digest]'; -function report(label: string, ms: number, before: number): void { - console.log(`${label.padEnd(32)} ${String(ms).padStart(7)}ms heap ${before} -> ${heapMb()}MB`); +interface Area { + run(log: ApexLog): Promise; + /** Prints a CSV of what the area would draw. Absent where timings are all there is. */ + digest?(log: ApexLog): void; } -async function time(label: string, body: () => T | Promise): Promise { - const before = heapMb(); - const start = now(); - const out = await body(); - report(label, now() - start, before); - return out; -} +const AREAS: Record = { + 'call-tree': { run: measureCallTree }, + minimap: { run: measureMinimap, digest: digestMinimap }, +}; + +const args = (() => { + try { + return parseArgs({ + args: process.argv.slice(2), + options: { log: { type: 'string' }, digest: { type: 'boolean', default: false } }, + allowPositionals: true, + }); + } catch (error) { + die(`${(error as Error).message}\n${USAGE}`); + } +})(); -interface Shape { - nodes: number; - /** Event indexes the rows store between them: what a row per occurrence costs. - * A row that derives its own stores none. */ - indexes: number; - fattest: ScopedRow | null; -} +const digest = args.values.digest === true; +const logPath = args.values.log ?? SAMPLE_LOG; + +// Named areas, or every area that can answer. A digest of timings would corrupt the CSV. +const names = args.positionals.length + ? args.positionals + : Object.keys(AREAS).filter((name) => !digest || AREAS[name]!.digest); -function shapeOf(rows: readonly ScopedRow[]): Shape { - const shape: Shape = { nodes: 0, indexes: 0, fattest: null }; - const stack = [...rows]; - while (stack.length) { - const row = stack.pop()!; - shape.nodes += 1; - const held = row.eventIndexes?.length ?? 0; - shape.indexes += held; - if (held > (shape.fattest?.eventIndexes?.length ?? 0)) { - shape.fattest = row; - } - if (row._children) { - for (const child of row._children) { - stack.push(child); - } - } +for (const name of names) { + const area = AREAS[name]; + if (!area) { + die(`unknown area "${name}". Known: ${Object.keys(AREAS).join(', ')}\n${USAGE}`); + } + if (digest && !area.digest) { + die(`no digest for: ${name}\n${USAGE}`); } - return shape; } -const logPath = process.argv[2]; -if (!logPath) { - console.error('usage: pnpm measure '); - process.exit(1); +// Each digest prints its own CSV header, so two into one stdout is not a CSV. +if (digest && names.length > 1) { + die(`name one area to digest: ${names.join(', ')}\n${USAGE}`); } -const text = await time('read file', () => readFileSync(logPath, 'utf8')); -console.log(`log ${Math.round(text.length / 1048576)}MB, ${text.split('\n').length} lines\n`); - -const log = await time('parse', () => parse(text)); -setCurrentLog(log); - -const scoped = await time('buildWholeLogCallTree', () => buildWholeLogCallTree({ yieldSlice })); -if (!scoped) { - throw new Error('nothing in scope'); +let text: string; +try { + text = readFileSync(logPath, 'utf8'); +} catch (error) { + die(`${(error as Error).message}\n${USAGE}`); } +if (!digest) { + console.log(`log ${Math.round(text.length / 1048576)}MB, ${text.split('\n').length} lines\n`); +} +const log = digest ? parse(text) : await time('parse', () => parse(text)); -const bottomUp = (await time('inspector bottomUp() rows', () => scoped.bottomUp({ yieldSlice })))!; -const aggregated = (await time('inspector aggregated() rows', () => - scoped.aggregated({ yieldSlice }), -))!; -const timeOrder = (await time('inspector timeOrder() rows', () => - scoped.timeOrder({ yieldSlice }), -))!; - -const shape = shapeOf(bottomUp); -const counts = ({ nodes, indexes }: Shape) => `${nodes} nodes, ${indexes} indexes held`; -console.log(`bottom-up ${counts(shape)}`); -console.log(`aggregated ${counts(shapeOf(aggregated))}`); -console.log(`time-order ${counts(shapeOf(timeOrder))}\n`); - -const byPathBottomUp = await time('rowIdsByPath bottom-up', () => rowIdsByPath(bottomUp)); -const byPathAggregated = await time('rowIdsByPath aggregated', () => rowIdsByPath(aggregated)); -console.log(`map entries: bottom-up ${byPathBottomUp.size}, aggregated ${byPathAggregated.size}`); - -// The mark, on the worst row there is: the frames a picked bucket counts, -// translated into the ids of every row they name. -const picked = shape.fattest?.eventIndexes ?? []; -console.log(`\nfattest row: ${picked.length} occurrences of "${shape.fattest?.text ?? ''}"`); -const ids = new LocatedRowIds(); -await time('mark callers (first)', () => ids.idsFor(log, picked, 'callers')); -await time('mark callers (same pick)', () => ids.idsFor(log, picked, 'callers')); -await time('mark callees', () => new LocatedRowIds().idsFor(log, picked, 'callees')); - -// The Call Tree tab's own grouped builds, for comparison with the inspector's. -console.log(''); -// A store of its own, so the inspector's builds above have not warmed the key -// table. Only the first build below is cold; the second reads what the first -// interned, as it does on screen where every view shares one table. -const gridPaths = new LogStore(log).keyPathIds(); -await time('grid toAggregatedCallTree', () => toAggregatedCallTree(log.children, gridPaths)); -await time('grid toBottomUpTree', () => toBottomUpTree(log.children, gridPaths)); +for (const name of names) { + const area = AREAS[name]!; + if (digest && area.digest) { + area.digest(log); + continue; + } + console.log(`\n--- ${name} ---`); + await area.run(log); +} diff --git a/scripts/measure/minimap.ts b/scripts/measure/minimap.ts new file mode 100644 index 000000000..04104c07b --- /dev/null +++ b/scripts/measure/minimap.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Times the minimap density query. + * + * The minimap holds one bucket per pixel of its width, so every step of a width + * drag is a fresh bucket count and a guaranteed cache miss. The drag sweep is + * the number a resize actually pays. + */ +import type { ApexLog } from 'apex-log-parser'; + +import { MinimapDensityQuery } from '../../log-viewer/src/features/timeline/optimised/minimap/MinimapDensityQuery.js'; +import { RectangleCache } from '../../log-viewer/src/features/timeline/optimised/RectangleCache.js'; +import { BUCKET_CONSTANTS } from '../../log-viewer/src/features/timeline/types/flamechart.types.js'; +import { logEventToTreeAndRects } from '../../log-viewer/src/features/timeline/utils/tree-converter.js'; +import { heapMb, line, ms, nowMs, time } from './harness.js'; + +/** The widths the digest samples: odd and even, and either side of a 1536px panel. */ +const DIGEST_WIDTHS = [97, 800, 1000, 1536, 1601]; + +/** One drag across 200px of panel edge, every intermediate width a cache miss. */ +const DRAG_FROM = 1200; +const DRAG_TO = 1400; + +/** Outside the drag, so the cold query does not leave the first drag width cached. */ +const COLD_WIDTH = 1000; + +/** + * Also outside the drag. `heapMb` collects, and the first query after a full + * collection costs ~65ms against a steady ~4ms, so without this the drag's worst + * step is always step 0 and reports the collection rather than the query. + */ +const WARM_WIDTH = 1100; + +interface Subject { + query: MinimapDensityQuery; + frames: number; + maxDepth: number; +} + +/** One query per run: the one width it caches makes every other width a miss anyway. */ +function build(log: ApexLog): Subject { + const categories = new Set(BUCKET_CONSTANTS.CATEGORY_PRIORITY); + const precomputed = logEventToTreeAndRects(log.children, categories, log.exitStamp); + const cache = new RectangleCache(log.children, categories, precomputed); + + return { + query: new MinimapDensityQuery( + [...cache.getRectsByCategory().values()], + precomputed.totalDuration, + precomputed.maxDepth, + ), + // One entry per rect the conversion made, so this is the sweep's own input. + frames: precomputed.rectMap.size, + maxDepth: precomputed.maxDepth, + }; +} + +/** A CSV row per bucket, so two revisions can be diffed to show the picture held. */ +export function digestMinimap(log: ApexLog): void { + const { query } = build(log); + console.log('width,bucket,eventCount,maxDepth,dominantCategory'); + for (const width of DIGEST_WIDTHS) { + const { buckets } = query.query(width); + for (let b = 0; b < buckets.length; b++) { + const bucket = buckets[b]!; + console.log( + `${width},${b},${bucket.eventCount},${bucket.maxDepth},${bucket.dominantCategory}`, + ); + } + } +} + +export async function measureMinimap(log: ApexLog): Promise { + // Timed: the rectangles and the tree are built during timeline init, so this and + // the cold query below are one bill the user pays before the first frame appears. + const { query, frames, maxDepth } = await time('build rects + tree', () => build(log)); + console.log(`${frames} frames, maxDepth ${maxDepth}`); + + // The first query builds the skyline, so its own line carries the build's cost. + await time(`cold query(${COLD_WIDTH})`, () => query.query(COLD_WIDTH)); + const stats = query.stats(); + const perFrame = (stats.segmentCount / frames).toFixed(2); + console.log( + ` ${stats.segmentCount} segments (${perFrame}/frame), ${stats.violations} violations`, + ); + + query.query(WARM_WIDTH); + const drag: number[] = []; + for (let width = DRAG_FROM; width <= DRAG_TO; width++) { + const start = nowMs(); + query.query(width); + drag.push(nowMs() - start); + } + const total = drag.reduce((sum, each) => sum + each, 0); + const sorted = [...drag].sort((a, b) => a - b); + const median = sorted[sorted.length >> 1]!; + const worst = sorted[sorted.length - 1]!; + line( + `drag ${DRAG_FROM}->${DRAG_TO}px`, + `median ${ms(median)}ms worst ${ms(worst)}ms ${Math.round(total)}ms total`, + ); + + // The cache holds one width, and the drag left a different one, so warm it before + // timing: otherwise the first call is a miss and its recompute averages into all 100. + query.query(DRAG_TO); + const hitStart = nowMs(); + for (let i = 0; i < 100; i++) { + query.query(DRAG_TO); + } + line('cached, same width', `${ms((nowMs() - hitStart) / 100)}ms`); + line('heap held', `${heapMb()}MB`); +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..5799f4b07 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../log-viewer/tsconfig.json", + "compilerOptions": { + "lib": ["ES2023", "dom", "DOM.Iterable"], + "types": ["node"], + "composite": false, + "declarationMap": false, + "noEmit": true, + "paths": { + "apex-log-parser": ["../apex-log-parser/src/index.ts"] + } + }, + "include": ["measure/**/*.ts"] +} From aa3e77cd5b4d39c72efab5019b5d7769a5b8d74d Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:59:50 +0100 Subject: [PATCH 35/61] feat(log-viewer): show the variables in scope at the selected frame (#1006) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview The inspector could say what a frame cost, not what it was working on. A log captured with Apex Code at **FINEST** records every variable write, and nothing read them. The **Variables** section now lists what Apex could reach from the frame you select: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, grouped by class. Every value reads as it stood at the frame, so an earlier frame shows the value it saw and not a later one. ## ๐Ÿ› ๏ธ Changes made - **Variables section** in the inspector on the Timeline, Call Tree, Analysis and Database tabs. A statement owns no variables of its own, so it answers from the Apex frame that ran it. - **Values as the log wrote them.** An object opens one level, which is all the log records; its properties are rows. A name the log declared but never wrote reads `not assigned`. A value the log wrote as an address reads as the object at that address, or `no value recorded` where the log never wrote one. - **A `this.field` line reports the object the field belongs to**, not the value, so its address names an owner and never contributes a value. `this` is shared between frames of one class only for the same instance. - **Only a construction may name an object's class**, so a superclass constructor cannot overwrite the concrete class of the object it runs on. - **One index per log** serves the whole section: tens of ms to build on a large log, and a couple of ms for the worst frame snapshot, with no measurable heap cost. It is built once, off the first render, and a frame answers before it exists. - **Keyboard**: the section is a tree โ€” arrows walk and open, `*` opens a level, and a note row is read rather than focused. ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [x] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ”— Related Issues resolves #373 ## โœ… Tests added? - [x] ๐Ÿ‘ yes `log-viewer`: 1782 tests, 143 suites, all passing. New suites cover the line reader, the value scanner, the frame scope and index, the row builder and the component, including a regression test for each rule above. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ”– CHANGELOG.md - [x] ๐Ÿ“– help site ## Anything else we need to know? [optional] Needs the log captured with Apex Code at **FINEST**; the section says so when a log was captured lower. --- CHANGELOG.md | 1 + lana-docs/docs/docs/features/inspector.md | 4 +- log-viewer/src/components/VariablesDetail.ts | 679 +++++++++++++++ .../__tests__/VariablesDetail.test.ts | 676 +++++++++++++++ .../__tests__/detailSections.test.ts | 14 +- .../components/__tests__/variableTree.test.ts | 248 ++++++ log-viewer/src/components/detailSections.ts | 12 + log-viewer/src/components/variableTree.ts | 301 +++++++ .../core/log/__tests__/frameVariables.test.ts | 728 ++++++++++++++++ .../core/log/__tests__/variableLine.test.ts | 189 +++++ .../core/log/__tests__/variableValue.test.ts | 272 ++++++ log-viewer/src/core/log/frameVariables.ts | 797 ++++++++++++++++++ log-viewer/src/core/log/variableLine.ts | 213 +++++ log-viewer/src/core/log/variableValue.ts | 272 ++++++ .../__tests__/databaseSections.test.ts | 12 +- .../database/components/databaseSections.ts | 9 + scripts/measure/measure.ts | 2 + scripts/measure/variables.ts | 62 ++ 18 files changed, 4484 insertions(+), 7 deletions(-) create mode 100644 log-viewer/src/components/VariablesDetail.ts create mode 100644 log-viewer/src/components/__tests__/VariablesDetail.test.ts create mode 100644 log-viewer/src/components/__tests__/variableTree.test.ts create mode 100644 log-viewer/src/components/variableTree.ts create mode 100644 log-viewer/src/core/log/__tests__/frameVariables.test.ts create mode 100644 log-viewer/src/core/log/__tests__/variableLine.test.ts create mode 100644 log-viewer/src/core/log/__tests__/variableValue.test.ts create mode 100644 log-viewer/src/core/log/frameVariables.ts create mode 100644 log-viewer/src/core/log/variableLine.ts create mode 100644 log-viewer/src/core/log/variableValue.ts create mode 100644 scripts/measure/variables.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 85a8d4538..920e549d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Timeline governor strip plots heap as it's allocated, so you can see where it spikes. - ๐Ÿงญ **Inspector**: select anything โ€” a timeline frame, a call tree or analysis row, a SOQL/DML/SOSL statement โ€” and inspect it without leaving the tab you're on. ([#113]) - **A selection** shows its details and governor metrics as `used / limit`, the call stack that led to it, and its own subtree in **Time Order**, **Aggregated** or **Bottom-Up**. Click a frame in the call stack to walk up it โ€” the details and subtree follow, and the stack stays anchored to what you selected. On the Timeline it also splits the self time under the selection by the namespace whose code ran it. + - **Variables** in scope at the frame you selected: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, each value as it stood at the frame. Needs Apex Code at **FINEST**. ([#373]) - **Nothing selected** shows the whole log instead of an empty panel: a governor overview on every tab, time by category, self time by namespace and governor trends on the Timeline, log-wide findings and how per-call self time spreads on Analysis, the hot path and hot spots on the Call Tree, and, on Database, which namespaces asked for and burned the database time, how few statements hold the time, and every call path that ends in a query, DML or search with total and self time. ([#373]) - Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view โ€” hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Click a point on a governor usage chart to move the Timeline to that instant and zoom in on it. Right-click for copy actions. - **Findings** list the statements behind them, most repeated first with how often each ran, and report one query built per record and run a row at a time. The severities head the list and filter it, any number at once, a finding the log times shows how long it took and what that is of the log, and selecting an Analysis row narrows the list to the findings that name that method or anything it called. diff --git a/lana-docs/docs/docs/features/inspector.md b/lana-docs/docs/docs/features/inspector.md index 471e8e13a..7136fcb10 100644 --- a/lana-docs/docs/docs/features/inspector.md +++ b/lana-docs/docs/docs/features/inspector.md @@ -1,12 +1,13 @@ --- id: inspector title: Inspector -description: Inspect any selected Salesforce Apex log frame or statement in a dockable side bar - vitals, governor metrics, call stack, a scoped call tree and SOQL optimization tips, available from the Timeline, Call Tree, Analysis and Database tabs. +description: Inspect any selected Salesforce Apex log frame or statement in a dockable side bar - vitals, governor metrics, variables in scope, call stack, a scoped call tree and SOQL optimization tips, available from the Timeline, Call Tree, Analysis and Database tabs. keywords: [ apex log detail panel, salesforce debug log inspector, apex call stack viewer, + apex variables in scope, scoped call tree, soql optimization tips, apex log analyzer, @@ -25,6 +26,7 @@ It docks to the **right**, **left** or **bottom**, resizes by dragging its edge, ### Sections - **Details** โ€“ timing, plus every governor metric the selection consumed as `used / limit`. For SOQL also selectivity, query plan and cardinality, with the query text highlighted and copyable. +- **Variables** โ€“ what Apex could reach from the frame: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, grouped by class. Every value reads as it stood at the frame, and a name the log declared but never wrote reads `not assigned`. An object opens one level, which is all the log records; where the log wrote an address instead of a value, the object at that address is shown, or `no value recorded` where the log never wrote one. A statement owns no variables of its own, so it answers from the Apex frame that ran it. Needs the log captured with Apex Code at **FINEST**. - **Self time by namespace** โ€“ Timeline only: the self time under the selection split by the namespace whose code ran it, so you can see whose package burned it. Every namespace bar colours the six biggest and gathers the rest into one **others** segment, which names them on hover. - **Findings** โ€“ Analysis only: which of the log's findings name the selected method or anything it called, so you can tell whether the row you picked is one of the log's problems. - **Call stack** โ€“ the parent frames that led to the selection, outermost first, with total and self time. diff --git a/log-viewer/src/components/VariablesDetail.ts b/log-viewer/src/components/VariablesDetail.ts new file mode 100644 index 000000000..d007dc535 --- /dev/null +++ b/log-viewer/src/components/VariablesDetail.ts @@ -0,0 +1,679 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-icon.js'; +import { consume } from '@lit/context'; +import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { + frameVariablesFor, + recordsVariables, + variableIndexFor, + type FrameVariables, + type VariableIndex, +} from '../core/log/frameVariables.js'; +import { logContext } from '../core/log/logContext.js'; +import type { LogStore } from '../core/log/LogStore.js'; +import { previewOf, RAW_CLAMP_CHARS, type VariableValue } from '../core/log/variableValue.js'; +import { globalStyles } from '../styles/global.styles.js'; +import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js'; +import { bleedRowStyles } from '../styles/revealRow.styles.js'; +import { parentOf, toTreeRows, type Shown, type VariableTreeRow } from './variableTree.js'; + +// web components +import './CodeBlock.js'; + +/** Any row that shows a value: a variable, a property, or the `this` group. */ +type Valued = Shown & { open: boolean }; + +/** + * What Apex could reach from the selected frame: its locals, its instance fields + * and the statics, each value as the frame saw it. + * + * Only the log's own text is shown. A value is never re-serialised, so a + * duplicate Map key, a truncation marker and an `{}` the log wrote all reach the + * screen as the log wrote them. The log records one level, so one level opens. + * + * A keyboard tree: the arrow keys walk every row, the properties inside an + * opened value included. + */ +@customElement('variables-detail') +export class VariablesDetail extends LitElement { + @property({ type: Number }) + eventIndex = -1; + + /** Occurrence eventIndexes when the selection is an aggregate row. One frame + * holds one set of variables, so an aggregate has none to show. */ + @property({ attribute: false }) + instances: number[] | null = null; + + /** The log on screen, from the app root. */ + @consume({ context: logContext, subscribe: true }) + @property({ attribute: false }) + logStore: LogStore | null = null; + + @state() + private _index: VariableIndex | null = null; + + /** Which rows the user has opened or closed, by their stable id, so disclosure + * survives walking the call stack. */ + @state() + private _disclosure: ReadonlyMap = new Map(); + + /** The row holding the tab stop. One tab stop for the whole tree, as the tree + * pattern wants: the arrows move within it. */ + @state() + private _focused: string | null = null; + + /** The whole-log walk threw, so there is nothing to read and nothing to + * retry from here: the section says so instead of reading forever. */ + @state() + private _readError = false; + + /** The rows on screen, kept so a key finds the next one without walking the + * DOM. */ + private _rows: readonly VariableTreeRow[] = []; + + /** Row id to its place in {@link _rows}, so a key is a lookup. */ + private _at: ReadonlyMap = new Map(); + + /** The scope as read for the current selection. + * + * Read once per selection, never per render: reading it back through a frame + * of hundreds of thousands of lines costs tens of ms, and opening a row must + * not pay that again. */ + private _frame: FrameVariables | null = null; + + /** Set when a key moved the tab stop, so `updated` moves focus with it. */ + private _takeFocus = false; + + static styles = [ + globalStyles, + inspectorSectionStyles, + bleedRowStyles, + css` + .tree { + display: flex; + flex-direction: column; + } + + /* One row is one line at any width: the name holds, the value gives way. + Depth is a variable, so every row shares one indent step. */ + .row { + display: flex; + align-items: center; + gap: var(--lana-space-2xs); + padding-left: calc(var(--depth, 0) * var(--lana-space-md)); + } + + .lead { + display: flex; + align-items: baseline; + gap: var(--lana-space-2xs); + flex: 1 1 auto; + min-width: 0; + } + + .chevron { + flex: 0 0 auto; + align-self: center; + color: var(--lana-fg-muted); + transition: transform 150ms ease-out; + } + + .row[aria-expanded='true'] > .chevron { + transform: rotate(90deg); + } + + /* A row that opens on nothing still takes the chevron's width, so every + name at one depth starts on the same edge. */ + .chevron-gap { + flex: 0 0 auto; + width: var(--lana-space-md); + } + + .group-name { + flex: 0 0 auto; + font-weight: 600; + } + + /* Whose frame, or whose class: metadata, so it gives way before the name. */ + .group-of, + .type { + min-width: 0; + overflow: hidden; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + font-size: var(--lana-text-sm); + text-overflow: ellipsis; + white-space: nowrap; + } + + .group-of { + flex: 0 1 auto; + } + + .type { + flex: 0 0 auto; + margin-left: auto; + } + + .name { + flex: 0 0 auto; + font-family: var(--lana-font-mono); + } + + /* The value is the log's own text: truncated rather than wrapped, so one + variable stays one row. */ + .value { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + font-variant-numeric: tabular-nums; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* The log wrote no value here, and its absence is the reading: the name + was declared and never written, or the object was only ever an address. */ + .missing { + flex: 0 0 auto; + color: var(--lana-fg-muted); + font-size: var(--lana-text-sm); + font-style: italic; + } + + .key { + flex: 0 0 auto; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + } + + /* The object's class leads its value, as Chrome does it: what the object + *is* carries more than the muted text describing it. */ + .cls { + color: var(--lana-fg); + } + + /* Where the value was only an address, the object it names leads and the + address trails it, so the row reads as the object with its provenance. */ + .ref { + flex: 0 0 auto; + color: var(--lana-fg-muted); + font-family: var(--lana-font-mono); + font-size: var(--lana-text-sm); + } + + .count { + flex: 0 0 auto; + min-width: 1.4em; + border-radius: var(--lana-radius-md); + padding: 0 var(--lana-space-2xs); + background-color: var(--lana-badge-bg); + color: var(--lana-badge-fg); + font-size: var(--lana-text-sm); + font-variant-numeric: tabular-nums; + text-align: center; + } + + /* What the log said about the value rather than the value itself. */ + .chip { + flex: 0 0 auto; + border: var(--lana-stroke) solid var(--lana-surface-border); + border-radius: var(--lana-radius-md); + padding: 0 var(--lana-space-2xs); + color: var(--lana-fg-muted); + font-size: var(--lana-text-sm); + } + + .row.is-note { + color: var(--lana-fg-muted); + cursor: default; + } + + code-block { + min-width: 0; + flex: 1 1 auto; + } + `, + ]; + + override willUpdate(changed: PropertyValues): void { + // Only what the selection is made of, so a disclosure or a key does not + // re-read the log. + const reselected = + changed.has('eventIndex') || + changed.has('instances') || + changed.has('logStore') || + changed.has('_index'); + if (reselected) { + // An aggregate answers with none of these: reading the frame back through + // it costs tens of ms on a huge frame, and render() would throw it away + // unread. + const aggregate = (this.instances?.length ?? 0) > 1; + this._frame = + !aggregate && this.logStore && this._index + ? frameVariablesFor(this.logStore, this.eventIndex, this._index) + : null; + } + // A key press moves the tab stop and nothing else, so the rows it walks are + // rebuilt only when the scope or what is open changes. + if (reselected || changed.has('_disclosure')) { + this._rebuild(); + } + } + + /** The rows on screen, and where each one sits, from the scope and what is + * open. Scanning a value is the cost here, so it is paid once. */ + private _rebuild(): void { + const frame = this._frame; + const index = this._index; + this._rows = + frame && index + ? toTreeRows( + frame, + (id, byDefault) => this._disclosure.get(id) ?? byDefault, + (address) => index.addressState(address, frame.cut), + (address) => index.classAt(address, frame.cut), + ) + : []; + this._at = new Map(this._rows.map((row, at) => [row.id, at])); + } + + override updated(changed: PropertyValues): void { + if (changed.has('logStore')) { + this._index = null; + this._readError = false; + this._disclosure = new Map(); + this._focused = null; + void this._read(); + } + if (this._takeFocus) { + this._takeFocus = false; + // By place, not by id: an id embeds names the log wrote, and rows render + // in `_rows` order, so the place is exact and needs no escaping. + const at = this._focused !== null ? this._at.get(this._focused) : undefined; + if (at !== undefined) { + this.renderRoot.querySelectorAll('.row')[at]?.focus(); + } + } + } + + render() { + const log = this.logStore?.log; + if (!log) { + return nothing; + } + // An aggregate row counts calls from many frames, and each held its own + // variables. Naming one of them would be a guess. + if (this.instances && this.instances.length > 1) { + return note('Pick one call to see its variables.'); + } + if (!recordsVariables(log)) { + return note('Variables available with the Apex Code log level at FINEST.'); + } + if (this._readError) { + return note('Could not read the log for variables.'); + } + const index = this._index; + if (!index) { + return note('Reading the logโ€ฆ'); + } + // A log can be captured at FINEST and still record no write, so this is not + // the same as a frame that had nothing in scope. + if (!index.sawAnyWrite) { + return note('This log records no variable assignments.'); + } + + const frame = this._frame; + if (!frame || !this._rows.length) { + return note('The log records no variables in scope here.'); + } + + // The tab stop follows the tree: a row that has gone hands it back. + const focused = + this._focused !== null && this._at.has(this._focused) + ? this._focused + : (this._rows[0]?.id ?? null); + + return html` + ${ + frame.truncated + ? note('The log is truncated here, so a write may be unrecorded rather than absent.') + : '' + } + ${index.capped ? note('Too many static assignments to hold them all, so some are missing.') : ''} +
    + ${this._rows.map((row) => this._render(row, row.id === focused))} +
    + `; + } + + /** Builds the index, which is the only walk of the whole log. */ + private async _read(): Promise { + const log = this.logStore?.log; + if (!log || !recordsVariables(log)) { + return; + } + try { + const index = await variableIndexFor(log); + // The log may have changed while the walk ran. + if (this.logStore?.log === log) { + this._index = index; + } + } catch { + // Left "Reading the logโ€ฆ" forever otherwise, with no error shown and no + // way to retry. + if (this.logStore?.log === log) { + this._readError = true; + } + } + } + + private _render(row: VariableTreeRow, focused: boolean): TemplateResult { + // A note is prose about the row above it, so it is read but never opened. + const isNote = row.kind === 'note'; + return html`
    this._pick(row)} + > + ${row.expandable ? CHEVRON : html``}${this._body(row)} +
    `; + } + + private _body(row: VariableTreeRow): TemplateResult | string { + switch (row.kind) { + case 'group': + return html` + ${row.name} + ${row.of ? html`${row.of}` : ''} + ${row.self ? this._value({ ...row.self, open: row.open }, row.self.declaredType) : ''} + + ${row.count} + ${typeColumn(row.self?.declaredType ?? null)}`; + case 'class': + return html` + ${row.className} + + ${row.count}`; + case 'variable': + return this._variable(row); + case 'entry': + return html` + ${row.key === null ? 'ยท' : `${row.key}:`} + ${this._value(row, null)} + + ${chipFor(row.value)}`; + case 'text': + return html``; + case 'note': + return html`${row.text}`; + } + } + + private _variable(row: Extract): TemplateResult { + const variable = row.row; + return html` + ${variable.assigned ? `${variable.name}:` : variable.name} + ${ + variable.assigned + ? this._value(row, variable.declaredType) + : html`not assigned` + } + + ${chipFor(row.value)}${typeColumn(variable.declaredType)}`; + } + + /** + * A value, and where it came from. + * + * One rule for an address: where the row's own text was only an address, that + * address trails the row, every time. What leads is the object the log + * recorded for it, or why it could not. + * + * Open, the rows below carry the value, so a preview here as well would print + * the same value twice. + */ + private _value(row: Valued, declaredType: string | null): TemplateResult { + const missing = this._missing(row); + return html`${this._slot(row, declaredType, missing)}${ + row.address + ? html`→ ${row.address}` + : '' + }`; + } + + /** What leads the row: nothing when open, why not where there is no object, + * else the object's class and the log's own text. */ + private _slot( + row: Valued, + declaredType: string | null, + missing: Missing | null, + ): TemplateResult | string { + if (row.open) { + return ''; + } + if (missing) { + return html`${missing.text}`; + } + // Left out where it matches the declared type: the type column says it. + const className = row.className && row.className !== declaredType ? row.className : null; + return html`${ + className ? html`${lastSegment(className)} ` : '' + }${previewOf(row.value)}`; + } + + /** + * Why a row shows no object, or null where it shows one. + * + * The address is only the identity the runtime printed for the reference. The + * object's contents reach the log as a separate event, and only where Apex + * assigned that object to a variable it could serialise. That event may land + * after the frame the reader picked, and often far from it, so the frame that + * holds it is named. + */ + private _missing(row: Valued): Missing | null { + if (row.address === null || row.resolved) { + return null; + } + if (row.laterAt === null) { + return { + text: 'no value recorded', + why: 'The log holds no value for this address, at any point.', + }; + } + const stack = this.logStore?.stackByEventIndex(row.laterAt) ?? []; + const where = stack[stack.length - 1]?.text; + return { + text: 'recorded later', + why: `The log describes this object after this frame${where ? `, in ${where}` : ''}, so it may differ from the value here.`, + }; + } + + private _pick(row: VariableTreeRow): void { + if (row.kind === 'note') { + return; + } + this._focused = row.id; + if (row.expandable) { + this._toggle(row.id, !row.open); + } + } + + /** The tree keyboard pattern: the arrows walk and open, nothing tabs away. */ + private _onKeyDown(event: KeyboardEvent): void { + const rows = this._rows; + // Nothing focused yet means the tab stop is on the first row, so a key moves + // from there rather than spending itself arriving. + const at = (this._focused !== null ? this._at.get(this._focused) : undefined) ?? 0; + const row = rows[at]; + if (!row) { + return; + } + // A note is read, never focused: it carries no tabindex, so landing the + // tab stop on one would leave the tree with none at all. + const move = (to: number): void => { + const found = nearestFocusable(rows, to, to >= at ? 1 : -1); + if (found) { + this._focused = found.id; + this._takeFocus = true; + } + }; + + switch (event.key) { + case 'ArrowDown': + move(at + 1); + break; + case 'ArrowUp': + move(at - 1); + break; + case 'ArrowRight': + // Open what is closed, then step into what is already open. + if (row.expandable && !row.open) { + this._toggle(row.id, true); + } else if (row.expandable) { + move(at + 1); + } + break; + case 'ArrowLeft': + // Close what is open, then step out to what holds it. + if (row.expandable && row.open) { + this._toggle(row.id, false); + } else { + const above = parentOf(rows, at); + if (above >= 0) { + move(above); + } + } + break; + case 'Home': + move(0); + break; + case 'End': + move(rows.length - 1); + break; + case 'Enter': + case ' ': + if (row.expandable) { + this._toggle(row.id, !row.open); + } + break; + case '*': + this._openAll(row.depth); + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + } + + /** Opens every row at one depth, which is what `*` means in a tree. */ + private _openAll(depth: number): void { + const next = new Map(this._disclosure); + for (const row of this._rows) { + if (row.expandable && row.depth === depth) { + next.set(row.id, true); + } + } + this._disclosure = next; + } + + private _toggle(id: string, open: boolean): void { + const next = new Map(this._disclosure); + next.set(id, open); + this._disclosure = next; + this._focused = id; + this._takeFocus = true; + } +} + +/** + * The nearest row to `to` that can hold the tab stop, walking `step` first and + * the far end of the list only if that runs out. + * + * A note is read, never focused: it carries no tabindex, so a move that lands + * on one would leave the tree with no tab stop at all. + */ +function nearestFocusable( + rows: readonly VariableTreeRow[], + to: number, + step: 1 | -1, +): VariableTreeRow | undefined { + const clamped = Math.max(0, Math.min(rows.length - 1, to)); + for (let at = clamped; at >= 0 && at < rows.length; at += step) { + if (rows[at]!.kind !== 'note') { + return rows[at]; + } + } + for (let at = step > 0 ? rows.length - 1 : 0; at >= 0 && at < rows.length; at -= step) { + if (rows[at]!.kind !== 'note') { + return rows[at]; + } + } + return undefined; +} + +const CHEVRON = html``; + +/** Why a row shows no object: what it reads, and the sentence behind it. */ +interface Missing { + text: string; + why: string; +} + +/** The declared type, in its own column, where the log gave one. */ +function typeColumn(declaredType: string | null): TemplateResult | string { + return declaredType ? html`${declaredType}` : ''; +} + +/** A qualified class as its own name: the row has no width for the namespace, + * and the hover carries it whole. */ +function lastSegment(className: string): string { + return className.slice(className.lastIndexOf('.') + 1); +} + +const WHY_RESOLVED = (address: string): string => + `The log wrote no value here. This is what it recorded for ${address}.`; + +function note(text: string): TemplateResult { + return html`

    ${text}

    `; +} + +/** What the log said about a value that its text alone does not show. */ +function chipFor(value: VariableValue): TemplateResult | string { + // Read out of a string, so the rows below are not what the log serialised. + if (value.kind === 'container' && value.fromString) { + return html`json`; + } + if (value.kind === 'string' && value.toStringLike) { + return html`toString`; + } + if (value.kind === 'string' && value.truncated) { + return html`truncated`; + } + return ''; +} + +declare global { + interface HTMLElementTagNameMap { + 'variables-detail': VariablesDetail; + } +} diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts new file mode 100644 index 000000000..4382b3bda --- /dev/null +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -0,0 +1,676 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; +import { parse } from 'apex-log-parser'; + +import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; + +// Avoid the heavy CodeBlock import chain (vscode-elements, soql formatter). The +// raw value it renders is covered by variableValue's own tests. +jest.mock('../CodeBlock.js', () => ({})); +// The chevron is a vscode-icon, and its connectedCallback throws under jsdom. +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); + +import type { VariablesDetail } from '../VariablesDetail.js'; +import '../VariablesDetail.js'; + +const FINEST = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; +const FINE = '64.0 APEX_CODE,FINE;APEX_PROFILING,NONE;DB,NONE\n'; + +function logOf(body: string, settings = FINEST): LogStore { + return logStoreFor( + parse( + settings + + '09:18:22.6 (100)|EXECUTION_STARTED\n' + + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + body + + '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (901000)|EXECUTION_FINISHED\n', + ), + ); +} + +const FRAME = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|total|Integer|true|false\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[2]|total|42\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[3]|this.name|"Acme"\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[4]|ns.Cache.hits|{"a":1,"b":2}\n' + + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + +/** The eventIndex of the frame whose log text is `text`. */ +function indexOf(store: LogStore, text: string): number { + const found = store.log.eventsById.find((event) => event.text === text); + if (!found) { + throw new Error(`no event with text ${text}`); + } + return found.eventIndex; +} + +/** No provider in the test, so the consumed store is assigned straight on. */ +async function mount(store: LogStore, props: Partial): Promise { + const el = document.createElement('variables-detail') as VariablesDetail; + Object.assign(el, { logStore: store }, props); + document.body.appendChild(el); + await el.updateComplete; + // The statics index is built on the first ask, so the first paint is a note. + await el.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 0)); + await el.updateComplete; + return el; +} + +/** The notes on screen. A shadow root's textContent also holds its styles. */ +function notes(el: VariablesDetail): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.note') ?? []).map( + (node) => node.textContent?.replace(/\s+/g, ' ').trim() ?? '', + ); +} + +/** Every row's text, styles left out. */ +function rowText(el: VariablesDetail): string { + return Array.from(el.shadowRoot?.querySelectorAll('[role="treeitem"], .is-note, .note') ?? []) + .map((node) => node.textContent ?? '') + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Names as shown, without the colon that joins a name to its value. */ +function rowNames(el: VariablesDetail): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.name') ?? []).map( + (node) => node.textContent?.trim().replace(/:$/, '') ?? '', + ); +} + +function rowNamed(el: VariablesDetail, name: string): Element | undefined { + return treeRows(el).find( + (row) => row.querySelector('.name')?.textContent?.replace(/:$/, '') === name, + ); +} + +function groupNames(el: VariablesDetail): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.group-name') ?? []).map( + (node) => node.textContent?.trim() ?? '', + ); +} + +// A throw during the whole-log walk must not leave the section reading forever +// with no error and no way to retry. +describe('VariablesDetail read failure', () => { + it('shows a message rather than staying on "Reading the logโ€ฆ" forever', async () => { + const store = logOf(FRAME); + Object.defineProperty(store.log, 'children', { + get(): never { + throw new Error('boom'); + }, + }); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).toContain('Could not read the log for variables.'); + errorSpy.mockRestore(); + }); +}); + +// Reading an aggregate's frame back through the log costs tens of ms on a huge +// frame (frameVariables.ts), and render() throws it away unread. +describe('VariablesDetail skips the frame read for an aggregate', () => { + it('never asks for the frame when more than one instance is selected', async () => { + const store = logOf(FRAME); + const el = await mount(store, { + eventIndex: indexOf(store, 'ns.Outer.run()'), + instances: [indexOf(store, 'ns.Outer.run()'), indexOf(store, 'ns.Outer.run()')], + }); + + expect((el as unknown as { _frame: unknown })._frame).toBeNull(); + }); +}); + +describe('VariablesDetail empty states', () => { + // Telling a FINEST user to set FINEST is the worst answer available, so each + // case has to read differently. + it('names the log level that would fill it', async () => { + const store = logOf(FRAME, FINE); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).toEqual(['Variables available with the Apex Code log level at FINEST.']); + }); + + it('says a FINEST log recorded no write at all', async () => { + const store = logOf('09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n'); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).toEqual(['This log records no variable assignments.']); + }); + + it('says a frame had nothing in scope, where the log has writes elsewhere', async () => { + const store = logOf( + FRAME + + '09:18:22.6 (1800)|METHOD_ENTRY|[9]|01p|ns.Quiet.run()\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Quiet.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Quiet.run()') }); + + // The statics are still visible from it, so this frame reports them. + expect(groupNames(el)).toContain('Static'); + }); + + it('asks for one call when the selection counts many', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { + eventIndex: indexOf(store, 'ns.Outer.run()'), + instances: [1, 2, 3], + }); + + expect(notes(el)).toEqual(['Pick one call to see its variables.']); + }); +}); + +function treeRows(el: VariablesDetail): HTMLElement[] { + return Array.from(el.shadowRoot?.querySelectorAll('[role="treeitem"]') ?? []); +} + +function tree(el: VariablesDetail): HTMLElement { + const found = el.shadowRoot?.querySelector('[role="tree"]'); + if (!found) { + throw new Error('no tree'); + } + return found; +} + +/** The row holding the tree's one tab stop. */ +function tabStop(el: VariablesDetail): string | null { + return el.shadowRoot?.querySelector('[tabindex="0"]')?.getAttribute('data-id') ?? null; +} + +async function press(el: VariablesDetail, key: string): Promise { + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); + await el.updateComplete; +} + +describe('VariablesDetail groups', () => { + it('shows Local, this and Static, in that order', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(groupNames(el)).toEqual(['Local', 'this', 'Static']); + }); + + it('opens Local and leaves the rest closed', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + const groups = treeRows(el).filter((row) => row.querySelector('.group-name')); + expect(groups.map((row) => row.getAttribute('aria-expanded'))).toEqual([ + 'true', + 'false', + 'false', + ]); + // Local is open, so its one row shows. + expect(rowNames(el)).toContain('total'); + }); + + // Everything that opens says so, groups included. + it('gives every row that opens a chevron, and every other row its gap', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + for (const row of treeRows(el)) { + const opens = row.getAttribute('aria-expanded') !== null; + expect(!!row.querySelector('.chevron')).toBe(opens); + expect(!!row.querySelector('.chevron-gap')).toBe(!opens); + } + }); + + it('shows the declared type the log recorded', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowText(el)).toContain('Integer'); + }); + + it('keeps a disclosure the user closed when the selection moves', async () => { + const store = logOf( + FRAME + + '09:18:22.6 (1800)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1850)|VARIABLE_ASSIGNMENT|[10]|other|7\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + treeRows(el)[0]?.click(); + await el.updateComplete; + el.eventIndex = indexOf(store, 'ns.Second.run()'); + await el.updateComplete; + + expect(treeRows(el)[0]?.getAttribute('aria-expanded')).toBe('false'); + }); + + // A chevron that opened on nothing would teach a depth the log lacks. + it('offers no expander on a value a row can hold', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // `total` is 42, so its row opens on nothing. + const total = rowNamed(el, 'total'); + expect(total?.getAttribute('aria-expanded')).toBeNull(); + expect(total?.querySelector('.chevron-gap')).not.toBeNull(); + }); + + // Open, the rows below carry the value; a preview as well would print it twice. + it('drops the preview once the value is open below it', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|held|{"a":1,"b":2}\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const held = () => treeRows(el).find((row) => row.dataset.id === 'local/held'); + + const before = held()?.querySelector('.value')?.textContent?.trim(); + held()?.click(); + await el.updateComplete; + + expect(before).toContain('a: 1'); + expect(held()?.getAttribute('aria-expanded')).toBe('true'); + expect(held()?.querySelector('.value')).toBeNull(); + // The properties are rows of their own, so the arrows reach them. + expect(treeRows(el).some((row) => row.dataset.id === 'local/held/0')).toBe(true); + }); + + it('says so where the log declared a name and never wrote it', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|never|Boolean|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|written|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowText(el)).toContain('not assigned'); + }); + + // A colon joins a name to its value, and only where a value follows it. + it('joins a name to its value with a colon', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'total')?.querySelector('.name')?.textContent).toBe('total:'); + }); + + it('leaves the colon off a name with no value', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|never|Boolean|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|written|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'never')?.querySelector('.name')?.textContent).toBe('never'); + }); + + // One rule for an address: it always trails the row, and the value slot says + // whether the log ever wrote the object down. + it('says the object is not recorded where the log wrote no value for it', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|alias|0xd854c6b\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const alias = rowNamed(el, 'alias'); + + expect(alias?.querySelector('.missing')?.textContent).toContain('no value recorded'); + expect(alias?.querySelector('.ref')?.textContent).toContain('0xd854c6b'); + expect(alias?.querySelector('.chip')).toBeNull(); + }); + + // The address is only the identity the runtime printed for the reference. The + // contents are a separate event, which may land after the frame. + it('says so where the log recorded the object only after this frame', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|alias|0xd854c6b\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n' + + '09:18:22.6 (1400)|METHOD_ENTRY|[4]|01p|ns.Outer.after()\n' + + '09:18:22.6 (1500)|VARIABLE_ASSIGNMENT|[5]|held|{"Id":"001"}|0xd854c6b\n' + + '09:18:22.6 (1600)|METHOD_EXIT|[4]|ns.Outer.after()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const alias = rowNamed(el, 'alias'); + + expect(alias?.querySelector('.missing')?.textContent).toContain('recorded later'); + expect(alias?.querySelector('.ref')?.textContent).toContain('0xd854c6b'); + // Named, because that frame can be far from the one the reader picked. + expect(alias?.querySelector('.missing')?.getAttribute('title')).toContain('ns.Outer.after()'); + }); + + // An interface-typed variable holding a stateless instance reads as `{}`, and + // the class is the only thing that says what it is. + it('names the class of the object it shows', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[2]|01p|()|ns.Writer.WithoutSharing\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Writer.WithoutSharing|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0x12d441a3\n' + + '09:18:22.6 (1080)|CONSTRUCTOR_EXIT|[2]|01p|()|ns.Writer.WithoutSharing\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[3]|writer|ns.IWriter|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|writer|{}|0x12d441a3\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const writer = rowNamed(el, 'writer'); + + // The row has no width for the namespace, so the hover carries it whole. + expect(writer?.querySelector('.cls')?.textContent).toBe('WithoutSharing'); + expect(writer?.querySelector('.value')?.getAttribute('title')).toContain( + 'ns.Writer.WithoutSharing', + ); + // The declared type stays its own column, so both read together. + expect(writer?.querySelector('.type')?.textContent).toBe('ns.IWriter'); + }); + + // The type column already says it, so saying it twice is noise. + it('leaves the class out where it matches the declared type', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Writer|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0x12d441a3\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[3]|writer|ns.Writer|true|false\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|writer|{}|0x12d441a3\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'writer')?.querySelector('.cls')).toBeNull(); + }); + + // `this` is the instance the frame runs on. Listing it among the locals reads + // as a variable the method declared. + it('keeps this out of the locals', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Writer.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Writer|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0x12d441a3\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|total|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Writer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Writer.run()') }); + + expect(rowNames(el)).not.toContain('this'); + expect(groupNames(el)).toContain('this'); + // A stateless class has nothing to open, and the group says so. + const group = treeRows(el).find((r) => r.getAttribute('data-id') === 'this'); + expect(group?.getAttribute('aria-expanded')).toBeNull(); + expect(group?.querySelector('.value')?.textContent).toContain('{}'); + }); + + it('marks an object it read out of a string', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|payload|"{\\"a\\":1}"\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(rowNamed(el, 'payload')?.querySelector('.chip')?.textContent).toBe('json'); + }); + + it('reads an address as the object it names', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|held|{"Id":"001"}|0xd854c6b\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|alias|0xd854c6b\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const alias = rowNamed(el, 'alias'); + + expect(alias?.querySelector('.value')?.textContent).toContain('Id'); + // The arrow says the log wrote no value on this line. + const ref = alias?.querySelector('.ref'); + expect(ref?.textContent).toContain('0xd854c6b'); + expect(ref?.textContent).toContain('\u2192'); + }); +}); + +// The tree keyboard pattern: one tab stop, the arrows walk and open. +describe('VariablesDetail keyboard', () => { + // A note carries no tabindex, so a move that landed on one would leave the + // tree with no tab stop at all: focus and forth keys would then do nothing. + it('never lands the tab stop on a note', async () => { + const store = logOf( + '09:18:22.6 (900)|METHOD_ENTRY|[1]|01p|ns.Setup.run()\n' + + '09:18:22.6 (950)|VARIABLE_ASSIGNMENT|[1]|ns.Cache.hits|1\n' + + '09:18:22.6 (990)|METHOD_EXIT|[1]|ns.Setup.run()\n' + + '09:18:22.6 (1000)|METHOD_ENTRY|[2]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[2]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // Local starts open with no locals, so it opens onto a note. + expect(tabStop(el)).toBe('local'); + await press(el, 'ArrowDown'); + + expect(tabStop(el)).not.toBeNull(); + expect(treeRows(el).find((row) => row.dataset.id === tabStop(el))?.classList).not.toContain( + 'is-note', + ); + }); + + it('gives the tree one tab stop', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(el.shadowRoot?.querySelectorAll('[tabindex="0"]')).toHaveLength(1); + expect(tabStop(el)).toBe('local'); + }); + + it('walks down and up', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'ArrowDown'); + const second = tabStop(el); + await press(el, 'ArrowUp'); + + expect(second).not.toBe('local'); + expect(tabStop(el)).toBe('local'); + }); + + it('opens with the right arrow and closes with the left', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // Walk to `this`, which starts closed. + while (tabStop(el) !== 'this') { + await press(el, 'ArrowDown'); + } + const fields = () => treeRows(el).find((row) => row.dataset.id === 'this'); + await press(el, 'ArrowRight'); + const opened = fields()?.getAttribute('aria-expanded'); + await press(el, 'ArrowLeft'); + + expect(opened).toBe('true'); + expect(fields()?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('steps out to the row that holds it', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + // Local is open, so the row below it is one of its own. + await press(el, 'ArrowDown'); + await press(el, 'ArrowLeft'); + + expect(tabStop(el)).toBe('local'); + }); + + it('reaches the first and last row', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'End'); + const last = tabStop(el); + await press(el, 'Home'); + + expect(last).toBe('static'); + expect(tabStop(el)).toBe('local'); + }); + + it('opens every group at one depth with a star', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, '*'); + + const groups = treeRows(el).filter((row) => row.querySelector('.group-name')); + expect(groups.map((row) => row.getAttribute('aria-expanded'))).toEqual([ + 'true', + 'true', + 'true', + ]); + }); + + it('toggles with Enter', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'Enter'); + + expect(treeRows(el)[0]?.getAttribute('aria-expanded')).toBe('false'); + }); +}); + +// Reading the scope back through a huge frame costs tens of ms, so opening a +// row must not pay it again. +describe('VariablesDetail reads the scope once per selection', () => { + /** The snapshot the section is rendering from. */ + const held = (el: VariablesDetail): unknown => (el as unknown as { _frame: unknown })._frame; + + it('keeps the same reading when a row opens', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = held(el); + + treeRows(el)[0]?.click(); + await el.updateComplete; + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + + expect(before).toBeTruthy(); + expect(held(el)).toBe(before); + }); + + /** The row list the keyboard walks. */ + const rows = (el: VariablesDetail): unknown => (el as unknown as { _rows: unknown })._rows; + + // Scanning a value is the cost, so a key that only moves the tab stop must not + // pay it again: a held arrow key fires ~20 times a second. + it('keeps the same rows when the tab stop moves', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = rows(el); + + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + + expect(before).toBeTruthy(); + expect(rows(el)).toBe(before); + }); + + it('builds the rows again when a row opens', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = rows(el); + + treeRows(el)[0]?.click(); + await el.updateComplete; + + expect(rows(el)).not.toBe(before); + }); + + it('reads again when the selection moves', async () => { + const store = logOf( + FRAME + + '09:18:22.6 (1800)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1850)|VARIABLE_ASSIGNMENT|[10]|other|7\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const before = held(el); + + el.eventIndex = indexOf(store, 'ns.Second.run()'); + await el.updateComplete; + + expect(held(el)).not.toBe(before); + }); +}); + +// A property is a row like any other, so the arrow keys reach it and it opens. +describe('VariablesDetail properties', () => { + it('opens a property that is an object in its own right', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|outer|{"inner":{"a":1,"b":2},"n":3}\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const at = (id: string) => treeRows(el).find((r) => r.dataset.id === id); + + at('local/outer')?.click(); + await el.updateComplete; + const before = at('local/outer/0')?.getAttribute('aria-expanded'); + at('local/outer/0')?.click(); + await el.updateComplete; + + expect(before).toBe('false'); + expect(at('local/outer/0')?.getAttribute('aria-expanded')).toBe('true'); + // Its own properties are rows, one level deeper. + expect(at('local/outer/0/0')?.getAttribute('aria-level')).toBe('4'); + }); + + it('leaves a property that opens on nothing without a chevron', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|outer|{"n":3}\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + treeRows(el) + .find((r) => r.dataset.id === 'local/outer') + ?.click(); + await el.updateComplete; + const property = treeRows(el).find((r) => r.dataset.id === 'local/outer/0'); + + expect(property?.getAttribute('aria-expanded')).toBeNull(); + expect(property?.querySelector('.chevron-gap')).not.toBeNull(); + }); +}); diff --git a/log-viewer/src/components/__tests__/detailSections.test.ts b/log-viewer/src/components/__tests__/detailSections.test.ts index c96945c9c..5c4cac6a5 100644 --- a/log-viewer/src/components/__tests__/detailSections.test.ts +++ b/log-viewer/src/components/__tests__/detailSections.test.ts @@ -58,6 +58,7 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('timeline', { kind: 'event', eventIndex: 4 }); expect(sections.map((s) => s.id)).toEqual([ 'vitals', + 'variables', 'namespace-time', 'callstack', 'calltree', @@ -130,7 +131,7 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('database', { kind: 'event', eventIndex: 9 }); expect(databaseCalls).toEqual([]); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); }); it('scopes an aggregate selection to its first occurrence', async () => { @@ -138,7 +139,13 @@ describe('buildDetailSections', () => { kind: 'aggregate', instances: [11, 12, 13], }); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'findings', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual([ + 'vitals', + 'variables', + 'findings', + 'callstack', + 'calltree', + ]); expect( (rendered(sections, 'vitals', 'event-vitals') as HTMLElement & { instances: number[] | null }) .instances, @@ -176,6 +183,7 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('timeline', { kind: 'event', eventIndex: 4 }); expect(sections.map((s) => s.id)).toEqual([ 'vitals', + 'variables', 'namespace-time', 'callstack', 'calltree', @@ -211,7 +219,7 @@ describe('buildDetailSections', () => { it('leaves the namespace split out for a selection from another tab', async () => { const sections = await buildDetailSections('calltree', { kind: 'event', eventIndex: 4 }); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); }); it('drops the aggregate once a single frame in its stack is the one being followed', async () => { diff --git a/log-viewer/src/components/__tests__/variableTree.test.ts b/log-viewer/src/components/__tests__/variableTree.test.ts new file mode 100644 index 000000000..3e2686bda --- /dev/null +++ b/log-viewer/src/components/__tests__/variableTree.test.ts @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + NOT_RECORDED, + type AddressState, + type FrameVariables, + type VariableRow, +} from '../../core/log/frameVariables.js'; +import { parentOf, toTreeRows } from '../variableTree.js'; + +function row(name: string, value: string, over: Partial = {}): VariableRow { + return { + name, + value, + declaredType: null, + address: null, + assigned: true, + objectAddress: null, + ...over, + }; +} + +const frame: FrameVariables = { + frameLabel: 'ns.Outer.run()', + cut: 100, + thisType: 'ns.Outer', + thisRow: null, + locals: [row('total', '42'), row('held', '{"a":1,"b":2}')], + fields: [row('name', '"Acme"')], + statics: [{ className: 'ns.Cache', rows: [row('hits', '3')] }], + truncated: false, +}; + +/** Opens nothing, so every group reads at its default. */ +const closed = (_id: string, byDefault: boolean): boolean => byDefault; +const openAll = (): boolean => true; + +describe('toTreeRows', () => { + it('opens Local and leaves the other groups closed', () => { + const rows = toTreeRows(frame, closed); + + expect(rows.filter((r) => r.kind === 'group').map((r) => [r.id, r.open])).toEqual([ + ['local', true], + ['this', false], + ['static', false], + ]); + // Local is open, so its variables are rows; the others contribute none. + expect(rows.filter((r) => r.kind === 'variable').map((r) => r.id)).toEqual([ + 'local/total', + 'local/held', + ]); + }); + + it('costs one row for a closed group', () => { + const rows = toTreeRows({ ...frame, locals: [] }, () => false); + + expect(rows).toHaveLength(3); + }); + + it('nests statics under their class', () => { + const rows = toTreeRows(frame, openAll); + + const ids = rows.map((r) => r.id); + expect(ids).toContain('static/ns.Cache'); + expect(ids).toContain('static/ns.Cache/hits'); + expect(rows.find((r) => r.id === 'static/ns.Cache')?.depth).toBe(1); + expect(rows.find((r) => r.id === 'static/ns.Cache/hits')?.depth).toBe(2); + }); + + it('gives an opened value one row per property', () => { + const rows = toTreeRows(frame, openAll); + + expect(rows.filter((r) => r.kind === 'entry').map((r) => r.id)).toEqual([ + 'local/held/0', + 'local/held/1', + ]); + }); + + // A chevron that opened on nothing would teach a depth the log lacks. + it('marks only a value that has something to open', () => { + const rows = toTreeRows(frame, closed); + + expect(rows.find((r) => r.id === 'local/total')?.expandable).toBe(false); + expect(rows.find((r) => r.id === 'local/held')?.expandable).toBe(true); + }); + + it('reads an address as the object it names', () => { + const rows = toTreeRows( + { ...frame, locals: [row('alias', '0xabc', { address: '0xabc' })] }, + closed, + (address) => (address === '0xabc' ? { text: '{"n":1}', laterAt: null } : NOT_RECORDED), + ); + + const alias = rows.find((r) => r.id === 'local/alias'); + expect(alias?.kind === 'variable' && alias.raw).toBe('{"n":1}'); + // Resolved to a container, so it opens. + expect(alias?.expandable).toBe(true); + }); + + it('says a frame assigned nothing rather than showing an empty group', () => { + const rows = toTreeRows({ ...frame, locals: [] }, closed); + + expect(rows.find((r) => r.id === 'local/none')?.kind).toBe('note'); + }); +}); + +describe('parentOf', () => { + it('names the row that holds a row', () => { + const rows = toTreeRows(frame, openAll); + const at = rows.findIndex((r) => r.id === 'static/ns.Cache/hits'); + + const owner = parentOf(rows, at); + + expect(rows[owner]?.id).toBe('static/ns.Cache'); + expect(rows[parentOf(rows, owner)]?.id).toBe('static'); + }); + + it('reports none above a group', () => { + const rows = toTreeRows(frame, closed); + + expect(parentOf(rows, 0)).toBe(-1); + }); +}); + +// `this` is the object the frame runs on, not one of its locals. +describe('the this group', () => { + const self = row('this', '{}', { objectAddress: '0xf1e2d3', declaredType: 'ns.Writer' }); + + it('heads its own group rather than sitting among the locals', () => { + const rows = toTreeRows({ ...frame, thisRow: self }, openAll); + + const group = rows.find((r) => r.id === 'this'); + expect(group?.kind === 'group' && group.self?.raw).toBe('{}'); + expect(rows.some((r) => r.id === 'local/this')).toBe(false); + }); + + // A stateless class has nothing inside, so a chevron would promise a depth + // the object does not have. + it('does not open where the object has no fields', () => { + const rows = toTreeRows({ ...frame, thisRow: self, fields: [] }, openAll); + + expect(rows.find((r) => r.id === 'this')?.expandable).toBe(false); + }); + + it('opens on the fields where the object has them', () => { + const rows = toTreeRows({ ...frame, thisRow: self, fields: [row('count', '3')] }, openAll); + + expect(rows.find((r) => r.id === 'this')?.expandable).toBe(true); + expect(rows.some((r) => r.id === 'this/count')).toBe(true); + }); + + it('shows the group without a value where the log wrote only fields', () => { + const rows = toTreeRows({ ...frame, fields: [row('count', '3')] }, openAll); + + const group = rows.find((r) => r.id === 'this'); + expect(group?.kind === 'group' && group.self).toBeNull(); + expect(group?.expandable).toBe(true); + }); +}); + +describe('toTreeRows references', () => { + const held = row('view', '{"m_tliFilter":"0x6c98700c","n":1}'); + const resolve = (address: string): AddressState => + address === '0x6c98700c' ? { text: '{"RowLimit":3000}', laterAt: null } : NOT_RECORDED; + + it('reads a field holding a reference as the object it names', () => { + const rows = toTreeRows({ ...frame, locals: [held] }, openAll, resolve); + + const entry = rows.find((r) => r.id === 'local/view/0'); + expect(entry?.kind === 'entry' && entry.raw).toBe('{"RowLimit":3000}'); + expect(entry?.kind === 'entry' && entry.address).toBe('0x6c98700c'); + expect(entry?.kind === 'entry' && entry.resolved).toBe(true); + // Resolved to an object, so the property opens on its own properties. + expect(entry?.expandable).toBe(true); + expect(rows.some((r) => r.id === 'local/view/0/0')).toBe(true); + }); + + it('leaves an address the log never wrote down as the address', () => { + const rows = toTreeRows({ ...frame, locals: [held] }, openAll, () => NOT_RECORDED); + + const entry = rows.find((r) => r.id === 'local/view/0'); + expect(entry?.kind === 'entry' && entry.raw).toBe('"0x6c98700c"'); + expect(entry?.kind === 'entry' && entry.resolved).toBe(false); + // Still names the address, so the row can say the log had no value for it. + expect(entry?.kind === 'entry' && entry.address).toBe('0x6c98700c'); + expect(entry?.expandable).toBe(false); + }); + + // The address is only the identity the runtime printed. The contents are a + // separate event, which may land after the frame the reader picked. + it('tells an address the log never wrote down from one it wrote later', () => { + const later = toTreeRows({ ...frame, locals: [held] }, openAll, () => ({ + text: null, + laterAt: 91, + })); + + const entry = later.find((r) => r.id === 'local/view/0'); + expect(entry?.kind === 'entry' && entry.resolved).toBe(false); + // The eventIndex, so the row can name the frame that records it. + expect(entry?.kind === 'entry' && entry.laterAt).toBe(91); + }); + + // A resolved object may name the address it was reached through. + it('stops a reference that points back at itself', () => { + const rows = toTreeRows( + { + ...frame, + locals: [row('loop', '0xaaa', { address: '0xaaa' })], + }, + openAll, + (address) => + address === '0xaaa' ? { text: '{"self":"0xaaa"}', laterAt: null } : NOT_RECORDED, + ); + + const inner = rows.find((r) => r.id === 'local/loop/0'); + // Read as the object, so the row never claims the log recorded nothing. + expect(inner?.kind === 'entry' && inner.resolved).toBe(true); + // But it does not open again, which is what would loop. + expect(inner?.expandable).toBe(false); + }); + + it('opens a property that is an object in its own right', () => { + const rows = toTreeRows( + { ...frame, locals: [row('outer', '{"inner":{"a":1,"b":2}}')] }, + openAll, + ); + + expect(rows.map((r) => r.id)).toContain('local/outer/0/0'); + expect(rows.find((r) => r.id === 'local/outer/0')?.expandable).toBe(true); + }); +}); + +describe('toTreeRows group labels', () => { + // Local's label says whose scope answered, which a SOQL selection needs. The + // others carried nothing the group name and count did not already say. + it('names the frame on Local and nothing on the others', () => { + const rows = toTreeRows(frame, openAll).filter((r) => r.kind === 'group'); + + expect(rows.map((r) => [r.id, r.kind === 'group' ? r.of : null])).toEqual([ + ['local', 'ns.Outer.run()'], + ['this', null], + ['static', null], + ]); + }); +}); diff --git a/log-viewer/src/components/detailSections.ts b/log-viewer/src/components/detailSections.ts index 12bbddf61..e41056579 100644 --- a/log-viewer/src/components/detailSections.ts +++ b/log-viewer/src/components/detailSections.ts @@ -21,6 +21,7 @@ import './HotPath.js'; import './HotSpots.js'; import './LogOverview.js'; import './NamespaceTimeBar.js'; +import './VariablesDetail.js'; /** * Build the inspector's sections for a selection from any tab. Every source gets @@ -202,6 +203,17 @@ export async function buildDetailSections( called-by=${calledBy} >`, }, + // What Apex could see from the frame. Always present, so it can say which + // log level would fill it rather than leaving the reader to guess. + { + id: 'variables', + title: 'Variables', + fit: 'content', + content: html``, + }, ]; if (source === 'timeline') { // The same split, asked of the selection: whose package burned the time under diff --git a/log-viewer/src/components/variableTree.ts b/log-viewer/src/components/variableTree.ts new file mode 100644 index 000000000..23eee5fb3 --- /dev/null +++ b/log-viewer/src/components/variableTree.ts @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { + NOT_RECORDED, + type AddressState, + type FrameVariables, + type VariableRow, +} from '../core/log/frameVariables.js'; +import { + clampRaw, + isExpandable, + parseVariableValue, + type VariableValue, +} from '../core/log/variableValue.js'; + +/** + * The Variables section as one flat list of rows. + * + * Flat because it is a keyboard tree: every row is reachable with the arrow + * keys, so the row after the one in hand has to be a lookup rather than a walk + * of nested markup. Only an open row contributes its children, so a closed + * group costs one row. + * + * Structure is how a value is formatted: an object opens into a row per + * property, and a property that is itself an object opens again. The log's own + * text is never reformatted, only laid out. + */ + +/** + * How deep a value may open. + * + * The log serialises one level itself, so depth beyond that comes from resolving + * an address, and each resolved object may name more. The cycle guard stops a + * loop; this stops a long chain. + */ +const MAX_DEPTH = 8; + +export interface Common { + /** Stable across selections, so disclosure and focus survive a re-render. */ + id: string; + depth: number; + expandable: boolean; + open: boolean; +} + +/** A value as shown, which is not always the text the log wrote on that line. */ +export interface Shown { + value: VariableValue; + /** The text behind {@link value}, for the raw text row. */ + raw: string; + /** The address this row's own text named, where its text was only an address. */ + address: string | null; + /** True where {@link raw} came from that address rather than from this row. */ + resolved: boolean; + /** Where the log first describes the object, where that is after this frame. */ + laterAt: number | null; + /** The class of the object shown, where the log names it. More telling than + * the declared type, which is often only an interface. */ + className: string | null; +} + +/** What the log holds for an address, as the frame stood. */ +export type Resolver = (address: string) => AddressState; + +/** The class of the object at an address, where the log names it. */ +export type ClassOf = (address: string) => string | null; + +/** A row that shows a value of its own, as well as holding others. */ +export type GroupSelf = Shown & { declaredType: string | null }; + +/** What a group is, before the disclosure state it is pushed with. */ +interface GroupHead { + id: string; + name: string; + count: number; + /** Whose frame, or whose class: metadata beside the name. */ + of?: string | null; + openByDefault?: boolean; + /** False where opening it would promise a depth the log has not got. */ + expandable?: boolean; + /** The group's own value, where the group *is* an object. */ + self?: GroupSelf | null; +} + +export type VariableTreeRow = Common & + ( + | { kind: 'group'; name: string; of: string | null; count: number; self: GroupSelf | null } + | { kind: 'class'; className: string; count: number } + | ({ kind: 'variable'; row: VariableRow } & Shown) + | ({ kind: 'entry'; key: string | null } & Shown) + | { kind: 'text'; raw: string } + | { kind: 'note'; text: string } + ); + +/** The value a row shows: an address resolves to the object it names. */ +export function shownValue(row: VariableRow, resolve: Resolver, classOf: ClassOf): Shown { + // The value's own address where it has one, else the address the line + // reported for it. A field write reports its owner and so carries neither. + return shown(row.value, row.address, row.address ?? row.objectAddress, resolve, classOf); +} + +/** + * One value, as shown: the object an address names where the log holds it, else + * the row's own text. + * + * `classAddress` differs from `address` only for a value the log serialised in + * place: it has no address to resolve, but the line still named the object. + */ +function shown( + text: string, + address: string | null, + classAddress: string | null, + resolve: Resolver, + classOf: ClassOf, +): Shown { + const state = address ? resolve(address) : NOT_RECORDED; + const raw = state.text ?? text; + return { + value: parseVariableValue(raw), + raw, + address, + resolved: state.text !== null, + laterAt: state.laterAt, + className: classAddress ? classOf(classAddress) : null, + }; +} + +/** + * Every row the section shows, in order, given which ids are open. + * + * `isOpen` decides a group's default too, so the caller owns the policy: Local + * opens, the rest do not. + */ +export function toTreeRows( + frame: FrameVariables, + isOpen: (id: string, openByDefault: boolean) => boolean, + resolve: Resolver = () => NOT_RECORDED, + classOf: ClassOf = () => null, +): VariableTreeRow[] { + const rows: VariableTreeRow[] = []; + + const note = (id: string, depth: number, text: string): void => { + rows.push({ kind: 'note', id, depth, expandable: false, open: false, text }); + }; + + /** The rows an open value contributes: one per property, or its raw text. */ + const children = ( + parentId: string, + depth: number, + holder: Shown, + seen: ReadonlySet, + ): void => { + const { value, raw } = holder; + if (value.kind === 'container' && value.entries.length) { + let repeats = 0; + const keys = new Set(); + value.entries.forEach((entry, at) => { + const id = `${parentId}/${at}`; + const { address } = entry; + if (entry.key !== null) { + if (keys.has(entry.key)) { + repeats++; + } else { + keys.add(entry.key); + } + } + // One already open above this row: opening it again would be a cycle. + const cycle = address !== null && seen.has(address); + const held = shown(entry.text, address, address, resolve, classOf); + const expandable = !cycle && depth < MAX_DEPTH && isExpandable(held.value); + const open = expandable && isOpen(id, false); + rows.push({ kind: 'entry', id, depth, expandable, open, key: entry.key, ...held }); + if (open) { + children(id, depth + 1, held, withAddress(seen, held.resolved ? held.address : null)); + } + }); + if (repeats) { + note( + `${parentId}/repeats`, + depth, + `${repeats} keys repeat, kept in the order the log wrote them.`, + ); + } + if (value.truncated) { + note(`${parentId}/cut`, depth, 'The log cut this collection short.'); + } + return; + } + const { text, clamped } = clampRaw(raw); + rows.push({ + kind: 'text', + id: `${parentId}/raw`, + depth, + expandable: false, + open: false, + raw: text, + }); + if (clamped) { + note(`${parentId}/clamped`, depth, `Shown to the first ${text.length} characters.`); + } + }; + + const variables = (parentId: string, depth: number, of: readonly VariableRow[]): void => { + for (const row of of) { + const id = `${parentId}/${row.name}`; + const held = shownValue(row, resolve, classOf); + const expandable = isExpandable(held.value); + const open = expandable && isOpen(id, false); + rows.push({ kind: 'variable', id, depth, expandable, open, row, ...held }); + if (open) { + children(id, depth + 1, held, withAddress(new Set(), held.resolved ? held.address : null)); + } + } + }; + + const group = (head: GroupHead, kids: (depth: number) => void): void => { + const { id, expandable = true, openByDefault = false, of = null, self = null } = head; + const open = expandable && isOpen(id, openByDefault); + rows.push({ ...head, kind: 'group', depth: 0, expandable, open, of, self }); + if (open) { + kids(1); + } + }; + + group( + { + id: 'local', + name: 'Local', + of: frame.frameLabel, + count: frame.locals.length, + openByDefault: true, + }, + (depth) => { + if (frame.locals.length) { + variables('local', depth, frame.locals); + } else { + note('local/none', depth, 'The log records no locals for this frame.'); + } + }, + ); + + // `this` is the object the frame runs on, so the group *is* that object: its + // own value when closed, its fields when open. A class with no fields has + // nothing to open, which is the honest reading of a stateless class. + if (frame.thisRow || frame.fields.length) { + const self = frame.thisRow ? shownValue(frame.thisRow, resolve, classOf) : null; + group( + { + id: 'this', + name: 'this', + count: frame.fields.length, + expandable: frame.fields.length > 0, + self: self && { ...self, declaredType: frame.thisRow?.declaredType ?? frame.thisType }, + }, + (depth) => variables('this', depth, frame.fields), + ); + } + + if (frame.statics.length) { + const total = frame.statics.reduce((sum, entry) => sum + entry.rows.length, 0); + // Statics nest one level by class: every static the log names is + // class-qualified, and a log holds thousands of them. + group({ id: 'static', name: 'Static', count: total }, () => { + for (const entry of frame.statics) { + const id = `static/${entry.className}`; + const open = isOpen(id, false); + rows.push({ + kind: 'class', + id, + depth: 1, + expandable: true, + open, + className: entry.className, + count: entry.rows.length, + }); + if (open) { + variables(id, 2, entry.rows); + } + } + }); + } + + return rows; +} + +/** The addresses open above a row, so the same object cannot open inside itself. */ +function withAddress(seen: ReadonlySet, address: string | null): ReadonlySet { + return address ? new Set([...seen, address]) : seen; +} + +/** The row that holds `id`'s children, for the key that moves out of one. */ +export function parentOf(rows: readonly VariableTreeRow[], at: number): number { + const depth = rows[at]?.depth ?? 0; + for (let above = at - 1; above >= 0; above--) { + if ((rows[above]?.depth ?? 0) < depth) { + return above; + } + } + return -1; +} diff --git a/log-viewer/src/core/log/__tests__/frameVariables.test.ts b/log-viewer/src/core/log/__tests__/frameVariables.test.ts new file mode 100644 index 000000000..f37a9087e --- /dev/null +++ b/log-viewer/src/core/log/__tests__/frameVariables.test.ts @@ -0,0 +1,728 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import { type ApexLog, parse } from 'apex-log-parser'; + +import { + apexCodeLevel, + frameVariablesFor, + recordsVariables, + variableIndexFor, +} from '../frameVariables.js'; +import { logStoreFor, type LogStore } from '../LogStore.js'; + +const SETTINGS = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; + +/** Wraps `body` in the header and footer the parser needs to build a tree. */ +function logOf(body: string, settings = SETTINGS): string { + return ( + settings + + '09:18:22.6 (100)|EXECUTION_STARTED\n' + + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + body + + '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (901000)|EXECUTION_FINISHED\n' + ); +} + +function storeOf(body: string, settings = SETTINGS): { log: ApexLog; store: LogStore } { + const log = parse(logOf(body, settings)); + return { log, store: logStoreFor(log) }; +} + +/** The eventIndex of the frame or event whose log text is `text`. */ +function indexOf(log: ApexLog, text: string): number { + const found = log.eventsById.find((event) => event.text === text); + if (!found) { + throw new Error(`no event with text ${text}`); + } + return found.eventIndex; +} + +const OUTER = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|total|Integer|true|false\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[2]|total|1\n' + + '09:18:22.6 (1300)|METHOD_ENTRY|[5]|01p|ns.Inner.step()\n' + + '09:18:22.6 (1400)|VARIABLE_ASSIGNMENT|[6]|inner|"deep"\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[5]|ns.Inner.step()\n' + + '09:18:22.6 (1600)|VARIABLE_ASSIGNMENT|[8]|total|2\n' + + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + +describe('apexCodeLevel', () => { + it('reads the level the log was captured at', () => { + expect(apexCodeLevel(storeOf('').log)).toBe('FINEST'); + expect(recordsVariables(storeOf('').log)).toBe(true); + }); + + it('tells a level that records no variables from one that does', () => { + const { log } = storeOf('', '64.0 APEX_CODE,FINE;APEX_PROFILING,NONE;DB,NONE\n'); + + expect(apexCodeLevel(log)).toBe('FINE'); + expect(recordsVariables(log)).toBe(false); + }); +}); + +describe('frameVariablesFor', () => { + it('reads the locals a frame wrote', async () => { + const { log, store } = storeOf(OUTER); + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.frameLabel).toBe('ns.Outer.run()'); + expect(frame?.locals).toEqual([ + { + name: 'total', + value: '2', + declaredType: 'Integer', + address: null, + assigned: true, + objectAddress: null, + }, + ]); + }); + + // The value is what the frame left, so the later write wins. + it('shows the last write a frame made, not the first', () => { + const { log, store } = storeOf(OUTER); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals[0]?.value).toBe('2'); + }); + + // "Only the ones on stack and would be visible": a caller's local is on the + // stack but out of scope from the method it called. + it('keeps a caller local out of the frame it called', () => { + const { log, store } = storeOf(OUTER); + + const inner = frameVariablesFor(store, indexOf(log, 'ns.Inner.step()'), null); + + expect(inner?.locals.map((row) => row.name)).toEqual(['inner']); + }); + + it('reads an event inside a frame as the log reached it', () => { + const { log, store } = storeOf(OUTER); + + // The inner call sits between the two writes to `total`. + const atInner = frameVariablesFor(store, indexOf(log, 'ns.Inner.step()'), null); + const outerIndex = indexOf(log, 'ns.Outer.run()'); + const inner = log.eventsById.find((event) => event.text === 'ns.Inner.step()')!; + const fromParent = frameVariablesFor(store, outerIndex, null); + + // Asked of the inner frame, the answer is the inner frame's own scope. + expect(atInner?.frameLabel).toBe('ns.Inner.step()'); + // Asked of the outer frame, both of its writes are in. + expect(fromParent?.locals[0]?.value).toBe('2'); + expect(inner.eventIndex).toBeGreaterThan(outerIndex); + }); + + it('splits instance fields out of the locals', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|count|1\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|this.count|7\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + // Shadowing stays visible: one name, one row in each group. + expect(frame?.locals.map((row) => row.name)).toEqual(['count']); + expect(frame?.fields).toEqual([ + { + name: 'count', + value: '7', + declaredType: null, + address: null, + assigned: true, + objectAddress: null, + }, + ]); + expect(frame?.thisType).toBe('ns.Outer'); + }); + + it('leaves a static out of the locals, since the index answers for it', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|ns.Cache.hits|4\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals).toEqual([]); + expect(frame?.fields).toEqual([]); + }); + + it('sorts a frameโ€™s locals by name', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|zeta|1\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|alpha|2\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals.map((row) => row.name)).toEqual(['alpha', 'zeta']); + }); + + it('reports no frame for an event the log does not hold', () => { + const { store } = storeOf(OUTER); + + expect(frameVariablesFor(store, 99_999, null)).toBeNull(); + }); +}); + +describe('VariableIndex', () => { + const STATICS = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_SCOPE_BEGIN|[2]|ns.Cache.hits|Integer|true|true\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|ns.Cache.hits|1\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[5]|01p|ns.Inner.step()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[6]|ns.Cache.hits|2\n' + + '09:18:22.6 (1350)|VARIABLE_ASSIGNMENT|[7]|ns.Other.flag|true\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[5]|ns.Inner.step()\n' + + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + it('groups statics by their class, both sorted', async () => { + const { log, store } = storeOf(STATICS); + const statics = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), statics); + + expect(frame?.statics).toEqual([ + { + className: 'ns.Cache', + rows: [ + { + name: 'hits', + value: '2', + declaredType: 'Integer', + address: null, + assigned: true, + objectAddress: null, + }, + ], + }, + { + className: 'ns.Other', + rows: [ + { + name: 'flag', + value: 'true', + declaredType: null, + address: null, + assigned: true, + objectAddress: null, + }, + ], + }, + ]); + }); + + // A static assigned after the frame ran was not visible from it. + it('holds back a static assigned after the frame', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|ns.Cache.hits|1\n' + + '09:18:22.6 (1200)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1300)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1400)|VARIABLE_ASSIGNMENT|[10]|ns.Later.set|9\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const statics = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), statics); + const second = frameVariablesFor(store, indexOf(log, 'ns.Second.run()'), statics); + + expect(first?.statics.map((group) => group.className)).toEqual(['ns.Cache']); + // Every static assigned by this point is visible, whichever frame wrote it. + expect(second?.statics.map((group) => group.className)).toEqual(['ns.Cache', 'ns.Later']); + }); + + it('says whether the log recorded any write at all', async () => { + const withWrites = await variableIndexFor(storeOf(OUTER).log); + const withNone = await variableIndexFor( + storeOf('09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n').log, + ); + + expect(withWrites.sawAnyWrite).toBe(true); + expect(withNone.sawAnyWrite).toBe(false); + }); + + it('builds one index per log, however many readers ask', async () => { + const { log } = storeOf(STATICS); + + const [first, second] = await Promise.all([variableIndexFor(log), variableIndexFor(log)]); + + expect(second).toBe(first); + expect(await variableIndexFor(log)).toBe(first); + }); +}); + +// Where a value would not serialise the log writes a bare address, and reports +// that same address beside a real value elsewhere, which is how nearly every +// bare address resolves. +// A frame can run with another instance of its own class on the stack. The class +// alone cannot tell them apart; the object's address can. +// Past the per-name cap the walk drops the oldest writes to a static, so a late +// frame reads the true last value rather than a stale early one. +describe('classAt does not leak the last declared class', () => { + // `this` is not redeclared on every call: the second frame here writes `this` + // with no scope declaration of its own, and must not borrow the first + // frame's class just because it was the last one the walk saw. + it('names no class for a this write its own frame never declared', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[2]|01p|()|ns.First\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.First|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0xaaa111\n' + + '09:18:22.6 (1080)|CONSTRUCTOR_EXIT|[2]|01p|()|ns.First\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[3]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[3]|this|{}|0xbbb222\n' + + '09:18:22.6 (1260)|METHOD_EXIT|[3]|ns.Second.run()\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.classAt('0xaaa111', 99_999)).toBe('ns.First'); + expect(index.classAt('0xbbb222', 99_999)).toBeNull(); + }); +}); + +describe('static write cap keeps recency, not insertion order', () => { + it('answers with the most recent write, not the earliest', async () => { + const lines: string[] = ['09:18:22.6 (300)|METHOD_ENTRY|[1]|01p|ns.Outer.run()']; + const total = 20_005; + for (let i = 0; i < total; i++) { + lines.push(`09:18:22.6 (${400 + i})|VARIABLE_ASSIGNMENT|[2]|ns.Counter.total|${i}`); + } + lines.push('09:18:22.6 (999999)|METHOD_EXIT|[1]|ns.Outer.run()'); + const { log, store } = storeOf(lines.join('\n') + '\n'); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index); + const counter = frame?.statics.find((entry) => entry.className === 'ns.Counter'); + + expect(counter?.rows.find((row) => row.name === 'total')?.value).toBe(String(total - 1)); + }, 15_000); +}); + +// A field row's own line reports its owner, never the field's own value: taking +// it as the field's address would resolve the field to the OWNER's class. +describe('a field row never borrows its owner as its own address', () => { + it('carries no object address of its own', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Outer|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0xbbb222\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|this.total|42|0xbbb222\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + const total = frame?.fields.find((row) => row.name === 'total'); + + expect(total?.value).toBe('42'); + expect(total?.objectAddress).toBeNull(); + }); +}); + +describe('two instances of one class on the stack', () => { + const TWO = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Same.outer()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.outerOnly|"A"|0xaaa111\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Same.inner()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[10]|this.innerOnly|"B"|0xbbb222\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[9]|ns.Same.inner()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Same.outer()\n'; + + it('keeps one instance out of the other instance fields', () => { + const { log, store } = storeOf(TWO); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Same.inner()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['innerOnly']); + }); + + // The caller's own frame reads as its own object, not the callee's. + it('reads the caller as its own instance', () => { + const { log, store } = storeOf(TWO); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Same.outer()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['outerOnly']); + }); + + // A frame the log never named still merges by class, which is all it has. + it('still merges by class where the log named no object', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Same.outer()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.outerOnly|"A"\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Same.inner()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[10]|this.innerOnly|"B"|0xbbb222\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[9]|ns.Same.inner()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Same.outer()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Same.inner()'), null); + + expect(frame?.fields.map((row) => row.name).sort()).toEqual(['innerOnly', 'outerOnly']); + }); +}); + +// The class of the object at an address, which the declared type often gives +// only as an interface. +describe('VariableIndex classAt', () => { + // A superclass constructor runs on the same object, inside the subclass + // constructor. Taking the later declaration would report the ancestor. + const SUBCLASS = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[17]|01p|()|ns.Handler\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[11]|this|ns.Handler|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[11]|this|{}|0x7b43a738\n' + + '09:18:22.6 (1080)|CONSTRUCTOR_ENTRY|[12]|01p|()|ns.BaseHandler\n' + + '09:18:22.6 (1090)|VARIABLE_SCOPE_BEGIN|[15]|this|ns.BaseHandler|true|false\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[15]|this|{}|0x7b43a738\n' + + '09:18:22.6 (1110)|CONSTRUCTOR_EXIT|[12]|01p|()|ns.BaseHandler\n' + + '09:18:22.6 (1120)|CONSTRUCTOR_EXIT|[17]|01p|()|ns.Handler\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + it('names the class the object was constructed as', async () => { + const { log } = storeOf(SUBCLASS); + const index = await variableIndexFor(log); + + expect(index.classAt('0x7b43a738', 99_999)).toBe('ns.Handler'); + }); + + it('names none before the log declared it', async () => { + const { log } = storeOf(SUBCLASS); + const index = await variableIndexFor(log); + + expect(index.classAt('0x7b43a738', 0)).toBeNull(); + expect(index.classAt('0xnothere', 99_999)).toBeNull(); + }); + + // An object built outside the log is only ever named by a method, which + // declares `this` as the type it was compiled against. + it('falls back to the type a method declared', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.BaseHandler.run()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[15]|this|ns.BaseHandler|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[15]|this|{}|0x7b43a738\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.BaseHandler.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.classAt('0x7b43a738', 99_999)).toBe('ns.BaseHandler'); + }); +}); + +describe('VariableIndex address resolution', () => { + const ADDRESSED = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|held|{"Id":"001"}|0xd854c6b\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|alias|0xd854c6b\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + // The row names the address; the index says what the log holds for it. The + // row's own value stays exactly what the log wrote on that line. + it('names the address a value is, and holds what the log wrote for it', async () => { + const { log, store } = storeOf(ADDRESSED); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index)!; + const alias = frame.locals.find((row) => row.name === 'alias'); + + expect(alias?.address).toBe('0xd854c6b'); + expect(alias?.value).toBe('0xd854c6b'); + expect(index.addressState('0xd854c6b', frame.cut).text).toBe('{"Id":"001"}'); + }); + + it('leaves a value that is not an address alone', async () => { + const { log, store } = storeOf(ADDRESSED); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index); + + expect(frame?.locals.find((row) => row.name === 'held')?.address).toBeNull(); + }); + + // The address names an object whose contents change, so answering with a + // later write would show a state the frame never saw. + it('answers as the value stood at the cut, not a later one', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|held|{"n":1}|0xaaa\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|alias|0xaaa\n' + + '09:18:22.6 (1150)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[10]|held|{"n":2}|0xaaa\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[11]|alias|0xaaa\n' + + '09:18:22.6 (1350)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const index = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), index)!; + const second = frameVariablesFor(store, indexOf(log, 'ns.Second.run()'), index)!; + + expect(index.addressState('0xaaa', first.cut).text).toBe('{"n":1}'); + expect(index.addressState('0xaaa', second.cut).text).toBe('{"n":2}'); + }); + + it('holds nothing for an address the log never serialised', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|alias|0xbbb\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index)!; + + expect(frame.locals[0]?.address).toBe('0xbbb'); + expect(index.addressState('0xbbb', frame.cut)).toEqual({ text: null, laterAt: null }); + }); + + // On a `this.field` line the reported address is the object the field belongs + // to, not the value the line wrote: one such address usually carries two or + // more different values. Taking it as a witness answers about an object with + // one of its fields. + it('takes no value from a field write, whose address is the owner', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.sortDir|"asc"|0xf1e2d3\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|alias|0xf1e2d3\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.addressState('0xf1e2d3', 99_999).text).toBeNull(); + }); + + // The same address on a `this` line does name the value: the variable is the + // object. + it('takes the value from a write of this, whose address is the object', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this|{"sortDir":"asc"}|0xf1e2d3\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[3]|alias|0xf1e2d3\n' + + '09:18:22.6 (1900)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.addressState('0xf1e2d3', 99_999).text).toBe('{"sortDir":"asc"}'); + }); + + // The frame answers before the index exists, so the section can show the + // scope while the walk runs. Only the log-wide statics wait for it. + it('answers the frame alone before the index is built', () => { + const { log, store } = storeOf(ADDRESSED); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + expect(frame?.locals.find((row) => row.name === 'alias')?.address).toBe('0xd854c6b'); + expect(frame?.statics).toEqual([]); + }); +}); + +// "Everything that frame could access": a name in scope with no value recorded +// is still in scope, and a field the frame never touched is still its field. +describe('frameVariablesFor whole scope', () => { + it('lists a local the log declared and never assigned', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|never|Boolean|true|false\n' + + '09:18:22.6 (1200)|VARIABLE_ASSIGNMENT|[3]|written|1\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + const never = frame?.locals.find((row) => row.name === 'never'); + + expect(never?.assigned).toBe(false); + expect(never?.value).toBe(''); + expect(never?.declaredType).toBe('Boolean'); + }); + + // A method of class X shares its `this` with another method of X that called + // it, so a field the caller set is in scope in the callee. + it('gathers fields from a caller frame of the same class', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.fromCaller|"set early"\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[5]|01p|ns.Outer.step()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[6]|this.fromCallee|7\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[5]|ns.Outer.step()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.step()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['fromCallee', 'fromCaller']); + }); + + // A different class on the stack has a different `this`. + it('leaves the fields of another class out', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Other.run()\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[2]|this.notMine|1\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[5]|01p|ns.Outer.step()\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[6]|this.mine|2\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[5]|ns.Outer.step()\n' + + '09:18:22.6 (1500)|METHOD_EXIT|[1]|ns.Other.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.step()'), null); + + expect(frame?.fields.map((row) => row.name)).toEqual(['mine']); + }); + + it('lists a static the log declared and never assigned', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1100)|VARIABLE_SCOPE_BEGIN|[2]|ns.Cache.never|Integer|true|true\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), index); + + expect(frame?.statics).toEqual([ + { + className: 'ns.Cache', + rows: [ + { + name: 'never', + value: '', + declaredType: 'Integer', + address: null, + assigned: false, + objectAddress: null, + }, + ], + }, + ]); + }); + + it('holds back a static declared after the frame ran', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|seen|1\n' + + '09:18:22.6 (1100)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_SCOPE_BEGIN|[10]|ns.Late.field|Integer|true|true\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const index = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), index); + const second = frameVariablesFor(store, indexOf(log, 'ns.Second.run()'), index); + + expect(first?.statics).toEqual([]); + expect(second?.statics.map((group) => group.className)).toEqual(['ns.Late']); + }); +}); + +/** + * A SOQL statement is not Apex code with locals of its own: `Database.query` + * issued it, and the query was built two lines above in the calling method. So + * the scope answered is the nearest frame recording a variable, and the label + * says which frame that was. + */ +describe('frameVariablesFor scope attribution', () => { + const QUERY = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|compId|"a450R000004NtIoQAK"\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|qry|"SELECT Id FROM Account"\n' + + '09:18:22.6 (1150)|STATEMENT_EXECUTE|[4]\n' + + '09:18:22.6 (1200)|SYSTEM_METHOD_ENTRY|[4]|Database.query(String)\n' + + '09:18:22.6 (1250)|SOQL_EXECUTE_BEGIN|[4]|Aggregations:0|SELECT Id FROM Account\n' + + '09:18:22.6 (1300)|SOQL_EXECUTE_END|[4]|Rows:204\n' + + '09:18:22.6 (1350)|SYSTEM_METHOD_EXIT|[4]|Database.query(String)\n' + + '09:18:22.6 (1400)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + it('answers a SOQL selection with the scope that built the query', () => { + const { log, store } = storeOf(QUERY); + + const frame = frameVariablesFor(store, indexOf(log, 'SELECT Id FROM Account'), null); + + expect(frame?.locals.map((row) => row.name)).toEqual(['compId', 'qry']); + // The label names the frame the locals belong to, so it is never a guess. + expect(frame?.frameLabel).toBe('ns.Outer.run()'); + }); + + it('answers a frame that has its own variables with its own', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|outer|1\n' + + '09:18:22.6 (1100)|METHOD_ENTRY|[5]|01p|ns.Inner.step()\n' + + '09:18:22.6 (1150)|VARIABLE_ASSIGNMENT|[6]|inner|2\n' + + '09:18:22.6 (1200)|METHOD_EXIT|[5]|ns.Inner.step()\n' + + '09:18:22.6 (1250)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Inner.step()'), null); + + // It records a variable of its own, so the climb stops there. + expect(frame?.locals.map((row) => row.name)).toEqual(['inner']); + expect(frame?.frameLabel).toBe('ns.Inner.step()'); + }); + + it('reads the query scope as it stood when the query ran', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|compId|"first"\n' + + '09:18:22.6 (1100)|SYSTEM_METHOD_ENTRY|[4]|Database.query(String)\n' + + '09:18:22.6 (1150)|SOQL_EXECUTE_BEGIN|[4]|Aggregations:0|SELECT Id FROM Account\n' + + '09:18:22.6 (1200)|SOQL_EXECUTE_END|[4]|Rows:1\n' + + '09:18:22.6 (1250)|SYSTEM_METHOD_EXIT|[4]|Database.query(String)\n' + + '09:18:22.6 (1300)|VARIABLE_ASSIGNMENT|[7]|compId|"after the query"\n' + + '09:18:22.6 (1350)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const atQuery = frameVariablesFor(store, indexOf(log, 'SELECT Id FROM Account'), null); + const atFrame = frameVariablesFor(store, indexOf(log, 'ns.Outer.run()'), null); + + // The write after the query was not in scope when the query ran. + expect(atQuery?.locals[0]?.value).toBe('"first"'); + expect(atFrame?.locals[0]?.value).toBe('"after the query"'); + }); +}); + +// An address can appear inside a value rather than as one, such as a field +// holding a reference to another object. +describe('VariableIndex nested addresses', () => { + it('resolves an address that only ever appears inside a value', async () => { + const { log } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|filter|{"RowLimit":3000}|0x6c98700c\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|view|{"m_tliFilter":"0x6c98700c"}|0x7d1781a3\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const index = await variableIndexFor(log); + + expect(index.addressState('0x6c98700c', Number.MAX_SAFE_INTEGER).text).toBe( + '{"RowLimit":3000}', + ); + }); + + it('reads it as it stood at the cut', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.First.run()\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[2]|filter|{"n":1}|0xaaa\n' + + '09:18:22.6 (1100)|VARIABLE_ASSIGNMENT|[3]|view|{"ref":"0xaaa"}\n' + + '09:18:22.6 (1150)|METHOD_EXIT|[1]|ns.First.run()\n' + + '09:18:22.6 (1200)|METHOD_ENTRY|[9]|01p|ns.Second.run()\n' + + '09:18:22.6 (1250)|VARIABLE_ASSIGNMENT|[10]|filter|{"n":2}|0xaaa\n' + + '09:18:22.6 (1300)|METHOD_EXIT|[9]|ns.Second.run()\n', + ); + const index = await variableIndexFor(log); + const first = frameVariablesFor(store, indexOf(log, 'ns.First.run()'), index); + + expect(index.addressState('0xaaa', first!.cut).text).toBe('{"n":1}'); + expect(index.addressState('0xaaa', Number.MAX_SAFE_INTEGER).text).toBe('{"n":2}'); + }); +}); diff --git a/log-viewer/src/core/log/__tests__/variableLine.test.ts b/log-viewer/src/core/log/__tests__/variableLine.test.ts new file mode 100644 index 000000000..cd577319f --- /dev/null +++ b/log-viewer/src/core/log/__tests__/variableLine.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + bareAddress, + bareAddressOf, + nestedAddressesOf, + classOf, + isStaticName, + parseVariableScope, + parseVariableWrite, + reportedAddressOf, +} from '../variableLine.js'; + +describe('parseVariableWrite', () => { + it('reads the name and the value', () => { + const write = parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|total|42'); + + expect(write).toEqual({ name: 'total', value: '42', address: null }); + }); + + it('takes the address off the end', () => { + const write = parseVariableWrite( + '11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|records|{"size":2}|0x3f2a1b', + ); + + expect(write).toEqual({ name: 'records', value: '{"size":2}', address: '0x3f2a1b' }); + }); + + // The log can leave the address field present and empty. Taken as part of the + // value it reads as `null|`. + it('drops the empty address field rather than reading it as the value', () => { + const write = parseVariableWrite('08:02:57.611 (1)|VARIABLE_ASSIGNMENT|[57]|a|null|'); + + expect(write).toEqual({ name: 'a', value: 'null', address: null }); + }); + + it('drops it from an object value too', () => { + const write = parseVariableWrite('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}|'); + + expect(write?.value).toBe('{"a":1}'); + }); + + it('still reads a bare address that has the empty field after it', () => { + expect(bareAddressOf('t|VARIABLE_ASSIGNMENT|[7]|alias|0xd854c6b|')).toBe('0xd854c6b'); + }); + + // No real value holds a pipe, but position parsing keeps one if it ever does. + it('keeps a value that holds a pipe', () => { + const write = parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|clause|Name|Id'); + + expect(write?.value).toBe('Name|Id'); + expect(write?.address).toBeNull(); + }); + + it('keeps a piped value and still takes its address', () => { + const write = parseVariableWrite( + '11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|clause|Name|Id|0xabc123', + ); + + expect(write?.value).toBe('Name|Id'); + expect(write?.address).toBe('0xabc123'); + }); + + it('reads a value that is itself hex but not an address', () => { + const write = parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|code|0xdeadbeef'); + + // No pipe after the name's, so the hex is the value rather than an address. + expect(write).toEqual({ name: 'code', value: '0xdeadbeef', address: null }); + }); + + it('holds a this-qualified field name whole', () => { + expect(parseVariableWrite('t|VARIABLE_ASSIGNMENT|[7]|this.count|3')?.name).toBe('this.count'); + }); + + it('reports no write where the line carries no value', () => { + expect(parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]|total')).toBeNull(); + }); + + it('reports no write where the line carries no name', () => { + expect(parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT|[7]||42')).toBeNull(); + expect(parseVariableWrite('11:22:33.1 (1)|VARIABLE_ASSIGNMENT')).toBeNull(); + }); +}); + +describe('parseVariableScope', () => { + it('reads the declared type and the static flag', () => { + const scope = parseVariableScope( + '11:22:33.1 (1)|VARIABLE_SCOPE_BEGIN|[7]|Account.cache|Map|true|true', + ); + + expect(scope).toEqual({ + name: 'Account.cache', + declaredType: 'Map', + isStatic: true, + }); + }); + + it('reads a local as not static', () => { + const scope = parseVariableScope( + '11:22:33.1 (1)|VARIABLE_SCOPE_BEGIN|[7]|total|Integer|false|false', + ); + + expect(scope).toEqual({ name: 'total', declaredType: 'Integer', isStatic: false }); + }); + + // The type holds commas, so the flags are read from the right, not by count. + it('keeps a generic type whole', () => { + const scope = parseVariableScope( + 't|VARIABLE_SCOPE_BEGIN|[7]|byKey|Map>|false|true', + ); + + expect(scope?.declaredType).toBe('Map>'); + expect(scope?.isStatic).toBe(true); + }); + + it('reports no scope for a line too short to carry one', () => { + expect(parseVariableScope('t|VARIABLE_SCOPE_BEGIN|[7]|total|Integer|false')).toBeNull(); + }); +}); + +describe('static names', () => { + it('names the class a static belongs to', () => { + expect(classOf('Account.cache')).toBe('Account'); + expect(classOf('ns.Account.cache')).toBe('ns.Account'); + expect(classOf('total')).toBeNull(); + }); + + it('tells a static from a field and a local', () => { + expect(isStaticName('Account.cache')).toBe(true); + expect(isStaticName('this.count')).toBe(false); + expect(isStaticName('total')).toBe(false); + }); +}); + +describe('addresses', () => { + it('reads a value that is only an address', () => { + expect(bareAddress('0xd854c6b')).toBe('0xd854c6b'); + expect(bareAddress(' 0xd854c6b ')).toBe('0xd854c6b'); + expect(bareAddress('{"a":1}')).toBeNull(); + expect(bareAddress('42')).toBeNull(); + }); + + it('reads one straight off a line', () => { + expect(bareAddressOf('t|VARIABLE_ASSIGNMENT|[7]|alias|0xd854c6b')).toBe('0xd854c6b'); + expect(bareAddressOf('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}|0xd854c6b')).toBeNull(); + }); + + // The walk reads every line of the log, and a value can be very long. + it('gives up on a long value before it slices it', () => { + const long = `t|VARIABLE_ASSIGNMENT|[7]|big|${'x'.repeat(70_000)}`; + + expect(bareAddressOf(long)).toBeNull(); + }); + + it('reads the address a line reported for its value', () => { + expect(reportedAddressOf('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}|0xd854c6b')).toBe('0xd854c6b'); + expect(reportedAddressOf('t|VARIABLE_ASSIGNMENT|[7]|held|{"a":1}')).toBeNull(); + // A piped value whose tail is not an address. + expect(reportedAddressOf('t|VARIABLE_ASSIGNMENT|[7]|clause|Name|Id')).toBeNull(); + }); +}); + +describe('nestedAddressesOf', () => { + it('finds an address a value names inside itself', () => { + const line = 't|VARIABLE_ASSIGNMENT|[7]|view|{"m_tliFilter":"0x6c98700c","n":1}'; + + expect(nestedAddressesOf(line)).toEqual(['0x6c98700c']); + }); + + // Every assignment reports one, so taking them all would hold the whole log. + it('leaves out the address the line reports for its own value', () => { + const line = 't|VARIABLE_ASSIGNMENT|[7]|view|{"n":1}|0x7d1781a3'; + + expect(nestedAddressesOf(line)).toEqual([]); + }); + + it('finds both when a value names one and the line reports another', () => { + const line = 't|VARIABLE_ASSIGNMENT|[7]|view|{"ref":"0x6c98700c"}|0x7d1781a3'; + + expect(nestedAddressesOf(line)).toEqual(['0x6c98700c']); + }); + + it('finds nothing in a value that names none', () => { + expect(nestedAddressesOf('t|VARIABLE_ASSIGNMENT|[7]|n|42')).toEqual([]); + }); +}); diff --git a/log-viewer/src/core/log/__tests__/variableValue.test.ts b/log-viewer/src/core/log/__tests__/variableValue.test.ts new file mode 100644 index 000000000..e101340a6 --- /dev/null +++ b/log-viewer/src/core/log/__tests__/variableValue.test.ts @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + clampRaw, + isExpandable, + parseVariableValue, + previewOf, + RAW_CLAMP_CHARS, +} from '../variableValue.js'; + +describe('parseVariableValue', () => { + it('reads a number, the commonest value', () => { + expect(parseVariableValue('42')).toEqual({ kind: 'literal', text: '42' }); + expect(parseVariableValue('-1.5')).toEqual({ kind: 'literal', text: '-1.5' }); + }); + + it('reads a boolean and a null', () => { + expect(parseVariableValue('true')).toEqual({ kind: 'literal', text: 'true' }); + expect(parseVariableValue('null')).toEqual({ kind: 'literal', text: 'null' }); + }); + + it('reads a value the log left blank', () => { + expect(parseVariableValue('')).toEqual({ kind: 'empty' }); + expect(parseVariableValue(' ')).toEqual({ kind: 'empty' }); + }); + + // A bare address means the value would not serialise. Calling it an empty + // object would claim the log knew it held nothing. + it('reads a bare address as an address', () => { + expect(parseVariableValue('0x3f2a1b')).toEqual({ kind: 'address', text: '0x3f2a1b' }); + }); + + it('reads a one-level object', () => { + const value = parseVariableValue('{"name":"Acme","count":2}'); + + expect(value).toEqual({ + kind: 'container', + brackets: '{}', + truncated: false, + fromString: false, + entries: [ + { key: 'name', text: '"Acme"', address: null }, + { key: 'count', text: '2', address: null }, + ], + }); + }); + + it('reads a list', () => { + const value = parseVariableValue('[1, 2, 3]'); + + expect(value).toMatchObject({ brackets: '[]' }); + expect(value).toMatchObject({ + entries: [ + { key: null, text: '1' }, + { key: null, text: '2' }, + { key: null, text: '3' }, + ], + }); + }); + + it('reads an empty object as an empty object', () => { + const value = parseVariableValue('{}'); + + expect(value).toEqual({ + kind: 'container', + brackets: '{}', + entries: [], + truncated: false, + fromString: false, + }); + expect(previewOf(value)).toBe('{}'); + expect(isExpandable(value)).toBe(false); + }); + + it('reads an empty list', () => { + expect(previewOf(parseVariableValue('[]'))).toBe('[]'); + }); + + // A Map serialises with repeats, so dropping them would hide entries the + // transaction really held. JSON.parse loses them in silence. + it('keeps duplicate keys, in the order the log wrote them', () => { + const value = parseVariableValue('{"key":"a","key":"b","key":"c"}'); + + expect(value).toMatchObject({ + entries: [ + { key: 'key', text: '"a"' }, + { key: 'key', text: '"b"' }, + { key: 'key', text: '"c"' }, + ], + }); + }); + + it('reads a string', () => { + const value = parseVariableValue('"Acme Corp"'); + + expect(value).toMatchObject({ kind: 'string', inner: 'Acme Corp', toStringLike: false }); + }); + + it('marks a string the log cut short', () => { + const value = parseVariableValue('"first20charsofthisva (10 more) ..."'); + + expect(value).toMatchObject({ kind: 'string', truncated: true }); + // The marker is the log's own text and stays in it. + expect(previewOf(value, 200)).toContain('(10 more) ...'); + }); + + it('marks an Apex toString() that landed inside a string', () => { + const value = parseVariableValue('"{Id=001, Name=Acme}"'); + + expect(value).toMatchObject({ kind: 'string', toStringLike: true }); + }); + + it('marks a collection the log cut short', () => { + const value = parseVariableValue('{"a":1, "b":2, ...}'); + + expect(value).toMatchObject({ truncated: true, entries: [{ key: 'a' }, { key: 'b' }] }); + expect(previewOf(value, 200)).toBe('{a: 1, b: 2, โ€ฆ}'); + }); + + it('keeps a nested value verbatim rather than reading into it', () => { + const value = parseVariableValue('{"child":{"aot":"0x1f"},"n":1}'); + + // One level: the log holds no more, so a scan would find nothing deeper. + expect(value).toMatchObject({ + entries: [ + { key: 'child', text: '{"aot":"0x1f"}' }, + { key: 'n', text: '1' }, + ], + }); + }); + + it('does not split on a comma inside a string', () => { + const value = parseVariableValue('{"address":"1 High St, London","n":2}'); + + expect(value).toMatchObject({ + entries: [ + { key: 'address', text: '"1 High St, London"' }, + { key: 'n', text: '2' }, + ], + }); + }); + + it('does not split on a comma inside a nested value', () => { + expect(parseVariableValue('{"a":{"x":1,"y":2},"n":3}')).toMatchObject({ + entries: [ + { key: 'a', text: '{"x":1,"y":2}' }, + { key: 'n', text: '3' }, + ], + }); + }); + + // One escaped quote before the comma: a scanner that does not skip the escape + // reads the string as closed and splits the entry in half. + it('reads an escaped quote without losing the key', () => { + expect(parseVariableValue('{"quote":"say \\"hi, there","n":1}')).toMatchObject({ + entries: [ + { key: 'quote', text: '"say \\"hi, there"' }, + { key: 'n', text: '1' }, + ], + }); + }); + + it('holds an unquoted key as text rather than guessing one', () => { + expect(parseVariableValue('{a=1}')).toMatchObject({ entries: [{ key: null, text: 'a=1' }] }); + }); + + // A logged value can be very long. Scanning that shape earns nothing, and the + // raw text is the honest answer. + it('hands back a huge value as text rather than scanning it', () => { + const huge = `{${'"k":"' + 'x'.repeat(70_000) + '"'}}`; + + expect(parseVariableValue(huge).kind).toBe('literal'); + }); +}); + +describe('isExpandable', () => { + it('offers an expander only where a row cannot hold the value', () => { + expect(isExpandable(parseVariableValue('42'))).toBe(false); + expect(isExpandable(parseVariableValue('0x3f2a'))).toBe(false); + expect(isExpandable(parseVariableValue('{}'))).toBe(false); + expect(isExpandable(parseVariableValue('{"a":1}'))).toBe(true); + expect(isExpandable(parseVariableValue(`"${'x'.repeat(200)}"`))).toBe(true); + expect(isExpandable(parseVariableValue('"short"'))).toBe(false); + }); +}); + +describe('an entry that names an address', () => { + // The log quotes a nested address, and the tree needs it to resolve the object. + it('reads it off a quoted entry', () => { + const value = parseVariableValue('{"m_tliFilter":"0x6c98700c","n":1}'); + + expect(value).toMatchObject({ + entries: [ + { key: 'm_tliFilter', address: '0x6c98700c' }, + { key: 'n', address: null }, + ], + }); + }); + + it('reads it off a bare list entry', () => { + expect(parseVariableValue('[0x6c98700c]')).toMatchObject({ + entries: [{ key: null, address: '0x6c98700c' }], + }); + }); +}); + +describe('a string holding JSON', () => { + it('reads it as the object it holds', () => { + const value = parseVariableValue('"{\\"a\\":1}"'); + + expect(value).toMatchObject({ + kind: 'container', + brackets: '{}', + fromString: true, + entries: [{ key: 'a', text: '1' }], + }); + }); + + it('reads an unescaped one too', () => { + expect(parseVariableValue('"{"a":1}"')).toMatchObject({ kind: 'container', fromString: true }); + }); + + it('reads a list it holds', () => { + expect(parseVariableValue('"[1,2]"')).toMatchObject({ brackets: '[]', fromString: true }); + }); + + // An Apex toString() is text. Reading structure out of one would claim the log + // recorded something it did not. + it('leaves an Apex toString() as text', () => { + expect(parseVariableValue('"{accountid=AccountId, name=Name}"')).toMatchObject({ + kind: 'string', + toStringLike: true, + }); + }); + + it('leaves a braced string with no key as text', () => { + expect(parseVariableValue('"{not json}"')).toMatchObject({ kind: 'string' }); + }); + + it('leaves an ordinary string alone', () => { + expect(parseVariableValue('"Acme"')).toMatchObject({ kind: 'string', inner: 'Acme' }); + }); +}); + +describe('previewOf', () => { + it('clamps a long value to one row', () => { + const preview = previewOf(parseVariableValue(`"${'x'.repeat(500)}"`)); + + expect(preview.length).toBeLessThanOrEqual(82); + expect(preview.endsWith('โ€ฆ')).toBe(true); + }); + + it('shows nothing for a value the log left blank', () => { + expect(previewOf(parseVariableValue(''))).toBe(''); + }); +}); + +describe('clampRaw', () => { + it('cuts a value too big to lay out, and says so', () => { + const clamped = clampRaw('x'.repeat(RAW_CLAMP_CHARS + 1)); + + expect(clamped.clamped).toBe(true); + expect(clamped.text).toHaveLength(RAW_CLAMP_CHARS); + }); + + it('leaves a value that fits alone', () => { + expect(clampRaw('{"a":1}')).toEqual({ text: '{"a":1}', clamped: false }); + }); +}); diff --git a/log-viewer/src/core/log/frameVariables.ts b/log-viewer/src/core/log/frameVariables.ts new file mode 100644 index 000000000..a61d0fb41 --- /dev/null +++ b/log-viewer/src/core/log/frameVariables.ts @@ -0,0 +1,797 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LOG_LEVEL, type ApexLog, type LogEvent } from 'apex-log-parser'; + +import { + CHECK_EVERY, + frameBudget, + type FrameBudgetOptions, + type Tick, +} from '../utility/FrameBudget.js'; +import type { LogStore, Stack } from './LogStore.js'; +import { + bareAddress, + bareAddressOf, + classOf, + isStaticName, + parseVariableScope, + nestedAddressesOf, + parseVariableWrite, + reportedAddressOf, + shortName, + variableNameOf, +} from './variableLine.js'; + +/** + * What Apex could reach from a frame, read from what the log recorded. + * + * Everything in scope, not only what the frame itself wrote: + * + * - **Local** โ€” the frame's own writes, plus the locals it declared and never + * assigned, which are in scope at their default. + * - **this** โ€” the fields of the instance, gathered from every frame on the + * stack that shares the frame's class, since those share its `this`. + * - **Static** โ€” every static assigned anywhere by this point, plus those + * declared and never assigned. A static lives for the transaction. + * + * A calling frame's *locals* stay out: they are on the stack but out of scope + * from a called method. + * + * Every value is the last write at or before the frame's cut point, so it reads + * as the frame saw it. + */ + +const ASSIGNMENT = 'VARIABLE_ASSIGNMENT'; +const SCOPE_BEGIN = 'VARIABLE_SCOPE_BEGIN'; +const CONSTRUCTOR_ENTRY = 'CONSTRUCTOR_ENTRY'; +const FIELD_PREFIX = 'this.'; + +/** + * Writes held per static name before the oldest is dropped, so a static + * reassigned in a long loop cannot grow the index without bound. + * + * Dropping the *oldest* keeps a late frame's reading correct, which is the + * common case; a frame whose cut falls before every retained write for that + * name reads it as unassigned, which is the honest answer once its history is + * gone. A real log holds at most a few thousand static lines in total, so this + * is headroom, not a working limit. + */ +const MAX_WRITES_PER_STATIC = 10_000; + +/** Distinct static names held. A codebase compiles a bounded number of static + * fields, so this only bites a pathological log. */ +const MAX_STATIC_NAMES = 50_000; + +/** One variable, as it stood at the frame. */ +export interface VariableRow { + /** What the row is called: bare for a local, the field name under `this`, the + * field name under its class for a static. */ + name: string; + /** The value exactly as the log wrote it, empty where it wrote none. */ + value: string; + declaredType: string | null; + /** Where the log said the value lives, where it wrote an address in place of a + * value. Resolved by {@link VariableIndex.addressState}, never here. */ + address: string | null; + /** False where the log declared the name and never wrote it: it is in scope at + * its default, and the log records no value for it. */ + assigned: boolean; + /** The address of the object this row's value *is*, where the log named one. + * A `this.field` line reports the object the field belongs to instead, so it + * contributes none. */ + objectAddress: string | null; +} + +/** What a whole-log walk takes. + * + * Not the full {@link FrameBudgetOptions}: the walk cannot be abandoned, so it + * must not advertise a signal it would ignore. */ +type WalkOptions = Pick; + +/** What the log holds for one address, as a frame stood. */ +export interface AddressState { + /** The value the log wrote for it at or before the frame, or null. */ + text: string | null; + /** Where the log first describes the object after the frame, so the reader can + * be sent to it. Null where the log describes it nowhere. */ + laterAt: number | null; +} + +/** The log holds nothing for the address, at any point. */ +export const NOT_RECORDED: AddressState = { text: null, laterAt: null }; + +/** One class's statics, since every static the log names is class-qualified. */ +export interface StaticClass { + className: string; + rows: VariableRow[]; +} + +export interface FrameVariables { + /** The frame these belong to, as the call tree names it. */ + frameLabel: string; + /** The point on the eventIndex axis these were read at, so a reader resolving + * an address asks for the value as it stood here. */ + cut: number; + /** The class owning the fields, where the frame's own name gives it. */ + thisType: string | null; + locals: VariableRow[]; + /** The object the frame runs on, where the log wrote it. Its own row, not a + * local: `this` is the instance, and the fields below it are its parts. */ + thisRow: VariableRow | null; + fields: VariableRow[]; + statics: StaticClass[]; + /** The frame or one of its callers ran past the end of a truncated log, so a + * missing write may be unrecorded rather than absent. */ + truncated: boolean; +} + +/** A declaration the log made, and where it made it. */ +interface Declared { + declaredType: string; + eventIndex: number; +} + +/** The Apex Code log level this log was captured at, or null if it says none. */ +export function apexCodeLevel(log: ApexLog): string | null { + return log.debugLevels.find((level) => level.logCategory === 'APEX_CODE')?.logLevel ?? null; +} + +/** True where the log was captured at the only level that records variables. */ +export function recordsVariables(log: ApexLog): boolean { + return apexCodeLevel(log) === LOG_LEVEL.Finest; +} + +/** + * What the log says about variables, indexed so any frame is answered by a + * search rather than a walk. + * + * Holds the two things the log only gives up at log scope: + * + * - **Statics.** A static lives for the whole transaction and is visible + * wherever its class is. A local cannot be indexed the same way: the log never + * emits `VARIABLE_SCOPE_END`, so two locals of one name in different blocks + * cannot be told apart outside their own frame. + * - **Addresses.** Where a value would not serialise the log writes a bare + * address, and reports that same address beside a real value elsewhere. Nearly + * every bare address resolves that way, which turns unreadable hex into the + * object. + * A `this.field` line is no such witness: its reported address is the object + * the field belongs to, not the value the line wrote. + */ +export class VariableIndex { + /** The log recorded at least one write, so an empty answer means an empty + * scope rather than a log that records nothing. */ + readonly sawAnyWrite: boolean; + /** A static name went unrecorded past {@link MAX_STATIC_NAMES}. */ + readonly capped: boolean; + + private readonly _writes: Map; + private readonly _declared: Map; + private readonly _byAddress: Map; + private readonly _classes: Map; + + private constructor( + writes: Map, + declared: Map, + byAddress: Map, + classes: Map, + sawAnyWrite: boolean, + capped: boolean, + ) { + this._writes = writes; + this._declared = declared; + this._byAddress = byAddress; + this._classes = classes; + this.sawAnyWrite = sawAnyWrite; + this.capped = capped; + } + + /** Reads the log, yielding between slices so the UI keeps its frames. */ + static async build(log: ApexLog, options: WalkOptions): Promise { + const tick = frameBudget(options); + const writes = new Map(); + const declared = new Map(); + // Only the addresses a value is ever written *as*. Every assignment reports + // an address, so indexing all of them would hold a quarter of a million + // events to answer about two thousand. + const wanted = new Set(); + // Address to the class of the object living there. A frame declares its own + // object as `this`, so a `this` write names the class the address holds. + const classes = new Map(); + // The class a frame's own `VARIABLE_SCOPE_BEGIN|this|โ€ฆ` declared, keyed by + // that frame. Walk-wide and keyed by name alone would leak: `this` is not + // redeclared on every call, so a frame with no declaration of its own would + // otherwise borrow whichever class was declared last, anywhere in the log. + const thisClassOf = new Map(); + let sawAnyWrite = false; + let cappedNames = false; + + await eachEvent(log, tick, (event) => { + if (event.type === ASSIGNMENT) { + sawAnyWrite = true; + // The name only: the value is read when a row renders it. + const name = variableNameOf(event.logLine); + if (name && isStaticName(name)) { + if (writes.has(name) || writes.size < MAX_STATIC_NAMES) { + pushCapped(writes, name, event, MAX_WRITES_PER_STATIC); + } else { + cappedNames = true; + } + } + if (name === 'this') { + const frame = event.parent; + const thisClass = frame && thisClassOf.get(frame); + const object = thisClass && reportedAddressOf(event.logLine); + if (frame && object) { + keepClass(classes, object, thisClass, { + at: event.eventIndex, + until: lastDescendantIndex(frame), + constructed: frame.type === CONSTRUCTOR_ENTRY, + }); + } + } + const address = bareAddressOf(event.logLine); + if (address) { + wanted.add(address); + } else { + // An address named inside a value, such as a field holding a + // reference. + for (const nested of nestedAddressesOf(event.logLine)) { + wanted.add(nested); + } + } + } else if (event.type === SCOPE_BEGIN) { + const scope = parseVariableScope(event.logLine); + // The declared type of `this` is the concrete class, where the value's + // own declared type is often only an interface. + if (scope?.name === 'this' && scope.declaredType && event.parent) { + thisClassOf.set(event.parent, scope.declaredType); + } + if (scope?.isStatic) { + const first = declared.get(scope.name); + // The earliest declaration: the point from which it is in scope. + if (!first || first.eventIndex > event.eventIndex) { + declared.set(scope.name, { + declaredType: scope.declaredType, + eventIndex: event.eventIndex, + }); + } + } + } + }); + + const byAddress = new Map(); + // A second read, and only where the first found an address to resolve. + if (wanted.size) { + await eachEvent(log, tick, (event) => { + if (event.type !== ASSIGNMENT) { + return; + } + // The reported address names the value only where the variable is that + // value. A `this.field` line reports the *owner*, so one such address + // usually carries two or more different values, and indexing it would + // answer about an object with one of its fields. + const name = variableNameOf(event.logLine); + if (!name || name.startsWith(FIELD_PREFIX)) { + return; + } + const address = reportedAddressOf(event.logLine); + // A line whose value *is* the address tells us nothing about it. + if (address && wanted.has(address) && !bareAddressOf(event.logLine)) { + push(byAddress, address, event); + } + }); + } + + // The walk pops what it pushed, so a key's writes come back out of order. + for (const found of [...writes.values(), ...byAddress.values()]) { + found.sort((left, right) => left.eventIndex - right.eventIndex); + } + return new VariableIndex(writes, declared, byAddress, classes, sawAnyWrite, cappedNames); + } + + /** + * Every static in scope at `cut`, grouped by class, both sorted. + * + * A static declared by this point but never assigned is in scope at its + * default, so it is listed without a value rather than left out. + */ + at(cut: number): StaticClass[] { + const byClass = new Map(); + const listed = new Set(); + const add = (name: string, row: VariableRow): void => { + listed.add(name); + push(byClass, classOf(name) ?? '', row); + }; + + for (const [name, writes] of this._writes) { + const last = lastAtOrBefore(writes, cut); + if (last) { + add( + name, + rowFor(shortName(name), last, this._declared.get(name), reportedAddressOf(last.logLine)), + ); + } + } + for (const [name, declared] of this._declared) { + if (!listed.has(name) && declared.eventIndex <= cut) { + add(name, unassignedRow(shortName(name), declared)); + } + } + return [...byClass] + .map(([className, rows]) => ({ className, rows: rows.sort(byName) })) + .sort((left, right) => left.className.localeCompare(right.className)); + } + + /** + * What the log holds for `address` as the frame stood. + * + * The address is only the identity the runtime printed for the reference. The + * object's contents are a separate event, and reach the log only where Apex + * assigned that object to a variable and could serialise it. So a log may hold + * the contents from before the frame, from after it, or not at all. + */ + addressState(address: string, cut: number): AddressState { + const writes = this._byAddress.get(address); + if (!writes?.length) { + return NOT_RECORDED; + } + const last = lastAtOrBefore(writes, cut); + return last + ? { text: parseVariableWrite(last.logLine)?.value ?? null, laterAt: null } + : { text: null, laterAt: writes[0]?.eventIndex ?? null }; + } + + /** + * The class of the object at `address` as the frame stood, or null where the + * log names none. + * + * More telling than the declared type: an interface-typed variable holding a + * `WithoutSharing` reads as `fflib_IDatabaseWriter` in its declaration, and + * the log records the implementation only on that object's own frame. + */ + classAt(address: string, cut: number): string | null { + const seen = this._classes.get(address); + if (!seen) { + return null; + } + const after = firstIndexWhere(seen.length, (index) => seen[index]!.at > cut); + return after ? (seen[after - 1]?.className ?? null) : null; + } +} + +/** + * What is in scope at `eventIndex`, or null where the log has no such event or + * it sits in no frame. + * + * Pass `index` as null to answer the frame alone, before the index is built. + */ +export function frameVariablesFor( + store: LogStore, + eventIndex: number, + index: VariableIndex | null, +): FrameVariables | null { + const selected = store.eventByIndex(eventIndex); + const stack = store.stackByEventIndex(eventIndex); + const frame = stack[stack.length - 1]; + if (!selected || !frame) { + return null; + } + + // The cut is on the eventIndex axis, never on a timestamp: real logs repeat + // timestamps, so they order nothing. A frame reads as it finished, an event + // inside one as the log reached it. + const cut = selected.isParent ? lastDescendantIndex(selected) : selected.eventIndex; + // Not always the frame the selection sits in: see `scopeFrame`. + const { frame: scope, scan: own } = scopeFrame(stack, cut, frame); + const thisType = classFromFrame(scope.text); + + const locals: VariableRow[] = []; + let thisRow: VariableRow | null = null; + for (const [name, write] of own.writes) { + // A static assigned here is still a static, and the index answers for it. + if (name !== 'this' && name.includes('.')) { + continue; + } + const row = rowFor(name, write, own.declared.get(name), reportedAddressOf(write.logLine)); + // `this` is the instance the frame runs on, so it heads its own group. + if (name === 'this') { + thisRow = row; + } else { + locals.push(row); + } + } + for (const [name, declared] of own.declared) { + if (name !== 'this' && !own.writes.has(name)) { + locals.push(unassignedRow(name, declared)); + } + } + + const fields: VariableRow[] = []; + // Every name here is `this.field`, so its line reports the owner, not the + // field's own value. + for (const [name, write] of fieldWrites(stack, scope, own, thisType, cut)) { + fields.push(rowFor(shortName(name), write, undefined, null)); + } + + return { + frameLabel: scope.text, + cut, + thisType, + locals: locals.sort(byName), + thisRow, + fields: fields.sort(byName), + statics: index?.at(cut) ?? [], + truncated: stack.some((entry) => entry.isTruncated) || selected.isTruncated, + }; +} + +const indexes = new WeakMap(); +const building = new WeakMap>(); + +/** + * The variable index for `log`, read once and then shared. + * + * Built on the first ask rather than at load: a 100MB log must not pay for a + * section nobody opened. + */ +export function variableIndexFor(log: ApexLog, options: WalkOptions = {}): Promise { + const held = indexes.get(log); + if (held) { + return Promise.resolve(held); + } + let inFlight = building.get(log); + if (!inFlight) { + inFlight = VariableIndex.build(log, options) + .then((index) => { + indexes.set(log, index); + return index; + }) + // A failed build must not be cached, or nothing would ever retry. + .finally(() => building.delete(log)); + building.set(log, inFlight); + } + return inFlight; +} + +/** What one frame wrote and declared, up to `cut`. */ +interface FrameScan { + /** Name to its last write at or before the cut. */ + writes: Map; + /** Name to its first declaration, for the locals the frame never assigned. */ + declared: Map; + /** The frame recorded a variable line of its own, so it owns a scope even + * where every line was a static the index answers for. */ + sawAny: boolean; +} + +/** + * Reads one frame's own lines back from `cut`. + * + * Backwards, so the first write seen for a name is the last one the frame made. + * It reads names only: a value is parsed for the handful of rows that win, not + * for every line, which matters in a frame holding a hundred thousand of them. + */ +function scanFrame(frame: LogEvent, cut: number): FrameScan { + const writes = new Map(); + const declared = new Map(); + let sawAny = false; + const children = frame.children; + for (let at = firstIndexWhere(children.length, (i) => children[i]!.eventIndex > cut); at--;) { + const child = children[at]!; + if (child.type === ASSIGNMENT || child.type === SCOPE_BEGIN) { + sawAny = true; + } + if (child.type === ASSIGNMENT) { + const name = variableNameOf(child.logLine); + if (name && !writes.has(name)) { + writes.set(name, child); + } + } else if (child.type === SCOPE_BEGIN) { + const scope = parseVariableScope(child.logLine); + // Only a local: a static is in scope everywhere, so the index holds it. + if (scope && !scope.isStatic) { + declared.set(scope.name, { + declaredType: scope.declaredType, + eventIndex: child.eventIndex, + }); + } + } + } + return { writes, declared, sawAny }; +} + +/** Only the writes that answer for the object a frame runs on. + * + * For a caller frame, whose locals are out of scope and whose declarations are + * never read: a frame can hold hundreds of thousands of children, so the rest + * is thrown away. */ +function thisWritesOf(frame: LogEvent, cut: number): Map { + const writes = new Map(); + const children = frame.children; + for (let at = firstIndexWhere(children.length, (i) => children[i]!.eventIndex > cut); at--;) { + const child = children[at]!; + if (child.type !== ASSIGNMENT) { + continue; + } + const name = variableNameOf(child.logLine); + if (name && (name === 'this' || name.startsWith(FIELD_PREFIX)) && !writes.has(name)) { + writes.set(name, child); + } + } + return writes; +} + +/** + * The frame whose locals are in scope at the selection. + * + * Not always the frame the selection sits in. A SOQL statement, a + * `STATEMENT_EXECUTE` and a system call such as `Database.query(String)` are not + * Apex code with locals of their own: they are issued *by* Apex code, and + * answering from them would report an empty scope for a query built two lines + * above it. + * + * So the nearest frame up the stack recording a variable of its own owns the + * scope. The group names that frame, so which scope answered is never a guess. + */ +function scopeFrame(stack: Stack, cut: number, innermost: LogEvent): Scope { + for (let at = stack.length; at--;) { + const frame = stack[at]; + if (frame) { + const scan = scanFrame(frame, cut); + if (scan.sawAny) { + return { frame, scan }; + } + } + } + return { frame: innermost, scan: scanFrame(innermost, cut) }; +} + +/** The frame that owns the scope, and the read that found it. */ +interface Scope { + frame: LogEvent; + scan: FrameScan; +} + +/** + * The instance fields in scope, from every frame on the stack running on the + * same object. + * + * A method of class X has a `this` of class X, and a method it calls on the same + * instance sees the same fields. So a field the constructor set and this frame + * never touched is still in scope, and reading only this frame's own writes + * would leave it out. + * + * The class alone does not settle it: a frame can run with another *instance* of + * its own class on the stack, and merging those would + * report one object's fields as another's. So where both frames name their + * object, the addresses decide. + */ +function fieldWrites( + stack: Stack, + frame: LogEvent, + own: FrameScan, + thisType: string | null, + cut: number, +): Map { + const found = new Map(); + const mine = thisAddressOf(own.writes); + for (const entry of stack) { + if (classFromFrame(entry.text) !== thisType) { + continue; + } + const writes = entry === frame ? own.writes : thisWritesOf(entry, cut); + const theirs = thisAddressOf(writes); + if (mine && theirs && mine !== theirs) { + continue; + } + for (const [name, write] of writes) { + if (!name.startsWith(FIELD_PREFIX)) { + continue; + } + const held = found.get(name); + // The latest write wins, whichever frame on the stack made it. + if (!held || held.eventIndex < write.eventIndex) { + found.set(name, write); + } + } + } + return found; +} + +/** + * The object a frame is running on, or null where the log never named it. + * + * Both a `this` write and a `this.field` write report the owning object's + * address, so either answers. + */ +function thisAddressOf(writes: ReadonlyMap): string | null { + for (const [name, write] of writes) { + if (name === 'this' || name.startsWith(FIELD_PREFIX)) { + const address = reportedAddressOf(write.logLine); + if (address) { + return address; + } + } + } + return null; +} + +/** + * One row, from a write the caller already knows the shape of. + * + * `objectAddress` is the caller's call: a `this.field` line reports the field's + * *owner*, never the field's own value, so a field row must always be built + * with `null`. Deciding this inside `rowFor` from the row's own `name` was the + * bug behind #373 review finding 1 โ€” by the time a field's name reaches here it + * has already been shortened to drop the `this.` that the decision needed. + */ +function rowFor( + name: string, + write: LogEvent, + declared: Declared | undefined, + objectAddress: string | null, +): VariableRow { + const value = parseVariableWrite(write.logLine)?.value ?? ''; + return { + name, + value, + declaredType: declared?.declaredType ?? null, + address: bareAddress(value), + assigned: true, + objectAddress, + }; +} + +/** One address, and the class of the object that lived there from `at` until the + * end of the frame that named it. */ +interface ClassAt { + at: number; + until: number; + className: string; +} + +/** Where a `this` write named its class, and whether it was a construction. */ +interface NamedAt { + at: number; + until: number; + constructed: boolean; +} + +/** + * Records the class of the object at an address. + * + * Two rules keep the answer the object's own class rather than an ancestor: + * + * - a write inside a run already recorded adds nothing. A superclass + * constructor runs on the same object, inside the subclass constructor, and + * would otherwise overwrite the concrete class with its parent; + * - only a construction may start a new run. A method declares `this` as the + * type it was compiled against, which can be an ancestor, so it names the + * class only for an object constructed outside the log. + * + * An address is reused once its object is collected, hence runs rather than one + * class per address. + */ +function keepClass( + classes: Map, + address: string, + className: string, + named: NamedAt, +): void { + const run = { at: named.at, until: named.until, className }; + const seen = classes.get(address); + if (!seen) { + classes.set(address, [run]); + return; + } + const last = seen[seen.length - 1]!; + if (named.at <= last.until || !named.constructed) { + return; + } + if (last.className === className) { + last.until = Math.max(last.until, named.until); + return; + } + seen.push(run); +} + +/** A name the log declared and never wrote: in scope, at its default. */ +function unassignedRow(name: string, declared: Declared): VariableRow { + return { + name, + value: '', + declaredType: declared.declaredType, + address: null, + assigned: false, + objectAddress: null, + }; +} + +/** Visits every event below `log`, handing the frame back between slices. */ +async function eachEvent( + log: ApexLog, + tick: Tick, + visit: (event: LogEvent) => void, +): Promise { + const stack = [...log.children].reverse(); + for (let walked = 0; stack.length; walked++) { + if (walked % CHECK_EVERY === 0) { + await tick(); + } + const event = stack.pop()!; // non-empty: the loop condition just checked + visit(event); + // Pushed back to front, so popping hands them over in log order: a line's + // meaning can depend on one above it, such as a declaration before a write. + for (let at = event.children.length; at--;) { + stack.push(event.children[at]!); + } + } +} + +function push(into: Map, key: string, value: T): void { + const found = into.get(key); + if (found) { + found.push(value); + } else { + into.set(key, [value]); + } +} + +/** {@link push}, dropping the oldest once a name holds more than `cap`. + * + * Trims to `cap` only once the array reaches double it, so the cost of + * dropping amortises to O(1) a write rather than paying an array shift on + * every one past the cap. */ +function pushCapped(into: Map, key: string, value: T, cap: number): void { + push(into, key, value); + const found = into.get(key)!; + if (found.length > cap * 2) { + found.splice(0, found.length - cap); + } +} + +/** The highest eventIndex in `event`'s subtree: where the frame finished. */ +function lastDescendantIndex(event: LogEvent): number { + let node = event; + // Children are appended in log order, so the last one holds the last index. + while (node.children.length) { + node = node.children[node.children.length - 1]!; + } + return node.eventIndex; +} + +/** The class from a frame's own name, or null where its name gives none. */ +function classFromFrame(label: string): string | null { + const paren = label.indexOf('('); + const call = (paren < 0 ? label : label.slice(0, paren)).trim(); + const lastDot = call.lastIndexOf('.'); + return lastDot > 0 ? call.slice(0, lastDot) : null; +} + +/** The last write at or before `cut`, or null where every write came after. */ +function lastAtOrBefore(writes: readonly LogEvent[], cut: number): LogEvent | null { + const after = firstIndexWhere(writes.length, (index) => writes[index]!.eventIndex > cut); + return after > 0 ? (writes[after - 1] ?? null) : null; +} + +/** The leftmost index below `length` where `holds` becomes true, or `length` if + * it never does. `holds` must be false then true across the run. */ +function firstIndexWhere(length: number, holds: (index: number) => boolean): number { + let low = 0; + let high = length; + while (low < high) { + const mid = (low + high) >>> 1; + if (holds(mid)) { + high = mid; + } else { + low = mid + 1; + } + } + return low; +} + +function byName(left: VariableRow, right: VariableRow): number { + return left.name.localeCompare(right.name); +} diff --git a/log-viewer/src/core/log/variableLine.ts b/log-viewer/src/core/log/variableLine.ts new file mode 100644 index 000000000..4f65011da --- /dev/null +++ b/log-viewer/src/core/log/variableLine.ts @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * The fields of a variable log line, read from the raw line the parser kept. + * + * The parser models only `lineNumber` on these events and joins the rest into a + * lossy `text`, so everything here comes from `event.logLine`. + */ + +/** A write the log recorded, from a `VARIABLE_ASSIGNMENT` line. */ +export interface VariableWrite { + /** `name` for a local, `this.name` for a field, `Class.name` for a static. */ + name: string; + /** The value exactly as the log wrote it. Never parsed here. */ + value: string; + /** The heap address the log reported, where it reported one. */ + address: string | null; +} + +/** A declaration, from a `VARIABLE_SCOPE_BEGIN` line. */ +export interface VariableScope { + name: string; + declaredType: string; + isStatic: boolean; +} + +const ADDRESS = /^0x[0-9a-f]+$/i; + +/** + * Just the name from a `VARIABLE_ASSIGNMENT` line, or null where the line + * carries no name, or no value after it. + * + * For a walk of the whole log: it never touches the value, which can reach tens + * of thousands of characters. + */ +export function variableNameOf(logLine: string): string | null { + const nameStart = pipeAfter(logLine, 3); + if (nameStart < 0) { + return null; + } + const nameEnd = logLine.indexOf('|', nameStart); + // A name and no value: nothing to show, so not a write. + if (nameEnd < 0) { + return null; + } + return logLine.slice(nameStart, nameEnd).trim() || null; +} + +/** Where a line's value starts and ends, and the address that follows it. + * Null where the line carries no value at all. */ +interface ValueSpan { + start: number; + end: number; + address: string | null; +} + +/** + * The span of a `VARIABLE_ASSIGNMENT` line's value. + * + * Found by pipe *position*, never by `split('|')`, and the last field is read + * for what it is rather than assumed: + * + * - an address (`|0x7d1781a3`), which belongs to the value, not in it; + * - **empty** (`|a|null|`), the address field present with nothing in it. Taken + * as part of the value it reads as `null|`; + * - anything else, which is the value's own text. + */ +function valueSpan(logLine: string): ValueSpan | null { + const nameStart = pipeAfter(logLine, 3); + if (nameStart < 0) { + return null; + } + const start = logLine.indexOf('|', nameStart) + 1; + if (start <= 0) { + return null; + } + const lastPipe = logLine.lastIndexOf('|'); + if (lastPipe >= start) { + const tail = logLine.slice(lastPipe + 1); + if (!tail) { + return { start, end: lastPipe, address: null }; + } + if (ADDRESS.test(tail)) { + return { start, end: lastPipe, address: tail }; + } + } + return { start, end: logLine.length, address: null }; +} + +/** Reads a `VARIABLE_ASSIGNMENT` line, or null where it carries no name. */ +export function parseVariableWrite(logLine: string): VariableWrite | null { + const name = variableNameOf(logLine); + const span = name && valueSpan(logLine); + if (!name || !span) { + return null; + } + return { name, value: logLine.slice(span.start, span.end), address: span.address }; +} + +/** + * Reads a `VARIABLE_SCOPE_BEGIN` line, or null where it is too short to carry a + * declaration. + * + * The two flags trail the declared type: the first says whether the variable can + * be referenced, the second whether it is static. A type holds commas + * (`Map`) but never a pipe, so both are read from the right. + */ +export function parseVariableScope(logLine: string): VariableScope | null { + const parts = logLine.split('|'); + if (parts.length < 7) { + return null; + } + const isStatic = parts[parts.length - 1]?.trim() === 'true'; + const declaredType = parts[parts.length - 3]?.trim() ?? ''; + const name = parts[3]?.trim() ?? ''; + return name ? { name, declaredType, isStatic } : null; +} + +/** The longest an address is. Past it, a value cannot be one. */ +const ADDRESS_MAX = 32; + +/** The address a value is, where the log wrote an address in place of a value: + * the value would not serialise, so the log named where it lived instead. + * + * Length first, so a very long value is rejected without being copied. */ +export function bareAddress(value: string): string | null { + if (value.length > ADDRESS_MAX * 2) { + return null; + } + const text = value.trim(); + return ADDRESS.test(text) ? text : null; +} + +/** + * {@link bareAddress} for a whole line, for a walk of the whole log: it gives up + * on length before it slices, so a very long value costs nothing. + */ +export function bareAddressOf(logLine: string): string | null { + const span = valueSpan(logLine); + return span && span.end - span.start <= ADDRESS_MAX + ? bareAddress(logLine.slice(span.start, span.end)) + : null; +} + +/** + * The address a line reported for the value it wrote, or null where it reported + * none. + * + * Cheap on any line: the address trails the value, so this reads back from the + * end rather than through it. + */ +export function reportedAddressOf(logLine: string): string | null { + return valueSpan(logLine)?.address ?? null; +} + +/** Chars of a value scanned for the addresses it names. An address the log + * bothered to name appears early, and a value can be very long. */ +const NESTED_SCAN_MAX = 4_000; + +const NESTED_ADDRESS = /0x[0-9a-f]+/gi; + +/** + * Every address a line's value names *inside* itself, as the `"0x6c98700c"` in + * `{"m_tliFilter":"0x6c98700c"}`. + * + * The address the line reports for its own value is left out: every assignment + * reports one, so taking them all would hold a quarter of a million events. + */ +export function nestedAddressesOf(logLine: string): readonly string[] { + const span = valueSpan(logLine); + if (!span) { + return []; + } + const end = Math.min(span.end, span.start + NESTED_SCAN_MAX); + const found = logLine.indexOf('0x', span.start); + // Tested on the line itself: slicing first would allocate up to 4KB for every + // assignment in the log, and most name no address at all. + if (found < 0 || found >= end) { + return []; + } + return logLine.slice(span.start, end).match(NESTED_ADDRESS) ?? []; +} + +/** The class a static belongs to, or null for a name that names no class. */ +export function classOf(staticName: string): string | null { + const lastDot = staticName.lastIndexOf('.'); + return lastDot > 0 ? staticName.slice(0, lastDot) : null; +} + +/** A qualified name without its owner, for a row whose group already names it. */ +export function shortName(name: string): string { + const lastDot = name.lastIndexOf('.'); + return lastDot > 0 ? name.slice(lastDot + 1) : name; +} + +/** True for a name the log qualified with its class, which every static is. */ +export function isStaticName(name: string): boolean { + return name.includes('.') && !name.startsWith('this.'); +} + +/** The character after the nth pipe, or -1 where the line has fewer. */ +function pipeAfter(line: string, pipes: number): number { + let at = -1; + for (let found = 0; found < pipes; found++) { + at = line.indexOf('|', at + 1); + if (at < 0) { + return -1; + } + } + return at + 1; +} diff --git a/log-viewer/src/core/log/variableValue.ts b/log-viewer/src/core/log/variableValue.ts new file mode 100644 index 000000000..9661bc1d3 --- /dev/null +++ b/log-viewer/src/core/log/variableValue.ts @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +import { bareAddress } from './variableLine.js'; + +/** + * Reads the shape of a logged variable value, without ever trusting it as JSON. + * + * The log writes values that `JSON.parse` cannot survive: a Map serialises with + * duplicate keys, so parsing silently loses entries; an Apex `toString()` lands + * inside a JSON string; a truncated value keeps its marker; and an + * unserialisable value collapses to a bare address. So this scans, and every + * piece of text it hands back is verbatim. + * + * The log records one level, so this reads one level. Nesting a scan deeper + * would only ever find the `{}` the log put there. + */ + +/** Text over this length earns an expander: it cannot be read in a row. */ +export const EXPAND_MIN_CHARS = 80; + +/** Never lay out more than this. A logged value can be very long. */ +export const RAW_CLAMP_CHARS = 2_000; + +/** Past this the structure is not scanned: the raw text is the honest answer. */ +const MAX_SCAN_CHARS = 64_000; + +/** Entries scanned in one container. Beyond it the value reads as truncated. */ +const MAX_ENTRIES = 500; + +/** One entry of a container, exactly as the log wrote it. */ +export interface ValueEntry { + /** The key, or null for a list entry or an entry with no readable key. */ + key: string | null; + text: string; + /** The address this entry's text names, quoted or bare, where it names one. + * Read here because this is where entry quoting is known. */ + address: string | null; +} + +export type VariableValue = + /** The log wrote no value at all. */ + | { kind: 'empty' } + /** A bare heap address: the value was not serialisable, not an empty object. */ + | { kind: 'address'; text: string } + /** A number, a boolean, null, or anything this cannot name. Verbatim. */ + | { kind: 'literal'; text: string } + | { kind: 'string'; text: string; inner: string; toStringLike: boolean; truncated: boolean } + | { + kind: 'container'; + brackets: '{}' | '[]'; + entries: ValueEntry[]; + truncated: boolean; + /** The log wrote this as a string, and its text was JSON. */ + fromString: boolean; + }; + +/** A collection the log cut short, as `โ€ฆ, ...}`. */ +const CONTAINER_CUT = /,\s*\.\.\.$/; + +/** A string the log cut short, as `first20chars (10 more) ...`. */ +const STRING_CUT = /\(\d+ more\)\s*\.\.\.\s*$/; + +/** Reads `raw` into the shape it has. Never throws, whatever the log wrote. */ +export function parseVariableValue(raw: string): VariableValue { + const text = raw.trim(); + if (!text) { + return { kind: 'empty' }; + } + const address = bareAddress(text); + if (address) { + return { kind: 'address', text: address }; + } + if (text.startsWith('"')) { + const inner = text.endsWith('"') && text.length > 1 ? text.slice(1, -1) : text.slice(1); + // An Apex toString() inside the quotes: `"{k=v, k=v}"`. + const toStringLike = inner.startsWith('{') && inner.endsWith('}') && inner.includes('='); + return ( + (toStringLike ? null : jsonInString(inner)) ?? { + kind: 'string', + text, + inner, + toStringLike, + truncated: STRING_CUT.test(inner), + } + ); + } + const brackets = bracketsOf(text); + if (brackets && text.length <= MAX_SCAN_CHARS) { + return scanContainer(text, brackets, false); + } + return { kind: 'literal', text }; +} + +/** + * A string whose text is JSON, read as the object it holds, or null where it is + * not JSON. + * + * Strictly gated on a quoted key, or on being a list. An Apex `toString()` has + * neither, and reading structure out of one would claim the log recorded + * something it did not. A string whose text is JSON is rare, so this is for a + * String field carrying a `JSON.serialize` result. + */ +function jsonInString(inner: string): VariableValue | null { + // A serialised string arrives escaped, and unescaping is recovery, not + // reformatting: the text is the log's own, with the log's own escaping undone. + const text = inner.includes('\\"') ? inner.replaceAll('\\"', '"') : inner; + const brackets = bracketsOf(text); + if (!brackets || text.length > MAX_SCAN_CHARS) { + return null; + } + const scanned = scanContainer(text, brackets, true); + if (!scanned.entries.length) { + return null; + } + const isJson = brackets === '[]' || scanned.entries.some((entry) => entry.key !== null); + return isJson ? scanned : null; +} + +/** True where the value cannot be read in a row and so earns an expander. */ +export function isExpandable(value: VariableValue): boolean { + switch (value.kind) { + case 'container': + // A chevron that opens on nothing would teach a depth the log lacks. + return value.entries.length > 0; + case 'string': + return value.inner.length > EXPAND_MIN_CHARS; + case 'literal': + return value.text.length > EXPAND_MIN_CHARS; + default: + return false; + } +} + +/** + * The one line a collapsed row shows. Clamped, so a huge value costs a row and + * not a layout. + */ +export function previewOf(value: VariableValue, maxChars = EXPAND_MIN_CHARS): string { + switch (value.kind) { + case 'empty': + return ''; + case 'address': + return value.text; + case 'container': { + // `{}` reads as `{}`. Naming it "empty object" would claim the log knew. + const [open, close] = value.brackets; + if (!value.entries.length) { + return `${open}${close}`; + } + let body = ''; + for (const entry of value.entries) { + body += `${body ? ', ' : ''}${entry.key === null ? entry.text : `${entry.key}: ${entry.text}`}`; + if (body.length > maxChars) { + break; + } + } + return clamp(`${open}${body}${value.truncated ? ', โ€ฆ' : ''}${close}`, maxChars); + } + default: + return clamp(value.text, maxChars); + } +} + +/** The raw text an expanded row shows, cut to what can be laid out. */ +export function clampRaw(raw: string): { text: string; clamped: boolean } { + return raw.length > RAW_CLAMP_CHARS + ? { text: raw.slice(0, RAW_CLAMP_CHARS), clamped: true } + : { text: raw, clamped: false }; +} + +function bracketsOf(text: string): '{}' | '[]' | null { + if (text.startsWith('{') && text.endsWith('}')) { + return '{}'; + } + return text.startsWith('[') && text.endsWith(']') ? '[]' : null; +} + +type Container = Extract; + +function scanContainer(text: string, brackets: '{}' | '[]', fromString: boolean): Container { + const body = text.slice(1, -1).trim(); + if (!body) { + return { kind: 'container', brackets, entries: [], truncated: false, fromString }; + } + const pieces = splitTopLevel(body); + let truncated = pieces.length > MAX_ENTRIES || CONTAINER_CUT.test(body); + const entries = pieces + .slice(0, MAX_ENTRIES) + .map((piece) => piece.trim()) + .filter((piece) => { + if (piece === '...') { + truncated = true; + return false; + } + return piece.length > 0; + }) + .map(entryOf); + return { kind: 'container', brackets, entries, truncated, fromString }; +} + +/** + * One entry, key kept apart from value where the log wrote a quoted key. + * + * Duplicate keys stay, in the order the log wrote them: a Map serialises with + * repeats, and dropping them would hide entries the transaction held. + */ +function entryOf(piece: string): ValueEntry { + if (!piece.startsWith('"')) { + return { key: null, ...valued(piece) }; + } + const closing = closingQuote(piece); + if (closing < 0 || piece[closing + 1] !== ':') { + return { key: null, ...valued(piece) }; + } + return { key: piece.slice(1, closing), ...valued(piece.slice(closing + 2).trim()) }; +} + +/** An entry's text, and the address it names. The log quotes a nested address, + * as the `"0x6c98700c"` in `{"delegate":"0x6c98700c"}`. */ +function valued(text: string): Pick { + const inner = text.startsWith('"') && text.endsWith('"') ? text.slice(1, -1) : text; + return { text, address: bareAddress(inner) }; +} + +/** Splits on the commas that separate entries: not those inside a string, and + * not those inside a nested value. */ +function splitTopLevel(body: string): string[] { + const pieces: string[] = []; + let depth = 0; + let inString = false; + let start = 0; + for (let at = 0; at < body.length; at++) { + const char = body[at]; + if (inString) { + if (char === '\\') { + at++; + } else if (char === '"') { + inString = false; + } + continue; + } + if (char === '"') { + inString = true; + } else if (char === '{' || char === '[') { + depth++; + } else if (char === '}' || char === ']') { + depth--; + } else if (char === ',' && depth === 0) { + pieces.push(body.slice(start, at)); + start = at + 1; + } + } + pieces.push(body.slice(start)); + return pieces; +} + +function closingQuote(piece: string): number { + for (let at = 1; at < piece.length; at++) { + if (piece[at] === '\\') { + at++; + } else if (piece[at] === '"') { + return at; + } + } + return -1; +} + +function clamp(text: string, maxChars: number): string { + return text.length > maxChars ? `${text.slice(0, maxChars)}โ€ฆ` : text; +} diff --git a/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts b/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts index b7cac5d70..314b458f0 100644 --- a/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts +++ b/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts @@ -41,7 +41,13 @@ function rendered(sections: PaneSection[], id: string, tag: string): Element { describe('buildDatabaseSections', () => { it('builds vitals + call stack + issues + call tree for a SOQL selection, badged by count', async () => { const sections = await buildDatabaseSections({ eventIndex: 3, type: 'soql' }); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'issues', 'calltree']); + expect(sections.map((s) => s.id)).toEqual([ + 'vitals', + 'variables', + 'callstack', + 'issues', + 'calltree', + ]); expect(sections.find((s) => s.id === 'issues')?.badge).toBe('2'); // The smallest section. expect(sections.find((s) => s.id === 'issues')?.weight).toBe(1); @@ -51,12 +57,12 @@ describe('buildDatabaseSections', () => { it('omits the SOQL issues section for a DML selection', async () => { const sections = await buildDatabaseSections({ eventIndex: 5, type: 'dml' }); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); }); it('builds vitals + call stack + call tree (no issues) for a SOSL selection', async () => { const sections = await buildDatabaseSections({ eventIndex: 7, type: 'sosl' }); - expect(sections.map((s) => s.id)).toEqual(['vitals', 'callstack', 'calltree']); + expect(sections.map((s) => s.id)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); }); it('anchors the call stack and the SOQL issues to the statement the user picked', async () => { diff --git a/log-viewer/src/features/database/components/databaseSections.ts b/log-viewer/src/features/database/components/databaseSections.ts index 43939919a..7d653bf3d 100644 --- a/log-viewer/src/features/database/components/databaseSections.ts +++ b/log-viewer/src/features/database/components/databaseSections.ts @@ -11,6 +11,7 @@ import { computeSoqlIssues } from '../../soql/components/SOQLLinterIssues.js'; import '../../../components/CallStackDetail.js'; import '../../../components/CallTreeDetail.js'; import '../../../components/EventVitals.js'; +import '../../../components/VariablesDetail.js'; import '../../soql/components/SOQLLinterIssues.js'; export interface DetailSelection { @@ -48,6 +49,14 @@ export async function buildDatabaseSections(selection: DetailSelection): Promise type=${ifDefined(activeType)} >`, }, + // What Apex could see from the frame. A statement owns no locals of its own, + // so the section answers from the Apex frame that issued it. + { + id: 'variables', + title: 'Variables', + fit: 'content', + content: html``, + }, { id: 'callstack', title: 'Call stack', diff --git a/scripts/measure/measure.ts b/scripts/measure/measure.ts index 3d0dc3da2..e605d828e 100644 --- a/scripts/measure/measure.ts +++ b/scripts/measure/measure.ts @@ -25,6 +25,7 @@ import { type ApexLog, parse } from 'apex-log-parser'; import { measureCallTree } from './call-tree.js'; import { die, time } from './harness.js'; import { digestMinimap, measureMinimap } from './minimap.js'; +import { measureVariables } from './variables.js'; /** The one log every measurement runs over, so the numbers compare across branches. */ const SAMPLE_LOG = 'sample-app/debug-logs/sample-log.log'; @@ -40,6 +41,7 @@ interface Area { const AREAS: Record = { 'call-tree': { run: measureCallTree }, minimap: { run: measureMinimap, digest: digestMinimap }, + variables: { run: measureVariables }, }; const args = (() => { diff --git a/scripts/measure/variables.ts b/scripts/measure/variables.ts new file mode 100644 index 000000000..248cfa2b6 --- /dev/null +++ b/scripts/measure/variables.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * Times the Variables section: one log-wide walk, then a frame snapshot. + */ +import type { ApexLog } from 'apex-log-parser'; + +import { + frameVariablesFor, + variableIndexFor, +} from '../../log-viewer/src/core/log/frameVariables.js'; +import { logStoreFor, setCurrentLog } from '../../log-viewer/src/core/log/LogStore.js'; +import { line, time } from './harness.js'; + +// The walk slices itself against this; resolving at once measures the work +// rather than the frames it would leave to the next paint. +const yieldSlice = () => Promise.resolve(); + +/** Frames timed, spread across the log, so the figure is not one warm subtree. */ +const SAMPLED_FRAMES = 100; + +export async function measureVariables(log: ApexLog): Promise { + setCurrentLog(log); + + const index = await time('variableIndexFor (first open)', () => + variableIndexFor(log, { yieldSlice }), + ); + await time('variableIndexFor (again)', () => variableIndexFor(log, { yieldSlice })); + line( + 'statics', + `sawAnyWrite=${index.sawAnyWrite}, capped=${index.capped}, ` + + `${index.at(Number.MAX_SAFE_INTEGER).length} classes`, + ); + + const store = logStoreFor(log); + const frames = log.eventsById.filter((event) => event.isParent); + const step = Math.max(1, Math.floor(frames.length / SAMPLED_FRAMES)); + const sampled = frames.filter((_, at) => at % step === 0).slice(0, SAMPLED_FRAMES); + await time(`${sampled.length} frame snapshots`, () => { + for (const frame of sampled) { + frameVariablesFor(store, frame.eventIndex, index); + } + }); + + // The average hides the frame holding hundreds of thousands of its own lines, + // and that is the frame a snapshot has to read back through. + const worst = frames.length + ? frames.reduce((held, frame) => (frame.children.length > held.children.length ? frame : held)) + : null; + if (!worst) { + return; + } + const shape = frameVariablesFor(store, worst.eventIndex, index); + line( + 'worst frame', + `${worst.children.length.toLocaleString()} children, ` + + `${shape?.locals.length ?? 0} locals, ${shape?.fields.length ?? 0} fields`, + ); + await time('worst frame snapshot', () => frameVariablesFor(store, worst.eventIndex, index)); +} From 7db497934615097446d34904e613832660fbce97 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:51:31 +0100 Subject: [PATCH 36/61] ci: stop installing vsce and ovsx unpinned (#1004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # PR overview Every publish path installed its own tooling with `pnpm add --global @vscode/vsce` and `pnpm add --global ovsx`, which takes whatever the registry serves that day. The pre-release job runs unattended every Tuesday, so nobody sees what version it picked. - **vsce** is already a `lana` devDependency, so it is already in the lockfile. Call it through `pnpm exec` / the existing `build:vsix` script instead of a global install. - **ovsx** is not a dependency and does not need to be: it only uploads at release time. Running it as `pnpm dlx ovsx@1.1.1` pins the version without putting its native keyring binaries into every CI job and every developer's install. No `package.json` or lockfile change, so `pnpm install --frozen-lockfile` is unaffected. ## Trade-off worth knowing Dependabot cannot see a version inside a `run:` block, so the `ovsx` pin will not be bumped automatically the way `vsce` is. A stale pin fails loudly at `verify-pat`, before anything is published. The alternative โ€” `ovsx` as a devDependency โ€” costs 56 extra packages (`@napi-rs/keyring`, `@node-rs/crc32` and the inquirer tree) on every install, for a tool used twice a release. ## Type of change - [x] Chore ## Validation - `pnpm install --frozen-lockfile` passes against the unchanged lockfile. - `pnpm --filter lana exec vsce package --pre-release --no-dependencies` runs in `lana/` with the flags passed through unchanged. - `ovsx@1.1.1` is current latest and still takes `verify-pat`, `--no-dependencies`, `--pre-release` and `--skip-duplicate`. - The `dlx` fetch happens at `verify-pat`, before any upload, so a download failure cannot land mid-publish. ## Known gap `cd-prerelease.yml` still packages inline (`vsce package --pre-release`) while `ci.yml` and `publish.yml` call the `build:vsix` script, because `build:vsix` has no `--pre-release` flag. Unifying them needs a second script in `lana/package.json`; left out to keep this PR to workflows only. --- .github/workflows/cd-prerelease.yml | 16 +++++----------- .github/workflows/ci.yml | 6 +----- .github/workflows/publish.yml | 16 +++++----------- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/.github/workflows/cd-prerelease.yml b/.github/workflows/cd-prerelease.yml index 22f80bd60..7efca6262 100644 --- a/.github/workflows/cd-prerelease.yml +++ b/.github/workflows/cd-prerelease.yml @@ -71,10 +71,6 @@ jobs: with: node-version: '24' cache: 'pnpm' - - name: Install vsce + ovsx - run: | - pnpm add --global @vscode/vsce - pnpm add --global ovsx - name: Dependencies run: pnpm run ci:install - name: update pre-release version @@ -82,9 +78,7 @@ jobs: echo "Updating pre-release version" pnpm run bump-prerelease; - name: Package the extension - run: | - cd lana - vsce package --pre-release --no-dependencies + run: pnpm --filter lana exec vsce package --pre-release --no-dependencies - name: Publish to VS Code Marketplace + Open VSX Registry # Tokens via env (not -p) so they don't appear in the process list. env: @@ -93,22 +87,22 @@ jobs: run: | cd lana echo "Verify vsce token has not expired" - vsce verify-pat + pnpm exec vsce verify-pat echo " Verify ovsx token has not expired" - ovsx verify-pat + pnpm dlx ovsx@1.1.1 verify-pat versionNum=$(jq -r '.version' package.json) pkgPath="lana-${versionNum}.vsix" echo "Publish to vsce vsix name: $pkgPath" - vsce publish --packagePath "${pkgPath}" --no-dependencies --pre-release --skip-duplicate + pnpm exec vsce publish --packagePath "${pkgPath}" --no-dependencies --pre-release --skip-duplicate echo " Publish to ovsx" - ovsx publish "${pkgPath}" --no-dependencies --pre-release --skip-duplicate + pnpm dlx ovsx@1.1.1 publish "${pkgPath}" --no-dependencies --pre-release --skip-duplicate - name: Update pre-release tag run: | echo "Updating pre release tag" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ca5cd1f4..5e4667d85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,14 +110,10 @@ jobs: with: node-version: '24' cache: 'pnpm' - - name: Install vsce - run: pnpm add --global @vscode/vsce - name: Install Dependencies run: pnpm run ci:install - name: Build VSCode Package - run: | - cd lana - vsce package --no-dependencies + run: pnpm --filter lana run build:vsix gate: # The single required status check: the ruleset never needs updating when jobs change. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9ea451415..c9ae2ff40 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,16 +24,10 @@ jobs: with: node-version: '24' cache: 'pnpm' - - name: Install vsce + ovsx - run: | - pnpm add --global @vscode/vsce - pnpm add --global ovsx - name: Dependencies run: pnpm run ci:install - name: Build extension - run: | - cd lana - vsce package --no-dependencies + run: pnpm --filter lana run build:vsix - name: Publish to VS Code Marketplace + Open VSX Registry # Secrets and the release tag are passed via env (not interpolated into the # shell) to avoid leaking tokens in the process list and shell injection. @@ -44,11 +38,11 @@ jobs: run: | cd lana echo "Verify vsce token has not expired" - vsce verify-pat + pnpm exec vsce verify-pat echo "Verify ovsx token has not expired" - ovsx verify-pat + pnpm dlx ovsx@1.1.1 verify-pat echo "Publish to vsce" - vsce publish --packagePath "lana-${TAG}.vsix" --no-dependencies + pnpm exec vsce publish --packagePath "lana-${TAG}.vsix" --no-dependencies echo "Publish to ovsx" - ovsx publish "lana-${TAG}.vsix" --no-dependencies + pnpm dlx ovsx@1.1.1 publish "lana-${TAG}.vsix" --no-dependencies From af78b3cf4bd41f0526377333ea3fa7792f6efc20 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:52:32 +0100 Subject: [PATCH 37/61] fix(lana): keep the raw log Outline when the tab model lags (#1007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # PR overview Follow-up to #997. Fixes an Outline regression that shipped there, removes three unreachable paths found while chasing it, and clears two small items noted during the #952โ€“#954 review. ## The Outline bug #997 gated the symbol provider on `isOpenAsTextTab` so that diffing a log would not parse it. That is right for folding and the cursor line decoration, which both recover on the next tab change through their own change events. It is wrong for symbols: VS Code asks a `DocumentSymbolProvider` once and has **no change event to ask again**. When a symbol request beats the tab model, the provider answers "empty" and the Outline stays empty for the life of the editor. Reproduced on a cold open of `lana/test/playwright/fixtures/apex-log.log`: folding, sticky scroll, the cursor line timings and the code lens all worked, the Outline was blank. The gate stays exactly as #997 wrote it. What is new is a repair path, the same one `RawLogFoldingProvider` already uses for the same race: remember that a request was rejected, and on the next tab or active-editor change re-register the provider, which is the only way to make VS Code ask again. Re-registration is deliberately narrow. It happens only when the active document is an Apex log that is *now* in a text tab, so a log sitting on the side of a diff never triggers one. The flag is cleared only on an actual retry โ€” a tab change that arrives before the active editor settles must not spend the repair that the editor event still needs. An earlier attempt weakened the gate instead, to "skip only a confirmed diff". That is worse: `TabInputTextMultiDiff` is absent from `@types/vscode` ~1.102, so a log inside a multi-file diff matches no known tab kind and would have parsed on every request โ€” exactly the case #997 skipped. ## Also in this PR - **`RawLogHoverProvider` deleted** โ€” `Context.ts` has never called its `apply()` (`git log -S'RawLogHoverProvider' -- lana/src/Context.ts` returns nothing), so it has been dead since it was written. `RawLogLineDecoration` builds the same hover from the same `buildMetricParts` helper and *is* wired. Its selector also covered the whole line rather than the end of it, so registering it merged our timings into the git blame hover โ€” the decoration's empty end-of-line range exists precisely to avoid that. - **The string branch of `Display.showFile`** โ€” both callers pass a `Uri`, and `Uri.parse` on a Windows path reads `c:` as the scheme. - **The `showError` case in `LogView`** โ€” nothing in `log-viewer/` or `lana/` sends it. Its `isTextPayload` guard went with it. - **`capabilities.untrustedWorkspaces`** โ€” dropped. Lana reads `sfdx-project.json` and runs a bundled webview, so claiming restricted-mode support was wrong. `virtualWorkspaces: true` stays. - **A stale comment in `SfdxProject.ts`** โ€” it explained `path.posix.basename`, which the code no longer calls. ## Type of change - [x] Bug fix - [x] Chore ## Validation - 21 suites, 342 tests pass, including four new cases covering the gate and the repair. The repair test is mutation-checked: stub out the re-registration and it fails. - `tsc -b lana`, `eslint lana/src` and `prettier --check` clean. - Dev host: Outline populates on a cold open of the fixture, folding and sticky scroll still work, and the hover shows once rather than twice. Verified separately that the provider yields 12 nested symbols for that fixture, so the empty Outline was the gate and not missing symbols. ## Noted, not fixed `ShowAnalysisCodeLens` is the same "asked once" shape โ€” it is registered without an `onDidChangeCodeLenses`, so a lens request that loses the tab-model race has no repair either. Pre-existing, and out of scope here. --- lana/package.json | 5 +- lana/src/commands/LogView.ts | 17 ----- lana/src/display/Display.ts | 12 ++- lana/src/hovers/RawLogHoverProvider.ts | 72 ------------------ lana/src/salesforce/codesymbol/SfdxProject.ts | 1 - lana/src/symbols/RawLogSymbolProvider.ts | 48 +++++++++++- .../__tests__/RawLogSymbolProvider.test.ts | 75 ++++++++++++++++++- 7 files changed, 130 insertions(+), 100 deletions(-) delete mode 100644 lana/src/hovers/RawLogHoverProvider.ts diff --git a/lana/package.json b/lana/package.json index 125e87b2b..9883a40a7 100644 --- a/lana/package.json +++ b/lana/package.json @@ -54,10 +54,7 @@ "Other" ], "capabilities": { - "virtualWorkspaces": true, - "untrustedWorkspaces": { - "supported": true - } + "virtualWorkspaces": true }, "activationEvents": [ "onLanguage:apexlog", diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 095ebd669..2f38ef746 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -196,13 +196,6 @@ export class LogView { break; } - case 'showError': { - if (isTextPayload(payload)) { - vscWindow.showErrorMessage(payload.text); - } - break; - } - case 'goToLogLine': { if (isTimestampPayload(payload) && logUri) { await RawLogNavigation.goToLineByTimestamp(logUri, payload.timestamp); @@ -325,16 +318,6 @@ function isSaveFileRequest( ); } -function isTextPayload(value: unknown): value is { text: string } { - return ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - typeof (value as Record).text === 'string' && - Boolean((value as Record).text) - ); -} - function isTimestampPayload(value: unknown): value is { timestamp: number } { return ( typeof value === 'object' && diff --git a/lana/src/display/Display.ts b/lana/src/display/Display.ts index fe1c50fab..16409d793 100644 --- a/lana/src/display/Display.ts +++ b/lana/src/display/Display.ts @@ -1,7 +1,13 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { Uri, commands, window, type MessageOptions, type TextDocumentShowOptions } from 'vscode'; +import { + commands, + window, + type MessageOptions, + type TextDocumentShowOptions, + type Uri, +} from 'vscode'; import { appName } from '../AppSettings.js'; @@ -23,7 +29,7 @@ export class Display { window.showErrorMessage(s, options); } - showFile(uri: Uri | string, options: TextDocumentShowOptions = {}): void { - commands.executeCommand('vscode.open', typeof uri === 'string' ? Uri.parse(uri) : uri, options); + showFile(uri: Uri, options: TextDocumentShowOptions = {}): void { + commands.executeCommand('vscode.open', uri, options); } } diff --git a/lana/src/hovers/RawLogHoverProvider.ts b/lana/src/hovers/RawLogHoverProvider.ts deleted file mode 100644 index 719802760..000000000 --- a/lana/src/hovers/RawLogHoverProvider.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2026 Certinia Inc. All rights reserved. - */ -import { - Hover, - languages, - MarkdownString, - type HoverProvider, - type Position, - type ProviderResult, - type TextDocument, - type Uri, -} from 'vscode'; - -import { LogEventCache } from '../cache/LogEventCache.js'; -import { isOpenAsTextTab } from '../editor/TabState.js'; -import type { Context } from '../Context.js'; -import { buildMetricParts, TIMESTAMP_REGEX } from '../log-utils.js'; - -class RawLogHoverProvider implements HoverProvider { - provideHover(document: TextDocument, position: Position): ProviderResult { - const line = document.lineAt(position.line); - const match = line.text.match(TIMESTAMP_REGEX); - - if (!match?.[1]) { - return null; - } - - if (!isOpenAsTextTab(document.uri)) { - return null; - } - - const timestamp = parseInt(match[1], 10); - return this.buildHover(document.uri, timestamp); - } - - private async buildHover(uri: Uri, timestamp: number): Promise { - // A command URI argument must be JSON, so the URI travels as a string here. - const args = encodeURIComponent(JSON.stringify({ timestamp, filePath: uri.toString() })); - const commandUri = `command:lana.showInLogAnalysis?${args}`; - - const apexLog = await LogEventCache.getApexLog(uri); - const result = apexLog ? LogEventCache.findEventByTimestamp(apexLog, timestamp) : null; - - const metricParts = result ? buildMetricParts(result.event) : []; - - const parts: string[] = []; - if (metricParts.length > 0) { - parts.push(metricParts.join(' ยท ')); - parts.push('---'); - } - parts.push(`[Show in Log Analysis](${commandUri})`); - - const markdown = new MarkdownString(parts.join('\n\n'), true); - markdown.isTrusted = true; - - return new Hover(markdown); - } - - static apply(context: Context): void { - const docSelector = [{ language: 'apexlog' }]; - - const hoverProviderDisposable = languages.registerHoverProvider( - docSelector, - new RawLogHoverProvider(), - ); - - context.context.subscriptions.push(hoverProviderDisposable); - } -} - -export { RawLogHoverProvider }; diff --git a/lana/src/salesforce/codesymbol/SfdxProject.ts b/lana/src/salesforce/codesymbol/SfdxProject.ts index a46b40c20..3f4013e8b 100644 --- a/lana/src/salesforce/codesymbol/SfdxProject.ts +++ b/lana/src/salesforce/codesymbol/SfdxProject.ts @@ -44,7 +44,6 @@ export class SfdxProject { // resolved, so a rejected findFiles never leaves an empty-but-valid cache. const classIndex = new Map(); for (const uri of allUris) { - // uri.path is always '/'-separated (unlike fsPath), so posix basename is safe everywhere const className = Utils.basename(uri) .replace(/\.cls$/i, '') .toLowerCase(); diff --git a/lana/src/symbols/RawLogSymbolProvider.ts b/lana/src/symbols/RawLogSymbolProvider.ts index 7713b91c2..3dce6ec3a 100644 --- a/lana/src/symbols/RawLogSymbolProvider.ts +++ b/lana/src/symbols/RawLogSymbolProvider.ts @@ -7,7 +7,10 @@ import { Position, Range, SymbolKind, + window, type CancellationToken, + type Disposable, + type DocumentFilter, type DocumentSymbolProvider, type TextDocument, } from 'vscode'; @@ -17,6 +20,7 @@ import type { LogEvent } from 'apex-log-parser'; import type { Context } from '../Context.js'; import { LogEventCache } from '../cache/LogEventCache.js'; import { isOpenAsTextTab } from '../editor/TabState.js'; +import { isApexLogContent } from '../language/ApexLogLanguageDetector.js'; import { formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; /** @@ -26,11 +30,15 @@ import { formatDuration, TIMESTAMP_REGEX } from '../log-utils.js'; * on scroll without these symbols. */ class RawLogSymbolProvider implements DocumentSymbolProvider { + private registration: Disposable | undefined; + private lostTabModelRace = false; + async provideDocumentSymbols( document: TextDocument, _token: CancellationToken, ): Promise { if (!isOpenAsTextTab(document.uri)) { + this.lostTabModelRace = true; return []; } @@ -96,11 +104,49 @@ class RawLogSymbolProvider implements DocumentSymbolProvider { return symbols; } + /** + * Re-register so VS Code asks for symbols again. + * + * The only repair path there is: a DocumentSymbolProvider has no change event, + * so a request that beat the tab model would otherwise leave the Outline empty + * for the life of the editor. This is the folding provider's changeEmitter.fire(). + */ + private reregister(docSelector: DocumentFilter[]): void { + this.registration?.dispose(); + this.registration = languages.registerDocumentSymbolProvider(docSelector, this); + } + static apply(context: Context): void { const docSelector = [{ language: 'apexlog' }]; + const provider = new RawLogSymbolProvider(); + provider.reregister(docSelector); + + // Only retry for a log now sitting in a text tab, so a rejected diff side does + // not re-register on every tab change for the rest of the session. + const repair = () => { + const document = window.activeTextEditor?.document; + const worthRetrying = + provider.lostTabModelRace && + document && + isOpenAsTextTab(document.uri) && + isApexLogContent(document); + + // Only cleared on an actual retry: a tab change that arrives before the active + // editor settles must not spend the one repair the editor event still needs. + if (worthRetrying) { + provider.lostTabModelRace = false; + provider.reregister(docSelector); + } + }; context.context.subscriptions.push( - languages.registerDocumentSymbolProvider(docSelector, new RawLogSymbolProvider()), + { dispose: () => provider.registration?.dispose() }, + // Not onDidOpenTextDocument: it fires before the tab model is updated, so the + // gate would reject a legitimate open. + window.tabGroups.onDidChangeTabs(repair), + // Reopening a closed editor often re-attaches the retained document model + // without re-firing the tab change. + window.onDidChangeActiveTextEditor(repair), ); } } diff --git a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts index 2e0035ea9..bec11904a 100644 --- a/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts +++ b/lana/src/symbols/__tests__/RawLogSymbolProvider.test.ts @@ -12,9 +12,11 @@ import { } from '../../__tests__/helpers/test-builders.js'; import { TabInputText, + TabInputTextDiff, Uri, createMockTextDocument, setOpenTabs, + window, } from '../../__tests__/mocks/vscode.js'; import { LogEventCache } from '../../cache/LogEventCache.js'; import { RawLogSymbolProvider } from '../RawLogSymbolProvider.js'; @@ -26,6 +28,7 @@ jest.mock('../../cache/LogEventCache.js', () => ({ })); const mockGetApexLog = LogEventCache.getApexLog as jest.Mock; +const APEX_LOG_LINE = '09:45:31.888 (1000)|EXECUTION_STARTED'; describe('RawLogSymbolProvider', () => { let provider: RawLogSymbolProvider; @@ -139,13 +142,42 @@ describe('RawLogSymbolProvider', () => { expect(symbols).toEqual([]); }); + + it('returns no symbols when the tab model does not list the document', async () => { + setOpenTabs(); + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + + expect(await provider.provideDocumentSymbols(doc, {} as never)).toEqual([]); + expect(mockGetApexLog).not.toHaveBeenCalled(); + }); + + it('returns no symbols for a URI shown as a diff side', async () => { + const uri = Uri.file('/test/file.log'); + setOpenTabs(new TabInputTextDiff(Uri.parse('git:/test/file.log'), uri)); + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + + expect(await provider.provideDocumentSymbols(doc, {} as never)).toEqual([]); + expect(mockGetApexLog).not.toHaveBeenCalled(); + }); }); describe('apply', () => { - it('registers a document symbol provider for apexlog', () => { + const applyProvider = () => { const mockContext = createMockContext(); - RawLogSymbolProvider.apply(mockContext as unknown as import('../../Context.js').Context); + return (languages.registerDocumentSymbolProvider as jest.Mock).mock.calls[0]?.[1] as + RawLogSymbolProvider | undefined; + }; + + const fireTabChange = () => { + const handler = (window.tabGroups.onDidChangeTabs as jest.Mock).mock.calls[0]?.[0] as ( + event: unknown, + ) => void; + handler(undefined); + }; + + it('registers a document symbol provider for apexlog', () => { + applyProvider(); expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(1); expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledWith( @@ -153,5 +185,44 @@ describe('RawLogSymbolProvider', () => { expect.any(RawLogSymbolProvider), ); }); + + it('asks VS Code again once the tab model lists a log it had rejected', async () => { + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + setOpenTabs(); + const registered = applyProvider(); + + await registered?.provideDocumentSymbols(doc, {} as never); + + setOpenTabs(new TabInputText(Uri.file('/test/file.log'))); + window.activeTextEditor = { document: doc }; + fireTabChange(); + + expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(2); + }); + + it('does not re-register for a log that is still only a diff side', async () => { + const uri = Uri.file('/test/file.log'); + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + setOpenTabs(); + const registered = applyProvider(); + + await registered?.provideDocumentSymbols(doc, {} as never); + + setOpenTabs(new TabInputTextDiff(Uri.parse('git:/test/file.log'), uri)); + window.activeTextEditor = { document: doc }; + fireTabChange(); + + expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(1); + }); + + it('does not re-register when no request was rejected', () => { + const doc = createMockTextDocument({ lines: [APEX_LOG_LINE], uri: '/test/file.log' }); + applyProvider(); + + window.activeTextEditor = { document: doc }; + fireTabChange(); + + expect(languages.registerDocumentSymbolProvider).toHaveBeenCalledTimes(1); + }); }); }); From a40d4b5787955f538b24f3900b0af41d015cdf96 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:05:34 +0100 Subject: [PATCH 38/61] chore(vscode): open sample-app in the dev host (#1008) The dev host started on no folder, so the test logs were always a few clicks away. The folder arg opens `sample-app`, which holds them. The worktree config opens that worktree's own copy, and the AGENTS.md command matches. Related: #992, which added the `lana-dev` profile these launch configs name. --- .vscode/launch.json | 7 ++++++- AGENTS.md | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 59a97c485..2b0a751c9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,11 @@ "name": "Run Extension", "type": "extensionHost", "request": "launch", - "args": ["--profile=lana-dev", "--extensionDevelopmentPath=${workspaceFolder}/lana"], + "args": [ + "--profile=lana-dev", + "${workspaceFolder}/sample-app", + "--extensionDevelopmentPath=${workspaceFolder}/lana" + ], "outFiles": ["${workspaceFolder}/lana/out/**/*.js"], "localRoot": "${workspaceFolder}/lana" }, @@ -22,6 +26,7 @@ "request": "launch", "args": [ "--profile=lana-dev", + "${workspaceFolder}/${input:worktree}/sample-app", "--extensionDevelopmentPath=${workspaceFolder}/${input:worktree}/lana" ], "outFiles": ["${workspaceFolder}/${input:worktree}/lana/out/**/*.js"], diff --git a/AGENTS.md b/AGENTS.md index 0430fb704..1f7fda740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,9 @@ Always use pnpm. - `pnpm lint` โ€” type + lint check - `pnpm prettier-format` โ€” auto-format -**Dev host** โ€” launch with `code-insiders --profile lana-dev --extensionDevelopmentPath=$PWD/lana`, -use the CLI of the launched editor, `code-insiders` or `code` +**Dev host** โ€” launch with +`code-insiders --profile lana-dev $PWD/sample-app --extensionDevelopmentPath=$PWD/lana`, use the +CLI of the launched editor, `code-insiders` or `code` **Compilers** โ€” `typecheck` = native TS7 (`tsc`); `typecheck:tsc6` = classic 6.0 (`tsc6`). Keep the `@typescript/typescript6` alias + `tsc6`: `typescript-eslint` and Docusaurus need From ddabe8a200f12c5415c4c8002b1c71e63981e934 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:06:10 +0100 Subject: [PATCH 39/61] feat(log-viewer): open a class instance in the Variables section (#1009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview The log writes `{}` for an object it could not serialise, so a Variables row read as empty while the log held that object's fields on lines of their own. Three in four `{}` rows in real FINEST logs are in this state. A row now opens into the fields the log recorded for its object, wherever they were written, and a field that is itself an object opens again. Every row that opens previews what is inside it with a count beside it, so nothing reads as empty when it is not. ## ๐Ÿ› ๏ธ Changes made - **Fields by owner.** A `this.field` line carries the address of the object the field belongs to, so the index groups an object's field writes by it โ€” one map filled during the walk that already runs. - **Two sources, one rule:** the latest write to a field wins. The index finds a write wherever it was made, so a field a returned constructor set is in scope; the frame walk finds the writes whose line reported no address, which the index cannot see. - **A dead object's fields stay out.** An address is reused once its object is collected, so a read is bounded at the object's own class run. A construction always starts a new run, even of the same class. - **Preview and count on every row that opens**, assembled or serialised, with the hover saying which: only parts written on lines of their own can be as this frame stood. - **Per-field caps**, so one field written in a long loop cannot evict the rest of its object. - **One index read per object per selection**, held for the frame: 42ms to build on a 9MB log, 97ms on a field-heavy 19MB one, field reads under a millisecond, retained heap unchanged. ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [x] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ”— Related Issues related #373 ## โœ… Tests added? - [x] ๐Ÿ‘ yes 25 new tests; 2298 pass across the three projects. Each new rule has a test proven by mutation: the reuse bound, the same-class reuse case, the per-field cap, the two-source merge, the address-less field write, the held read, the assembled preview, the count, and the row that cannot open. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ”– CHANGELOG.md - [x] ๐Ÿ“– help site ## Anything else we need to know? [optional] One deliberate limit: every recorded field of the object is listed, so a base-class frame can show a field only the subclass declares. The log names no class hierarchy, and the only available filter โ€” the class of the writing frame โ€” would drop inherited fields set in a base constructor, which is the common case. The trade is commented at the merge site. --- CHANGELOG.md | 2 +- lana-docs/docs/docs/features/inspector.md | 2 +- log-viewer/src/components/VariablesDetail.ts | 50 ++-- .../__tests__/VariablesDetail.test.ts | 90 +++++++ .../components/__tests__/variableTree.test.ts | 198 +++++++++++++++- log-viewer/src/components/variableTree.ts | 196 +++++++++++---- .../core/log/__tests__/frameVariables.test.ts | 196 +++++++++++++++ log-viewer/src/core/log/frameVariables.ts | 224 +++++++++++++++--- log-viewer/src/core/log/variableLine.ts | 10 +- log-viewer/src/core/log/variableValue.ts | 21 ++ scripts/measure/variables.ts | 17 +- 11 files changed, 894 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 920e549d4..07025efb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Timeline governor strip plots heap as it's allocated, so you can see where it spikes. - ๐Ÿงญ **Inspector**: select anything โ€” a timeline frame, a call tree or analysis row, a SOQL/DML/SOSL statement โ€” and inspect it without leaving the tab you're on. ([#113]) - **A selection** shows its details and governor metrics as `used / limit`, the call stack that led to it, and its own subtree in **Time Order**, **Aggregated** or **Bottom-Up**. Click a frame in the call stack to walk up it โ€” the details and subtree follow, and the stack stays anchored to what you selected. On the Timeline it also splits the self time under the selection by the namespace whose code ran it. - - **Variables** in scope at the frame you selected: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, each value as it stood at the frame. Needs Apex Code at **FINEST**. ([#373]) + - **Variables** in scope at the frame you selected: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, each value as it stood at the frame. An object opens into the fields the log recorded for it, and a field that is itself an object opens again. Every row that opens previews what is inside it, with a count. Needs Apex Code at **FINEST**. ([#373]) - **Nothing selected** shows the whole log instead of an empty panel: a governor overview on every tab, time by category, self time by namespace and governor trends on the Timeline, log-wide findings and how per-call self time spreads on Analysis, the hot path and hot spots on the Call Tree, and, on Database, which namespaces asked for and burned the database time, how few statements hold the time, and every call path that ends in a query, DML or search with total and self time. ([#373]) - Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view โ€” hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Click a point on a governor usage chart to move the Timeline to that instant and zoom in on it. Right-click for copy actions. - **Findings** list the statements behind them, most repeated first with how often each ran, and report one query built per record and run a row at a time. The severities head the list and filter it, any number at once, a finding the log times shows how long it took and what that is of the log, and selecting an Analysis row narrows the list to the findings that name that method or anything it called. diff --git a/lana-docs/docs/docs/features/inspector.md b/lana-docs/docs/docs/features/inspector.md index 7136fcb10..f6be7bb46 100644 --- a/lana-docs/docs/docs/features/inspector.md +++ b/lana-docs/docs/docs/features/inspector.md @@ -26,7 +26,7 @@ It docks to the **right**, **left** or **bottom**, resizes by dragging its edge, ### Sections - **Details** โ€“ timing, plus every governor metric the selection consumed as `used / limit`. For SOQL also selectivity, query plan and cardinality, with the query text highlighted and copyable. -- **Variables** โ€“ what Apex could reach from the frame: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, grouped by class. Every value reads as it stood at the frame, and a name the log declared but never wrote reads `not assigned`. An object opens one level, which is all the log records; where the log wrote an address instead of a value, the object at that address is shown, or `no value recorded` where the log never wrote one. A statement owns no variables of its own, so it answers from the Apex frame that ran it. Needs the log captured with Apex Code at **FINEST**. +- **Variables** โ€“ what Apex could reach from the frame: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, grouped by class. Every value reads as it stood at the frame, and a name the log declared but never wrote reads `not assigned`. An object opens into the fields the log recorded for it, wherever they were set, and a field that is itself an object opens again. Every row that opens previews what is inside it with a count beside it, and the hover says whether those parts were written on one line or assembled from writes of their own. An object the log recorded nothing for reads `{}` with nothing to open. Where the log wrote an address instead of a value, the object at that address is shown, or `no value recorded` where the log never wrote one. A statement owns no variables of its own, so it answers from the Apex frame that ran it. Needs the log captured with Apex Code at **FINEST**. - **Self time by namespace** โ€“ Timeline only: the self time under the selection split by the namespace whose code ran it, so you can see whose package burned it. Every namespace bar colours the six biggest and gathers the rest into one **others** segment, which names them on hover. - **Findings** โ€“ Analysis only: which of the log's findings name the selected method or anything it called, so you can tell whether the row you picked is one of the log's problems. - **Call stack** โ€“ the parent frames that led to the selection, outermost first, with total and self time. diff --git a/log-viewer/src/components/VariablesDetail.ts b/log-viewer/src/components/VariablesDetail.ts index d007dc535..87bac1a9b 100644 --- a/log-viewer/src/components/VariablesDetail.ts +++ b/log-viewer/src/components/VariablesDetail.ts @@ -11,6 +11,7 @@ import { recordsVariables, variableIndexFor, type FrameVariables, + type IndexView, type VariableIndex, } from '../core/log/frameVariables.js'; import { logContext } from '../core/log/logContext.js'; @@ -85,6 +86,10 @@ export class VariablesDetail extends LitElement { * not pay that again. */ private _frame: FrameVariables | null = null; + /** The index bound to this frame's cut, which holds what it reads: every row + * is built again whenever anything opens. */ + private _view: IndexView | null = null; + /** Set when a key moved the tab stop, so `updated` moves focus with it. */ private _takeFocus = false; @@ -258,6 +263,7 @@ export class VariablesDetail extends LitElement { !aggregate && this.logStore && this._index ? frameVariablesFor(this.logStore, this.eventIndex, this._index) : null; + this._view = this._frame && this._index ? this._index.viewAt(this._frame.cut) : null; } // A key press moves the tab stop and nothing else, so the rows it walks are // rebuilt only when the scope or what is open changes. @@ -270,15 +276,10 @@ export class VariablesDetail extends LitElement { * open. Scanning a value is the cost here, so it is paid once. */ private _rebuild(): void { const frame = this._frame; - const index = this._index; + const view = this._view; this._rows = - frame && index - ? toTreeRows( - frame, - (id, byDefault) => this._disclosure.get(id) ?? byDefault, - (address) => index.addressState(address, frame.cut), - (address) => index.classAt(address, frame.cut), - ) + frame && view + ? toTreeRows(frame, (id, byDefault) => this._disclosure.get(id) ?? byDefault, view) : []; this._at = new Map(this._rows.map((row, at) => [row.id, at])); } @@ -345,7 +346,7 @@ export class VariablesDetail extends LitElement { ? note('The log is truncated here, so a write may be unrecorded rather than absent.') : '' } - ${index.capped ? note('Too many static assignments to hold them all, so some are missing.') : ''} + ${index.capped ? note('Too many assignments to hold them all, so some values are missing.') : ''}
    ${this._rows.map((row) => this._render(row, row.id === focused))}
    @@ -412,7 +413,7 @@ export class VariablesDetail extends LitElement { ${row.key === null ? 'ยท' : `${row.key}:`} ${this._value(row, null)} - ${chipFor(row.value)}`; + ${partCount(row)}${chipFor(row.value)}`; case 'text': return html``; case 'note': @@ -430,7 +431,7 @@ export class VariablesDetail extends LitElement { : html`not assigned` } - ${chipFor(row.value)}${typeColumn(variable.declaredType)}`; + ${partCount(row)}${chipFor(row.value)}${typeColumn(variable.declaredType)}`; } /** @@ -469,12 +470,15 @@ export class VariablesDetail extends LitElement { } // Left out where it matches the declared type: the type column says it. const className = row.className && row.className !== declaredType ? row.className : null; + // An object the log wrote as `{}` previews the parts it opens on, or the row + // would read as empty while holding eight fields. + const shows = row.assembled ?? row.value; return html`${ className ? html`${lastSegment(className)} ` : '' - }${previewOf(row.value)}`; } @@ -488,7 +492,9 @@ export class VariablesDetail extends LitElement { * holds it is named. */ private _missing(row: Valued): Missing | null { - if (row.address === null || row.resolved) { + // The log wrote no value for the whole object and still recorded its parts, + // which are what the row shows: saying it holds nothing would be false. + if (row.address === null || row.resolved || row.assembled) { return null; } if (row.laterAt === null) { @@ -642,6 +648,22 @@ function typeColumn(declaredType: string | null): TemplateResult | string { return declaredType ? html`${declaredType}` : ''; } +/** How many parts the row opens into, on every row that opens into parts, as a + * group row already carries. */ +function partCount(row: Shown & { expandable: boolean }): TemplateResult | string { + // A cycle, or the depth bound, leaves a row that cannot open: a count would + // promise rows the tree will not give. + if (!row.expandable || !row.parts.length) { + return ''; + } + // Assembled from writes of their own, or written on this line: the reader is + // owed the difference, since only the first can be as this frame stood. + const title = row.assembled + ? 'Fields the log recorded for this object, with any keys its own value held.' + : 'Properties the log wrote for this value.'; + return html`${row.parts.length}`; +} + /** A qualified class as its own name: the row has no width for the namespace, * and the hover carries it whole. */ function lastSegment(className: string): string { diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts index 4382b3bda..aa9197acf 100644 --- a/log-viewer/src/components/__tests__/VariablesDetail.test.ts +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -674,3 +674,93 @@ describe('VariablesDetail properties', () => { expect(property?.querySelector('.chevron-gap')).not.toBeNull(); }); }); + +// A local holding an object reads `{}`, because the log could not serialise it. +// Its fields are lines of their own, and the badge is what says so. +describe('VariablesDetail object fields', () => { + const BUILT = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1010)|CONSTRUCTOR_ENTRY|[9]|01p|()|ns.Holder\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Holder|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[2]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_EXIT|[9]|01p|()|ns.Holder\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[10]|holder|ns.Holder|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[10]|holder|{}|0xaaa\n' + + '09:18:22.6 (1080)|VARIABLE_SCOPE_BEGIN|[11]|plain|ns.Other|true|false\n' + + '09:18:22.6 (1090)|VARIABLE_ASSIGNMENT|[11]|plain|{}|0xzzz\n' + + '09:18:22.6 (1100)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + + it('counts the fields beside a value the log wrote as {}', async () => { + const store = logOf(BUILT); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const holder = rowNamed(el, 'holder'); + + expect(holder?.querySelector('.count')?.textContent?.trim()).toBe('1'); + expect(holder?.getAttribute('aria-expanded')).toBe('false'); + }); + + // `{}` beside a count of one reads as empty, so the row shows what it opens on. + it('previews the recorded fields on the closed row', async () => { + const store = logOf(BUILT); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const holder = rowNamed(el, 'holder'); + + expect(holder?.querySelector('.value')?.textContent).toContain('sObj: "Account"'); + }); + + // The log wrote no value for the whole object and still recorded its parts. A + // row that shows those parts must not also claim the log holds nothing. + it('shows the recorded parts rather than "no value recorded"', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|this.rows|5|0xbbb\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[3]|big|ns.Big|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[3]|big|0xbbb\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const big = rowNamed(el, 'big'); + + expect(big?.querySelector('.missing')).toBeNull(); + expect(big?.querySelector('.value')?.textContent).toContain('rows: 5'); + expect(big?.querySelector('.count')?.textContent?.trim()).toBe('1'); + }); + + // A cycle leaves a row that cannot open, so a count on it would promise rows + // the tree will not give. + it('leaves the count off a row that cannot open', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Loop.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|this.me|0xddd|0xddd\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[3]|holder|ns.Loop|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[3]|holder|{}|0xddd\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Loop.run()\n', + ); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Loop.run()') }); + + // The field names the object it belongs to, so it may be read but not opened. + treeRows(el) + .find((row) => row.dataset.id === 'local/holder') + ?.click(); + await el.updateComplete; + const me = treeRows(el).find((row) => row.dataset.id === 'local/holder/me'); + + expect(me).toBeDefined(); + expect(me?.getAttribute('aria-expanded')).toBeNull(); + expect(me?.querySelector('.count')).toBeNull(); + }); + + it('offers nothing to open where the log recorded no fields', async () => { + const store = logOf(BUILT); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const plain = rowNamed(el, 'plain'); + + expect(plain?.querySelector('.count')).toBeNull(); + expect(plain?.getAttribute('aria-expanded')).toBeNull(); + }); +}); diff --git a/log-viewer/src/components/__tests__/variableTree.test.ts b/log-viewer/src/components/__tests__/variableTree.test.ts index 3e2686bda..e98612db4 100644 --- a/log-viewer/src/components/__tests__/variableTree.test.ts +++ b/log-viewer/src/components/__tests__/variableTree.test.ts @@ -9,7 +9,7 @@ import { type FrameVariables, type VariableRow, } from '../../core/log/frameVariables.js'; -import { parentOf, toTreeRows } from '../variableTree.js'; +import { parentOf, toTreeRows, type VariableTreeRow } from '../variableTree.js'; function row(name: string, value: string, over: Partial = {}): VariableRow { return { @@ -91,7 +91,10 @@ describe('toTreeRows', () => { const rows = toTreeRows( { ...frame, locals: [row('alias', '0xabc', { address: '0xabc' })] }, closed, - (address) => (address === '0xabc' ? { text: '{"n":1}', laterAt: null } : NOT_RECORDED), + { + resolve: (address) => + address === '0xabc' ? { text: '{"n":1}', laterAt: null } : NOT_RECORDED, + }, ); const alias = rows.find((r) => r.id === 'local/alias'); @@ -167,7 +170,7 @@ describe('toTreeRows references', () => { address === '0x6c98700c' ? { text: '{"RowLimit":3000}', laterAt: null } : NOT_RECORDED; it('reads a field holding a reference as the object it names', () => { - const rows = toTreeRows({ ...frame, locals: [held] }, openAll, resolve); + const rows = toTreeRows({ ...frame, locals: [held] }, openAll, { resolve }); const entry = rows.find((r) => r.id === 'local/view/0'); expect(entry?.kind === 'entry' && entry.raw).toBe('{"RowLimit":3000}'); @@ -179,7 +182,9 @@ describe('toTreeRows references', () => { }); it('leaves an address the log never wrote down as the address', () => { - const rows = toTreeRows({ ...frame, locals: [held] }, openAll, () => NOT_RECORDED); + const rows = toTreeRows({ ...frame, locals: [held] }, openAll, { + resolve: () => NOT_RECORDED, + }); const entry = rows.find((r) => r.id === 'local/view/0'); expect(entry?.kind === 'entry' && entry.raw).toBe('"0x6c98700c"'); @@ -192,10 +197,9 @@ describe('toTreeRows references', () => { // The address is only the identity the runtime printed. The contents are a // separate event, which may land after the frame the reader picked. it('tells an address the log never wrote down from one it wrote later', () => { - const later = toTreeRows({ ...frame, locals: [held] }, openAll, () => ({ - text: null, - laterAt: 91, - })); + const later = toTreeRows({ ...frame, locals: [held] }, openAll, { + resolve: () => ({ text: null, laterAt: 91 }), + }); const entry = later.find((r) => r.id === 'local/view/0'); expect(entry?.kind === 'entry' && entry.resolved).toBe(false); @@ -211,8 +215,10 @@ describe('toTreeRows references', () => { locals: [row('loop', '0xaaa', { address: '0xaaa' })], }, openAll, - (address) => - address === '0xaaa' ? { text: '{"self":"0xaaa"}', laterAt: null } : NOT_RECORDED, + { + resolve: (address) => + address === '0xaaa' ? { text: '{"self":"0xaaa"}', laterAt: null } : NOT_RECORDED, + }, ); const inner = rows.find((r) => r.id === 'local/loop/0'); @@ -246,3 +252,175 @@ describe('toTreeRows group labels', () => { ]); }); }); + +// The log serialises an object as `{}` wherever it could not serialise its +// contents, but it does record the writes to that object's fields. Those are +// what the row opens on. +describe('recorded fields', () => { + const recorded: Record = { + '0xaaa': [row('rows', '5'), row('sObj', '"Account"')], + }; + const lookups = { fields: (address: string) => recorded[address] ?? [] }; + + /** What a row is called, whichever kind of row it is. */ + const nameOf = (held: VariableTreeRow): string | null => + held.kind === 'variable' ? held.row.name : held.kind === 'entry' ? held.key : null; + + it('opens a value the log wrote as {} on the fields it recorded', () => { + const rows = toTreeRows( + { ...frame, locals: [row('selector', '{}', { objectAddress: '0xaaa' })] }, + openAll, + lookups, + ); + + const held = rows.find((r) => r.id === 'local/selector'); + expect(held?.expandable).toBe(true); + expect(held?.kind === 'variable' && held.parts).toHaveLength(2); + expect(rows.map((r) => r.id)).toContain('local/selector/sObj'); + }); + + // `{}` with nothing recorded is the honest reading of a stateless object, so + // it must not offer a chevron that opens on nothing. + // `{}` and a count of eight reads as empty, so the row previews what it opens + // on. Assembled from writes of their own: the log never held it in one piece. + it('previews the parts a closed row opens on, in the order they open', () => { + const rows = toTreeRows( + { + ...frame, + locals: [row('selector', '{"sObj":"stale","extra":1}', { objectAddress: '0xaaa' })], + }, + closed, + lookups, + ); + + const held = rows.find((r) => r.id === 'local/selector'); + expect(held?.kind === 'variable' && held.assembled).toMatchObject({ + entries: [ + { key: 'rows', text: '5' }, + { key: 'sObj', text: '"Account"' }, + { key: 'extra', text: '1' }, + ], + }); + // The row's own text stays what the log wrote, for the raw value. + expect(held?.kind === 'variable' && held.raw).toBe('{"sObj":"stale","extra":1}'); + expect(held?.kind === 'variable' && held.parts).toHaveLength(3); + }); + + // One rule for every row that opens into parts, so a count means one thing + // wherever it appears. + it('counts the parts of a value the log serialised itself', () => { + const rows = toTreeRows(frame, closed, lookups); + + const held = rows.find((r) => r.id === 'local/held'); + expect(held?.kind === 'variable' && held.parts).toHaveLength(2); + // Written on its own line, so there is nothing to assemble. + expect(held?.kind === 'variable' && held.assembled).toBeNull(); + }); + + it('counts no parts for a value that opens on its text', () => { + const long = 'x'.repeat(200); + const rows = toTreeRows({ ...frame, locals: [row('big', `"${long}"`)] }, closed, lookups); + + const held = rows.find((r) => r.id === 'local/big'); + expect(held?.expandable).toBe(true); + expect(held?.kind === 'variable' && held.parts).toHaveLength(0); + }); + + it('leaves {} closed where the log recorded no fields for it', () => { + const rows = toTreeRows( + { ...frame, locals: [row('empty', '{}', { objectAddress: '0xzzz' })] }, + openAll, + lookups, + ); + + expect(rows.find((r) => r.id === 'local/empty')?.expandable).toBe(false); + }); + + // A recorded field reads as the frame stood; a serialised key reads as the + // object stood when the log wrote it. So the field wins, and the key it covers + // is left out rather than shown twice. + it('puts recorded fields first and drops a serialised key one covers', () => { + const rows = toTreeRows( + { + ...frame, + locals: [row('selector', '{"sObj":"stale","extra":1}', { objectAddress: '0xaaa' })], + }, + openAll, + lookups, + ); + + const inside = rows.filter((r) => r.id.startsWith('local/selector/') && nameOf(r) !== null); + expect(inside.map(nameOf)).toEqual(['rows', 'sObj', 'extra']); + expect(inside.find((r) => nameOf(r) === 'sObj')?.kind).toBe('variable'); + }); + + it('opens a field that is an object in its own right', () => { + const nested = { + fields: (address: string) => + address === '0xaaa' + ? [row('inner', '{}', { objectAddress: '0xbbb' })] + : address === '0xbbb' + ? [row('leaf', '"deep"')] + : [], + }; + const rows = toTreeRows( + { ...frame, locals: [row('selector', '{}', { objectAddress: '0xaaa' })] }, + openAll, + nested, + ); + + expect(rows.map((r) => r.id)).toContain('local/selector/inner/leaf'); + }); + + it('stops a field that points back at its own object', () => { + const loop = { + fields: (address: string) => + address === '0xaaa' ? [row('self', '{}', { objectAddress: '0xaaa' })] : [], + }; + const rows = toTreeRows( + { ...frame, locals: [row('holder', '{}', { objectAddress: '0xaaa' })] }, + openAll, + loop, + ); + + const inner = rows.find((r) => r.id === 'local/holder/self'); + expect(inner?.kind === 'variable' && inner.parts).toHaveLength(1); + // Named, but not opened: opening it would be the same object inside itself. + expect(inner?.expandable).toBe(false); + }); + + // The group's preview, count and rows must be one list. Its fields merge the + // index with the frame's own writes, so the preview has to read that merge and + // not the index alone. + it('previews the this group from the fields it opens on', () => { + const self = row('this', '{}', { objectAddress: '0xaaa' }); + const merged = [row('plain', '"no address"'), ...(recorded['0xaaa'] ?? [])]; + const rows = toTreeRows({ ...frame, thisRow: self, fields: merged }, closed, lookups); + + const group = rows.find((r) => r.id === 'this'); + expect(group?.kind === 'group' && group.count).toBe(3); + expect(group?.kind === 'group' && group.self?.parts).toHaveLength(3); + expect( + group?.kind === 'group' && + group.self?.assembled?.kind === 'container' && + group.self.assembled.entries.map((entry) => entry.key), + ).toEqual(['plain', 'rows', 'sObj']); + }); + + // The `this` group *is* the frame's object, so a field of it that names that + // object again must not reopen the group's own contents. + it('stops a field of this that points back at this', () => { + const self = row('this', '{}', { objectAddress: '0xaaa' }); + const loop = { + fields: (address: string) => + address === '0xaaa' ? [row('me', '{}', { objectAddress: '0xaaa' })] : [], + }; + const rows = toTreeRows( + { ...frame, thisRow: self, fields: [row('me', '{}', { objectAddress: '0xaaa' })] }, + openAll, + loop, + ); + + expect(rows.find((r) => r.id === 'this/me')?.expandable).toBe(false); + }); +}); diff --git a/log-viewer/src/components/variableTree.ts b/log-viewer/src/components/variableTree.ts index 23eee5fb3..d2b109e20 100644 --- a/log-viewer/src/components/variableTree.ts +++ b/log-viewer/src/components/variableTree.ts @@ -3,14 +3,16 @@ */ import { NOT_RECORDED, - type AddressState, type FrameVariables, + type IndexView, type VariableRow, } from '../core/log/frameVariables.js'; import { + assembledContainer, clampRaw, isExpandable, parseVariableValue, + type ValueEntry, type VariableValue, } from '../core/log/variableValue.js'; @@ -58,13 +60,25 @@ export interface Shown { /** The class of the object shown, where the log names it. More telling than * the declared type, which is often only an interface. */ className: string | null; + /** The object this row shows, however its line named it: its own address, or + * the address the line reported for it. What its fields are indexed by. */ + objectAddress: string | null; + /** What the row opens into, in that order. One list, so the preview, the + * count and the rows below cannot disagree. */ + parts: readonly Part[]; + /** What a closed row previews, where the object's parts reached the log as + * writes of their own rather than on its own line. Null where its own text is + * all there is. */ + assembled: VariableValue | null; } -/** What the log holds for an address, as the frame stood. */ -export type Resolver = (address: string) => AddressState; +/** One part of a value: a field the log recorded for the object, or a key the + * value's own text held. `at` is its place in that text, for a stable id. */ +export type Part = { field: VariableRow } | { entry: ValueEntry; at: number }; -/** The class of the object at an address, where the log names it. */ -export type ClassOf = (address: string) => string | null; +/** What the tree asks the log about an address. Every lookup is optional: a + * caller with no index still gets its rows, with nothing resolved. */ +export type Lookups = Partial; /** A row that shows a value of its own, as well as holding others. */ export type GroupSelf = Shown & { declaredType: string | null }; @@ -93,39 +107,88 @@ export type VariableTreeRow = Common & | { kind: 'note'; text: string } ); -/** The value a row shows: an address resolves to the object it names. */ -export function shownValue(row: VariableRow, resolve: Resolver, classOf: ClassOf): Shown { +/** The value a row shows: an address resolves to the object it names. + * + * `fields` overrides what the log holds for the object, for a caller that has + * already gathered them: the `this` group's fields merge the index with the + * frame's own writes, and its preview must be the list it opens on. */ +function shownValue(row: VariableRow, lookups: Lookups, fields?: readonly VariableRow[]): Shown { // The value's own address where it has one, else the address the line // reported for it. A field write reports its owner and so carries neither. - return shown(row.value, row.address, row.address ?? row.objectAddress, resolve, classOf); + return shown(row.value, row.address, row.address ?? row.objectAddress, lookups, fields); } /** * One value, as shown: the object an address names where the log holds it, else * the row's own text. * - * `classAddress` differs from `address` only for a value the log serialised in - * place: it has no address to resolve, but the line still named the object. + * `objectAddress` differs from `address` only for a value the log serialised in + * place: it has no address to resolve, but the line still named the object, and + * that is what its class and its fields are indexed by. */ function shown( text: string, address: string | null, - classAddress: string | null, - resolve: Resolver, - classOf: ClassOf, + objectAddress: string | null, + lookups: Lookups, + given?: readonly VariableRow[], ): Shown { - const state = address ? resolve(address) : NOT_RECORDED; + const state = (address ? lookups.resolve?.(address) : null) ?? NOT_RECORDED; const raw = state.text ?? text; + const value = parseVariableValue(raw); + const fields = given ?? (objectAddress ? (lookups.fields?.(objectAddress) ?? []) : []); + const parts = partsOf(fields, value); return { - value: parseVariableValue(raw), + value, raw, address, resolved: state.text !== null, laterAt: state.laterAt, - className: classAddress ? classOf(classAddress) : null, + className: (objectAddress && lookups.classOf?.(objectAddress)) || null, + objectAddress, + parts, + // Only where a part came from a write of its own: a value the log + // serialised in place previews as the log wrote it. + assembled: fields.length ? assembledOf(parts, value) : null, }; } +/** + * What a value opens into, in that order: the fields the log recorded for the + * object, then the keys the value's own text held that no field covers. + * + * A recorded field wins because it is its own write, at or before this frame, + * where a serialised key is only as the object stood when that line was written. + * One list, read by the preview, the count and the rows alike. + */ +function partsOf(fields: readonly VariableRow[], value: VariableValue): Part[] { + const named = new Set(fields.map((field) => field.name)); + const parts: Part[] = fields.map((field) => ({ field })); + const held = value.kind === 'container' ? value.entries : []; + held.forEach((entry, at) => { + if (entry.key === null || !named.has(entry.key)) { + parts.push({ entry, at }); + } + }); + return parts; +} + +/** {@link partsOf} as one value, for the row that holds them closed: the log + * wrote `{}` for an object it could not serialise, and a row showing only that + * reads as empty while holding eight fields. */ +function assembledOf(parts: readonly Part[], value: VariableValue): VariableValue { + return assembledContainer( + parts.map((part) => + 'field' in part + ? { key: part.field.name, text: part.field.value } + : { key: part.entry.key, text: part.entry.text }, + ), + // The object's own line serialised nothing, so only a surviving serialised + // part can be short of what the log held. + value.kind === 'container' && value.truncated && parts.some((part) => 'entry' in part), + ); +} + /** * Every row the section shows, in order, given which ids are open. * @@ -135,8 +198,7 @@ function shown( export function toTreeRows( frame: FrameVariables, isOpen: (id: string, openByDefault: boolean) => boolean, - resolve: Resolver = () => NOT_RECORDED, - classOf: ClassOf = () => null, + lookups: Lookups = {}, ): VariableTreeRow[] { const rows: VariableTreeRow[] = []; @@ -144,20 +206,23 @@ export function toTreeRows( rows.push({ kind: 'note', id, depth, expandable: false, open: false, text }); }; - /** The rows an open value contributes: one per property, or its raw text. */ - const children = ( + /** The rows an open value contributes: the parts it holds, or its raw text. */ + function children( parentId: string, depth: number, holder: Shown, seen: ReadonlySet, - ): void => { - const { value, raw } = holder; - if (value.kind === 'container' && value.entries.length) { + ): void { + const { value, raw, parts } = holder; + if (parts.length) { let repeats = 0; const keys = new Set(); - value.entries.forEach((entry, at) => { - const id = `${parentId}/${at}`; - const { address } = entry; + for (const part of parts) { + if ('field' in part) { + variable(parentId, depth, part.field, seen); + continue; + } + const { entry, at } = part; if (entry.key !== null) { if (keys.has(entry.key)) { repeats++; @@ -165,16 +230,12 @@ export function toTreeRows( keys.add(entry.key); } } - // One already open above this row: opening it again would be a cycle. - const cycle = address !== null && seen.has(address); - const held = shown(entry.text, address, address, resolve, classOf); - const expandable = !cycle && depth < MAX_DEPTH && isExpandable(held.value); - const open = expandable && isOpen(id, false); - rows.push({ kind: 'entry', id, depth, expandable, open, key: entry.key, ...held }); - if (open) { - children(id, depth + 1, held, withAddress(seen, held.resolved ? held.address : null)); + const id = `${parentId}/${at}`; + const held = shown(entry.text, entry.address, entry.address, lookups); + if (pushValue({ kind: 'entry', key: entry.key }, id, depth, held, seen)) { + children(id, depth + 1, held, withAddress(seen, held.objectAddress)); } - }); + } if (repeats) { note( `${parentId}/repeats`, @@ -182,7 +243,7 @@ export function toTreeRows( `${repeats} keys repeat, kept in the order the log wrote them.`, ); } - if (value.truncated) { + if (value.kind === 'container' && value.truncated) { note(`${parentId}/cut`, depth, 'The log cut this collection short.'); } return; @@ -199,20 +260,51 @@ export function toTreeRows( if (clamped) { note(`${parentId}/clamped`, depth, `Shown to the first ${text.length} characters.`); } - }; + } - const variables = (parentId: string, depth: number, of: readonly VariableRow[]): void => { + function variables( + parentId: string, + depth: number, + of: readonly VariableRow[], + seen: ReadonlySet, + ): void { for (const row of of) { - const id = `${parentId}/${row.name}`; - const held = shownValue(row, resolve, classOf); - const expandable = isExpandable(held.value); - const open = expandable && isOpen(id, false); - rows.push({ kind: 'variable', id, depth, expandable, open, row, ...held }); - if (open) { - children(id, depth + 1, held, withAddress(new Set(), held.resolved ? held.address : null)); - } + variable(parentId, depth, row, seen); } - }; + } + + function variable( + parentId: string, + depth: number, + row: VariableRow, + seen: ReadonlySet, + ): void { + const id = `${parentId}/${row.name}`; + const held = shownValue(row, lookups); + if (pushValue({ kind: 'variable', row }, id, depth, held, seen)) { + children(id, depth + 1, held, withAddress(seen, held.objectAddress)); + } + } + + /** Pushes a row that shows a value, and says whether its children follow. + * + * One rule for what may open: the value holds parts, or its text is too long + * to read in a row. An object already open above this row would be a cycle, + * and `MAX_DEPTH` stops a long chain. */ + function pushValue( + of: { kind: 'variable'; row: VariableRow } | { kind: 'entry'; key: string | null }, + id: string, + depth: number, + held: Shown, + seen: ReadonlySet, + ): boolean { + const cycle = held.objectAddress !== null && seen.has(held.objectAddress); + const expandable = + !cycle && depth < MAX_DEPTH && (held.parts.length > 0 || isExpandable(held.value)); + const open = expandable && isOpen(id, false); + rows.push({ ...of, id, depth, expandable, open, ...held }); + return open; + } const group = (head: GroupHead, kids: (depth: number) => void): void => { const { id, expandable = true, openByDefault = false, of = null, self = null } = head; @@ -233,7 +325,7 @@ export function toTreeRows( }, (depth) => { if (frame.locals.length) { - variables('local', depth, frame.locals); + variables('local', depth, frame.locals, new Set()); } else { note('local/none', depth, 'The log records no locals for this frame.'); } @@ -244,7 +336,7 @@ export function toTreeRows( // own value when closed, its fields when open. A class with no fields has // nothing to open, which is the honest reading of a stateless class. if (frame.thisRow || frame.fields.length) { - const self = frame.thisRow ? shownValue(frame.thisRow, resolve, classOf) : null; + const self = frame.thisRow ? shownValue(frame.thisRow, lookups, frame.fields) : null; group( { id: 'this', @@ -253,7 +345,9 @@ export function toTreeRows( expandable: frame.fields.length > 0, self: self && { ...self, declaredType: frame.thisRow?.declaredType ?? frame.thisType }, }, - (depth) => variables('this', depth, frame.fields), + // The frame's own object, so a field pointing back at it cannot reopen it. + (depth) => + variables('this', depth, frame.fields, withAddress(new Set(), self?.objectAddress ?? null)), ); } @@ -275,7 +369,7 @@ export function toTreeRows( count: entry.rows.length, }); if (open) { - variables(id, 2, entry.rows); + variables(id, 2, entry.rows, new Set()); } } }); diff --git a/log-viewer/src/core/log/__tests__/frameVariables.test.ts b/log-viewer/src/core/log/__tests__/frameVariables.test.ts index f37a9087e..837afff7c 100644 --- a/log-viewer/src/core/log/__tests__/frameVariables.test.ts +++ b/log-viewer/src/core/log/__tests__/frameVariables.test.ts @@ -726,3 +726,199 @@ describe('VariableIndex nested addresses', () => { expect(index.addressState('0xaaa', Number.MAX_SAFE_INTEGER).text).toBe('{"n":2}'); }); }); + +// A `this.field` line names the object the field belongs to, so an object's +// fields are indexed by that address wherever they were written. This is what +// lets a value the log wrote as `{}` show its parts. +describe('VariableIndex object fields', () => { + const BUILT = + '09:18:22.6 (1000)|METHOD_ENTRY|[70]|01p|ns.Caller.run()\n' + + '09:18:22.6 (1010)|CONSTRUCTOR_ENTRY|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1020)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[2]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1050)|VARIABLE_ASSIGNMENT|[3]|this.rows|5|0xaaa\n' + + '09:18:22.6 (1060)|CONSTRUCTOR_EXIT|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1070)|VARIABLE_SCOPE_BEGIN|[71]|selector|ns.Selector|true|false\n' + + '09:18:22.6 (1080)|VARIABLE_ASSIGNMENT|[71]|selector|{}|0xaaa\n' + + '09:18:22.6 (1090)|METHOD_EXIT|[70]|ns.Caller.run()\n'; + + it('answers with the fields the log recorded for an object', async () => { + const { log } = storeOf(BUILT); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xaaa', Number.MAX_SAFE_INTEGER)).toMatchObject([ + { name: 'rows', value: '5' }, + { name: 'sObj', value: '"Account"' }, + ]); + expect(index.fieldsAt('0xaaa', Number.MAX_SAFE_INTEGER)).toHaveLength(2); + }); + + it('holds nothing for an object whose fields the log never wrote', async () => { + const { log } = storeOf(BUILT); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xbbb', Number.MAX_SAFE_INTEGER)).toEqual([]); + }); + + // A field is its own write with its own place in the log, so it reads as the + // frame stood rather than as the object ended up. + it('answers as the frame stood, not as the object ended up', async () => { + const { log, store } = storeOf( + BUILT + + '09:18:22.6 (2000)|METHOD_ENTRY|[80]|01p|ns.Caller.later()\n' + + '09:18:22.6 (2010)|VARIABLE_ASSIGNMENT|[81]|this.rows|9|0xaaa\n' + + '09:18:22.6 (2020)|METHOD_EXIT|[80]|ns.Caller.later()\n', + ); + const index = await variableIndexFor(log); + + const first = frameVariablesFor(store, indexOf(log, 'ns.Caller.run()'), index)!; + const later = frameVariablesFor(store, indexOf(log, 'ns.Caller.later()'), index)!; + + expect(index.fieldsAt('0xaaa', first.cut)).toMatchObject([{ name: 'rows', value: '5' }, {}]); + expect(index.fieldsAt('0xaaa', later.cut)).toMatchObject([{ name: 'rows', value: '9' }, {}]); + }); + + // The frame's own stack cannot see a constructor that has already returned, + // and most of an object's fields are set there. + it('gives a frame the fields its returned constructor set', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|CONSTRUCTOR_ENTRY|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xaaa\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1040)|CONSTRUCTOR_EXIT|[62]|01p|()|ns.Selector\n' + + '09:18:22.6 (1050)|METHOD_ENTRY|[70]|01p|ns.Selector.query()\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[71]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[71]|this|{}|0xaaa\n' + + '09:18:22.6 (1080)|VARIABLE_ASSIGNMENT|[72]|found|7\n' + + '09:18:22.6 (1090)|METHOD_EXIT|[70]|ns.Selector.query()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Selector.query()'), index); + + expect(frame?.fields.map((row) => row.name)).toEqual(['sObj']); + }); + + // An address is reused once its object is collected, so the fields of the + // object that lived there before are not this one's. + it('leaves out the fields of an earlier object at the same address', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|CONSTRUCTOR_ENTRY|[1]|01p|()|ns.First\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.First|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xbbb\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|this.one|1|0xbbb\n' + + '09:18:22.6 (1040)|CONSTRUCTOR_EXIT|[1]|01p|()|ns.First\n' + + '09:18:22.6 (1050)|CONSTRUCTOR_ENTRY|[9]|01p|()|ns.Second\n' + + '09:18:22.6 (1060)|VARIABLE_SCOPE_BEGIN|[9]|this|ns.Second|true|false\n' + + '09:18:22.6 (1070)|VARIABLE_ASSIGNMENT|[9]|this|{}|0xbbb\n' + + '09:18:22.6 (1080)|VARIABLE_ASSIGNMENT|[10]|this.two|2|0xbbb\n' + + '09:18:22.6 (1090)|CONSTRUCTOR_EXIT|[9]|01p|()|ns.Second\n' + + '09:18:22.6 (1100)|METHOD_ENTRY|[20]|01p|ns.Second.go()\n' + + '09:18:22.6 (1110)|VARIABLE_SCOPE_BEGIN|[20]|this|ns.Second|true|false\n' + + '09:18:22.6 (1120)|VARIABLE_ASSIGNMENT|[20]|this|{}|0xbbb\n' + + '09:18:22.6 (1130)|METHOD_EXIT|[20]|ns.Second.go()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Second.go()'), index)!; + + expect(index.fieldsAt('0xbbb', frame.cut).map((row) => row.name)).toEqual(['two']); + }); + + // The index only sees a field write whose line reported an address, so the + // frame's own writes are merged in rather than replaced. + it('keeps a field write whose line reported no address', async () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[70]|01p|ns.Selector.query()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[71]|this|ns.Selector|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[71]|this|{}|0xaaa\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[72]|this.sObj|"Account"|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[73]|this.plain|"no address"\n' + + '09:18:22.6 (1050)|METHOD_EXIT|[70]|ns.Selector.query()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Selector.query()'), index); + + expect(frame?.fields.map((row) => row.name)).toEqual(['plain', 'sObj']); + }); + + // Every row that shows an object reads its fields, and every row is built + // again whenever anything opens, so the read is held. + it("holds an object's fields for the point it was asked about", async () => { + const { log } = storeOf(BUILT); + const index = await variableIndexFor(log); + + const view = index.viewAt(Number.MAX_SAFE_INTEGER); + + expect(view.fields('0xaaa')).toBe(view.fields('0xaaa')); + expect(view.fields('0xaaa')).toHaveLength(2); + }); + + // Two objects of ONE class at a reused address are still two objects. The + // runs are what tells them apart, so they must not merge on the class name. + it('leaves out the fields of an earlier object of the same class', async () => { + const twice = (line: number, field: string) => + `09:18:22.6 (${1000 + line * 10})|CONSTRUCTOR_ENTRY|[${line}]|01p|()|ns.Item\n` + + `09:18:22.6 (${1001 + line * 10})|VARIABLE_SCOPE_BEGIN|[${line}]|this|ns.Item|true|false\n` + + `09:18:22.6 (${1002 + line * 10})|VARIABLE_ASSIGNMENT|[${line}]|this|{}|0xbbb\n` + + `09:18:22.6 (${1003 + line * 10})|VARIABLE_ASSIGNMENT|[${line}]|this.${field}|1|0xbbb\n` + + `09:18:22.6 (${1004 + line * 10})|CONSTRUCTOR_EXIT|[${line}]|01p|()|ns.Item\n`; + const { log, store } = storeOf( + twice(1, 'first') + + twice(5, 'second') + + '09:18:22.6 (1100)|METHOD_ENTRY|[20]|01p|ns.Item.go()\n' + + '09:18:22.6 (1110)|VARIABLE_SCOPE_BEGIN|[20]|this|ns.Item|true|false\n' + + '09:18:22.6 (1120)|VARIABLE_ASSIGNMENT|[20]|this|{}|0xbbb\n' + + '09:18:22.6 (1130)|METHOD_EXIT|[20]|ns.Item.go()\n', + ); + const index = await variableIndexFor(log); + + const frame = frameVariablesFor(store, indexOf(log, 'ns.Item.go()'), index)!; + + expect(index.fieldsAt('0xbbb', frame.cut).map((row) => row.name)).toEqual(['second']); + }); + + // Each field holds its own writes, so a field assigned once early survives a + // field assigned four thousand times. + it('keeps a field written once beside a field written past the cap', async () => { + let body = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Loop.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Loop|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xccc\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|this.keep|"important"|0xccc\n'; + for (let at = 1; at <= 4_100; at++) { + body += `09:18:22.6 (${2000 + at})|VARIABLE_ASSIGNMENT|[3]|this.n|${at}|0xccc\n`; + } + body += '09:18:22.6 (7000)|METHOD_EXIT|[1]|ns.Loop.run()\n'; + const { log } = storeOf(body); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xccc', Number.MAX_SAFE_INTEGER)).toMatchObject([ + { name: 'keep', value: '"important"' }, + { name: 'n', value: '4100' }, + ]); + }); + + // Past the per-object cap the oldest writes are dropped, so a late frame still + // reads the true last value rather than a stale early one. + it('keeps the newest field writes when an object is written past the cap', async () => { + let body = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Loop.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[1]|this|ns.Loop|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[1]|this|{}|0xccc\n'; + for (let at = 1; at <= 4_100; at++) { + body += `09:18:22.6 (${2000 + at})|VARIABLE_ASSIGNMENT|[2]|this.n|${at}|0xccc\n`; + } + body += '09:18:22.6 (7000)|METHOD_EXIT|[1]|ns.Loop.run()\n'; + const { log } = storeOf(body); + const index = await variableIndexFor(log); + + expect(index.fieldsAt('0xccc', Number.MAX_SAFE_INTEGER)).toMatchObject([ + { name: 'n', value: '4100' }, + ]); + expect(index.capped).toBe(false); + }); +}); diff --git a/log-viewer/src/core/log/frameVariables.ts b/log-viewer/src/core/log/frameVariables.ts index a61d0fb41..697587456 100644 --- a/log-viewer/src/core/log/frameVariables.ts +++ b/log-viewer/src/core/log/frameVariables.ts @@ -14,6 +14,7 @@ import { bareAddress, bareAddressOf, classOf, + isFieldName, isStaticName, parseVariableScope, nestedAddressesOf, @@ -45,7 +46,6 @@ import { const ASSIGNMENT = 'VARIABLE_ASSIGNMENT'; const SCOPE_BEGIN = 'VARIABLE_SCOPE_BEGIN'; const CONSTRUCTOR_ENTRY = 'CONSTRUCTOR_ENTRY'; -const FIELD_PREFIX = 'this.'; /** * Writes held per static name before the oldest is dropped, so a static @@ -63,6 +63,14 @@ const MAX_WRITES_PER_STATIC = 10_000; * fields, so this only bites a pathological log. */ const MAX_STATIC_NAMES = 50_000; +/** Writes held per field of an object, dropping the oldest, as + * {@link MAX_WRITES_PER_STATIC} does for a static. Per field, not per object: + * one field written in a long loop must not evict the rest of the object. */ +const MAX_WRITES_PER_FIELD = 2_000; + +/** Distinct objects whose fields are held. */ +const MAX_FIELD_OWNERS = 100_000; + /** One variable, as it stood at the frame. */ export interface VariableRow { /** What the row is called: bare for a local, the field name under `this`, the @@ -158,24 +166,30 @@ export function recordsVariables(log: ApexLog): boolean { * object. * A `this.field` line is no such witness: its reported address is the object * the field belongs to, not the value the line wrote. + * - **An object's fields.** That same owner address groups the writes to one + * object's fields, wherever they were made. A frame's own stack cannot see a + * constructor that has already returned; the index can. */ export class VariableIndex { /** The log recorded at least one write, so an empty answer means an empty * scope rather than a log that records nothing. */ readonly sawAnyWrite: boolean; - /** A static name went unrecorded past {@link MAX_STATIC_NAMES}. */ + /** A name or an object went unrecorded past its cap, so an answer may be + * missing something the log did record. */ readonly capped: boolean; private readonly _writes: Map; private readonly _declared: Map; private readonly _byAddress: Map; private readonly _classes: Map; + private readonly _fieldsByOwner: Map>; private constructor( writes: Map, declared: Map, byAddress: Map, classes: Map, + fieldsByOwner: Map>, sawAnyWrite: boolean, capped: boolean, ) { @@ -183,6 +197,7 @@ export class VariableIndex { this._declared = declared; this._byAddress = byAddress; this._classes = classes; + this._fieldsByOwner = fieldsByOwner; this.sawAnyWrite = sawAnyWrite; this.capped = capped; } @@ -204,8 +219,10 @@ export class VariableIndex { // redeclared on every call, so a frame with no declaration of its own would // otherwise borrow whichever class was declared last, anywhere in the log. const thisClassOf = new Map(); + // Owner address to that object's fields, each name to its own writes. + const fieldsByOwner = new Map>(); let sawAnyWrite = false; - let cappedNames = false; + let dropped = false; await eachEvent(log, tick, (event) => { if (event.type === ASSIGNMENT) { @@ -216,7 +233,22 @@ export class VariableIndex { if (writes.has(name) || writes.size < MAX_STATIC_NAMES) { pushCapped(writes, name, event, MAX_WRITES_PER_STATIC); } else { - cappedNames = true; + dropped = true; + } + } + // The trailing address on a field write is the object the field belongs + // to, so it gathers that object's fields however far apart they were set. + const owner = name && isFieldName(name) ? reportedAddressOf(event.logLine) : null; + if (owner && name) { + let held = fieldsByOwner.get(owner); + if (!held && fieldsByOwner.size < MAX_FIELD_OWNERS) { + held = new Map(); + fieldsByOwner.set(owner, held); + } + if (held) { + pushCapped(held, name, event, MAX_WRITES_PER_FIELD); + } else { + dropped = true; } } if (name === 'this') { @@ -273,7 +305,7 @@ export class VariableIndex { // usually carries two or more different values, and indexing it would // answer about an object with one of its fields. const name = variableNameOf(event.logLine); - if (!name || name.startsWith(FIELD_PREFIX)) { + if (!name || isFieldName(name)) { return; } const address = reportedAddressOf(event.logLine); @@ -284,11 +316,25 @@ export class VariableIndex { }); } - // The walk pops what it pushed, so a key's writes come back out of order. + // Every read below searches by eventIndex, so every list has to be in it. + const byIndex = (left: LogEvent, right: LogEvent): number => left.eventIndex - right.eventIndex; for (const found of [...writes.values(), ...byAddress.values()]) { - found.sort((left, right) => left.eventIndex - right.eventIndex); + found.sort(byIndex); + } + for (const fields of fieldsByOwner.values()) { + for (const found of fields.values()) { + found.sort(byIndex); + } } - return new VariableIndex(writes, declared, byAddress, classes, sawAnyWrite, cappedNames); + return new VariableIndex( + writes, + declared, + byAddress, + classes, + fieldsByOwner, + sawAnyWrite, + dropped, + ); } /** @@ -352,13 +398,91 @@ export class VariableIndex { * the log records the implementation only on that object's own frame. */ classAt(address: string, cut: number): string | null { + return this._runAt(address, cut)?.run.className ?? null; + } + + /** The class run covering `cut`, and how many objects the log saw at this + * address before it. One reader of the runs, so the class a row names and the + * fields it opens on cannot disagree about which object lived here. */ + private _runAt(address: string, cut: number): { run: ClassAt; before: number } | null { const seen = this._classes.get(address); if (!seen) { return null; } const after = firstIndexWhere(seen.length, (index) => seen[index]!.at > cut); - return after ? (seen[after - 1]?.className ?? null) : null; + return after ? { run: seen[after - 1]!, before: after - 1 } : null; + } + + /** + * The fields the log recorded for the object at `address`, as the frame stood. + * + * This is what lets a value the log wrote as `{}` open: the object's own line + * carries no contents, but the writes to its fields are lines of their own. + */ + fieldsAt(address: string, cut: number): VariableRow[] { + return fieldRowsOf(this.fieldWritesAt(address, cut)); } + + /** {@link fieldsAt} as the writes behind it, for a caller merging them with + * writes of its own. */ + fieldWritesAt(address: string, cut: number): Map { + const found = new Map(); + const held = this._fieldsByOwner.get(address); + if (!held) { + return found; + } + const from = this._objectFrom(address, cut); + for (const [name, writes] of held) { + const last = lastAtOrBefore(writes, cut); + // Before this object's own history: the field belonged to the object that + // used this address before it. + if (last && last.eventIndex >= from) { + found.set(name, last); + } + } + return found; + } + + /** + * Where this object's own history starts, for an address the log has seen hold + * more than one object. + * + * Zero where it has not: a field write can precede the `this` write that names + * the class, so bounding every object at its own run would drop it. + */ + private _objectFrom(address: string, cut: number): number { + const found = this._runAt(address, cut); + return found && found.before > 0 ? found.run.at : 0; + } + + /** + * The lookups a reader needs at one point in the log, bound to that point. + * + * An object's fields are read for every row that shows it, and every row is + * built again whenever anything opens, so a read is held rather than repeated. + */ + viewAt(cut: number): IndexView { + const fields = new Map(); + return { + resolve: (address) => this.addressState(address, cut), + classOf: (address) => this.classAt(address, cut), + fields: (address) => { + let held = fields.get(address); + if (!held) { + held = this.fieldsAt(address, cut); + fields.set(address, held); + } + return held; + }, + }; + } +} + +/** What a reader asks the log about an address, bound to one point in it. */ +export interface IndexView { + resolve(address: string): AddressState; + classOf(address: string): string | null; + fields(address: string): readonly VariableRow[]; } /** @@ -408,12 +532,27 @@ export function frameVariablesFor( } } - const fields: VariableRow[] = []; - // Every name here is `this.field`, so its line reports the owner, not the - // field's own value. - for (const [name, write] of fieldWrites(stack, scope, own, thisType, cut)) { - fields.push(rowFor(shortName(name), write, undefined, null)); + // Two sources, one rule: the latest write to a field wins. The index finds + // every write against the frame's object wherever it was made, so a field a + // returned constructor set is in scope here; the stack walk finds the writes + // whose line reported no address, which the index never sees. + // + // Every field of the object, not only the ones the frame's own class wrote: a + // base method reads fields a base constructor set, and the log names no class + // hierarchy to tell an inherited field from a subclass's own. So a base frame + // can list a field only the subclass declares. Filtering by the writing + // frame's class would lose the inherited case, which is the common one. + const object = thisAddressOf(own.writes); + const found = fieldWrites(stack, scope, own, thisType, cut); + if (object && index) { + for (const [name, write] of index.fieldWritesAt(object, cut)) { + const held = found.get(name); + if (!held || held.eventIndex < write.eventIndex) { + found.set(name, write); + } + } } + const fields = fieldRowsOf(found); return { frameLabel: scope.text, @@ -421,7 +560,7 @@ export function frameVariablesFor( thisType, locals: locals.sort(byName), thisRow, - fields: fields.sort(byName), + fields, statics: index?.at(cut) ?? [], truncated: stack.some((entry) => entry.isTruncated) || selected.isTruncated, }; @@ -508,19 +647,40 @@ function scanFrame(frame: LogEvent, cut: number): FrameScan { * never read: a frame can hold hundreds of thousands of children, so the rest * is thrown away. */ function thisWritesOf(frame: LogEvent, cut: number): Map { - const writes = new Map(); - const children = frame.children; - for (let at = firstIndexWhere(children.length, (i) => children[i]!.eventIndex > cut); at--;) { - const child = children[at]!; - if (child.type !== ASSIGNMENT) { - continue; - } - const name = variableNameOf(child.logLine); - if (name && (name === 'this' || name.startsWith(FIELD_PREFIX)) && !writes.has(name)) { - writes.set(name, child); + return lastWritesByName(frame.children, cut, (name) => name === 'this' || isFieldName(name)); +} + +/** + * The last write to each name at or before `cut`, from events in eventIndex + * order. + * + * Backwards from the cut, so the first write seen for a name is the last one + * made. + */ +function lastWritesByName( + events: readonly LogEvent[], + cut: number, + keep: (name: string) => boolean, +): Map { + const found = new Map(); + for (let at = firstIndexWhere(events.length, (i) => events[i]!.eventIndex > cut); at--;) { + const event = events[at]!; + const name = event.type === ASSIGNMENT ? variableNameOf(event.logLine) : null; + if (name && keep(name) && !found.has(name)) { + found.set(name, event); } } - return writes; + return found; +} + +/** Field writes as their rows, sorted, carrying the `objectAddress` a field row + * must always have: its line reports the owner, never its own value. */ +function fieldRowsOf(writes: ReadonlyMap): VariableRow[] { + const rows: VariableRow[] = []; + for (const [name, write] of writes) { + rows.push(rowFor(shortName(name), write, undefined, null)); + } + return rows.sort(byName); } /** @@ -587,7 +747,7 @@ function fieldWrites( continue; } for (const [name, write] of writes) { - if (!name.startsWith(FIELD_PREFIX)) { + if (!isFieldName(name)) { continue; } const held = found.get(name); @@ -608,7 +768,7 @@ function fieldWrites( */ function thisAddressOf(writes: ReadonlyMap): string | null { for (const [name, write] of writes) { - if (name === 'this' || name.startsWith(FIELD_PREFIX)) { + if (name === 'this' || isFieldName(name)) { const address = reportedAddressOf(write.logLine); if (address) { return address; @@ -672,7 +832,9 @@ interface NamedAt { * class only for an object constructed outside the log. * * An address is reused once its object is collected, hence runs rather than one - * class per address. + * class per address. A construction past the last run is always a new object, + * even of the same class: merging those two runs would let a collected object's + * fields read as the new one's. */ function keepClass( classes: Map, @@ -690,10 +852,6 @@ function keepClass( if (named.at <= last.until || !named.constructed) { return; } - if (last.className === className) { - last.until = Math.max(last.until, named.until); - return; - } seen.push(run); } diff --git a/log-viewer/src/core/log/variableLine.ts b/log-viewer/src/core/log/variableLine.ts index 4f65011da..211152f64 100644 --- a/log-viewer/src/core/log/variableLine.ts +++ b/log-viewer/src/core/log/variableLine.ts @@ -195,9 +195,17 @@ export function shortName(name: string): string { return lastDot > 0 ? name.slice(lastDot + 1) : name; } +const FIELD_PREFIX = 'this.'; + /** True for a name the log qualified with its class, which every static is. */ export function isStaticName(name: string): boolean { - return name.includes('.') && !name.startsWith('this.'); + return name.includes('.') && !isFieldName(name); +} + +/** True for a field of the object a frame runs on. Its line reports that + * object's address, never the field's own value. */ +export function isFieldName(name: string): boolean { + return name.startsWith(FIELD_PREFIX); } /** The character after the nth pipe, or -1 where the line has fewer. */ diff --git a/log-viewer/src/core/log/variableValue.ts b/log-viewer/src/core/log/variableValue.ts index 9661bc1d3..7eed28e03 100644 --- a/log-viewer/src/core/log/variableValue.ts +++ b/log-viewer/src/core/log/variableValue.ts @@ -224,6 +224,27 @@ function valued(text: string): Pick { return { text, address: bareAddress(inner) }; } +/** + * A container assembled from writes of their own, for an object the log wrote as + * `{}` and described in lines of its own. + * + * Read the same way a parsed container is, so an entry names the address its + * text names. Truncation is the caller's to state: the object was never + * serialised, so its own line cut nothing short. + */ +export function assembledContainer( + parts: readonly { key: string | null; text: string }[], + truncated: boolean, +): VariableValue { + return { + kind: 'container', + brackets: '{}', + entries: parts.map((part) => ({ key: part.key, ...valued(part.text) })), + truncated, + fromString: false, + }; +} + /** Splits on the commas that separate entries: not those inside a string, and * not those inside a nested value. */ function splitTopLevel(body: string): string[] { diff --git a/scripts/measure/variables.ts b/scripts/measure/variables.ts index 248cfa2b6..e14fef16b 100644 --- a/scripts/measure/variables.ts +++ b/scripts/measure/variables.ts @@ -38,9 +38,16 @@ export async function measureVariables(log: ApexLog): Promise { const frames = log.eventsById.filter((event) => event.isParent); const step = Math.max(1, Math.floor(frames.length / SAMPLED_FRAMES)); const sampled = frames.filter((_, at) => at % step === 0).slice(0, SAMPLED_FRAMES); + // The addresses come from the snapshots this times, so the sampled frames are + // read once rather than again for the fields below. + const objects = new Set(); await time(`${sampled.length} frame snapshots`, () => { for (const frame of sampled) { - frameVariablesFor(store, frame.eventIndex, index); + for (const row of frameVariablesFor(store, frame.eventIndex, index)?.locals ?? []) { + if (row.objectAddress) { + objects.add(row.objectAddress); + } + } } }); @@ -59,4 +66,12 @@ export async function measureVariables(log: ApexLog): Promise { `${shape?.locals.length ?? 0} locals, ${shape?.fields.length ?? 0} fields`, ); await time('worst frame snapshot', () => frameVariablesFor(store, worst.eventIndex, index)); + + // Opening an object row reads that object's fields back, so this is what a row + // costs once the scope is on screen. + await time(`fieldsAt on ${objects.size} objects`, () => { + for (const address of objects) { + index.fieldsAt(address, Number.MAX_SAFE_INTEGER); + } + }); } From bf37abd01d43a42d212fb08378004f2c861fe179 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:59:59 +0100 Subject: [PATCH 40/61] docs: trim the unreleased changelog to short, user-focused entries (#1012) The unreleased section had grown to 65 lines with sub-bullets nested three deep. Nobody reads that. ## Changelog Unreleased is now 37 lines, 26 entries. Each entry is one or two lines and says what the user gets. - Sub-bullets folded into their headline. The Inspector went from 6 bullets to 1 line. - Related entries merged. Three grid-styling entries became one. - Internal names cut. "Replace webview-ui-toolkit with vscode-elements" is not something a user can see. - Each section ordered by impact. "Go to Code is 6x to 10x faster" moved from last to third. - All 14 issue references kept. No released section is touched. ## AGENTS.md Records the rules above, so the next entry starts in the right shape. --- .claude/skills/changelog-entry/SKILL.md | 114 ++++++++++++++++++++++++ AGENTS.md | 3 + CHANGELOG.md | 93 ++++++------------- 3 files changed, 146 insertions(+), 64 deletions(-) create mode 100644 .claude/skills/changelog-entry/SKILL.md diff --git a/.claude/skills/changelog-entry/SKILL.md b/.claude/skills/changelog-entry/SKILL.md new file mode 100644 index 000000000..88a838abf --- /dev/null +++ b/.claude/skills/changelog-entry/SKILL.md @@ -0,0 +1,114 @@ +--- +name: changelog-entry +description: Write, review or trim CHANGELOG.md entries so a reader understands them. Use when a pull request needs a changelog line, when an Unreleased section is too long or too technical to read, when deciding if a change is breaking, or when a changelog conflict appears while rebasing. +--- + +# Changelog entry + +A changelog is written for the person who upgrades. How the change was made, what it cost, and which +files moved belong in the issue and the pull request. + +## The shape of an entry + +``` +- [**Breaking:** ] ([#]) +``` + +```markdown +### Changed + +- **Breaking:** drop `file` from `get_apex_log_summary` in favour of the scalar `topMethodsSelfPercentage` ([#86]) +- Reduce every tool response with no fact lost: `execute_anonymous` by 30%, `get_apex_log_summary` by 27% ([#86]) + + + +[#86]: https://github.com/owner/repo/issues/86 +``` + +- **One sentence, no sub-bullets.** No semicolon joining two facts. Two wrapped lines is the ceiling. +- **Present tense.** "Add", "Reduce", "Refuse" โ€” not "Added", "Reduced". +- **Breaking entries first** in their section, prefixed `**Breaking:**`. +- **Then most impactful first.** The entry that changes the most readers' day leads its + section. Not commit order, not issue number, not the order you wrote them. +- **Sections in this order:** Changed, Added, Removed, Fixed. +- **A reference link on every substantial entry**, defined under `` at the end of + the file. Never an inline URL. + +**The file outranks this skill on style.** Read the released sections first. If they carry an emoji +and a bold label, or past tense, match them โ€” a changelog that switches voice mid-file reads worse +than one in the wrong voice. Length and jargon are not style: those rules hold everywhere. + +## Write for the reader, not the author + +The reader upgrades the package; they did not write it. Name the outcome they can see. + +- **No internal jargon.** No module, class, library or algorithm names. If the reader cannot find + the word in the product, cut it. +- **A fix names the symptom, not the cause.** +- **A big feature gets one headline entry**, not a tour of every facet. Detail belongs in the docs. + +## What earns an entry + +One entry per user-visible change, not one per commit. If a user of the released package cannot see +it, it gets no entry: a refactor, a renamed internal helper, a test, the mechanism behind a fix. + +Give the result, not the method. A number earns its place when the size **is** the result; how it +was measured does not. + +**A performance entry always carries its number** โ€” a multiple or a percentage, and what it is of. +"Faster" on its own is not an entry, because the reader cannot tell whether to care. + +Already-unreleased work: edit the existing entry, and drop a fix for a bug that only ever existed +in it. A change nobody has received is not a change, and nobody met the bug. + +No issue fits? File one, then reference it. + +## Wrong, then right + +| Wrong | Right | +| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `- Removed destructiveHint from three tools, since the spec says it is meaningless when readOnlyHint is true` | no entry โ€” the user sees no difference | +| `- Replaced ten per-category properties with one z.partialRecord, cutting ~844 to ~428 tokens` | fold the result into the one user-facing entry | +| `- Reduced the cost by 31% ([#87](https://.../87))` | `- Reduce the cost by 31% ([#87])`, plus a reference definition | +| `- Refactor CSV parsing to process dataset arrays asynchronously` | `- Fix the freeze on a large CSV export` | +| `- Replace webview-ui-toolkit with vscode-elements` | `- Match the host's controls more closely` | +| a feature with six nested sub-bullets | one headline sentence, plus a docs link | +| `- Improve search performance` | `- Search a 100MB log 10ร— faster` | +| `- Optimise the parser` | `- Cut parse time on a large log by 31%` | + +## Trim a section nobody will read + +Screens long, or nested three deep. Rewrite the section whole โ€” entry-by-entry edits never merge +anything, and merging is most of the win. + +1. Find the bounds: `grep -n '^## \[' CHANGELOG.md`. +2. Read the whole section before changing a word. +3. Draft the replacement in one pass. Fold every sub-bullet into its headline, or drop it. +4. Merge entries that name the same surface or the same fix. Three styling entries are one entry. +5. Drop what the reader cannot see, by the rules above. +6. Re-order each section by impact. A trimmed section in the old order still buries the lead. +7. Keep every issue reference. Losing one loses the trail to the detail you cut. +8. Splice it in, then check the references โ€” left column is used but undefined, right is defined but + unused: + + ```bash + comm -3 <(grep -v '^\[#' CHANGELOG.md | grep -o '\[#[0-9]*\]' | sort -u) \ + <(grep -o '^\[#[0-9]*\]' CHANGELOG.md | sort -u) + ``` + +9. Run the repo's formatter. + +Report the before/after line count and every entry you merged or dropped. A cut the author disagrees +with is invisible to them otherwise. + +## Versions and migration + +- A version heading is added when the release is tagged, with an absolute date: `## [1.0.0] - 2026-03-20`. + Until then everything sits under `## [Unreleased]`. +- **Major** is forced by behaviour that changes for someone who upgrades and changes nothing else. +- When upgrading needs an action, add a migration note under `## [Unreleased]` that points at it. + +## Stacked branches + +Every branch in a stack writes into the same `## [Unreleased]` section, so a rebase conflicts there. +Keep both sides. Losing the other branch's entry is silent, and review will not catch it. diff --git a/AGENTS.md b/AGENTS.md index 1f7fda740..0c393045f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,9 @@ communicate via message passing only. `refactor:`, `perf:`, `test:`). Don't auto-commit. - Branches: `feat-*` for features, `bug-*` for defects. - Releases follow SemVer; update CHANGELOG; breaking changes need a migration guide. +- CHANGELOG entries: one or two lines, no sub-bullets. Say what the user gets, not how it + was built. Order each section by impact, most impactful first. A perf entry states its + multiple or percentage. House style: `- **Label**: ([#issue])`. - Never reference Anthropic or Claude in commit messages, PRs, etc. ## Rules manifest diff --git a/CHANGELOG.md b/CHANGELOG.md index 07025efb3..e5d811e13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,77 +9,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- ๐Ÿง  **Heap analysis**: heap is no longer a single number. Every method and call path now carries three heap metrics, so you can tell a real leak from harmless allocate-then-free churn. ([#32]) - - **Net** โ€“ bytes retained (allocated minus freed); the lasting footprint. Can be negative where a path frees more than it allocates. - - **Gross** โ€“ bytes allocated, ignoring frees; allocation churn and GC pressure. - - **Peak** โ€“ highest live heap reached on the path; the number comparable to the heap governor limit. - - Shown together in the **Memory** view (total + self); peak also appears in the **Governor Limits** view and feeds the Gov Avg/Peak columns. Method tooltips show net heap retained. - - The Timeline governor strip plots heap as it's allocated, so you can see where it spikes. -- ๐Ÿงญ **Inspector**: select anything โ€” a timeline frame, a call tree or analysis row, a SOQL/DML/SOSL statement โ€” and inspect it without leaving the tab you're on. ([#113]) - - **A selection** shows its details and governor metrics as `used / limit`, the call stack that led to it, and its own subtree in **Time Order**, **Aggregated** or **Bottom-Up**. Click a frame in the call stack to walk up it โ€” the details and subtree follow, and the stack stays anchored to what you selected. On the Timeline it also splits the self time under the selection by the namespace whose code ran it. - - **Variables** in scope at the frame you selected: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, each value as it stood at the frame. An object opens into the fields the log recorded for it, and a field that is itself an object opens again. Every row that opens previews what is inside it, with a count. Needs Apex Code at **FINEST**. ([#373]) - - **Nothing selected** shows the whole log instead of an empty panel: a governor overview on every tab, time by category, self time by namespace and governor trends on the Timeline, log-wide findings and how per-call self time spreads on Analysis, the hot path and hot spots on the Call Tree, and, on Database, which namespaces asked for and burned the database time, how few statements hold the time, and every call path that ends in a query, DML or search with total and self time. ([#373]) - - Every row is a link: click it to reveal the frame, row or statement behind it in the tab you're on. Hover works both ways without moving the view โ€” hover a row to pick out what it names in the tab you're on, or hover there to mark the rows that name it, and what you click stays picked out until `Escape`. Click a point on a governor usage chart to move the Timeline to that instant and zoom in on it. Right-click for copy actions. - - **Findings** list the statements behind them, most repeated first with how often each ran, and report one query built per record and run a row at a time. The severities head the list and filter it, any number at once, a finding the log times shows how long it took and what that is of the log, and selecting an Analysis row narrows the list to the findings that name that method or anything it called. - - **Detail | Summary** switches between what you picked and the tab's summary of the whole log, keeping the selection to come back to. - - Dock it left, right or bottom, drag to resize any section โ€” double-click a divider to restore the defaults โ€” and collapse the sections you don't need; the layout is remembered. `Escape` clears the selection and returns the whole-log view. ([#63]) -- ๐Ÿ—„๏ธ **Database Analysis**: governor-limit visibility and SOSL usage. ([#162]) - - ๐Ÿ“ **Governor-limit overview**: SOQL, SOSL, DML and query/DML rows shown as `used / limit`, colored as they approach the limit. - - ๐Ÿงฎ **Found vs Counted**: each section reconciles statements found in the log against the governor-counted total, flagging queries that didn't consume the limit (e.g. custom metadata, which is free unless it selects a long text area field or runs in a Flow). - - ๐Ÿ”Ž **SOSL table**: a dedicated, searchable Database table for SOSL. - - ๐Ÿงญ **Show in Call Tree**: right-click any SOQL, DML or SOSL statement to jump to it in the full Call Tree. -- ๐Ÿ—‚๏ธ **Configurable table columns** (Call Tree, Analysis, Database). ([#298]) - - ๐Ÿ—‚๏ธ **Column views**: 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). -- ๐Ÿงฐ **Filter bar** (Call Tree, Database): filters now live in one toolbar above each table. - - Filter by **Namespace**, **Object** or **Caller Namespace**, or by a **Row Count** / **Time Taken** minโ€“max range; active filters are highlighted. - - Collapse behind a **Filter** button on narrow window. ([#873]) -- ๐Ÿ”ด **Timeline exception markers**: exceptions show as red lines, with a **Throws** count in method tooltips. ([#828]) -- ๐Ÿชช **Header**: the header now includes entry point, user, and start time with hover for more details. +- ๐Ÿงญ **Inspector**: select a timeline frame, a table row or a statement to see its details, governor usage, call stack and subtree - or select nothing for a whole-log overview. Dock it left, right or bottom. ([#113] [#373] [#63]) +- ๐Ÿ”ฌ **Variables**: see the **Local** and **Static** variables in scope at the frame you selected, each holding the value it had at that point; an object opens into its fields. Needs Apex Code at **FINEST**. ([#373]) +- ๐Ÿง  **Heap analysis**: every method and call path reports heap three ways - **Net** (retained), **Gross** (allocated) and **Peak** (highest live) - so allocate-then-free churn no longer looks like a leak. ([#32]) +- ๐Ÿ—„๏ธ **Database governor limits**: SOQL, SOSL, DML and row counts show as `used / limit`, flagging queries that did not consume the limit, plus a dedicated SOSL table. ([#162]) +- ๐Ÿ”ด **Timeline exception markers**: exceptions show as red lines, with a Throws count in method tooltips. ([#828]) +- ๐Ÿงฐ **Filter bar**: Call Tree and Database filters sit in one toolbar - filter by namespace, object or caller namespace, or by a row count or time range. ([#873]) +- ๐Ÿ—‚๏ธ **Choose your columns** in Call Tree, Analysis and Database: switch between presets, show or hide any column, and your choice is remembered. ([#298]) +- ๐Ÿท๏ธ **New columns**: Object on SOQL and DML, SOSL Count and Rows, Avg Self Time, a Self variant of every governor metric, and a SOQL Query Plan view. ([#298]) +- ๐Ÿชช **Header**: shows the entry point, user and start time, with more detail on hover. ### Changed - โฌ†๏ธ **Requires VS Code 1.102 or newer**. -- ๐Ÿ“ **Governor figures**: every whole-log readout โ€” the overview gauges, the governor trends, the Database overview and the Analysis findings โ€” reports a metric at its peak, the level the governor charges the transaction at. The Timeline governor strip still plots the log as recorded. -- ๐Ÿ“Š **Timeline** - - **Governor limits strip**: tooltip rows keep a stable order and always show the `used / limit` value, so figures no longer jump around as you move the pointer. ([#827]) - - **Timeline zooming**: consistent, smooth zoom across platforms and input devices โ€” a Windows mouse wheel no longer over-zooms in large jumps, fast scrolls stay bounded, and zooming in then back out returns to the same level. - - **Truncation markers** now end where the log recovers, so trusted sections are no longer flagged. ([#828]) - - **Frame details**: the hover panel now sits against the frame โ€” above it, or below it when there is no room โ€” and slides along the frame with the pointer. It fades in after a short pause and keeps one size, which grows with the window. A SOQL query is fitted to that size clause by clause, so the `WHERE` is always visible however long the field list is, and each clause says what it left out โ€” `+35 fields`, `โ€ฆ +6 conditions`, `IN (โ€ฆ 200 ids)`. A footer row points to the inspector for the rest. The panel never takes the pointer, so you can hover and click the frames underneath it. Turn it off from the toolbar button or with `lana.timeline.showTooltip`. - - **Legend**: moved from below the chart to the toolbar above it, restyled as colour-dot chips, and each chip now shows the log's self time in that category. Event tooltips name the category next to its colour swatch. -- ๐Ÿท๏ธ **Call Tree names**: rows no longer carry a raw `EVENT_TYPE:` prefix in front of text that already identifies them, so `WF_CRITERIA_BEGIN: WF_CRITERIA : ON_ALL_CHANGES` reads as `WF_CRITERIA : ON_ALL_CHANGES`. Frames whose text can't stand alone keep the type, and the ones that needed naming now say what they are โ€” `(code unit)`, `(constructor)`, `(managed package)`, `(flow)`. A **Type** column is available in every view from the **Columns** menu if you want the raw types back. -- ๐Ÿ—‚๏ธ **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]). -- ๐Ÿ—„๏ธ **Database table columns** (DML, SOQL, SOSL): consolidated onto the shared Call Tree column/sort styling for a consistent look across all tables. ([#873]) -- ๐Ÿงฑ **Data grids**: a crisper header/content separator and tidied grid styling across all tables. ([#873]) -- ๐Ÿ“ **Column widths**: sized to fit their header and values, so nothing clips. -- ๐Ÿ”ค **Text sizes** now follow your VS Code font size instead of fixed pixel sizes, and code โ€” table name columns, tooltips, group rows โ€” always uses your editor font. -- ๐ŸŽจ **Header bar** - - **Log problems** and **Notifications** redesigned cards, show two lines of summary and message (click the message for the rest), and go to the Call Tree when clicked. An **Unsupported log event** card opens a prefilled bug report. - - **Log problems** icon shows the most severe problem found, with a count, the card shows the issue kind (a `Fatal error` / `Exception` pill) and the time in the log under the summary . - - **Log problems** card say what kind of problem they are: a `Fatal error` / `Exception` pill and the time in the log sit under the summary. - - **Help & documentation** and **Report an issue** move into a `โ€ขโ€ขโ€ข` menu, which also holds the values and controls the header drops as the window narrows. -- โ™ป๏ธ Replace `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). -- โšก **Go to Code**: Faster in large projects โ€” ~6ร— to ~10ร— faster ([#834]). -- โšก **Timeline minimap**: ~25ร— faster and uses less memory. +- ๐Ÿ“ **Governor figures**: the Inspector overview and the Database tab report each metric at its peak, the level the governor charges the transaction at. The Timeline strip still plots the log as recorded. +- ๐ŸŽจ **Header bar**: Log problems and Notifications are redesigned cards that name the problem and its time and jump to the Call Tree; Help and Report an issue move into a `โ€ขโ€ขโ€ข` menu. +- ๐ŸŽจ **Timeline legend**: moved into the toolbar as colour chips, each showing the log's self time in that category. +- ๐Ÿ“Š **Timeline frame details**: the hover panel sits against the frame, follows the pointer, never blocks clicks, and fits long SOQL so the `WHERE` stays visible. Turn it off with `lana.timeline.showTooltip`. +- ๐Ÿ” **Timeline zooming**: smooth and consistent on every platform - a Windows mouse wheel no longer over-zooms, and zooming back out returns you to where you started. +- โœ‚๏ธ **Truncation markers** end where the log recovers, so sound sections are no longer flagged. ([#828]) +- โšก **Timeline minimap** is ~25ร— faster and uses less memory. +- ๐Ÿ“ˆ **Governor strip tooltips** keep a stable row order and always show `used / limit`, so figures no longer jump as you move the pointer. ([#827]) +- โšก **Go to Code** is 6ร— to 10ร— faster in large projects. ([#834]) +- ๐Ÿท๏ธ **Call Tree names** drop the redundant `EVENT_TYPE:` prefix and say what a frame is - `(code unit)`, `(flow)`. Add the **Type** column back from the **Columns** menu. +- ๐Ÿงฑ **Table styling**: VS Code tree icons, rows indented under their group headings, a crisper header separator, columns sized to fit so nothing clips, and one consistent look across every table. ([#832] [#873]) +- ๐ŸŽ›๏ธ **Dropdowns** are searchable and compact, and carry the field and value together (e.g. `Group: Namespace`). ([#848]) +- ๐Ÿ’… **UI** More closely matches VS Code styling in several areas. ([#576]) ### Fixed -- ๐ŸŽจ **Timeline theme switch**: parts of the Timeline did not update on theme switch until the log view was reopened; they now do. -- ๐Ÿ“Š **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. ([#873]) -- ๐ŸŽจ **Theme colours**: some colours did not update on theme switch; they now do. -- โŒจ๏ธ **Call Tree keyboard**: clicking a row's expand arrow dropped keyboard focus, so the arrow keys scrolled the table instead of moving through it; focus now returns after every expand and collapse. -- ๐Ÿงญ **Call Tree navigation**: jumping to a row in the Aggregated or Bottom-Up view could stop on one of its callers and need a second try, because the walk read a row's children before they had rendered; it now waits for the render. -- ๐Ÿงญ **Inspector call stack**: cumulative limit and profiling frames appeared in the stack, so the path to a selection read wrong; the stack now excludes them, like the call tree already did. -- ๐Ÿ› **Go to Code**: Match methods with namespace/`System`-qualified parameter types. ([#834]) -- ๐Ÿ“ **Timeline height**: the Flame Chart stopped short of the bottom of its panel, leaving a strip of empty space; it now fills the panel and follows the Inspector as you resize or re-dock it. -- ๐Ÿ—„๏ธ **Flow database usage**: SOQL and DML run by a Flow or Process Builder element went uncounted, because the log never reports it as a statement; the element's own usage is now counted and rolls up like any other. Needs `WORKFLOW` at `FINER` or above. ([#871]) -- ๐Ÿ“ **Timeline length**: the chart stopped at the last frame the log recorded, so it drew shorter than the log's own duration โ€” 10.8s of a 27.1s log where the size cap cut the log off. The chart now spans the whole log, and the truncation marker shades the part the log never recorded. ([#828]) -- ๐Ÿงญ **Hot spots**: the log itself topped the Inspector's hot spots, and the Analysis findings, whenever time went unrecorded โ€” the gap between frames lands on the log, which is a container and not code. It is now left out of both. -- ๐Ÿ“Š **Governor limits strip**: where a log records nothing โ€” it hit the maximum size, or lines were skipped โ€” the strip drew its last reading across the gap as though it had been measured. The area fills, the over-100% band and the collapsed traffic light now leave the gap blank, the step line holds its last level, and the tooltip names the reason and the range, such as `Max-Size-reached ยท 10.8s โ†’ 27.1s`. Truncation shading also ends with its marker instead of running on to the next one. ([#828]) -- ๐Ÿ–ฑ๏ธ **Governor limits strip**: the strip is 15px tall when collapsed, so reading across it lost the tooltip on the smallest vertical wobble. The hover now holds until the pointer is clear of the strip. Hovering the chevron blanks the tooltip only over the arrow, not across the whole 20px column, and the pointer reads as a crosshair over the data, where a click centres and the wheel zooms. -- โšก **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge โ€” it cleared its canvases in one frame and drew in the next, sized to a box the drag had already left. It now clears, sizes and draws in the frame the layout changed, and no longer recomputes the minimap for a change that cannot alter it. -- ๐ŸŽฏ **Timeline highlight**: clicking a frame greyed out the whole rest of the chart, which reads as a filter and not a selection. A click now selects and leaves every other frame in its own colour, and the frame under the pointer washes as you move across. The Inspector and the search still grey out what they do not match. +- ๐Ÿ“ **Timeline length**: the chart stopped at the last recorded frame instead of spanning the log - 10.8s of a 27.1s log - and now shades the part the log never recorded. ([#828]) +- ๐Ÿ“Š **Governor limits strip**: where the log recorded nothing, the strip drew its last reading across the gap as though it had been measured; the gap is now blank, and the tooltip names the reason and range. ([#828]) +- ๐Ÿ—„๏ธ **Flow database usage**: SOQL and DML run by a Flow or Process Builder element are now counted. Needs `WORKFLOW` at `FINER` or above. ([#871]). +- โšก **Timeline resize**: the Flame Chart flashed and trailed a frame behind as you dragged the window or the panel edge. +- ๐Ÿ–ฑ๏ธ **Governor limits strip**: reading across the 15px collapsed strip lost the tooltip on the smallest wobble; the hover now holds until the pointer is clear of it. +- ๐Ÿ› **Go to Code** matches methods with namespace or `System` qualified parameter types. ([#834]) +- ๐ŸŽจ **Theme switch**: Timeline and view colours update straight away instead of needing the log reopened. ## [1.20.1] 2026-07-23 From 011058ee7b0ac77031b0501cd70d82f63ee28d9c Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:30:47 +0100 Subject: [PATCH 41/61] refactor(log-viewer): one size for code text, and tokens for the ring and the pill (#1011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview Grid cells, query text, Inspector snippets and notification stack traces took their size from the reader's `editor.fontSize`, so a 14px editor font put them two steps over the chrome and the app could not state its own density. `.soql-block` also overrode two surfaces that had deliberately chosen a smaller step: the diagnostics evidence line, and the timeline tooltip, whose one-line clamp is measured against it. Code-shaped text now takes no size of its own and inherits the surface that holds it. One place cannot: tabulator's `textSize` is a Sass parameter, not a declaration, so the table root names the app's step. Nothing in the webview reads `--vscode-editor-font-size` any more, and the editor still supplies the family. Alongside that, the appearance tokens the sweep needed: one focus ring instead of nine copies, a pill radius for a bar that must round whatever its height, and a verdict that reads as an outline chip. ## ๐Ÿ› ๏ธ Changes made - Code-shaped text inherits its surface: `.soql-block`, the grid's code column, `CodeBlock`'s `pre` and `IssueList`'s stack traces all drop their size. `--lana-text-mono` had no consumer left and is gone - `--lana-focus-ring`, `--lana-focus-offset` and `--lana-focus-inset` replace the same three declarations written out nine times across seven files. The repo's rule already said a `calc` in three or more files is a token, and that `calc` was in four - `--lana-radius-pill` on both governor gauges and the facet count. A corner radius un-rounds a thin bar as soon as a host sets a smaller step - A verdict is an outline chip: one hue drives its text, a 12% ground and a 30% edge, matching the tint percentages already in the app. "Not selective" reads as a warning rather than an error - `GovernorSummary`'s gauge figure states no size, so it reads at whatever surface holds it - Literals converted in the rules the sweep touched, and the comments it made stale ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A ## ๐Ÿ”— Related Issues N/A ## โœ… Tests added? - [x] ๐Ÿ™… no, not needed Appearance tokens only, and no test asserts a `--lana-*` value. Verified with `jest --runInBand` (2298 tests / 169 suites, all three projects), `tsc -b log-viewer --force`, eslint, prettier and a production build. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ™… not needed `.claude/rules/log-viewer.md` records the rule that now holds: code-shaped text takes no size of its own, so nothing in the webview follows the reader's `editor.fontSize`. ## Anything else we need to know? [optional] Two things found while reviewing, neither introduced here. Tabulator's own `min-height: $textSize + ($headerMargin * 2)` cannot take a `var()` โ€” Sass concatenates instead of adding, so the shipped rule is `min-height:var(--lana-text-base)8px`, which browsers drop. The header-cell min-height has therefore never applied, with the old token or the new one. Left alone; fixing it needs a literal or an unlayered override of upstream. The gotcha is noted on the declaration that feeds it. The verdict chip's coloured text sits under WCAG AA on light themes, roughly 3.1:1 for Light+'s warning colour at 10px. That is inherited from `severityStyles`, which colours the Analysis findings the same way; the fix is to darken the severity tokens for light grounds, app-wide, and is tracked separately. --- .claude/rules/log-viewer.md | 5 +++-- log-viewer/src/components/CodeBlock.ts | 1 - log-viewer/src/components/EventVitals.ts | 15 ++++++++++----- log-viewer/src/components/GovernorTrends.ts | 6 +++--- log-viewer/src/components/OverflowList.ts | 12 ++++++------ log-viewer/src/components/PaneView.ts | 6 +++--- .../src/components/datagrid-facet-filter.ts | 2 +- .../src/components/datagrid-range-filter.ts | 8 ++++---- .../analysis/components/LogDiagnosticsView.ts | 8 ++++---- .../database/components/DatabaseMetricCard.ts | 4 ++-- .../database/components/GovernorSummary.ts | 6 +++--- .../notifications/components/IssueList.ts | 1 - .../src/features/soql/styles/soql-syntax.css.ts | 1 - log-viewer/src/styles/global.styles.ts | 8 ++++---- log-viewer/src/styles/revealRow.styles.ts | 4 ++-- log-viewer/src/styles/tokens.css | 17 +++++++++++++---- log-viewer/src/tabulator/style/DataGrid.scss | 6 ++++-- 17 files changed, 62 insertions(+), 48 deletions(-) diff --git a/.claude/rules/log-viewer.md b/.claude/rules/log-viewer.md index 2575429f9..337465c72 100644 --- a/.claude/rules/log-viewer.md +++ b/.claude/rules/log-viewer.md @@ -39,8 +39,9 @@ Webview UI. - Never define or override a `--vscode-*` name โ€” an override is global to the webview. Exception: skinning a `vscode-elements` component; scope it to that element, never `:host` or `:root`. - Write no literal font size or family. Take a step from the ramp in `styles/tokens.css` - (`--lana-text-*`, `--lana-text-mono` for editor-sized text, `--lana-text-meta` for header - metadata) and a family from `--lana-font-mono` or `--lana-font-ui`. + (`--lana-text-*`, `--lana-text-meta` for header metadata) and a family from `--lana-font-mono` or + `--lana-font-ui`. Code-shaped text takes no size of its own: it inherits the surface that holds + it, so nothing in the webview follows the reader's `editor.fontSize`. - Mono is for text whose alignment carries meaning โ€” stacks, code, log text. Prose takes the UI font. - An all-caps run takes `--lana-text-caps` and `--lana-text-caps-tracking`, one step down: every glyph reaches cap height, so caps read a size larger. diff --git a/log-viewer/src/components/CodeBlock.ts b/log-viewer/src/components/CodeBlock.ts index 314ec5179..a78eedd3b 100644 --- a/log-viewer/src/components/CodeBlock.ts +++ b/log-viewer/src/components/CodeBlock.ts @@ -48,7 +48,6 @@ export class CodeBlock extends LitElement { pre { margin: 0; font-family: var(--lana-font-mono); - font-size: var(--lana-text-mono); white-space: pre-wrap; word-break: break-word; } diff --git a/log-viewer/src/components/EventVitals.ts b/log-viewer/src/components/EventVitals.ts index 132e28b85..e7147606b 100644 --- a/log-viewer/src/components/EventVitals.ts +++ b/log-viewer/src/components/EventVitals.ts @@ -112,19 +112,24 @@ export class EventVitals extends LitElement { color: var(--lana-fg-muted); font-size: var(--lana-text-sm); } + /* One hue carries the verdict through the text, a tinted ground and its + edge, as SelfTimeSpreadView tints a row from its own hue property. + Every variant sets the hue. */ .pill { display: inline-block; - padding: 0 var(--lana-space-sm); - border-radius: var(--lana-radius-sm); + padding: 0 var(--lana-space-2xs); + border: var(--lana-stroke) solid color-mix(in srgb, var(--pill-hue) 30%, transparent); + border-radius: var(--lana-radius-md); + background-color: color-mix(in srgb, var(--pill-hue) 12%, transparent); + color: var(--pill-hue); font-size: var(--lana-text-xs); line-height: 1.4; - color: var(--lana-editor-bg); } .pill--yes { - background-color: var(--vscode-charts-green, #388a34); + --pill-hue: var(--lana-severity-ok); } .pill--no { - background-color: var(--vscode-charts-red, #d13438); + --pill-hue: var(--lana-severity-warning); } .empty { color: var(--lana-fg-muted); diff --git a/log-viewer/src/components/GovernorTrends.ts b/log-viewer/src/components/GovernorTrends.ts index 53bdd72ea..75bcba461 100644 --- a/log-viewer/src/components/GovernorTrends.ts +++ b/log-viewer/src/components/GovernorTrends.ts @@ -152,7 +152,7 @@ export class GovernorTrends extends LitElement { display: block; width: 100%; border: 0; - border-bottom: 1px solid var(--lana-surface-border); + border-bottom: var(--lana-stroke) solid var(--lana-surface-border); padding: 0; background: none; color: inherit; @@ -166,8 +166,8 @@ export class GovernorTrends extends LitElement { } .trend__chart:focus-visible { - outline: var(--lana-stroke) solid var(--lana-focus-border); - outline-offset: calc(-1 * var(--lana-stroke)); + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-inset); } .trend--safe { diff --git a/log-viewer/src/components/OverflowList.ts b/log-viewer/src/components/OverflowList.ts index b5a97d83e..c11dacbd8 100644 --- a/log-viewer/src/components/OverflowList.ts +++ b/log-viewer/src/components/OverflowList.ts @@ -103,10 +103,10 @@ export class OverflowList extends LitElement { .overflow { display: inline-flex; align-items: center; - gap: 4px; - padding: 2px 6px; - border: 1px solid var(--lana-control-border); - border-radius: 4px; + gap: var(--lana-space-2xs); + padding: var(--lana-space-3xs) var(--lana-space-xs); + border: var(--lana-stroke) solid var(--lana-control-border); + border-radius: var(--lana-radius-sm); background-color: var(--lana-control-bg); color: var(--lana-fg); font: inherit; @@ -123,8 +123,8 @@ export class OverflowList extends LitElement { } .overflow:focus-visible { - outline: 1px solid var(--lana-focus-border); - outline-offset: 1px; + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } .overflow__count { diff --git a/log-viewer/src/components/PaneView.ts b/log-viewer/src/components/PaneView.ts index c8024e19e..6470cfcf4 100644 --- a/log-viewer/src/components/PaneView.ts +++ b/log-viewer/src/components/PaneView.ts @@ -130,7 +130,7 @@ export class PaneView extends LitElement { letter-spacing: var(--lana-text-caps-tracking); color: var(--vscode-sideBarSectionHeader-foreground); background-color: var(--vscode-sideBarSectionHeader-background); - border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); + border-top: var(--lana-stroke) solid var(--vscode-sideBarSectionHeader-border, transparent); user-select: none; white-space: nowrap; overflow: hidden; @@ -143,8 +143,8 @@ export class PaneView extends LitElement { background-color: var(--lana-row-hover-bg); } .pane-header:focus-visible { - outline: 1px solid var(--lana-focus-border); - outline-offset: -1px; + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-inset); } .pane-header vscode-icon { color: var(--lana-icon-fg); diff --git a/log-viewer/src/components/datagrid-facet-filter.ts b/log-viewer/src/components/datagrid-facet-filter.ts index 272e89059..1d4a37a09 100644 --- a/log-viewer/src/components/datagrid-facet-filter.ts +++ b/log-viewer/src/components/datagrid-facet-filter.ts @@ -82,7 +82,7 @@ export class DatagridFacetFilter extends LitElement { font-weight: 600; color: var(--lana-badge-fg); background-color: var(--lana-badge-bg); - border-radius: 999px; + border-radius: var(--lana-radius-pill); padding: 0 5px; font-size: var(--lana-text-xs); line-height: 1.5; diff --git a/log-viewer/src/components/datagrid-range-filter.ts b/log-viewer/src/components/datagrid-range-filter.ts index 29916d7ec..7a3f27721 100644 --- a/log-viewer/src/components/datagrid-range-filter.ts +++ b/log-viewer/src/components/datagrid-range-filter.ts @@ -123,8 +123,8 @@ export class DatagridRangeFilter extends LitElement { font-size: var(--lana-text-base); color: var(--vscode-settings-numberInputForeground); background-color: var(--vscode-settings-numberInputBackground); - border: 1px solid var(--vscode-settings-numberInputBorder, transparent); - border-radius: 4px; + border: var(--lana-stroke) solid var(--vscode-settings-numberInputBorder, transparent); + border-radius: var(--lana-radius-sm); appearance: textfield; } @@ -135,8 +135,8 @@ export class DatagridRangeFilter extends LitElement { } .range-popover__input:focus { - outline: 1px solid var(--lana-focus-border); - outline-offset: -1px; + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-inset); } .range-popover__clear { diff --git a/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts b/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts index 910e961b1..b13c97508 100644 --- a/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts +++ b/log-viewer/src/features/analysis/components/LogDiagnosticsView.ts @@ -208,8 +208,8 @@ export class LogDiagnosticsView extends LitElement { } .rollup__seg:focus-visible { - outline: var(--lana-stroke) solid var(--lana-focus-border); - outline-offset: var(--lana-stroke); + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } /* How long the finding's own events took, and what that is of the log. Only @@ -416,8 +416,8 @@ export class LogDiagnosticsView extends LitElement { } .evidence--link:focus-visible { - outline: var(--lana-stroke) solid var(--lana-focus-border); - outline-offset: var(--lana-stroke); + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } .evidence__go { diff --git a/log-viewer/src/features/database/components/DatabaseMetricCard.ts b/log-viewer/src/features/database/components/DatabaseMetricCard.ts index 7354c34eb..570a317c0 100644 --- a/log-viewer/src/features/database/components/DatabaseMetricCard.ts +++ b/log-viewer/src/features/database/components/DatabaseMetricCard.ts @@ -105,7 +105,7 @@ export class DatabaseMetricCard extends LitElement { display: block; width: 100%; height: 3px; - border-radius: 2px; + border-radius: var(--lana-radius-pill); background: var(--lana-surface-border); overflow: hidden; } @@ -113,7 +113,7 @@ export class DatabaseMetricCard extends LitElement { .stat__fill { display: block; height: 100%; - border-radius: 2px; + border-radius: var(--lana-radius-pill); transition: width 150ms ease; } diff --git a/log-viewer/src/features/database/components/GovernorSummary.ts b/log-viewer/src/features/database/components/GovernorSummary.ts index d262784fa..816db66be 100644 --- a/log-viewer/src/features/database/components/GovernorSummary.ts +++ b/log-viewer/src/features/database/components/GovernorSummary.ts @@ -87,10 +87,10 @@ export class GovernorSummary extends LitElement { white-space: nowrap; } + /* No size of its own: the figure reads at whatever surface holds it. */ .gauge__value { font-family: var(--lana-font-mono); font-variant-numeric: tabular-nums; - font-size: var(--lana-text-base); white-space: nowrap; } @@ -106,14 +106,14 @@ export class GovernorSummary extends LitElement { .gauge__track { height: 5px; - border-radius: 3px; + border-radius: var(--lana-radius-pill); background: var(--lana-surface-border); overflow: hidden; } .gauge__fill { height: 100%; - border-radius: 3px; + border-radius: var(--lana-radius-pill); transition: width 150ms ease; } diff --git a/log-viewer/src/features/notifications/components/IssueList.ts b/log-viewer/src/features/notifications/components/IssueList.ts index 7d7d2213b..4b8181b7b 100644 --- a/log-viewer/src/features/notifications/components/IssueList.ts +++ b/log-viewer/src/features/notifications/components/IssueList.ts @@ -142,7 +142,6 @@ export class IssueList extends LitElement { /* Stack traces are code: keep their line breaks and their font, so each "at Class.method" frame reads as a frame, not one run-on paragraph. */ .issue__message { - font-size: var(--lana-text-mono); font-family: var(--lana-font-mono); color: var(--lana-fg-muted); white-space: pre-wrap; diff --git a/log-viewer/src/features/soql/styles/soql-syntax.css.ts b/log-viewer/src/features/soql/styles/soql-syntax.css.ts index 18204f48c..c92bd31f0 100644 --- a/log-viewer/src/features/soql/styles/soql-syntax.css.ts +++ b/log-viewer/src/features/soql/styles/soql-syntax.css.ts @@ -6,7 +6,6 @@ export const soqlSyntaxStyles = ` .soql-block { display: inline; font-family: var(--lana-font-mono); - font-size: var(--lana-text-mono); white-space: pre-wrap; word-break: break-word; } diff --git a/log-viewer/src/styles/global.styles.ts b/log-viewer/src/styles/global.styles.ts index 4e3e4505e..25725e48c 100644 --- a/log-viewer/src/styles/global.styles.ts +++ b/log-viewer/src/styles/global.styles.ts @@ -132,8 +132,8 @@ export const globalStyles = [ } .vs-checkbox:focus-visible { - outline: 1px solid var(--lana-focus-border); - outline-offset: 1px; + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } .vs-checkbox-label { @@ -172,8 +172,8 @@ export const globalStyles = [ } .filter-control:focus-visible { - outline: 1px solid var(--lana-focus-border); - outline-offset: 1px; + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-offset); } /* Toggle-button counterpart to the facet/range trigger pills โ€” same diff --git a/log-viewer/src/styles/revealRow.styles.ts b/log-viewer/src/styles/revealRow.styles.ts index 163387672..2b1302315 100644 --- a/log-viewer/src/styles/revealRow.styles.ts +++ b/log-viewer/src/styles/revealRow.styles.ts @@ -30,8 +30,8 @@ export const bleedRowStyles = css` } .bleed-row:focus-visible { - outline: var(--lana-stroke) solid var(--lana-focus-border); - outline-offset: calc(-1 * var(--lana-stroke)); + outline: var(--lana-focus-ring); + outline-offset: var(--lana-focus-inset); } `; diff --git a/log-viewer/src/styles/tokens.css b/log-viewer/src/styles/tokens.css index eb7a83b94..0300eb0d7 100644 --- a/log-viewer/src/styles/tokens.css +++ b/log-viewer/src/styles/tokens.css @@ -28,6 +28,9 @@ /* Radius โ€” VS Code's cornerRadius registry. */ --lana-radius-sm: var(--vscode-cornerRadius-small, 4px); --lana-radius-md: var(--vscode-cornerRadius-medium, 6px); + /* Round the ends whatever the height, for a bar or a count. A corner radius + cannot say this: it un-rounds as soon as a host sets a smaller step. */ + --lana-radius-pill: 999px; /* Space โ€” a t-shirt scale, so a step can be re-sized without renaming it. */ --lana-space-3xs: var(--vscode-spacing-size20, 2px); @@ -69,10 +72,11 @@ /* Header metadata (size ยท duration ยท identity) โ€” just under the title size. */ --lana-text-meta: var(--lana-text-sm); - /* Monospace, for text whose alignment carries meaning: stacks, code. The host - sets the editor's own size, which is not always its UI size. */ + /* Monospace, for text whose alignment carries meaning: stacks, code. The + family is the editor's; the size never is. Code-shaped text inherits the + surface that holds it, so a grid cell, a snippet and a stack trace all read + at the size of the panel around them. */ --lana-font-mono: var(--vscode-editor-font-family, monospace); - --lana-text-mono: var(--vscode-editor-font-size, 0.9em); --lana-font-ui: var(--vscode-font-family, sans-serif); @@ -109,8 +113,13 @@ var(--vscode-editorWidget-border, rgba(128, 128, 128, 0.35)) ); - /* One ring app-wide, so focus never reads as a hover. */ + /* One ring app-wide, so focus never reads as a hover. `focus-inset` is for a + full-bleed row or input, whose ring would otherwise be clipped; a control + with room around it offsets by `--lana-stroke` instead. */ --lana-focus-border: var(--vscode-focusBorder, #007fd4); + --lana-focus-ring: var(--lana-stroke) solid var(--lana-focus-border); + --lana-focus-offset: var(--lana-stroke); + --lana-focus-inset: calc(-1 * var(--lana-stroke)); /* Icons that stand on their own, not beside the text they belong to. */ --lana-icon-fg: var(--vscode-icon-foreground, currentColor); diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss index bd89b2c97..6efa4cd6b 100644 --- a/log-viewer/src/tabulator/style/DataGrid.scss +++ b/log-viewer/src/tabulator/style/DataGrid.scss @@ -38,7 +38,10 @@ $codicon-chevron-down: '\eab4'; $with: ( backgroundColor: var(--lana-editor-bg), borderColor: transparent, - textSize: var(--lana-text-mono), + // The root must name a size: a grid is a list, not an editor, so it + // takes the app's step, never `editor.fontSize`. Tabulator also adds + // this to a margin for its header min-height, which no var() survives. + textSize: var(--lana-text-base), // header headerBackgroundColor: transparent, headerTextColor: var(--lana-editor-fg), @@ -229,7 +232,6 @@ $codicon-chevron-down: '\eab4'; .datagrid-code-text { font-family: var(--lana-font-mono); font-weight: var(--vscode-font-weight, normal); - font-size: var(--lana-text-mono); } .tabulator-row.tabulator-selected { From bd833b0e53b5f4cf01ec5074d322e5423a0b51f3 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:32:20 +0100 Subject: [PATCH 42/61] fix(log-viewer): keep one governor trend chart reading at a time (#1013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview The four Governor trend charts shared one cursor. A sample read on one chart answered the arrow keys and the live region on the other three, so the keyboard reported a figure from a chart the reader was not on. Holding Enter also re-zoomed the flame chart on every key repeat. A cursor now belongs to the chart that placed it, and to the log that placed it. Each chart reads only its own sample, and one chart reads at a time. ## ๐Ÿ› ๏ธ Changes made - A cursor carries its chart's label, so a sample on one chart no longer answers another. - A held Enter is ignored after the first press - a key repeat must not re-zoom the flame chart. Arrows still repeat, so holding one scrubs. - A click with no coordinates (assistive tech, a programmatic click) reads the chart's cursor, else the last sample, so it always reaches a frame. - The arrows carry on from the sample Enter answered, and keep stepping while the pointer rests on the chart. - A new log clears the cursor: metric labels repeat between logs. - Removes `reveal-row--no-swatch`, a class with no rule behind it, and the three tests that asserted the absence of an element it never controlled. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A - keyboard and pointer behaviour, nothing new on screen. ## ๐Ÿ”— Related Issues related #950 ## โœ… Tests added? - [x] ๐Ÿ‘ yes Eight tests in `GovernorTrends.test.ts` cover the cursor rules. Each new guard was mutation proved: break it, watch its own test fail, restore it. \`\`\` pnpm test # 169 suites, 2306 tests pnpm lint \`\`\` Dev host: step the arrows on one chart, then rest the pointer on another. The stepped chart keeps answering, and only one chart reads at a time. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ™… not needed The trend seek feature is unreleased, so this fix reaches nobody as a change. The Inspector changelog entry already covers it. --------- Co-authored-by: Luke Cotter <81575432+lcottercertinia@users.noreply.github.com> --- log-viewer/src/components/GovernorTrends.ts | 101 ++++++++++---- .../__tests__/GovernorTrends.test.ts | 132 ++++++++++++++++-- .../src/components/__tests__/HotPath.test.ts | 1 - .../src/components/__tests__/HotSpots.test.ts | 1 - .../analysis/components/SelfTimeSpreadView.ts | 2 +- .../__tests__/SelfTimeSpreadView.test.ts | 4 - 6 files changed, 202 insertions(+), 39 deletions(-) diff --git a/log-viewer/src/components/GovernorTrends.ts b/log-viewer/src/components/GovernorTrends.ts index 75bcba461..8e5a97133 100644 --- a/log-viewer/src/components/GovernorTrends.ts +++ b/log-viewer/src/components/GovernorTrends.ts @@ -2,7 +2,7 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { consume } from '@lit/context'; -import { LitElement, css, html, svg } from 'lit'; +import { LitElement, css, html, svg, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { eventBus } from '../core/events/EventBus.js'; @@ -25,6 +25,17 @@ import { } from './governorTrendData.js'; import { NO_CUMULATIVE_LIMITS_TEXT } from './logOverviewMetrics.js'; +/** A placed cursor: the sample, and the chart it belongs to. */ +interface Cursor { + label: string; + point: TrendPoint; +} + +/** The cursor's sample, when it is this chart's. */ +function pointOn(cursor: Cursor | null, series: TrendSeries): TrendPoint | null { + return cursor?.label === series.label ? cursor.point : null; +} + /** Chart-space size; the SVG stretches to fill its row. */ const VIEW_W = 100; const VIEW_H = 30; @@ -88,11 +99,15 @@ function trendGeometry(series: TrendSeries, logTotal: number): TrendGeometry { */ @customElement('governor-trends') export class GovernorTrends extends LitElement { - /** The sample under the pointer or the arrow keys, on the one chart that holds - * it. `from` says which placed it: a pointer leaving takes its own cursor - * with it, never one the keys placed. */ + /** The sample under the pointer, on the chart it is over. Cleared when the + * pointer leaves, so it never outlives the hover that made it. */ @state() - private _cursor: { label: string; point: TrendPoint; from: 'pointer' | 'key' } | null = null; + private _hover: Cursor | null = null; + + /** Where the arrow keys left the cursor. Held apart from the hover because a + * pointer crossing any chart would otherwise erase a stepped position. */ + @state() + private _keyed: Cursor | null = null; /** The log on screen, from the app root. */ @consume({ context: logContext, subscribe: true }) @@ -268,20 +283,53 @@ export class GovernorTrends extends LitElement { `; } + /** A cursor belongs to the log that placed it; metric labels repeat. */ + protected willUpdate(changed: PropertyValues): void { + if (changed.has('logStore')) { + this._hover = null; + this._keyed = null; + } + } + private _onPointerMove(event: PointerEvent, series: TrendSeries, logTotal: number): void { const point = this._pointFrom(event, series, logTotal); - this._cursor = point ? { label: series.label, point, from: 'pointer' } : null; + this._hover = point ? { label: series.label, point } : null; } - private _onPointerLeave(): void { - if (this._cursor?.from === 'pointer') { - this._cursor = null; + /** Puts the cursor where the keys left it. A resting pointer would otherwise + * answer every step from the same sample, so the keys take this chart from + * it; moving the pointer takes it back. */ + private _hold(series: TrendSeries, point: TrendPoint | null | undefined): TrendPoint | null { + if (!point) { + return null; } + this._keyed = { label: series.label, point }; + if (this._hover?.label === series.label) { + this._hover = null; + } + return point; + } + + /** + * What an activation moves to: the sample given, else this chart's cursor, + * else the last sample, since consumption never falls inside a transaction + * and that is where the metric stands highest. + */ + private _target(series: TrendSeries, placed: TrendPoint | null): TrendPoint | null { + return placed ?? this._cursorFor(series) ?? series.points.at(-1) ?? null; + } + + private _onPointerLeave(): void { + this._hover = null; } private _onClick(event: PointerEvent, series: TrendSeries, logTotal: number): void { - // Where the pointer is wins: the cursor may sit where the arrow keys left it. - const point = this._pointFrom(event, series, logTotal) ?? this._cursorFor(series); + // Where the pointer is wins, but a click carrying no coordinates (assistive + // tech, or `click()`) reports x 0, which would seek the log's start. + const placed = event.detail === 0 ? null : this._pointFrom(event, series, logTotal); + // No cursor is kept: the pointer is still on the chart and holds its own, + // and a second chart reading at once says two things at the same time. + const point = this._target(series, placed); if (point) { this._seek(point.t); } @@ -295,28 +343,33 @@ export class GovernorTrends extends LitElement { private _onKeyDown(event: KeyboardEvent, series: TrendSeries, logTotal: number): void { const step = event.key === 'ArrowRight' ? KEY_STEP : event.key === 'ArrowLeft' ? -KEY_STEP : undefined; + const activates = event.key === 'Enter' || event.key === ' '; + if (step === undefined && !activates) { + return; + } + event.preventDefault(); if (step !== undefined) { const from = this._cursorFor(series)?.t ?? 0; const t = Math.min(Math.max(from + step * logTotal, 0), logTotal); - const point = pointAt(series.points, t); - if (point) { - this._cursor = { label: series.label, point, from: 'key' }; - } - event.preventDefault(); + this._hold(series, pointAt(series.points, t)); return; } - if (event.key === 'Enter' || event.key === ' ') { - const point = this._cursorFor(series) ?? series.points.at(-1); - if (point) { - this._seek(point.t); - } - event.preventDefault(); + // Arrows repeat, so holding one scrubs the cursor. An activation must not: + // it would re-zoom the flame chart on every repeat. + if (event.repeat) { + return; + } + // The cursor stays where this landed, so the arrows carry on from it. + const point = this._hold(series, this._target(series, null)); + if (point) { + this._seek(point.t); } } - /** The cursor, when this chart is the one holding it. */ + /** This chart's cursor: the pointer's while it is over the chart, else the + * keys'. */ private _cursorFor(series: TrendSeries): TrendPoint | null { - return this._cursor?.label === series.label ? this._cursor.point : null; + return pointOn(this._hover, series) ?? pointOn(this._keyed, series); } /** The series' value at the pointer, in the log's own time. */ diff --git a/log-viewer/src/components/__tests__/GovernorTrends.test.ts b/log-viewer/src/components/__tests__/GovernorTrends.test.ts index 235075844..dee95cc90 100644 --- a/log-viewer/src/components/__tests__/GovernorTrends.test.ts +++ b/log-viewer/src/components/__tests__/GovernorTrends.test.ts @@ -25,8 +25,8 @@ import '../GovernorTrends.js'; const LOG_NS = 1_000; -const trend = (): TrendSeries => ({ - label: 'SOQL queries', +const trend = (label = 'SOQL queries'): TrendSeries => ({ + label, points: [ { t: 0, ratio: 0, used: 0 }, { t: 400, ratio: 40, used: 40 }, @@ -38,24 +38,37 @@ const trend = (): TrendSeries => ({ format: String, }); +const aLog = () => ({ log: { duration: { total: LOG_NS } } }) as unknown as LogStore; + async function mount(): Promise { const element = document.createElement('governor-trends'); // No provider in the test, so the consumed store is assigned straight on. - (element as unknown as { logStore: LogStore }).logStore = { - log: { duration: { total: LOG_NS } }, - } as unknown as LogStore; + (element as unknown as { logStore: LogStore }).logStore = aLog(); document.body.append(element); await element.updateComplete; return element; } /** The chart, given a width so a pointer x maps to a time. */ -function chartOf(element: LitElement): HTMLButtonElement { - const chart = element.shadowRoot!.querySelector('.trend__chart') as HTMLButtonElement; +function chartOf(element: LitElement, at = 0): HTMLButtonElement { + const chart = element.shadowRoot!.querySelectorAll('.trend__chart')[at] as HTMLButtonElement; chart.getBoundingClientRect = () => ({ left: 0, width: 100, top: 0, height: 44 }) as DOMRect; return chart; } +/** A key press on a chart. */ +const press = (chart: HTMLButtonElement, key: string, init: KeyboardEventInit = {}) => + chart.dispatchEvent(new KeyboardEvent('keydown', { key, ...init })); + +/** A pointer over a chart, at an x the chart's width maps to a time. */ +const hover = (chart: HTMLButtonElement, clientX: number) => + chart.dispatchEvent(new MouseEvent('pointermove', { clientX })); + +/** A click. `detail` is 1 for a real pointer and 0 where there are no + * coordinates: assistive tech, or `click()`. */ +const click = (chart: HTMLButtonElement, clientX: number, detail = 1) => + chart.dispatchEvent(new MouseEvent('click', { clientX, detail })); + let seeks: { timestamp?: number; mode?: string }[]; let unsubscribe: () => void; @@ -73,7 +86,7 @@ describe('governor-trends', () => { it('moves the timeline to the instant clicked on a chart', async () => { const element = await mount(); - chartOf(element).dispatchEvent(new MouseEvent('click', { clientX: 60 })); + click(chartOf(element), 60); expect(seeks).toEqual([{ timestamp: 600, mode: 'seek' }]); }); @@ -131,6 +144,109 @@ describe('governor-trends', () => { expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]); }); + it('reads the cursor when a click carries no coordinates', async () => { + const element = await mount(); + const chart = chartOf(element); + + press(chart, 'ArrowRight'); + click(chart, 0, 0); + + expect(seeks).toEqual([{ timestamp: 20, mode: 'seek' }]); + }); + + it('answers the last sample when a click carries neither coordinates nor a cursor', async () => { + const element = await mount(); + + click(chartOf(element), 0, 0); + + // x 0 is the log's start, which is never where a limit stands highest. + expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]); + }); + + it('holds a stepped cursor against a pointer on another chart', async () => { + series = [trend(), trend('DML statements')]; + const element = await mount(); + const [first, second] = [chartOf(element), chartOf(element, 1)]; + + press(first, 'ArrowRight'); + // The pointer rests on the second chart: its hover is not this chart's cursor. + hover(second, 40); + press(first, 'Enter'); + + expect(seeks).toEqual([{ timestamp: 20, mode: 'seek' }]); + + // And losing that hover leaves the stepped cursor where it was. + second.dispatchEvent(new MouseEvent('pointerleave')); + press(first, 'Enter'); + + expect(seeks).toEqual([ + { timestamp: 20, mode: 'seek' }, + { timestamp: 20, mode: 'seek' }, + ]); + }); + + it('keeps stepping while the pointer rests on the chart', async () => { + const element = await mount(); + const chart = chartOf(element); + + hover(chart, 40); + press(chart, 'ArrowRight'); + press(chart, 'ArrowRight'); + press(chart, 'Enter'); + + // Two steps on from the hover, not the hover answered twice. + expect(seeks).toEqual([{ timestamp: 440, mode: 'seek' }]); + }); + + it('leaves one chart reading at a time after a click', async () => { + series = [trend(), trend('DML statements')]; + const element = await mount(); + const [first, second] = [chartOf(element), chartOf(element, 1)]; + + hover(first, 30); + click(first, 30); + first.dispatchEvent(new MouseEvent('pointerleave')); + hover(second, 40); + await element.updateComplete; + + expect(element.shadowRoot?.querySelectorAll('.trend__cursor')).toHaveLength(1); + }); + + it('steps on from the sample Enter answered', async () => { + const element = await mount(); + const chart = chartOf(element); + + press(chart, 'Enter'); + press(chart, 'ArrowLeft'); + press(chart, 'Enter'); + + // One step back from the last sample, not from the log's start. + expect(seeks).toEqual([ + { timestamp: 800, mode: 'seek' }, + { timestamp: 780, mode: 'seek' }, + ]); + }); + + it('drops the cursor when another log arrives', async () => { + const element = await mount(); + + press(chartOf(element), 'ArrowRight'); + (element as unknown as { logStore: LogStore }).logStore = aLog(); + await element.updateComplete; + press(chartOf(element), 'Enter'); + + // The label repeats across logs, so a kept cursor would seek the old point. + expect(seeks).toEqual([{ timestamp: 800, mode: 'seek' }]); + }); + + it('ignores a held Enter, so the flame chart is not re-zoomed', async () => { + const element = await mount(); + + press(chartOf(element), 'Enter', { repeat: true }); + + expect(seeks).toEqual([]); + }); + // A button, so the focus ring only shows for keyboard focus, never a click. it('gives every chart keyboard reach', async () => { const element = await mount(); diff --git a/log-viewer/src/components/__tests__/HotPath.test.ts b/log-viewer/src/components/__tests__/HotPath.test.ts index b8df20b42..59042a45d 100644 --- a/log-viewer/src/components/__tests__/HotPath.test.ts +++ b/log-viewer/src/components/__tests__/HotPath.test.ts @@ -93,7 +93,6 @@ describe('hot-path', () => { 'self 0.001 ms (50.0%) \u00b7 0.001 ms (50.0%) below this frame \u00b7 the hot spot', ); // The hue is decorative, so the category is named in text a reader can hear. - expect(row?.querySelector('.reveal-row__swatch')).toBeNull(); expect(row?.querySelector('.reveal-row__sr')?.textContent).toBe('Apex'); }); diff --git a/log-viewer/src/components/__tests__/HotSpots.test.ts b/log-viewer/src/components/__tests__/HotSpots.test.ts index 81a3ef9df..cd1ef73c4 100644 --- a/log-viewer/src/components/__tests__/HotSpots.test.ts +++ b/log-viewer/src/components/__tests__/HotSpots.test.ts @@ -68,7 +68,6 @@ describe('hot-spots', () => { // The bar runs to the 40% total share; half of it โ€” the 20% self share โ€” is solid. expect(row?.style.getPropertyValue('--self-pct')).toBe('50%'); // The hue is decorative, so the category is named in text a reader can hear. - expect(row?.querySelector('.reveal-row__swatch')).toBeNull(); expect(row?.querySelector('.reveal-row__sr')?.textContent).toBe('Apex'); expect( element.shadowRoot?.querySelector('.reveal-row__meter-fill')?.style.width, diff --git a/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts b/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts index 38c7b6463..54f71680f 100644 --- a/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts +++ b/log-viewer/src/features/analysis/components/SelfTimeSpreadView.ts @@ -157,7 +157,7 @@ export class SelfTimeSpreadView extends LitElement { private _row(row: SingleRow, title: string, value: number, extras: TemplateResult | '') { return html` `; } diff --git a/log-viewer/src/components/__tests__/EventVitals.test.ts b/log-viewer/src/components/__tests__/EventVitals.test.ts index e43718f44..58cddd2d4 100644 --- a/log-viewer/src/components/__tests__/EventVitals.test.ts +++ b/log-viewer/src/components/__tests__/EventVitals.test.ts @@ -145,6 +145,35 @@ describe('EventVitals', () => { expect(shown).not.toContain('Object rows'); }); + it('gives the selectivity verdict a chip, which the tier colours', async () => { + // The log above records no query plan, so the verdict needs one of its own. + const explained = parse( + '09:18:22.6 (6574780)|EXECUTION_STARTED\n' + + '17:33:36.2 (1672655920)|SOQL_EXECUTE_BEGIN|[198]|Aggregations:0|SELECT Id FROM Account\n' + + '17:33:36.2 (1672700000)|SOQL_EXECUTE_EXPLAIN|[198]|Index on Account : [Id], cardinality: 1, sobjectCardinality: 1, relativeCost 0.65\n' + + '17:33:36.2 (1680000000)|SOQL_EXECUTE_BEGIN|[199]|Aggregations:0|SELECT Id FROM Contact\n' + + '17:33:36.2 (1680100000)|SOQL_EXECUTE_EXPLAIN|[199]|TableScan on Contact : [], cardinality: 9, sobjectCardinality: 9, relativeCost 2.5\n' + + '09:18:22.6 (7400000)|EXECUTION_FINISHED\n', + ); + const explainStore = logStoreFor(explained); + const indexOf = (query: string) => + explained.eventsById.find((e) => e.text === query)!.eventIndex; + + const selective = await mount(explainStore, { + eventIndex: indexOf('SELECT Id FROM Account'), + type: 'soql', + }); + const notSelective = await mount(explainStore, { + eventIndex: indexOf('SELECT Id FROM Contact'), + type: 'soql', + }); + + expect( + [selective, notSelective].map((el) => el.shadowRoot?.querySelector('.pill')?.className), + ).toEqual(['pill pill--yes', 'pill pill--no']); + expect(notSelective.shadowRoot?.querySelector('.pill')?.textContent).toBe('Not selective'); + }); + it('omits fields with no value', async () => { const el = await mount(store, { eventIndex: dmlIndex, type: 'dml' }); // A DML statement allocates no heap and throws nothing in this log. diff --git a/log-viewer/src/components/__tests__/HotPath.test.ts b/log-viewer/src/components/__tests__/HotPath.test.ts index 59042a45d..0f9f3b5d4 100644 --- a/log-viewer/src/components/__tests__/HotPath.test.ts +++ b/log-viewer/src/components/__tests__/HotPath.test.ts @@ -289,6 +289,16 @@ describe('hot-path', () => { ]); }); + it('heads a truncated log with a caveat the shared warning glyph marks', async () => { + highlights = { ...pathOf(1), truncation: { regionCount: 2, firstEventIndex: 5 } }; + + const element = await hotPath(); + + const caveat = element.shadowRoot!.querySelector('.caveat-row')!; + expect(caveat.textContent).toContain('2 truncated calls'); + expect(caveat.querySelector('vscode-icon')?.className).toBe('sev-warning'); + }); + it('marks every merged instance of the row under the pointer', async () => { highlights = pathOf(1); highlights.hotPath[0]!.eventIndexes = [4, 9]; diff --git a/log-viewer/src/features/database/components/DatabaseRowBudget.ts b/log-viewer/src/features/database/components/DatabaseRowBudget.ts index 5aab622ec..13d8627b9 100644 --- a/log-viewer/src/features/database/components/DatabaseRowBudget.ts +++ b/log-viewer/src/features/database/components/DatabaseRowBudget.ts @@ -5,6 +5,7 @@ import { consume } from '@lit/context'; import { LitElement, css, html, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import '#vscode-elements/vscode-icon.js'; import { CategoryPaletteController } from '../../../components/categoryTime.js'; import { ESTIMATED_LIMITS_TEXT, @@ -17,6 +18,7 @@ import type { LogStore } from '../../../core/log/LogStore.js'; import { formatInteger } from '../../../core/utility/Util.js'; import { globalStyles } from '../../../styles/global.styles.js'; import { inspectorSectionStyles } from '../../../styles/inspectorSection.styles.js'; +import { severityIcon, severityStyles } from '../../../styles/severity.styles.js'; import { NO_STATEMENTS } from '../services/databaseOverview.js'; import { rowBudgets, @@ -56,6 +58,7 @@ export class DatabaseRowBudget extends LitElement { static styles = [ globalStyles, inspectorSectionStyles, + severityStyles, css` .note { padding: var(--lana-space-2xs) 0 0; @@ -85,14 +88,8 @@ export class DatabaseRowBudget extends LitElement { font-variant-numeric: tabular-nums; } - .budget__figure--safe { - color: var(--lana-fg); - } - .budget__figure--warn { - color: var(--lana-severity-warning); - } - .budget__figure--danger { - color: var(--lana-severity-error); + .budget__figure vscode-icon { + margin-right: var(--lana-space-2xs); } .counts { @@ -186,6 +183,7 @@ export class DatabaseRowBudget extends LitElement { private _budget(budget: RowBudget, hue: string): TemplateResult { const shown = budget.used ?? budget.observed; const percent = budget.limit > 0 ? (shown / budget.limit) * 100 : 0; + const tier = governorTier(percent); const against = budget.limit > 0 ? ` / ${formatInteger(budget.limit)}` : ''; const segments = objectSegments(budget.groups, hue); // What the governor counted and no statement holds. Never negative: the @@ -204,9 +202,7 @@ export class DatabaseRowBudget extends LitElement {

    ${KIND_LABEL[budget.kind]} - ${formatInteger(shown)}${against} + ${tierMark(tier)}${formatInteger(shown)}${against}

    ): TemplateResult | string { + if (tier === 'safe') { + return ''; + } + const { severity, title } = TIER_MARK[tier]; + // Sized to the figure: the stock 16px glyph grows the line it sits in. + return html``; +} + /** Whether the segments passed the limit, which is when the bar marks it. */ function overLimit(budget: RowBudget): boolean { return budget.limit > 0 && Math.max(budget.observed, budget.used ?? 0) > budget.limit; diff --git a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts index ff832b3dc..863e767db 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts @@ -18,6 +18,7 @@ let budgets: RowBudgets; jest.mock('../../services/rowBudget.js', () => ({ rowBudgets: () => budgets, })); +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); import '../DatabaseRowBudget.js'; @@ -81,6 +82,12 @@ const texts = (element: Element, selector: string) => (node.textContent ?? '').replace(/\s+/g, ' ').trim(), ); +/** The glyph each figure carries, in order, `null` where the tier is safe. */ +const marks = (element: Element) => + [...(element.shadowRoot?.querySelectorAll('.budget__figure') ?? [])].map( + (figure) => figure.querySelector('vscode-icon')?.getAttribute('name') ?? null, + ); + const bars = (element: Element) => [...(element.shadowRoot?.querySelectorAll('stacked-time-bar') ?? [])] as StackedTimeBar[]; @@ -142,12 +149,22 @@ describe('database-rows', () => { ]); }); - it('warns as a limit is approached, and alarms once it is near breach', async () => { + it('warns as a limit is approached, marking the tier with a glyph', async () => { const element = await mount(); // 90% of the query rows, 4% of the DML rows. - expect(texts(element, '.budget__figure--warn')).toEqual(['45,000 / 50,000']); - expect(texts(element, '.budget__figure--safe')).toEqual(['400 / 10,000']); + expect(texts(element, '.budget__figure')).toEqual(['45,000 / 50,000', '400 / 10,000']); + expect(marks(element)).toEqual(['warning', null]); + }); + + it('marks a breached limit as an error, and says what the mark means', async () => { + budgets = withBudgets({ used: 60_000, observed: 60_000 }); + const element = await mount(); + + expect(marks(element)[0]).toBe('error'); + expect( + element.shadowRoot?.querySelector('.budget__figure vscode-icon')?.getAttribute('title'), + ).toBe('Past the row limit'); }); it('counts the statements of every kind against its own limit', async () => { diff --git a/log-viewer/src/styles/severity.styles.ts b/log-viewer/src/styles/severity.styles.ts index e6925f638..a416ce053 100644 --- a/log-viewer/src/styles/severity.styles.ts +++ b/log-viewer/src/styles/severity.styles.ts @@ -17,7 +17,12 @@ export function severityIcon(severity: Severity): string { } } -/** Colours for `class="sev-"`, where the severity is lower case. */ +/** + * Colours for `class="sev-"`, where the severity is lower case. For a + * mark only: an icon, a border, a tint, a bar fill, where 3:1 applies. Text + * reads as the text around it, since these hues are tuned for a glyph and a + * light theme can read one at 2:1 as a word. + */ export const severityStyles = css` .sev-error { color: var(--lana-severity-error); diff --git a/log-viewer/src/styles/tokens.css b/log-viewer/src/styles/tokens.css index 0300eb0d7..9953f7222 100644 --- a/log-viewer/src/styles/tokens.css +++ b/log-viewer/src/styles/tokens.css @@ -154,8 +154,9 @@ var(--vscode-editorInfo-foreground, #3794ff) ); - /* Nothing wrong โ€” the colour VS Code marks a passing test with. */ - --lana-severity-ok: var(--vscode-testing-iconPassed, var(--vscode-charts-green, #73c991)); + /* Nothing wrong. The chart green leads: `testing-iconPassed` carries one hex + for light and dark, so a light theme reads a dark theme's green at 2:1. */ + --lana-severity-ok: var(--vscode-charts-green, var(--vscode-testing-iconPassed, #388a34)); /* Row hover, for lists whose rows can be opened or picked. */ --lana-row-hover-bg: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.12)); From 3b0ee5a5254e2500a380392d7142aeceec3f3c43 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:46:30 +0100 Subject: [PATCH 45/61] fix(log-viewer): finish the fire-once contract for held keys (#1017) --- log-viewer/src/components/VariablesDetail.ts | 9 ++- .../__tests__/VariablesDetail.test.ts | 64 +++++++++++++++- .../__tests__/keyboard-handler.test.ts | 73 +++++++++++++++++++ .../optimised/interaction/KeyboardHandler.ts | 36 ++++++--- 4 files changed, 168 insertions(+), 14 deletions(-) diff --git a/log-viewer/src/components/VariablesDetail.ts b/log-viewer/src/components/VariablesDetail.ts index 87bac1a9b..ace83c78d 100644 --- a/log-viewer/src/components/VariablesDetail.ts +++ b/log-viewer/src/components/VariablesDetail.ts @@ -575,12 +575,17 @@ export class VariablesDetail extends LitElement { break; case 'Enter': case ' ': - if (row.expandable) { + // Fires once: a repeat would flap the row open and shut. The arrows open + // and close in one direction each, so they carry on repeating. + if (row.expandable && !event.repeat) { this._toggle(row.id, !row.open); } break; case '*': - this._openAll(row.depth); + // Fires once: every row at this depth is open after the first press. + if (!event.repeat) { + this._openAll(row.depth); + } break; default: return; diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts index aa9197acf..7513ad9c6 100644 --- a/log-viewer/src/components/__tests__/VariablesDetail.test.ts +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -192,9 +192,21 @@ function tabStop(el: VariablesDetail): string | null { return el.shadowRoot?.querySelector('[tabindex="0"]')?.getAttribute('data-id') ?? null; } -async function press(el: VariablesDetail, key: string): Promise { - tree(el).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })); +/** Returns the event, so a caller can read back whether the tree consumed it. */ +async function press( + el: VariablesDetail, + key: string, + options: Partial = {}, +): Promise { + const event = new KeyboardEvent('keydown', { + key, + bubbles: true, + cancelable: true, + ...options, + }); + tree(el).dispatchEvent(event); await el.updateComplete; + return event; } describe('VariablesDetail groups', () => { @@ -568,6 +580,54 @@ describe('VariablesDetail keyboard', () => { }); }); +describe('VariablesDetail keyboard, key repeat', () => { + it('holds a row where a held Enter left it, rather than flapping', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'Enter'); + await press(el, 'Enter', { repeat: true }); + const afterEnter = treeRows(el)[0]?.getAttribute('aria-expanded'); + await press(el, ' ', { repeat: true }); + + expect(afterEnter).toBe('false'); + expect(treeRows(el)[0]?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('leaves a group closed that a held star would re-open', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + const local = () => treeRows(el).find((row) => row.dataset.id === 'local'); + + // Local starts open, and holds the tab stop, so Enter closes it. + await press(el, 'Enter'); + await press(el, '*', { repeat: true }); + + expect(local()?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps the key consumed on a suppressed repeat', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + const event = await press(el, 'Enter', { repeat: true }); + + expect(event.defaultPrevented).toBe(true); + }); + + it('walks on every repeat, so holding an arrow scrubs', async () => { + const store = logOf(FRAME); + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + await press(el, 'ArrowDown'); + const second = tabStop(el); + await press(el, 'ArrowDown', { repeat: true }); + + expect(second).not.toBe('local'); + expect(tabStop(el)).not.toBe(second); + }); +}); + // Reading the scope back through a huge frame costs tens of ms, so opening a // row must not pay it again. describe('VariablesDetail reads the scope once per selection', () => { diff --git a/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts b/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts index 4f672843d..eb6cb2bbf 100644 --- a/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts +++ b/log-viewer/src/features/timeline/__tests__/keyboard-handler.test.ts @@ -636,6 +636,79 @@ describe('KeyboardHandler', () => { }); }); + describe('minimap commands (pointer over the minimap)', () => { + beforeEach(() => { + (callbacks.isInMinimapArea as jest.Mock).mockReturnValue(true); + }); + + it('should call each command once on a held key', () => { + dispatchKeyEvent('keydown', 'Home'); + dispatchKeyEvent('keydown', 'Home', { repeat: true }); + dispatchKeyEvent('keydown', 'End'); + dispatchKeyEvent('keydown', 'End', { repeat: true }); + // 0 and Escape are the same command, so a repeat of either is suppressed. + dispatchKeyEvent('keydown', '0'); + dispatchKeyEvent('keydown', 'Escape', { repeat: true }); + + expect(callbacks.onMinimapJumpStart).toHaveBeenCalledTimes(1); + expect(callbacks.onMinimapJumpEnd).toHaveBeenCalledTimes(1); + expect(callbacks.onMinimapResetZoom).toHaveBeenCalledTimes(1); + }); + + // End is this area's alone: the main timeline ignores it, so a repeat that + // reported nothing prevented would mean the area handler never saw it. + it('should still prevent default on a repeated command', () => { + const event = dispatchKeyEvent('keydown', 'End', { repeat: true }); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should pan and zoom the lens on every repeat', () => { + dispatchKeyEvent('keydown', 'ArrowLeft', { repeat: true }); + dispatchKeyEvent('keydown', 'ArrowRight', { repeat: true }); + dispatchKeyEvent('keydown', 'w', { repeat: true }); + + expect(callbacks.onMinimapPanViewport).toHaveBeenCalledTimes(2); + expect(callbacks.onMinimapZoom).toHaveBeenCalledTimes(1); + }); + }); + + describe('metric strip commands (pointer over the strip)', () => { + beforeEach(() => { + (callbacks.isInMetricStripArea as jest.Mock).mockReturnValue(true); + }); + + it('should call each command once on a held key', () => { + dispatchKeyEvent('keydown', 'Home'); + dispatchKeyEvent('keydown', 'Home', { repeat: true }); + dispatchKeyEvent('keydown', 'End'); + dispatchKeyEvent('keydown', 'End', { repeat: true }); + dispatchKeyEvent('keydown', '0'); + dispatchKeyEvent('keydown', 'Escape', { repeat: true }); + + expect(callbacks.onMetricStripJumpStart).toHaveBeenCalledTimes(1); + expect(callbacks.onMetricStripJumpEnd).toHaveBeenCalledTimes(1); + expect(callbacks.onMetricStripResetZoom).toHaveBeenCalledTimes(1); + }); + + // End is this area's alone: the main timeline ignores it, so a repeat that + // reported nothing prevented would mean the area handler never saw it. + it('should still prevent default on a repeated command', () => { + const event = dispatchKeyEvent('keydown', 'End', { repeat: true }); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should pan and zoom the strip on every repeat', () => { + dispatchKeyEvent('keydown', 'ArrowLeft', { repeat: true }); + dispatchKeyEvent('keydown', 'ArrowRight', { repeat: true }); + dispatchKeyEvent('keydown', 'w', { repeat: true }); + + expect(callbacks.onMetricStripPanViewport).toHaveBeenCalledTimes(2); + expect(callbacks.onMetricStripZoom).toHaveBeenCalledTimes(1); + }); + }); + describe('key repeat on continuous controls', () => { it('should pan and zoom on every repeat, so holding a key scrubs', () => { dispatchKeyEvent('keydown', 'ArrowLeft', { repeat: true }); diff --git a/log-viewer/src/features/timeline/optimised/interaction/KeyboardHandler.ts b/log-viewer/src/features/timeline/optimised/interaction/KeyboardHandler.ts index 88a1855a3..18bbd899d 100644 --- a/log-viewer/src/features/timeline/optimised/interaction/KeyboardHandler.ts +++ b/log-viewer/src/features/timeline/optimised/interaction/KeyboardHandler.ts @@ -344,19 +344,27 @@ export class KeyboardHandler { return true; } - // Jump to start/end (Home/End) + // Jump to start/end (Home/End). Each fires once: the lens lands on a fixed + // end of the log, so a repeat only re-renders it there. switch (event.key) { case 'Home': - this.callbacks.onMinimapJumpStart?.(); + if (!event.repeat) { + this.callbacks.onMinimapJumpStart?.(); + } return true; case 'End': - this.callbacks.onMinimapJumpEnd?.(); + if (!event.repeat) { + this.callbacks.onMinimapJumpEnd?.(); + } return true; } - // Reset zoom (0/Escape) + // Reset zoom (0/Escape). Fires once: the viewport is already reset, so a + // repeat only re-renders. if (key === '0' || event.key === 'Escape') { - this.callbacks.onMinimapResetZoom?.(); + if (!event.repeat) { + this.callbacks.onMinimapResetZoom?.(); + } return true; } @@ -436,19 +444,27 @@ export class KeyboardHandler { return true; } - // Jump to start/end (Home/End) + // Jump to start/end (Home/End). Each fires once: the strip lands on a fixed + // end of the log, so a repeat only re-renders it there. switch (event.key) { case 'Home': - this.callbacks.onMetricStripJumpStart?.(); + if (!event.repeat) { + this.callbacks.onMetricStripJumpStart?.(); + } return true; case 'End': - this.callbacks.onMetricStripJumpEnd?.(); + if (!event.repeat) { + this.callbacks.onMetricStripJumpEnd?.(); + } return true; } - // Reset zoom (0/Escape) + // Reset zoom (0/Escape). Fires once: the viewport is already reset, so a + // repeat only re-renders. if (key === '0' || event.key === 'Escape') { - this.callbacks.onMetricStripResetZoom?.(); + if (!event.repeat) { + this.callbacks.onMetricStripResetZoom?.(); + } return true; } From 75cd0339da4b6cb59f1888f7c99222836a1e2f80 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:46:52 +0100 Subject: [PATCH 46/61] refactor(log-viewer): style the located row once, not in six grids (#1018) --- log-viewer/src/components/CallStackDetail.ts | 6 +----- log-viewer/src/components/CallTreeDetail.ts | 12 +----------- log-viewer/src/components/locatedRow.ts | 3 ++- .../src/features/analysis/components/AnalysisView.ts | 6 ------ .../features/call-tree/components/CalltreeView.ts | 6 ------ .../features/database/components/DatabaseTimeTree.ts | 10 +--------- .../features/database/components/DatabaseView.scss | 5 ----- log-viewer/src/tabulator/style/DataGrid.scss | 8 ++++++++ 8 files changed, 13 insertions(+), 43 deletions(-) diff --git a/log-viewer/src/components/CallStackDetail.ts b/log-viewer/src/components/CallStackDetail.ts index a240227d0..a8b5e041d 100644 --- a/log-viewer/src/components/CallStackDetail.ts +++ b/log-viewer/src/components/CallStackDetail.ts @@ -16,7 +16,7 @@ import { soqlInlineElement } from '../features/soql/format/inlineCell.js'; import { soqlSyntaxStyles } from '../features/soql/styles/soql-syntax.css.js'; import { eventBus } from '../core/events/EventBus.js'; import { SelectionEchoGuard } from '../core/events/SelectionEchoGuard.js'; -import { LOCATED_ROW_CLASS, LocatedRowMarker, rowIndexStamper } from './locatedRow.js'; +import { LocatedRowMarker, rowIndexStamper } from './locatedRow.js'; import { globalStyles } from '../styles/global.styles.js'; import { progressColumnWidth } from '../tabulator/format/measureWidth.js'; import dataGridStyles from '../tabulator/style/DataGrid.scss'; @@ -95,10 +95,6 @@ export class CallStackDetail extends LitElement { overflow: hidden; text-overflow: ellipsis; } - /* The frame under the pointer in the tab on screen. */ - #call-stack-table .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } `, ]; diff --git a/log-viewer/src/components/CallTreeDetail.ts b/log-viewer/src/components/CallTreeDetail.ts index 0fdf3ffa7..0ba2bb90c 100644 --- a/log-viewer/src/components/CallTreeDetail.ts +++ b/log-viewer/src/components/CallTreeDetail.ts @@ -38,13 +38,7 @@ import dataGridStyles from '../tabulator/style/DataGrid.scss'; import './ContextMenu.js'; import type { ContextMenu } from './ContextMenu.js'; import { dispatchInspectorLocate, dispatchInspectorReveal } from './inspectorReveal.js'; -import { - LOCATED_ROW_CLASS, - LocatedRowIds, - LocatedRowMarker, - rowId, - rowIndexStamper, -} from './locatedRow.js'; +import { LocatedRowIds, LocatedRowMarker, rowId, rowIndexStamper } from './locatedRow.js'; import { PANEL_ROW_MENU_ITEMS, runPanelRowAction } from './panelRowMenu.js'; import { buildScopedCallTree, @@ -369,10 +363,6 @@ export class CallTreeDetail extends LitElement { overflow: hidden; text-overflow: ellipsis; } - /* The frame under the pointer in the tab on screen. */ - .table-host .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } `, ]; diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index dc22dde7b..e9c3ea955 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -10,7 +10,8 @@ import { ROOT_PATH_ID } from '../core/log/keyPathIds.js'; import { logStoreFor } from '../core/log/LogStore.js'; import { eventByEventIndex } from '../core/utility/EventSearch.js'; -/** Class the marked row carries; each table styles it itself. */ +/** Class the marked row carries. Styled once, in `tabulator/style/DataGrid.scss`, + * which spells the name out: rename it here and the mark stops painting. */ export const LOCATED_ROW_CLASS = 'located-row'; /** Attribute holding a row's index, so the mark can find its element. */ diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 9334f11cc..1332bcc14 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -15,7 +15,6 @@ import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; import { eventBus } from '../../../core/events/EventBus.js'; import { - LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, rowDetailSelection, @@ -79,11 +78,6 @@ export class AnalysisView extends LitElement { box-sizing: border-box; } - /* The frame under the pointer in the inspector. */ - .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } - .analysis-view { display: flex; flex-direction: column; diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 837fb2751..d11b7b11e 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -66,7 +66,6 @@ import { toggleField, } from '../../../tabulator/ColumnViews.js'; import { - LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, rowDetailSelection, @@ -301,11 +300,6 @@ export class CalltreeView extends LitElement { opacity: 0; pointer-events: none; } - - /* The frame under the pointer in the inspector. */ - .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } `, categoryColoringStyles, ]; diff --git a/log-viewer/src/features/database/components/DatabaseTimeTree.ts b/log-viewer/src/features/database/components/DatabaseTimeTree.ts index 28fd8ab7a..207dc4e66 100644 --- a/log-viewer/src/features/database/components/DatabaseTimeTree.ts +++ b/log-viewer/src/features/database/components/DatabaseTimeTree.ts @@ -17,11 +17,7 @@ import { dispatchInspectorLocate, dispatchInspectorReveal, } from '../../../components/inspectorReveal.js'; -import { - LOCATED_ROW_CLASS, - LocatedRowMarker, - rowIndexStamper, -} from '../../../components/locatedRow.js'; +import { LocatedRowMarker, rowIndexStamper } from '../../../components/locatedRow.js'; import { PANEL_ROW_MENU_ITEMS, runPanelRowAction } from '../../../components/panelRowMenu.js'; import { eventBus } from '../../../core/events/EventBus.js'; import { logContext } from '../../../core/log/logContext.js'; @@ -193,10 +189,6 @@ export class DatabaseTime extends LitElement { overflow: hidden; text-overflow: ellipsis; } - /* The statement under the pointer in the grid beside. */ - .tabulator-row.${unsafeCSS(LOCATED_ROW_CLASS)} { - background-color: var(--lana-row-hover-bg); - } `, ]; diff --git a/log-viewer/src/features/database/components/DatabaseView.scss b/log-viewer/src/features/database/components/DatabaseView.scss index 225e007c4..09f74ed96 100644 --- a/log-viewer/src/features/database/components/DatabaseView.scss +++ b/log-viewer/src/features/database/components/DatabaseView.scss @@ -1,10 +1,5 @@ @use '../../../tabulator/style/DataGrid'; -/* The statement under the pointer in the inspector (LOCATED_ROW_CLASS). */ -.tabulator-row.located-row { - background-color: var(--lana-row-hover-bg); -} - .db-group-row { display: flex; min-width: 0; diff --git a/log-viewer/src/tabulator/style/DataGrid.scss b/log-viewer/src/tabulator/style/DataGrid.scss index 6efa4cd6b..03ac507d5 100644 --- a/log-viewer/src/tabulator/style/DataGrid.scss +++ b/log-viewer/src/tabulator/style/DataGrid.scss @@ -238,6 +238,14 @@ $codicon-chevron-down: '\eab4'; color: var(--vscode-list-activeSelectionForeground); } + // What the inspector located: the row it named, in whichever grid is on + // screen. It ties with hover and with selection, so it has to come after + // both โ€” a mark holds until Escape, where a hover is only where the pointer + // is now. + .tabulator-row.located-row { + background-color: var(--lana-row-hover-bg); + } + .tabulator-cell.datagrid-textarea { white-space: pre-wrap; overflow-wrap: break-word; From 8cc92316ef51230fd604bb7025302cb097367382 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:21:36 +0100 Subject: [PATCH 47/61] feat(log-viewer): compare the variables across a merged row's calls (#1021) Part of #373. Builds on the sidebar Variables section (#1006) and opening an object in it (#1009). The Variables section said "Pick one call to see its variables" for every merged row. Now it compares those calls, so an Aggregated, Bottom-Up or Analysis row answers with the reading the grids beside it cannot give: which input varied, and which was the same every time. ## What a reader sees - The names that varied lead. Each opens into every value it held, how many calls held it, and whether that was one unbroken run or a value that came and went. - A name every call agreed on reads as it does for one frame. - Hover a value to light the calls that held it in the timeline and the grids; click, or press Enter, to keep them lit. The panel never re-scopes to one call - every other section answers a merged row with aggregated figures, and dropping onto one of its calls would throw away the reading. - An object opens into its fields, as it does for one frame, read as the first call that held it recorded it. - Statics are left out, and the section says so: a static lives for the whole transaction, so it moves for reasons the row does not own. Every cap says so on screen: over 1,000 values for a name, a mark that stops at 200 calls, a truncated log, and an index that dropped writes. ## Speed and memory A merged row can hold tens of thousands of calls, and the comparison reads every one - a recursive frame's nested call is a call of its own. It walks in frame-sized slices against a frame budget, so a wide selection never blocks the panel, and a walk the selection has moved past is abandoned. | log | busiest signature | first | again | | --- | --- | --- | --- | | 19MB | 34,857 calls | 52ms | 0ms | | 18MB FINEST | 2,364 calls | 9ms | 0ms | | 9MB FINEST | 1,169 calls | 18ms | 0ms | Reading the same shared caller frame once per call was the whole cost: 786ms to 63ms on the worst signature, 3,733ms to 47ms on the 34,857-call one. Frame reads hold their parsed lines only from the second ask, and a frame asked about once is scanned bounded at the cut, so a memo built over a 500k-child frame is neither built nor held for a read that wanted one line. ## Notes - `scripts/measure/variables.ts` times the section: the index, a frame snapshot, an early cut in the log's fattest frame, and the busiest merged row's comparison. - Opening an object into its fields at aggregate scope was out of scope in the plan; it turned out to be needed to read the section at all, so it is here. - The last four commits act on a `/simplify` and a `/code-review` pass over this branch. Tests: 2,364 pass. `tsc -b`, eslint and prettier clean. --- CHANGELOG.md | 2 +- lana-docs/docs/docs/features/inspector.md | 2 +- log-viewer/src/components/CallTreeDetail.ts | 7 +- log-viewer/src/components/VariablesDetail.ts | 363 ++++++++++++++++-- .../components/__tests__/LogInspector.test.ts | 3 + .../__tests__/VariablesDetail.test.ts | 258 ++++++++++++- .../__tests__/detailSections.test.ts | 45 ++- .../components/__tests__/locatedRow.test.ts | 71 ++++ .../components/__tests__/variableTree.test.ts | 198 +++++++++- log-viewer/src/components/detailSections.ts | 5 +- log-viewer/src/components/locatedRow.ts | 3 + log-viewer/src/components/variableTree.ts | 333 ++++++++++++---- log-viewer/src/core/events/EventBus.ts | 4 + .../log/__tests__/aggregateVariables.test.ts | 263 +++++++++++++ .../core/log/__tests__/frameVariables.test.ts | 50 +++ log-viewer/src/core/log/aggregateVariables.ts | 303 +++++++++++++++ log-viewer/src/core/log/frameVariables.ts | 217 +++++++++-- .../analysis/components/AnalysisView.ts | 2 +- .../components/__tests__/AnalysisView.test.ts | 6 +- .../call-tree/components/CalltreeView.ts | 2 +- scripts/measure/variables.ts | 68 +++- 21 files changed, 2033 insertions(+), 172 deletions(-) create mode 100644 log-viewer/src/core/log/__tests__/aggregateVariables.test.ts create mode 100644 log-viewer/src/core/log/aggregateVariables.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d811e13..6e8972f17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - ๐Ÿงญ **Inspector**: select a timeline frame, a table row or a statement to see its details, governor usage, call stack and subtree - or select nothing for a whole-log overview. Dock it left, right or bottom. ([#113] [#373] [#63]) -- ๐Ÿ”ฌ **Variables**: see the **Local** and **Static** variables in scope at the frame you selected, each holding the value it had at that point; an object opens into its fields. Needs Apex Code at **FINEST**. ([#373]) +- ๐Ÿ”ฌ **Variables**: see the **Local** and **Static** variables in scope at the frame you selected, each holding the value it had at that point; an object opens into its fields. Pick a merged row and it compares its calls instead โ€” which names varied and every value they held, with how many calls held each; hover a value to light those calls in the timeline and grids. Needs Apex Code at **FINEST**. ([#373]) - ๐Ÿง  **Heap analysis**: every method and call path reports heap three ways - **Net** (retained), **Gross** (allocated) and **Peak** (highest live) - so allocate-then-free churn no longer looks like a leak. ([#32]) - ๐Ÿ—„๏ธ **Database governor limits**: SOQL, SOSL, DML and row counts show as `used / limit`, flagging queries that did not consume the limit, plus a dedicated SOSL table. ([#162]) - ๐Ÿ”ด **Timeline exception markers**: exceptions show as red lines, with a Throws count in method tooltips. ([#828]) diff --git a/lana-docs/docs/docs/features/inspector.md b/lana-docs/docs/docs/features/inspector.md index f6be7bb46..ef65cc30e 100644 --- a/lana-docs/docs/docs/features/inspector.md +++ b/lana-docs/docs/docs/features/inspector.md @@ -26,7 +26,7 @@ It docks to the **right**, **left** or **bottom**, resizes by dragging its edge, ### Sections - **Details** โ€“ timing, plus every governor metric the selection consumed as `used / limit`. For SOQL also selectivity, query plan and cardinality, with the query text highlighted and copyable. -- **Variables** โ€“ what Apex could reach from the frame: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, grouped by class. Every value reads as it stood at the frame, and a name the log declared but never wrote reads `not assigned`. An object opens into the fields the log recorded for it, wherever they were set, and a field that is itself an object opens again. Every row that opens previews what is inside it with a count beside it, and the hover says whether those parts were written on one line or assembled from writes of their own. An object the log recorded nothing for reads `{}` with nothing to open. Where the log wrote an address instead of a value, the object at that address is shown, or `no value recorded` where the log never wrote one. A statement owns no variables of its own, so it answers from the Apex frame that ran it. Needs the log captured with Apex Code at **FINEST**. +- **Variables** โ€“ what Apex could reach from the frame: its **Local** variables, `this` and its fields, and the **Static** variables assigned by that point, grouped by class. Every value reads as it stood at the frame, and a name the log declared but never wrote reads `not assigned`. An object opens into the fields the log recorded for it, wherever they were set, and a field that is itself an object opens again. Every row that opens previews what is inside it with a count beside it, and the hover says whether those parts were written on one line or assembled from writes of their own. An object the log recorded nothing for reads `{}` with nothing to open. Where the log wrote an address instead of a value, the object at that address is shown, or `no value recorded` where the log never wrote one. A statement owns no variables of its own, so it answers from the Apex frame that ran it. Pick a row that merges calls - Aggregated, Bottom-Up or any Analysis row - and the section compares those calls instead: the names that varied lead, each opening into every value it held, how many calls held it, and whether that was one unbroken run of calls or a value that came and went. A name every call agreed on reads as it does for one frame. Hover a value to light the calls that held it in the timeline and the grids, and click to keep them lit. An object opens into its fields as it does for one frame, read as the first call that held it recorded it. Statics are left out: a static lives for the whole transaction, so it moves for reasons the row does not own. Needs the log captured with Apex Code at **FINEST**. - **Self time by namespace** โ€“ Timeline only: the self time under the selection split by the namespace whose code ran it, so you can see whose package burned it. Every namespace bar colours the six biggest and gathers the rest into one **others** segment, which names them on hover. - **Findings** โ€“ Analysis only: which of the log's findings name the selected method or anything it called, so you can tell whether the row you picked is one of the log's problems. - **Call stack** โ€“ the parent frames that led to the selection, outermost first, with total and self time. diff --git a/log-viewer/src/components/CallTreeDetail.ts b/log-viewer/src/components/CallTreeDetail.ts index 0ba2bb90c..3e344b9cb 100644 --- a/log-viewer/src/components/CallTreeDetail.ts +++ b/log-viewer/src/components/CallTreeDetail.ts @@ -623,10 +623,11 @@ export class CallTreeDetail extends LitElement { // The same aggregate a merged row in the tab itself reports, so Details // reads the same either way. Built from the row: a scoped row carries no // key, which is what the tab's own rows are read through. - const instances = locatableEventIndexes(data); - dispatchInspectorLocate(this, frameEventIndexes(data), true, { + const frames = frameEventIndexes(data); + dispatchInspectorLocate(this, frames, true, { kind: 'aggregate', - instances, + instances: locatableEventIndexes(data), + frames, calledBy: this.viewMode === 'bottom-up' ? callerOfRow(rows[0]) : undefined, }); } diff --git a/log-viewer/src/components/VariablesDetail.ts b/log-viewer/src/components/VariablesDetail.ts index ace83c78d..90930c355 100644 --- a/log-viewer/src/components/VariablesDetail.ts +++ b/log-viewer/src/components/VariablesDetail.ts @@ -6,21 +6,38 @@ import { consume } from '@lit/context'; import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { + aggregateVariablesFor, + cachedAggregateVariables, + MAX_MARKED_PER_VALUE, + MAX_VALUES_PER_NAME, + type AggregateVariables, + type SpreadValue, + type VariableSpread, +} from '../core/log/aggregateVariables.js'; import { frameVariablesFor, recordsVariables, variableIndexFor, type FrameVariables, - type IndexView, type VariableIndex, } from '../core/log/frameVariables.js'; import { logContext } from '../core/log/logContext.js'; import type { LogStore } from '../core/log/LogStore.js'; import { previewOf, RAW_CLAMP_CHARS, type VariableValue } from '../core/log/variableValue.js'; +import { formatInteger } from '../core/utility/Util.js'; import { globalStyles } from '../styles/global.styles.js'; import { inspectorSectionStyles } from '../styles/inspectorSection.styles.js'; import { bleedRowStyles } from '../styles/revealRow.styles.js'; -import { parentOf, toTreeRows, type Shown, type VariableTreeRow } from './variableTree.js'; +import { dispatchInspectorLocate } from './inspectorReveal.js'; +import { + parentOf, + toSpreadRows, + toTreeRows, + type Lookups, + type Shown, + type VariableTreeRow, +} from './variableTree.js'; // web components import './CodeBlock.js'; @@ -44,10 +61,10 @@ export class VariablesDetail extends LitElement { @property({ type: Number }) eventIndex = -1; - /** Occurrence eventIndexes when the selection is an aggregate row. One frame - * holds one set of variables, so an aggregate has none to show. */ + /** The frames a merged row is, where the selection is one: the scope every + * call it counts held. Null for a single frame, which `eventIndex` names. */ @property({ attribute: false }) - instances: number[] | null = null; + frames: number[] | null = null; /** The log on screen, from the app root. */ @consume({ context: logContext, subscribe: true }) @@ -57,6 +74,18 @@ export class VariablesDetail extends LitElement { @state() private _index: VariableIndex | null = null; + /** What the selected calls held, compared, or null while the walk runs. */ + @state() + private _spread: AggregateVariables | null = null; + + /** The frames `_spread` describes, so a render for any other reason does not + * walk again, and a new selection does. */ + private _spreadKey?: readonly number[] | null; + + /** The comparison in flight; a new selection aborts it, and so does a + * disconnect. */ + private _walk: AbortController | null = null; + /** Which rows the user has opened or closed, by their stable id, so disclosure * survives walking the call stack. */ @state() @@ -79,6 +108,14 @@ export class VariablesDetail extends LitElement { /** Row id to its place in {@link _rows}, so a key is a lookup. */ private _at: ReadonlyMap = new Map(); + /** A row on screen shows an object read through an address, so the reading is + * one call's. Fixed by {@link _rows}, so render never rescans them. */ + private _resolved = false; + + /** A value in the comparison holds more calls than its mark can name. Read + * from the spread, not the rows: a closed name would hide it. */ + private _partlyMarked = false; + /** The scope as read for the current selection. * * Read once per selection, never per render: reading it back through a frame @@ -86,9 +123,10 @@ export class VariablesDetail extends LitElement { * not pay that again. */ private _frame: FrameVariables | null = null; - /** The index bound to this frame's cut, which holds what it reads: every row - * is built again whenever anything opens. */ - private _view: IndexView | null = null; + /** One index view per point the section reads at: one for a frame, one per + * compared value's own call. Every row is built again whenever anything + * opens, so the views are held rather than remade. */ + private _views = new Map(); /** Set when a key moved the tab stop, so `updated` moves focus with it. */ private _takeFocus = false; @@ -246,42 +284,109 @@ export class VariablesDetail extends LitElement { `, ]; + /** The selection merges calls, so the section compares them rather than + * reading one frame. */ + private get _isAggregate(): boolean { + return (this.frames?.length ?? 0) > 1; + } + + /** + * The frame one reading is of. + * + * A merged row that comes down to a single frame is that frame, never the + * calls it counts: a bottom-up caller row counts its callee's calls, so + * `eventIndex` names one of those and reading it would show the called + * method's scope under a row that names the caller. + */ + private get _readIndex(): number { + return this.frames?.length === 1 ? this.frames[0]! : this.eventIndex; + } + + /** The index, but only once the log has a write in it. A log captured at + * FINEST can still record none, and reading every call to find that out + * costs seconds; one derivation, so the note and the walk cannot disagree. */ + private get _readable(): VariableIndex | null { + return this._index?.sawAnyWrite ? this._index : null; + } + override willUpdate(changed: PropertyValues): void { // Only what the selection is made of, so a disclosure or a key does not // re-read the log. const reselected = changed.has('eventIndex') || - changed.has('instances') || + changed.has('frames') || changed.has('logStore') || changed.has('_index'); if (reselected) { + this._views.clear(); // An aggregate answers with none of these: reading the frame back through // it costs tens of ms on a huge frame, and render() would throw it away // unread. - const aggregate = (this.instances?.length ?? 0) > 1; this._frame = - !aggregate && this.logStore && this._index - ? frameVariablesFor(this.logStore, this.eventIndex, this._index) + !this._isAggregate && this.logStore && this._index + ? frameVariablesFor(this.logStore, this._readIndex, this._index) : null; - this._view = this._frame && this._index ? this._index.viewAt(this._frame.cut) : null; + // The comparison is resolved here, not in `updated`: a walk that runs a + // step later would let this pass rebuild and render the *previous* row's + // spread first, and repopulate the views with its cuts. + this._spread = this._compareKey() ? this._held() : null; } // A key press moves the tab stop and nothing else, so the rows it walks are // rebuilt only when the scope or what is open changes. - if (reselected || changed.has('_disclosure')) { + if (reselected || changed.has('_spread') || changed.has('_disclosure')) { this._rebuild(); } } + /** The frames to compare, or null where the section reads one frame or the + * log records nothing to compare. */ + private _compareKey(): readonly number[] | null { + return this._isAggregate && this.logStore && this._readable ? this.frames : null; + } + + /** A comparison already walked, so a re-selection shows no placeholder. */ + private _held(): AggregateVariables | null { + const frames = this._compareKey(); + return frames ? (cachedAggregateVariables(frames) ?? null) : null; + } + /** The rows on screen, and where each one sits, from the scope and what is * open. Scanning a value is the cost here, so it is paid once. */ private _rebuild(): void { + const isOpen = (id: string, byDefault: boolean): boolean => + this._disclosure.get(id) ?? byDefault; const frame = this._frame; - const view = this._view; - this._rows = - frame && view - ? toTreeRows(frame, (id, byDefault) => this._disclosure.get(id) ?? byDefault, view) + this._rows = this._isAggregate + ? // Read through what is held: a walk that has yet to answer shows its + // placeholder, never the last row's spread. + this._spread + ? toSpreadRows(this._spread, isOpen, (cut) => this._viewAt(cut)) + : [] + : frame + ? toTreeRows(frame, isOpen, this._viewAt(frame.cut)) : []; this._at = new Map(this._rows.map((row, at) => [row.id, at])); + // Fixed here, so neither is rescanned on every render. + this._resolved = this._rows.some((row) => 'resolved' in row && row.resolved); + const spread = this._spread; + this._partlyMarked = + (spread?.locals.some(partlyMarked) || spread?.fields.some(partlyMarked)) ?? false; + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + // Comparing on into a detached host wastes frames and answers nobody. + this._walk?.abort(); + } + + /** The log bound to one point in it, held per point. */ + private _viewAt(cut: number): Lookups { + let held = this._views.get(cut); + if (!held) { + held = this._index?.viewAt(cut) ?? {}; + this._views.set(cut, held); + } + return held; } override updated(changed: PropertyValues): void { @@ -290,8 +395,15 @@ export class VariablesDetail extends LitElement { this._readError = false; this._disclosure = new Map(); this._focused = null; + this._spreadKey = undefined; + this._spread = null; void this._read(); } + // Comparing reads every call, so only a changed selection - or one we have + // yet to resolve - earns the walk. + if (changed.has('frames') || changed.has('_index') || this._spreadKey === undefined) { + void this._compare(); + } if (this._takeFocus) { this._takeFocus = false; // By place, not by id: an id embeds names the log wrote, and rows render @@ -308,32 +420,55 @@ export class VariablesDetail extends LitElement { if (!log) { return nothing; } - // An aggregate row counts calls from many frames, and each held its own - // variables. Naming one of them would be a guess. - if (this.instances && this.instances.length > 1) { - return note('Pick one call to see its variables.'); - } if (!recordsVariables(log)) { return note('Variables available with the Apex Code log level at FINEST.'); } if (this._readError) { return note('Could not read the log for variables.'); } - const index = this._index; - if (!index) { + if (!this._index) { return note('Reading the logโ€ฆ'); } // A log can be captured at FINEST and still record no write, so this is not // the same as a frame that had nothing in scope. - if (!index.sawAnyWrite) { + const index = this._readable; + if (!index) { return note('This log records no variable assignments.'); } + return this._isAggregate ? this._spreadTree(index) : this._frameTree(index); + } + /** One frame: its locals, its object's fields and the statics in scope. */ + private _frameTree(index: VariableIndex): TemplateResult { const frame = this._frame; if (!frame || !this._rows.length) { return note('The log records no variables in scope here.'); } + return this._tree([frame.truncated && TRUNCATED_NOTE, index.capped && HELD_NOTE]); + } + /** + * A merged row: what its calls held, compared. Which name varied is the + * reading, and every value it lists names the calls that held it. + */ + private _spreadTree(index: VariableIndex): TemplateResult { + const spread = this._spread; + if (!spread) { + return note(`Comparing ${callsHeld(this.frames?.length ?? 0)}โ€ฆ`); + } + return this._tree([ + spread.truncated && TRUNCATED_NOTE, + index.capped && HELD_NOTE, + spread.capped && `Over ${formatInteger(MAX_VALUES_PER_NAME)} values, so some are not listed.`, + this._partlyMarked && + `A value held by over ${formatInteger(MAX_MARKED_PER_VALUE)} calls marks that many of them.`, + this._resolved && RESOLVED_NOTE, + STATICS_NOTE, + ]); + } + + /** The rows, under whatever the scope has to say about them. */ + private _tree(notes: readonly (string | false)[]): TemplateResult { // The tab stop follows the tree: a row that has gone hands it back. const focused = this._focused !== null && this._at.has(this._focused) @@ -341,12 +476,7 @@ export class VariablesDetail extends LitElement { : (this._rows[0]?.id ?? null); return html` - ${ - frame.truncated - ? note('The log is truncated here, so a write may be unrecorded rather than absent.') - : '' - } - ${index.capped ? note('Too many assignments to hold them all, so some values are missing.') : ''} + ${notes.map((text) => (text ? note(text) : ''))}
    ${this._rows.map((row) => this._render(row, row.id === focused))}
    @@ -374,6 +504,42 @@ export class VariablesDetail extends LitElement { } } + /** + * Compares the selected calls in frame-sized slices, so a wide selection never + * blocks the panel, and abandons a walk the selection has moved past. + */ + private async _compare(): Promise { + const store = this.logStore; + const index = this._readable; + const frames = this._compareKey(); + if (frames === this._spreadKey) { + return; + } + this._spreadKey = frames; + this._walk?.abort(); + const walk = (this._walk = new AbortController()); + if (!frames || !store || !index) { + return; + } + // `willUpdate` already answered from the memo, so only an unwalked + // selection reaches the walk. + if (this._spread) { + return; + } + const spread = await aggregateVariablesFor(store, frames, index, { signal: walk.signal }); + if (this._walk !== walk) { + return; + } + if (spread) { + this._spread = spread; + } else { + // Abandoned while the selection still stands - a disconnected host. Forget + // the key, so a later render compares again instead of waiting on a dead + // walk. + this._spreadKey = undefined; + } + } + private _render(row: VariableTreeRow, focused: boolean): TemplateResult { // A note is prose about the row above it, so it is read but never opened. const isNote = row.kind === 'note'; @@ -386,6 +552,8 @@ export class VariablesDetail extends LitElement { aria-expanded=${row.expandable ? String(row.open) : nothing} tabindex=${isNote ? nothing : focused ? 0 : -1} @click=${() => this._pick(row)} + @pointerenter=${() => this._hover(row, true)} + @pointerleave=${() => this._hover(row, false)} > ${row.expandable ? CHEVRON : html``}${this._body(row)}
    `; @@ -408,6 +576,16 @@ export class VariablesDetail extends LitElement { ${row.count}`; case 'variable': return this._variable(row); + case 'spread': + return this._agreed(row); + case 'spread-many': + return this._varied(row); + case 'spread-value': + return html`${this._value(row, null)} + ${runsChip(row.held)} + ${formatInteger(row.held.calls)}`; case 'entry': return html` ${row.key === null ? 'ยท' : `${row.key}:`} @@ -434,6 +612,53 @@ export class VariablesDetail extends LitElement { ${partCount(row)}${chipFor(row.value)}${typeColumn(variable.declaredType)}`; } + /** A name every call agreed on: the value itself, so it reads and opens + * exactly as a single frame's row does. */ + private _agreed(row: Extract): TemplateResult { + const { name, declaredType } = row.row; + return html` + ${name}: + ${this._value(row, declaredType)} + + ${partCount(row)}${chipFor(row.value)}${this._spreadCounts(row.row)}${typeColumn( + declaredType, + )}`; + } + + /** A name the calls disagreed on: how many values they held, opening on them. */ + private _varied(row: Extract): TemplateResult { + const { name, declaredType, values, capped } = row.row; + return html` + ${name} + ${ + values.length + ? html`${capped ? `over ${formatInteger(MAX_VALUES_PER_NAME)}` : formatInteger(values.length)} + values` + : html`not assigned` + } + + ${this._spreadCounts(row.row)}${typeColumn(declaredType)}`; + } + + /** What the calls did with a name, beside the value: how many had it in scope, + * and how many declared it and never wrote it. */ + private _spreadCounts(spread: VariableSpread): TemplateResult { + const { calls, unassigned, values } = spread; + return html`${ + // Assigned by some calls and not by others, which neither the value nor + // the count says. + unassigned && values.length + ? html`${formatInteger(unassigned)} unassigned` + : '' + }${formatInteger(calls)}`; + } + /** * A value, and where it came from. * @@ -516,11 +741,26 @@ export class VariablesDetail extends LitElement { return; } this._focused = row.id; + // A picked value holds its mark while the pointer is elsewhere. No + // selection rides with it, so the panel keeps comparing: every other + // section answers a merged row with aggregated figures, and dropping onto + // one of its calls would throw away the reading the reader came for. + if (row.kind === 'spread-value') { + dispatchInspectorLocate(this, row.held.at, true); + } if (row.expandable) { this._toggle(row.id, !row.open); } } + /** The calls a value row stands for, marked in the tab while the pointer is + * over it. Nothing is selected and no section changes. */ + private _hover(row: VariableTreeRow, over: boolean): void { + if (row.kind === 'spread-value') { + dispatchInspectorLocate(this, over ? row.held.at : []); + } + } + /** The tree keyboard pattern: the arrows walk and open, nothing tabs away. */ private _onKeyDown(event: KeyboardEvent): void { const rows = this._rows; @@ -575,10 +815,12 @@ export class VariablesDetail extends LitElement { break; case 'Enter': case ' ': - // Fires once: a repeat would flap the row open and shut. The arrows open - // and close in one direction each, so they carry on repeating. - if (row.expandable && !event.repeat) { - this._toggle(row.id, !row.open); + // What a click does: mark the calls that held the value, and open the + // row where it opens. A value is often a scalar with nothing to open, + // so toggling alone leaves the mark out of a keyboard's reach. + // Fires once: a repeat would flap an expandable row open and shut. + if (!event.repeat) { + this._pick(row); } break; case '*': @@ -648,6 +890,32 @@ interface Missing { why: string; } +/** + * Whether a value was a phase the calls passed through or one that came and + * went. + * + * Counts alone mislead: 328 of 340 calls reads as noise, and if those 328 are + * one unbroken run it is a state the calls were in. One call is trivially one + * run, so it says nothing. + */ +function runsChip(value: SpreadValue): TemplateResult | string { + if (value.calls < 2) { + return ''; + } + return value.runs === 1 + ? html`one run` + : html`${value.runs} runs`; +} + +/** A call count as prose. The same shape `SelfTimeSpreadView` uses, which is + * where a shared `plural` helper would live if a third caller wants one. */ +const callsHeld = (calls: number): string => + calls === 1 ? '1 call' : `${formatInteger(calls)} calls`; + /** The declared type, in its own column, where the log gave one. */ function typeColumn(declaredType: string | null): TemplateResult | string { return declaredType ? html`${declaredType}` : ''; @@ -675,6 +943,29 @@ function lastSegment(className: string): string { return className.slice(className.lastIndexOf('.') + 1); } +/** A name holds a value whose mark names fewer calls than held it. */ +function partlyMarked(row: VariableSpread): boolean { + return row.values.some((value) => value.at.length < value.calls); +} + +/** The frame or a caller ran past the end of a truncated log. Shared: it is the + * same fact at either scope. */ +const TRUNCATED_NOTE = + 'The log is truncated here, so a write may be unrecorded rather than absent.'; + +/** The index dropped writes past a cap, so an answer may be short of what the + * log recorded. Shared: it is the same fact at either scope. */ +const HELD_NOTE = 'Too many assignments to hold them all, so some values are missing.'; + +/** A comparison reads each value at the point its first call stood, so an + * object it opens is that one call's reading. */ +const RESOLVED_NOTE = + 'An object is shown as the first call that held it recorded it, so another call may have held a different one.'; + +/** Why a merged row's comparison stops at the locals and the fields. */ +export const STATICS_NOTE = + 'Statics are not compared: a static lives for the whole transaction, so it moves for reasons this row does not own.'; + const WHY_RESOLVED = (address: string): string => `The log wrote no value here. This is what it recorded for ${address}.`; diff --git a/log-viewer/src/components/__tests__/LogInspector.test.ts b/log-viewer/src/components/__tests__/LogInspector.test.ts index a384a6630..ac4a0c8f5 100644 --- a/log-viewer/src/components/__tests__/LogInspector.test.ts +++ b/log-viewer/src/components/__tests__/LogInspector.test.ts @@ -395,6 +395,7 @@ describe('LogInspector', () => { dispatchInspectorLocate(dockLayout(el), [5, 9], true, { kind: 'aggregate', instances: [5, 9], + frames: [5, 9], }); await flush(el); @@ -421,6 +422,7 @@ describe('LogInspector', () => { dispatchInspectorLocate(dockLayout(el), [5, 9], true, { kind: 'aggregate', instances: [5, 9], + frames: [5, 9], }); await flush(el); @@ -436,6 +438,7 @@ describe('LogInspector', () => { dispatchInspectorLocate(dockLayout(el), [5, 9], true, { kind: 'aggregate', instances: [5, 9], + frames: [5, 9], }); await flush(el); diff --git a/log-viewer/src/components/__tests__/VariablesDetail.test.ts b/log-viewer/src/components/__tests__/VariablesDetail.test.ts index 7513ad9c6..d498043e7 100644 --- a/log-viewer/src/components/__tests__/VariablesDetail.test.ts +++ b/log-viewer/src/components/__tests__/VariablesDetail.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from '@jest/globals'; import { parse } from 'apex-log-parser'; +import { MAX_MARKED_PER_VALUE } from '../../core/log/aggregateVariables.js'; import { logStoreFor, type LogStore } from '../../core/log/LogStore.js'; // Avoid the heavy CodeBlock import chain (vscode-elements, soql formatter). The @@ -14,7 +15,7 @@ jest.mock('../CodeBlock.js', () => ({})); // The chevron is a vscode-icon, and its connectedCallback throws under jsdom. jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -import type { VariablesDetail } from '../VariablesDetail.js'; +import { STATICS_NOTE, type VariablesDetail } from '../VariablesDetail.js'; import '../VariablesDetail.js'; const FINEST = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; @@ -124,7 +125,7 @@ describe('VariablesDetail skips the frame read for an aggregate', () => { const store = logOf(FRAME); const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()'), - instances: [indexOf(store, 'ns.Outer.run()'), indexOf(store, 'ns.Outer.run()')], + frames: [indexOf(store, 'ns.Outer.run()'), indexOf(store, 'ns.Outer.run()')], }); expect((el as unknown as { _frame: unknown })._frame).toBeNull(); @@ -162,17 +163,6 @@ describe('VariablesDetail empty states', () => { // The statics are still visible from it, so this frame reports them. expect(groupNames(el)).toContain('Static'); }); - - it('asks for one call when the selection counts many', async () => { - const store = logOf(FRAME); - - const el = await mount(store, { - eventIndex: indexOf(store, 'ns.Outer.run()'), - instances: [1, 2, 3], - }); - - expect(notes(el)).toEqual(['Pick one call to see its variables.']); - }); }); function treeRows(el: VariablesDetail): HTMLElement[] { @@ -824,3 +814,245 @@ describe('VariablesDetail object fields', () => { expect(plain?.getAttribute('aria-expanded')).toBeNull(); }); }); + +// A merged row has no single frame, so the section compares its calls: which +// name varied is the reading the grids beside it do not carry. +describe('VariablesDetail comparing a merged row', () => { + /** One call of `ns.Svc.run()`, writing `retry` and the constant `batchSize`. */ + function call(at: number, retry: string): string { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_SCOPE_BEGIN|[2]|retry|Boolean|true|false\n` + + `${t(20)}|VARIABLE_ASSIGNMENT|[2]|retry|${retry}\n` + + `${t(30)}|VARIABLE_ASSIGNMENT|[3]|batchSize|200\n` + + `${t(40)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); + } + + const CALLS = call(1000, 'true') + call(2000, 'false') + call(3000, 'false'); + + /** Every frame whose text is `text`: a METHOD_EXIT carries the entry's text. */ + function framesOf(store: LogStore, text: string): number[] { + return store.log.eventsById + .filter((event) => event.isParent && event.text === text) + .map((event) => event.eventIndex); + } + + /** The comparison on screen, once its walk has answered. */ + async function compared(body = CALLS): Promise { + const store = logOf(body); + const frames = framesOf(store, 'ns.Svc.run()'); + const el = await mount(store, { eventIndex: frames[0]!, frames }); + // The comparison is a walk of its own, after the index it reads through. + await new Promise((resolve) => setTimeout(resolve, 0)); + await el.updateComplete; + return el; + } + + it('lists every name the calls held, varying ones first', async () => { + const el = await compared(); + + expect(rowNames(el)).toEqual(['retry', 'batchSize']); + }); + + it('opens a name the calls disagreed on into its values and their counts', async () => { + const el = await compared(); + rowNamed(el, 'retry')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await el.updateComplete; + + expect(rowText(el)).toContain('false'); + expect(rowText(el)).toContain('true'); + }); + + it('shows a name every call agreed on as its one value', async () => { + const el = await compared(); + + expect(rowNamed(el, 'batchSize')?.textContent).toContain('200'); + expect(rowNamed(el, 'batchSize')?.getAttribute('aria-expanded')).toBeNull(); + }); + + /** Every locate the section raised, in order. */ + function locates(el: VariablesDetail): { eventIndexes: readonly number[]; sticky: boolean }[] { + const seen: { eventIndexes: readonly number[]; sticky: boolean }[] = []; + el.addEventListener('inspector-locate', (event) => { + const { eventIndexes, sticky } = ( + event as CustomEvent<{ eventIndexes: readonly number[]; sticky: boolean }> + ).detail; + seen.push({ eventIndexes, sticky }); + }); + return seen; + } + + /** The value rows of an opened name. */ + async function valuesOf(el: VariablesDetail, name: string): Promise { + rowNamed(el, name)?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await el.updateComplete; + return treeRows(el).filter((row) => row.dataset.id?.startsWith(`local/${name}/`)); + } + + // Every other section answers a merged row with aggregated figures, so + // dropping onto one of its calls would throw away the reading. + it('never re-scopes the panel to one call', async () => { + const el = await compared(); + const revealed: number[] = []; + el.addEventListener('inspector-reveal', (event) => { + revealed.push((event as CustomEvent<{ eventIndex: number }>).detail.eventIndex); + }); + const values = await valuesOf(el, 'retry'); + + values[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + rowNamed(el, 'batchSize')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(revealed).toEqual([]); + }); + + it('marks the calls that held a value while the pointer is over it', async () => { + const el = await compared(); + const values = await valuesOf(el, 'retry'); + const seen = locates(el); + const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + + values[0]?.dispatchEvent(new MouseEvent('pointerenter', { bubbles: true })); + values[0]?.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); + + // `false` was the second and third calls; leaving hands the mark back. + expect(seen).toEqual([ + { eventIndexes: [calls[1], calls[2]], sticky: false }, + { eventIndexes: [], sticky: false }, + ]); + }); + + it('holds the mark once a value is picked', async () => { + const el = await compared(); + const values = await valuesOf(el, 'retry'); + const seen = locates(el); + const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + + values[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + // `true` was the first call. Sticky, and no selection rides with it. + expect(seen).toEqual([{ eventIndexes: [calls[0]], sticky: true }]); + }); + + // A value is often a scalar with nothing to open, so a keyboard that only + // toggled could never reach the mark at all. + it('marks the calls that held a value from the keyboard', async () => { + const el = await compared(); + await valuesOf(el, 'retry'); + const seen = locates(el); + const calls = framesOf(el.logStore!, 'ns.Svc.run()'); + + // Opening `retry` left the tab stop on it, so one step down lands on its + // first value. + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await el.updateComplete; + tree(el).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await el.updateComplete; + + // `false` was the second and third calls, and it leads on count. + expect(seen).toEqual([{ eventIndexes: [calls[1], calls[2]], sticky: true }]); + }); + + // Every other cap in this section says so; a mark that stops short while the + // count beside it says thousands would read as a bug in the mark. + it('says a value holds more calls than its mark names', async () => { + const many = Array.from({ length: MAX_MARKED_PER_VALUE + 5 }, (_, at) => + call(1000 + at * 100, 'true'), + ).join(''); + const el = await compared(many); + + expect(notes(el)).toContain( + `A value held by over ${MAX_MARKED_PER_VALUE} calls marks that many of them.`, + ); + }); + + it('says nothing of the kind where every call is marked', async () => { + const el = await compared(); + + expect(notes(el).join(' ')).not.toContain('marks that many'); + }); + + // Counts alone mislead: an unbroken run is a state the calls were in. + it('says whether a value was one run or came and went', async () => { + const el = await compared(); + const values = await valuesOf(el, 'retry'); + + expect(values[0]?.textContent).toContain('one run'); + }); + + it('counts the runs of a value the calls returned to', async () => { + const el = await compared(call(1000, 'true') + call(2000, 'false') + call(3000, 'true')); + const values = await valuesOf(el, 'retry'); + + expect(values[0]?.textContent).toContain('2 runs'); + }); + + // The panel stays on the comparison, so a value has to open where it stands. + it('opens a value that is an object into its fields', async () => { + const held = (at: number, name: string): string => { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_ASSIGNMENT|[2]|opts|{"name":"${name}","rows":5}\n` + + `${t(20)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); + }; + const el = await compared(held(1000, 'A') + held(2000, 'B')); + + const values = await valuesOf(el, 'opts'); + expect(values[0]?.getAttribute('aria-expanded')).toBe('false'); + + values[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await el.updateComplete; + + expect(rowText(el)).toContain('name'); + expect(rowText(el)).toContain('rows'); + }); + + // A static moves for reasons the row does not own, so leaving them out is a + // decision the reader is owed. + it('says the statics are not compared', async () => { + const el = await compared(); + + expect(notes(el)).toContain(STATICS_NOTE); + }); + + it('does not say the statics are not compared for a single frame', async () => { + const store = logOf(FRAME); + + const el = await mount(store, { eventIndex: indexOf(store, 'ns.Outer.run()') }); + + expect(notes(el)).not.toContain(STATICS_NOTE); + }); + + // A row's frames are its own scope; its calls are a level below it. A bottom-up + // caller row that ran once comes down to one frame, and reading the calls it + // counts would show the called method's scope under the caller's name. + it('reads its own frame where a merged row comes down to one', async () => { + const store = logOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Outer.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|outerLocal|"here"\n' + + '09:18:22.6 (1020)|METHOD_ENTRY|[3]|01p|ns.Svc.query()\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[4]|inner|1\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[3]|ns.Svc.query()\n' + + '09:18:22.6 (1050)|METHOD_ENTRY|[3]|01p|ns.Svc.query()\n' + + '09:18:22.6 (1060)|VARIABLE_ASSIGNMENT|[4]|inner|2\n' + + '09:18:22.6 (1070)|METHOD_EXIT|[3]|ns.Svc.query()\n' + + '09:18:22.6 (1080)|METHOD_EXIT|[1]|ns.Outer.run()\n', + ); + const calls = store.log.eventsById + .filter((event) => event.isParent && event.text === 'ns.Svc.query()') + .map((event) => event.eventIndex); + + // What a bottom-up caller row hands over: the calls it counts, and the one + // frame that made them. + const el = await mount(store, { + eventIndex: calls[0]!, + frames: [indexOf(store, 'ns.Outer.run()')], + }); + + expect(rowNames(el)).toContain('outerLocal'); + expect(rowNames(el)).not.toContain('inner'); + }); +}); diff --git a/log-viewer/src/components/__tests__/detailSections.test.ts b/log-viewer/src/components/__tests__/detailSections.test.ts index 5c4cac6a5..9515163a9 100644 --- a/log-viewer/src/components/__tests__/detailSections.test.ts +++ b/log-viewer/src/components/__tests__/detailSections.test.ts @@ -138,6 +138,7 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('analysis', { kind: 'aggregate', instances: [11, 12, 13], + frames: [11, 12, 13], }); expect(sections.map((s) => s.id)).toEqual([ 'vitals', @@ -152,10 +153,45 @@ describe('buildDetailSections', () => { ).toEqual([11, 12, 13]); }); + // A bottom-up caller row counts its callee's calls, so reading variables from + // those would show the called method's scope under a row that names the caller. + it('gives Variables the frames the row is, not the calls it counts', async () => { + const sections = await buildDetailSections('analysis', { + kind: 'aggregate', + instances: [11, 12, 13], + frames: [4, 5, 6], + }); + + expect( + ( + rendered(sections, 'variables', 'variables-detail') as HTMLElement & { + frames: number[] | null; + } + ).frames, + ).toEqual([4, 5, 6]); + }); + + it('gives Variables the calls themselves where the row sits at their depth', async () => { + const sections = await buildDetailSections('analysis', { + kind: 'aggregate', + instances: [11, 12, 13], + frames: [11, 12, 13], + }); + + expect( + ( + rendered(sections, 'variables', 'variables-detail') as HTMLElement & { + frames: number[] | null; + } + ).frames, + ).toEqual([11, 12, 13]); + }); + it('asks the findings which of them name the selection', async () => { const sections = await buildDetailSections('analysis', { kind: 'aggregate', instances: [11, 12, 13], + frames: [11, 12, 13], }); const findings = rendered(sections, 'findings', 'log-diagnostics') as HTMLElement & { @@ -169,7 +205,7 @@ describe('buildDetailSections', () => { it('scopes the findings to the frame being followed, not the aggregate it left', async () => { const sections = await buildDetailSections( 'analysis', - { kind: 'aggregate', instances: [11, 12, 13] }, + { kind: 'aggregate', instances: [11, 12, 13], frames: [11, 12, 13] }, { kind: 'event', eventIndex: 8 }, ); @@ -209,6 +245,7 @@ describe('buildDetailSections', () => { const sections = await buildDetailSections('timeline', { kind: 'aggregate', instances: [11, 12, 13], + frames: [11, 12, 13], }); const bar = rendered(sections, 'namespace-time', 'namespace-time-bar') as HTMLElement & { @@ -225,7 +262,7 @@ describe('buildDetailSections', () => { it('drops the aggregate once a single frame in its stack is the one being followed', async () => { const sections = await buildDetailSections( 'analysis', - { kind: 'aggregate', instances: [11, 12, 13] }, + { kind: 'aggregate', instances: [11, 12, 13], frames: [11, 12, 13] }, { kind: 'event', eventIndex: 8 }, ); @@ -240,8 +277,8 @@ describe('buildDetailSections', () => { it('describes the calls a walked bucket counts, as a bucket picked in the tab is', async () => { const sections = await buildDetailSections( 'analysis', - { kind: 'aggregate', instances: [11, 12, 13] }, - { kind: 'aggregate', instances: [21, 22], calledBy: 'Trigger1' }, + { kind: 'aggregate', instances: [11, 12, 13], frames: [11, 12, 13] }, + { kind: 'aggregate', instances: [21, 22], frames: [21, 22], calledBy: 'Trigger1' }, ); const vitals = rendered(sections, 'vitals', 'event-vitals') as HTMLElement & { diff --git a/log-viewer/src/components/__tests__/locatedRow.test.ts b/log-viewer/src/components/__tests__/locatedRow.test.ts index de5858cef..d435d310c 100644 --- a/log-viewer/src/components/__tests__/locatedRow.test.ts +++ b/log-viewer/src/components/__tests__/locatedRow.test.ts @@ -14,6 +14,7 @@ import { LOCATED_ROW_CLASS, LocatedRowIds, LocatedRowMarker, + rowDetailSelection, rowFrames, rowIndexStamper, rowPathId, @@ -339,6 +340,76 @@ describe('rowFrames', () => { }); }); +describe('rowDetailSelection', () => { + /** exec -> m1 -> soql, with the bucket and caller rows a bottom-up grid + * leaves: the bucket holds the occurrences, the caller row derives its own. */ + function rows() { + const exec = ev('exec', null, 1); + const m1 = ev('m1', exec, 3); + const soql = ev('soql', m1, 5); + const apexLog = { eventsById: { 1: exec, 3: m1, 5: soql } } as unknown as ApexLog; + const paths = logStoreFor(apexLog).keyPathIds(); + const bucketPath = paths.step(ROOT_PATH_ID, paths.keyIdOf(soql)); + const bucket = rowComponent(document.createElement('div'), { + key: 'soql', + _pathId: bucketPath, + instances: [soql], + originalData: soql, + text: 'soql', + }); + const caller = rowComponent( + document.createElement('div'), + { + key: 'm1', + _pathId: paths.step(bucketPath, paths.keyIdOf(m1)), + originalData: soql, + text: 'm1', + }, + bucket, + ); + return { apexLog, bucket, caller }; + } + + // Its locals are the caller's, a level above the calls its totals count. + it('names the frames a bottom-up caller row is, beside the calls it counts', () => { + const { apexLog, caller } = rows(); + + expect(rowDetailSelection(caller, apexLog, 'callers')).toEqual({ + kind: 'aggregate', + instances: [5], + frames: [3], + calledBy: 'm1', + }); + }); + + it('gives a root bucket its own calls, as it stands at their depth', () => { + const { apexLog, bucket } = rows(); + + expect(rowDetailSelection(bucket, apexLog, 'callers')).toEqual({ + kind: 'aggregate', + instances: [5], + frames: [5], + calledBy: undefined, + }); + }); + + it('gives a top-down row its own calls, as it sits at their depth', () => { + const { apexLog, caller } = rows(); + + expect(rowDetailSelection(caller, apexLog, 'callees')).toMatchObject({ + instances: [5], + frames: [5], + }); + }); + + it('names the one call a Time Order row is', () => { + const soql = ev('soql', null, 5); + const row = rowComponent(document.createElement('div'), { originalData: soql }); + + expect(rowDetailSelection(row, null, 'callees')).toEqual({ kind: 'event', eventIndex: 5 }); + }); +}); + describe('LocatedRowIds', () => { const root = ev('exec', null); const outerFrame = ev('outer', root); diff --git a/log-viewer/src/components/__tests__/variableTree.test.ts b/log-viewer/src/components/__tests__/variableTree.test.ts index e98612db4..307e5acc2 100644 --- a/log-viewer/src/components/__tests__/variableTree.test.ts +++ b/log-viewer/src/components/__tests__/variableTree.test.ts @@ -9,7 +9,8 @@ import { type FrameVariables, type VariableRow, } from '../../core/log/frameVariables.js'; -import { parentOf, toTreeRows, type VariableTreeRow } from '../variableTree.js'; +import type { AggregateVariables, VariableSpread } from '../../core/log/aggregateVariables.js'; +import { parentOf, toSpreadRows, toTreeRows, type VariableTreeRow } from '../variableTree.js'; function row(name: string, value: string, over: Partial = {}): VariableRow { return { @@ -424,3 +425,198 @@ describe('recorded fields', () => { expect(rows.find((r) => r.id === 'this/me')?.expandable).toBe(false); }); }); + +describe('toSpreadRows', () => { + function spread(name: string, values: [string, number][], over: Partial = {}) { + return { + name, + declaredType: null, + values: values.map(([text, calls], index) => ({ + text, + address: null, + objectAddress: null, + calls, + at: [100 + index], + runs: 1, + cut: 100 + index, + })), + calls: values.reduce((sum, [, calls]) => sum + calls, 0), + unassigned: 0, + capped: false, + ...over, + } satisfies VariableSpread; + } + + const aggregate: AggregateVariables = { + locals: [ + spread('accountId', [ + ['"001A"', 200], + ['"001B"', 140], + ]), + spread('retry', [ + ['false', 328], + ['true', 12], + ]), + spread('batchSize', [['200', 340]]), + ], + thisType: 'ns.Svc', + objects: 1, + fields: [spread('cache', [['{}', 340]])], + truncated: false, + capped: false, + }; + + const closed = (_id: string, byDefault: boolean): boolean => byDefault; + const openAll = (): boolean => true; + + it('opens Local and leaves this closed, one row per name', () => { + const rows = toSpreadRows(aggregate, closed); + + expect(rows.filter((r) => r.kind === 'group').map((r) => [r.id, r.open, r.count])).toEqual([ + ['local', true, 3], + ['this', false, 1], + ]); + // A name the calls disagreed on holds its values; one they agreed on is the value. + expect( + rows + .filter((r) => r.kind === 'spread' || r.kind === 'spread-many') + .map((r) => [r.id, r.kind]), + ).toEqual([ + ['local/accountId', 'spread-many'], + ['local/retry', 'spread-many'], + ['local/batchSize', 'spread'], + ]); + }); + + // The data layer ranks them; the rows must not re-order what it decided. + it('keeps the order the comparison put the names in', () => { + const rows = toSpreadRows(aggregate, closed); + + expect( + rows + .filter((r) => r.kind === 'spread' || r.kind === 'spread-many') + .map((r) => (r.kind === 'spread' || r.kind === 'spread-many' ? r.row.name : '')), + ).toEqual(['accountId', 'retry', 'batchSize']); + }); + + it('opens a name the calls disagreed on into its distinct values', () => { + const rows = toSpreadRows(aggregate, openAll); + + const values = rows.filter((r) => r.kind === 'spread-value' && r.id.startsWith('local/retry/')); + expect(values.map((r) => [r.id, r.kind === 'spread-value' && r.held.calls])).toEqual([ + ['local/retry/0', 328], + ['local/retry/1', 12], + ]); + // Every value is a way into a call, never a further object to open. + expect(values.every((r) => !r.expandable)).toBe(true); + }); + + // A constant reads exactly as it does for a single frame. + it('shows a name every call agreed on as its one value, with nothing to open', () => { + const rows = toSpreadRows(aggregate, openAll); + + const held = rows.find((r) => r.id === 'local/batchSize'); + expect(held?.expandable).toBe(false); + expect(held?.kind === 'spread' && held.raw).toBe('200'); + }); + + it('names the class and the objects the calls ran on', () => { + const rows = toSpreadRows({ ...aggregate, objects: 4 }, closed); + + const group = rows.find((r) => r.kind === 'group' && r.id === 'this'); + expect(group?.kind === 'group' && group.of).toBe('ns.Svc, 4 objects'); + }); + + it('names the class alone where every call ran on one object', () => { + const group = toSpreadRows(aggregate, closed).find( + (r) => r.kind === 'group' && r.id === 'this', + ); + + expect(group?.kind === 'group' && group.of).toBe('ns.Svc'); + }); + + // The plan deferred this while a value drilled to a call; the panel now stays + // on the comparison, so a value has to open where it stands. + it('opens a value the log serialised in place, with no lookups at all', () => { + const rows = toSpreadRows( + { ...aggregate, locals: [spread('held', [['{"a":1,"b":2}', 340]])] }, + openAll, + ); + + expect(rows.find((r) => r.id === 'local/held')?.expandable).toBe(true); + expect(rows.filter((r) => r.kind === 'entry').map((r) => r.id)).toEqual([ + 'local/held/0', + 'local/held/1', + ]); + }); + + it('opens one of many values into its own properties', () => { + const rows = toSpreadRows( + { + ...aggregate, + locals: [ + spread('held', [ + ['{"a":1}', 200], + ['{"b":2}', 140], + ]), + ], + }, + openAll, + ); + + expect(rows.find((r) => r.id === 'local/held/0')?.expandable).toBe(true); + expect(rows.map((r) => r.id)).toContain('local/held/0/0'); + }); + + // Each call read at its own point, so a value resolves against the first call + // that held it. + it("reads an address as the object the value's own call recorded", () => { + const held = spread('ref', [['0xabc', 340]]); + held.values[0]!.address = '0xabc'; + held.values[0]!.cut = 42; + const asked: number[] = []; + + const rows = toSpreadRows({ ...aggregate, locals: [held] }, openAll, (cut) => { + asked.push(cut); + return { + resolve: (address) => + address === '0xabc' && cut === 42 ? { text: '{"n":1}', laterAt: null } : NOT_RECORDED, + }; + }); + + const row = rows.find((r) => r.id === 'local/ref'); + expect(row?.kind === 'spread' && row.raw).toBe('{"n":1}'); + expect(row?.expandable).toBe(true); + expect(asked).toContain(42); + }); + + // The log names the object beside a serialised value as well as in place of + // one, and the fields it recorded elsewhere are what opens it. + it("opens a serialised value into the index's fields for its object", () => { + const held = spread('opts', [['{"name":"A"}', 340]]); + held.values[0]!.objectAddress = '0xf00'; + held.values[0]!.cut = 7; + + const rows = toSpreadRows({ ...aggregate, locals: [held] }, openAll, (cut) => ({ + fields: (address) => (address === '0xf00' && cut === 7 ? [row('tries', '3')] : []), + classOf: (address) => (address === '0xf00' ? 'ns.Options' : null), + })); + + const opts = rows.find((r) => r.id === 'local/opts'); + expect(opts?.expandable).toBe(true); + expect(opts?.kind === 'spread' && opts.className).toBe('ns.Options'); + expect(rows.some((r) => r.id === 'local/opts/tries')).toBe(true); + }); + + it('says the calls hold no locals rather than showing an empty group', () => { + const rows = toSpreadRows({ ...aggregate, locals: [] }, closed); + + expect(rows.find((r) => r.id === 'local/none')?.kind).toBe('note'); + }); + + it('leaves out the this group where no call wrote a field', () => { + const rows = toSpreadRows({ ...aggregate, fields: [] }, closed); + + expect(rows.some((r) => r.id === 'this')).toBe(false); + }); +}); diff --git a/log-viewer/src/components/detailSections.ts b/log-viewer/src/components/detailSections.ts index e41056579..feb042397 100644 --- a/log-viewer/src/components/detailSections.ts +++ b/log-viewer/src/components/detailSections.ts @@ -190,6 +190,9 @@ export async function buildDetailSections( ? selection : null; const instances = shown?.instances ?? null; + // The frames the row is, which is the scope Variables compares: a bottom-up + // caller row counts its callee's calls, so its locals live a level up. + const scopeFrames = shown?.frames ?? null; const calledBy = shown?.calledBy ?? ''; const sections: PaneSection[] = [ @@ -211,7 +214,7 @@ export async function buildDetailSections( fit: 'content', content: html``, }, ]; diff --git a/log-viewer/src/components/locatedRow.ts b/log-viewer/src/components/locatedRow.ts index e9c3ea955..e0e376ba8 100644 --- a/log-viewer/src/components/locatedRow.ts +++ b/log-viewer/src/components/locatedRow.ts @@ -396,10 +396,12 @@ export function rowFrames( * counts, a Time Order row the one call it is, and no row nothing. * * @param root - the log the row was built from, which its path id belongs to + * @param direction - the way the row's own table reads the tree */ export function rowDetailSelection( row: RowComponent | undefined, root: ApexLog | null, + direction: SelectionView, ): DetailSelection | null { if (!row) { return null; @@ -419,6 +421,7 @@ export function rowDetailSelection( return { kind: 'aggregate', instances: rowOccurrences(row, root), + frames: rowFrames(row, root, direction), calledBy: data.instances?.length ? undefined : data.text, }; } diff --git a/log-viewer/src/components/variableTree.ts b/log-viewer/src/components/variableTree.ts index d2b109e20..ced5d1990 100644 --- a/log-viewer/src/components/variableTree.ts +++ b/log-viewer/src/components/variableTree.ts @@ -1,6 +1,11 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import type { + AggregateVariables, + SpreadValue, + VariableSpread, +} from '../core/log/aggregateVariables.js'; import { NOT_RECORDED, type FrameVariables, @@ -97,15 +102,19 @@ interface GroupHead { self?: GroupSelf | null; } -export type VariableTreeRow = Common & - ( - | { kind: 'group'; name: string; of: string | null; count: number; self: GroupSelf | null } - | { kind: 'class'; className: string; count: number } - | ({ kind: 'variable'; row: VariableRow } & Shown) - | ({ kind: 'entry'; key: string | null } & Shown) - | { kind: 'text'; raw: string } - | { kind: 'note'; text: string } - ); +/** What a row is, before the place and disclosure state it is pushed with. */ +export type RowBody = + | { kind: 'group'; name: string; of: string | null; count: number; self: GroupSelf | null } + | { kind: 'class'; className: string; count: number } + | ({ kind: 'variable'; row: VariableRow } & Shown) + | ({ kind: 'entry'; key: string | null } & Shown) + | ({ kind: 'spread'; row: VariableSpread } & Shown) + | { kind: 'spread-many'; row: VariableSpread } + | ({ kind: 'spread-value'; held: SpreadValue; of: number } & Shown) + | { kind: 'text'; raw: string } + | { kind: 'note'; text: string }; + +export type VariableTreeRow = Common & RowBody; /** The value a row shows: an address resolves to the object it names. * @@ -189,37 +198,102 @@ function assembledOf(parts: readonly Part[], value: VariableValue): VariableValu ); } +/** A row that shows a value, before the disclosure state it is pushed with. */ +type ValueRow = + | { kind: 'variable'; row: VariableRow } + | { kind: 'entry'; key: string | null } + | { kind: 'spread'; row: VariableSpread } + | { kind: 'spread-value'; held: SpreadValue; of: number }; + /** - * Every row the section shows, in order, given which ids are open. + * The half both builders share: what a value contributes once it is open. * - * `isOpen` decides a group's default too, so the caller owns the policy: Local - * opens, the rest do not. + * One emitter, so a value opens the same way at either scope. A spread's value + * is an object as much as a frame's local is, and the reader who can open one + * expects to open the other. + * + * `lookups` rides on each call rather than being captured: a frame reads every + * value at one point in the log, where a comparison reads each value at the + * point the first call to hold it stood. */ -export function toTreeRows( - frame: FrameVariables, - isOpen: (id: string, openByDefault: boolean) => boolean, - lookups: Lookups = {}, -): VariableTreeRow[] { +function rowEmitter(isOpen: (id: string, openByDefault: boolean) => boolean) { const rows: VariableTreeRow[] = []; const note = (id: string, depth: number, text: string): void => { rows.push({ kind: 'note', id, depth, expandable: false, open: false, text }); }; + /** + * A row that holds others, opened where it may be and the reader wants it. + * The one place disclosure is decided, so every kind of row obeys one rule. + */ + function node( + of: RowBody, + id: string, + depth: number, + expandable: boolean, + kids?: (depth: number) => void, + openByDefault = false, + ): void { + const open = expandable && isOpen(id, openByDefault); + rows.push({ ...of, id, depth, expandable, open }); + if (open) { + kids?.(depth + 1); + } + } + + const group = (head: GroupHead, kids: (depth: number) => void): void => { + const { + id, + name, + count, + expandable = true, + openByDefault = false, + of = null, + self = null, + } = head; + node({ kind: 'group', name, count, of, self }, id, 0, expandable, kids, openByDefault); + }; + + /** + * One rule for what may open: the value holds parts, or its text is too long + * to read in a row. An object already open above this row would be a cycle, + * and `MAX_DEPTH` stops a long chain. + */ + function opens(depth: number, held: Shown, seen: ReadonlySet): boolean { + const cycle = held.objectAddress !== null && seen.has(held.objectAddress); + return !cycle && depth < MAX_DEPTH && (held.parts.length > 0 || isExpandable(held.value)); + } + + /** A row that shows a value, and everything inside it while it is open. */ + function value( + of: ValueRow, + id: string, + depth: number, + held: Shown, + seen: ReadonlySet, + lookups: Lookups, + ): void { + node({ ...of, ...held }, id, depth, opens(depth, held, seen), (inside) => + children(id, inside, held, withAddress(seen, held.objectAddress), lookups), + ); + } + /** The rows an open value contributes: the parts it holds, or its raw text. */ function children( parentId: string, depth: number, holder: Shown, seen: ReadonlySet, + lookups: Lookups, ): void { - const { value, raw, parts } = holder; + const { value: held, raw, parts } = holder; if (parts.length) { let repeats = 0; const keys = new Set(); for (const part of parts) { if ('field' in part) { - variable(parentId, depth, part.field, seen); + variable(parentId, depth, part.field, seen, lookups); continue; } const { entry, at } = part; @@ -231,10 +305,14 @@ export function toTreeRows( } } const id = `${parentId}/${at}`; - const held = shown(entry.text, entry.address, entry.address, lookups); - if (pushValue({ kind: 'entry', key: entry.key }, id, depth, held, seen)) { - children(id, depth + 1, held, withAddress(seen, held.objectAddress)); - } + value( + { kind: 'entry', key: entry.key }, + id, + depth, + shown(entry.text, entry.address, entry.address, lookups), + seen, + lookups, + ); } if (repeats) { note( @@ -243,7 +321,7 @@ export function toTreeRows( `${repeats} keys repeat, kept in the order the log wrote them.`, ); } - if (value.kind === 'container' && value.truncated) { + if (held.kind === 'container' && held.truncated) { note(`${parentId}/cut`, depth, 'The log cut this collection short.'); } return; @@ -262,58 +340,50 @@ export function toTreeRows( } } - function variables( + function variable( parentId: string, depth: number, - of: readonly VariableRow[], + row: VariableRow, seen: ReadonlySet, + lookups: Lookups, ): void { - for (const row of of) { - variable(parentId, depth, row, seen); - } + value( + { kind: 'variable', row }, + `${parentId}/${row.name}`, + depth, + shownValue(row, lookups), + seen, + lookups, + ); } - function variable( + function variables( parentId: string, depth: number, - row: VariableRow, + of: readonly VariableRow[], seen: ReadonlySet, + lookups: Lookups, ): void { - const id = `${parentId}/${row.name}`; - const held = shownValue(row, lookups); - if (pushValue({ kind: 'variable', row }, id, depth, held, seen)) { - children(id, depth + 1, held, withAddress(seen, held.objectAddress)); + for (const row of of) { + variable(parentId, depth, row, seen, lookups); } } - /** Pushes a row that shows a value, and says whether its children follow. - * - * One rule for what may open: the value holds parts, or its text is too long - * to read in a row. An object already open above this row would be a cycle, - * and `MAX_DEPTH` stops a long chain. */ - function pushValue( - of: { kind: 'variable'; row: VariableRow } | { kind: 'entry'; key: string | null }, - id: string, - depth: number, - held: Shown, - seen: ReadonlySet, - ): boolean { - const cycle = held.objectAddress !== null && seen.has(held.objectAddress); - const expandable = - !cycle && depth < MAX_DEPTH && (held.parts.length > 0 || isExpandable(held.value)); - const open = expandable && isOpen(id, false); - rows.push({ ...of, id, depth, expandable, open, ...held }); - return open; - } + return { rows, note, group, node, value, variables }; +} - const group = (head: GroupHead, kids: (depth: number) => void): void => { - const { id, expandable = true, openByDefault = false, of = null, self = null } = head; - const open = expandable && isOpen(id, openByDefault); - rows.push({ ...head, kind: 'group', depth: 0, expandable, open, of, self }); - if (open) { - kids(1); - } - }; +/** + * Every row the section shows, in order, given which ids are open. + * + * `isOpen` decides a group's default too, so the caller owns the policy: Local + * opens, the rest do not. + */ +export function toTreeRows( + frame: FrameVariables, + isOpen: (id: string, openByDefault: boolean) => boolean, + lookups: Lookups = {}, +): VariableTreeRow[] { + const { rows, note, group, node, variables } = rowEmitter(isOpen); group( { @@ -325,7 +395,7 @@ export function toTreeRows( }, (depth) => { if (frame.locals.length) { - variables('local', depth, frame.locals, new Set()); + variables('local', depth, frame.locals, NOTHING_OPEN, lookups); } else { note('local/none', depth, 'The log records no locals for this frame.'); } @@ -347,7 +417,13 @@ export function toTreeRows( }, // The frame's own object, so a field pointing back at it cannot reopen it. (depth) => - variables('this', depth, frame.fields, withAddress(new Set(), self?.objectAddress ?? null)), + variables( + 'this', + depth, + frame.fields, + withAddress(NOTHING_OPEN, self?.objectAddress ?? null), + lookups, + ), ); } @@ -355,22 +431,16 @@ export function toTreeRows( const total = frame.statics.reduce((sum, entry) => sum + entry.rows.length, 0); // Statics nest one level by class: every static the log names is // class-qualified, and a log holds thousands of them. - group({ id: 'static', name: 'Static', count: total }, () => { + group({ id: 'static', name: 'Static', count: total }, (depth) => { for (const entry of frame.statics) { - const id = `static/${entry.className}`; - const open = isOpen(id, false); - rows.push({ - kind: 'class', - id, - depth: 1, - expandable: true, - open, - className: entry.className, - count: entry.rows.length, - }); - if (open) { - variables(id, 2, entry.rows, new Set()); - } + node( + { kind: 'class', className: entry.className, count: entry.rows.length }, + `static/${entry.className}`, + depth, + true, + (inside) => + variables(`static/${entry.className}`, inside, entry.rows, NOTHING_OPEN, lookups), + ); } }); } @@ -378,6 +448,111 @@ export function toTreeRows( return rows; } +/** + * Every row a merged row's comparison shows, in order, given which ids are open. + * + * The same row model and the same emitter as {@link toTreeRows}, so one + * renderer and one keyboard tree serve both scopes, and a value that is an + * object opens into its fields here as it does for one frame. + * + * @param lookupsAt - the log bound to the point a value's first call read at. + * Each call read at its own point, so an object is shown as that one call + * recorded it; the section says so. + */ +export function toSpreadRows( + aggregate: AggregateVariables, + isOpen: (id: string, openByDefault: boolean) => boolean, + lookupsAt: (cut: number) => Lookups = () => ({}), +): VariableTreeRow[] { + const { rows, note, group, node, value } = rowEmitter(isOpen); + + function spread(parentId: string, depth: number, row: VariableSpread): void { + const id = `${parentId}/${row.name}`; + // One value every call held, so the row *is* that value: it reads and opens + // exactly as a single frame's row does, through the same emitter. + const only = row.values.length === 1 ? row.values[0] : null; + if (only) { + value( + { kind: 'spread', row }, + id, + depth, + shownOf(only, lookupsAt), + NOTHING_OPEN, + lookupsAt(only.cut), + ); + return; + } + // The calls disagreed, so the row holds their values rather than being one. + node({ kind: 'spread-many', row }, id, depth, row.values.length > 0, (inside) => { + row.values.forEach((entry, at) => { + value( + { kind: 'spread-value', held: entry, of: row.calls }, + `${id}/${at}`, + inside, + shownOf(entry, lookupsAt), + NOTHING_OPEN, + lookupsAt(entry.cut), + ); + }); + }); + } + + group( + { id: 'local', name: 'Local', count: aggregate.locals.length, openByDefault: true }, + (depth) => { + if (!aggregate.locals.length) { + note('local/none', depth, 'The log records no locals for these calls.'); + return; + } + for (const row of aggregate.locals) { + spread('local', depth, row); + } + }, + ); + + if (aggregate.fields.length) { + group( + { + id: 'this', + name: 'this', + // The calls need not have run on one object, and which they ran on is + // part of the reading, so the head says it rather than implying one. + of: objectsLabel(aggregate), + count: aggregate.fields.length, + }, + (depth) => { + for (const row of aggregate.fields) { + spread('this', depth, row); + } + }, + ); + } + + return rows; +} + +/** One compared value as shown, read at the point its first call stood. + * + * Only a value the log wrote as an address asks the log anything, so a scalar + * never builds a view it would not read. */ +function shownOf(value: SpreadValue, lookupsAt: (cut: number) => Lookups): Shown { + // The object the value is, which is what opens it - the log names it beside a + // serialised value as well as in place of one. + const object = value.address ?? value.objectAddress; + const lookups = object ? lookupsAt(value.cut) : {}; + return shown(value.text, value.address, object, lookups); +} + +/** Whose class the fields belong to, and how many objects held them. */ +function objectsLabel(aggregate: AggregateVariables): string | null { + const objects = aggregate.objects > 1 ? `${aggregate.objects} objects` : null; + return [aggregate.thisType, objects].filter(Boolean).join(', ') || null; +} + +/** Nothing open above a row, shared so a rebuild allocates none. `withAddress` + * never mutates what it is given. */ +const NOTHING_OPEN: ReadonlySet = new Set(); + /** The addresses open above a row, so the same object cannot open inside itself. */ function withAddress(seen: ReadonlySet, address: string | null): ReadonlySet { return address ? new Set([...seen, address]) : seen; diff --git a/log-viewer/src/core/events/EventBus.ts b/log-viewer/src/core/events/EventBus.ts index 5698b7a96..381b40e69 100644 --- a/log-viewer/src/core/events/EventBus.ts +++ b/log-viewer/src/core/events/EventBus.ts @@ -32,6 +32,10 @@ export type DetailSelection = | { kind: 'aggregate'; instances: number[]; + /** The frames the row *is*, which is the scope it holds: a bottom-up + * caller row counts its callee's calls, so its own scope lives a level + * up. Equal to {@link instances} where the row sits at its calls' depth. */ + frames: number[]; /** The frame that made the calls, where the row naming them is not it: a * bottom-up caller row counts its callee's calls. Absent where the row * names the calls it counts. */ diff --git a/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts b/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts new file mode 100644 index 000000000..b1abb9c32 --- /dev/null +++ b/log-viewer/src/core/log/__tests__/aggregateVariables.test.ts @@ -0,0 +1,263 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it, jest } from '@jest/globals'; +import { type ApexLog, parse } from 'apex-log-parser'; + +import { + aggregateVariablesFor, + cachedAggregateVariables, + MAX_VALUES_PER_NAME, +} from '../aggregateVariables.js'; +import { variableIndexFor } from '../frameVariables.js'; +import { logStoreFor, type LogStore } from '../LogStore.js'; + +const SETTINGS = '64.0 APEX_CODE,FINEST;APEX_PROFILING,NONE;DB,NONE\n'; + +/** Resolves at once, so a test measures the walk rather than the frames it + * would leave to the next paint. */ +const yieldSlice = (): Promise => Promise.resolve(); + +function storeOf(body: string): { log: ApexLog; store: LogStore } { + const log = parse( + SETTINGS + + '09:18:22.6 (100)|EXECUTION_STARTED\n' + + '09:18:22.6 (200)|CODE_UNIT_STARTED|[EXTERNAL]|066d0000002m8ij|apex://pkg.Entry\n' + + body + + '09:18:22.6 (900000)|CODE_UNIT_FINISHED|apex://pkg.Entry\n' + + '09:18:22.6 (901000)|EXECUTION_FINISHED\n', + ); + return { log, store: logStoreFor(log) }; +} + +/** Every frame whose log text is `text`, in log order. Frames only: a + * METHOD_EXIT carries the same text as the entry it closes. */ +function indexesOf(log: ApexLog, text: string): number[] { + return log.eventsById + .filter((event) => event.isParent && event.text === text) + .map((event) => event.eventIndex); +} + +/** One call of `ns.Svc.run()` writing `retry` and `accountId`. */ +function call(at: number, retry: string, accountId: string): string { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_SCOPE_BEGIN|[2]|retry|Boolean|true|false\n` + + `${t(20)}|VARIABLE_ASSIGNMENT|[2]|retry|${retry}\n` + + `${t(30)}|VARIABLE_SCOPE_BEGIN|[3]|accountId|Id|true|false\n` + + `${t(40)}|VARIABLE_ASSIGNMENT|[3]|accountId|"${accountId}"\n` + + `${t(50)}|VARIABLE_ASSIGNMENT|[4]|batchSize|200\n` + + `${t(60)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); +} + +const CALLS = + call(1000, 'true', '001A') + call(2000, 'false', '001B') + call(3000, 'false', '001C'); + +/** The comparison of every `ns.Svc.run()` call, with the statics index built. */ +async function compare(body: string, text = 'ns.Svc.run()') { + const { log, store } = storeOf(body); + const index = await variableIndexFor(log, { yieldSlice }); + const frames = indexesOf(log, text); + const spread = await aggregateVariablesFor(store, frames, index, { yieldSlice }); + return { log, store, index, frames, spread }; +} + +describe('aggregateVariablesFor', () => { + it('reads every call and groups its locals by name', async () => { + const { spread } = await compare(CALLS); + + expect(spread?.locals.map((row) => [row.name, row.calls])).toEqual([ + ['accountId', 3], + ['retry', 3], + ['batchSize', 3], + ]); + }); + + it('counts the calls that held each distinct value, most calls first', async () => { + const { spread } = await compare(CALLS); + + const retry = spread?.locals.find((row) => row.name === 'retry'); + expect(retry?.values.map((value) => [value.text, value.calls])).toEqual([ + ['false', 2], + ['true', 1], + ]); + expect(retry?.calls).toBe(3); + }); + + // Which input varied is the reading a merged row carries, so the names that + // varied lead; a constant reads as it does for a single frame. + it('leads with the names that varied and trails with the constants', async () => { + const { spread } = await compare(CALLS); + + expect(spread?.locals.map((row) => [row.name, row.values.length])).toEqual([ + ['accountId', 3], + ['retry', 2], + ['batchSize', 1], + ]); + }); + + // The mark is what a value row points at, so it names every call, not one. + it('names the calls that held a value', async () => { + const { log, spread } = await compare(CALLS); + const calls = indexesOf(log, 'ns.Svc.run()'); + + const retry = spread?.locals.find((row) => row.name === 'retry'); + expect(retry?.values.find((value) => value.text === 'false')?.at).toEqual([calls[1], calls[2]]); + expect(retry?.values.find((value) => value.text === 'true')?.at).toEqual([calls[0]]); + }); + + // Counts alone mislead: an unbroken run is a state the calls were in, where + // the same count scattered is a value that came and went. + it('counts the runs of consecutive calls that held each value', async () => { + const { spread } = await compare(CALLS); + const back = await compare( + call(1000, 'true', '001A') + call(2000, 'false', '001B') + call(3000, 'true', '001C'), + ); + + expect( + spread?.locals.find((row) => row.name === 'retry')?.values.map((value) => value.runs), + ).toEqual([1, 1]); + // `true` was held by the first and last call, with `false` between them. + expect( + back.spread?.locals.find((row) => row.name === 'retry')?.values.map((value) => value.runs), + ).toEqual([2, 1]); + }); + + it('stops naming calls for the mark past its own cap', async () => { + let body = ''; + for (let at = 0; at < 210; at++) { + body += call(1000 + at * 100, 'true', '001A'); + } + + const { spread } = await compare(body); + + const retry = spread?.locals.find((row) => row.name === 'retry'); + // Every call held it, and the mark holds the first 200 of them. + expect(retry?.values[0]?.calls).toBe(210); + expect(retry?.values[0]?.at).toHaveLength(200); + expect(retry?.values[0]?.runs).toBe(1); + }); + + // In scope at its default, with no value the log recorded. + it('counts the calls that declared a name and never wrote it', async () => { + const declared = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[2]|held|Integer|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[2]|held|7\n' + + '09:18:22.6 (1030)|METHOD_EXIT|[1]|ns.Svc.run()\n' + + '09:18:22.6 (2000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (2010)|VARIABLE_SCOPE_BEGIN|[2]|held|Integer|true|false\n' + + '09:18:22.6 (2030)|METHOD_EXIT|[1]|ns.Svc.run()\n'; + + const { spread } = await compare(declared); + + const held = spread?.locals.find((row) => row.name === 'held'); + expect(held).toMatchObject({ calls: 2, unassigned: 1, declaredType: 'Integer' }); + expect(held?.values.map((value) => value.text)).toEqual(['7']); + }); + + // Every value is listed, since a value per call is the reading that says a + // name is an input; the cap only bounds a pathological selection. + it('stops holding values past the cap and says it did', async () => { + let body = ''; + for (let at = 0; at < MAX_VALUES_PER_NAME + 20; at++) { + body += call(1000 + at * 100, 'true', `001${at}`); + } + + const { spread } = await compare(body); + + const accountId = spread?.locals.find((row) => row.name === 'accountId'); + expect(accountId?.values).toHaveLength(MAX_VALUES_PER_NAME); + expect(accountId?.capped).toBe(true); + expect(accountId?.calls).toBe(MAX_VALUES_PER_NAME + 20); + // Under the cap, so its count is the whole truth. + expect(spread?.locals.find((row) => row.name === 'retry')?.capped).toBe(false); + }); + + // A recursive frame's nested call is its own call with its own values, so + // dropping it the way an outermost-events dedupe would undercounts the spread. + it('reads a nested call of the same frame as a call of its own', async () => { + const recursive = + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1010)|VARIABLE_ASSIGNMENT|[2]|depth|1\n' + + '09:18:22.6 (1020)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[2]|depth|2\n' + + '09:18:22.6 (1040)|METHOD_EXIT|[1]|ns.Svc.run()\n' + + '09:18:22.6 (1050)|METHOD_EXIT|[1]|ns.Svc.run()\n'; + + const { spread } = await compare(recursive); + + const depth = spread?.locals.find((row) => row.name === 'depth'); + expect(depth?.calls).toBe(2); + expect(depth?.values.map((value) => value.text)).toEqual(['1', '2']); + }); + + it('returns null for an abandoned walk, and never memoises it', async () => { + const { log, store } = storeOf(CALLS); + const index = await variableIndexFor(log, { yieldSlice }); + const frames = indexesOf(log, 'ns.Svc.run()'); + // Only a spent slice yields, and only a yield reads the signal. + const clock = jest.spyOn(performance, 'now'); + let time = 0; + clock.mockImplementation(() => (time += 100)); + try { + const spread = await aggregateVariablesFor(store, frames, index, { + yieldSlice, + signal: AbortSignal.abort(), + }); + + expect(spread).toBeNull(); + expect(cachedAggregateVariables(frames)).toBeUndefined(); + } finally { + clock.mockRestore(); + } + }); + + it('answers a walked selection from the memo, so nothing walks twice', async () => { + const { store, index, frames, spread } = await compare(CALLS); + + expect(cachedAggregateVariables(frames)).toBe(spread); + expect(await aggregateVariablesFor(store, frames, index, { yieldSlice })).toBe(spread); + }); +}); + +describe('aggregateVariablesFor on this', () => { + /** One call of `ns.Svc.run()` on the object at `address`. */ + function onObject(at: number, address: string, name: string): string { + const t = (offset: number): string => `09:18:22.6 (${at + offset})`; + return ( + `${t(0)}|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n` + + `${t(10)}|VARIABLE_SCOPE_BEGIN|[2]|this|ns.Svc|true|false\n` + + `${t(20)}|VARIABLE_ASSIGNMENT|[2]|this|{}|${address}\n` + + `${t(30)}|VARIABLE_ASSIGNMENT|[3]|this.name|"${name}"|${address}\n` + + `${t(40)}|METHOD_EXIT|[1]|ns.Svc.run()\n` + ); + } + + it('spreads the fields and counts the objects the calls ran on', async () => { + const { spread } = await compare(onObject(1000, '0xaaa', 'A') + onObject(2000, '0xbbb', 'B')); + + expect(spread?.thisType).toBe('ns.Svc'); + expect(spread?.objects).toBe(2); + expect(spread?.fields.map((row) => [row.name, row.values.length])).toEqual([['name', 2]]); + }); + + it('names no class where the calls did not agree on one', async () => { + const { log, store } = storeOf( + onObject(1000, '0xaaa', 'A') + + '09:18:22.6 (2000)|METHOD_ENTRY|[1]|01p|ns.Other.run()\n' + + '09:18:22.6 (2010)|VARIABLE_ASSIGNMENT|[2]|count|1\n' + + '09:18:22.6 (2020)|METHOD_EXIT|[1]|ns.Other.run()\n', + ); + const index = await variableIndexFor(log, { yieldSlice }); + const frames = [...indexesOf(log, 'ns.Svc.run()'), ...indexesOf(log, 'ns.Other.run()')]; + + const spread = await aggregateVariablesFor(store, frames, index, { yieldSlice }); + + // Both frames were read: the second one's local is the only one that holds it. + expect(spread?.locals.find((row) => row.name === 'count')?.calls).toBe(1); + expect(spread?.thisType).toBeNull(); + }); +}); diff --git a/log-viewer/src/core/log/__tests__/frameVariables.test.ts b/log-viewer/src/core/log/__tests__/frameVariables.test.ts index 837afff7c..5046f7d59 100644 --- a/log-viewer/src/core/log/__tests__/frameVariables.test.ts +++ b/log-viewer/src/core/log/__tests__/frameVariables.test.ts @@ -173,6 +173,42 @@ describe('frameVariablesFor', () => { expect(frameVariablesFor(store, 99_999, null)).toBeNull(); }); + + // A frame asked about once is scanned straight from its children, bounded at + // the cut; a frame asked about twice is read into a memo. Both must answer the + // same, or looking at a row a second time would change it. + it('answers the same however many times a frame has been read', () => { + const { log, store } = storeOf( + '09:18:22.6 (1000)|METHOD_ENTRY|[1]|01p|ns.Svc.run()\n' + + '09:18:22.6 (1010)|VARIABLE_SCOPE_BEGIN|[2]|tries|Integer|true|false\n' + + '09:18:22.6 (1020)|VARIABLE_ASSIGNMENT|[2]|tries|1\n' + + '09:18:22.6 (1030)|VARIABLE_ASSIGNMENT|[3]|this.name|"A"|0xaaa\n' + + '09:18:22.6 (1040)|VARIABLE_ASSIGNMENT|[2]|tries|2\n' + + '09:18:22.6 (1050)|METHOD_EXIT|[1]|ns.Svc.run()\n', + ); + const at = (text: string): number => { + const found = log.eventsById.find((event) => event.logLine?.includes(text)); + if (!found) { + throw new Error(`no line holding ${text}`); + } + return found.eventIndex; + }; + const read = (eventIndex: number): string => + JSON.stringify(frameVariablesFor(store, eventIndex, null)); + + const early = at('this.name'); + const first = read(early); + // The whole frame, which is what earns the memo. + read(at('ns.Svc.run()')); + const again = read(early); + + // As it stood at that line: the first write, and the field beside it. + expect(JSON.parse(first).locals).toEqual([ + expect.objectContaining({ name: 'tries', value: '1' }), + ]); + expect(JSON.parse(first).fields).toEqual([expect.objectContaining({ name: 'name' })]); + expect(again).toBe(first); + }); }); describe('VariableIndex', () => { @@ -186,6 +222,20 @@ describe('VariableIndex', () => { '09:18:22.6 (1400)|METHOD_EXIT|[5]|ns.Inner.step()\n' + '09:18:22.6 (1700)|METHOD_EXIT|[1]|ns.Outer.run()\n'; + // Reading them walks every static class the log holds, which is the bulk of a + // read; a caller that does not compare statics must not pay for it. + it('leaves the statics unread where the caller asked it to', async () => { + const { log, store } = storeOf(STATICS); + const statics = await variableIndexFor(log); + const at = indexOf(log, 'ns.Outer.run()'); + + const frame = frameVariablesFor(store, at, statics, { statics: false }); + + expect(frame?.statics).toEqual([]); + // Everything else the frame holds is untouched. + expect(frame?.locals).toEqual(frameVariablesFor(store, at, statics)?.locals); + }); + it('groups statics by their class, both sorted', async () => { const { log, store } = storeOf(STATICS); const statics = await variableIndexFor(log); diff --git a/log-viewer/src/core/log/aggregateVariables.ts b/log-viewer/src/core/log/aggregateVariables.ts new file mode 100644 index 000000000..6ef35dc98 --- /dev/null +++ b/log-viewer/src/core/log/aggregateVariables.ts @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { frameBudget, type FrameBudgetOptions } from '../utility/FrameBudget.js'; +import { frameVariablesFor, type VariableIndex, type VariableRow } from './frameVariables.js'; +import type { LogStore } from './LogStore.js'; + +/** + * What a merged row's calls held, compared across them. + * + * A merged row has no single frame, so it is answered by the spread rather than + * by one reading: 340 calls with 340 different `accountId` values and one + * `batchSize` of 200 says which input varied, which is the verdict the grids + * beside it do not carry. + * + * Statics are left out. A static lives for the whole transaction, so it moves + * for reasons the row does not own, and comparing it would report the log's + * history as this row's spread. + */ + +/** + * Distinct values held per name before the rest go uncounted. + * + * This is an interrogation tool, so every value a name held is listed: a name + * with one value per call is exactly the reading that says it is an input. The + * cap is only a bound on a pathological selection, since the section renders + * every row it lists. Past it the screen says some are not listed, never a + * distinct count the walk did not finish. + */ +export const MAX_VALUES_PER_NAME = 1_000; + +/** + * Calls held per value, for the mark. + * + * A mark on more frames than this shows the reader nothing further, and the + * lists partition the calls, so this only stops one value of a two-valued name + * from holding the whole selection. + */ +export const MAX_MARKED_PER_VALUE = 200; + +/** What a compared frame is read with, allocated once: a wide selection reads + * tens of thousands of frames. Statics are not compared, and the rows are + * grouped by name here, so the per-frame sort is thrown away. */ +const AS_READ = { statics: false, sorted: false } as const; + +/** One value a name held, and which calls held it. */ +export interface SpreadValue { + /** The log's own text, as it wrote it. */ + text: string; + address: string | null; + /** The address of the object the value *is*, where the log named one beside + * the text. What opens the value into the fields the index recorded for it. */ + objectAddress: string | null; + /** How many of the read calls held it. */ + calls: number; + /** The calls that held it, in the order the selection lists them, for the + * mark. Short of {@link calls} once {@link MAX_MARKED_PER_VALUE} bites. */ + at: number[]; + /** Runs of consecutive calls that held it. One run is a phase the calls + * passed through; more is a value that came and went. */ + runs: number; + /** The point the first call that held it read at, so an address can be + * resolved and an object opened. Each call read at its own point, so this is + * the one call the object is shown as. */ + cut: number; +} + +/** One name, across the calls the row counts. */ +export interface VariableSpread { + name: string; + declaredType: string | null; + /** Distinct values, most calls first. */ + values: SpreadValue[]; + /** Calls that had the name in scope. */ + calls: number; + /** Calls that declared it and never wrote it. */ + unassigned: number; + /** More distinct values than the cap holds, so `values` is not all of them. */ + capped: boolean; +} + +export interface AggregateVariables { + locals: VariableSpread[]; + /** The class owning the fields, where every call agreed on one. */ + thisType: string | null; + /** Distinct objects the calls ran on. */ + objects: number; + fields: VariableSpread[]; + /** A frame read ran past the end of a truncated log, so a missing write may be + * unrecorded rather than absent. */ + truncated: boolean; + /** Some name held more distinct values than the cap, so a spread is not all of + * what the calls held. Read here rather than rescanned per render. */ + capped: boolean; +} + +/** One value while it is still being gathered, beside where its last call sat + * so a break in the run can be seen. */ +interface GatheredValue { + value: SpreadValue; + /** Which call held it last, as an ordinal into the calls read. */ + previous: number; +} + +/** A spread while it is still being gathered: the row it will ship, with the + * values keyed by identity until they can be ordered. */ +interface Gathering { + row: VariableSpread; + byValue: Map; +} + +/** + * What the calls `frames` names held, compared across them. + * + * Returns null when the walk is abandoned (see {@link FrameBudgetOptions}). + */ +async function compareFrames( + store: LogStore, + frames: readonly number[], + index: VariableIndex | null, + options: FrameBudgetOptions, +): Promise { + const tick = frameBudget(options); + const locals = new Map(); + const fields = new Map(); + const objects = new Set(); + const classes = new Set(); + let read = 0; + let truncated = false; + let capped = false; + + // Every frame, with no `outermostEvents` dedupe โ€” deliberately, and unlike + // every other multi-frame read here. A recursive frame's nested call is its + // own call with its own values, so dropping it would undercount the spread. + for (const eventIndex of frames) { + // Per frame rather than every CHECK_EVERY: one read scans a whole frame's + // lines, so 256 of them would overrun the slice many times over, and asking + // the clock once per read costs nothing beside it. + if (!(await tick())) { + return null; + } + const frame = frameVariablesFor(store, eventIndex, index, AS_READ); + if (!frame) { + continue; + } + truncated ||= frame.truncated; + for (const row of frame.locals) { + hold(locals, row, eventIndex, read, frame.cut); + } + for (const row of frame.fields) { + hold(fields, row, eventIndex, read, frame.cut); + } + read++; + if (frame.thisType) { + classes.add(frame.thisType); + } + if (frame.thisRow?.objectAddress) { + objects.add(frame.thisRow.objectAddress); + } + } + + for (const held of [...locals.values(), ...fields.values()]) { + capped ||= held.row.capped; + } + return { + locals: spreadsOf(locals), + // Only where every call agreed: two classes under one row means the reading + // is not one class's. + thisType: classes.size === 1 ? [...classes][0]! : null, + objects: objects.size, + fields: spreadsOf(fields), + truncated, + capped, + }; +} + +/** + * Adds one call's reading of a name to what the walk holds for it. + * + * @param at - the call's eventIndex, for the mark + * @param ordinal - which call it is among those read, so a value's runs can be + * counted without holding the whole sequence + * @param cut - the point that call read at, kept for the first call to hold a + * value so its object can be opened + */ +function hold( + into: Map, + row: VariableRow, + at: number, + ordinal: number, + cut: number, +): void { + let held = into.get(row.name); + if (!held) { + held = { + row: { + name: row.name, + declaredType: null, + values: [], + calls: 0, + unassigned: 0, + capped: false, + }, + byValue: new Map(), + }; + into.set(row.name, held); + } + const spread = held.row; + spread.calls++; + // The first call to declare it names the type; a later one repeats it. + spread.declaredType ??= row.declaredType; + if (!row.assigned) { + // In scope at its default, with no value the log recorded. + spread.unassigned++; + return; + } + // The log's own text is the identity: two calls that named the same address + // held the same object, and two that wrote the same text held the same value. + const identity = row.value || row.address || ''; + const seen = held.byValue.get(identity); + if (seen) { + const value = seen.value; + value.calls++; + // A call that does not follow the last one starts a run of its own. + if (ordinal !== seen.previous + 1) { + value.runs++; + } + seen.previous = ordinal; + if (value.at.length < MAX_MARKED_PER_VALUE) { + value.at.push(at); + } + } else if (held.byValue.size < MAX_VALUES_PER_NAME) { + held.byValue.set(identity, { + value: { + text: row.value, + address: row.address, + objectAddress: row.objectAddress, + calls: 1, + at: [at], + runs: 1, + cut, + }, + previous: ordinal, + }); + } else { + spread.capped = true; + } +} + +/** + * The gathered names as rows, in the order they read. + * + * The names that varied lead, most distinct values first, since they are what + * explains the spread; then the constants, then the names every call declared + * and never wrote. + */ +function spreadsOf(held: ReadonlyMap): VariableSpread[] { + const spreads: VariableSpread[] = []; + for (const { row, byValue } of held.values()) { + for (const { value } of byValue.values()) { + row.values.push(value); + } + row.values.sort((left, right) => right.calls - left.calls); + spreads.push(row); + } + return spreads.sort( + (left, right) => + right.values.length - left.values.length || left.name.localeCompare(right.name), + ); +} + +/** Memo of the walk: the log never changes after parse, so each row's frames are + * compared once. Keyed by the frames array, which stays the same object while + * the selection does. */ +const compared = new WeakMap(); + +/** The memoised comparison for `frames`, or undefined if it has never been + * walked. Lets a caller render an already-walked selection without showing a + * placeholder first. */ +export function cachedAggregateVariables(frames: object): AggregateVariables | undefined { + return compared.get(frames); +} + +/** + * {@link compareFrames} memoised on the `frames` array's identity. An abandoned + * walk is not memoised. + */ +export async function aggregateVariablesFor( + store: LogStore, + frames: readonly number[], + index: VariableIndex | null, + options: FrameBudgetOptions, +): Promise { + const held = compared.get(frames); + if (held) { + return held; + } + const spread = await compareFrames(store, frames, index, options); + if (spread) { + compared.set(frames, spread); + } + return spread; +} diff --git a/log-viewer/src/core/log/frameVariables.ts b/log-viewer/src/core/log/frameVariables.ts index 697587456..ff29bba36 100644 --- a/log-viewer/src/core/log/frameVariables.ts +++ b/log-viewer/src/core/log/frameVariables.ts @@ -485,6 +485,18 @@ export interface IndexView { fields(address: string): readonly VariableRow[]; } +/** What a caller wants left out of a read. */ +export interface FrameReadOptions { + /** False to leave the statics unread. {@link VariableIndex.at} walks every + * static class the log holds, which is the bulk of a read, so a caller that + * does not compare statics must not pay for them. */ + statics?: boolean; + /** False to leave the rows in the order they were read. A caller that groups + * every row by name throws the order away, and `localeCompare` over the + * locals of 35,000 calls is 55ms of it. */ + sorted?: boolean; +} + /** * What is in scope at `eventIndex`, or null where the log has no such event or * it sits in no frame. @@ -495,6 +507,7 @@ export function frameVariablesFor( store: LogStore, eventIndex: number, index: VariableIndex | null, + options: FrameReadOptions = {}, ): FrameVariables | null { const selected = store.eventByIndex(eventIndex); const stack = store.stackByEventIndex(eventIndex); @@ -509,7 +522,7 @@ export function frameVariablesFor( const cut = selected.isParent ? lastDescendantIndex(selected) : selected.eventIndex; // Not always the frame the selection sits in: see `scopeFrame`. const { frame: scope, scan: own } = scopeFrame(stack, cut, frame); - const thisType = classFromFrame(scope.text); + const thisType = frameClassOf(scope); const locals: VariableRow[] = []; let thisRow: VariableRow | null = null; @@ -552,16 +565,16 @@ export function frameVariablesFor( } } } - const fields = fieldRowsOf(found); + const fields = fieldRowsOf(found, options.sorted !== false); return { frameLabel: scope.text, cut, thisType, - locals: locals.sort(byName), + locals: options.sorted === false ? locals : locals.sort(byName), thisRow, fields, - statics: index?.at(cut) ?? [], + statics: options.statics === false ? [] : (index?.at(cut) ?? []), truncated: stack.some((entry) => entry.isTruncated) || selected.isTruncated, }; } @@ -605,6 +618,116 @@ interface FrameScan { sawAny: boolean; } +/** One variable line of a frame, with what it names read once. */ +interface FrameLine { + event: LogEvent; + /** The name the line names: the variable written, or the scope declared. Null + * where the line parses none. */ + name: string | null; + /** The local declaration the line made, or null where it wrote a value or + * declared a static, which is in scope everywhere and lives in the index. */ + declared: Declared | null; +} + +/** + * A frame's own variable lines, in eventIndex order, held per frame. + * + * Every read back through a frame parses the same names, and a caller frame is + * read again for every call an aggregate compares: 4,000 calls under one method + * re-parsed its lines 4,000 times. + */ +const frameLines = new WeakMap(); + +/** Which frames have been asked about, for {@link askedBefore}. */ +const linesAsked = new WeakSet(); +const objectsAsked = new WeakSet(); + +/** + * Whether `frame` has been asked about before, recording the ask either way. + * + * What decides whether a memo of the whole frame is worth building. A frame + * asked about once is read for its own sake: only the lines before the cut + * answer, so parsing the rest costs time an early selection in a 500k-child + * frame cannot spare, and holding them is pure retention - 166 bytes a line, + * 83MB on a 500k-line log. A frame asked about twice is one several calls + * escalate to ({@link scopeFrame}), which is what earns the memo. + */ +function askedBefore(asked: WeakSet, frame: LogEvent): boolean { + if (asked.has(frame)) { + return true; + } + asked.add(frame); + return false; +} + +function readFrameLines(frame: LogEvent): FrameLine[] { + const lines: FrameLine[] = []; + for (const child of frame.children) { + if (child.type === ASSIGNMENT) { + lines.push({ event: child, name: variableNameOf(child.logLine), declared: null }); + } else if (child.type === SCOPE_BEGIN) { + const scope = parseVariableScope(child.logLine); + lines.push({ + event: child, + name: scope?.name ?? null, + declared: + scope && !scope.isStatic + ? { declaredType: scope.declaredType, eventIndex: child.eventIndex } + : null, + }); + } + } + return lines; +} + +/** Where a backwards read of `lines` starts to answer at `cut`. */ +function linesBefore(lines: readonly { event: LogEvent }[], cut: number): number { + return firstIndexWhere(lines.length, (at) => lines[at]!.event.eventIndex > cut); +} + +/** One write to the object a frame runs on. */ +interface ObjectWrite { + name: string; + event: LogEvent; +} + +/** + * A frame's writes to its own object, in eventIndex order, held per frame. + * + * Held apart from {@link frameLines}: a caller frame answers only for its + * object, so a frame holding a hundred thousand locals holds a handful of + * these, and going through the line list would hold all hundred thousand to + * answer about two - 1MB a frame, for the log's lifetime. + */ +const objectWrites = new WeakMap(); + +function readObjectWrites(frame: LogEvent): ObjectWrite[] { + const found: ObjectWrite[] = []; + for (const child of frame.children) { + if (child.type !== ASSIGNMENT) { + continue; + } + const name = variableNameOf(child.logLine); + if (name && (name === 'this' || isFieldName(name))) { + found.push({ name, event: child }); + } + } + return found; +} + +/** The class from a frame's own name, held per frame: an aggregate asks it of + * every frame on the stack, for every call it compares. */ +const frameClasses = new WeakMap(); + +function frameClassOf(frame: LogEvent): string | null { + let held = frameClasses.get(frame); + if (held === undefined) { + held = classFromFrame(frame.text); + frameClasses.set(frame, held); + } + return held; +} + /** * Reads one frame's own lines back from `cut`. * @@ -613,21 +736,57 @@ interface FrameScan { * for every line, which matters in a frame holding a hundred thousand of them. */ function scanFrame(frame: LogEvent, cut: number): FrameScan { + const held = frameLines.get(frame); + if (held) { + return scanLines(held, cut); + } + if (!askedBefore(linesAsked, frame)) { + return scanChildren(frame, cut); + } + const lines = readFrameLines(frame); + frameLines.set(frame, lines); + return scanLines(lines, cut); +} + +/** A frame's scope from its held lines. */ +function scanLines(lines: readonly FrameLine[], cut: number): FrameScan { + const writes = new Map(); + const declared = new Map(); + const from = linesBefore(lines, cut); + for (let at = from; at--;) { + const line = lines[at]!; + if (line.event.type === ASSIGNMENT) { + if (line.name && !writes.has(line.name)) { + writes.set(line.name, line.event); + } + } else if (line.declared && line.name) { + // Backwards, so the earliest declaration is the one left standing: the + // point from which the name is in scope. + declared.set(line.name, line.declared); + } + } + // A line of its own before the cut, whatever it said, so the frame owns a + // scope even where every line was a static the index answers for. + return { writes, declared, sawAny: from > 0 }; +} + +/** A frame's scope read straight from its children, parsing only the lines + * before the cut: what a frame asked about once is owed. */ +function scanChildren(frame: LogEvent, cut: number): FrameScan { const writes = new Map(); const declared = new Map(); let sawAny = false; const children = frame.children; for (let at = firstIndexWhere(children.length, (i) => children[i]!.eventIndex > cut); at--;) { const child = children[at]!; - if (child.type === ASSIGNMENT || child.type === SCOPE_BEGIN) { - sawAny = true; - } if (child.type === ASSIGNMENT) { + sawAny = true; const name = variableNameOf(child.logLine); if (name && !writes.has(name)) { writes.set(name, child); } } else if (child.type === SCOPE_BEGIN) { + sawAny = true; const scope = parseVariableScope(child.logLine); // Only a local: a static is in scope everywhere, so the index holds it. if (scope && !scope.isStatic) { @@ -647,40 +806,48 @@ function scanFrame(frame: LogEvent, cut: number): FrameScan { * never read: a frame can hold hundreds of thousands of children, so the rest * is thrown away. */ function thisWritesOf(frame: LogEvent, cut: number): Map { - return lastWritesByName(frame.children, cut, (name) => name === 'this' || isFieldName(name)); + const held = objectWrites.get(frame); + if (!held && !askedBefore(objectsAsked, frame)) { + return lastWritesByName(frame.children, cut); + } + const writes = held ?? readObjectWrites(frame); + if (!held) { + objectWrites.set(frame, writes); + } + const found = new Map(); + for (let at = linesBefore(writes, cut); at--;) { + const { event, name } = writes[at]!; + if (!found.has(name)) { + found.set(name, event); + } + } + return found; } -/** - * The last write to each name at or before `cut`, from events in eventIndex - * order. +/** The last write to each of the object's names at or before `cut`, read + * straight from the children: what a frame asked about once is owed. * - * Backwards from the cut, so the first write seen for a name is the last one - * made. - */ -function lastWritesByName( - events: readonly LogEvent[], - cut: number, - keep: (name: string) => boolean, -): Map { + * Backwards from the cut, so the first write seen for a name is the last made. */ +function lastWritesByName(events: readonly LogEvent[], cut: number): Map { const found = new Map(); for (let at = firstIndexWhere(events.length, (i) => events[i]!.eventIndex > cut); at--;) { const event = events[at]!; const name = event.type === ASSIGNMENT ? variableNameOf(event.logLine) : null; - if (name && keep(name) && !found.has(name)) { + if (name && (name === 'this' || isFieldName(name)) && !found.has(name)) { found.set(name, event); } } return found; } -/** Field writes as their rows, sorted, carrying the `objectAddress` a field row - * must always have: its line reports the owner, never its own value. */ -function fieldRowsOf(writes: ReadonlyMap): VariableRow[] { +/** Field writes as their rows, carrying the `objectAddress` a field row must + * always have: its line reports the owner, never its own value. */ +function fieldRowsOf(writes: ReadonlyMap, sorted = true): VariableRow[] { const rows: VariableRow[] = []; for (const [name, write] of writes) { rows.push(rowFor(shortName(name), write, undefined, null)); } - return rows.sort(byName); + return sorted ? rows.sort(byName) : rows; } /** @@ -738,7 +905,7 @@ function fieldWrites( const found = new Map(); const mine = thisAddressOf(own.writes); for (const entry of stack) { - if (classFromFrame(entry.text) !== thisType) { + if (frameClassOf(entry) !== thisType) { continue; } const writes = entry === frame ? own.writes : thisWritesOf(entry, cut); diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 1332bcc14..58d7b72af 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -639,7 +639,7 @@ export class AnalysisView extends LitElement { } eventBus.emit('detail:select', { source: 'analysis', - selection: rowDetailSelection(rows[0], this.timelineRoot), + selection: rowDetailSelection(rows[0], this.timelineRoot, 'callers'), // The grid ranks methods by self time and expands to their callers, so // the inspector opens on the forward view instead. view: 'callers', diff --git a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts index 94ade21ba..453398113 100644 --- a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts @@ -166,6 +166,7 @@ describe('analysis-view selection', () => { selection: { kind: 'aggregate', instances: rootRow.instances.map((event) => event.eventIndex), + frames: rootRow.instances.map((event) => event.eventIndex), // The root row names the calls it counts, so nothing made them but it. calledBy: undefined, }, @@ -186,15 +187,18 @@ describe('analysis-view selection', () => { expect(derived).toHaveLength(1); expect(seen.map((detail) => detail.selection)).toEqual([ // Both rows hold the same one call, so the row is what tells them apart: - // reached through B, then through the A above B. + // reached through B, then through the A above B. `frames` is the caller + // each row is, a level up from the call its totals count. { kind: 'aggregate', instances: derived.map((event) => event.eventIndex), + frames: [2], calledBy: 'B', }, { kind: 'aggregate', instances: derived.map((event) => event.eventIndex), + frames: [1], calledBy: 'A', }, ]); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index d11b7b11e..53e6b1700 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -1120,7 +1120,7 @@ export class CalltreeView extends LitElement { if (this._echoGuard.suppressed) { return; } - const selection = rowDetailSelection(rows[0], this.rootMethod); + const selection = rowDetailSelection(rows[0], this.rootMethod, directionOf(this.viewMode)); if (!selection) { // The selection went with it, and so does a mark a picked inspector row // left here โ€” it was never a selection of this table. diff --git a/scripts/measure/variables.ts b/scripts/measure/variables.ts index e14fef16b..61178bb5b 100644 --- a/scripts/measure/variables.ts +++ b/scripts/measure/variables.ts @@ -3,10 +3,13 @@ */ /** - * Times the Variables section: one log-wide walk, then a frame snapshot. + * Times the Variables section: one log-wide walk, a frame snapshot, then the + * busiest merged row's comparison. */ -import type { ApexLog } from 'apex-log-parser'; +import type { ApexLog, LogEvent } from 'apex-log-parser'; +import { aggregateVariablesFor } from '../../log-viewer/src/core/log/aggregateVariables.js'; +import { getEventKey } from '../../log-viewer/src/core/log/eventKeys.js'; import { frameVariablesFor, variableIndexFor, @@ -59,11 +62,21 @@ export async function measureVariables(log: ApexLog): Promise { if (!worst) { return; } + line('worst frame', `${worst.children.length.toLocaleString()} children`); + + // Timed before anything else reads the frame, so this is a first ask. Only the + // lines before the cut answer, so selecting the first line inside the frame + // must not cost what reading the whole frame does. + const firstLine = worst.children[0]; + if (firstLine) { + await time('worst frame, first line', () => + frameVariablesFor(store, firstLine.eventIndex, index), + ); + } const shape = frameVariablesFor(store, worst.eventIndex, index); line( - 'worst frame', - `${worst.children.length.toLocaleString()} children, ` + - `${shape?.locals.length ?? 0} locals, ${shape?.fields.length ?? 0} fields`, + 'worst frame scope', + `${shape?.locals.length ?? 0} locals, ${shape?.fields.length ?? 0} fields`, ); await time('worst frame snapshot', () => frameVariablesFor(store, worst.eventIndex, index)); @@ -74,4 +87,49 @@ export async function measureVariables(log: ApexLog): Promise { index.fieldsAt(address, Number.MAX_SAFE_INTEGER); } }); + + // A merged row is answered by comparing its calls, so the worst row there is + // sets what the section costs on a selection the reader will actually make. + const busiest = busiestSignature(frames); + if (!busiest.length) { + return; + } + line('busiest signature', `${busiest[0]!.text} โ€” ${busiest.length.toLocaleString()} calls`); + const compared = busiest.map((frame) => frame.eventIndex); + const spread = await time(`aggregate over ${compared.length.toLocaleString()} calls`, () => + aggregateVariablesFor(store, compared, index, { yieldSlice }), + ); + line( + 'spread', + `${spread?.locals.length ?? 0} locals, ${spread?.fields.length ?? 0} fields, ` + + `${spread?.locals.filter((row) => row.values.length > 1).length ?? 0} varied`, + ); + await time('aggregate (again)', () => + aggregateVariablesFor(store, compared, index, { yieldSlice }), + ); +} + +/** The frames of the signature the log holds most calls of: the merged row whose + * comparison costs the most. + * + * Keyed by {@link getEventKey}, which is what the grids bucket by, so the row + * this times is one a reader can actually select. */ +function busiestSignature(frames: readonly LogEvent[]): LogEvent[] { + const byKey = new Map(); + for (const frame of frames) { + const key = getEventKey(frame); + const held = byKey.get(key); + if (held) { + held.push(frame); + } else { + byKey.set(key, [frame]); + } + } + let busiest: LogEvent[] = []; + for (const held of byKey.values()) { + if (held.length > busiest.length) { + busiest = held; + } + } + return busiest; } From b33496bad5b71de7169909f0460b1d922d592600 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:55:44 +0100 Subject: [PATCH 48/61] feat(log-viewer): arrange and size the inspector's sections (#1022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview The inspector remembered too much, and the wrong things. A section height dragged for a 100MB log was stored and replayed onto the next log, where it was wrong. One drag pinned **every** open section, not the two beside the divider, so automatic sizing never came back โ€” each divider had to be double-clicked to undo it. The section order was fixed in code. There was no way to hide a section you never want, and no way to reset any of it. Sizing is automatic again and never carried between logs. Sections reorder by dragging their headers, the way VS Code's views do. A right-click menu chooses which sections a tab shows โ€” a hidden section builds nothing at all โ€” and one **Reset Sections** row restores the defaults. Two more things came out of it. Stepping from one timeline frame to the next used to resize the whole stack, because sections whose content changes with the selection were sized to that content; those now keep a steady height and scroll inside. And a divider drag now cascades like VS Code's: the sections on the other side give up room in turn, each down to the same minimum, so the divider follows the pointer until they are all there. ## ๐Ÿ› ๏ธ Changes made - **Reorder**: drag a section header, or press `Alt+Up` / `Alt+Down` on a focused one. Works docked left, right and bottom. - **Choose sections**: right-click a header to tick sections on and off, plus a **Reset Sections** row. The last section showing cannot be unticked โ€” hiding it would remove the only header left to right-click. - **Hidden means no work**: a hidden section's body never mounts, and the SOQL lint behind the **SOQL issues** badge is skipped. - **Per tab and per scope**: order, hidden and collapse are keyed `::`. `calltree` means the whole log on the Timeline's summary and one frame's subtree in a detail list, so a choice made in one never reaches the other. - **Sizes are runtime only**: `inspector.paneSizes` is gone. A drag lives as long as the panel is open. - **Steady heights**: **Details** and a selection's **Self time by namespace** take a `--lana-pane-*` tier; **Variables**, **Call stack**, **Call tree** and **Findings** share what is left. Each tier is `clamp(min, share, max)`, so it scales with the panel and depends on nothing the selection changes. - **Cascading drag**: one drag snapshots the whole open stack and hands room out nearest-first, every section down to the same `--lana-pane-min`. - **Sizing lives in CSS**: every `flex` rule is in the stylesheet, reached through `var(--pane-size, )`. The drag emits numbers only, so a dragged size beats each default without a second selector. - **No section takes the panel**: a section sized to its content starts from an equal share and grows back towards its content only as far as the others leave room, scrolling inside past that. Sized to its content first, one long list held every other section at its floor, because flexbox shrinks by basis and the biggest keeps the most. - **Arranged order survives the selection**: the list under one key varies with what is selected - `issues` is built for a SOQL statement and not for a DML one - so a reorder now keeps an id the store knows that this build did not produce, and an id it never knew falls back to where the builder puts it rather than to the end. - **Call tree column view is private**: the view a table was last left in is remembered UI state, not a preference, so `lana.callTree.columnView` joins its three `database.*.columnView` siblings in `globalState` and leaves the settings list. Neither it nor the inspector keys have shipped, so no migration is needed. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [x] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] To follow. ## ๐Ÿ”— Related Issues related #113 ## โœ… Tests added? - [x] ๐Ÿ‘ yes `PaneView` covers the cascade (a drag past one section's floor moves the next, and every open pane holds its measured size for the length of the gesture), the sizing each `fit`/`height`/`weight` combination resolves to on both axes, reorder by drag and by `Alt+Arrow`, and the drop hit-test. `LogInspector` covers the composite keys, the last-visible guard, hiding a section while its build is still running, and that a reset returns the builder's order. Two new pure modules, `inspectorLayout` and `sectionMenu`, are tested directly. ``` pnpm test # 171 suites, 2388 tests pnpm lint ``` Dev host, light and dark: opened a 2MB log then a 100MB one and no layout carried over; dragged a divider past two sections' floors and watched the third give way; stepped through timeline frames with no stack movement; reordered by drag and by keyboard on all three dock positions; unticked a section and confirmed it stayed shown on the other tab; reset. ## ๐Ÿ“š Docs updated? - [x] ๐Ÿ”– CHANGELOG.md - [x] ๐Ÿ“– help site - [x] ๐Ÿ™… not needed The Inspector's Unreleased entry gains the reorder and the section menu. `features/inspector.md` describes the heights and the cascade. `settings.mdx` loses **Default column view**, and `features/calltree.mdx` loses the link to it. ## Anything else we need to know? [optional] Two things left alone on purpose: - The `` in **Details** is unbounded, so a long statement pushes the figures below the fold inside that section. The section no longer moves the stack, but capping the block would put the numbers back in view. - `detailSections.ts` and `databaseSections.ts` define `vitals`, `variables`, `callstack` and `calltree` twice over, with matching ids, titles, sizing and prose. Pre-existing, and wider than this branch; `namespaceTimeSection` is the precedent for a shared factory. --- .claude/rules/lana.md | 3 + CHANGELOG.md | 5 +- lana-docs/docs/docs/features/calltree.mdx | 2 +- lana-docs/docs/docs/features/inspector.md | 6 +- lana-docs/docs/docs/settings.mdx | 4 - lana/package.json | 14 - lana/src/commands/LogView.ts | 1 + lana/src/workspace/AppConfig.ts | 28 +- .../src/workspace/__tests__/AppConfig.test.ts | 15 + log-viewer/src/components/DetailDock.ts | 6 +- log-viewer/src/components/DockLayout.ts | 6 +- log-viewer/src/components/LogInspector.ts | 210 ++++- log-viewer/src/components/PaneView.ts | 724 ++++++++++++---- .../components/__tests__/LogInspector.test.ts | 339 +++++++- .../src/components/__tests__/PaneView.test.ts | 818 ++++++++++++++---- .../__tests__/detailSections.test.ts | 14 +- .../__tests__/inspectorLayout.test.ts | 241 ++++++ .../components/__tests__/sectionMenu.test.ts | 57 ++ log-viewer/src/components/detailSections.ts | 52 +- log-viewer/src/components/inspectorLayout.ts | 161 ++++ log-viewer/src/components/sectionMenu.ts | 42 + .../__tests__/databaseSections.test.ts | 34 +- .../database/components/databaseSections.ts | 22 +- log-viewer/src/features/settings/Settings.ts | 8 +- log-viewer/src/styles/tokens.css | 28 +- 25 files changed, 2367 insertions(+), 473 deletions(-) create mode 100644 log-viewer/src/components/__tests__/inspectorLayout.test.ts create mode 100644 log-viewer/src/components/__tests__/sectionMenu.test.ts create mode 100644 log-viewer/src/components/inspectorLayout.ts create mode 100644 log-viewer/src/components/sectionMenu.ts diff --git a/.claude/rules/lana.md b/.claude/rules/lana.md index 85bae3747..fb2da352a 100644 --- a/.claude/rules/lana.md +++ b/.claude/rules/lana.md @@ -16,6 +16,9 @@ VS Code extension. Applies when working under `lana/`. ## Settings - **Give every user preference a `lana.*` setting.** Do not hide it in `globalState`. +- **Remembered UI state is not a preference.** The view a table was last left in, the inspector's + section layout, column overrides: these persist privately in `globalState` โ€” see + `PRIVATE_SECTIONS` in `AppConfig.ts` โ€” and are not registered settings. - **Push `configChanged` to an open panel when a setting changes.** The panel sets `retainContextWhenHidden`, so it is never re-created and never re-reads the config itself. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8972f17..120c1b456 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- ๐Ÿงญ **Inspector**: select a timeline frame, a table row or a statement to see its details, governor usage, call stack and subtree - or select nothing for a whole-log overview. Dock it left, right or bottom. ([#113] [#373] [#63]) +- ๐Ÿงญ **Inspector**: select a timeline frame, a table row or a statement to see its details, governor usage, call stack and subtree - or select nothing for a whole-log overview. Dock it left, right or bottom, and each tab keeps its own arrangement. ([#113] [#373] [#63]) + - **Reorder**: drag a section header, or press `Alt+Up` / `Alt+Down`. + - **Choose sections**: right-click a header to tick sections on and off, or reset the list. + - **Resize**: drag a divider; double-click one to restore the default sizes. - ๐Ÿ”ฌ **Variables**: see the **Local** and **Static** variables in scope at the frame you selected, each holding the value it had at that point; an object opens into its fields. Pick a merged row and it compares its calls instead โ€” which names varied and every value they held, with how many calls held each; hover a value to light those calls in the timeline and grids. Needs Apex Code at **FINEST**. ([#373]) - ๐Ÿง  **Heap analysis**: every method and call path reports heap three ways - **Net** (retained), **Gross** (allocated) and **Peak** (highest live) - so allocate-then-free churn no longer looks like a leak. ([#32]) - ๐Ÿ—„๏ธ **Database governor limits**: SOQL, SOSL, DML and row counts show as `used / limit`, flagging queries that did not consume the limit, plus a dedicated SOSL table. ([#162]) diff --git a/lana-docs/docs/docs/features/calltree.mdx b/lana-docs/docs/docs/features/calltree.mdx index ff6205d02..251fdebaa 100644 --- a/lana-docs/docs/docs/features/calltree.mdx +++ b/lana-docs/docs/docs/features/calltree.mdx @@ -62,7 +62,7 @@ Switch column sets from the **Columns** button in the toolbar (or the header rig Show or hide individual columns from the same menu; an edited view shows a **reset** icon. Choices persist per view. **Type** (the raw log event type) is hidden by default in every view and can be turned on here โ€” names read better without it, but it sorts, groups and exports like any other column. -The view you switch to is remembered as the `lana.callTree.columnView` setting โ€” see [Settings โ†’ Default column view](../settings.mdx#default-column-view). +The view you switch to is remembered, so the tables reopen the way you left them. ### Heap analysis diff --git a/lana-docs/docs/docs/features/inspector.md b/lana-docs/docs/docs/features/inspector.md index ef65cc30e..a0cf8b5e0 100644 --- a/lana-docs/docs/docs/features/inspector.md +++ b/lana-docs/docs/docs/features/inspector.md @@ -33,7 +33,11 @@ It docks to the **right**, **left** or **bottom**, resizes by dragging its edge, - **Call tree** โ€“ **Time Order** and **Aggregated** run from the log root through the selection's callers into what ran inside it; **Bottom-Up** ranks what ran inside it by self time. A caller holds only the time that reached the selection, so the tree reads 100% down to it and everything below is a share of it. - **SOQL issues** โ€“ SOQL only: optimization tips for the query. -Collapse a section by clicking its header, drag a divider to resize two of them, double-click a divider to restore the default sizes. It's one panel, so your layout follows you from tab to tab. +Collapse a section by clicking its header. Drag a header to reorder the stack, or press `Alt+Up` / `Alt+Down` on it. Right-click a header to choose which sections show, or to reset the list. Each list keeps its own choices โ€” every tab, and **Detail** apart from **Summary** โ€” because one section answers a different question in each. + +Sections that read the whole log size themselves to their content, and to no more than an equal share of the panel โ€” they grow back towards their content only as far as the other sections leave room, and scroll inside past that, so no one section can crowd the rest down to its minimum. The ones that answer about a selection do not: **Details** and **Self time by namespace** keep a steady height, and **Variables**, **Call stack**, **Call tree** and **Findings** share what is left. Every one of those is a share of the panel, so docking wider or taller gives each section more room. So stepping from one frame to the next never resizes the stack โ€” a section with more to say scrolls inside instead. + +Drag a divider to resize: the sections on the other side give up room in turn, each down to the same small minimum, and the divider follows the pointer until they are all there. A drag sets the size of every section, so the stack holds where you left it. Double-click a divider to hand back the sizes of the two sections beside it, or use **Reset Sections** for the whole stack. Sizes are not remembered: a size set for one log is the wrong one for the next. ### Summary diff --git a/lana-docs/docs/docs/settings.mdx b/lana-docs/docs/docs/settings.mdx index 121418d72..3816d0dee 100644 --- a/lana-docs/docs/docs/settings.mdx +++ b/lana-docs/docs/docs/settings.mdx @@ -86,10 +86,6 @@ Define your own with `lana.timeline.customThemes`. Each key is a theme name and The Call Tree colors its Name column by event category using the active timeline theme. A color chip is shown by default; enable **Colorize Call Tree category names** (`lana.callTree.categoryColorize`) under `preferences -> extensions -> Apex Log Analyzer` to tint the whole cell instead. See [Call Tree โ†’ Category Coloring](./features/calltree.mdx#category-coloring). -## Default column view - -`lana.callTree.columnView` sets the column view the Call Tree and Analysis tables open with: `General` (default), `Time`, `Governor Limits`, `Database` or `Memory`. Switching views from the **Columns** menu updates this setting, so the tables reopen the way you left them. See [Call Tree โ†’ Column Views](./features/calltree.mdx#column-views). - ## Inspector The [inspector](./features/inspector.md) remembers where you dock it and how big it is: diff --git a/lana/package.json b/lana/package.json index 9883a40a7..4505b61ee 100644 --- a/lana/package.json +++ b/lana/package.json @@ -143,20 +143,6 @@ "default": false, "markdownDescription": "Tint the Call Tree Name column by event category, matching the Timeline colors, instead of showing a small color chip. Default: `false`", "order": 0 - }, - "lana.callTree.columnView": { - "title": "Column view", - "type": "string", - "default": "General", - "enum": [ - "General", - "Time", - "Governor Limits", - "Database", - "Memory" - ], - "markdownDescription": "The column view applied to the Call Tree and Analysis tables. Default: `General`", - "order": 1 } } }, diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 2f38ef746..afb8288e4 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -224,6 +224,7 @@ export class LogView { config.database.dml.columnOverrides = overrides['database.dml.columnOverrides'] ?? {}; config.database.sosl.columnOverrides = overrides['database.sosl.columnOverrides'] ?? {}; const columnViews = getColumnViews(context.context.globalState); + config.callTree.columnView = columnViews['callTree.columnView'] ?? 'General'; config.database.soql.columnView = columnViews['database.soql.columnView'] ?? 'General'; config.database.dml.columnView = columnViews['database.dml.columnView'] ?? 'General'; config.database.sosl.columnView = columnViews['database.sosl.columnView'] ?? 'General'; diff --git a/lana/src/workspace/AppConfig.ts b/lana/src/workspace/AppConfig.ts index f33bd03dc..57d5126ac 100644 --- a/lana/src/workspace/AppConfig.ts +++ b/lana/src/workspace/AppConfig.ts @@ -49,7 +49,8 @@ export interface Config { size: number; // The rest is private globalState (see INSPECTOR_STATE_SECTIONS), not settings. collapsed: Record; - paneSizes: Record; + sectionOrder: Record; + hiddenSections: Record; visible: boolean | null; }; } @@ -122,25 +123,28 @@ export const COLUMN_OVERRIDE_SECTIONS = [ ] as const; /** - * The Database column-view presets persist privately in globalState (they are - * not registered `lana.*` settings). `callTree.columnView` stays a public - * setting. + * The column-view presets are the view a table was last left in โ€” remembered UI + * state, not a preference โ€” so they persist privately in globalState rather than + * as registered `lana.*` settings. */ export const COLUMN_VIEW_SECTIONS = [ + 'callTree.columnView', 'database.soql.columnView', 'database.dml.columnView', 'database.sosl.columnView', ] as const; /** - * The inspector's layout state (which sections are collapsed, their sizes, the - * call tree's view mode, whether the panel is open) is remembered UI state + * The inspector's layout state (which sections are collapsed, which it hides, + * the order they are in, whether the panel is open) is remembered UI state * rather than a preference, so it persists in globalState. Dock position and - * size stay public `lana.inspector.*` settings. + * size stay public `lana.inspector.*` settings. Section sizes are remembered + * nowhere: a section takes the space its content and the panel allow. */ export const INSPECTOR_STATE_SECTIONS = [ 'inspector.collapsed', - 'inspector.paneSizes', + 'inspector.sectionOrder', + 'inspector.hiddenSections', 'inspector.visible', ] as const; @@ -152,12 +156,16 @@ export const PRIVATE_SECTIONS = [ ] as const; type ColumnOverrides = Record; -type InspectorState = Pick; +type InspectorState = Pick< + Config['inspector'], + 'collapsed' | 'sectionOrder' | 'hiddenSections' | 'visible' +>; export function getInspectorState(globalState: Memento): InspectorState { return { collapsed: globalState.get>('inspector.collapsed', {}), - paneSizes: globalState.get>('inspector.paneSizes', {}), + sectionOrder: globalState.get>('inspector.sectionOrder', {}), + hiddenSections: globalState.get>('inspector.hiddenSections', {}), visible: globalState.get('inspector.visible', null), }; } diff --git a/lana/src/workspace/__tests__/AppConfig.test.ts b/lana/src/workspace/__tests__/AppConfig.test.ts index 32f71f7cb..396b4c1b2 100644 --- a/lana/src/workspace/__tests__/AppConfig.test.ts +++ b/lana/src/workspace/__tests__/AppConfig.test.ts @@ -7,7 +7,9 @@ import { workspace } from 'vscode'; import { COLUMN_OVERRIDE_SECTIONS, + COLUMN_VIEW_SECTIONS, getColumnOverrides, + getColumnViews, getConfig, sameConfig, updateColumnOverride, @@ -113,6 +115,19 @@ describe('AppConfig column overrides', () => { }); }); + describe('getColumnViews', () => { + it('reads every view preset from globalState, defaulting to General', () => { + const globalState = mockMemento({ 'callTree.columnView': 'Memory' }); + + const views = getColumnViews(globalState); + + // The Call Tree's view is remembered UI state, not a `lana.*` setting. + expect(views['callTree.columnView']).toBe('Memory'); + expect(views['database.soql.columnView']).toBe('General'); + expect(globalState.get).toHaveBeenCalledTimes(COLUMN_VIEW_SECTIONS.length); + }); + }); + describe('updateColumnOverride', () => { it('writes only to globalState', () => { const globalState = mockMemento(); diff --git a/log-viewer/src/components/DetailDock.ts b/log-viewer/src/components/DetailDock.ts index 6c08518db..9ebf297d6 100644 --- a/log-viewer/src/components/DetailDock.ts +++ b/log-viewer/src/components/DetailDock.ts @@ -34,8 +34,8 @@ export class DetailDock extends LitElement { @property({ attribute: false }) collapsed: Record = {}; - @property({ attribute: false }) - paneSizes: Record = {}; + @property({ type: Number }) + layoutEpoch = 0; static styles = [ globalStyles, @@ -140,7 +140,7 @@ export class DetailDock extends LitElement { orientation=${this.dock === 'bottom' ? 'horizontal' : 'vertical'} .sections=${this.sections} .collapsed=${this.collapsed} - .paneSizes=${this.paneSizes} + .layoutEpoch=${this.layoutEpoch} >` : html`
    ${this.emptyText}
    ` } diff --git a/log-viewer/src/components/DockLayout.ts b/log-viewer/src/components/DockLayout.ts index 51f1a04bc..de9fad61e 100644 --- a/log-viewer/src/components/DockLayout.ts +++ b/log-viewer/src/components/DockLayout.ts @@ -43,8 +43,8 @@ export class DockLayout extends LitElement { @property({ attribute: false }) collapsed: Record = {}; - @property({ attribute: false }) - paneSizes: Record = {}; + @property({ type: Number }) + layoutEpoch = 0; // Live drag state (transient); when set, overrides `size` while dragging. @state() @@ -133,7 +133,7 @@ export class DockLayout extends LitElement { .sections=${this.sections} .emptyText=${this.emptyText} .collapsed=${this.collapsed} - .paneSizes=${this.paneSizes} + .layoutEpoch=${this.layoutEpoch} dock=${this.dock} > diff --git a/log-viewer/src/components/LogInspector.ts b/log-viewer/src/components/LogInspector.ts index 9ae779631..da7b951e8 100644 --- a/log-viewer/src/components/LogInspector.ts +++ b/log-viewer/src/components/LogInspector.ts @@ -2,7 +2,7 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { LitElement, css, html, type PropertyValues } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property, query, state } from 'lit/decorators.js'; import { TAB_TO_SOURCE, @@ -16,10 +16,23 @@ import { debounce } from '../core/utility/Util.js'; import { getSettings, updateSetting } from '../features/settings/Settings.js'; import { emptyTextFor } from './detailEmptyText.js'; import { buildDetailSections } from './detailSections.js'; +import { + hiddenIds, + keepUnbuilt, + layoutKey, + mergeOrder, + orderSections, + scopedKey, + scopedRecord, + withoutScope, +} from './inspectorLayout.js'; +import { RESET_SECTIONS_ID, buildSectionMenuItems, sectionIdFor } from './sectionMenu.js'; import { globalStyles } from '../styles/global.styles.js'; +import './ContextMenu.js'; +import type { ContextMenu } from './ContextMenu.js'; import type { DockPosition } from './DetailDock.js'; import './DockLayout.js'; -import type { PaneOrientation, PaneSection } from './PaneView.js'; +import type { PaneSection } from './PaneView.js'; import './ViewModeSwitch.js'; import type { ViewModeOption } from './ViewModeSwitch.js'; @@ -36,7 +49,13 @@ const SCOPE_OPTIONS: readonly ViewModeOption[] = [ * via a forwarded `main` slot) so it crosscuts every tab. It follows the active * tab: each source's latest selection is remembered, and it shows the active * tab's selection. Persists dock position/size (public settings) plus its - * open/closed state, section collapse and pane sizes (private globalState). + * open/closed state and its section layout (private globalState). + * + * Collapse, order and which sections show are remembered per section list โ€” per + * tab and per scope, see {@link layoutKey} โ€” because one id means different + * content in two lists. Section sizes are remembered nowhere: a section takes + * the space its content and the panel allow, and a size dragged for one log is + * the wrong one for the next. */ @customElement('log-inspector') export class LogInspector extends LitElement { @@ -51,11 +70,37 @@ export class LogInspector extends LitElement { @state() private panelSize = 500; - // Keyed by section id and shared by every tab โ€” one panel, one layout. + // Keyed `::
    `, so the same section keeps its own + // state in each list it appears in. @state() private collapsedSections: Record = {}; + // The order the user arranged each list in, keyed `:`. + @state() + private sectionOrder: Record = {}; + // The sections a list is set to hide, keyed like the collapse record. + @state() + private hiddenSections: Record = {}; + // Bumped by a reset, which hands the panes' sizes back to automatic. @state() - private paneSizes: Record = {}; + private _layoutEpoch = 0; + + // What the builder produced for the list on screen. Kept in its own order, + // because that is what a reset goes back to. + private _builtSections: PaneSection[] = []; + // Everything else about that list, derived together in `_applyLayout` so the + // four can never disagree: which list it is, every section of it in the order + // the user arranged (hidden ones included, so the header menu can offer them + // back), the ones it hides, and its collapse record by plain section id, which + // is what `` takes. + private _layout: { + key: string; + ordered: PaneSection[]; + hidden: ReadonlySet; + collapsed: Record; + } = { key: '', ordered: [], hidden: new Set(), collapsed: {} }; + + @query('context-menu') + private _menu?: ContextMenu; // Latest selection per source; the bar renders the active tab's entry. private _selections = new Map(); @@ -107,7 +152,11 @@ export class LogInspector extends LitElement { this.dock = panel.position; this.panelSize = panel.size; this.collapsedSections = panel.collapsed ?? {}; - this.paneSizes = panel.paneSizes ?? {}; + this.sectionOrder = panel.sectionOrder ?? {}; + this.hiddenSections = panel.hiddenSections ?? {}; + // A hidden section's build skips work, so the list is rebuilt rather + // than filtered. + void this._rebuild(); } }) .catch(() => { @@ -166,21 +215,23 @@ export class LogInspector extends LitElement { .size=${this.panelSize} ?visible=${this._visible} .sections=${this.sections} - .collapsed=${this.collapsedSections} - .paneSizes=${this.paneSizes} + .collapsed=${this._layout.collapsed} + .layoutEpoch=${this._layoutEpoch} emptyText=${emptyTextFor(this._activeSource)} @dock-position-change=${this._onDockPositionChange} @dock-resize=${this._onDockResize} @dock-hide=${this._hidePanel} @dock-collapse=${this._hidePanel} @pane-toggle=${this._onPaneToggle} - @pane-resize=${this._onPaneResize} + @pane-reorder=${this._onPaneReorder} + @pane-menu=${this._onPaneMenu} @inspector-reveal=${this._onReveal} @inspector-locate=${this._onLocate} > ${this._scopeSwitch()} + `; } @@ -343,17 +394,64 @@ export class LogInspector extends LitElement { private async _rebuild(): Promise { const epoch = ++this._rebuildEpoch; const source = this._activeSource; + const selection = source ? this._scopedSelection(source) : null; + const key = source ? layoutKey(source, selection ? 'detail' : 'summary') : ''; + // What this build skips. Read before the await, which is why `_applyLayout` + // reads the store again rather than trusting it. + const hidden = hiddenIds(this.hiddenSections, key); const sections = source ? await buildDetailSections( source, - this._scopedSelection(source), + selection, this._active.get(source) ?? null, this._sourceViews.get(source), + hidden, ) : []; // Drop a slow build that a newer selection already superseded. if (epoch === this._rebuildEpoch) { - this.sections = sections; + this._builtSections = sections; + this._applyLayout(key); + } + } + + /** + * The built sections in this list's own order, and only the ones it shows. + * Reads the stored set itself rather than taking one: a build's set is a + * snapshot from before it awaited, and the user can hide a section while it + * runs. + */ + private _applyLayout(key = this._layout.key): void { + const ordered = orderSections(this._builtSections, this.sectionOrder[key]); + let hidden = hiddenIds(this.hiddenSections, key); + // A stored set that hides every section โ€” a list whose sections have changed + // since โ€” would leave no header to right-click, and that menu is the only way + // back. Forgetting it is the way out, so the store agrees with the screen. + if (ordered.length && ordered.every((section) => hidden.has(section.id))) { + this.hiddenSections = withoutScope(this.hiddenSections, key); + updateSetting('inspector.hiddenSections', this.hiddenSections); + hidden = new Set(); + // They were built as hidden, so the work their build skipped is missing โ€” + // a badge, or anything else the section resolves up front. + void this._rebuild(); + } + this._layout = { + key, + ordered, + hidden, + collapsed: scopedRecord(this.collapsedSections, key), + }; + this.sections = ordered.filter((section) => !hidden.has(section.id)); + } + + /** After a change to what this list shows: only a section coming back needs + * the work its build skipped. Settles before it resolves, so a caller can + * read the layout it produced. */ + private async _relayout(needsBuild: boolean): Promise { + if (needsBuild) { + await this._rebuild(); + } else { + this._applyLayout(); } } @@ -373,24 +471,90 @@ export class LogInspector extends LitElement { private _onPaneToggle = (e: CustomEvent<{ collapsed: Record }>) => { this._userAdjusted = true; - this.collapsedSections = { ...this.collapsedSections, ...e.detail.collapsed }; + // The pane names sections by id; the panel remembers them per list. + const scoped = Object.entries(e.detail.collapsed).map(([id, value]) => [ + scopedKey(this._layout.key, id), + value, + ]); + this.collapsedSections = { ...this.collapsedSections, ...Object.fromEntries(scoped) }; + this._layout = { ...this._layout, collapsed: e.detail.collapsed }; updateSetting('inspector.collapsed', this.collapsedSections); }; - // `pane-resize` fires on pointer-up, so this write lands on interaction-end. - private _onPaneResize = ( - e: CustomEvent<{ sizes: Record; orientation: PaneOrientation }>, - ) => { + private _onPaneReorder = (e: CustomEvent<{ ids: string[] }>) => { this._userAdjusted = true; - // Every size for the dragged axis arrives together, so that axis is - // replaced, not merged: a pane reset to its content's size has no entry to - // merge. The other axis' sizes are untouched. - const prefix = `${e.detail.orientation}:`; - const otherAxis = Object.entries(this.paneSizes).filter(([key]) => !key.startsWith(prefix)); - this.paneSizes = { ...Object.fromEntries(otherAxis), ...e.detail.sizes }; - updateSetting('inspector.paneSizes', this.paneSizes); + const ids = this._layout.ordered.map((section) => section.id); + const arranged = mergeOrder(ids, this._layout.hidden, e.detail.ids); + // What this build never produced is off screen like a hidden section, not + // gone: a reorder under a DML row must not drop where they put SOQL issues. + const order = keepUnbuilt(this.sectionOrder[this._layout.key] ?? [], arranged); + this.sectionOrder = { ...this.sectionOrder, [this._layout.key]: order }; + updateSetting('inspector.sectionOrder', this.sectionOrder); + this._applyLayout(); }; + /** Offers every section of the list, hidden ones included, so any can come back. */ + private _onPaneMenu = (e: CustomEvent<{ x: number; y: number }>) => { + this._menu?.show(this._sectionMenuItems(), e.detail.x, e.detail.y); + }; + + /** The menu stays open through a toggle, so its ticks are refreshed in place. */ + private _refreshSectionMenu(): void { + if (this._menu?.isVisible()) { + this._menu.items = this._sectionMenuItems(); + } + } + + private _sectionMenuItems() { + return buildSectionMenuItems(this._layout.ordered, this._layout.hidden); + } + + private _onSectionMenuSelect = (e: CustomEvent<{ itemId: string }>) => { + if (e.detail.itemId === RESET_SECTIONS_ID) { + this._resetSections(); + return; + } + const id = sectionIdFor(e.detail.itemId); + if (id) { + void this._toggleSection(id); + } + }; + + private async _toggleSection(id: string): Promise { + this._userAdjusted = true; + const key = scopedKey(this._layout.key, id); + const hidden = { ...this.hiddenSections }; + const bringingBack = !!hidden[key]; + if (bringingBack) { + delete hidden[key]; + } else { + hidden[key] = true; + } + this.hiddenSections = hidden; + updateSetting('inspector.hiddenSections', this.hiddenSections); + // Re-ticked from the layout the toggle produced, so a slow rebuild cannot + // leave a row showing the state before the click. + await this._relayout(bringingBack); + this._refreshSectionMenu(); + } + + /** + * This list back to its defaults: the order it is built in, every section + * showing, and the panes' sizes automatic again. Collapse is left alone โ€” it is + * a live reading choice, and one click undoes it. + */ + private _resetSections(): void { + this._userAdjusted = true; + const { [this._layout.key]: _cleared, ...order } = this.sectionOrder; + this.sectionOrder = order; + const hadHidden = this._layout.hidden.size > 0; + this.hiddenSections = withoutScope(this.hiddenSections, this._layout.key); + updateSetting('inspector.sectionOrder', this.sectionOrder); + updateSetting('inspector.hiddenSections', this.hiddenSections); + this._layoutEpoch++; + void this._relayout(hadHidden); + } + private _hidePanel = () => { this._setVisible(false); }; diff --git a/log-viewer/src/components/PaneView.ts b/log-viewer/src/components/PaneView.ts index 6e129d8b9..26b83d8e3 100644 --- a/log-viewer/src/components/PaneView.ts +++ b/log-viewer/src/components/PaneView.ts @@ -5,6 +5,9 @@ import '#vscode-elements/vscode-icon.js'; import '#vscode-elements/vscode-badge.js'; import { LitElement, css, html, nothing, type PropertyValues, type TemplateResult } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; // styles import { globalStyles } from '../styles/global.styles.js'; @@ -18,25 +21,60 @@ export interface PaneSection { /** Default flex-grow weight when open, seeded on first render (default 1). */ weight?: number; /** - * How the open pane takes space (default `'fill'`). A `'content'` pane sizes - * to its content and shrinks โ€” scrolling inside โ€” when space runs out, it - * never stretches to soak up leftovers, and it leaves the open fill panes a - * share of the space. Dragging its sash pins it to a size instead, which a - * double-click hands back to the content. + * How the open pane takes space (default `'fill'`). A `'content'` pane asks + * for its content and never for more, so it does not stretch to soak up + * leftovers โ€” but it asks for no more than an equal share of the panel + * either, growing back towards its content only as far as the room the other + * sections leave and scrolling inside beyond that. So no one section can take + * the panel and hold every other one at its floor. Dragging its sash gives it + * whatever room the drag asks for. * A `'fill'` pane shares the remaining space by weight. + * + * Only for content that does not change with what the consumer is showing. + * A section that says something different about each selection sizes itself + * with {@link PaneSection.height} instead: sizing that content would move + * every boundary in the stack each time the reader steps to the next one. */ fit?: 'content' | 'fill'; + /** + * A steady height, in place of the pane's content or its share: the choice for + * a section whose content changes with the selection. The tier names a + * `--lana-pane-*` token, so the height lives in CSS โ€” a share of the panel + * between two bounds, which scales with the room there is and depends on + * nothing the selection changes. A dragged size still wins, and the section + * scrolls inside when it has more to show. + * + * Read while the stack is vertical, the only axis on which a section has a + * height; laid out side by side it shares the width like any fill pane. + */ + height?: 'sm' | 'md'; } export type PaneOrientation = 'vertical' | 'horizontal'; -const MIN_PANE_PX = 44; +/** An open pane as a sash drag found it: what it measured, and its floor. */ +interface SashPane { + id: string; + size: number; + min: number; +} /** * A VS Code sidebar-style PaneView: a stack of titled sections that (when * vertical) collapse via a twistie and share the available space, with a - * draggable sash between adjacent open sections that redistributes their size. + * draggable sash between adjacent open sections. Dragging one gives room to the + * sections on one side of it and takes it from the other, nearest first: each + * gives up room down to `--lana-pane-min` and then the next one does, so the + * sash keeps following the pointer until the whole giving side is at its floor. + * A drag fixes the size of every open section, not only the two it moved: + * `layoutEpoch` is what hands the whole stack back, and a double-click on a + * sash re-splits the pair beside it. * Horizontal mode lays the sections side by side with resize-only sashes. + * + * Dragging a header โ€” or `Alt+Arrow` on a focused one โ€” reorders the stack. The + * consumer owns the order, and hears the new one through `pane-reorder`. + * Right-clicking a header reports `pane-menu`: the consumer knows what sections + * there are, so it owns the menu. */ @customElement('pane-view') export class PaneView extends LitElement { @@ -50,28 +88,40 @@ export class PaneView extends LitElement { @property({ attribute: false }) collapsed: Record = {}; - /** - * Pane sizes from the last drag (px), keyed `:
    `. - * Relative only, and per axis โ€” a width dragged in the horizontal dock says - * nothing about heights in the vertical one, so each orientation keeps its own - * sizes and the other's are left untouched. - */ - @property({ attribute: false }) - paneSizes: Record = {}; + /** Bump to drop the sizes the panes were dragged to, back to automatic. */ + @property({ type: Number }) + layoutEpoch = 0; - // The current axis' weights, keyed by section id: seeded from `paneSizes`, - // then edited in place by a live drag until `pane-resize` hands the result - // back to the consumer. + // Sizes a drag has given the panes (px), keyed by section id. The panel keeps + // them while it is open and never persists them: sections size + // themselves from their content and the space there is, and a layout dragged + // for one log is the wrong one for the next. @state() private _weights: Record = {}; + // The header being dragged, and where it would land: an insertion point in + // `sections`, so one boundary is one mark however it was reached. + @state() + private _dragId: string | null = null; + @state() + private _dropIndex: number | null = null; + + // The header to put focus back on once a keyboard move has re-rendered. + private _focusId: string | null = null; + private _sash: { - aId: string; - bId: string; + /** The panes each way from the sash, nearest it first, and the room each side has. */ + above: SashPane[]; + below: SashPane[]; + slackAbove: number; + slackBelow: number; + /** Every open pane at the size it was measured at, keyed by id. */ + measured: Record; start: number; - startA: number; - startB: number; - moved: boolean; + /** The last delta applied, so a move that changes nothing renders nothing. */ + delta: number; + /** The dragged sizes as the gesture found them, so a cancel puts them back. */ + weights: Record; } | null = null; static styles = [ @@ -102,8 +152,64 @@ export class PaneView extends LitElement { .pane-view[data-orientation='horizontal'] { flex-direction: row; } + .pane-view[data-orientation='horizontal'] .pane[data-open] { + min-width: var(--lana-pane-min); + } + + /* How a section takes space, in one place. The element carries only the + numbers behind it: --pane-grow for a fill pane's weight, --pane-count + for the share, and --pane-size for a size the reader dragged to. + + A drag beats every default here, and one mechanism says so: --pane-size + is set only on a dragged pane, so every rule below reads it first and + falls back to what it would otherwise have used. */ + .pane { + flex: 0 0 auto; + } + /* Open: it shrinks โ€” scrolling inside โ€” when the space runs out. Each + sizing mode below sets its own basis. */ + .pane[data-open] { + flex-shrink: 1; + } + .pane[data-open][data-sizing='fill'] { + flex-grow: var(--pane-grow, 1); + flex-basis: var(--pane-size, 0); + } + /* An equal share as the basis, so the panes shrink alongside each other + rather than by size. From a basis of zero a fill pane can only grow + into free space, and a stack of sized-to-content panes leaves none โ€” it + would sit at the floor; a content pane sized to its content is the one + holding it there, because flexbox shrinks by basis and the biggest + keeps the most. A tier is exempt: a bounded slot already, and a share + here would over-subscribe the panel and flatten the weights. */ + .pane-view[data-content] .pane[data-open][data-sizing='fill'], + .pane[data-open][data-sizing='content'] { + flex-basis: var(--pane-size, calc(100% / var(--pane-count))); + } + /* A content pane is then a weight-1 fill pane capped at its content: it + never stretches past what it has to show, and scrolls when the share is + all it gets. */ + .pane[data-open][data-sizing='content'] { + flex-grow: 1; + } + .pane-view[data-orientation='vertical'] .pane[data-open][data-sizing='content'] { + max-height: var(--pane-size, max-content); + } + .pane-view[data-orientation='horizontal'] .pane[data-open][data-sizing='content'] { + max-width: var(--pane-size, max-content); + } + + /* A steady height, so walking the selection does not resize the stack. + Only a vertical stack is given a tier, so no rule here re-checks it. */ + .pane[data-open][data-tier='sm'] { + flex-basis: var(--pane-size, var(--lana-pane-sm)); + } + .pane[data-open][data-tier='md'] { + flex-basis: var(--pane-size, var(--lana-pane-md)); + } .pane { + position: relative; display: flex; flex-direction: column; min-height: 0; @@ -117,6 +223,44 @@ export class PaneView extends LitElement { border-right: none; } + /* Where a dragged section would land, on the edge it would land against. + Drawn over the content: the header paints its own background, so a + shadow cast by the pane behind it would not show. */ + .pane--drop-before::after, + .pane--drop-after::after { + content: ''; + position: absolute; + z-index: 2; + background-color: var(--lana-focus-border); + } + .pane-view[data-orientation='vertical'] .pane--drop-before::after, + .pane-view[data-orientation='vertical'] .pane--drop-after::after { + left: 0; + right: 0; + height: var(--lana-space-3xs); + } + .pane-view[data-orientation='vertical'] .pane--drop-before::after { + top: 0; + } + .pane-view[data-orientation='vertical'] .pane--drop-after::after { + bottom: 0; + } + .pane-view[data-orientation='horizontal'] .pane--drop-before::after, + .pane-view[data-orientation='horizontal'] .pane--drop-after::after { + top: 0; + bottom: 0; + width: var(--lana-space-3xs); + } + .pane-view[data-orientation='horizontal'] .pane--drop-before::after { + left: 0; + } + .pane-view[data-orientation='horizontal'] .pane--drop-after::after { + right: 0; + } + .pane--dragging { + opacity: 0.6; + } + .pane-header { display: flex; align-items: center; @@ -142,7 +286,9 @@ export class PaneView extends LitElement { .pane-header--button:hover { background-color: var(--lana-row-hover-bg); } - .pane-header:focus-visible { + /* Focus, not focus-visible: the focused header is the one Alt+Arrow moves, + so which one that is has to show even when a click put it there. */ + .pane-header:focus { outline: var(--lana-focus-ring); outline-offset: var(--lana-focus-inset); } @@ -198,106 +344,150 @@ export class PaneView extends LitElement { ]; willUpdate(changed: PropertyValues): void { - // Adopt whatever the consumer stored for this axis; a drag then edits this - // copy. Re-docking flips the orientation on the same element, so that has to - // re-seed too or the previous axis' sizes would carry over. - if (changed.has('paneSizes') || changed.has('orientation')) { - const prefix = `${this.orientation}:`; - this._weights = Object.fromEntries( - Object.entries(this.paneSizes) - .filter(([key]) => key.startsWith(prefix)) - .map(([key, size]) => [key.slice(prefix.length), size]), - ); + // Re-docking flips the orientation on the same element, and a height dragged + // down the side says nothing about a width along the bottom. + if (changed.has('orientation') || changed.has('layoutEpoch')) { + this._weights = {}; + } + // A section that opens after a drag has no size of its own, and the panes + // holding pixels leave it no share to take โ€” it would come up at its floor. + // So the stack goes back to sizing itself. + if (this._mixesSizes()) { + this._weights = {}; } } - render() { - const weights = this._flexWeights(); - const basis = this._fillBasis(); - const items: TemplateResult[] = []; - this.sections.forEach((section, index) => { - items.push(this._renderPane(section, weights.get(section.id) ?? 1, basis)); - const next = this.sections[index + 1]; - // A sash trades space between the two panes beside it, so it exists - // wherever both neighbours are open. A content pane starts at its - // content's size and then holds the size it is dragged to. - if (next && this._isOpen(section.id) && this._isOpen(next.id)) { - items.push(this._renderSash(section.id, next.id)); - } - }); + /** + * Whether some open section holds a dragged size while another has none. The + * two cannot be mixed: a size is taken out of the panel before the panes that + * share it are given anything. + */ + private _mixesSizes(): boolean { + const open = this.sections.filter((section) => this._isOpen(section.id)); + const sized = open.filter((section) => this._weights[section.id] !== undefined).length; + return sized > 0 && sized < open.length; + } + + updated(): void { + // A keyboard move re-renders the stack under the pointer of the keyboard, so + // the moved section keeps the focus and a second press moves the same one. + if (this._focusId) { + this._headerFor(this._focusId)?.focus(); + this._focusId = null; + } + } - return html`
    ${items}
    `; + render() { + const isOpen = this.sections.map((section) => this._isOpen(section.id)); + const open = this.sections.filter((_section, index) => isOpen[index]); + const dragged = this._draggedWeights(open); + + // Keyed on the section, so a reorder โ€” or a collapse that adds a sash above + // one โ€” moves the panes that are already there. Rendered by position they + // would be rebuilt in place instead, re-mounting each body's grid and + // losing its scroll, its expanded rows and the row the user picked. + return html`
    + ${repeat( + this.sections, + (section) => section.id, + (section, index) => { + const next = this.sections[index + 1]; + // A sash trades space between the two panes beside it, so it exists + // wherever both neighbours are open, and travels with the pane above. + const sash = + next && isOpen[index] && isOpen[index + 1] + ? this._renderSash(section.id, next.id) + : nothing; + return html`${this._renderPane( + section, + dragged.get(section.id) ?? section.weight ?? 1, + this._dropEdge(index), + )}${sash}`; + }, + )} +
    `; } /** - * Flex weights for the open panes, all on one scale. Stored sizes are pixel - * snapshots taken during a drag while `section.weight` is a small unit share, - * so the stored set is rescaled onto the unit scale: mixing the two would - * render a section with no stored size โ€” one added after the drag, or - * collapsed during it โ€” as a sliver beside its pixel-sized siblings. + * The dragged fill panes' weights, rescaled from pixels onto the unit scale + * `section.weight` uses. A dragged pane takes its size from its basis, not + * from this, so what the rescale decides is how the panes split whatever is + * left over โ€” after the dock is resized, or a section is collapsed. Mixing + * the two scales would hand nearly all of it to the panes that were dragged. */ - private _flexWeights(): Map { - const open = this.sections.filter( - (section) => this._isOpen(section.id) && this._isFill(section), + private _draggedWeights(open: PaneSection[]): Map { + const fill = open.filter( + (section) => this._sizingOf(section) === 'fill' && this._weights[section.id] !== undefined, ); - let storedPx = 0; - let storedUnits = 0; - for (const section of open) { - const stored = this._weights[section.id]; - if (stored !== undefined) { - storedPx += stored; - storedUnits += section.weight ?? 1; - } + let px = 0; + let units = 0; + for (const section of fill) { + px += this._weights[section.id] ?? 0; + units += section.weight ?? 1; } - // px โ†’ units, and 0 when nothing is stored so every pane falls back to units. - const scale = storedPx > 0 ? storedUnits / storedPx : 0; - return new Map( - open.map((section) => { - const stored = this._weights[section.id]; - const weight = stored !== undefined && scale > 0 ? stored * scale : (section.weight ?? 1); - return [section.id, weight]; - }), - ); + if (px === 0) { + return new Map(); + } + const scale = units / px; + return new Map(fill.map((section) => [section.id, (this._weights[section.id] ?? 0) * scale])); } /** - * A fill pane's flex basis: an equal share of the panel, so a stack of - * sized-to-content panes cannot squeeze it to `--lana-pane-min`. From a basis - * of zero it can only grow into free space, and once the content panes fill - * the panel there is none; from a share it shrinks alongside them instead, and - * keeps its natural size while the panel is roomy. Zero while every open pane - * fills, where a share each would take the whole panel and flatten the - * weights. + * Whether the fill panes need a share of the panel rather than a basis of + * zero โ€” which the stylesheet decides from `data-content`. True while some + * open pane is sized by its content, the one size nothing here bounds; see + * the rule itself for what the share buys and what it costs. Only down the + * side: laid out along the bottom the sections share the width already. */ - private _fillBasis(): string { - const open = this.sections.filter((section) => this._isOpen(section.id)); - const content = open.filter((section) => !this._isFill(section)).length; - return this.orientation === 'vertical' && content > 0 ? `calc(100% / ${open.length})` : '0'; + private _needsShare(open: PaneSection[]): boolean { + return ( + this.orientation === 'vertical' && + open.some((section) => this._sizingOf(section) === 'content') + ); } - private _renderPane(section: PaneSection, weight: number, basis: string) { + private _renderPane(section: PaneSection, weight: number, drop: 'before' | 'after' | null) { const open = this._isOpen(section.id); const collapsible = this._collapsible; - // An open content pane sizes to its content, or to the size it was dragged - // to, but stays shrinkable, so when space runs out it scrolls instead of - // pushing the fill panes off screen. + const sizing = this._sizingOf(section); const dragged = this._weights[section.id]; - const style = !open - ? 'flex: 0 0 auto' - : this._isFill(section) - ? `flex: ${weight} 1 ${basis}` - : dragged !== undefined - ? `flex: 0 1 ${dragged}px` - : 'flex: 0 1 auto'; - - return html`
    + + return html`
    this._toggle(section.id) : undefined} - @keydown=${collapsible ? (e: KeyboardEvent) => this._onHeaderKey(e, section.id) : undefined} + @keydown=${(e: KeyboardEvent) => this._onHeaderKey(e, section.id)} + @contextmenu=${(e: MouseEvent) => this._onHeaderMenu(e, section.id)} + @dragstart=${(e: DragEvent) => this._onDragStart(e, section.id)} + @dragend=${this._endDrag} > ${ collapsible @@ -311,10 +501,23 @@ export class PaneView extends LitElement {
    `; } + /** + * The edge of this pane a dragged section would land against, if any. Every + * boundary but the last is the near edge of the pane below it, so only the + * final position marks a pane's far edge. + */ + private _dropEdge(index: number): 'before' | 'after' | null { + if (this._dropIndex === index) { + return 'before'; + } + const last = this.sections.length - 1; + return this._dropIndex === last + 1 && index === last ? 'after' : null; + } + private _renderSash(aId: string, bId: string) { return html`
    this._startSash(e, aId, bId)} + @pointerdown=${(e: PointerEvent) => this._startSash(e, aId)} @dblclick=${() => this._resetSash(aId, bId)} >
    `; } @@ -327,8 +530,24 @@ export class PaneView extends LitElement { return this._collapsible ? !this.collapsed[id] : true; } - private _isFill(section: PaneSection) { - return (section.fit ?? 'fill') === 'fill'; + /** + * Which of the three ways this pane takes space. One derivation, so the modes + * stay mutually exclusive and the stylesheet, the share and the drag all read + * the same answer. + */ + private _sizingOf(section: PaneSection): 'fill' | 'content' | 'tier' { + if (section.height && this.orientation === 'vertical') { + return 'tier'; + } + return (section.fit ?? 'fill') === 'content' ? 'content' : 'fill'; + } + + private _paneEl(id: string): HTMLElement | null { + return this.renderRoot?.querySelector(`.pane[data-id="${id}"]`) ?? null; + } + + private _headerFor(id: string): HTMLElement | null { + return this._paneEl(id)?.querySelector('.pane-header') ?? null; } private _toggle(id: string) { @@ -344,7 +563,15 @@ export class PaneView extends LitElement { } private _onHeaderKey(e: KeyboardEvent, id: string) { - if (e.key === 'Enter' || e.key === ' ') { + if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + e.preventDefault(); + // Fires once: each move is a layout the consumer persists. + if (!e.repeat) { + this._moveBy(id, e.key === 'ArrowUp' ? -1 : 1); + } + return; + } + if (this._collapsible && (e.key === 'Enter' || e.key === ' ')) { // Consumed on a repeat too, so Space never scrolls the stack. e.preventDefault(); // Fires once: a repeat would flap the pane, and each toggle persists a setting. @@ -354,40 +581,182 @@ export class PaneView extends LitElement { } } + /** The keyboard's reach for the header drag: one place per press. */ + private _moveBy(id: string, step: number) { + const from = this.sections.findIndex((section) => section.id === id); + const to = from + step; + if (from < 0 || to < 0 || to >= this.sections.length) { + return; + } + this._focusId = id; + this._emitReorder(this._reorderedIds(from, to)); + } + + /** The consumer owns which sections there are, so it owns the menu too. */ + private _onHeaderMenu(e: MouseEvent, id: string) { + e.preventDefault(); + this.dispatchEvent( + new CustomEvent('pane-menu', { + detail: { id, x: e.clientX, y: e.clientY }, + bubbles: true, + composed: true, + }), + ); + } + + private _onDragStart(e: DragEvent, id: string) { + this._dragId = id; + if (e.dataTransfer) { + // The payload is the id, so a drop outside the stack carries something + // meaningful rather than nothing. + e.dataTransfer.setData('text/plain', id); + e.dataTransfer.effectAllowed = 'move'; + } + } + + private _onDragOver = (e: DragEvent) => { + if (!this._dragId) { + return; + } + // Claiming the drag is what makes the drop land here at all, and it is + // claimed for the whole stack โ€” headers, bodies, sashes and the section + // being dragged โ€” or crossing any of them reads as leaving. + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'move'; + } + this._dropIndex = this._insertionIndex(e); + }; + + /** + * Where the dragged section would land: the first pane whose middle the + * pointer has not passed, or the end of the stack. Measured against the whole + * pane rather than its header, so the target is the section the pointer is + * over, not a strip at the top of it. + */ + private _insertionIndex(e: DragEvent): number | null { + const vertical = this.orientation === 'vertical'; + const pos = vertical ? e.clientY : e.clientX; + let index = this.sections.length; + for (const [i, section] of this.sections.entries()) { + const rect = this._paneEl(section.id)?.getBoundingClientRect(); + if (!rect) { + continue; + } + const middle = vertical ? rect.top + rect.height / 2 : rect.left + rect.width / 2; + if (pos < middle) { + index = i; + break; + } + } + const from = this.sections.findIndex((section) => section.id === this._dragId); + // Either edge of where it already sits moves nothing, so nothing is marked. + return index === from || index === from + 1 ? null : index; + } + + // Leaving the stack drops the mark. Moving between headers inside it does not: + // the next `dragover` sets the mark again, and clearing here would flicker. + private _onStackDragLeave = (e: DragEvent) => { + const to = e.relatedTarget as Node | null; + if (!to || !(e.currentTarget as HTMLElement).contains(to)) { + this._dropIndex = null; + } + }; + + private _onDrop = (e: DragEvent) => { + e.preventDefault(); + const from = this.sections.findIndex((section) => section.id === this._dragId); + const index = this._dropIndex; + this._endDrag(); + if (from >= 0 && index !== null) { + // Lifting the section out shifts every later boundary up one. + this._emitReorder(this._reorderedIds(from, index > from ? index - 1 : index)); + } + }; + + private _endDrag = () => { + this._dragId = null; + this._dropIndex = null; + }; + + /** The section ids with the one at `from` lifted out and put back at `to`. */ + private _reorderedIds(from: number, to: number): string[] { + const ids = this.sections.map((section) => section.id); + ids.splice(to, 0, ...ids.splice(from, 1)); + return ids; + } + + /** Fires with the whole order, so the consumer stores it rather than + * reconstructing the move. */ + private _emitReorder(ids: string[]) { + this.dispatchEvent( + new CustomEvent('pane-reorder', { + detail: { ids }, + bubbles: true, + composed: true, + }), + ); + } + + /** + * The floor a pane cannot be dragged below, read from the pane itself so the + * drag stops exactly where CSS would: one token sets it, every section shares + * it, and the stack's arithmetic keeps matching what is on screen. Zero where + * there is no layout to read (jsdom), which leaves CSS the only floor. + */ + private _paneMin(id: string): number { + const el = this._paneEl(id); + if (!el) { + return 0; + } + const style = getComputedStyle(el); + return parseFloat(this.orientation === 'vertical' ? style.minHeight : style.minWidth) || 0; + } + private _paneSize(id: string): number { - const el = this.renderRoot?.querySelector(`.pane[data-id="${id}"]`) as HTMLElement | null; + const el = this._paneEl(id); if (!el) { return 0; } return this.orientation === 'vertical' ? el.offsetHeight : el.offsetWidth; } - private _startSash(e: PointerEvent, aId: string, bId: string) { + /** `aId` is the pane above the sash (left of it, side by side). */ + private _startSash(e: PointerEvent, aId: string) { e.preventDefault(); const sash = e.currentTarget as HTMLElement; sash.setPointerCapture(e.pointerId); sash.classList.add('pane-sash--active'); - // Snapshot every open pane's rendered size as its weight, so weights are in - // pixels and only the two dragged panes change (their sum stays constant). - for (const section of this.sections) { - if (this._isOpen(section.id)) { - this._weights[section.id] = this._paneSize(section.id); - } - } - + // Snapshot the whole open stack as pixels: the drag gives the panes on one + // side of the sash to the panes on the other, and once the nearest of them + // is at its floor the next one gives up room in its place. Nothing is + // written yet, so a sash click that never moves changes no size at all. + // + // One token sets the floor and every section shares it, so it is resolved + // once rather than per pane. + const ids = this.sections.filter((section) => this._isOpen(section.id)).map(({ id }) => id); + const min = this._paneMin(ids[0] ?? ''); + const panes = ids.map((id) => ({ id, size: this._paneSize(id), min })); + // Nearest the sash first, so that pane gives up room until it is at its + // floor, then the one beyond it does, out to the end of the stack. + const index = panes.findIndex((pane) => pane.id === aId); + const above = panes.slice(0, index + 1).reverse(); + const below = panes.slice(index + 1); this._sash = { - aId, - bId, - start: this.orientation === 'vertical' ? e.clientY : e.clientX, - startA: this._weights[aId] ?? 0, - startB: this._weights[bId] ?? 0, - moved: false, + above, + below, + slackAbove: this._slack(above), + slackBelow: this._slack(below), + measured: Object.fromEntries(panes.map((pane) => [pane.id, pane.size])), + start: this._pointerPos(e), + delta: 0, + weights: { ...this._weights }, }; sash.addEventListener('pointermove', this._onSashMove); sash.addEventListener('pointerup', this._endSash); sash.addEventListener('pointercancel', this._cancelSash); - sash.addEventListener('lostpointercapture', this._onLostCapture); + sash.addEventListener('lostpointercapture', this._endSash); } private _onSashMove = (e: PointerEvent) => { @@ -395,42 +764,75 @@ export class PaneView extends LitElement { if (!sash) { return; } - const pos = this.orientation === 'vertical' ? e.clientY : e.clientX; - const total = sash.startA + sash.startB; - const delta = pos - sash.start; - const newA = Math.max(MIN_PANE_PX, Math.min(sash.startA + delta, total - MIN_PANE_PX)); - sash.moved = true; - this._weights = { ...this._weights, [sash.aId]: newA, [sash.bId]: total - newA }; + // The stack's size is fixed, so the room one side takes is the room the + // other gives up: the drag stops once the giving side is all at its floor, + // and a move past that changes nothing worth rendering. + const delta = Math.max( + -sash.slackAbove, + Math.min(this._pointerPos(e) - sash.start, sash.slackBelow), + ); + if (delta === sash.delta) { + return; + } + sash.delta = delta; + + // Every open pane holds its measured size, through the gesture and after it, + // so the bases add up to the panel and flexbox has nothing to shrink. Leave + // one sizing itself and its basis is its content โ€” larger than it renders at + // once the panel is over-subscribed โ€” so the whole stack would shrink and the + // boundary would lag the pointer. Hand one back at the end and the room it + // holds becomes room the sized panes grow into, shrinking a section the drag + // never named. A double-click or `layoutEpoch` is what hands sizes back. + const weights = { ...sash.weights, ...sash.measured }; + this._distribute(sash.above, delta, weights); + this._distribute(sash.below, -delta, weights); + this._weights = weights; }; + private _slack(side: SashPane[]): number { + return side.reduce((room, pane) => room + pane.size - pane.min, 0); + } + + /** Gives `room` to `side`, nearest the sash first, each pane down to its floor. */ + private _distribute(side: SashPane[], room: number, into: Record): void { + let left = room; + for (const pane of side) { + const size = Math.max(pane.min, pane.size + left); + left -= size - pane.size; + if (size !== pane.size) { + into[pane.id] = size; + } + } + } + + private _pointerPos(e: PointerEvent): number { + return this.orientation === 'vertical' ? e.clientY : e.clientX; + } + + // Also the handler for a capture lost without a pointerup (window blur, + // another element capturing): either way the gesture is over, or a later move + // would resize with no button held. private _endSash = (e: PointerEvent) => { - const moved = this._sash?.moved ?? false; - // A click that never moved changed no size, so it isn't worth persisting โ€” - // and a double-click reset would otherwise emit three times. - if (this._teardownSash(e.currentTarget as HTMLElement, e.pointerId) && moved) { - this._emitResize(); + // A gesture that ends where it began is not a drag: the sections it held for + // the drag go back to sizing themselves. + if (this._sash?.delta === 0) { + this._weights = this._sash.weights; } + this._teardownSash(e.currentTarget as HTMLElement, e.pointerId); }; /** An interrupted gesture (OS gesture, touch cancel) undoes the drag. */ private _cancelSash = (e: PointerEvent) => { - const sash = this._sash; - if (sash) { - this._weights = { ...this._weights, [sash.aId]: sash.startA, [sash.bId]: sash.startB }; + if (this._sash) { + this._weights = this._sash.weights; } this._teardownSash(e.currentTarget as HTMLElement, e.pointerId); }; - // Capture lost without a pointerup (window blur, another element capturing): - // stop tracking, or a later move would resize with no button held. - private _onLostCapture = (e: PointerEvent) => { - this._teardownSash(e.currentTarget as HTMLElement, e.pointerId); - }; - - /** Detaches the drag; false if it had already ended. */ - private _teardownSash(sashEl: HTMLElement, pointerId: number): boolean { + /** Detaches the drag, unless it had already ended. */ + private _teardownSash(sashEl: HTMLElement, pointerId: number): void { if (!this._sash) { - return false; + return; } this._sash = null; if (sashEl.hasPointerCapture(pointerId)) { @@ -440,17 +842,21 @@ export class PaneView extends LitElement { sashEl.removeEventListener('pointermove', this._onSashMove); sashEl.removeEventListener('pointerup', this._endSash); sashEl.removeEventListener('pointercancel', this._cancelSash); - sashEl.removeEventListener('lostpointercapture', this._onLostCapture); - return true; + sashEl.removeEventListener('lostpointercapture', this._endSash); } /** * Back to each pane's default: a content pane returns to its content's size, - * and a pair of fill panes splits the space between them evenly. + * and a pair of fill panes splits the space between them evenly. Only the two + * beside the sash โ€” a drag that cascaded past them leaves those at the sizes + * it gave them, and `layoutEpoch` is what hands the whole stack back. */ private _resetSash(aId: string, bId: string) { const weights = { ...this._weights }; - const dragged = [aId, bId].filter((id) => this._isContent(id)); + const dragged = [aId, bId].filter((id) => { + const section = this.sections.find((candidate) => candidate.id === id); + return !!section && this._sizingOf(section) !== 'fill'; + }); if (dragged.length) { for (const id of dragged) { delete weights[id]; @@ -461,27 +867,5 @@ export class PaneView extends LitElement { weights[bId] = total / 2; } this._weights = weights; - this._emitResize(); - } - - private _isContent(id: string): boolean { - const section = this.sections.find((candidate) => candidate.id === id); - return !!section && !this._isFill(section); - } - - /** Fires on interaction-end only, so the consumer's write isn't per-frame. */ - private _emitResize() { - // Every size this axis holds, named by it, so the consumer can replace the - // axis: a pane reset to its content's size has no entry left to merge. - const sizes = Object.fromEntries( - Object.entries(this._weights).map(([id, size]) => [`${this.orientation}:${id}`, size]), - ); - this.dispatchEvent( - new CustomEvent('pane-resize', { - detail: { sizes, orientation: this.orientation }, - bubbles: true, - composed: true, - }), - ); } } diff --git a/log-viewer/src/components/__tests__/LogInspector.test.ts b/log-viewer/src/components/__tests__/LogInspector.test.ts index ac4a0c8f5..333fdc0ca 100644 --- a/log-viewer/src/components/__tests__/LogInspector.test.ts +++ b/log-viewer/src/components/__tests__/LogInspector.test.ts @@ -38,13 +38,18 @@ jest.mock('../../features/settings/Settings.js', () => ({ // every other test leaves it false and gets an immediately-resolved build. let deferSections = false; const pendingSections: Array<() => void> = []; +// What each build was told to hide, so skipped work is provable and not just +// filtered away afterwards. +const builtHiding: string[][] = []; jest.mock('../detailSections.js', () => ({ buildDetailSections: ( _source: string, selection: { eventIndex?: number } | null, active: { kind: string; eventIndex?: number; instances?: number[] } | null, sourceView?: string, + hidden: ReadonlySet = new Set(), ) => { + builtHiding.push([...hidden]); const walked = active?.kind === 'event' ? String(active.eventIndex) : '-'; const counted = active?.kind === 'aggregate' ? (active.instances?.join(',') ?? '-') : '-'; // The markers carry the anchor and the active frame through to the rendered @@ -63,6 +68,8 @@ jest.mock('../detailSections.js', () => ({ { id: 'callstack', title: 'Call stack', content: html`
    c
    ` }, ] : []; + // The real builder returns a hidden section too, so the header menu can + // still offer it; the panel leaves out what it does not show. if (!deferSections) { return Promise.resolve(sections); } @@ -78,6 +85,8 @@ import type { PaneView } from '../PaneView.js'; import type { ViewModeSwitch } from '../ViewModeSwitch.js'; import '../LogInspector.js'; import { dispatchInspectorLocate, dispatchInspectorReveal } from '../inspectorReveal.js'; +import type { ContextMenu } from '../ContextMenu.js'; +import { RESET_SECTIONS_ID } from '../sectionMenu.js'; /** * Settles the async section build and the render chain through the nested @@ -96,6 +105,11 @@ async function flush(el: LogInspector): Promise { await settle(el); } +/** The stored panel, open and docked right, plus whatever the test is about. */ +function inspectorSettings(overrides: Record = {}): Record { + return { position: 'right', size: 400, collapsed: {}, visible: true, ...overrides }; +} + async function mount(activeTab: string): Promise { const el = document.createElement('log-inspector') as LogInspector; el.activeTab = activeTab; @@ -140,6 +154,31 @@ function select(source: DetailSource, eventIndex: number): void { eventBus.emit('detail:select', { source, selection: { kind: 'event', eventIndex } }); } +/** The menu the inspector owns; the pane only reports the right-click. */ +function sectionMenu(el: LogInspector): ContextMenu { + const found = el.shadowRoot?.querySelector('context-menu'); + if (!found) { + throw new Error('context-menu not rendered'); + } + return found; +} + +function openSectionMenu(el: LogInspector, id = 'vitals'): void { + paneView(el).dispatchEvent( + new CustomEvent('pane-menu', { detail: { id, x: 10, y: 20 }, bubbles: true, composed: true }), + ); +} + +function pickMenuItem(el: LogInspector, itemId: string): void { + sectionMenu(el).dispatchEvent( + new CustomEvent('menu-select', { detail: { itemId }, bubbles: true, composed: true }), + ); +} + +function sectionIds(el: LogInspector): string[] { + return paneView(el).sections.map((section) => section.id); +} + function marker(el: LogInspector): string | null { return paneView(el).shadowRoot?.querySelector('.marker')?.textContent ?? null; } @@ -182,29 +221,26 @@ describe('LogInspector', () => { releaseSettings = null; deferSections = false; pendingSections.length = 0; + builtHiding.length = 0; document.body.replaceChildren(); }); - it('applies the persisted collapse, and keeps it when the tab changes', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: { callstack: true }, - paneSizes: {}, - visible: true, - }; + it('applies the persisted collapse to the list it was made in', async () => { + settings.inspector = inspectorSettings({ + collapsed: { 'database:detail:callstack': true }, + }); const el = await mount('database-tab'); select('database', 3); await flush(el); expect(paneView(el).collapsed).toEqual({ callstack: true }); - // One panel, one layout: a section's collapse follows it across tabs. + // One id means different content in two lists, so the collapse stays in its own. el.activeTab = 'timeline-tab'; select('timeline', 9); await flush(el); - expect(paneView(el).collapsed).toEqual({ callstack: true }); + expect(paneView(el).collapsed).toEqual({}); }); it('persists a collapse', async () => { @@ -220,7 +256,255 @@ describe('LogInspector', () => { }), ); - expect(written).toEqual([{ section: 'inspector.collapsed', value: { callstack: true } }]); + expect(written).toEqual([ + { section: 'inspector.collapsed', value: { 'timeline:detail:callstack': true } }, + ]); + }); + + it('persists a reorder, and applies it to the list on screen', async () => { + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['vitals', 'callstack']); + + paneView(el).dispatchEvent( + new CustomEvent('pane-reorder', { + detail: { ids: ['callstack', 'vitals'] }, + bubbles: true, + composed: true, + }), + ); + await settle(el); + + expect(written).toEqual([ + { section: 'inspector.sectionOrder', value: { 'timeline:detail': ['callstack', 'vitals'] } }, + ]); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['callstack', 'vitals']); + }); + + it('keeps a section this selection never built in the order it stores', async () => { + // Arranged while a SOQL statement was selected, so the store names a + // section the timeline's list does not build. + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['issues', 'vitals', 'callstack'] }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + paneView(el).dispatchEvent( + new CustomEvent('pane-reorder', { + detail: { ids: ['callstack', 'vitals'] }, + bubbles: true, + composed: true, + }), + ); + await settle(el); + + expect(written).toEqual([ + { + section: 'inspector.sectionOrder', + value: { 'timeline:detail': ['issues', 'callstack', 'vitals'] }, + }, + ]); + }); + + it('hides a section from the menu, and stops building it', async () => { + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + + openSectionMenu(el); + pickMenuItem(el, 'section:callstack'); + await flush(el); + + expect(written).toEqual([ + { section: 'inspector.hiddenSections', value: { 'timeline:detail:callstack': true } }, + ]); + expect(sectionIds(el)).toEqual(['vitals']); + + // Hiding only drops it from the list; every build from here is told to skip + // it, so its content is never built again. + select('timeline', 2); + await flush(el); + expect(builtHiding.at(-1)).toEqual(['callstack']); + }); + + it('offers a hidden section back, and hides it in that list only', async () => { + settings.inspector = inspectorSettings({ + hiddenSections: { 'timeline:detail:callstack': true }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals']); + + // Another list keeps the section. + el.activeTab = 'database-tab'; + select('database', 2); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + + // The menu still offers the hidden one, so it can come back. + el.activeTab = 'timeline-tab'; + await flush(el); + openSectionMenu(el); + expect(sectionMenu(el).items.map((item) => [item.id, item.checked])).toEqual([ + [RESET_SECTIONS_ID, undefined], + ['section-sep', undefined], + ['section:vitals', true], + ['section:callstack', false], + ]); + + pickMenuItem(el, 'section:callstack'); + await flush(el); + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(written.at(-1)).toEqual({ section: 'inspector.hiddenSections', value: {} }); + }); + + it('shows every section again when the stored set would hide them all', async () => { + settings.inspector = inspectorSettings({ + // A list whose sections have changed since: the panel must not end up with + // no header to right-click, since that menu is the only way back. + hiddenSections: { + 'timeline:detail:vitals': true, + 'timeline:detail:callstack': true, + }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + openSectionMenu(el); + expect(sectionMenu(el).items.filter((item) => item.checked === false)).toEqual([]); + }); + + it('keeps a section hidden when the toggle lands mid-build', async () => { + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + // A build is in flight, holding the set it was told to skip from before it + // awaited; the toggle must not be undone when that build lands. + deferSections = true; + select('timeline', 2); + await new Promise((resolve) => requestAnimationFrame(resolve)); + openSectionMenu(el); + pickMenuItem(el, 'section:callstack'); + await settle(el); + expect(sectionIds(el)).toEqual(['vitals']); + + pendingSections[0]!(); + await settle(el); + + expect(sectionIds(el)).toEqual(['vitals']); + }); + + it('re-ticks the menu from the layout a slow rebuild produced', async () => { + settings.inspector = inspectorSettings({ + hiddenSections: { 'timeline:detail:callstack': true }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + openSectionMenu(el); + + // Bringing it back rebuilds, because its build skipped work. The row must + // read from that rebuild, not from the state before the click. + deferSections = true; + pickMenuItem(el, 'section:callstack'); + await settle(el); + pendingSections[0]!(); + await settle(el); + + expect( + sectionMenu(el) + .items.filter((item) => item.id.startsWith('section:')) + .map((item) => [item.id, item.checked]), + ).toEqual([ + ['section:vitals', true], + ['section:callstack', true], + ]); + }); + + it('rebuilds the sections it un-hides when the stored set hid them all', async () => { + settings.inspector = inspectorSettings({ + hiddenSections: { + 'timeline:detail:vitals': true, + 'timeline:detail:callstack': true, + }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + + // Their first build was told to skip them, so what it left out has to be + // built again rather than shown empty. + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(builtHiding.at(-1)).toEqual([]); + }); + + it('resets a list that only has an order, back to the order it is built in', async () => { + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['callstack', 'vitals'] }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['callstack', 'vitals']); + + openSectionMenu(el, 'callstack'); + pickMenuItem(el, RESET_SECTIONS_ID); + await flush(el); + + // Nothing was hidden, so nothing needs rebuilding โ€” the built order still + // has to come back. + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(paneView(el).layoutEpoch).toBe(1); + }); + + it('resets this list only: built order, every section back, sizes automatic', async () => { + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['callstack', 'vitals'] }, + hiddenSections: { + 'timeline:detail:vitals': true, + 'analysis:detail:callstack': true, + }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(sectionIds(el)).toEqual(['callstack']); + + openSectionMenu(el, 'callstack'); + pickMenuItem(el, RESET_SECTIONS_ID); + await flush(el); + + expect(sectionIds(el)).toEqual(['vitals', 'callstack']); + expect(written).toEqual([ + { section: 'inspector.sectionOrder', value: {} }, + // The Analysis list's own choice is untouched. + { section: 'inspector.hiddenSections', value: { 'analysis:detail:callstack': true } }, + ]); + // The panes' dragged sizes go with it. + expect(paneView(el).layoutEpoch).toBe(1); + }); + + it('applies a persisted order to its own list only', async () => { + settings.inspector = inspectorSettings({ + sectionOrder: { 'timeline:detail': ['callstack', 'vitals'] }, + }); + const el = await mount('timeline-tab'); + select('timeline', 1); + await flush(el); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['callstack', 'vitals']); + + el.activeTab = 'database-tab'; + select('database', 2); + await flush(el); + expect(paneView(el).sections.map((section) => section.id)).toEqual(['vitals', 'callstack']); }); it('auto-opens on the first selection only while the user has never chosen', async () => { @@ -231,13 +515,9 @@ describe('LogInspector', () => { }); it('stays closed when the user closed it before, and remembers each choice', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: {}, - paneSizes: {}, + settings.inspector = inspectorSettings({ visible: false, - }; + }); const el = await mount('timeline-tab'); select('timeline', 1); await el.updateComplete; @@ -250,13 +530,10 @@ describe('LogInspector', () => { }); it('keeps what the user did while the settings load was still in flight', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: { callstack: true }, - paneSizes: {}, + settings.inspector = inspectorSettings({ + collapsed: { 'timeline:detail:callstack': true }, visible: false, - }; + }); deferSettings = true; const el = document.createElement('log-inspector') as LogInspector; el.activeTab = 'timeline-tab'; @@ -477,13 +754,7 @@ describe('LogInspector', () => { }); it('shows a source-specific empty state, and updates it as the active tab changes', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: {}, - paneSizes: {}, - visible: true, - }; + settings.inspector = inspectorSettings(); const el = await mount('timeline-tab'); expect(emptyText(el)).toBe('Select a frame on the timeline to inspect it.'); @@ -501,13 +772,7 @@ describe('LogInspector', () => { }); it('returns to the whole-log empty state when a null selection clears the source', async () => { - settings.inspector = { - position: 'right', - size: 400, - collapsed: {}, - paneSizes: {}, - visible: true, - }; + settings.inspector = inspectorSettings(); const el = await mount('timeline-tab'); select('timeline', 1); await flush(el); diff --git a/log-viewer/src/components/__tests__/PaneView.test.ts b/log-viewer/src/components/__tests__/PaneView.test.ts index b67d23715..c12c7e108 100644 --- a/log-viewer/src/components/__tests__/PaneView.test.ts +++ b/log-viewer/src/components/__tests__/PaneView.test.ts @@ -22,36 +22,75 @@ const sections: PaneSection[] = [ * Collapse is controlled: the consumer owns the record and feeds it back. Mount * with that loop wired, the way the inspector does. */ -async function mount(orientation: PaneOrientation): Promise { +async function mountSections( + paneSections: PaneSection[], + props: Partial = {}, +): Promise { const el = document.createElement('pane-view') as PaneView; - el.sections = sections; - el.orientation = orientation; + Object.assign(el, { orientation: 'vertical', sections: paneSections }, props); + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +/** With the collapse loop wired, the way the inspector owns the record. */ +async function mount(orientation: PaneOrientation): Promise { + const el = await mountSections(sections, { orientation }); el.addEventListener('pane-toggle', (e) => { el.collapsed = (e as CustomEvent<{ collapsed: Record }>).detail.collapsed; }); - document.body.appendChild(el); - await el.updateComplete; return el; } -function header(el: PaneView, id: string): HTMLElement | null { - return el.shadowRoot?.querySelector(`.pane[data-id="${id}"] .pane-header`) ?? null; +function pane(el: PaneView, id: string): HTMLElement | null { + return el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`) ?? null; +} + +function paneIds(el: PaneView): string[] { + return [...(el.shadowRoot?.querySelectorAll('.pane') ?? [])].map( + (found) => found.dataset.id ?? '', + ); } function body(el: PaneView, id: string): HTMLElement | null { return el.shadowRoot?.querySelector(`.pane[data-id="${id}"] .pane-body`) ?? null; } -function sash(el: PaneView): HTMLElement { - const found = el.shadowRoot?.querySelector('.pane-sash'); +function sash(el: PaneView, index = 0): HTMLElement { + const found = el.shadowRoot?.querySelectorAll('.pane-sash')[index]; if (!found) { throw new Error('sash not rendered'); } return found as HTMLElement; } -function paneStyle(el: PaneView, id: string): string { - return el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`)?.getAttribute('style') ?? ''; +/** + * What the pane hands the stylesheet: the rule it selects, then its weight and + * the size it was dragged to. The flex itself is in CSS, which jsdom does not + * compute. + */ +function sizing(el: PaneView, id: string): string { + const found = pane(el, id); + if (!found) { + throw new Error(`no pane for ${id}`); + } + if (!found.hasAttribute('data-open')) { + return 'closed'; + } + const parts = [found.getAttribute('data-tier') ?? found.getAttribute('data-sizing') ?? '']; + for (const name of ['--pane-grow', '--pane-size']) { + const value = found.style.getPropertyValue(name); + if (value) { + parts.push(value); + } + } + return parts.join(' '); +} + +/** The share the fill panes resolve from, or null while they resolve from zero. */ +function share(el: PaneView): string | null { + const view = stack(el); + return view.hasAttribute('data-content') ? view.style.getPropertyValue('--pane-count') : null; } // jsdom has no PointerEvent; the handlers only read the coordinate and pointerId. @@ -61,6 +100,54 @@ function pointer(type: string, clientY: number): Event { }); } +function headerOf(el: PaneView, id: string): HTMLElement { + const found = el.shadowRoot?.querySelector(`.pane[data-id="${id}"] .pane-header`); + if (!found) { + throw new Error(`no header for ${id}`); + } + return found; +} + +// jsdom has no DragEvent; the handlers only read the coordinate, and treat a +// missing dataTransfer as nothing to carry. +function dragEvent(type: string, clientY = 0): Event { + return new MouseEvent(type, { clientY, bubbles: true, cancelable: true }); +} + +/** The headers are 20px tall here: above 10 drops before, below it drops after. */ +function stack(el: PaneView): HTMLElement { + const found = el.shadowRoot?.querySelector('.pane-view'); + if (!found) { + throw new Error('stack not rendered'); + } + return found; +} + +/** The whole stack takes the drop, so only where the pointer is decides. */ +async function dragSection(el: PaneView, fromId: string, clientY: number): Promise { + headerOf(el, fromId).dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', clientY)); + await el.updateComplete; + stack(el).dispatchEvent(dragEvent('drop', clientY)); + await el.updateComplete; +} + +function reorders(el: PaneView): string[][] { + const seen: string[][] = []; + el.addEventListener('pane-reorder', (e) => { + seen.push((e as CustomEvent<{ ids: string[] }>).detail.ids); + }); + return seen; +} + +/** Every pane measures 100px in jsdom, so a +20 drag makes the pair 120/80. */ +async function drag(el: PaneView, handle: HTMLElement, to = 120): Promise { + handle.dispatchEvent(pointer('pointerdown', 100)); + handle.dispatchEvent(pointer('pointermove', to)); + handle.dispatchEvent(pointer('pointerup', to)); + await el.updateComplete; +} + describe('PaneView', () => { beforeAll(() => { expect(customElements.get('pane-view')).toBeDefined(); @@ -73,6 +160,26 @@ describe('PaneView', () => { value: 100, configurable: true, }); + // jsdom performs no layout either, so the panes need boxes before a drop + // coordinate can name one. Stacked 100 tall, matching `offsetHeight`: `a` + // is 0-100, `b` 100-200, `c` 200-300. + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + const isPane = this.classList.contains('pane'); + const top = isPane + ? [...(this.parentElement?.querySelectorAll('.pane') ?? [])].indexOf(this) * 100 + : 0; + const height = isPane ? 100 : 20; + return { + top, + left: 0, + width: 200, + height, + bottom: top + height, + right: 200, + x: 0, + y: top, + } as unknown as DOMRect; + }; }); it('renders a header per section with a twistie when vertical', async () => { @@ -92,7 +199,7 @@ describe('PaneView', () => { it('collapses a section on header click, removing its body and its sashes', async () => { const el = await mount('vertical'); - header(el, 'b')?.click(); + headerOf(el, 'b').click(); await el.updateComplete; expect(body(el, 'b')).toBeNull(); @@ -105,21 +212,21 @@ describe('PaneView', () => { it('toggles with the keyboard (Enter)', async () => { const el = await mount('vertical'); - const h = header(el, 'a'); - h?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + const h = headerOf(el, 'a'); + h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await el.updateComplete; expect(body(el, 'a')).toBeNull(); }); it('ignores a held Enter, so the pane does not flap', async () => { const el = await mount('vertical'); - const h = header(el, 'a'); + const h = headerOf(el, 'a'); let toggles = 0; el.addEventListener('pane-toggle', () => toggles++); - h?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - h?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, repeat: true })); - h?.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, repeat: true })); + h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + h.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, repeat: true })); + h.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, repeat: true })); await el.updateComplete; expect(toggles).toBe(1); @@ -134,7 +241,7 @@ describe('PaneView', () => { cancelable: true, repeat: true, }); - header(el, 'a')?.dispatchEvent(event); + headerOf(el, 'a').dispatchEvent(event); expect(event.defaultPrevented).toBe(true); }); @@ -145,7 +252,7 @@ describe('PaneView', () => { expect(el.shadowRoot?.querySelectorAll('.pane-header vscode-icon').length).toBe(0); expect(el.shadowRoot?.querySelector('.pane-header--button')).toBeNull(); - header(el, 'b')?.click(); + headerOf(el, 'b').click(); await el.updateComplete; expect(body(el, 'b')).not.toBeNull(); // All three open โ†’ two sashes between neighbours. @@ -159,7 +266,7 @@ describe('PaneView', () => { last = (e as CustomEvent<{ collapsed: Record }>).detail.collapsed; }); - header(el, 'a')?.click(); + headerOf(el, 'a').click(); await el.updateComplete; expect(last?.a).toBe(true); expect(body(el, 'a')).toBeNull(); @@ -172,63 +279,129 @@ describe('PaneView', () => { }); it('takes collapse from the collapsed property', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = sections; + const el = await mountSections(sections); el.collapsed = { b: true }; - document.body.appendChild(el); await el.updateComplete; expect(body(el, 'a')).not.toBeNull(); expect(body(el, 'b')).toBeNull(); }); it('does not collapse when the consumer ignores pane-toggle (fully controlled)', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = sections; - document.body.appendChild(el); - await el.updateComplete; + const el = await mountSections(sections); - header(el, 'a')?.click(); + headerOf(el, 'a').click(); await el.updateComplete; expect(body(el, 'a')).not.toBeNull(); }); - it('emits pane-resize on a sash drag, the pair sharing their combined size', async () => { + it('shares the pair their combined size on a sash drag', async () => { const el = await mount('vertical'); - let sizes: Record | undefined; - el.addEventListener('pane-resize', (e) => { - sizes = (e as CustomEvent<{ sizes: Record }>).detail.sizes; - }); + await drag(el, sash(el)); - const handle = sash(el); + // The dragged size is the basis, so the pane is that size however tight the + // panel gets; the weight rescaled onto the unit scale shares any free space. + expect(sizing(el, 'a')).toBe('fill 1.2 120px'); + expect(sizing(el, 'b')).toBe('fill 0.8 80px'); + }); + + it('holds every pane at its measured size, through the drag and after it', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, + { id: 'b', title: 'B', content: html`
    B
    ` }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); + + // Mid-gesture, with `a` nowhere near the b-c sash the drag names. + const handle = sash(el, 1); handle.dispatchEvent(pointer('pointerdown', 100)); handle.dispatchEvent(pointer('pointermove', 120)); + await el.updateComplete; + + // Its basis is its measured size, not its content: the bases add up to the + // panel, so flexbox shrinks nothing and the boundary tracks the pointer. + expect(sizing(el, 'a')).toBe('content 100px'); + handle.dispatchEvent(pointer('pointerup', 120)); await el.updateComplete; - // Keyed by axis: a height dragged here is not a width in the bottom dock. - expect(sizes?.['vertical:a']).toBe(120); - expect(sizes?.['vertical:b']).toBe(80); + // And keeps it: handing a pane back to sizing itself frees the room it holds + // to the panes the drag sized, which shrinks a section nobody dragged. + expect(sizing(el, 'a')).toBe('content 100px'); + }); + + it('leaves the panes away from the sash at the size they had', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, + { id: 'b', title: 'B', content: html`
    B
    ` }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); + + // The b-c sash, so the drag never names the content pane above it. + await drag(el, sash(el, 1)); + + expect(sizing(el, 'a')).toBe('content 100px'); + expect(sizing(el, 'b')).toBe('fill 1.2 120px'); + expect(sizing(el, 'c')).toBe('fill 0.8 80px'); + }); + + it('rescales the dragged sizes onto the unit scale the weights use', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, weight: 3 }, + { id: 'b', title: 'B', content: html`
    B
    ` }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); + + await drag(el, sash(el, 1)); + + // Every open pane holds a size, so the sizes decide how free space splits; + // the weights are the scale those sizes land on. + expect(sizing(el, 'a')).toBe('fill 1.6666666666666667 100px'); + expect(sizing(el, 'b')).toBe('fill 2 120px'); + expect(sizing(el, 'c')).toBe('fill 1.3333333333333333 80px'); + }); + + it('leaves no size behind for a drag that ends where it began', async () => { + const el = await mount('vertical'); + const handle = sash(el); + handle.dispatchEvent(pointer('pointerdown', 100)); + handle.dispatchEvent(pointer('pointermove', 140)); + handle.dispatchEvent(pointer('pointermove', 100)); + handle.dispatchEvent(pointer('pointerup', 100)); + await el.updateComplete; + + // Nothing moved on screen, so nothing is pinned: the sections go on sizing + // themselves. + expect(sizing(el, 'a')).toBe('fill 1'); + expect(sizing(el, 'b')).toBe('fill 1'); }); - it('does not emit pane-resize for a sash click that never moved', async () => { + it('hands the stack back when a section opens with no size of its own', async () => { + const el = await mount('vertical'); + await drag(el, sash(el)); + expect(sizing(el, 'a')).toBe('fill 1.2 120px'); + + el.sections = [...sections, { id: 'd', title: 'D', content: html`
    D
    ` }]; + await el.updateComplete; + + // The newcomer has no size, and the sized panes leave it no share: it would + // open at its floor, so every section shares the panel again. + expect(sizing(el, 'a')).toBe('fill 1'); + expect(sizing(el, 'd')).toBe('fill 1'); + }); + + it('leaves the sizes alone for a sash click that never moved', async () => { const el = await mount('vertical'); - let emitted = 0; - el.addEventListener('pane-resize', () => emitted++); const handle = sash(el); handle.dispatchEvent(pointer('pointerdown', 100)); handle.dispatchEvent(pointer('pointerup', 100)); await el.updateComplete; - expect(emitted).toBe(0); + expect(sizing(el, 'a')).toBe(sizing(el, 'c')); }); - it('does not emit pane-resize when the drag is cancelled, and restores the sizes', async () => { + it('restores the sizes when the drag is cancelled', async () => { const el = await mount('vertical'); - let emitted = 0; - el.addEventListener('pane-resize', () => emitted++); const handle = sash(el); handle.dispatchEvent(pointer('pointerdown', 100)); @@ -236,149 +409,234 @@ describe('PaneView', () => { handle.dispatchEvent(pointer('pointercancel', 120)); await el.updateComplete; - expect(emitted).toBe(0); // Back to the measured 100/100, so a-and-b weigh the same again. - const style = (id: string) => - el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`)?.getAttribute('style'); - expect(style('a')).toBe(style('b')); + expect(sizing(el, 'a')).toBe(sizing(el, 'b')); // The gesture is over: a stray move can no longer resize. handle.dispatchEvent(pointer('pointermove', 200)); await el.updateComplete; - expect(style('a')).toBe(style('b')); + expect(sizing(el, 'a')).toBe(sizing(el, 'b')); }); - it('keeps the persisted ratio when every open pane has a size', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ - { id: 'a', title: 'A', content: html`
    A
    ` }, - { id: 'b', title: 'B', content: html`
    B
    ` }, - ]; - el.paneSizes = { 'vertical:a': 300, 'vertical:b': 100 }; - document.body.appendChild(el); + // A height dragged down the side is not a width along the bottom, and a reset + // hands every size back: one guard in `willUpdate`, both its arms. + it.each([ + ['the panel is re-docked', (el: PaneView) => (el.orientation = 'horizontal')], + ['the layout is reset', (el: PaneView) => el.layoutEpoch++], + ])('drops the dragged sizes when %s', async (_name, change) => { + const el = await mount('vertical'); + await drag(el, sash(el)); + expect(sizing(el, 'a')).toBe('fill 1.2 120px'); + + change(el); await el.updateComplete; - // 2 units over 400px โ†’ 3:1, the dragged ratio. - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 1.5 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 0.5 1 0'); + expect(sizing(el, 'a')).toBe('fill 1'); + expect(sizing(el, 'b')).toBe('fill 1'); }); - it('rescales persisted pane sizes onto the weight scale of a pane without one', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ - { id: 'a', title: 'A', content: html`
    A
    `, weight: 3 }, + it('sizes a content pane to its content, shrinkable, and never stretches it', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, { id: 'b', title: 'B', content: html`
    B
    ` }, - ]; - // Only a was on screen when the drag happened; b must not become a sliver. - el.paneSizes = { 'vertical:a': 120 }; - document.body.appendChild(el); - await el.updateComplete; + ]); - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 3 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 1 1 0'); + expect(sizing(el, 'a')).toBe('content'); + expect(sizing(el, 'b')).toBe('fill 1'); + expect(share(el)).toBe('2'); }); - it('ignores sizes dragged on the other axis, and re-seeds when re-docked', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ - { id: 'a', title: 'A', content: html`
    A
    ` }, + it('renders a sash beside a content pane too', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, { id: 'b', title: 'B', content: html`
    B
    ` }, - ]; - // Widths from the bottom dock; replaying them as heights is a layout the - // user never chose, so the vertical dock falls back to even weights. - el.paneSizes = { 'horizontal:a': 300, 'horizontal:b': 100 }; - document.body.appendChild(el); - await el.updateComplete; + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 1 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 1 1 0'); + // aโ†”b and bโ†”c: a content pane holds the size it is dragged to. + expect(el.shadowRoot?.querySelectorAll('.pane-sash').length).toBe(2); + }); - // Re-docking flips orientation on the same element: its own sizes apply now. - el.orientation = 'horizontal'; - await el.updateComplete; - expect(pane('a')?.getAttribute('style')).toContain('flex: 1.5 1 0'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 0.5 1 0'); + it('pins a content pane to its dragged size, and keeps it out of the fill scale', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, + { id: 'b', title: 'B', content: html`
    B
    ` }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); + + await drag(el, sash(el)); + + // A basis, not a weight: it never stretches, and it still shrinks to scroll. + expect(sizing(el, 'a')).toBe('content 120px'); + // The content pane's size is no part of the fill panes' weights, so b and c + // split what is left between the two of them. + expect(sizing(el, 'b')).toBe('fill 0.888888888888889 80px'); + expect(sizing(el, 'c')).toBe('fill 1.1111111111111112 100px'); + expect(share(el)).toBe('3'); }); - it('sizes a content pane to its content, shrinkable, and never stretches it', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + it('takes a fill pane down to the same size a content pane goes to', async () => { + const el = await mountSections([ { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, { id: 'b', title: 'B', content: html`
    B
    ` }, - ]; - document.body.appendChild(el); - await el.updateComplete; + { id: 'c', title: 'C', content: html`
    C
    `, weight: 4 }, + ]); + + // Drag b down to 60 of the pair's 200px. + await drag(el, sash(el, 1), 60); - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 0 1 auto'); - expect(pane('b')?.getAttribute('style')).toContain('flex: 1 1 calc(100% / 2)'); + // Both kinds end up sized by the same basis, so neither bottoms out sooner + // than the other โ€” `--lana-pane-min` alone decides how small that is, and it + // is one value for every section. + expect(sizing(el, 'b')).toBe('fill 1.5 60px'); + expect(sizing(el, 'c')).toBe('fill 3.5 140px'); }); - it('renders a sash beside a content pane too', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + it('gives a content pane the room it was dragged to, past its content', async () => { + const el = await mountSections([ { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, { id: 'b', title: 'B', content: html`
    B
    ` }, - { id: 'c', title: 'C', content: html`
    C
    ` }, - ]; - document.body.appendChild(el); - await el.updateComplete; + ]); - // aโ†”b and bโ†”c: a content pane holds the size it is dragged to. - expect(el.shadowRoot?.querySelectorAll('.pane-sash').length).toBe(2); + await drag(el, sash(el), 160); + // The drag is what the user asked for; a double-click is how they take it back. + expect(sizing(el, 'a')).toBe('content 160px'); }); - it('pins a content pane to its dragged size, and keeps it out of the fill scale', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + it('drags every section to the same floor, whatever its neighbour asks for', async () => { + // A pane's floor must not depend on what its neighbour holds. + const el = await mountSections([ { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, + { id: 'b', title: 'B', content: html`
    B
    `, fit: 'content' }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); + + await drag(el, sash(el, 0), -500); + await drag(el, sash(el, 1), -500); + + // `--lana-pane-min` is the only floor, and jsdom resolves no layout, so both + // sections reach the same 0 โ€” a content pane above a content pane, and one + // above a fill pane. + expect(sizing(el, 'a')).toBe('content 0px'); + expect(sizing(el, 'b')).toBe('content 0px'); + }); + + describe('height tier', () => { + const tiered = (): PaneSection[] => [ + { id: 'a', title: 'A', content: html`
    A
    `, height: 'md' }, { id: 'b', title: 'B', content: html`
    B
    ` }, { id: 'c', title: 'C', content: html`
    C
    ` }, ]; - el.paneSizes = { 'vertical:a': 500, 'vertical:b': 300, 'vertical:c': 100 }; - document.body.appendChild(el); - await el.updateComplete; - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - // A basis, not a weight: it never stretches, and it still shrinks to scroll. - expect(pane('a')?.getAttribute('style')).toContain('flex: 0 1 500px'); - // The content pane's size is no part of the fill panes' weights. - expect(pane('b')?.getAttribute('style')).toContain('flex: 1.5 1 calc(100% / 3)'); - expect(pane('c')?.getAttribute('style')).toContain('flex: 0.5 1 calc(100% / 3)'); + it('leaves a tiered section to CSS, so the token is what sizes it', async () => { + const el = await mountSections(tiered()); + + // The tier and nothing else: no weight, no dragged size, so the token in + // the stylesheet is the whole answer. + expect(sizing(el, 'a')).toBe('md'); + }); + + it('leaves the fill panes sharing what is left, so their weights decide', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, height: 'md' }, + { id: 'b', title: 'B', content: html`
    B
    `, weight: 2 }, + { id: 'c', title: 'C', content: html`
    C
    `, weight: 4 }, + ]); + + // A tier is a bounded slot, so the fill panes resolve from zero and split + // the rest 2:4. A share each would over-subscribe the panel, and flexbox + // shrinks by basis alone โ€” which would flatten 2 against 4. + expect(sizing(el, 'b')).toBe('fill 2'); + expect(sizing(el, 'c')).toBe('fill 4'); + expect(share(el)).toBeNull(); + }); + + it('still hands the fill panes a share beside a content pane', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    `, height: 'md' }, + { id: 'b', title: 'B', content: html`
    B
    `, fit: 'content' }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); + + // Nothing bounds a content pane, so the guard against it squeezing the + // grids to the floor stays. + expect(sizing(el, 'c')).toBe('fill 1'); + expect(share(el)).toBe('3'); + }); + + it('hands the tier over to a dragged size', async () => { + const el = await mountSections(tiered()); + + await drag(el, sash(el), 140); + + // Still on its tier; the dragged size is what every rule reads first. + expect(sizing(el, 'a')).toBe('md 140px'); + }); + + it('shares the width like any fill pane when the sections sit side by side', async () => { + const el = await mountSections(tiered(), { orientation: 'horizontal' }); + + // A height means nothing along this axis. + expect(pane(el, 'a')?.hasAttribute('data-tier')).toBe(false); + expect(sizing(el, 'a')).toBe('fill 1'); + }); + + it('drops the tier while the section is collapsed', async () => { + const el = await mountSections(tiered()); + el.collapsed = { a: true }; + await el.updateComplete; + + expect(sizing(el, 'a')).toBe('closed'); + }); + }); + + it('shrinks the pane above, then the one above that, out to the top', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    ` }, + { id: 'b', title: 'B', content: html`
    B
    ` }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + { id: 'd', title: 'D', content: html`
    D
    ` }, + ]); + + // Each pane is 100 here. Dragging the b-c sash to the top makes c as large + // as the stack allows: b gives up its room first, then a. + await drag(el, sash(el, 1), -1000); + + expect(sizing(el, 'b')).toBe('fill 0 0px'); + expect(sizing(el, 'a')).toBe('fill 0 0px'); + expect(sizing(el, 'c')).toBe('fill 3 300px'); + // d is past the far end of the drag, and keeps its size: the room the + // cascade moved never comes out of a section the reader did not drag. + expect(sizing(el, 'd')).toBe('fill 1 100px'); + }); + + it('shrinks the pane below, then the one below that, out to the bottom', async () => { + const el = await mountSections([ + { id: 'a', title: 'A', content: html`
    A
    ` }, + { id: 'b', title: 'B', content: html`
    B
    ` }, + { id: 'c', title: 'C', content: html`
    C
    ` }, + ]); + + await drag(el, sash(el, 0), 1000); + + expect(sizing(el, 'b')).toBe('fill 0 0px'); + expect(sizing(el, 'c')).toBe('fill 0 0px'); + expect(sizing(el, 'a')).toBe('fill 3 300px'); }); it('hands a content pane back to its content on a double-click', async () => { - const el = document.createElement('pane-view') as PaneView; - el.orientation = 'vertical'; - el.sections = [ + const el = await mountSections([ { id: 'a', title: 'A', content: html`
    A
    `, fit: 'content' }, { id: 'b', title: 'B', content: html`
    B
    ` }, - ]; - el.paneSizes = { 'vertical:a': 500 }; - document.body.appendChild(el); - await el.updateComplete; + ]); + + await drag(el, sash(el)); + expect(sizing(el, 'a')).toBe('content 120px'); - let detail: { sizes: Record; orientation: string } | undefined; - el.addEventListener('pane-resize', (e) => { - detail = (e as CustomEvent<{ sizes: Record; orientation: string }>).detail; - }); sash(el).dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); await el.updateComplete; - const pane = (id: string) => el.shadowRoot?.querySelector(`.pane[data-id="${id}"]`); - expect(pane('a')?.getAttribute('style')).toContain('flex: 0 1 auto'); - // The size is gone rather than zeroed, so the consumer replaces the axis. - expect(detail?.sizes['vertical:a']).toBeUndefined(); - expect(detail?.orientation).toBe('vertical'); + expect(sizing(el, 'a')).toBe('content'); }); describe('fill pane share', () => { @@ -388,51 +646,269 @@ describe('PaneView', () => { { id: 'c', title: 'C', content: html`
    C
    ` }, ]; - async function mountMixed( - orientation: PaneOrientation, - props: Partial = {}, - ): Promise { - const el = document.createElement('pane-view') as PaneView; - Object.assign(el, { orientation, sections: mixed }, props); - document.body.appendChild(el); - await el.updateComplete; - return el; - } - it('gives the fill pane a share to shrink from, and leaves the content panes alone', async () => { - const el = await mountMixed('vertical'); + const el = await mountSections(mixed); // Three open panes, so the fill pane starts from a third and grows. - expect(paneStyle(el, 'c')).toContain('flex: 1 1 calc(100% / 3)'); - expect(paneStyle(el, 'a')).toContain('flex: 0 1 auto'); - expect(paneStyle(el, 'b')).toContain('flex: 0 1 auto'); + expect(share(el)).toBe('3'); + expect(sizing(el, 'c')).toBe('fill 1'); + expect(sizing(el, 'a')).toBe('content'); + expect(sizing(el, 'b')).toBe('content'); }); it('widens the share as sections collapse', async () => { - const el = await mountMixed('vertical', { collapsed: { b: true } }); + const el = await mountSections(mixed, { collapsed: { b: true } }); - expect(paneStyle(el, 'c')).toContain('flex: 1 1 calc(100% / 2)'); + expect(share(el)).toBe('2'); }); it('shares by weight alone once no content pane is open', async () => { - const el = await mountMixed('vertical', { collapsed: { a: true, b: true } }); + const el = await mountSections(mixed, { collapsed: { a: true, b: true } }); // One section read on its own gets the whole panel either way, and with // several fill panes a share each would flatten their weights. - expect(paneStyle(el, 'c')).toContain('flex: 1 1 0'); + expect(share(el)).toBeNull(); + expect(sizing(el, 'c')).toBe('fill 1'); }); - it('leaves a dragged content pane at the size it was given', async () => { - const el = await mountMixed('vertical', { paneSizes: { 'vertical:a': 500 } }); + it('leaves a dragged content pane at the size it was dragged to', async () => { + const el = await mountSections(mixed); + + await drag(el, sash(el)); - expect(paneStyle(el, 'a')).toContain('flex: 0 1 500px'); - expect(paneStyle(el, 'c')).toContain('flex: 1 1 calc(100% / 3)'); + expect(sizing(el, 'a')).toBe('content 120px'); + expect(sizing(el, 'b')).toBe('content 80px'); + expect(sizing(el, 'c')).toBe('fill 1 100px'); + expect(share(el)).toBe('3'); }); it('shares nothing side by side, where the axis is the width', async () => { - const el = await mountMixed('horizontal'); + const el = await mountSections(mixed, { orientation: 'horizontal' }); + + expect(share(el)).toBeNull(); + expect(sizing(el, 'c')).toBe('fill 1'); + }); + }); + + describe('reorder', () => { + it('moves the panes it already has, rather than rebuilding them', async () => { + const el = await mount('vertical'); + const before = pane(el, 'a'); + + el.sections = [sections[2]!, sections[0]!, sections[1]!]; + await el.updateComplete; + + // Same element: a rebuilt pane would re-mount its body's grid and lose + // the scroll, the expanded rows and the row the user picked. + expect(pane(el, 'a')).toBe(before); + expect(paneIds(el)).toEqual(['c', 'a', 'b']); + }); + + it('drops a section before the one it is over the top half of', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + await dragSection(el, 'c', 20); + + expect(seen).toEqual([['c', 'a', 'b']]); + }); + + it('drops it after the one it is over the bottom half of', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + // Over `c`'s body, nowhere near a header: the section under the pointer is + // what the drop reads. + await dragSection(el, 'a', 280); - expect(paneStyle(el, 'c')).toContain('flex: 1 1 0'); + expect(seen).toEqual([['b', 'c', 'a']]); }); + + it('marks the edge the section would land on, and the one being dragged', async () => { + const el = await mount('vertical'); + + headerOf(el, 'c').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 20)); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.pane[data-id="a"]')?.className).toContain( + 'pane--drop-before', + ); + expect(el.shadowRoot?.querySelector('.pane[data-id="c"]')?.className).toContain( + 'pane--dragging', + ); + + // The gesture ends: no mark is left behind. + headerOf(el, 'c').dispatchEvent(dragEvent('dragend')); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.pane--drop-before')).toBeNull(); + expect(el.shadowRoot?.querySelector('.pane--dragging')).toBeNull(); + }); + + it('marks the last edge when the section would land at the end', async () => { + const el = await mount('vertical'); + + headerOf(el, 'a').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 280)); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.pane[data-id="c"]')?.className).toContain( + 'pane--drop-after', + ); + // One boundary, one mark: nothing else is marked. + expect(el.shadowRoot?.querySelectorAll('.pane--drop-before').length).toBe(0); + }); + + it('marks one edge however the boundary was reached', async () => { + const el = await mount('vertical'); + + // The bottom of `a` and the top of `b` are the same boundary, and `c` + // would land on it either way. + headerOf(el, 'c').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 80)); + await el.updateComplete; + const fromBelow = el.shadowRoot?.querySelector('.pane--drop-before')?.getAttribute('data-id'); + + stack(el).dispatchEvent(dragEvent('dragover', 120)); + await el.updateComplete; + + expect(fromBelow).toBe('b'); + expect(el.shadowRoot?.querySelector('.pane--drop-before')?.getAttribute('data-id')).toBe('b'); + }); + + it('marks neither edge of where the section already sits', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + headerOf(el, 'b').dispatchEvent(dragEvent('dragstart')); + // Its own top edge, its own bottom edge, and the top of the next section: + // every one of them moves nothing. + for (const at of [120, 180, 220]) { + stack(el).dispatchEvent(dragEvent('dragover', at)); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.pane--drop-before')).toBeNull(); + expect(el.shadowRoot?.querySelector('.pane--drop-after')).toBeNull(); + } + + stack(el).dispatchEvent(dragEvent('drop', 120)); + expect(seen).toEqual([]); + }); + + it('drops the mark when the drag leaves the stack', async () => { + const el = await mount('vertical'); + + headerOf(el, 'c').dispatchEvent(dragEvent('dragstart')); + stack(el).dispatchEvent(dragEvent('dragover', 20)); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.pane--drop-before')).not.toBeNull(); + + stack(el).dispatchEvent(dragEvent('dragleave')); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.pane--drop-before')).toBeNull(); + }); + + it('reports nothing for a section dropped on itself', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + await dragSection(el, 'b', 120); + + expect(seen).toEqual([]); + }); + + it('does not reorder itself when the consumer ignores pane-reorder', async () => { + const el = await mount('vertical'); + + await dragSection(el, 'c', 20); + + // Controlled, like collapse: the consumer owns the order. + expect(el.sections.map((section) => section.id)).toEqual(['a', 'b', 'c']); + }); + + it('moves a section one place with Alt+Arrow', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + headerOf(el, 'c').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true }), + ); + + expect(seen).toEqual([ + ['b', 'a', 'c'], + ['a', 'c', 'b'], + ]); + }); + + it('ignores Alt+Arrow off either end, and a held key', async () => { + const el = await mount('vertical'); + const seen = reorders(el); + + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, bubbles: true }), + ); + headerOf(el, 'c').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + altKey: true, + bubbles: true, + repeat: true, + }), + ); + + expect(seen).toEqual([]); + }); + + it('keeps Alt+Arrow off the collapse it shares a header with', async () => { + const el = await mount('vertical'); + let toggles = 0; + el.addEventListener('pane-toggle', () => toggles++); + + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + + expect(toggles).toBe(0); + }); + + it('reorders side by side too, where the axis is the width', async () => { + const el = await mount('horizontal'); + const seen = reorders(el); + + // Focusable on both axes, so the keyboard reaches the reorder in the + // bottom dock as well. + expect(headerOf(el, 'a').getAttribute('tabindex')).toBe('0'); + headerOf(el, 'a').dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', altKey: true, bubbles: true }), + ); + + expect(seen).toEqual([['b', 'a', 'c']]); + }); + }); + + it('reports a header right-click, so the consumer can offer the sections', async () => { + const el = await mount('vertical'); + const seen: Array<{ id: string; x: number; y: number }> = []; + el.addEventListener('pane-menu', (e) => { + seen.push((e as CustomEvent<{ id: string; x: number; y: number }>).detail); + }); + + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: 40, + clientY: 12, + }); + headerOf(el, 'b').dispatchEvent(event); + + expect(seen).toEqual([{ id: 'b', x: 40, y: 12 }]); + // The host's own menu never opens over it. + expect(event.defaultPrevented).toBe(true); }); }); diff --git a/log-viewer/src/components/__tests__/detailSections.test.ts b/log-viewer/src/components/__tests__/detailSections.test.ts index 9515163a9..8fb0e46f1 100644 --- a/log-viewer/src/components/__tests__/detailSections.test.ts +++ b/log-viewer/src/components/__tests__/detailSections.test.ts @@ -65,8 +65,14 @@ describe('buildDetailSections', () => { ]); // The call tree gets the most room, so it is the section worth reading. expect(sections.find((s) => s.id === 'calltree')?.weight).toBe(4); - // The vitals are a fixed set of figures: they take their own height only. - expect(sections.find((s) => s.id === 'vitals')?.fit).toBe('content'); + // The figures about the frame take a steady height, so walking the selection + // does not resize the stack under the reader. + expect(sections.find((s) => s.id === 'vitals')?.height).toBe('md'); + // The variables have no natural size, so they take a share and scroll. + expect(sections.find((s) => s.id === 'variables')?.weight).toBe(3); + // Asked of a frame it empties while the figures are added up, so it keeps a + // steady height rather than flickering on every step. + expect(sections.find((s) => s.id === 'namespace-time')?.height).toBe('sm'); }); it('delegates a database statement to the richer database sections', async () => { @@ -328,6 +334,10 @@ describe('buildDetailSections', () => { expect(sections.find((s) => s.id === 'calltree')?.weight).toBe(4); expect(sections.find((s) => s.id === 'calltree')?.fit ?? 'fill').toBe('fill'); expect(sections.find((s) => s.id === 'governor-trends')?.fit).toBe('content'); + // Worked out once for the whole log, so this one is snug rather than steady: + // the same section takes a height only when it answers about a frame. + expect(sections.find((s) => s.id === 'namespace-time')?.fit).toBe('content'); + expect(sections.find((s) => s.id === 'namespace-time')?.height).toBeUndefined(); // The tab draws the log top down, so the tree opens on where the time went. const tree = rendered(sections, 'calltree', 'call-tree-detail') as CallTreeDetail; expect(tree.sourceView).toBe('callees'); diff --git a/log-viewer/src/components/__tests__/inspectorLayout.test.ts b/log-viewer/src/components/__tests__/inspectorLayout.test.ts new file mode 100644 index 000000000..847d36fb8 --- /dev/null +++ b/log-viewer/src/components/__tests__/inspectorLayout.test.ts @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import { html } from 'lit'; + +import type { PaneSection } from '../PaneView.js'; +import { + hiddenIds, + keepUnbuilt, + layoutKey, + mergeOrder, + orderSections, + scopedKey, + scopedRecord, + withoutScope, +} from '../inspectorLayout.js'; + +function sectionsOf(...ids: string[]): PaneSection[] { + return ids.map((id) => ({ id, title: id, content: html`
    ${id}
    ` })); +} + +function idsOf(sections: PaneSection[]): string[] { + return sections.map((section) => section.id); +} + +describe('layoutKey', () => { + it('names the tab and the scope, so one id is two lists', () => { + expect(layoutKey('timeline', 'summary')).toBe('timeline:summary'); + expect(layoutKey('timeline', 'detail')).not.toBe(layoutKey('timeline', 'summary')); + expect(layoutKey('analysis', 'detail')).not.toBe(layoutKey('calltree', 'detail')); + }); +}); + +describe('scopedKey', () => { + it('names one section inside its list', () => { + expect(scopedKey(layoutKey('timeline', 'detail'), 'callstack')).toBe( + 'timeline:detail:callstack', + ); + }); +}); + +describe('scopedRecord', () => { + const store = { + 'timeline:detail:callstack': true, + 'timeline:summary:calltree': true, + 'analysis:detail:callstack': true, + }; + + it('takes one listโ€™s entries, by plain section id', () => { + expect(scopedRecord(store, 'timeline:detail')).toEqual({ callstack: true }); + expect(scopedRecord(store, 'timeline:summary')).toEqual({ calltree: true }); + }); + + it('is empty for a list with nothing remembered', () => { + expect(scopedRecord(store, 'database:summary')).toEqual({}); + }); + + it('is empty without a key, so a list still building matches nothing', () => { + expect(scopedRecord(store, '')).toEqual({}); + }); +}); + +describe('withoutScope', () => { + it('drops one listโ€™s entries and leaves every other list alone', () => { + const store = { + 'timeline:detail:callstack': true, + 'timeline:summary:calltree': true, + 'analysis:detail:callstack': true, + }; + + expect(withoutScope(store, 'timeline:detail')).toEqual({ + 'timeline:summary:calltree': true, + 'analysis:detail:callstack': true, + }); + }); +}); + +describe('orderSections', () => { + const built = sectionsOf('vitals', 'variables', 'callstack', 'calltree'); + + it('returns the builder order when the user has arranged nothing', () => { + expect(orderSections(built)).toBe(built); + expect(idsOf(orderSections(built, []))).toEqual(idsOf(built)); + }); + + it('applies the order the user arranged', () => { + const order = ['calltree', 'vitals', 'callstack', 'variables']; + expect(idsOf(orderSections(built, order))).toEqual(order); + }); + + it('leaves the built sections alone', () => { + orderSections(built, ['calltree', 'vitals']); + expect(idsOf(built)).toEqual(['vitals', 'variables', 'callstack', 'calltree']); + }); + + it('keeps a section the order never named behind the one it follows', () => { + // `variables` arrived after this list was last arranged, and follows + // `vitals` in the builder's list. + const order = ['calltree', 'vitals', 'callstack']; + expect(idsOf(orderSections(built, order))).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + ]); + }); + + it('leads with an unnamed section the order names nothing before', () => { + expect(idsOf(orderSections(built, ['calltree']))).toEqual([ + 'vitals', + 'variables', + 'callstack', + 'calltree', + ]); + }); + + it('places a section the arranged list did not have where the builder puts it', () => { + // Reordered while a DML row was selected, so the order never named + // `issues` โ€” which the builder puts after `callstack` for a SOQL one. + const soql = sectionsOf('vitals', 'variables', 'callstack', 'issues', 'calltree'); + const order = ['calltree', 'vitals', 'variables', 'callstack']; + + expect(idsOf(orderSections(soql, order))).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + 'issues', + ]); + }); + + it('ignores an id this list no longer has', () => { + const order = ['issues', 'calltree', 'vitals', 'variables', 'callstack']; + expect(idsOf(orderSections(built, order))).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + ]); + }); +}); + +describe('hiddenIds', () => { + it('takes the ids this list hides', () => { + const store = { 'timeline:detail:calltree': true, 'analysis:detail:findings': true }; + + expect(hiddenIds(store, 'timeline:detail')).toEqual(new Set(['calltree'])); + expect(hiddenIds(store, 'timeline:summary')).toEqual(new Set()); + }); + + it('ignores an entry left behind by bringing a section back', () => { + expect(hiddenIds({ 'timeline:detail:calltree': false }, 'timeline:detail')).toEqual(new Set()); + }); +}); + +describe('mergeOrder', () => { + it('is the dragged order when the list hides nothing', () => { + expect(mergeOrder(['vitals', 'callstack'], new Set(), ['callstack', 'vitals'])).toEqual([ + 'callstack', + 'vitals', + ]); + }); + + it('keeps a hidden section behind the one it follows', () => { + // `variables` sits behind `vitals`, hidden; moving `calltree` up must not + // strand it at the end of the list. + const ids = ['vitals', 'variables', 'callstack', 'calltree']; + + expect(mergeOrder(ids, new Set(['variables']), ['calltree', 'vitals', 'callstack'])).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + ]); + }); + + it('leads with a hidden section that has nothing above it', () => { + const ids = ['overview', 'findings', 'calltree']; + + expect(mergeOrder(ids, new Set(['overview']), ['calltree', 'findings'])).toEqual([ + 'overview', + 'calltree', + 'findings', + ]); + }); + + it('keeps several hidden sections behind the same one, in order', () => { + const ids = ['vitals', 'variables', 'issues', 'calltree']; + + expect(mergeOrder(ids, new Set(['variables', 'issues']), ['calltree', 'vitals'])).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'issues', + ]); + }); +}); + +describe('keepUnbuilt', () => { + it('is the arranged order when the store knew nothing more', () => { + expect(keepUnbuilt(['vitals', 'callstack'], ['callstack', 'vitals'])).toEqual([ + 'callstack', + 'vitals', + ]); + }); + + it('keeps an id this build never produced behind the one it followed', () => { + // Arranged under a SOQL statement, reordered under a DML one, which builds + // no `issues` section at all. + const stored = ['vitals', 'variables', 'callstack', 'issues', 'calltree']; + + expect(keepUnbuilt(stored, ['calltree', 'vitals', 'variables', 'callstack'])).toEqual([ + 'calltree', + 'vitals', + 'variables', + 'callstack', + 'issues', + ]); + }); + + it('leads with an unbuilt id the store put above everything', () => { + // They dragged SOQL issues to the top; a reorder under a DML row must not + // cost them that. + const stored = ['issues', 'vitals', 'callstack']; + + expect(keepUnbuilt(stored, ['callstack', 'vitals'])).toEqual(['issues', 'callstack', 'vitals']); + }); + + it('takes a section the store never named', () => { + expect(keepUnbuilt(['vitals', 'callstack'], ['callstack', 'variables', 'vitals'])).toEqual([ + 'callstack', + 'variables', + 'vitals', + ]); + }); + + it('is the arranged order when nothing is stored yet', () => { + expect(keepUnbuilt([], ['callstack', 'vitals'])).toEqual(['callstack', 'vitals']); + }); +}); diff --git a/log-viewer/src/components/__tests__/sectionMenu.test.ts b/log-viewer/src/components/__tests__/sectionMenu.test.ts new file mode 100644 index 000000000..d93f4743c --- /dev/null +++ b/log-viewer/src/components/__tests__/sectionMenu.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; +import { html } from 'lit'; + +import type { PaneSection } from '../PaneView.js'; +import { RESET_SECTIONS_ID, buildSectionMenuItems, sectionIdFor } from '../sectionMenu.js'; + +function sections(...ids: string[]): PaneSection[] { + return ids.map((id) => ({ id, title: id.toUpperCase(), content: html`
    ${id}
    ` })); +} + +describe('sectionIdFor', () => { + it('reads the section off a section row', () => { + expect(sectionIdFor('section:callstack')).toBe('callstack'); + }); + + it('names no section for any other row', () => { + expect(sectionIdFor(RESET_SECTIONS_ID)).toBeNull(); + expect(sectionIdFor('section-sep')).toBeNull(); + }); +}); + +describe('buildSectionMenuItems', () => { + it('heads the menu with the reset, then a row per section', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set(['calltree'])); + + expect(items[0]).toEqual({ id: RESET_SECTIONS_ID, label: 'Reset Sections' }); + expect(items[1]?.separator).toBe(true); + expect(items.slice(2).map((item) => [item.id, item.label, item.checked])).toEqual([ + ['section:vitals', 'VITALS', true], + ['section:calltree', 'CALLTREE', false], + ]); + }); + + it('keeps the menu open through a toggle, so several can be picked', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set()); + + expect(items.slice(2).every((item) => item.keepOpen)).toBe(true); + }); + + it('will not untick the last section showing', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set(['calltree'])); + + // Hiding it would leave no header to right-click, and no way back. + expect(items[2]?.disabled).toBe(true); + // A hidden row is always pickable: that is how it comes back. + expect(items[3]?.disabled).toBe(false); + }); + + it('leaves every row pickable while more than one shows', () => { + const items = buildSectionMenuItems(sections('vitals', 'calltree'), new Set()); + + expect(items.slice(2).some((item) => item.disabled)).toBe(false); + }); +}); diff --git a/log-viewer/src/components/detailSections.ts b/log-viewer/src/components/detailSections.ts index feb042397..908315ff8 100644 --- a/log-viewer/src/components/detailSections.ts +++ b/log-viewer/src/components/detailSections.ts @@ -45,12 +45,17 @@ import './VariablesDetail.js'; * one frame, or the calls a row counts where the view's rows merge occurrences. * Details and the call tree follow it; the call stack stays anchored to * `selection`, so walking down a stack never puts a frame out of reach. + * + * `hidden` names the sections this list is set to hide, so a builder can skip + * work nobody will see. They are still returned: the header menu offers them + * back, and the panel leaves out what it does not show. */ export async function buildDetailSections( source: DetailSource, selection: DetailSelection | null, active: DetailSelection | null = null, sourceView?: SelectionView, + hidden: ReadonlySet = new Set(), ): Promise { // Nothing selected: the whole log is the scope. `DetailDock`'s own empty // state still covers the moment before a tab id resolves. @@ -138,7 +143,7 @@ export async function buildDetailSections( fit: 'content', content: html``, }, - namespaceTimeSection(html``), + namespaceTimeSection(html``, { fit: 'content' }), { id: 'governor-trends', title: 'Governor usage over time', @@ -146,8 +151,9 @@ export async function buildDetailSections( content: html``, }, { - // The same id as the selection's tree, deliberately: collapse state is - // keyed by section id, so the pane treats them as one "Call tree". + // The same id as the selection's tree: it is the one "Call tree" + // section, asked at whole-log scope. Collapse and order are remembered + // per list, so the two scopes still keep their own. id: 'calltree', title: 'Call tree', weight: 4, @@ -165,11 +171,14 @@ export async function buildDetailSections( // The Database grids resolve statement-specific vitals and SOQL lint issues. if (source === 'database' && selection.kind === 'event' && selection.type) { - return buildDatabaseSections({ - eventIndex: selection.eventIndex, - type: selection.type, - activeEventIndex: active?.kind === 'event' ? active.eventIndex : null, - }); + return buildDatabaseSections( + { + eventIndex: selection.eventIndex, + type: selection.type, + activeEventIndex: active?.kind === 'event' ? active.eventIndex : null, + }, + hidden, + ); } const isAggregate = selection.kind === 'aggregate'; @@ -199,7 +208,9 @@ export async function buildDetailSections( { id: 'vitals', title: 'Details', - fit: 'content', + // A steady height: what this section says changes with every frame the + // reader steps to, and sizing to it would move the whole stack each time. + height: 'md', content: html``, + { height: 'sm' }, ), ); } @@ -268,8 +282,18 @@ export async function buildDetailSections( return sections; } -/** The Timeline's namespace split. One id and title for both scopes: collapse - * state is keyed by section id, so a drift would split it. */ -function namespaceTimeSection(content: TemplateResult): PaneSection { - return { id: 'namespace-time', title: 'Self time by namespace', fit: 'content', content }; +/** + * The Timeline's namespace split. One id and title for both scopes: it is the + * same section, asked of the whole log or of a selection. + * + * A bar and a legend line per namespace, so it draws little and varies by a + * line. Asked of the whole log it is worked out once, so it sizes to that; + * asked of a selection it empties to one line of prose while each frame's + * figures are added up, and a content-sized pane would flicker on every step. + */ +function namespaceTimeSection( + content: TemplateResult, + sizing: Pick, +): PaneSection { + return { id: 'namespace-time', title: 'Self time by namespace', ...sizing, content }; } diff --git a/log-viewer/src/components/inspectorLayout.ts b/log-viewer/src/components/inspectorLayout.ts new file mode 100644 index 000000000..4c36aae9d --- /dev/null +++ b/log-viewer/src/components/inspectorLayout.ts @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { DetailSource } from '../core/events/EventBus.js'; +import type { PaneSection } from './PaneView.js'; + +/** What a section list is reading: the selection, or the whole log. */ +export type LayoutScope = 'detail' | 'summary'; + +/** + * The key the panel remembers a list's section choices under. + * + * Every choice is per tab **and** per scope, because the same id means + * different content in two lists: `calltree` is the whole log on the Timeline's + * summary and one frame's subtree in every detail list. A choice made in one + * list must never reach the other. + */ +export function layoutKey(source: DetailSource, scope: LayoutScope): string { + return `${source}:${scope}`; +} + +/** One section's entry in the flat stores, keyed `:
    `. */ +export function scopedKey(key: string, id: string): string { + return `${key}:${id}`; +} + +/** + * One list's entries, by plain section id. The stores are flat, so this is what + * a list hands to ``, which knows only section ids. + */ +export function scopedRecord(store: Record, key: string): Record { + const prefix = `${key}:`; + return Object.fromEntries( + Object.entries(store) + .filter(([stored]) => stored.startsWith(prefix)) + .map(([stored, value]) => [stored.slice(prefix.length), value]), + ); +} + +/** The store with one list's entries dropped: what a reset of that list leaves. */ +export function withoutScope(store: Record, key: string): Record { + const prefix = `${key}:`; + return Object.fromEntries(Object.entries(store).filter(([stored]) => !stored.startsWith(prefix))); +} + +/** + * The sections in the order the user arranged them. An id the order does not + * name keeps its place from the builder, behind the named section it follows + * there. + * + * Not the end of the stack: the list under one key varies with the selection โ€” + * `issues` is built for a SOQL statement and not for a DML one โ€” so an unnamed + * id means "this list did not have it when they arranged it" as often as it + * means "added since". Ranking it last put SOQL issues below the call tree for + * anyone who had reordered while a DML row was selected. + */ +export function orderSections(sections: PaneSection[], order?: string[]): PaneSection[] { + if (!order?.length) { + return sections; + } + const named = new Set(order); + return weave( + sections, + (section) => section.id, + order, + (id) => named.has(id), + ); +} + +/** The section ids this list hides. */ +export function hiddenIds(hidden: Record, key: string): Set { + return new Set( + Object.entries(scopedRecord(hidden, key)) + .filter(([, value]) => value) + .map(([id]) => id), + ); +} + +/** + * The order to remember after a reorder: the visible ids as the user left them, + * with each hidden id kept behind the visible one it currently follows. Bringing + * a section back then returns it to its place rather than the end of the stack. + */ +export function mergeOrder( + ids: string[], + hidden: ReadonlySet, + visibleOrder: string[], +): string[] { + return weave( + ids, + (id) => id, + visibleOrder, + (id) => !hidden.has(id), + (id) => id, + ); +} + +/** + * The order to store after a reorder: `arranged` as the user left it, with an + * id the store already knew that this build did not produce kept behind the id + * it followed there. + * + * The list under one key varies with the selection - `issues` is built for a + * SOQL statement and not for a DML one - so a reorder made under one selection + * would otherwise drop what the user arranged under another, for good. + */ +export function keepUnbuilt(stored: string[], arranged: string[]): string[] { + const built = new Set(arranged); + return weave( + stored, + (id) => id, + arranged, + (id) => built.has(id), + (id) => id, + ); +} + +/** + * `items` in the sequence `order` names, each named item followed by the items + * `anchored` left off that sequence which travel behind it โ€” the one placement + * rule {@link orderSections} and {@link mergeOrder} are inverses about. + * + * An unanchored item above every anchored one has no predecessor, so it leads. + * An id `order` names that `items` does not hold is skipped, unless `orphan` + * says what to put there: a list of ids can stand for itself, where a list of + * sections cannot conjure a pane it was never given. + */ +function weave( + items: T[], + idOf: (item: T) => string, + order: string[], + anchored: (id: string) => boolean, + orphan?: (id: string) => T, +): T[] { + const anchors = new Map(); + const trailing = new Map(); + let previous = ''; + for (const item of items) { + const id = idOf(item); + if (anchored(id)) { + anchors.set(id, item); + previous = id; + } else { + const group = trailing.get(previous); + if (group) { + group.push(item); + } else { + trailing.set(previous, [item]); + } + } + } + const woven = [...(trailing.get('') ?? [])]; + for (const id of order) { + const anchor = anchors.get(id) ?? orphan?.(id); + if (anchor !== undefined) { + woven.push(anchor); + } + woven.push(...(trailing.get(id) ?? [])); + } + return woven; +} diff --git a/log-viewer/src/components/sectionMenu.ts b/log-viewer/src/components/sectionMenu.ts new file mode 100644 index 000000000..861d53d38 --- /dev/null +++ b/log-viewer/src/components/sectionMenu.ts @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ContextMenuItem } from './ContextMenu.js'; +import type { PaneSection } from './PaneView.js'; + +/** The reset row. Every other row names a section, behind {@link SECTION_PREFIX}. */ +export const RESET_SECTIONS_ID = 'reset-sections'; +const SECTION_PREFIX = 'section:'; + +/** The section a menu row names, or null for any other row. */ +export function sectionIdFor(itemId: string): string | null { + return itemId.startsWith(SECTION_PREFIX) ? itemId.slice(SECTION_PREFIX.length) : null; +} + +/** + * The section header's menu: reset this list, then a row per section, ticked + * while it shows. + * + * The last section still showing cannot be unticked. Hiding it would leave no + * header to right-click, and so no way back to this menu. + */ +export function buildSectionMenuItems( + sections: PaneSection[], + hidden: ReadonlySet, +): ContextMenuItem[] { + const showing = sections.filter((section) => !hidden.has(section.id)).length; + return [ + { id: RESET_SECTIONS_ID, label: 'Reset Sections' }, + { id: 'section-sep', label: '', separator: true }, + ...sections.map((section) => { + const shows = !hidden.has(section.id); + return { + id: `${SECTION_PREFIX}${section.id}`, + label: section.title, + checked: shows, + keepOpen: true, + disabled: showing === 1 && shows, + }; + }), + ]; +} diff --git a/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts b/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts index 314b458f0..29720cd6d 100644 --- a/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts +++ b/log-viewer/src/features/database/components/__tests__/databaseSections.test.ts @@ -10,11 +10,17 @@ import { describe, expect, it } from '@jest/globals'; jest.mock('../../../../components/CallStackDetail.js', () => ({})); jest.mock('../../../../components/CallTreeDetail.js', () => ({})); jest.mock('../../../../components/EventVitals.js', () => ({})); +// Counted, so a hidden section can be shown to skip the lint rather than just +// to drop its result. +let lintCalls = 0; jest.mock('../../../soql/components/SOQLLinterIssues.js', () => ({ - computeSoqlIssues: async () => [ - { severity: 'Warning', summary: 'w', message: 'm' }, - { severity: 'Info', summary: 'i', message: 'm' }, - ], + computeSoqlIssues: async () => { + lintCalls++; + return [ + { severity: 'Warning', summary: 'w', message: 'm' }, + { severity: 'Info', summary: 'i', message: 'm' }, + ]; + }, })); import { render, type TemplateResult } from 'lit'; @@ -51,8 +57,24 @@ describe('buildDatabaseSections', () => { expect(sections.find((s) => s.id === 'issues')?.badge).toBe('2'); // The smallest section. expect(sections.find((s) => s.id === 'issues')?.weight).toBe(1); - // The vitals are a fixed set of figures: they take their own height only. - expect(sections.find((s) => s.id === 'vitals')?.fit).toBe('content'); + // The statement's figures take a steady height rather than resizing the + // stack from one statement to the next; the variables take a share. + expect(sections.find((s) => s.id === 'vitals')?.height).toBe('md'); + expect(sections.find((s) => s.id === 'variables')?.weight).toBe(3); + }); + + it('skips the lint for a hidden issues section, but still offers it', async () => { + const before = lintCalls; + const sections = await buildDatabaseSections( + { eventIndex: 3, type: 'soql' }, + new Set(['issues']), + ); + + const issues = sections.find((s) => s.id === 'issues'); + // Still in the list, so the header menu can bring it back. + expect(issues).toBeDefined(); + expect(issues?.badge).toBeUndefined(); + expect(lintCalls).toBe(before); }); it('omits the SOQL issues section for a DML selection', async () => { diff --git a/log-viewer/src/features/database/components/databaseSections.ts b/log-viewer/src/features/database/components/databaseSections.ts index 7d653bf3d..ed6d6d219 100644 --- a/log-viewer/src/features/database/components/databaseSections.ts +++ b/log-viewer/src/features/database/components/databaseSections.ts @@ -29,21 +29,25 @@ export interface DetailSelection { * Details and the call tree follow the active frame; the call stack and the SOQL * issues stay anchored to the statement the user picked. */ -export async function buildDatabaseSections(selection: DetailSelection): Promise { +export async function buildDatabaseSections( + selection: DetailSelection, + hidden: ReadonlySet = new Set(), +): Promise { const { eventIndex, type } = selection; const active = selection.activeEventIndex ?? eventIndex; // An ancestor method is not a statement, so the statement-shaped vitals do // not apply to it. const activeType = active === eventIndex ? type : undefined; - // The vitals are a fixed set of figures, so they take their own height; the - // fill sections share the leftover space, the call tree getting the most, - // SOQL issues the least (but still open). The call tree closes the panel. + // The vitals take a steady height, so stepping from one statement to the next + // does not resize the stack; the fill sections share the leftover space, the + // call tree getting the most, SOQL issues the least (but still open). The + // call tree closes the panel. const sections: PaneSection[] = [ { id: 'vitals', title: 'Details', - fit: 'content', + height: 'md', content: html``, }, { @@ -69,7 +75,9 @@ export async function buildDatabaseSections(selection: DetailSelection): Promise ]; if (type === 'soql') { - const issues = await computeSoqlIssues(eventIndex); + // Hidden, and the badge is a count nobody sees: the lint is worth skipping, + // but the section still has to be offered in the header menu. + const issues = hidden.has('issues') ? [] : await computeSoqlIssues(eventIndex); sections.push({ id: 'issues', title: 'SOQL issues', diff --git a/log-viewer/src/features/settings/Settings.ts b/log-viewer/src/features/settings/Settings.ts index dc5c6b145..e2809a0e4 100644 --- a/log-viewer/src/features/settings/Settings.ts +++ b/log-viewer/src/features/settings/Settings.ts @@ -47,10 +47,12 @@ export type LanaSettings = { inspector: { position: 'left' | 'right' | 'bottom'; size: number; - /** Collapsed sections, keyed by section id โ€” shared by every tab. */ + /** Collapsed sections, keyed `::
    `. */ collapsed: Record; - /** Pane sizes (px, used as flex weights), keyed `:
    `. */ - paneSizes: Record; + /** The order the user arranged each list in, keyed `:`. */ + sectionOrder: Record; + /** The sections a list hides, keyed like `collapsed`. */ + hiddenSections: Record; /** Last open/closed state; `null` means never toggled, so it may auto-open. */ visible: boolean | null; }; diff --git a/log-viewer/src/styles/tokens.css b/log-viewer/src/styles/tokens.css index 9953f7222..bd81619a5 100644 --- a/log-viewer/src/styles/tokens.css +++ b/log-viewer/src/styles/tokens.css @@ -191,7 +191,29 @@ /* Shared height for the inspector dock's action bar and pane section headers. */ --lana-panel-header-height: var(--lana-space-xl); - /* Least a pane section shows before the stack scrolls instead: its header and a - few rows of body, so no section is squeezed down to its title by its siblings. */ - --lana-pane-min: calc(var(--lana-panel-header-height) * 4); + /* Least a pane section shows before the stack scrolls instead: its header and + about a row of body. Small, and the same for every section, so a drag can + take any of them down to the same size. */ + --lana-pane-min: calc(var(--lana-panel-header-height) * 2); + + /* Steady heights for a pane section whose content changes with the selection. + Such a section takes its tier rather than its content, so stepping through + frames does not move every boundary in the stack; it scrolls inside when a + frame has more to say. + + A share of the panel between two bounds, so the tier scales with the room + there is โ€” a tall panel does not leave the section in a slot it has + outgrown, and a short one is not carved up before the grids get any โ€” while + depending on nothing the selection changes. The bounds are row counts under + the header: sm 2-5 rows, md 5-11. */ + --lana-pane-sm: clamp( + calc(var(--lana-panel-header-height) * 3), + 10%, + calc(var(--lana-panel-header-height) * 6) + ); + --lana-pane-md: clamp( + calc(var(--lana-panel-header-height) * 6), + 24%, + calc(var(--lana-panel-header-height) * 12) + ); } From bbb02d37a91f13b1d9258e3773152926d35090cb Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:13:32 +0100 Subject: [PATCH 49/61] fix(log-viewer): take governor limits only from the log, and measure per-row bars against the transaction (#1026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes The log is now the only source of a governor maximum, and a per-row bar measures contribution, not headroom. **A maximum is never assumed.** A hardcoded table of Salesforce's *synchronous* maxima used to stand in where a log reported none. It is wrong for a third of the logs that do report: over 438 real logs, 85 ran async (CPU 60,000 / heap 12 MB / SOQL 200) and 15 reported CPU 25,000. Nothing in the code could tell one context from the other. 23% of logs report no maximum at all, and no debug level guarantees one โ€” 36 complete `APEX_PROFILING,FINE` logs and 7 complete `INFO` logs carry no limit block. Without a reported limit: - the overview gauges read as levels, with a sparkline of the metric over the log where the bar sits; - the Timeline strip and the governor trends scale each metric to its own peak โ€” no 80% band, no 100% line, no breach fill, no traffic lights; - **Gov Avg %** and **Gov Peak %** read `โ€”` instead of a confident `0.0%`; - a limit line in the log body (`LIMIT_USAGE`, and the flow variants) now reports a maximum too, so fewer logs fall into this state than before. **Per-row governor bars answer contribution.** The governor count and row columns in the Call Tree and Analysis now fill against what the transaction consumed, the same question the time columns beside them already answered that way. A path that ran 3 of the transaction's 12 SOQL drew a 3% bar next to a 25% time bar; it now draws 25%. Headroom is still answered, once, by **Gov Avg %** / **Gov Peak %** and by the gauges. Each cell's tooltip still names the limit. The wording says which denominator is in play: `/` and "of limit" for headroom, "of" and "of log" for contribution. ## Two fixes found on the way - A metric was dropped from the Timeline series when its limit was 0, so a log with no limit block drew no strip at all. - A hairline sparkline or trend at a metric's peak had half its stroke clipped by the plot box. ## Verification `pnpm lint` and `pnpm test` clean โ€” 2412 tests. Checked in the dev host, light and dark, on a log that reports limits (`sample-app/debug-logs/sample-log.log`), one that reports none, and two whose maxima are not the old defaults (CPU 60,000 and CPU 25,000). ## Not in scope A multi-namespace log can still read `SOQL 119 / 100`: the parser sums `used` across namespaces while keeping one namespace's limit (#862). A log reporting both a sync and an async family resolves to the higher one. --- CHANGELOG.md | 2 + .../docs/features/governor-limits-heap.md | 8 +- lana-docs/docs/docs/features/timeline.mdx | 4 + log-viewer/src/components/EventVitals.ts | 30 +++- log-viewer/src/components/GovernorTrends.ts | 52 ++++-- log-viewer/src/components/LogOverview.ts | 23 ++- .../components/__tests__/EventVitals.test.ts | 10 +- .../__tests__/GovernorTrends.test.ts | 42 +++++ .../components/__tests__/LogOverview.test.ts | 50 ++---- .../__tests__/governorTrendData.test.ts | 18 ++ .../__tests__/logOverviewMetrics.test.ts | 83 ++++++++- log-viewer/src/components/governorCopy.ts | 21 +++ .../src/components/governorTrendData.ts | 58 ++++--- .../src/components/logOverviewMetrics.ts | 107 +++++++++--- .../metrics/__tests__/eventMetrics.test.ts | 39 ++++- log-viewer/src/core/metrics/eventMetrics.ts | 31 +++- .../analysis/services/LogDiagnostics.ts | 15 +- .../call-tree/components/AggregatedTable.ts | 2 +- .../call-tree/components/BottomUpTable.ts | 2 +- .../call-tree/components/TableShared.ts | 163 +++++++++++++----- .../call-tree/components/TimeOrderTable.ts | 2 +- .../features/call-tree/utils/Aggregation.ts | 12 +- .../features/call-tree/utils/GovernorCost.ts | 23 ++- .../features/call-tree/utils/TimeOrderTree.ts | 6 +- .../utils/__tests__/GovernorCost.test.ts | 29 +++- .../database/components/DatabaseRowBudget.ts | 10 +- .../database/components/DatabaseView.ts | 3 +- .../database/components/GovernorSummary.ts | 67 ++++++- .../__tests__/DatabaseRowBudget.test.ts | 4 +- .../__tests__/GovernorSummary.test.ts | 89 ++++++++++ .../timeline/optimised/apex-limit-series.ts | 43 ++--- .../metric-strip/MetricStripOrchestrator.ts | 5 +- .../metric-strip/MetricStripRenderer.test.ts | 11 +- .../metric-strip/MetricStripRenderer.ts | 27 ++- .../MetricStripTooltipRenderer.test.ts | 38 +++- .../MetricStripTooltipRenderer.ts | 50 +++++- .../metric-strip/MetricTierClassifier.test.ts | 127 ++++++++++++++ .../metric-strip/MetricTierClassifier.ts | 163 +++++++++++------- .../metric-strip/governor-timeline.test.ts | 23 ++- .../metric-strip/governor-timeline.ts | 60 +++++-- .../metric-strip/metric-strip-colors.ts | 27 +++ .../optimised/rendering/tooltip-utils.ts | 43 +++-- .../timeline/types/flamechart.types.ts | 22 ++- 43 files changed, 1272 insertions(+), 372 deletions(-) create mode 100644 log-viewer/src/components/governorCopy.ts create mode 100644 log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts create mode 100644 log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 120c1b456..913938c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - โฌ†๏ธ **Requires VS Code 1.102 or newer**. +- ๐Ÿ“ **Governor limits come only from the log**: no limit is assumed where the log reports none, so a figure is never measured against a maximum the transaction never had. Without one the gauges read as levels, the Timeline strip and the governor trends scale each metric to its own peak, and the governor cost columns read `โ€”`. +- ๐Ÿ“Š **Governor columns** in the Call Tree and Analysis fill against what the transaction consumed, like the time columns beside them, so the path responsible for a metric stands out. **Gov Avg %** and **Gov Peak %** still measure headroom, and each tooltip still names the limit. - ๐Ÿ“ **Governor figures**: the Inspector overview and the Database tab report each metric at its peak, the level the governor charges the transaction at. The Timeline strip still plots the log as recorded. - ๐ŸŽจ **Header bar**: Log problems and Notifications are redesigned cards that name the problem and its time and jump to the Call Tree; Help and Report an issue move into a `โ€ขโ€ขโ€ข` menu. - ๐ŸŽจ **Timeline legend**: moved into the toolbar as colour chips, each showing the log's self time in that category. diff --git a/lana-docs/docs/docs/features/governor-limits-heap.md b/lana-docs/docs/docs/features/governor-limits-heap.md index d7f4b3e2a..e267dda4f 100644 --- a/lana-docs/docs/docs/features/governor-limits-heap.md +++ b/lana-docs/docs/docs/features/governor-limits-heap.md @@ -32,12 +32,16 @@ Every limit reported by the log is tracked: SOQL queries and query rows, SOSL qu | Which call path is responsible? | The [Call Tree](./calltree.mdx#column-views) **Governor Limits** column view (average + tightest peak) | | What did this one statement cost? | The [inspector](./inspector.md)'s **Details** section | +Two denominators answer two questions, and the wording says which is in play. A `/` and "of limit" measure headroom against a limit the log reported. An "of" and "of log" measure contribution โ€” a path's share of what the transaction consumed โ€” which the Call Tree's governor columns and the inspector's Details use, so they read the same on every log. + ### Found vs counted The Database tab reconciles the statements found in the log against the governor-counted total. When those disagree, the difference is usually work that doesn't consume the limit โ€” custom metadata SOQL, for example, is free unless it selects a long text area field or runs inside a Flow. Seeing both numbers means you can trust the gap instead of wondering which one is wrong. :::note -Some logs contain no `CUMULATIVE_LIMIT_USAGE` block at all. Where the log never reports a total, no limit is shown rather than a guessed one. +The log is the only source of a limit. Salesforce's own maximum varies by context โ€” asynchronous Apex gets 60,000 ms of CPU against a synchronous transaction's 10,000, and other entry points differ again โ€” so where the log reports no limit, none is shown rather than a guessed one. No debug level guarantees a limit block: complete logs at `APEX_PROFILING` `FINE` and `INFO` alike can carry none. + +Figures still show without one. Gauges read as levels with no bar, the Timeline strip and the inspector's trend charts scale each metric to its own peak with no 80% band or 100% line, and the governor cost columns read `โ€”`. ::: ### Flow and Process Builder usage @@ -70,3 +74,5 @@ Because a path can consume several limits at once, the Call Tree and Analysis ta - **Gov Peak %** โ€“ the single tightest governor on that path. Hidden by default; the tooltip names which limit is the peak. Sorting by **Gov Peak** is the fastest way to find the call path that is closest to breaching something, regardless of _which_ limit it is. + +These two are where headroom is answered, so they are the columns measured against the limits. The DML, SOQL, SOSL and row columns beside them answer contribution instead: each bar is that path's share of what the whole transaction consumed, so it is comparable with the time bars in the same row and reads the same whether or not the log reported a limit. The limit still names itself in each cell's tooltip. Where the log reported none, **Gov Avg %** and **Gov Peak %** read `โ€”` and sort last. diff --git a/lana-docs/docs/docs/features/timeline.mdx b/lana-docs/docs/docs/features/timeline.mdx index 47dceaf7b..2b4c32d0b 100644 --- a/lana-docs/docs/docs/features/timeline.mdx +++ b/lana-docs/docs/docs/features/timeline.mdx @@ -271,6 +271,10 @@ Click the chevron icon (โ—€/โ–ผ) in the top-left corner or `Shift+Click` anywher - **100% Limit Line**: Red dashed line at the limit threshold - **Breach Areas**: Purple shading for values above 100% +#### Where the log reports no limits + +A percentage needs a limit, and the log is the only source of one. Where it reports none, each metric is scaled to its own highest point instead, so the shape of usage over time still reads. The band, the 100% line, the breach shading and the traffic-light colours all mark a distance from a limit, so they come off; the collapsed strip shades by level in a single neutral grey, and the tooltip reads `770 of 1,240` rather than `770 / 1,240`. + ### Mouse Interactions | Action | Mouse | Result | diff --git a/log-viewer/src/components/EventVitals.ts b/log-viewer/src/components/EventVitals.ts index 5bbe9fada..41298d644 100644 --- a/log-viewer/src/components/EventVitals.ts +++ b/log-viewer/src/components/EventVitals.ts @@ -235,13 +235,14 @@ export class EventVitals extends LitElement { /** * One row per metric the grids expose as columns, so hiding a column never - * hides the data. Each reads `used / limit (self: n) pct%` โ€” the denominator - * *is* the governor limit, so usage and limit are never reported twice. - * Metrics with no transaction limit show the count alone. Zero rows are - * omitted; `self` only appears when it adds something. + * hides the data. Each reads `used of the log's total (share, limit share, self)` โ€” the + * denominator is what the transaction consumed, the question a selection is asked, so the row + * reads on every log whether or not one reported limits. A reported limit follows as a + * qualifier. Zero rows are omitted; `self` only appears when it adds something. */ private _metricRows(rows: TemplateResult[], events: LogEvent[]): void { - const limits = this.logStore?.log.governorLimits; + const apexLog = this.logStore?.log; + const limits = apexLog?.governorLimits; // A total nests and a self reading does not, so each sums the set that holds // it once. const outer = outermostEvents(events); @@ -265,7 +266,13 @@ export class EventVitals extends LitElement { this._row( rows, metric.label, - usage(total, limit, format, self > 0 && self !== total ? format(self) : null), + usage( + total, + apexLog ? metric.pick(apexLog).total : 0, + limit, + format, + self > 0 && self !== total ? format(self) : null, + ), ); } @@ -274,7 +281,13 @@ export class EventVitals extends LitElement { this._row( rows, HEAP_PEAK.label, - usage(heapPeak, limits ? HEAP_PEAK.limit(limits) : 0, formatBytes, null), + usage( + heapPeak, + apexLog ? HEAP_PEAK.pick(apexLog) : 0, + limits ? HEAP_PEAK.limit(limits) : 0, + formatBytes, + null, + ), ); } } @@ -334,11 +347,12 @@ function qualifier(...parts: Array): Template /** {@link usageParts} as the row renders it. */ function usage( total: number, + logTotal: number, limit: number, format: (value: number) => string, self: string | null, ): TemplateResult { - const { primary, qualifiers } = usageParts(total, limit, format, self); + const { primary, qualifiers } = usageParts(total, logTotal, limit, format, self); return html`${primary}${qualifier(...qualifiers)}`; } diff --git a/log-viewer/src/components/GovernorTrends.ts b/log-viewer/src/components/GovernorTrends.ts index 7f92b6645..ac929a724 100644 --- a/log-viewer/src/components/GovernorTrends.ts +++ b/log-viewer/src/components/GovernorTrends.ts @@ -23,7 +23,7 @@ import { type TrendPoint, type TrendSeries, } from './governorTrendData.js'; -import { NO_CUMULATIVE_LIMITS_TEXT } from './logOverviewMetrics.js'; +import { NO_GOVERNOR_USAGE_TEXT, NO_LOG_TEXT } from './governorCopy.js'; /** A placed cursor: the sample, and the chart it belongs to. */ interface Cursor { @@ -64,7 +64,10 @@ function trendGeometry(series: TrendSeries, logTotal: number): TrendGeometry { return cached; } - const maxRatio = Math.max(100, ...series.points.map((p) => p.ratio)); + // At least 100%, so a safe line reads as safe. The highest point is already on the series: the + // ratio peaks at `finalRatio` where a limit was reported, and at exactly 100% where the + // denominator is the metric's own peak. + const maxRatio = Math.max(100, series.limit > 0 ? series.finalRatio : 100); const x = (t: number) => (logTotal > 0 ? (t / logTotal) * VIEW_W : 0); const y = (ratio: number) => VIEW_H - (ratio / maxRatio) * VIEW_H; @@ -173,10 +176,13 @@ export class GovernorTrends extends LitElement { cursor: pointer; } + /* Overflow visible: a peak-scaled series tops out at exactly 100%, putting the vertex on + y=0, where half the non-scaling stroke would fall outside the viewBox and be clipped. */ .trend__plot { display: block; width: 100%; height: 44px; + overflow: visible; } .trend__chart:focus-visible { @@ -194,6 +200,11 @@ export class GovernorTrends extends LitElement { color: var(--lana-severity-error); } + /* No reported limit, so no severity: the shape is a level, drawn in the muted foreground. */ + .trend--level { + color: var(--lana-fg-muted); + } + .trend__area { fill: currentColor; opacity: 0.25; @@ -227,15 +238,13 @@ export class GovernorTrends extends LitElement { render() { const apexLog = this.logStore?.log; if (!apexLog) { - return html`

    No log is loaded.

    `; + return html`

    ${NO_LOG_TEXT}

    `; } const series = governorTrendSeries(apexLimitTimeSeries(apexLog)); if (!series.length) { - return html`

    ${NO_CUMULATIVE_LIMITS_TEXT}

    `; + return html`

    ${NO_GOVERNOR_USAGE_TEXT}

    `; } - // With no cumulative snapshots the series draws from granular events and - // the default limits โ€” the Log overview above carries the estimated note. const logTotal = apexLog.duration.total; return html``; } @@ -245,21 +254,36 @@ export class GovernorTrends extends LitElement { const cursor = this._cursorFor(series); const cursorX = cursor ? x(cursor.t).toFixed(2) : null; + // No reported limit: the denominator is the metric's own peak, so the figure is spelled "of" + // rather than "/", and the guide and the tier colour โ€” both distances from a cap โ€” come off. + const metered = series.limit > 0; + + // Only once a cursor names a moment: the whole-log figure *is* the peak, so with no cursor the + // denominator would restate the value beside it. Cursor presence, not the value โ€” gating on the + // value would drop the suffix across the flat tail and change the readout's width mid-scrub. + const denominator = metered + ? `/ ${series.format(series.limit)}` + : cursor + ? `of ${series.format(series.used)}` + : ''; + return html`
    ${series.label} ${cursor ? html`${formatDuration(cursor.t)} ยท ` : ''}${series.format( cursor ? cursor.used : series.used, - )} / ${series.format(series.limit)}${denominator}
    `; } + + /** The level over the log, scaled to its own peak. Nothing to draw without a peak. */ + private _renderSpark(metric: GaugeMetric) { + const spark = metric.spark ?? []; + const peak = spark.reduce((highest, level) => (level > highest ? level : highest), 0); + if (peak <= 0) { + return nothing; + } + + // A 0-100 x 0-10 box stretched to the gauge's width, so the path needs no pixel measurements. + const points = spark + .map((level, i) => { + const x = spark.length > 1 ? (i / (spark.length - 1)) * 100 : 0; + return `${x.toFixed(2)},${(10 - (level / peak) * 10).toFixed(2)}`; + }) + .join(' '); + + return html` + + `; + } } diff --git a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts index 863e767db..39338de94 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseRowBudget.test.ts @@ -201,10 +201,10 @@ describe('database-rows', () => { expect(texts(await mount(), '.note')).toEqual(['Worst search 1,800 of 2,000 rows per query.']); }); - it('says the figures are observed when the log captured no cumulative limits', async () => { + it('says no limits were reported when the log reported none', async () => { budgets = { ...full(), hasLimits: false }; - expect(texts(await mount(), '.note').at(-1)).toContain('CUMULATIVE_LIMIT_USAGE'); + expect(texts(await mount(), '.note').at(-1)).toContain('no governor limits'); }); it('shows the rows the statements held when the log captured no governor peak', async () => { diff --git a/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts b/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts new file mode 100644 index 000000000..7c12a133b --- /dev/null +++ b/log-viewer/src/features/database/components/__tests__/GovernorSummary.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; + +import { NO_LIMIT_FOR_METRIC_TEXT } from '../../../../components/governorCopy.js'; +import { formatInteger } from '../../../../core/utility/Util.js'; +import type { GaugeMetric, GovernorSummary } from '../GovernorSummary.js'; +import '../GovernorSummary.js'; + +const strip = async (metrics: GaugeMetric[]) => { + const element = document.createElement('governor-summary') as GovernorSummary; + element.metrics = metrics; + document.body.append(element); + await element.updateComplete; + return element; +}; + +describe('governor-summary', () => { + beforeEach(() => { + document.body.replaceChildren(); + }); + + describe('a limit the log reported', () => { + it('meters the value against it', async () => { + const element = await strip([ + { label: 'SOQL', found: 40, used: 40, limit: 100, format: formatInteger }, + ]); + const gauge = element.shadowRoot?.querySelector('.gauge'); + + expect(gauge?.getAttribute('role')).toBe('meter'); + expect(gauge?.getAttribute('aria-valuemax')).toBe('100'); + expect(gauge?.querySelector('.gauge__fill')).not.toBeNull(); + expect(gauge?.querySelector('.gauge__spark')).toBeNull(); + }); + }); + + describe('no limit reported', () => { + const spark: GaugeMetric = { + label: 'SOQL', + found: 12, + used: 12, + limit: 0, + spark: [3, 6, 12], + format: formatInteger, + }; + + // A bar against the level's own peak would sit full, which is how the strip says "breached". + it('draws no bar and claims no meter', async () => { + const element = await strip([spark]); + const gauge = element.shadowRoot?.querySelector('.gauge'); + + expect(gauge?.getAttribute('role')).toBeNull(); + expect(gauge?.querySelector('.gauge__track')).toBeNull(); + expect(gauge?.textContent).toContain('12'); + }); + + // The missing denominator says there is no limit; the hover says why, so the host needs no + // note under the strip. + it('says why it has no bar, on hover', async () => { + const element = await strip([spark]); + + expect(element.shadowRoot?.querySelector('.gauge')?.getAttribute('title')).toBe( + NO_LIMIT_FOR_METRIC_TEXT, + ); + }); + + it('draws the level scaled to its own peak, described rather than metered', async () => { + const element = await strip([spark]); + const svg = element.shadowRoot?.querySelector('.gauge__spark'); + + expect(svg?.getAttribute('aria-label')).toContain('highest point 12'); + // 3, 6 and 12 of a 12 peak, in a 0-10 box: three quarters, half, then the top. + expect(svg?.querySelector('polyline')?.getAttribute('points')).toBe( + '0.00,7.50 50.00,5.00 100.00,0.00', + ); + }); + + it('draws nothing where the host passed no readings', async () => { + const element = await strip([ + { label: 'SOQL', found: 12, used: 12, limit: 0, format: formatInteger }, + ]); + + expect(element.shadowRoot?.querySelector('.gauge__spark')).toBeNull(); + }); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/apex-limit-series.ts b/log-viewer/src/features/timeline/optimised/apex-limit-series.ts index 62497ca1a..8c870bfb0 100644 --- a/log-viewer/src/features/timeline/optimised/apex-limit-series.ts +++ b/log-viewer/src/features/timeline/optimised/apex-limit-series.ts @@ -50,27 +50,6 @@ const APEX_METRICS: Map = new Map([ ], ]); -/** - * Standard synchronous Apex governor limits, used as a fallback so a metric can render from - * granular usage alone when the log has no cumulative limit event. Any limit reported by the log - * (LIMIT_USAGE_FOR_NS / LIMIT_USAGE / flow) overrides these. - */ -const DEFAULT_LIMITS = new Map([ - ['soqlQueries', 100], - ['queryRows', 50000], - ['soslQueries', 20], - ['dmlStatements', 150], - ['publishImmediateDml', 150], - ['dmlRows', 10000], - ['cpuTime', 10000], - ['heapSize', 6000000], - ['callouts', 100], - ['emailInvocations', 10], - ['futureCalls', 50], - ['queueableJobsAddedToQueue', 50], - ['mobileApexPushCalls', 10], -]); - /** Memo of {@link buildApexLimitTimeSeries} per log: the walk visits the full event * tree, and the series feeds two surfaces โ€” the metric strip and the inspector's * governor trend charts โ€” which must chart the same figures. */ @@ -98,7 +77,7 @@ export function apexLimitTimeSeries(apexLog: ApexLog): HeatStripTimeSeries { * `LIMIT_USAGE` / flow `*_LIMIT_USAGE` reports) add intermediate data points so the line * rises as usage happens rather than only at code-unit boundaries. * - * @param apexLog - Parsed log providing cumulative snapshots, authoritative limits and the + * @param apexLog - Parsed log providing cumulative snapshots, the limits they report and the * event tree, which is walked in full for granular deltas. */ function buildApexLimitTimeSeries(apexLog: ApexLog): HeatStripTimeSeries { @@ -109,9 +88,16 @@ function buildApexLimitTimeSeries(apexLog: ApexLog): HeatStripTimeSeries { const observations: GranularObservation[] = []; - // Authoritative limit per metric = max limit reported by any cumulative snapshot, else the - // default. Fixed for the whole series so the "out of" total never flips (e.g. heap 6MBโ†’12MB). - const metricLimits = new Map(DEFAULT_LIMITS); + // The log is the only source of a limit: the highest one it reported anywhere, fixed for the + // whole series so the "out of" total never flips (a log can report both the synchronous and the + // asynchronous ceiling). A metric the log never named keeps limit 0 โ€” its consumers scale it by + // its own peak rather than measure it against a number the log never gave. + const metricLimits = new Map(); + const reportLimit = (metric: keyof Limits, limit: number): void => { + if (limit > 0) { + metricLimits.set(metric, Math.max(metricLimits.get(metric) ?? 0, limit)); + } + }; // Cumulative snapshots โ€” authoritative multi-metric correctives (transaction usage). for (const snapshot of apexLog.governorLimits.snapshots) { @@ -127,9 +113,7 @@ function buildApexLimitTimeSeries(apexLog: ApexLog): HeatStripTimeSeries { used: value.used, scope: 'cumulative', }); - if (value.limit > 0) { - metricLimits.set(metric, Math.max(metricLimits.get(metric) ?? 0, value.limit)); - } + reportLimit(metric, value.limit); } } @@ -195,6 +179,9 @@ function buildApexLimitTimeSeries(apexLog: ApexLog): HeatStripTimeSeries { // Flow CPU time is flow-scoped with a different limit (15000 vs the 10000 apex limit), // so skip it here โ€” CPU stays sourced from LIMIT_USAGE_FOR_NS to keep percentages consistent. if (usage && !(event.type !== 'LIMIT_USAGE' && usage.metric === 'cpuTime')) { + // These lines report a block's usage, but the limit they name is the transaction's, and + // some logs carry them with no cumulative block at all. + reportLimit(usage.metric, usage.limit); observations.push({ kind: 'absolute', timestamp, diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts index 5e42fdf2b..febc47724 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripOrchestrator.ts @@ -35,7 +35,7 @@ import { MeshAxisRenderer } from '../time-axis/MeshAxisRenderer.js'; import { wheelZoomFactor } from '../ViewportUtils.js'; import { MetricStripRenderer } from './MetricStripRenderer.js'; import { MetricStripTooltipRenderer } from './MetricStripTooltipRenderer.js'; -import { MetricTierClassifier } from './MetricTierClassifier.js'; +import { EMPTY_METRIC_STRIP_DATA, MetricTierClassifier } from './MetricTierClassifier.js'; import { isOverChevron } from './strip-pointer.js'; import { getMetricStripColors, @@ -474,7 +474,7 @@ export class MetricStripOrchestrator { // Render the step chart with markers this.renderer.render( - data ?? { points: [], classifiedMetrics: [], globalMaxPercent: 0, hasData: false, gaps: [] }, + data ?? EMPTY_METRIC_STRIP_DATA, context.viewportState, context.totalDuration, context.markers, @@ -486,6 +486,7 @@ export class MetricStripOrchestrator { context.viewportState, (timeNs) => this.classifier?.getDataPointAtTime(timeNs) ?? null, context.totalDuration, + data.scaledToPeak, ); } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts index 338564c7c..57816802a 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.test.ts @@ -23,7 +23,7 @@ const cpuTime: MetricStripClassifiedMetric = { displayName: 'CPU Time', tier: 1, globalMaxPercent: 0.5, - limit: 100, + denominator: { kind: 'limit', value: 100 }, color: 0xff0000, priority: 0, unit: '', @@ -40,7 +40,14 @@ function point(timestamp: number, percent: number): MetricStripDataPoint { } function data(points: MetricStripDataPoint[], gaps: NoDataSpan[]): MetricStripProcessedData { - return { points, classifiedMetrics: [cpuTime], globalMaxPercent: 0.5, hasData: true, gaps }; + return { + points, + classifiedMetrics: [cpuTime], + globalMaxPercent: 0.5, + hasData: true, + scaledToPeak: false, + gaps, + }; } const viewportState: ViewportState = { diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts index aa20ba511..bb4cc48fd 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripRenderer.ts @@ -53,6 +53,7 @@ import { BREACH_AREA_OPACITY, DANGER_ZONE_OPACITY, getMetricStripColors, + getDensityColor, getTrafficLightColor, METRIC_STRIP_HEIGHT, METRIC_STRIP_LINE_WIDTHS, @@ -287,11 +288,18 @@ export class MetricStripRenderer { // Render expanded view layers (back to front). The fills and the breach band leave the // unrecorded spans blank: a fill reads as measured volume and the band is a verdict. The // step line carries its last reading across, because a governor total cannot fall. - this.renderDangerZone(displayWidth, height); + // + // The band, the 100% line and the breach fill all mark a distance from a cap, so they are + // drawn only where the log reported one. + if (!data.scaledToPeak) { + this.renderDangerZone(displayWidth, height); + } this.renderAreaFills(data, viewportState, totalDuration, height); this.renderStepChartLines(data, viewportState, totalDuration, height); - this.renderLimitLine(displayWidth, height); - this.renderBreachAreas(data, viewportState, totalDuration, height); + if (!data.scaledToPeak) { + this.renderLimitLine(displayWidth, height); + this.renderBreachAreas(data, viewportState, totalDuration, height); + } } /** @@ -329,9 +337,15 @@ export class MetricStripRenderer { viewportState: ViewportState, getDataPointAtTime: (timeNs: number) => DataPointResult | null, totalDuration: number, + scaledToPeak: boolean, ): void { if (this.isCollapsed) { - this.renderCollapsedHeatStrips(viewportState, getDataPointAtTime, totalDuration); + this.renderCollapsedHeatStrips( + viewportState, + getDataPointAtTime, + totalDuration, + scaledToPeak, + ); } } @@ -347,6 +361,7 @@ export class MetricStripRenderer { viewportState: ViewportState, getDataPointAtTime: (timeNs: number) => DataPointResult | null, totalDuration: number, + scaledToPeak: boolean, ): void { const { zoom, offsetX, displayWidth } = viewportState; const height = this.height; @@ -390,7 +405,9 @@ export class MetricStripRenderer { // A traffic light is a verdict, so the strip draws none over unrecorded time. if (cachedResult && !this.isNoData(bucketStartTime)) { const maxPercent = this.getMaxPercentAtPoint(cachedResult.point); - const colorInfo = getTrafficLightColor(maxPercent); + const colorInfo = scaledToPeak + ? getDensityColor(maxPercent) + : getTrafficLightColor(maxPercent); color = colorInfo.color; alpha = colorInfo.alpha; } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts index 6cf96f5fd..6dd09446a 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.test.ts @@ -8,26 +8,28 @@ import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; import type { + MetricDenominator, MetricStripClassifiedMetric, MetricStripDataPoint, } from '../../types/flamechart.types.js'; +import { PERCENT_COLORS } from '../rendering/tooltip-utils.js'; import { MetricStripTooltipRenderer } from './MetricStripTooltipRenderer.js'; /** - * Build a classified metric. Only metricId/displayName/globalMaxPercent/limit matter here. + * Build a classified metric. Only metricId/displayName/globalMaxPercent/denominator matter here. */ function metric( metricId: string, displayName: string, globalMaxPercent: number, - limit = 100, + denominator: MetricDenominator = { kind: 'limit', value: 100 }, ): MetricStripClassifiedMetric { return { metricId, displayName, tier: 1, globalMaxPercent, - limit, + denominator, color: 0xffffff, priority: 0, unit: '', @@ -187,7 +189,7 @@ describe('MetricStripTooltipRenderer', () => { it('always shows the (used / limit) value, even at 0% with no data point for the metric', () => { // cpuTime has a limit but no entry in rawValues (not observed yet at this timestamp). - const metrics = [metric('cpuTime', 'CPU Time', 0, 10000)]; + const metrics = [metric('cpuTime', 'CPU Time', 0, { kind: 'limit', value: 10_000 })]; const dataPoint: MetricStripDataPoint = { timestamp: 0, values: new Map([['cpuTime', 0]]), @@ -201,6 +203,34 @@ describe('MetricStripTooltipRenderer', () => { expect(text).toContain('(0 / 10,000)'); }); + describe('a log that reported no limits', () => { + // Peak 1,240: the reading is a share of the log's own peak, not of a cap. + const metrics = [metric('queryRows', 'Query Rows', 1, { kind: 'peak', value: 1240 })]; + const dataPoint: MetricStripDataPoint = { + timestamp: 0, + values: new Map([['queryRows', 0.62]]), + rawValues: new Map([['queryRows', { used: 770, limit: 0 }]]), + tier3Max: 0, + }; + + it('reads the value against the log\'s own peak, spelled "of"', () => { + renderer.show(0, 0, dataPoint, metrics, 60); + + expect(panel().textContent).toContain('(770 of 1,240)'); + expect(panel().textContent).not.toContain('/'); + }); + + it('gives the figure no severity colour, since there is no cap to be near', () => { + renderer.show(0, 0, dataPoint, metrics, 60); + const [row] = rows(); + const percent = row!.children[2] as HTMLElement; + + expect(percent.textContent).toContain('62.0%'); + // 62% would be amber against a limit; against a peak it stays the panel's own foreground. + expect(percent.style.color).not.toBe(PERCENT_COLORS.warning); + }); + }); + it('keeps the same row order at different timestamps', () => { const metrics = [ metric('cpuTime', 'CPU Time', 0.9), diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts index ea1a5867f..0c53f9f37 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricStripTooltipRenderer.ts @@ -22,6 +22,7 @@ */ import type { + MetricDenominator, MetricStripClassifiedMetric, MetricStripDataPoint, } from '../../types/flamechart.types.js'; @@ -73,8 +74,10 @@ export interface MetricStripTooltipOptions { interface RowData { color: string; name: string; - /** 0-1, which decides both the figure and its colour. */ + /** 0-1, the figure the row leads with. */ percent: number; + /** The percentage's colour. A share of a peak takes no severity colour โ€” there is no cap. */ + percentColor: string; value: string; /** The corrective count, where the log dropped events Salesforce still counted. */ ghost: string; @@ -82,6 +85,13 @@ interface RowData { muted?: boolean; } +/** A share of a peak has no cap to be near, so it takes the panel's own foreground. */ +function percentColorFor(denominator: MetricDenominator, percent: number): string { + return denominator.kind === 'limit' + ? getPercentColor(percent) + : TOOLTIP_CSS.descriptionForeground; +} + /** The elements one row is written into, held so they are never rebuilt. */ interface RowNodes { root: HTMLElement; @@ -229,6 +239,9 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { classifiedMetrics: MetricStripClassifiedMetric[], ): RowData[] { const allMetrics = classifiedMetrics + // A metric the log gave no denominator for is off the series: a row would read 0.0% for + // something that was consumed, which is worse than no row at all. + .filter((metric) => metric.denominator.kind !== 'none') .map((metric) => ({ metric, percent: dataPoint.values.get(metric.metricId) ?? 0, @@ -249,9 +262,14 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { } } - // Pass 2: anything at the danger threshold. + // Pass 2: anything at the danger threshold. Only against a reported limit โ€” 80% of a metric's + // own peak says nothing about proximity to anything. for (const item of allMetrics) { - if (!shownMetricIds.has(item.metric.metricId) && item.percent >= DANGER_THRESHOLD) { + if ( + !shownMetricIds.has(item.metric.metricId) && + item.metric.denominator.kind === 'limit' && + item.percent >= DANGER_THRESHOLD + ) { visibleMetrics.push(item); shownMetricIds.add(item.metric.metricId); } @@ -281,16 +299,24 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { visibleMetrics.sort((a, b) => b.metric.globalMaxPercent - a.metric.globalMaxPercent); const rows: RowData[] = visibleMetrics.map(({ metric, percent, rawValue }) => { - // Always state the limit, even at 0% or before the metric's first observation, so the - // headroom is visible. The limit is fixed across the series, so the classified metric - // answers where this timestamp has no data point. - const limit = rawValue?.limit ?? metric.limit; + // The denominator is fixed across the series, so it reads even at 0% or before the metric's + // first observation โ€” where this timestamp has no data point at all. + const { denominator } = metric; + const used = rawValue?.used ?? 0; return { color: hexToCSS(metric.color), name: metric.displayName, percent, + percentColor: percentColorFor(denominator, percent), value: - limit > 0 ? formatMetricValueWithParens(rawValue?.used ?? 0, limit, metric.unit) : '', + denominator.kind === 'none' + ? '' + : formatMetricValueWithParens( + used, + denominator.value, + metric.unit, + denominator.kind === 'limit' ? '/' : 'of', + ), // Only where the count tracked from detailed events falls below the corrective // cumulative total โ€” the log dropped events Salesforce still counted. ghost: @@ -302,10 +328,16 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { if (hiddenMetrics.length > 0) { const maxHiddenPercent = Math.max(...hiddenMetrics.map((item) => item.percent)); + // The summary stands for a set, so it takes a severity colour only where every metric it + // stands for has a cap to be near. + const metered = hiddenMetrics.every((item) => item.metric.denominator.kind === 'limit'); rows.push({ color: hexToCSS(this.colors.tier3), name: `Other (${hiddenMetrics.length})`, percent: maxHiddenPercent, + percentColor: metered + ? getPercentColor(maxHiddenPercent) + : TOOLTIP_CSS.descriptionForeground, value: '', ghost: '', muted: true, @@ -341,7 +373,7 @@ export class MetricStripTooltipRenderer extends BaseTooltipRenderer { row.swatch.setAttribute('color', data.color); row.name.textContent = data.name; row.percent.textContent = `${(data.percent * 100).toFixed(1).padStart(5)}%`; - row.percent.style.color = getPercentColor(data.percent); + row.percent.style.color = data.percentColor; row.valueText.data = data.value; row.ghost.textContent = data.ghost; row.root.style.opacity = data.muted ? '0.7' : ''; diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.test.ts new file mode 100644 index 000000000..5868599e1 --- /dev/null +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import type { + HeatStripEvent, + HeatStripMetric, + HeatStripMetricValue, + HeatStripTimeSeries, +} from '../../types/flamechart.types.js'; +import { MetricTierClassifier } from './MetricTierClassifier.js'; + +const metricDef = (id: string, priority: number): [string, HeatStripMetric] => [ + id, + { id, displayName: id, unit: '', priority }, +]; + +const series = ( + events: Array<[number, Record]>, + ids: string[], +): HeatStripTimeSeries => ({ + metrics: new Map(ids.map((id, i) => metricDef(id, i))), + events: events.map(([timestamp, values]): HeatStripEvent => ({ + timestamp, + namespace: 'combined', + values: new Map(Object.entries(values)), + })), +}); + +describe('MetricTierClassifier', () => { + describe('a log that reported limits', () => { + it('reads every percentage against the reported limit', () => { + const data = new MetricTierClassifier().processData( + series( + [ + [0, { soqlQueries: { used: 25, limit: 100 } }], + [10, { soqlQueries: { used: 50, limit: 100 } }], + ], + ['soqlQueries'], + ), + ); + + expect(data.scaledToPeak).toBe(false); + expect(data.points.map((p) => p.values.get('soqlQueries'))).toEqual([0.25, 0.5]); + expect(data.classifiedMetrics[0]?.denominator).toEqual({ kind: 'limit', value: 100 }); + }); + + // A metric the block never named has a different kind of denominator to its neighbours, so it + // stays out rather than being drawn against its own peak on a shared axis. + it('leaves out a metric the log reported no limit for', () => { + const data = new MetricTierClassifier().processData( + series( + [ + [ + 0, + { + soqlQueries: { used: 50, limit: 100 }, + callouts: { used: 3, limit: 0 }, + }, + ], + ], + ['soqlQueries', 'callouts'], + ), + ); + + expect(data.scaledToPeak).toBe(false); + expect(data.points[0]?.values.has('callouts')).toBe(false); + expect(data.points[0]?.values.get('soqlQueries')).toBe(0.5); + }); + }); + + describe('a log that reported none', () => { + const noLimits = series( + [ + [0, { soqlQueries: { used: 3, limit: 0 }, queryRows: { used: 300, limit: 0 } }], + [10, { soqlQueries: { used: 12, limit: 0 }, queryRows: { used: 1200, limit: 0 } }], + ], + ['soqlQueries', 'queryRows'], + ); + + it('reads each metric against its own peak so the series still has a shape', () => { + const data = new MetricTierClassifier().processData(noLimits); + + expect(data.scaledToPeak).toBe(true); + expect(data.points.map((p) => p.values.get('soqlQueries'))).toEqual([0.25, 1]); + expect(data.points.map((p) => p.values.get('queryRows'))).toEqual([0.25, 1]); + }); + + it("carries each metric's own peak as its denominator", () => { + const byId = new Map( + new MetricTierClassifier() + .processData(noLimits) + .classifiedMetrics.map((m) => [m.metricId, m]), + ); + + expect(byId.get('soqlQueries')?.denominator).toEqual({ kind: 'peak', value: 12 }); + expect(byId.get('queryRows')?.denominator).toEqual({ kind: 'peak', value: 1200 }); + }); + + // Every metric that moved reaches exactly 100% of its own peak, so the percentage ranks + // nothing and reading order (priority) decides which are drawn as primaries. + it('tiers by reading order, and promotes nothing for passing 80% of itself', () => { + const data = new MetricTierClassifier().processData(noLimits); + const byId = new Map(data.classifiedMetrics.map((m) => [m.metricId, m])); + + expect(byId.get('soqlQueries')?.tier).toBe(1); + expect(byId.get('queryRows')?.tier).toBe(1); + expect(data.classifiedMetrics.some((m) => m.tier === 2)).toBe(false); + }); + + // Peak-scaling puts every metric at exactly 100% at its own peak, so the default 110% ceiling + // still leaves the headroom the axis is drawn with. + it('keeps the y-axis off the top of the strip', () => { + const classifier = new MetricTierClassifier(); + classifier.processData(noLimits); + + expect(classifier.getEffectiveYMax()).toBeCloseTo(1.1); + }); + }); + + it('reports no mode for an empty series', () => { + const data = new MetricTierClassifier().processData({ metrics: new Map(), events: [] }); + + expect(data).toMatchObject({ hasData: false, scaledToPeak: false }); + }); +}); diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts b/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts index 4fd92c4f2..c35d6ffd4 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/MetricTierClassifier.ts @@ -23,6 +23,7 @@ import type { HeatStripTimeSeries, + MetricDenominator, MetricStripClassifiedMetric, MetricStripDataPoint, MetricStripProcessedData, @@ -43,6 +44,27 @@ const TIER_1_COUNT = 3; */ const TIER_2_THRESHOLD = METRIC_STRIP_THRESHOLDS.dangerStart; +/** What a strip with nothing to draw reads, shared so a new field is added once. */ +export const EMPTY_METRIC_STRIP_DATA: MetricStripProcessedData = { + points: [], + classifiedMetrics: [], + globalMaxPercent: 0, + hasData: false, + scaledToPeak: false, + gaps: [], +}; + +/** + * A metric's denominator from its resolved scale. A scale of 0 is a metric the log reported no + * limit for in a log that reported some: it is off the series, not measurable against anything. + */ +function denominatorOf(scale: number, reportedLimit: boolean): MetricDenominator { + if (scale <= 0) { + return { kind: 'none' }; + } + return reportedLimit ? { kind: 'limit', value: scale } : { kind: 'peak', value: scale }; +} + /** Cached lookup result for getDataPointAtTime optimization. */ interface CachedLookup { /** Start time of the cached segment (inclusive). */ @@ -71,37 +93,35 @@ export class MetricTierClassifier { this.lookupCache = null; if (timeSeries.events.length === 0) { - this.processedData = { - points: [], - classifiedMetrics: [], - globalMaxPercent: 0, - hasData: false, - gaps: timeSeries.gaps ?? [], - }; + this.processedData = { ...EMPTY_METRIC_STRIP_DATA, gaps: timeSeries.gaps ?? [] }; return this.processedData; } // Step 1: Aggregate events by timestamp (sum used values across namespaces) const aggregatedByTime = this.aggregateByTimestamp(timeSeries); - // Step 2: Calculate global max percentage and authoritative limit for each metric - const { maxPercents, limits } = this.calculateMetricMaxPercents(aggregatedByTime, timeSeries); + // Step 2: Resolve each metric's denominator, then its global max share of it + const { scales, limits, peaks, scaledToPeak } = this.metricScales(aggregatedByTime); + const maxPercents = this.metricMaxPercents(timeSeries, scales, peaks); // Step 3: Classify metrics into tiers - const classifiedMetrics = this.classifyMetrics(maxPercents, limits, timeSeries); - - // Step 4: Build data points with tier classification - const { points, globalMaxPercent } = this.buildDataPoints( - aggregatedByTime, - classifiedMetrics, + const classifiedMetrics = this.classifyMetrics( + maxPercents, + scales, + limits, timeSeries, + scaledToPeak, ); + // Step 4: Build data points with tier classification + const points = this.buildDataPoints(aggregatedByTime, classifiedMetrics, scales); + this.processedData = { points, classifiedMetrics, - globalMaxPercent, + globalMaxPercent: Math.max(0, ...maxPercents.values()), hasData: points.length > 0, + scaledToPeak, gaps: timeSeries.gaps ?? [], }; @@ -261,49 +281,80 @@ export class MetricTierClassifier { } /** - * Calculate the global maximum percentage and authoritative limit for each metric. - * The limit is fixed across the series, so the largest one seen is captured for display. + * The denominator every percentage on the strip divides by, per metric. + * + * Normally a metric's own reported limit โ€” fixed across the series, so the largest one seen wins. + * When the log reported no limit for any metric, the metric's own peak level stands in so the + * series still has a shape to draw; `scaledToPeak` then tells the renderer to drop the 80% band, + * the 100% line, the breach fill and the traffic lights, none of which mean anything against a + * peak. A log that reports *some* limits keeps the old behaviour: a metric with none is left at + * scale 0 and dropped, rather than drawn against a different kind of denominator to its + * neighbours. */ - private calculateMetricMaxPercents( + private metricScales( aggregatedByTime: Map>, - timeSeries: HeatStripTimeSeries, - ): { maxPercents: Map; limits: Map } { - const maxPercents = new Map(); + ): { + scales: Map; + limits: Map; + peaks: Map; + scaledToPeak: boolean; + } { const limits = new Map(); + const peaks = new Map(); - // Initialize all metrics with 0 - for (const metricId of timeSeries.metrics.keys()) { - maxPercents.set(metricId, 0); - limits.set(metricId, 0); - } - - // Find max percentage (and capture the limit) for each metric for (const timestampData of aggregatedByTime.values()) { for (const [metricId, value] of timestampData) { - if (value.limit > 0) { - const percent = value.used / value.limit; - const currentMax = maxPercents.get(metricId) ?? 0; - if (percent > currentMax) { - maxPercents.set(metricId, percent); - } - if (value.limit > (limits.get(metricId) ?? 0)) { - limits.set(metricId, value.limit); - } + if (value.limit > (limits.get(metricId) ?? 0)) { + limits.set(metricId, value.limit); + } + if (value.used > (peaks.get(metricId) ?? 0)) { + peaks.set(metricId, value.used); } } } - return { maxPercents, limits }; + let anyLimit = false; + for (const limit of limits.values()) { + if (limit > 0) { + anyLimit = true; + break; + } + } + return { scales: anyLimit ? limits : peaks, limits, peaks, scaledToPeak: !anyLimit }; + } + + /** + * Each metric's highest share of its denominator across the series โ€” its rank for tiering. A + * scale is fixed for the whole series, so the highest share is the metric's peak over it and + * needs no second walk of the timestamps. + */ + private metricMaxPercents( + timeSeries: HeatStripTimeSeries, + scales: Map, + peaks: Map, + ): Map { + const maxPercents = new Map(); + for (const metricId of timeSeries.metrics.keys()) { + const scale = scales.get(metricId) ?? 0; + maxPercents.set(metricId, scale > 0 ? (peaks.get(metricId) ?? 0) / scale : 0); + } + return maxPercents; } /** * Classify metrics into tiers based on their global max percentages. * Colors are assigned by rank within each tier, not by metric type. + * + * Peak-scaled, every metric that moved reaches exactly 100% of its own peak, so the percentage + * ranks nothing: reading order stands in, as it does for the gauges, and nothing is promoted for + * passing 80% of itself. */ private classifyMetrics( metricMaxPercents: Map, - metricLimits: Map, + scales: Map, + limits: Map, timeSeries: HeatStripTimeSeries, + scaledToPeak: boolean, ): MetricStripClassifiedMetric[] { // Create array of metrics with their max percents for sorting const metricsWithMax: Array<{ @@ -311,7 +362,7 @@ export class MetricTierClassifier { displayName: string; priority: number; maxPercent: number; - limit: number; + denominator: MetricDenominator; unit: string; }> = []; @@ -322,13 +373,14 @@ export class MetricTierClassifier { displayName: metricDef?.displayName ?? metricId, priority: metricDef?.priority ?? 999, maxPercent, - limit: metricLimits.get(metricId) ?? 0, + denominator: denominatorOf(scales.get(metricId) ?? 0, (limits.get(metricId) ?? 0) > 0), unit: metricDef?.unit ?? '', }); } - // Sort by max percent descending - metricsWithMax.sort((a, b) => b.maxPercent - a.maxPercent); + metricsWithMax.sort((a, b) => + scaledToPeak ? a.priority - b.priority : b.maxPercent - a.maxPercent, + ); // Classify into tiers and track rank within each tier const classified: MetricStripClassifiedMetric[] = []; @@ -346,8 +398,8 @@ export class MetricTierClassifier { // Top 3 metrics are always Tier 1 tier = 1; rankInTier = tier1Rank++; - } else if (metric.maxPercent >= TIER_2_THRESHOLD) { - // Metrics that exceed 80% are Tier 2 + } else if (!scaledToPeak && metric.maxPercent >= TIER_2_THRESHOLD) { + // Metrics that exceed 80% of a reported limit are Tier 2 tier = 2; rankInTier = tier2Rank++; } else { @@ -361,7 +413,7 @@ export class MetricTierClassifier { displayName: metric.displayName, tier, globalMaxPercent: metric.maxPercent, - limit: metric.limit, + denominator: metric.denominator, color: getRankBasedColor(tier, rankInTier), priority: metric.priority, unit: metric.unit, @@ -377,8 +429,8 @@ export class MetricTierClassifier { private buildDataPoints( aggregatedByTime: Map>, classifiedMetrics: MetricStripClassifiedMetric[], - _timeSeries: HeatStripTimeSeries, - ): { points: MetricStripDataPoint[]; globalMaxPercent: number } { + scales: Map, + ): MetricStripDataPoint[] { // Create lookup for tier by metric ID const metricTiers = new Map(); for (const metric of classifiedMetrics) { @@ -386,7 +438,6 @@ export class MetricTierClassifier { } const points: MetricStripDataPoint[] = []; - let globalMaxPercent = 0; // Sort timestamps const timestamps = Array.from(aggregatedByTime.keys()).sort((a, b) => a - b); @@ -398,16 +449,12 @@ export class MetricTierClassifier { let tier3Max = 0; for (const [metricId, value] of timestampData) { - if (value.limit > 0) { - const percent = value.used / value.limit; + const scale = scales.get(metricId) ?? 0; + if (scale > 0) { + const percent = value.used / scale; values.set(metricId, percent); rawValues.set(metricId, { used: value.used, limit: value.limit, tracked: value.tracked }); - // Track global max - if (percent > globalMaxPercent) { - globalMaxPercent = percent; - } - // Track Tier 3 max for aggregation const tier = metricTiers.get(metricId); if (tier === 3 && percent > tier3Max) { @@ -424,6 +471,6 @@ export class MetricTierClassifier { }); } - return { points, globalMaxPercent }; + return points; } } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts index 354967552..d4ec101c3 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.test.ts @@ -21,6 +21,9 @@ const LIMITS = new Map([ ['heapSize', 6000000], ]); +/** The fold targets ~500 points per metric per namespace; allow the rounding slack. */ +const POINT_BUDGET_CEILING = 520; + const delta = ( timestamp: number, metric: string, @@ -67,13 +70,27 @@ describe('buildGovernorTimeSeries', () => { ); }); - it('does not emit a metric with no limit in the map', () => { + // The log is the only source of a limit, so a metric it never gave one for still belongs on the + // series โ€” carrying limit 0, which its consumers read as "scale me by my own peak". + it('emits a metric the log reported no limit for, with limit 0', () => { const series = buildGovernorTimeSeries( - [delta(10, 'soqlQueries', 1)], + [delta(10, 'soqlQueries', 1), delta(20, 'soqlQueries', 1)], METRICS, new Map(), // no limits ); - expect(series.events).toEqual([]); + + expect(series.events.map((e) => e.values.get('soqlQueries')?.used)).toEqual([1, 2]); + expect(series.events.every((e) => e.values.get('soqlQueries')?.limit === 0)).toBe(true); + }); + + // Without a limit to size the coalescing threshold from, the metric's own total magnitude does + // it, so a high-frequency metric stays bounded instead of emitting a point per event. + it('bounds points from the observed magnitude when the log reported no limit', () => { + const heapDeltas = Array.from({ length: 4000 }, (_, i) => delta(i + 1, 'heapSize', 1000)); + const series = buildGovernorTimeSeries(heapDeltas, METRICS, new Map()); + + expect(series.events.length).toBeLessThanOrEqual(POINT_BUDGET_CEILING); + expect(series.events[series.events.length - 1]?.values.get('heapSize')?.used).toBe(4_000_000); }); it('corrects up to the cumulative snapshot and records the tracked divergence', () => { diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts index 2646da94d..9f99cf47e 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/governor-timeline.ts @@ -89,17 +89,55 @@ function sortByTime(observations: LimitObservation[]): LimitObservation[] { return observations.slice().sort((a, b) => a.timestamp - b.timestamp); } +/** + * The coalescing threshold per metric, sized from its reported limit or โ€” where the log reported + * none โ€” the highest level it ever held. + * + * The running net, not the sum of the magnitudes: heap allocates and frees far more than it holds, + * and sizing from that churn would set a threshold the curve never crosses, leaving a metric with a + * handful of points instead of a shape. Observations must be time-sorted. + */ +function coalescingThresholds( + sorted: LimitObservation[], + limits: Map, +): Map { + const scales = new Map(limits); + // One entry per unlimited metric, mutated in place: this walks every heap allocation in the log. + const levels = new Map(); + for (const obs of sorted) { + if (obs.kind === 'delta' && (limits.get(obs.metric) ?? 0) <= 0) { + let level = levels.get(obs.metric); + if (!level) { + level = { running: 0, peak: 0 }; + levels.set(obs.metric, level); + } + level.running += obs.delta; + const held = Math.abs(level.running); + if (held > level.peak) { + level.peak = held; + scales.set(obs.metric, held); + } + } + } + + for (const [metric, scale] of scales) { + scales.set(metric, Math.max(1, Math.floor(scale / POINT_BUDGET))); + } + return scales; +} + /** * Coalesce consecutive same-`(namespace, metric)` deltas: accumulate and emit a single delta only - * once `|pending| โ‰ฅ max(1, floor(limit / POINT_BUDGET))`, on the triggering event's timestamp. + * once `|pending| โ‰ฅ max(1, floor(scale / POINT_BUDGET))`, on the triggering event's timestamp. * Pending deltas are flushed before any absolute for the same key (so the correction sees them) and - * at end of stream. Counts (small limits โ†’ threshold 1) stay per-event; rows/heap coalesce. Absolutes + * at end of stream. Counts (small scales โ†’ threshold 1) stay per-event; rows/heap coalesce. Absolutes * pass through untouched. Input must be time-sorted; output is re-sorted (the flush order can differ). */ function coalesceDeltas( sorted: LimitObservation[], limits: Map, ): LimitObservation[] { + const thresholds = coalescingThresholds(sorted, limits); const out: LimitObservation[] = []; // namespace -> metric -> accumulated delta + latest timestamp const pending = new Map>(); @@ -116,7 +154,7 @@ function coalesceDeltas( for (const obs of sorted) { if (obs.kind === 'delta') { - const threshold = Math.max(1, Math.floor((limits.get(obs.metric) ?? 0) / POINT_BUDGET)); + const threshold = thresholds.get(obs.metric) ?? 1; let byMetric = pending.get(obs.namespace); if (!byMetric) { byMetric = new Map(); @@ -172,9 +210,10 @@ function reportedCaps(observations: LimitObservation[]): Map 0) and at least one observation. + * @param limits - The per-metric limit the log reported (the "out of" total), resolved once by the + * caller from the cumulative snapshots so the total never changes across the series. A metric the + * log reported no limit for is emitted with `limit: 0`; its consumers scale it by its own peak + * instead. Nothing here substitutes a limit the log did not give. */ export function buildGovernorTimeSeries( observations: LimitObservation[], @@ -241,7 +280,7 @@ export function buildGovernorTimeSeries( return { metrics, events }; } -/** Emit one combined point: sum last-known per-namespace values for every metric with a known limit. */ +/** Emit one combined point: sum the last-known per-namespace values for every observed metric. */ function emitPoint( timestamp: number, state: Map>, @@ -267,11 +306,8 @@ function emitPoint( const values = new Map(); for (const [metric, a] of agg) { - const limit = limits.get(metric) ?? 0; - if (limit <= 0) { - continue; - } - const value: HeatStripMetricValue = { used: a.used, limit }; + // 0 means the log reported no limit โ€” the value still belongs on the series. + const value: HeatStripMetricValue = { used: a.used, limit: limits.get(metric) ?? 0 }; if (a.anyDelta && a.trackedSum < a.used) { value.tracked = a.trackedSum; } diff --git a/log-viewer/src/features/timeline/optimised/metric-strip/metric-strip-colors.ts b/log-viewer/src/features/timeline/optimised/metric-strip/metric-strip-colors.ts index bc2db591f..d2bb5cf67 100644 --- a/log-viewer/src/features/timeline/optimised/metric-strip/metric-strip-colors.ts +++ b/log-viewer/src/features/timeline/optimised/metric-strip/metric-strip-colors.ts @@ -223,6 +223,33 @@ export function getTrafficLightColor(percent: number): TrafficLightColor { } } +/** + * The steps {@link getDensityColor} answers with. Quantised and pre-allocated: the collapsed strip + * merges adjacent buckets into one rect per repeated colour, so a continuous alpha would give it a + * rect per data point, and it asks per bucket on every frame. + */ +const DENSITY_STEPS: readonly TrafficLightColor[] = [ + { color: 0x000000, alpha: 0 }, + ...Array.from({ length: 8 }, (_, step) => ({ + color: METRIC_STRIP_COLORS.gridLine, + alpha: 0.12 + ((step + 1) / 8) * 0.38, + })), +]; + +/** + * Density colour for the collapsed strip when the log reported no limits. Severity is unknowable + * without a cap, so this carries level only: one neutral grey, opacity rising with the share of the + * log's own peak. Never a traffic light โ€” an amber or red bucket would assert a proximity we cannot + * measure. + * + * @param fraction - Share of the metric's own peak (0-1) + */ +export function getDensityColor(fraction: number): TrafficLightColor { + const clamped = Math.max(0, Math.min(1, fraction)); + // Ceiling, so any level at all draws: rounding would leave the lowest shares invisible. + return DENSITY_STEPS[Math.ceil(clamped * 8)]!; +} + /** * Default metric color mapping for Apex governor limits. * Maps metric IDs to their assigned colors. diff --git a/log-viewer/src/features/timeline/optimised/rendering/tooltip-utils.ts b/log-viewer/src/features/timeline/optimised/rendering/tooltip-utils.ts index 08844dc4e..13b1362bb 100644 --- a/log-viewer/src/features/timeline/optimised/rendering/tooltip-utils.ts +++ b/log-viewer/src/features/timeline/optimised/rendering/tooltip-utils.ts @@ -116,31 +116,36 @@ export function hexToCSS(hex: number): string { return `#${hex.toString(16).padStart(6, '0')}`; } +/** How a reading names its denominator: `/` for a reported limit, `of` for the log's own peak. */ +export type UsageSeparator = '/' | 'of'; + /** - * Format metric value with used/limit and optional unit. + * Format a reading against its denominator and an optional unit. * * @param used - Used value - * @param limit - Limit value + * @param denominator - Reported limit, or the metric's own peak in the log * @param unit - Optional unit string (e.g., "ms", "bytes") - * @returns Formatted string (e.g., "250 / 500 ms") + * @param separator - `/` reads as a cap the transaction was measured against, so a peak takes `of` + * @returns Formatted string (e.g., "250 / 500 ms", "770 of 1,240") */ -export function formatMetricValue(used: number, limit: number, unit?: string): string { +export function formatMetricValue( + used: number, + denominator: number, + unit?: string, + separator: UsageSeparator = '/', +): string { const usedStr = formatNumber(Math.round(used)); - const limitStr = formatNumber(Math.round(limit)); - if (unit) { - return `${usedStr} / ${limitStr} ${unit}`; - } - return `${usedStr} / ${limitStr}`; + const denominatorStr = formatNumber(Math.round(denominator)); + const reading = `${usedStr} ${separator} ${denominatorStr}`; + return unit ? `${reading} ${unit}` : reading; } -/** - * Format metric value with parentheses. - * - * @param used - Used value - * @param limit - Limit value - * @param unit - Optional unit string - * @returns Formatted string with parentheses (e.g., "(250 / 500 ms)") - */ -export function formatMetricValueWithParens(used: number, limit: number, unit?: string): string { - return `(${formatMetricValue(used, limit, unit)})`; +/** {@link formatMetricValue} in parentheses (e.g., "(250 / 500 ms)"). */ +export function formatMetricValueWithParens( + used: number, + denominator: number, + unit?: string, + separator?: UsageSeparator, +): string { + return `(${formatMetricValue(used, denominator, unit, separator)})`; } diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index 7cf15e3d5..02af38040 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -847,7 +847,7 @@ export interface HeatStripMetric { export interface HeatStripMetricValue { /** Current usage value (corrected: last cumulative baseline + granular deltas since). */ used: number; - /** Maximum allowed value (limit) */ + /** The limit the log reported for this metric; 0 when the log reported none. */ limit: number; /** * Increment-only total from detailed events, set only for delta-tracked metrics and only @@ -918,6 +918,14 @@ export type HeatStripTimeSeriesMetric = HeatStripMetric; * Classified metric for metric strip tier system. * Metrics are classified into tiers based on their global max percentage. */ +/** + * What a metric's percentages are measured against. `peak` is the metric's own highest level, + * standing in where the log reported no limit; `none` is a metric the log reported no limit for in + * a log that reported some, which is off the series and cannot be read at all. + */ +export type MetricDenominator = + { kind: 'limit'; value: number } | { kind: 'peak'; value: number } | { kind: 'none' }; + export interface MetricStripClassifiedMetric { /** Unique metric identifier (e.g., 'cpuTime', 'soqlQueries') */ metricId: string; @@ -927,8 +935,8 @@ export interface MetricStripClassifiedMetric { tier: 1 | 2 | 3; /** Maximum percentage reached across all timestamps (0-1+) */ globalMaxPercent: number; - /** Authoritative limit for this metric ("out of" total), fixed across the series. 0 if unknown. */ - limit: number; + /** What this metric's percentages divide by, resolved once for the whole series. */ + denominator: MetricDenominator; /** Line color for this metric (hex number 0xRRGGBB) */ color: number; /** Priority for ordering (lower = higher priority, shown first) */ @@ -943,7 +951,7 @@ export interface MetricStripClassifiedMetric { export interface MetricStripRawValue { /** Current usage value (corrected line value). */ used: number; - /** Maximum allowed value (limit) */ + /** The limit the log reported for this metric; 0 when the log reported none. */ limit: number; /** Increment-only tracked total, present only when it diverges below `used`. See HeatStripMetricValue.tracked. */ tracked?: number; @@ -975,6 +983,12 @@ export interface MetricStripProcessedData { globalMaxPercent: number; /** Whether there's any data to render */ hasData: boolean; + /** + * True when the log reported no limit for any metric, so every percentage is a share of that + * metric's own peak rather than of a cap. The 80% band, the 100% line, the breach fill and the + * traffic-light colours mean nothing against a peak, so the renderer drops them. + */ + scaledToPeak: boolean; /** Spans the log recorded nothing in, carried through from the series */ gaps: NoDataSpan[]; } From bcc6e2106b922e45777d55733a502fc347482d70 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:18:58 +0100 Subject: [PATCH 50/61] docs: put every changelog rule in its skill, sub-bullets allowed (#1027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview The changelog rules sat in two places and disagreed. `AGENTS.md` banned sub-bullets outright, so a feature with several distinct parts had to become one run-on sentence. The `changelog-entry` skill meanwhile set a section order and a tense that this repo's `CHANGELOG.md` has never used. An entry may now carry up to three sub-bullets, each naming one capability. `AGENTS.md` forwards to the skill instead of restating it, so there is one place to read and one place to change. ## ๐Ÿ› ๏ธ Changes made - Allow three sub-bullets at most under an entry, each naming one capability the reader can use โ€” a run-on sentence was the only alternative. - Point `AGENTS.md` at the skill rather than repeating it, so the two cannot drift apart again. - Move the `- **Label**:` house style into the skill, alongside the rest of the guidance. - Correct the skill's section order to Added, Changed, Fixed, and its tense examples, to match what `CHANGELOG.md` actually does. - Tighten the skill description so it loads while `CHANGELOG.md` is being edited, and fits the 200-character limit it was over. ## ๐Ÿงฉ Type of change (check all applicable) - [ ] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [ ] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [x] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A ## ๐Ÿ”— Related Issues N/A ## โœ… Tests added? - [ ] ๐Ÿ‘ yes - [x] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [ ] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features (README `๐Ÿงช` badge โ€” see [RELEASING.md](../RELEASING.md#-marking-pre-release-only-features)) - [x] ๐Ÿ™… not needed ## Anything else we need to know? [optional] Agent instructions only. Nothing ships to users. --- .claude/skills/changelog-entry/SKILL.md | 52 ++++++++++++++++--------- AGENTS.md | 4 +- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/.claude/skills/changelog-entry/SKILL.md b/.claude/skills/changelog-entry/SKILL.md index 88a838abf..48714acc1 100644 --- a/.claude/skills/changelog-entry/SKILL.md +++ b/.claude/skills/changelog-entry/SKILL.md @@ -1,6 +1,6 @@ --- name: changelog-entry -description: Write, review or trim CHANGELOG.md entries so a reader understands them. Use when a pull request needs a changelog line, when an Unreleased section is too long or too technical to read, when deciding if a change is breaking, or when a changelog conflict appears while rebasing. +description: Write, review or trim CHANGELOG.md entries. Use when editing CHANGELOG.md, when a PR needs a changelog line, when a section is too long or a change may be breaking, or on a changelog conflict. --- # Changelog entry @@ -25,18 +25,30 @@ files moved belong in the issue and the pull request. [#86]: https://github.com/owner/repo/issues/86 ``` -- **One sentence, no sub-bullets.** No semicolon joining two facts. Two wrapped lines is the ceiling. -- **Present tense.** "Add", "Reduce", "Refuse" โ€” not "Added", "Reduced". +- **One sentence.** No semicolon joining two facts. Two wrapped lines is the ceiling. +- **Sub-bullets only where one feature has distinct parts** โ€” three at most, each naming one + capability. Never how it was built. +- **Present tense.** "reports", "shows", "flags" โ€” never "reported", "showed". - **Breaking entries first** in their section, prefixed `**Breaking:**`. - **Then most impactful first.** The entry that changes the most readers' day leads its section. Not commit order, not issue number, not the order you wrote them. -- **Sections in this order:** Changed, Added, Removed, Fixed. +- **Sections in this order:** Added, Changed, Fixed. - **A reference link on every substantial entry**, defined under `` at the end of the file. Never an inline URL. -**The file outranks this skill on style.** Read the released sections first. If they carry an emoji -and a bold label, or past tense, match them โ€” a changelog that switches voice mid-file reads worse -than one in the wrong voice. Length and jargon are not style: those rules hold everywhere. +## This repo's house style + +``` +- **Label**: ([#issue]) +``` + +Say what the user gets. No leading verb โ€” the label names the feature. Use an emoji no other entry +in the section uses. + +The root `CHANGELOG.md` is the source. The `lana/` copy is generated by the build โ€” never edit it. + +**The file outranks this skill on style.** Match the released sections. Length and jargon are not +style โ€” those rules always hold. ## Write for the reader, not the author @@ -45,7 +57,8 @@ The reader upgrades the package; they did not write it. Name the outcome they ca - **No internal jargon.** No module, class, library or algorithm names. If the reader cannot find the word in the product, cut it. - **A fix names the symptom, not the cause.** -- **A big feature gets one headline entry**, not a tour of every facet. Detail belongs in the docs. +- **A big feature gets one headline entry**, plus up to three sub-bullets for its parts. Detail + belongs in the docs. ## What earns an entry @@ -65,16 +78,16 @@ No issue fits? File one, then reference it. ## Wrong, then right -| Wrong | Right | -| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `- Removed destructiveHint from three tools, since the spec says it is meaningless when readOnlyHint is true` | no entry โ€” the user sees no difference | -| `- Replaced ten per-category properties with one z.partialRecord, cutting ~844 to ~428 tokens` | fold the result into the one user-facing entry | -| `- Reduced the cost by 31% ([#87](https://.../87))` | `- Reduce the cost by 31% ([#87])`, plus a reference definition | -| `- Refactor CSV parsing to process dataset arrays asynchronously` | `- Fix the freeze on a large CSV export` | -| `- Replace webview-ui-toolkit with vscode-elements` | `- Match the host's controls more closely` | -| a feature with six nested sub-bullets | one headline sentence, plus a docs link | -| `- Improve search performance` | `- Search a 100MB log 10ร— faster` | -| `- Optimise the parser` | `- Cut parse time on a large log by 31%` | +| Wrong | Right | +| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `- Removed destructiveHint from three tools, since the spec says it is meaningless when readOnlyHint is true` | no entry โ€” the user sees no difference | +| `- Replaced ten per-category properties with one z.partialRecord, cutting ~844 to ~428 tokens` | fold the result into the one user-facing entry | +| `- Reduced the cost by 31% ([#87](https://.../87))` | `- Reduce the cost by 31% ([#87])`, plus a reference definition | +| `- Refactor CSV parsing to process dataset arrays asynchronously` | `- Fix the freeze on a large CSV export` | +| `- Replace webview-ui-toolkit with vscode-elements` | `- Match the host's controls more closely` | +| a feature with six nested sub-bullets | a headline sentence, then three sub-bullets at most, one capability each | +| `- Improve search performance` | `- Search a 100MB log 10ร— faster` | +| `- Optimise the parser` | `- Cut parse time on a large log by 31%` | ## Trim a section nobody will read @@ -83,7 +96,8 @@ anything, and merging is most of the win. 1. Find the bounds: `grep -n '^## \[' CHANGELOG.md`. 2. Read the whole section before changing a word. -3. Draft the replacement in one pass. Fold every sub-bullet into its headline, or drop it. +3. Draft the replacement in one pass. Three sub-bullets at most under a headline; fold or drop + the rest. 4. Merge entries that name the same surface or the same fix. Three styling entries are one entry. 5. Drop what the reader cannot see, by the rules above. 6. Re-order each section by impact. A trimmed section in the old order still buries the lead. diff --git a/AGENTS.md b/AGENTS.md index 0c393045f..7bc208978 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,9 +53,7 @@ communicate via message passing only. `refactor:`, `perf:`, `test:`). Don't auto-commit. - Branches: `feat-*` for features, `bug-*` for defects. - Releases follow SemVer; update CHANGELOG; breaking changes need a migration guide. -- CHANGELOG entries: one or two lines, no sub-bullets. Say what the user gets, not how it - was built. Order each section by impact, most impactful first. A perf entry states its - multiple or percentage. House style: `- **Label**: ([#issue])`. +- CHANGELOG entries: see the `changelog-entry` skill in `.claude/skills/`. - Never reference Anthropic or Claude in commit messages, PRs, etc. ## Rules manifest From ff71cdc1ded9b995f4cf8db8469a6c8a76669336 Mon Sep 17 00:00:00 2001 From: peternhale Date: Wed, 9 Sep 2026 10:13:59 -0600 Subject: [PATCH 51/61] fix(lana): embed browser log viewer assets (#1015) ## Summary - bundle the browser extension and log viewer assets into one self-contained web entrypoint - retain packaged-file loading for the desktop extension host - preserve replacement tokens such as $& when embedding minified JavaScript and CSS, preventing bundle source from appearing as webview text - cover embedded browser assets and the desktop fallback with regression tests ## Testing - pnpm build - pnpm test --runInBand (169 suites, 2,307 tests) - pnpm lint Co-authored-by: Luke Cotter <81575432+lcottercertinia@users.noreply.github.com> --- lana/src/Main.web.ts | 14 ++++++ lana/src/commands/LogView.ts | 39 +++++++++++---- lana/src/commands/__tests__/LogView.test.ts | 53 ++++++++++++++++----- lana/src/display/LogViewerAssets.ts | 20 ++++++++ lana/src/types/virtual.d.ts | 19 ++++++++ rolldown.config.ts | 33 ++++++++++++- rollup.config.mjs | 44 +++++++++++++++++ scripts/rollup-plugin-text.mjs | 38 +++++++++++++++ 8 files changed, 238 insertions(+), 22 deletions(-) create mode 100644 lana/src/display/LogViewerAssets.ts create mode 100644 lana/src/types/virtual.d.ts create mode 100644 scripts/rollup-plugin-text.mjs diff --git a/lana/src/Main.web.ts b/lana/src/Main.web.ts index effc1281d..b9b78a958 100644 --- a/lana/src/Main.web.ts +++ b/lana/src/Main.web.ts @@ -2,4 +2,18 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +import codiconCss from 'virtual:lana-codicon-css'; +import codiconFont from 'virtual:lana-codicon-font'; +import logViewerHtml from 'virtual:lana-log-viewer-html'; +import logViewerScript from 'virtual:lana-log-viewer-script'; + +import { setEmbeddedLogViewerAssets } from './display/LogViewerAssets.js'; + +setEmbeddedLogViewerAssets({ + html: logViewerHtml, + script: logViewerScript, + codiconCss, + codiconFont, +}); + export { activate, context, deactivate } from './Main.js'; diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index afb8288e4..bdad33299 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -5,6 +5,7 @@ import { Uri, commands, window as vscWindow, workspace, type WebviewPanel } from import { Utils } from 'vscode-uri'; import type { Context } from '../Context.js'; +import { getEmbeddedLogViewerAssets } from '../display/LogViewerAssets.js'; import { OpenFileInPackage } from '../display/OpenFileInPackage.js'; import { WebView } from '../display/WebView.js'; import { RawLogNavigation } from '../log-features/RawLogNavigation.js'; @@ -65,14 +66,18 @@ export class LogView { this.currentLogUri = logUri; const logViewerRoot = Utils.joinPath(context.context.extensionUri, 'out'); - const index = Utils.joinPath(logViewerRoot, 'index.html'); - const bundleUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'bundle.js')); - const codiconUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'codicon.css')); - const indexSrc = await this.getFile(index); panel.iconPath = Utils.joinPath(logViewerRoot, 'certinia-icon-color.png'); - panel.webview.html = indexSrc - .replace(/bundle\.js/gi, bundleUri.toString(true)) - .replace(/codicon\.css/gi, codiconUri.toString(true)); + const embeddedAssets = getEmbeddedLogViewerAssets(); + if (embeddedAssets) { + panel.webview.html = LogView.embedAssets(embeddedAssets); + } else { + const bundleUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'bundle.js')); + const codiconUri = panel.webview.asWebviewUri(Utils.joinPath(logViewerRoot, 'codicon.css')); + const index = Utils.joinPath(logViewerRoot, 'index.html'); + panel.webview.html = (await readFileText(index)) + .replace(/bundle\.js/gi, bundleUri.toString(true)) + .replace(/codicon\.css/gi, codiconUri.toString(true)); + } // The panel keeps its context when hidden, so it is never re-created: settings // edits have to be pushed to it. Only push when the resolved payload actually @@ -232,8 +237,24 @@ export class LogView { return config; } - private static async getFile(fileUri: Uri): Promise { - return readFileText(fileUri); + private static embedAssets( + assets: NonNullable>, + ): string { + const fontData = `data:font/ttf;base64,${assets.codiconFont}`; + const codiconCss = assets.codiconCss + .replace(/url\((['"]?)\.\/codicon\.ttf[^)]*\)/i, `url("${fontData}")`) + .replace(/<\/style/gi, '<\\/style'); + const script = assets.script.replace(/<\/script/gi, '<\\/script'); + + return assets.html + .replace( + /]*\bid="vscode-codicon-stylesheet")[^>]*>/i, + () => ``, + ) + .replace( + /]*\bsrc="bundle\.js")[^>]*><\/script>/i, + () => ``, + ); } private static async sendLog( diff --git a/lana/src/commands/__tests__/LogView.test.ts b/lana/src/commands/__tests__/LogView.test.ts index ad6284f58..07c41d434 100644 --- a/lana/src/commands/__tests__/LogView.test.ts +++ b/lana/src/commands/__tests__/LogView.test.ts @@ -1,10 +1,11 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import { describe, expect, it } from '@jest/globals'; +import { afterEach, describe, expect, it } from '@jest/globals'; import { createMockContext } from '../../__tests__/helpers/test-builders.js'; import { Uri, workspace } from '../../__tests__/mocks/vscode.js'; +import { setEmbeddedLogViewerAssets } from '../../display/LogViewerAssets.js'; import { WebView } from '../../display/WebView.js'; import { LogView } from '../LogView.js'; @@ -32,12 +33,13 @@ jest.mock('../../workspace/AppConfig.js', () => ({ })); const mockApplyWebView = WebView.apply as jest.Mock; -// The file-I/O layer is deliberately not mocked out: createView reads its own -// bundled index.html, and mocking that module away is what hid it reading -// through a service that throws unless another extension has initialised it. const mockReadFile = workspace.fs.readFile as unknown as jest.Mock; describe('LogView', () => { + afterEach(() => { + setEmbeddedLogViewerAssets(undefined); + }); + it('uses a display path in the payload and the captured URI for open actions', async () => { let receiveMessage: ((message: unknown) => Promise) | undefined; const postMessage = jest.fn().mockResolvedValue(true); @@ -56,9 +58,12 @@ describe('LogView', () => { }, }; mockApplyWebView.mockReturnValue(panel as unknown as import('vscode').WebviewPanel); - mockReadFile.mockResolvedValue( - new TextEncoder().encode(''), - ); + setEmbeddedLogViewerAssets({ + html: '', + script: 'const replacementToken = "$&"; globalThis.viewerLoaded = true;', + codiconCss: '@font-face { src: url("./codicon.ttf?hash") format("truetype"); } /* $& */', + codiconFont: 'Zm9udA==', + }); workspace.asRelativePath.mockReturnValue('workspace/logs/virtual.log'); const context = createMockContext(); const logUri = Uri.parse('memfs:/repository/logs/virtual.log'); @@ -69,11 +74,12 @@ describe('LogView', () => { logUri, 'log body', ); - // createView must resolve and rewrite the bundled index.html. It read that - // file through a service needing another extension's initialisation, so it - // rejected before the webview had any content. - expect(panel.webview.html).toContain('webview:/test/extension/out/bundle.js'); - expect(panel.webview.html).not.toContain('src="bundle.js"'); + expect(panel.webview.html).toContain( + ''), + ); + + await LogView.createView(createMockContext() as unknown as import('../../Context.js').Context); + + expect(mockReadFile).toHaveBeenCalledWith(Uri.parse('file:///test/extension/out/index.html')); + expect(panel.webview.html).toContain('webview:/test/extension/out/bundle.js'); + expect(panel.webview.html).not.toContain('src="bundle.js"'); + }); }); diff --git a/lana/src/display/LogViewerAssets.ts b/lana/src/display/LogViewerAssets.ts new file mode 100644 index 000000000..5b21e9b2a --- /dev/null +++ b/lana/src/display/LogViewerAssets.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +export interface EmbeddedLogViewerAssets { + html: string; + script: string; + codiconCss: string; + codiconFont: string; +} + +let embeddedAssets: EmbeddedLogViewerAssets | undefined; + +export function setEmbeddedLogViewerAssets(assets: EmbeddedLogViewerAssets | undefined): void { + embeddedAssets = assets; +} + +export function getEmbeddedLogViewerAssets(): EmbeddedLogViewerAssets | undefined { + return embeddedAssets; +} diff --git a/lana/src/types/virtual.d.ts b/lana/src/types/virtual.d.ts new file mode 100644 index 000000000..b3854a86b --- /dev/null +++ b/lana/src/types/virtual.d.ts @@ -0,0 +1,19 @@ +declare module 'virtual:lana-log-viewer-html' { + const content: string; + export default content; +} + +declare module 'virtual:lana-log-viewer-script' { + const content: string; + export default content; +} + +declare module 'virtual:lana-codicon-css' { + const content: string; + export default content; +} + +declare module 'virtual:lana-codicon-font' { + const content: string; + export default content; +} diff --git a/rolldown.config.ts b/rolldown.config.ts index 5ed24514f..88b6ae3c6 100644 --- a/rolldown.config.ts +++ b/rolldown.config.ts @@ -11,11 +11,22 @@ import nodePolyfills from '@rolldown/plugin-node-polyfills'; import copy from 'rollup-plugin-copy'; import css from './scripts/rollup-plugin-css.mjs'; +import text from './scripts/rollup-plugin-text.mjs'; // Resolve the codicons dist dir via Node resolution so it works regardless of // pnpm hoisting (avoids a hard-coded node_modules path). const nodeRequire = createRequire(import.meta.url); const codiconsDist = path.dirname(nodeRequire.resolve('@vscode/codicons/dist/codicon.css')); +const embeddedLogViewerPath = path.resolve('lana/build/log-viewer-embedded.js'); +const webExtensionAssets = { + 'virtual:lana-log-viewer-html': { path: path.resolve('log-viewer/index.html') }, + 'virtual:lana-log-viewer-script': { path: embeddedLogViewerPath }, + 'virtual:lana-codicon-css': { path: path.join(codiconsDist, 'codicon.css') }, + 'virtual:lana-codicon-font': { + path: path.join(codiconsDist, 'codicon.ttf'), + encoding: 'base64' as const, + }, +}; const production = process.env.NODE_ENV === 'production'; export default defineConfig([ @@ -35,6 +46,26 @@ export default defineConfig([ external: ['vscode'], }, + { + input: { 'log-viewer-embedded': './log-viewer/src/Main.ts' }, + output: { + format: 'esm', + dir: './lana/build', + entryFileNames: 'log-viewer-embedded.js', + cleanDir: true, + codeSplitting: false, + sourcemap: false, + keepNames: true, + minify: production, + }, + platform: 'browser', + moduleTypes: { + '.css': 'js', + '.scss': 'js', + }, + tsconfig: production ? './log-viewer/tsconfig.json' : './log-viewer/tsconfig-dev.json', + plugins: [nodePolyfills(), css({ minify: production })], + }, { input: { Main: './lana/src/Main.web.ts' }, output: { @@ -51,7 +82,7 @@ export default defineConfig([ tsconfig: production ? './lana/tsconfig.json' : './lana/tsconfig-dev.json', platform: 'browser', external: ['vscode'], - plugins: [nodePolyfills()], + plugins: [nodePolyfills(), text({ sources: webExtensionAssets })], }, { input: { bundle: './log-viewer/src/Main.ts' }, diff --git a/rollup.config.mjs b/rollup.config.mjs index cd3c301cd..300f7705e 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -11,11 +11,22 @@ import nodePolyfills from 'rollup-plugin-polyfill-node'; import { defineRollupSwcOption, swc } from 'rollup-plugin-swc3'; import css from './scripts/rollup-plugin-css.mjs'; +import text from './scripts/rollup-plugin-text.mjs'; // Resolve the codicons dist dir via Node resolution so it works regardless of // pnpm hoisting (avoids a hard-coded node_modules path). const nodeRequire = createRequire(import.meta.url); const codiconsDist = path.dirname(nodeRequire.resolve('@vscode/codicons/dist/codicon.css')); +const embeddedLogViewerPath = path.resolve('lana/build/log-viewer-embedded.js'); +const webExtensionAssets = { + 'virtual:lana-log-viewer-html': { path: path.resolve('log-viewer/index.html') }, + 'virtual:lana-log-viewer-script': { path: embeddedLogViewerPath }, + 'virtual:lana-codicon-css': { path: path.join(codiconsDist, 'codicon.css') }, + 'virtual:lana-codicon-font': { + path: path.join(codiconsDist, 'codicon.ttf'), + encoding: 'base64', + }, +}; const production = process.env.NODE_ENV === 'production'; export default [ @@ -60,6 +71,38 @@ export default [ ), ], }, + { + input: { 'log-viewer-embedded': './log-viewer/src/Main.ts' }, + moduleContext: (id) => + id.includes('/@vscode-elements/elements/') ? 'globalThis' : undefined, + output: { + format: 'es', + dir: './lana/build', + entryFileNames: 'log-viewer-embedded.js', + inlineDynamicImports: true, + sourcemap: false, + }, + plugins: [ + nodeResolve({ browser: true, preferBuiltins: false }), + commonjs(), + nodePolyfills(), + swc( + defineRollupSwcOption({ + include: /\.[mc]?[jt]sx?$/, + exclude: /node_modules/, + tsconfig: production ? './log-viewer/tsconfig.json' : './log-viewer/tsconfig-dev.json', + jsc: { + transform: { useDefineForClassFields: false }, + minify: { + compress: production, + mangle: production ? { keep_classnames: true } : false, + }, + }, + }), + ), + css({ minify: production }), + ], + }, { input: './lana/src/Main.web.ts', output: { @@ -77,6 +120,7 @@ export default [ commonjs(), json(), nodePolyfills(), + text({ sources: webExtensionAssets }), swc( defineRollupSwcOption({ include: /\.[mc]?[jt]sx?$/, diff --git a/scripts/rollup-plugin-text.mjs b/scripts/rollup-plugin-text.mjs new file mode 100644 index 000000000..69d96e2e2 --- /dev/null +++ b/scripts/rollup-plugin-text.mjs @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { readFile } from 'node:fs/promises'; + +/** + * @param {{ sources: Record }} options + * @returns {import('rollup').Plugin} + */ +export default function text({ sources }) { + const prefix = '\0lana-text:'; + return { + name: 'lana-text', + + resolveId(id) { + return Object.hasOwn(sources, id) ? `${prefix}${id}` : null; + }, + + async load(id) { + if (!id.startsWith(prefix)) { + return null; + } + + const source = sources[id.slice(prefix.length)]; + if (!source) { + return null; + } + + this.addWatchFile(source.path); + const content = await readFile(source.path); + const value = content.toString(source.encoding ?? 'utf8'); + return { + code: `export default ${JSON.stringify(value)};`, + moduleSideEffects: false, + }; + }, + }; +} From 3c163fe1a6f23ec643041021c0c4c74e436e4df6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:51:35 +0100 Subject: [PATCH 52/61] fix(log-viewer): bring the frame the inspector picks into the timeline's view (#1031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ๐Ÿ“ PR Overview Zoomed into the timeline, picking a row in the inspector marked the frame but left the view where it was. A single-frame row panned only when the frame overlapped the view nowhere, so one showing a pixel at the screen edge stayed there; an aggregated or bottom-up row moved nothing at all, and with every occurrence off screen the chart dimmed with no lit frame anywhere. A pick now brings a frame into view: the one it names, or for a merged row the occurrence nearest the middle of the view. Nothing moves when what you need is already on screen, a frame spanning the view edge to edge stays put, and a merged pick still selects none of its occurrences โ€” the mark on all of them is what locates them. Zoom is never touched. ## ๐Ÿ› ๏ธ Changes made - `revealTarget` โ€” one policy for both cases: which frame to bring in, and which axes to centre it on, or nothing when the view already shows one. A single frame is a one-element list. - `TimelineViewport.centerOffsetFor(โ€ฆ, axes)` holds the centring maths. `calculateCenterOffset`, `centerOnEvent` and `focusOnEvent` all delegate to it, so the midpoint-and-clamp maths and the off-screen test each exist once instead of three times โ€” search navigation included. - `FlameChart.panToFrame` pans without a selection, which is what lets a merged pick move without naming one occurrence as the pick. - `revealMerged?` replaces `movesToMergedPick?: boolean` in the inspector wiring: the view chooses which occurrence, because only it knows what nearest means in its own layout. The tables keep first-occurrence behaviour through the shared `revealFirstOf`; the Database grids still only mark. ## ๐Ÿงฉ Type of change (check all applicable) - [x] ๐Ÿ› Bug fix - something not working as expected - [ ] โœจ New feature โ€“ adds new functionality - [x] โ™ป๏ธ Refactor - internal changes with no user impact - [ ] โšก Performance Improvement - [ ] ๐Ÿ“ Documentation - README or documentation site changes - [ ] ๐Ÿ”ง Chore - dev tooling, CI, config - [ ] ๐Ÿ’ฅ Breaking change ## ๐Ÿ“ท Screenshots / gifs / video [optional] N/A โ€” the change is a viewport movement, not a new surface. ## ๐Ÿ”— Related Issues related #373 ## โœ… Tests added? - [x] ๐Ÿ‘ yes - [ ] ๐Ÿ™… no, not needed - [ ] ๐Ÿ™‹ no, I need help `pnpm lint && pnpm test`. New cases cover the policy (`detail-selection-sync.test.ts`), the geometry and its clamping (`viewport.test.ts`), and the wiring for both a single frame and a merged row (`inspector-reveal-pan.test.ts`). The new `centerOnEvent` cases pass against the pre-refactor implementation too, which is what pins that commit as behaviour-preserving. To check by hand: open a log, Timeline tab, zoom well in, then in the inspector pick a narrow Call Tree row off to one side (the view slides so the frame is centred), a Call Stack ancestor that spans the screen (nothing moves), a row at a depth off screen (the view scrolls to that depth), and an aggregated or bottom-up row whose calls are all off screen (the view moves to the nearest one, and no frame is selected). ## ๐Ÿ“š Docs updated? - [ ] ๐Ÿ”– README.md - [ ] ๐Ÿ”– CHANGELOG.md - [ ] ๐Ÿ“– help site - [ ] ๐Ÿงช Marked any pre-release-only features (README `๐Ÿงช` badge โ€” see [RELEASING.md](../RELEASING.md#-marking-pre-release-only-features)) - [x] ๐Ÿ™… not needed --- .../components/__tests__/inspectorTab.test.ts | 22 +-- log-viewer/src/components/inspectorTab.ts | 36 +++-- .../analysis/components/AnalysisView.ts | 7 +- .../call-tree/components/CalltreeView.ts | 8 +- .../__tests__/detail-selection-sync.test.ts | 69 ++++++++-- .../__tests__/inspector-reveal-pan.test.ts | 124 +++++++++++++++++ .../timeline/__tests__/viewport.test.ts | 76 +++++++++++ .../timeline/optimised/ApexLogTimeline.ts | 77 +++++++---- .../features/timeline/optimised/FlameChart.ts | 47 +++++-- .../timeline/optimised/TimelineViewport.ts | 127 ++++++------------ .../timeline/types/flamechart.types.ts | 12 ++ .../timeline/utils/detail-selection-sync.ts | 67 ++++++++- 12 files changed, 513 insertions(+), 159 deletions(-) create mode 100644 log-viewer/src/features/timeline/__tests__/inspector-reveal-pan.test.ts diff --git a/log-viewer/src/components/__tests__/inspectorTab.test.ts b/log-viewer/src/components/__tests__/inspectorTab.test.ts index ab711369f..fbc108043 100644 --- a/log-viewer/src/components/__tests__/inspectorTab.test.ts +++ b/log-viewer/src/components/__tests__/inspectorTab.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from '@jest/globals'; import { eventBus } from '../../core/events/EventBus.js'; import { InspectorEmphasis } from '../inspectorEmphasis.js'; -import { wireInspectorTab } from '../inspectorTab.js'; +import { revealFirstOf, wireInspectorTab } from '../inspectorTab.js'; describe('wireInspectorTab', () => { let off: (() => void) | null = null; @@ -27,23 +27,25 @@ describe('wireInspectorTab', () => { /** Marks and moves in the order they arrived, which the two lists cannot show. */ const order: string[] = []; let clears = 0; + const moveTo = (eventIndex: number, signal: AbortSignal): void | Promise => { + order.push('move'); + signals.push(signal); + if (reveal) { + return reveal(eventIndex, signal); + } + revealed.push(eventIndex); + }; off = wireInspectorTab('calltree', new InspectorEmphasis(), { mark: (eventIndexes) => { marks.push(eventIndexes); order.push('mark'); }, - reveal: (eventIndex, signal) => { - order.push('move'); - signals.push(signal); - if (reveal) { - return reveal(eventIndex, signal); - } - revealed.push(eventIndex); - }, + reveal: moveTo, clear: () => { clears++; }, - movesToMergedPick, + // As the tables do it, through the helper they ship with. + revealMerged: movesToMergedPick ? revealFirstOf(moveTo) : undefined, }); return { marks, revealed, order, signals, clears: () => clears }; } diff --git a/log-viewer/src/components/inspectorTab.ts b/log-viewer/src/components/inspectorTab.ts index 5cde7bf58..a469ba0b2 100644 --- a/log-viewer/src/components/inspectorTab.ts +++ b/log-viewer/src/components/inspectorTab.ts @@ -22,11 +22,28 @@ export interface InspectorTabSync { clear: () => void; /** - * True where a picked row that merges occurrences also moves, to the first of - * them. Omitted where choosing one of several would be arbitrary, which is why - * the Database grids and the flame chart only mark. + * Move for a picked row that merges occurrences. The view chooses which of them + * it moves to, because only it knows what "nearest" means in its own layout: a + * table takes the first, the flame chart the one nearest the view. Omitted where + * no move helps, which is why the Database grids only mark. + * + * @param signal - as {@link reveal}. */ - movesToMergedPick?: boolean; + revealMerged?: (eventIndexes: readonly number[], signal: AbortSignal) => void | Promise; +} + +/** + * The merged-pick move for a view whose rows are ordered rather than placed: it + * moves to the first occurrence, as a pick of one frame does. The flame chart + * supplies its own, because on a time axis "first" is not what the reader wants. + */ +export function revealFirstOf( + reveal: InspectorTabSync['reveal'], +): NonNullable { + return (eventIndexes, signal) => { + const first = eventIndexes[0]; + return first === undefined ? undefined : reveal(first, signal); + }; } /** @@ -44,25 +61,26 @@ export function wireInspectorTab( sync: InspectorTabSync, ): () => void { let moving: AbortController | null = null; - const move = (eventIndex: number): void => { + const move = (run: (signal: AbortSignal) => void | Promise): void => { // A move waits on a render, and a pointer crossing rows asks for several, so // the last one asked for is the one that scrolls. Only the move is abandoned: // the mark of the move that is dropped went on before it. moving?.abort(); moving = new AbortController(); // The view reports its own failure; the mark stands either way. - void Promise.resolve(sync.reveal(eventIndex, moving.signal)).catch(() => {}); + void Promise.resolve(run(moving.signal)).catch(() => {}); }; const offs = [ eventBus.onSource('inspector:reveal', source, (detail) => { - move(detail.eventIndex); + move((signal) => sync.reveal(detail.eventIndex, signal)); }), eventBus.onSource('inspector:locate', source, (detail) => { sync.mark(emphasis.report(detail.eventIndexes, detail.sticky)); - if (sync.movesToMergedPick && detail.sticky && detail.eventIndexes.length) { - move(detail.eventIndexes[0]!); + const revealMerged = sync.revealMerged; + if (revealMerged && detail.sticky && detail.eventIndexes.length) { + move((signal) => revealMerged(detail.eventIndexes, signal)); } }), diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 58d7b72af..b49086ad2 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -21,7 +21,7 @@ import { rowFrames, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { wireInspectorTab } from '../../../components/inspectorTab.js'; +import { revealFirstOf, wireInspectorTab } from '../../../components/inspectorTab.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; @@ -174,7 +174,10 @@ export class AnalysisView extends LitElement { // The table reports the clear itself, which is what reaches the inspector. this.analysisTable?.deselectRow(); }, - movesToMergedPick: true, + // A row buckets calls, so a merged pick moves to the first of them. + revealMerged: revealFirstOf((eventIndex, signal) => + this._revealEventIndex(eventIndex, signal), + ), }); document.addEventListener('lv-find', this._findEvt); document.addEventListener('lv-find-match', this._findEvt); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 53e6b1700..67df933c0 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -73,7 +73,7 @@ import { rowFrames, } from '../../../components/locatedRow.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { wireInspectorTab } from '../../../components/inspectorTab.js'; +import { revealFirstOf, wireInspectorTab } from '../../../components/inspectorTab.js'; import { createTimeOrderTable } from './TimeOrderTable.js'; /** Time Order keys its rows by event index; the grouped views key theirs by the @@ -188,8 +188,10 @@ export class CalltreeView extends LitElement { } }, // A picked row merges calls, so the mark shows all of them while the view - // moves to the first, as a pick of one frame does. - movesToMergedPick: true, + // moves to the first of them. + revealMerged: revealFirstOf((eventIndex, signal) => + this._revealEventIndex(eventIndex, signal), + ), }); document.addEventListener(CALLTREE_GO_TO_ROW, this._goToRowEvt); document.addEventListener('lv-find', this._findEvt); diff --git a/log-viewer/src/features/timeline/__tests__/detail-selection-sync.test.ts b/log-viewer/src/features/timeline/__tests__/detail-selection-sync.test.ts index 3286da97f..8473499ce 100644 --- a/log-viewer/src/features/timeline/__tests__/detail-selection-sync.test.ts +++ b/log-viewer/src/features/timeline/__tests__/detail-selection-sync.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from '@jest/globals'; import { TimelineViewport } from '../optimised/TimelineViewport.js'; -import { isFrameOffscreen, toDetailSelection } from '../utils/detail-selection-sync.js'; +import { revealTarget, toDetailSelection } from '../utils/detail-selection-sync.js'; describe('toDetailSelection', () => { it('builds an event selection from an eventIndex', () => { @@ -20,23 +20,72 @@ describe('toDetailSelection', () => { }); }); -describe('isFrameOffscreen', () => { +describe('revealTarget', () => { + // 1000px over a 1,000,000ns log, zoomed to 100,000ns starting at 200,000. const viewport = new TimelineViewport(1000, 600, 1_000_000, 10); + viewport.setZoom(0.01); + viewport.setPan(2000, 0); const bounds = viewport.getBounds(); - it('reports a frame inside both ranges as on screen', () => { - expect(isFrameOffscreen(bounds, bounds.timeStart, 1_000, bounds.depthStart)).toBe(false); + const frame = (timestamp: number, duration = 1_000, depth = bounds.depthStart) => ({ + timestamp, + duration, + depth, }); - it('reports a frame after the visible time range as off screen', () => { - expect(isFrameOffscreen(bounds, bounds.timeEnd + 1_000, 1_000, bounds.depthStart)).toBe(true); + const axesFor = (...frames: ReturnType[]) => revealTarget(bounds, frames)?.axes; + + it('leaves a frame that is wholly in view where it is', () => { + expect(revealTarget(bounds, [frame(250_000)])).toBeNull(); + }); + + it('centres a frame clipped by the edge of the view', () => { + expect(axesFor(frame(299_000, 5_000))).toEqual({ time: true, depth: false }); + }); + + it('centres a frame that is off screen', () => { + expect(axesFor(frame(400_000))).toEqual({ time: true, depth: false }); }); - it('reports a frame before the visible time range as off screen', () => { - expect(isFrameOffscreen(bounds, bounds.timeStart - 5_000, 1_000, bounds.depthStart)).toBe(true); + // It fills the screen either way, so a move would only lose the reader's bearings. + it('leaves a frame spanning the view from edge to edge where it is', () => { + expect(revealTarget(bounds, [frame(100_000, 500_000)])).toBeNull(); + }); + + // Wider than the view, but all of it bar a sliver is off to the left. + it('centres a wide frame showing only a sliver at the edge', () => { + expect(axesFor(frame(100_000, 100_001))).toEqual({ time: true, depth: false }); + }); + + it('centres a frame wider than the view that it does not reach', () => { + expect(axesFor(frame(400_000, 500_000))).toEqual({ time: true, depth: false }); + }); + + it('centres the depth on its own when only the depth is off screen', () => { + expect(axesFor(frame(250_000, 1_000, bounds.depthEnd + 1))).toEqual({ + time: false, + depth: true, + }); + }); + + it('leaves the view alone when one of several occurrences is in it', () => { + expect(revealTarget(bounds, [frame(10_000), frame(260_000), frame(900_000)])).toBeNull(); + }); + + it('brings in the occurrence nearest the middle of the view', () => { + const target = revealTarget(bounds, [frame(900_000), frame(310_000), frame(10_000)]); + + expect(target?.frame.timestamp).toBe(310_000); + }); + + it('measures nearest from the middle of the frame, not its start', () => { + const target = revealTarget(bounds, [frame(150_000, 20_000), frame(340_000)]); + + // Its middle lands at 160,000, which is nearer 250,000 than 340,500 is. + expect(target?.frame.timestamp).toBe(150_000); }); - it('reports a frame below the visible depth range as off screen', () => { - expect(isFrameOffscreen(bounds, bounds.timeStart, 1_000, bounds.depthEnd + 1)).toBe(true); + it('has nothing to bring in for a row that merges no frames', () => { + expect(revealTarget(bounds, [])).toBeNull(); }); }); diff --git a/log-viewer/src/features/timeline/__tests__/inspector-reveal-pan.test.ts b/log-viewer/src/features/timeline/__tests__/inspector-reveal-pan.test.ts new file mode 100644 index 000000000..944a6b89f --- /dev/null +++ b/log-viewer/src/features/timeline/__tests__/inspector-reveal-pan.test.ts @@ -0,0 +1,124 @@ +/** + * @jest-environment jsdom + */ + +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * What a reveal from the inspector does to the view. `revealTarget` holds the + * policy and is tested with it; this covers the wiring - the frame and axes it + * works out reach the chart, and a frame needing no move asks for none. + */ + +import { describe, expect, it, jest } from '@jest/globals'; +import { ApexLogTimeline } from '../optimised/ApexLogTimeline.js'; +import type { ViewportBounds, ViewportPanAxes } from '../types/flamechart.types.js'; + +/** 100,000ns of a log on screen, from 200,000, over six depths. */ +const BOUNDS: ViewportBounds = { + timeStart: 200_000, + timeEnd: 300_000, + depthStart: 0, + depthEnd: 5, +}; + +type Frame = { eventIndex: number; timestamp: number; total: number }; + +/** A frame the chart was panned to, with every argument it was given. */ +type Panned = { timestamp: number; duration: number; depth: number; axes: ViewportPanAxes }; + +function timelineWith(...events: Frame[]): { + revealFromInspector: (eventIndex: number) => void; + pickMergedRow: (eventIndexes: number[]) => void; + panned: () => Panned[]; + selected: () => number; +} { + const timeline = new ApexLogTimeline(); + const internals = timeline as unknown as Record; + const panned: Panned[] = []; + let selected = 0; + + internals['flamechart'] = { + locateByEventNodes: jest.fn(), + selectByEventNode: () => { + selected++; + return true; + }, + getViewportManager: () => ({ getBounds: () => BOUNDS }), + panToFrame: (timestamp: number, duration: number, depth: number, axes: ViewportPanAxes) => { + panned.push({ timestamp, duration, depth, axes }); + }, + }; + internals['apexLog'] = { + eventsById: Object.fromEntries( + events.map((event) => [ + event.eventIndex, + { + eventIndex: event.eventIndex, + timestamp: event.timestamp, + duration: { total: event.total }, + parent: null, + }, + ]), + ), + }; + + const reveal = internals['selectFrameByEventIndex'] as (eventIndex: number) => void; + const pan = internals['panToNearestFrame'] as (eventIndexes: readonly number[]) => void; + + return { + revealFromInspector: (eventIndex) => reveal.call(timeline, eventIndex), + pickMergedRow: (eventIndexes) => pan.call(timeline, eventIndexes), + panned: () => panned, + selected: () => selected, + }; +} + +describe('revealing one frame from the inspector', () => { + it('centres the view on a frame the edge of the view clips', () => { + const timeline = timelineWith({ eventIndex: 4, timestamp: 299_000, total: 5_000 }); + + timeline.revealFromInspector(4); + + expect(timeline.panned()).toEqual([ + { timestamp: 299_000, duration: 5_000, depth: 0, axes: { time: true, depth: false } }, + ]); + }); + + it('leaves the view alone for a frame already on screen', () => { + const timeline = timelineWith({ eventIndex: 4, timestamp: 250_000, total: 1_000 }); + + timeline.revealFromInspector(4); + + expect(timeline.panned()).toEqual([]); + }); +}); + +describe('picking a merged row in the inspector', () => { + it('pans to the occurrence nearest the view, selecting none of them', () => { + const timeline = timelineWith( + { eventIndex: 4, timestamp: 10_000, total: 1_000 }, + { eventIndex: 5, timestamp: 310_000, total: 1_000 }, + ); + + timeline.pickMergedRow([4, 5]); + + expect(timeline.panned()).toEqual([ + { timestamp: 310_000, duration: 1_000, depth: 0, axes: { time: true, depth: false } }, + ]); + expect(timeline.selected()).toBe(0); + }); + + it('leaves the view alone when one occurrence is already on screen', () => { + const timeline = timelineWith( + { eventIndex: 4, timestamp: 10_000, total: 1_000 }, + { eventIndex: 5, timestamp: 260_000, total: 1_000 }, + ); + + timeline.pickMergedRow([4, 5]); + + expect(timeline.panned()).toEqual([]); + }); +}); diff --git a/log-viewer/src/features/timeline/__tests__/viewport.test.ts b/log-viewer/src/features/timeline/__tests__/viewport.test.ts index 72e598387..2c87ebe97 100644 --- a/log-viewer/src/features/timeline/__tests__/viewport.test.ts +++ b/log-viewer/src/features/timeline/__tests__/viewport.test.ts @@ -477,4 +477,80 @@ describe('TimelineViewport', () => { expect(state.zoom).toBeCloseTo(minZoom, 5); }); }); + + describe('centerOnEvent', () => { + beforeEach(() => { + viewport.setZoom(0.01); + viewport.setPan(2000, 0); + }); + + it('should center an event that is off screen', () => { + viewport.centerOnEvent(400_000, 1_000, 0); + + const state = viewport.getState(); + const eventMidpointX = (400_000 + 1_000 / 2) * state.zoom; + expect(state.offsetX + DISPLAY_WIDTH / 2).toBeCloseTo(eventMidpointX, 5); + }); + + it('should hold an event that is on screen', () => { + viewport.centerOnEvent(250_000, 1_000, 0); + + expect(viewport.getState().offsetX).toBe(2000); + }); + }); + + describe('centerOffsetFor', () => { + beforeEach(() => { + viewport.setZoom(0.01); + viewport.setPan(2000, -100); + }); + + it('should center the time axis on the event midpoint', () => { + const state = viewport.getState(); + const target = viewport.centerOffsetFor(250_000, 10_000, 3, { time: true, depth: false }); + + const eventMidpointX = (250_000 + 10_000 / 2) * state.zoom; + expect(target.x + DISPLAY_WIDTH / 2).toBeCloseTo(eventMidpointX, 5); + }); + + it('should hold an axis it was not asked to center', () => { + const state = viewport.getState(); + const target = viewport.centerOffsetFor(250_000, 10_000, 3, { time: true, depth: false }); + + expect(target.y).toBe(state.offsetY); + }); + + it('should center the depth axis on the event depth', () => { + // Deep enough that the depth axis can scroll at all. + const deep = new TimelineViewport(DISPLAY_WIDTH, DISPLAY_HEIGHT, TOTAL_DURATION, 200); + const target = deep.centerOffsetFor(250_000, 10_000, 60, { time: false, depth: true }); + + const eventY = 60 * TIMELINE_CONSTANTS.EVENT_HEIGHT; + expect(-target.y + DISPLAY_HEIGHT / 2).toBeCloseTo(eventY, 5); + }); + + it('should center on an event on screen, unlike calculateCenterOffset', () => { + const onScreen = viewport.centerOffsetFor(250_000, 1_000, 3, { time: true, depth: false }); + const gated = viewport.calculateCenterOffset(250_000, 1_000, 3); + + expect(onScreen.x).not.toBe(gated.x); + expect(gated.x).toBe(viewport.getState().offsetX); + }); + + it('should clamp a target at the start of the log', () => { + const target = viewport.centerOffsetFor(0, 1_000, 3, { time: true, depth: false }); + + expect(target.x).toBe(0); + }); + + it('should clamp a target at the end of the log', () => { + const state = viewport.getState(); + const target = viewport.centerOffsetFor(TOTAL_DURATION, 1_000, 3, { + time: true, + depth: false, + }); + + expect(target.x).toBeCloseTo(state.zoom * TOTAL_DURATION - DISPLAY_WIDTH, 5); + }); + }); }); diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index 2545dbe59..2e2c2e769 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -24,7 +24,11 @@ import { eventBus, type TimelineNavigateMode } from '../../../core/events/EventB import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { copyToClipboard } from '../../../core/utility/Clipboard.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; -import { findEventByEventIndex, findEventByTimestamp } from '../../../core/utility/EventSearch.js'; +import { + findEventByEventIndex, + findEventByTimestamp, + type EventSearchResult, +} from '../../../core/utility/EventSearch.js'; import { goToRow } from '../../call-tree/navigation.js'; import { formatCallStack, formatEventDetails } from '../../call-tree/utils/eventText.js'; import { getTheme } from '../themes/ThemeSelector.js'; @@ -42,7 +46,11 @@ import { import type { SearchCursor } from '../types/search.types.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; import { wireInspectorTab } from '../../../components/inspectorTab.js'; -import { isFrameOffscreen, toDetailSelection } from '../utils/detail-selection-sync.js'; +import { + revealTarget, + toDetailSelection, + type FramePlacement, +} from '../utils/detail-selection-sync.js'; import { extractExceptionMarkers, extractMarkers, noDataSpans } from '../utils/marker-utils.js'; import { seekWindow } from '../utils/navigate-window.js'; import { logEventToTreeAndRects } from '../utils/tree-converter.js'; @@ -58,6 +66,15 @@ interface ApexTimelineOptions extends TimelineOptions { * a selection with where its time went. */ const TIMELINE_VIEW = 'callees' as const; +/** Where a found event sits, as the reveal policy reads it. */ +function placementOf(result: EventSearchResult): FramePlacement { + return { + timestamp: result.event.timestamp, + duration: result.event.duration.total, + depth: result.depth, + }; +} + export class ApexLogTimeline { private flamechart: FlameChart; private tooltipRenderer: FrameTooltipRenderer | null = null; @@ -221,6 +238,7 @@ export class ApexLogTimeline { this.inspectorUnsubscribe = wireInspectorTab('timeline', this.emphasis, { mark: (eventIndexes) => this.applyEmphasis(eventIndexes), reveal: (eventIndex) => this.selectFrameByEventIndex(eventIndex), + revealMerged: (eventIndexes) => this.panToNearestFrame(eventIndexes), clear: () => { // The chart reports the clear itself. Its own Escape, with the container // focused, consumes the key before this. @@ -230,8 +248,8 @@ export class ApexLogTimeline { } /** - * Select the frame for `eventIndex` and pan to it when it is off-screen. - * Passive sync, so it never zooms - a full focus would be too disruptive. + * Select the frame for `eventIndex` and centre the view on it. Passive sync, + * so it never zooms - a full focus would be too disruptive. */ private selectFrameByEventIndex(eventIndex: number): void { if (!this.apexLog) { @@ -254,35 +272,48 @@ export class ApexLogTimeline { // run: the select inside it clears the mark, as any chart select does. this.pickEmphasis(eventIndex); + this.bringIntoView([placementOf(result)]); + } + + /** + * Bring one of a merged row's frames into view, the one nearest what is on + * screen. Selects nothing: the row names no single frame, so its mark on all + * of them is what locates them. + */ + private panToNearestFrame(eventIndexes: readonly number[]): void { + this.bringIntoView(this.resolveEvents(eventIndexes).map(placementOf)); + } + + /** + * Pan to whichever of `frames` the view should show, and only when it shows + * none of them already. Never zooms - a full focus would be too disruptive. + */ + private bringIntoView(frames: readonly FramePlacement[]): void { const bounds = this.flamechart.getViewportManager()?.getBounds(); - if ( - bounds && - isFrameOffscreen(bounds, result.event.timestamp, result.event.duration.total, result.depth) - ) { - this.flamechart.centerOnSelectedFrame(); + const target = bounds ? revealTarget(bounds, frames) : null; + if (target) { + const { timestamp, duration, depth } = target.frame; + this.flamechart.panToFrame(timestamp, duration, depth, target.axes); } } + /** The events `eventIndexes` name, with their depths, skipping any the log lost. */ + private resolveEvents(eventIndexes: readonly number[]): EventSearchResult[] { + const apexLog = this.apexLog; + return apexLog + ? eventIndexes.flatMap((eventIndex) => findEventByEventIndex(apexLog, eventIndex) ?? []) + : []; + } + /** * Keep `eventIndexes` in colour and dim the rest of the chart, or drop the * emphasis when there are none. A grouped inspector row names every occurrence * it merges, so all of them light at once. Never selects, never pans. */ private applyEmphasis(eventIndexes: readonly number[]): void { - const apexLog = this.apexLog; - if (!eventIndexes.length || !apexLog) { - this.flamechart.locateByEventNodes([]); - return; - } - - const nodes: EventNode[] = []; - for (const eventIndex of eventIndexes) { - const result = findEventByEventIndex(apexLog, eventIndex); - if (result) { - nodes.push(this.toEventNode(result)); - } - } - this.flamechart.locateByEventNodes(nodes); + this.flamechart.locateByEventNodes( + this.resolveEvents(eventIndexes).map((result) => this.toEventNode(result)), + ); } /** Rest the emphasis on one frame, until something else picks or clears it. */ diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index 819504dfc..1709a4ac3 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -25,6 +25,7 @@ import type { TimelineOptions, TimelineState, TreeNode, + ViewportPanAxes, ViewportState, } from '../types/flamechart.types.js'; import { TIMELINE_CONSTANTS, TimelineError, TimelineErrorCode } from '../types/flamechart.types.js'; @@ -80,6 +81,9 @@ import { waitForNextFrame } from '../../../core/utility/FrameBudget.js'; */ const SIZE_WAIT_FRAMES = 60; +/** How long a pan to a frame takes, matching the moves the selection sync animates. */ +const PAN_ANIMATION_MS = 300; + export interface FlameChartCallbacks { onMouseMove?: ( screenX: number, @@ -1645,12 +1649,7 @@ export class FlameChart { this.callbacks.onMarkerNavigate?.(marker, screenX, screenY); }, onAnimateToPosition: (targetX: number, targetY: number, durationMs: number) => { - if (!this.viewport || !this.viewportAnimator) { - return; - } - this.viewportAnimator.animate(this.viewport, targetX, targetY, durationMs, () => - this.notifyViewportChange(), - ); + this.animateViewportTo(targetX, targetY, durationMs); }, requestRender: () => { // Selection change only needs highlights + overlays (Phase 3 optimization) @@ -2165,12 +2164,38 @@ export class FlameChart { } /** - * Pan (without changing zoom) so the currently selected frame is visible. - * Animated, and a no-op if the frame is already in view - use this for the - * passive selection sync, where a full zoom-to-fit would be too disruptive. + * Pan (without changing zoom) to put a frame in the middle of the view, on the + * axes asked for. Selects nothing, so a caller standing for several frames can + * bring one into view without naming it as the selection. + * + * @param timestamp - Frame start time in nanoseconds + * @param duration - Frame duration in nanoseconds + * @param depth - Frame depth in the call tree + * @param axes - Axes to centre on */ - public centerOnSelectedFrame(): void { - this.selectionOrchestrator?.centerOnSelectedFrame(); + public panToFrame( + timestamp: number, + duration: number, + depth: number, + axes: ViewportPanAxes, + ): void { + if (!this.viewport) { + return; + } + + const target = this.viewport.centerOffsetFor(timestamp, duration, depth, axes); + this.animateViewportTo(target.x, target.y, PAN_ANIMATION_MS); + } + + /** Animate the view to an offset, abandoning whatever move was in flight. */ + private animateViewportTo(targetX: number, targetY: number, durationMs: number): void { + if (!this.viewport || !this.viewportAnimator) { + return; + } + + this.viewportAnimator.animate(this.viewport, targetX, targetY, durationMs, () => + this.notifyViewportChange(), + ); } /** diff --git a/log-viewer/src/features/timeline/optimised/TimelineViewport.ts b/log-viewer/src/features/timeline/optimised/TimelineViewport.ts index 795579c3f..753189b53 100644 --- a/log-viewer/src/features/timeline/optimised/TimelineViewport.ts +++ b/log-viewer/src/features/timeline/optimised/TimelineViewport.ts @@ -9,7 +9,7 @@ * Handles coordinate transformations and boundary constraints. */ -import type { ViewportBounds, ViewportState } from '../types/flamechart.types.js'; +import type { ViewportBounds, ViewportPanAxes, ViewportState } from '../types/flamechart.types.js'; import { TIMELINE_CONSTANTS } from '../types/flamechart.types.js'; export class TimelineViewport { @@ -250,79 +250,27 @@ export class TimelineViewport { // Clamp to valid zoom range const clampedZoom = Math.max(this.getMinZoom(), Math.min(this.getMaxZoom(), newZoom)); - // Apply new zoom + // Apply new zoom, which the centring below reads this.state.zoom = clampedZoom; - // Calculate target offsets to center the event - const eventX = eventTimestamp * this.state.zoom; - const eventWidth = eventDuration * this.state.zoom; - const eventMidpoint = eventX + eventWidth / 2; - - // Center event midpoint at screen center - const newOffsetX = eventMidpoint - this.state.displayWidth / 2; - this.state.offsetX = this.clampOffsetX(newOffsetX); - - // Center vertically on the event depth - const eventY = eventDepth * TIMELINE_CONSTANTS.EVENT_HEIGHT; - const newWorldYBottom = eventY - this.state.displayHeight / 2; - this.state.offsetY = this.clampOffsetY(-newWorldYBottom); + const { x, y } = this.centerOffsetFor(eventTimestamp, eventDuration, eventDepth, { + time: true, + depth: true, + }); + this.setOffset(x, y); } /** - * Center viewport on a specific event. - * Scrolls horizontally and vertically to center the event in the viewport. - * Only scrolls if event is off-screen or not fully visible. + * Center viewport on a specific event, on whichever axis the event is off + * screen on. The zoom is left alone. * * @param eventTimestamp - Event start time in nanoseconds * @param eventDuration - Event duration in nanoseconds * @param eventDepth - Event depth in call tree (0-indexed) - * - * Algorithm (from legacy Timeline.ts lines 1023-1041): - * - Calculate event midpoint in pixels - * - Check if event is off-screen - * - If off-screen: center event midpoint at screen center - * - Apply boundary constraints - * - Trigger viewport change notification */ public centerOnEvent(eventTimestamp: number, eventDuration: number, eventDepth: number): void { - // ========== Horizontal Centering ========== - - const eventX = eventTimestamp * this.state.zoom; - const eventWidth = eventDuration * this.state.zoom; - const eventMidpoint = eventX + eventWidth / 2; - - // Check if off-screen (left or right) - const screenX = eventX - this.state.offsetX; - const isOffScreenHorizontal = screenX > this.state.displayWidth || screenX + eventWidth < 0; - - if (isOffScreenHorizontal) { - // Center event midpoint at screen center - const newOffsetX = eventMidpoint - this.state.displayWidth / 2; - - // Apply boundary constraints - this.state.offsetX = this.clampOffsetX(newOffsetX); - } - - // ========== Vertical Centering ========== - - const eventY = eventDepth * TIMELINE_CONSTANTS.EVENT_HEIGHT; - - // Calculate screen Y position of event - const worldYBottom = -this.state.offsetY; - const screenY = this.state.displayHeight - (eventY - worldYBottom); - - // Check if off-screen (top or bottom) - const isOffScreenVertical = screenY < 0 || screenY > this.state.displayHeight; - - if (isOffScreenVertical) { - // Center event at vertical center - const targetWorldY = eventY; // World Y of event center - const newWorldYBottom = targetWorldY - this.state.displayHeight / 2; - const newOffsetY = -newWorldYBottom; - - // Apply boundary constraints - this.state.offsetY = this.clampOffsetY(newOffsetY); - } + const { x, y } = this.calculateCenterOffset(eventTimestamp, eventDuration, eventDepth); + this.setOffset(x, y); } /** @@ -351,37 +299,48 @@ export class TimelineViewport { eventDuration: number, eventDepth: number, ): { x: number; y: number } { - // ========== Horizontal Centering ========== const eventX = eventTimestamp * this.state.zoom; const eventWidth = eventDuration * this.state.zoom; - const eventMidpoint = eventX + eventWidth / 2; - - // Check if off-screen (left or right) const screenX = eventX - this.state.offsetX; - const isOffScreenHorizontal = screenX > this.state.displayWidth || screenX + eventWidth < 0; - - let targetOffsetX = this.state.offsetX; - if (isOffScreenHorizontal) { - // Center event midpoint at screen center - targetOffsetX = this.clampOffsetX(eventMidpoint - this.state.displayWidth / 2); - } - // ========== Vertical Centering ========== const eventY = eventDepth * TIMELINE_CONSTANTS.EVENT_HEIGHT; - - // Calculate screen Y position of event const worldYBottom = -this.state.offsetY; const screenY = this.state.displayHeight - (eventY - worldYBottom); - // Check if off-screen (top or bottom) - const isOffScreenVertical = screenY < 0 || screenY > this.state.displayHeight; + return this.centerOffsetFor(eventTimestamp, eventDuration, eventDepth, { + time: screenX > this.state.displayWidth || screenX + eventWidth < 0, + depth: screenY < 0 || screenY > this.state.displayHeight, + }); + } + + /** + * Target offsets that put an event in the middle of the view, on the axes + * asked for; an axis left out keeps its current offset. Unlike + * {@link calculateCenterOffset} it asks nothing about whether the event is on + * screen - that policy belongs to the caller. + * + * @param eventTimestamp - Event start time in nanoseconds + * @param eventDuration - Event duration in nanoseconds + * @param eventDepth - Event depth in call tree (0-indexed) + * @param axes - Axes to center on + * @returns Target offsets (clamped to valid range) + */ + public centerOffsetFor( + eventTimestamp: number, + eventDuration: number, + eventDepth: number, + axes: ViewportPanAxes, + ): { x: number; y: number } { + let targetOffsetX = this.state.offsetX; + if (axes.time) { + const eventMidpoint = (eventTimestamp + eventDuration / 2) * this.state.zoom; + targetOffsetX = this.clampOffsetX(eventMidpoint - this.state.displayWidth / 2); + } let targetOffsetY = this.state.offsetY; - if (isOffScreenVertical) { - // Center event at vertical center - const targetWorldY = eventY; - const newWorldYBottom = targetWorldY - this.state.displayHeight / 2; - targetOffsetY = this.clampOffsetY(-newWorldYBottom); + if (axes.depth) { + const eventY = eventDepth * TIMELINE_CONSTANTS.EVENT_HEIGHT; + targetOffsetY = this.clampOffsetY(-(eventY - this.state.displayHeight / 2)); } return { x: targetOffsetX, y: targetOffsetY }; diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index 02af38040..98fffffe4 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -67,6 +67,18 @@ export interface ViewportBounds { depthEnd: number; } +/** + * Which axes a viewport move should centre. An axis left out keeps its current + * offset, so a caller can pan in time without disturbing the depth on screen. + */ +export interface ViewportPanAxes { + /** Centre horizontally, on the frame's midpoint. */ + time: boolean; + + /** Centre vertically, on the frame's depth. */ + depth: boolean; +} + /** * Modifier keys state from mouse/keyboard events. * Used for Cmd/Ctrl+Click navigation. diff --git a/log-viewer/src/features/timeline/utils/detail-selection-sync.ts b/log-viewer/src/features/timeline/utils/detail-selection-sync.ts index a2785a593..3bdb11e7b 100644 --- a/log-viewer/src/features/timeline/utils/detail-selection-sync.ts +++ b/log-viewer/src/features/timeline/utils/detail-selection-sync.ts @@ -8,22 +8,75 @@ * flame chart instance. */ import type { DetailSelection } from '../../../core/events/EventBus.js'; -import type { ViewportBounds } from '../types/flamechart.types.js'; +import type { ViewportBounds, ViewportPanAxes } from '../types/flamechart.types.js'; /** The `detail:select` payload for a frame, or null when it carries no eventIndex. */ export function toDetailSelection(eventIndex: number | undefined): DetailSelection | null { return eventIndex === undefined ? null : { kind: 'event', eventIndex }; } -/** True when the frame falls outside the viewport in time or in depth. */ -export function isFrameOffscreen( +/** + * Which axes a reveal should centre the frame on, given what the view already + * shows. A frame spanning the view from edge to edge keeps its place: it fills + * the screen either way, so centring its midpoint would only lose the reader's + * bearings. One merely wider than the view still moves, or a frame showing a + * sliver at the edge would never be brought in. + */ +function revealPanAxes( bounds: ViewportBounds, timestamp: number, duration: number, depth: number, -): boolean { +): ViewportPanAxes { const frameEnd = timestamp + duration; - const inTimeRange = frameEnd >= bounds.timeStart && timestamp <= bounds.timeEnd; - const inDepthRange = depth >= bounds.depthStart && depth <= bounds.depthEnd; - return !(inTimeRange && inDepthRange); + const fullyVisible = timestamp >= bounds.timeStart && frameEnd <= bounds.timeEnd; + const fillsTheView = timestamp <= bounds.timeStart && frameEnd >= bounds.timeEnd; + + return { + time: !fullyVisible && !fillsTheView, + depth: depth < bounds.depthStart || depth > bounds.depthEnd, + }; +} + +/** Where a frame sits, as the reveal policy reads it. */ +export interface FramePlacement { + timestamp: number; + duration: number; + depth: number; +} + +/** The frame to bring into view, and the axes to centre it on. */ +export interface RevealTarget { + frame: FramePlacement; + axes: ViewportPanAxes; +} + +/** + * Which frame a reveal should bring into view - of several, the one nearest the + * middle of what is on screen - or null when one of them is already there. A row + * that merges occurrences names no single frame, so the view moves without + * selecting: the mark on every occurrence is what says where they all are. + */ +export function revealTarget( + bounds: ViewportBounds, + frames: readonly FramePlacement[], +): RevealTarget | null { + const middle = (bounds.timeStart + bounds.timeEnd) / 2; + let nearest: RevealTarget | null = null; + let shortest = Infinity; + + for (const frame of frames) { + const axes = revealPanAxes(bounds, frame.timestamp, frame.duration, frame.depth); + if (!axes.time && !axes.depth) { + return null; + } + + const distance = Math.abs(frame.timestamp + frame.duration / 2 - middle); + if (distance < shortest) { + shortest = distance; + nearest = { frame, axes }; + } + } + + return nearest; } From a32e09850adc2187ddf2693bea1f9d0a4d0d1f79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:27:54 +0100 Subject: [PATCH 53/61] build(deps): bump pnpm/action-setup from 6.0.10 to 6.1.0 in the github-actions group (#1024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup). Updates `pnpm/action-setup` from 6.0.10 to 6.1.0
    Release notes

    Sourced from pnpm/action-setup's releases.

    v6.1.0

    What's Changed

    Full Changelog: https://github.com/pnpm/action-setup/compare/v6.0.10...v6.1.0

    Commits

    [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pnpm/action-setup&package-manager=github_actions&previous-version=6.0.10&new-version=6.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
    Dependabot commands and options
    You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
    Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/cd-prerelease.yml | 2 +- .github/workflows/ci.yml | 8 ++++---- .github/workflows/publish-gh-pages.yml | 2 +- .github/workflows/publish.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/cd-prerelease.yml b/.github/workflows/cd-prerelease.yml index 7efca6262..a408cc46f 100644 --- a/.github/workflows/cd-prerelease.yml +++ b/.github/workflows/cd-prerelease.yml @@ -63,7 +63,7 @@ jobs: - name: Checkout code uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e4667d85..3004b501a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.10 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -44,7 +44,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.10 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -64,7 +64,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.10 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node @@ -102,7 +102,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.10 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node diff --git a/.github/workflows/publish-gh-pages.yml b/.github/workflows/publish-gh-pages.yml index cad03f61c..b36369bcb 100644 --- a/.github/workflows/publish-gh-pages.yml +++ b/.github/workflows/publish-gh-pages.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false # ๐Ÿ‘‡ Build steps - name: pnpm setup - uses: pnpm/action-setup@v6.0.10 + uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c9ae2ff40..64550c8c8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: pnpm/action-setup@v6.0.10 + - uses: pnpm/action-setup@v6.1.0 with: version: 10 - name: Set up Node From 7e38acc70248dde01c4fad497ff096a519dbdb91 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:39:05 +0100 Subject: [PATCH 54/61] refactor(log-viewer): name the find bus events in one place Five views each declared an identical find event type, and a sixth copy sat in the flame chart's types. One module now names them, keyed by event name so a listener's payload follows the name it subscribed to. Also drops a SearchOptions that collided by name with a different SearchOptions in the same feature folder. --- log-viewer/src/features/find/findEvents.ts | 49 +++++++++++++++++++ .../timeline/optimised/ApexLogTimeline.ts | 3 +- .../timeline/types/flamechart.types.ts | 37 -------------- 3 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 log-viewer/src/features/find/findEvents.ts diff --git a/log-viewer/src/features/find/findEvents.ts b/log-viewer/src/features/find/findEvents.ts new file mode 100644 index 000000000..7adfd7443 --- /dev/null +++ b/log-viewer/src/features/find/findEvents.ts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { StatementType } from '../../core/metrics/eventMetrics.js'; + +/** + * The find bus: the `document` CustomEvents that connect {@link FindWidget} to + * every searchable view. + * + * The widget produces `lv-find`, `lv-find-match` and `lv-find-close`; each view + * answers with `lv-find-results`. The three database grids report their own + * totals to `DatabaseView` through `db-find-results`, which rolls them up into + * one `lv-find-results`. + */ + +/** Payload for `lv-find`, `lv-find-match` and `lv-find-close`. */ +export interface FindEventDetail { + /** Search query text. */ + text: string; + + /** + * Match index for navigation (1-based). + * - `lv-find`: always 1 (start at the first match) + * - `lv-find-match`: the current match number (1 to totalMatches) + * - `lv-find-close`: always 0 (no active match) + */ + count: number; + + options: { matchCase: boolean }; +} + +/** Payload for `lv-find-results`, a view's answer to a search. */ +export interface FindResultsEventDetail { + totalMatches: number; +} + +/** Payload for `db-find-results`, one grid's count on the way to `DatabaseView`. */ +export interface DbFindResultsEventDetail extends FindResultsEventDetail { + type: StatementType; +} + +/** Every find event, so a listener's payload follows the name it subscribed to. */ +export interface FindEventMap { + 'lv-find': CustomEvent; + 'lv-find-match': CustomEvent; + 'lv-find-close': CustomEvent; + 'lv-find-results': CustomEvent; + 'db-find-results': CustomEvent; +} diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index 2e2c2e769..de7870dbb 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -29,6 +29,7 @@ import { findEventByTimestamp, type EventSearchResult, } from '../../../core/utility/EventSearch.js'; +import type { FindEventDetail, FindResultsEventDetail } from '../../find/findEvents.js'; import { goToRow } from '../../call-tree/navigation.js'; import { formatCallStack, formatEventDetails } from '../../call-tree/utils/eventText.js'; import { getTheme } from '../themes/ThemeSelector.js'; @@ -36,8 +37,6 @@ import { BUCKET_CONSTANTS, type EditorColors, type EventNode, - type FindEventDetail, - type FindResultsEventDetail, type ModifierKeys, type TimelineMarker, type TimelineOptions, diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index 98fffffe4..6ddc8ec36 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -796,43 +796,6 @@ export interface SearchMatch { matchType: 'type' | 'text'; } -/** - * Search behavior options. - */ -export interface SearchOptions { - /** Case-sensitive matching. */ - matchCase: boolean; -} - -/** - * Payload for find/search CustomEvents (lv-find, lv-find-match, lv-find-close). - * Standardized communication between FindWidget and Timeline components. - */ -export interface FindEventDetail { - /** Search query text. */ - text: string; - - /** - * Match index for navigation (1-based). - * - For lv-find: Always 1 (start at first match) - * - For lv-find-match: Current match number (1 to totalMatches) - * - For lv-find-close: Always 0 (no active match) - */ - count: number; - - /** Search options. */ - options: SearchOptions; -} - -/** - * Payload for find results CustomEvent (lv-find-results). - * Timeline dispatches this after search completes. - */ -export interface FindResultsEventDetail { - /** Total number of matches found. */ - totalMatches: number; -} - // ============================================================================ // HEAT STRIP VISUALIZATION TYPES // ============================================================================ From 9711c39fe55e29bc68a7a0a1cd6c0f4289ba8fa3 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:39:07 +0100 Subject: [PATCH 55/61] refactor(log-viewer): add a controller for listeners on document and window Subscribes in hostConnected and unsubscribes in hostDisconnected, so a listener on a global target lives exactly as long as its host is connected. Handlers are passed as inline arrows: this package compiles with useDefineForClassFields:false, so a field initialiser cannot reference a field declared below it, and addEventListener with an undefined handler is a silent no-op. The constructor throws instead. --- .../src/core/events/DomListenerController.ts | 56 ++++++++ .../__tests__/DomListenerController.test.ts | 131 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 log-viewer/src/core/events/DomListenerController.ts create mode 100644 log-viewer/src/core/events/__tests__/DomListenerController.test.ts diff --git a/log-viewer/src/core/events/DomListenerController.ts b/log-viewer/src/core/events/DomListenerController.ts new file mode 100644 index 000000000..3e1b45d87 --- /dev/null +++ b/log-viewer/src/core/events/DomListenerController.ts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +/** A handler map: the event name to call for, and what to call. */ +export type DomListeners = { + [K in keyof M]?: (event: M[K]) => void; +}; + +/** + * Listens on a target outside the host's own tree โ€” `document` or `window` โ€” + * for exactly as long as the host is connected. + * + * Wiring a global listener in a constructor and removing it in + * `disconnectedCallback` leaves the host deaf after a re-attach, because the + * constructor never runs again. The host lifecycle owns both ends here, so a + * detach and re-attach re-subscribes. + * + * Pass each handler as an inline arrow, never a reference to a class field: + * this package compiles with `useDefineForClassFields: false`, so field + * initialisers run in declaration order and a field declared below this one is + * still `undefined` when the controller is built. `addEventListener(name, + * undefined)` then fails silently. + */ +export class DomListenerController< + M extends { [K in keyof M]: Event }, +> implements ReactiveController { + private readonly _target: EventTarget; + private readonly _entries: [string, EventListener][]; + + constructor(host: ReactiveControllerHost, target: EventTarget, listeners: DomListeners) { + this._target = target; + this._entries = Object.entries(listeners) as [string, EventListener][]; + for (const [name, handler] of this._entries) { + if (typeof handler !== 'function') { + // addEventListener(name, undefined) is a legal no-op, so without this the + // host just goes quiet. See the field-order note above. + throw new TypeError(`DomListenerController: the handler for "${name}" is not a function.`); + } + } + host.addController(this); + } + + hostConnected(): void { + for (const [name, handler] of this._entries) { + this._target.addEventListener(name, handler); + } + } + + hostDisconnected(): void { + for (const [name, handler] of this._entries) { + this._target.removeEventListener(name, handler); + } + } +} diff --git a/log-viewer/src/core/events/__tests__/DomListenerController.test.ts b/log-viewer/src/core/events/__tests__/DomListenerController.test.ts new file mode 100644 index 000000000..e8f685fb8 --- /dev/null +++ b/log-viewer/src/core/events/__tests__/DomListenerController.test.ts @@ -0,0 +1,131 @@ +/** + * @jest-environment jsdom + */ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import { LitElement, html, type ReactiveController } from 'lit'; + +import { DomListenerController, type DomListeners } from '../DomListenerController.js'; +import type { FindEventDetail, FindEventMap } from '../../../features/find/findEvents.js'; + +/** A host that drives the controller hooks without needing a real element. */ +function fakeHost() { + const controllers = new Set(); + return { + addController: (c: ReactiveController) => void controllers.add(c), + removeController: (c: ReactiveController) => void controllers.delete(c), + requestUpdate: () => {}, + updateComplete: Promise.resolve(true), + connect: () => controllers.forEach((c) => c.hostConnected?.()), + disconnect: () => controllers.forEach((c) => c.hostDisconnected?.()), + }; +} + +const DETAIL: FindEventDetail = { text: 'abc', count: 2, options: { matchCase: true } }; + +const EVERY_NAME: (keyof FindEventMap)[] = [ + 'lv-find', + 'lv-find-match', + 'lv-find-close', + 'lv-find-results', + 'db-find-results', +]; + +function find(name: keyof FindEventMap, detail: unknown = DETAIL): void { + document.dispatchEvent(new CustomEvent(name, { detail })); +} + +/** Records every event the controller hands back, for one set of names. */ +function listen(names: readonly (keyof FindEventMap)[]) { + const host = fakeHost(); + const seen: CustomEvent[] = []; + const listeners: DomListeners = {}; + for (const name of names) { + listeners[name] = (e) => void seen.push(e); + } + new DomListenerController(host, document, listeners); + return { host, seen }; +} + +/** A real Lit host, so the re-attach case goes through Lit's own lifecycle. */ +const litCalls: CustomEvent[] = []; + +class FindBusTestHost extends LitElement { + readonly bus = new DomListenerController(this, document, { + 'lv-find': (e) => void litCalls.push(e), + }); + + override render() { + return html``; + } +} +customElements.define('find-bus-test-host', FindBusTestHost); + +describe('DomListenerController', () => { + beforeEach(() => { + litCalls.length = 0; + }); + + it('stays deaf until the host connects', () => { + const { seen } = listen(['lv-find']); + + find('lv-find'); + + expect(seen).toEqual([]); + }); + + it('routes each name to its own handler, and ignores the rest', () => { + const host = fakeHost(); + const finds: CustomEvent[] = []; + const results: CustomEvent[] = []; + new DomListenerController(host, document, { + 'lv-find': (e) => void finds.push(e), + 'db-find-results': (e) => void results.push(e), + }); + host.connect(); + + for (const name of EVERY_NAME) { + find(name, name === 'db-find-results' ? { totalMatches: 3, type: 'soql' } : DETAIL); + } + + expect(finds.map((e) => e.type)).toEqual(['lv-find']); + expect(results.map((e) => e.detail)).toEqual([{ totalMatches: 3, type: 'soql' }]); + }); + + it('hands the event through untouched', () => { + const { host, seen } = listen(['lv-find-close']); + host.connect(); + + find('lv-find-close'); + + // The views branch on `type`, so it has to survive alongside the payload. + expect(seen[0]?.type).toBe('lv-find-close'); + expect(seen[0]?.detail).toEqual(DETAIL); + }); + + it('stops listening when the host disconnects', () => { + const { host, seen } = listen(['lv-find']); + host.connect(); + host.disconnect(); + + find('lv-find'); + + expect(seen).toEqual([]); + }); + + it('hears again after the element is detached and re-attached', async () => { + const el = new FindBusTestHost(); + document.body.append(el); + await el.updateComplete; + el.remove(); + document.body.append(el); + await el.updateComplete; + + find('lv-find'); + + expect(litCalls).toHaveLength(1); + el.remove(); + }); +}); From b0e30db9cd34b3ddc1685e6ab93de0971d0b8eae Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:40:03 +0100 Subject: [PATCH 56/61] refactor(log-viewer): tie the find bus to each view's connected life Seven views registered a global listener in the constructor and removed it in disconnectedCallback, so a re-attached view heard nothing. FindWidget never removed either of its two. Each view's event subset is unchanged. The adapter fields and the casts they needed are gone, and FindWidget now reads the shared results type rather than its own copy carrying a count no view sends. --- .../analysis/components/AnalysisView.ts | 22 +++++----- .../call-tree/components/CalltreeView.ts | 33 ++++++--------- .../src/features/call-tree/navigation.ts | 5 +++ .../features/database/components/DMLView.ts | 26 ++++-------- .../database/components/DatabaseView.ts | 32 ++++++--------- .../features/database/components/SOQLView.ts | 26 ++++-------- .../features/database/components/SOSLView.ts | 26 ++++-------- .../components/__tests__/DatabaseView.test.ts | 40 +++++++++++++++++++ .../features/find/components/FindWidget.ts | 29 ++++++-------- 9 files changed, 114 insertions(+), 125 deletions(-) diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index b49086ad2..abc1a2fb4 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -13,7 +13,9 @@ import type { RowComponent, Tabulator } from 'tabulator-tables'; import type { ApexLog } from 'apex-log-parser'; import '../../../components/ContextMenu.js'; import type { ContextMenu } from '../../../components/ContextMenu.js'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; import { eventBus } from '../../../core/events/EventBus.js'; +import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { LocatedRowIds, LocatedRowMarker, @@ -160,6 +162,12 @@ export class AnalysisView extends LitElement { private _locateIds = new LocatedRowIds(); private _emphasis = new InspectorEmphasis(); + private readonly _findBus = new DomListenerController(this, document, { + 'lv-find': (e) => void this._find(e), + 'lv-find-match': (e) => void this._find(e), + 'lv-find-close': (e) => void this._find(e), + }); + constructor() { super(); @@ -179,9 +187,6 @@ export class AnalysisView extends LitElement { this._revealEventIndex(eventIndex, signal), ), }); - document.addEventListener('lv-find', this._findEvt); - document.addEventListener('lv-find-match', this._findEvt); - document.addEventListener('lv-find-close', this._findEvt); } override connectedCallback(): void { @@ -193,9 +198,6 @@ export class AnalysisView extends LitElement { super.disconnectedCallback(); this._categoryColoringOff?.(); this._categoryColoringOff = null; - document.removeEventListener('lv-find', this._findEvt); - document.removeEventListener('lv-find-match', this._findEvt); - document.removeEventListener('lv-find-close', this._findEvt); this._inspectorUnsubscribe?.(); this._inspectorUnsubscribe = null; this._locatedRow.clear(); @@ -499,10 +501,6 @@ export class AnalysisView extends LitElement { return (this.tableContainer ??= this.renderRoot?.querySelector('#analysis-table')); } - _findEvt = ((event: FindEvt) => { - this._find(event); - }) as EventListener; - _groupBy(event: Event) { const target = event.target as HTMLInputElement; // Grouping renumbers the matches both ways round, and `dataGrouped` reports @@ -567,7 +565,7 @@ export class AnalysisView extends LitElement { }); } - async _find(e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>) { + async _find(e: CustomEvent) { const isTableVisible = !!this.analysisTable?.element?.clientHeight; if (!isTableVisible && !this.totalMatches) { return; @@ -687,5 +685,3 @@ export class AnalysisView extends LitElement { this.totalMatches = 0; } } - -type FindEvt = CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>; diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 67df933c0..59955b22f 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -11,13 +11,15 @@ import { repeat } from 'lit/directives/repeat.js'; import type { RowComponent, Tabulator } from 'tabulator-tables'; import type { ApexLog, LogEvent } from 'apex-log-parser'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; import { eventBus, type DetailSource } from '../../../core/events/EventBus.js'; +import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; import { getSettings, updateSetting } from '../../settings/Settings.js'; -import { CALLTREE_GO_TO_ROW } from '../navigation.js'; +import { CALLTREE_GO_TO_ROW, type CalltreeNavigationEventMap } from '../navigation.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; import { findBucketRow } from '../utils/bucketRows.js'; import { @@ -163,10 +165,6 @@ export class CalltreeView extends LitElement { return (this.tableContainer = this.renderRoot?.querySelector('#call-tree-table') ?? null); } - private _goToRowEvt = ((e: CustomEvent<{ eventIndex: number }>) => { - this._goToRow(e.detail.eventIndex); - }) as EventListener; - /** Guards the programmatic select made on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); private _inspectorUnsubscribe: (() => void) | null = null; @@ -175,6 +173,15 @@ export class CalltreeView extends LitElement { /** Which of the inspector's reports the mark follows. */ private _emphasis = new InspectorEmphasis(); + private readonly _documentBus = new DomListenerController< + FindEventMap & CalltreeNavigationEventMap + >(this, document, { + [CALLTREE_GO_TO_ROW]: (e) => void this._goToRow(e.detail.eventIndex), + 'lv-find': (e) => void this._find(e), + 'lv-find-match': (e) => void this._find(e), + 'lv-find-close': (e) => void this._find(e), + }); + constructor() { super(); @@ -193,10 +200,6 @@ export class CalltreeView extends LitElement { this._revealEventIndex(eventIndex, signal), ), }); - document.addEventListener(CALLTREE_GO_TO_ROW, this._goToRowEvt); - document.addEventListener('lv-find', this._findEvt); - document.addEventListener('lv-find-match', this._findEvt); - document.addEventListener('lv-find-close', this._findEvt); } override connectedCallback(): void { @@ -208,10 +211,6 @@ export class CalltreeView extends LitElement { super.disconnectedCallback(); this._categoryColoringOff?.(); this._categoryColoringOff = null; - document.removeEventListener(CALLTREE_GO_TO_ROW, this._goToRowEvt); - document.removeEventListener('lv-find', this._findEvt); - document.removeEventListener('lv-find-match', this._findEvt); - document.removeEventListener('lv-find-close', this._findEvt); this._inspectorUnsubscribe?.(); this._inspectorUnsubscribe = null; this._destroyCurrentTable(); @@ -478,10 +477,6 @@ export class CalltreeView extends LitElement { `; } - _findEvt = ((event: FindEvt) => { - this._find(event); - }) as EventListener; - _getAllTypes(data: LogEvent[]): string[] { const flattened = this._flatten(data); const types = new Set(); @@ -937,7 +932,7 @@ export class CalltreeView extends LitElement { ); } - async _find(e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>) { + async _find(e: CustomEvent) { const activeTable = this._getActiveTable(); const isTableVisible = !!activeTable?.element?.clientHeight; if (!isTableVisible && !this.totalMatches) { @@ -1392,5 +1387,3 @@ export class CalltreeView extends LitElement { return indexByEventIndex; } } - -type FindEvt = CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>; diff --git a/log-viewer/src/features/call-tree/navigation.ts b/log-viewer/src/features/call-tree/navigation.ts index eb3ecca0f..7ff85d89f 100644 --- a/log-viewer/src/features/call-tree/navigation.ts +++ b/log-viewer/src/features/call-tree/navigation.ts @@ -7,6 +7,11 @@ import type { IssueAction } from '../notifications/types.js'; /** Document event asking the Call Tree tab to reveal a log event. */ export const CALLTREE_GO_TO_ROW = 'calltree-go-to-row'; +/** {@link CALLTREE_GO_TO_ROW} keyed by name, for a listener that wants its payload. */ +export interface CalltreeNavigationEventMap { + [CALLTREE_GO_TO_ROW]: CustomEvent<{ eventIndex: number }>; +} + /** * Reveal a log event in the main Call Tree tab: switches to the tab, forces * time-order and scrolls/focuses the row. Lives apart from `CalltreeView` so diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index ad22fb2d7..4bd3c9d69 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -11,6 +11,8 @@ import { Tabulator, type GroupComponent, type RowComponent } from 'tabulator-tab import type { ApexLog, DMLBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; +import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { goToRow } from '../../call-tree/navigation.js'; import { isVisible } from '../../../core/utility/Util.js'; import { getSettings, updateSetting } from '../../settings/Settings.js'; @@ -121,18 +123,10 @@ export class DMLView extends LitElement { private rowCountRange: FilterRange = { start: null, end: null }; private timeTakenRange: FilterRange = { start: null, end: null }; - constructor() { - super(); - - document.addEventListener('lv-find', this._findEvt); - document.addEventListener('lv-find-close', this._findEvt); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - document.removeEventListener('lv-find', this._findEvt); - document.removeEventListener('lv-find-close', this._findEvt); - } + private readonly _findBus = new DomListenerController(this, document, { + 'lv-find': (e) => void this._find(e), + 'lv-find-close': (e) => void this._find(e), + }); firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); @@ -461,10 +455,6 @@ export class DMLView extends LitElement { this.dmlTable?.download('csv', 'dml.csv', { bom: true, delimiter: ',' }); } - _findEvt = ((event: FindEvt) => { - this._find(event); - }) as EventListener; - _dmlGroupBy(event: Event) { if (!this.dmlTable) { return; @@ -519,7 +509,7 @@ export class DMLView extends LitElement { this.oldIndex = highlightIndex; } - async _find(e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>) { + async _find(e: CustomEvent) { const isTableVisible = !!this.dmlTable?.element?.clientHeight; if (!isTableVisible && !this.totalMatches) { return; @@ -836,5 +826,3 @@ interface DMLRow { timeTaken?: number; eventIndex?: number; } - -type FindEvt = CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>; diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index a2c3e4d44..fe66a7a96 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -14,7 +14,9 @@ import type { } from 'apex-log-parser'; import { limitTotals } from '../../../components/logOverviewMetrics.js'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; import { eventBus, type StatementType } from '../../../core/events/EventBus.js'; +import type { DbFindResultsEventDetail, FindEventMap } from '../../find/findEvents.js'; import { apexLimitTimeSeries } from '../../timeline/optimised/apex-limit-series.js'; import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; import { wireInspectorTab } from '../../../components/inspectorTab.js'; @@ -92,13 +94,15 @@ export class DatabaseView extends LitElement { /** Which of the inspector's reports the grids' mark follows. */ private _emphasis = new InspectorEmphasis(); + private readonly _findBus = new DomListenerController(this, document, { + 'lv-find': (e) => this._find(e.detail.count), + 'lv-find-match': (e) => this._find(e.detail.count), + 'db-find-results': (e) => this._findResults(e), + }); + constructor() { super(); - document.addEventListener('db-find-results', this._findResults as EventListener); - document.addEventListener('lv-find-match', this._findHandler as EventListener); - document.addEventListener('lv-find', this._findHandler as EventListener); - this._offInspector = wireInspectorTab('database', this._emphasis, { mark: (eventIndexes) => this._markLocated(eventIndexes), // The eventIndex belongs to exactly one grid, so each is offered it in turn @@ -158,9 +162,6 @@ export class DatabaseView extends LitElement { disconnectedCallback(): void { super.disconnectedCallback(); - document.removeEventListener('db-find-results', this._findResults as EventListener); - document.removeEventListener('lv-find-match', this._findHandler as EventListener); - document.removeEventListener('lv-find', this._findHandler as EventListener); this._offInspector?.(); this._offInspector = null; } @@ -450,14 +451,7 @@ export class DatabaseView extends LitElement { return gauges; } - _findHandler = ( - e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>, - ) => { - this._find(e.detail); - }; - - _find = (arg: { count: number }) => { - const matchIndex = arg.count; + _find(matchIndex: number) { if (matchIndex <= this.dmlMatches) { this.dmlHighlightIndex = matchIndex; this.soqlHighlightIndex = 0; @@ -471,9 +465,9 @@ export class DatabaseView extends LitElement { this.dmlHighlightIndex = 0; this.soqlHighlightIndex = 0; } - }; + } - _findResults = (e: CustomEvent<{ totalMatches: number; type: SectionKind }>) => { + _findResults(e: CustomEvent) { if (e.detail.type === 'dml') { this.dmlMatches = e.detail.totalMatches; } else if (e.detail.type === 'soql') { @@ -482,14 +476,14 @@ export class DatabaseView extends LitElement { this.soslMatches = e.detail.totalMatches; } - this._find({ count: 1 }); + this._find(1); document.dispatchEvent( new CustomEvent('lv-find-results', { detail: { totalMatches: this.dmlMatches + this.soqlMatches + this.soslMatches }, }), ); - }; + } } interface SectionSpec { diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index a111d623f..ba86ee6db 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -17,6 +17,8 @@ import type { ApexLog, SOQLExecuteBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { isVisible } from '../../../core/utility/Util.js'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; +import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { goToRow } from '../../call-tree/navigation.js'; import { deriveSoqlObject } from '../services/sobjectClassification.js'; import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; @@ -141,18 +143,10 @@ export class SOQLView extends LitElement { return this.renderRoot?.querySelector('#db-soql-table'); } - constructor() { - super(); - - document.addEventListener('lv-find', this._findEvt); - document.addEventListener('lv-find-close', this._findEvt); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - document.removeEventListener('lv-find', this._findEvt); - document.removeEventListener('lv-find-close', this._findEvt); - } + private readonly _findBus = new DomListenerController(this, document, { + 'lv-find': (e) => void this._find(e), + 'lv-find-close': (e) => void this._find(e), + }); firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); @@ -479,10 +473,6 @@ export class SOQLView extends LitElement { this.soqlTable?.download('csv', 'soql.csv', { bom: true, delimiter: ',' }); } - _findEvt = ((event: FindEvt) => { - this._find(event); - }) as EventListener; - _soqlGroupBy(event: Event) { if (!this.soqlTable) { return; @@ -533,7 +523,7 @@ export class SOQLView extends LitElement { this.oldIndex = highlightIndex; } - async _find(e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>) { + async _find(e: CustomEvent) { const isTableVisible = !!this.soqlTable?.element?.clientHeight; if (!isTableVisible && !this.totalMatches) { return; @@ -995,5 +985,3 @@ interface GridSOQLData { fields?: string | null; eventIndex?: number; } - -type FindEvt = CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>; diff --git a/log-viewer/src/features/database/components/SOSLView.ts b/log-viewer/src/features/database/components/SOSLView.ts index 2c41443a7..31e71872d 100644 --- a/log-viewer/src/features/database/components/SOSLView.ts +++ b/log-viewer/src/features/database/components/SOSLView.ts @@ -11,6 +11,8 @@ import { Tabulator, type GroupComponent, type RowComponent } from 'tabulator-tab import type { ApexLog, SOSLExecuteBeginLine } from 'apex-log-parser'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { getCallerNamespace } from '../../../core/utility/CallerNamespace.js'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; +import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { goToRow } from '../../call-tree/navigation.js'; import { isVisible } from '../../../core/utility/Util.js'; import { getSettings, updateSetting } from '../../settings/Settings.js'; @@ -119,18 +121,10 @@ export class SOSLView extends LitElement { private rowCountRange: FilterRange = { start: null, end: null }; private timeTakenRange: FilterRange = { start: null, end: null }; - constructor() { - super(); - - document.addEventListener('lv-find', this._findEvt); - document.addEventListener('lv-find-close', this._findEvt); - } - - disconnectedCallback(): void { - super.disconnectedCallback(); - document.removeEventListener('lv-find', this._findEvt); - document.removeEventListener('lv-find-close', this._findEvt); - } + private readonly _findBus = new DomListenerController(this, document, { + 'lv-find': (e) => void this._find(e), + 'lv-find-close': (e) => void this._find(e), + }); firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); @@ -425,10 +419,6 @@ export class SOSLView extends LitElement { this.soslTable?.download('csv', 'sosl.csv', { bom: true, delimiter: ',' }); } - _findEvt = ((event: FindEvt) => { - this._find(event); - }) as EventListener; - _soslGroupBy(event: Event) { if (!this.soslTable) { return; @@ -482,7 +472,7 @@ export class SOSLView extends LitElement { this.oldIndex = highlightIndex; } - async _find(e: CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>) { + async _find(e: CustomEvent) { const isTableVisible = !!this.soslTable?.element?.clientHeight; if (!isTableVisible && !this.totalMatches) { return; @@ -802,5 +792,3 @@ interface SOSLRow { timeTaken?: number; eventIndex?: number; } - -type FindEvt = CustomEvent<{ text: string; count: number; options: { matchCase: boolean } }>; diff --git a/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts b/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts index c8dce66ba..f88c581d1 100644 --- a/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts +++ b/log-viewer/src/features/database/components/__tests__/DatabaseView.test.ts @@ -179,3 +179,43 @@ describe('database-view selection', () => { }); }); }); + +describe('database-view find bus', () => { + let view: HTMLElement & { updateComplete: Promise }; + + beforeEach(async () => { + document.body.replaceChildren(); + view = document.createElement('database-view') as typeof view; + document.body.append(view); + await view.updateComplete; + }); + + afterEach(() => { + document.body.replaceChildren(); + }); + + /** The roll-up DatabaseView sends back to the find widget. */ + function totalsAfter(section: string, totalMatches: number): number[] { + const seen: number[] = []; + const probe = (e: Event) => + void seen.push((e as CustomEvent<{ totalMatches: number }>).detail.totalMatches); + document.addEventListener('lv-find-results', probe); + document.dispatchEvent( + new CustomEvent('db-find-results', { detail: { totalMatches, type: section } }), + ); + document.removeEventListener('lv-find-results', probe); + return seen; + } + + it('rolls a grid count up to the find widget', () => { + expect(totalsAfter('soql', 2)).toEqual([2]); + }); + + it('keeps rolling up after a detach and re-attach', async () => { + view.remove(); + document.body.append(view); + await view.updateComplete; + + expect(totalsAfter('soql', 2)).toEqual([2]); + }); +}); diff --git a/log-viewer/src/features/find/components/FindWidget.ts b/log-viewer/src/features/find/components/FindWidget.ts index c45c979b9..d6b399192 100644 --- a/log-viewer/src/features/find/components/FindWidget.ts +++ b/log-viewer/src/features/find/components/FindWidget.ts @@ -1,11 +1,12 @@ -//totod: event types - import '#vscode-elements/vscode-textfield.js'; import '#vscode-elements/vscode-toolbar-button.js'; import type { VscodeTextfield } from '#vscode-elements/vscode-textfield.js'; import { LitElement, css, html } from 'lit'; import { customElement, state } from 'lit/decorators.js'; +import { DomListenerController } from '../../../core/events/DomListenerController.js'; +import type { FindEventMap, FindResultsEventDetail } from '../findEvents.js'; + // styles import { globalStyles } from '../../../styles/global.styles.js'; @@ -22,18 +23,13 @@ export class FindWidget extends LitElement { lastMatch: string | null = null; nextMatchDirection = true; // Remembers last direction: true=next, false=previous - constructor() { - super(); - window.addEventListener('keydown', (e: KeyboardEvent) => { - this._keyPress(e); - }); - - document.addEventListener('lv-find-results', (( - e: CustomEvent<{ totalMatches: number; count?: number }>, - ) => { - this._updateCounts(e); - }) as EventListener); - } + private readonly _findBus = new DomListenerController(this, document, { + 'lv-find-results': (e) => this._updateCounts(e), + }); + + private readonly _keyBus = new DomListenerController(this, window, { + keydown: (e) => this._keyPress(e), + }); static styles = [ globalStyles, @@ -241,9 +237,10 @@ export class FindWidget extends LitElement { return this.shadowRoot?.querySelector('.find-input-box'); } - _updateCounts(e: { detail: { totalMatches: number; count?: number } }) { + _updateCounts(e: CustomEvent) { this.totalMatches = e.detail.totalMatches; - this.currentMatch = e.detail.count ?? 1; + // A view reports totals, never a position, so a fresh count starts at the first match. + this.currentMatch = 1; } _resetCounts() { From 0e09cb4044e650883e9b42cdc1a30d26674855c7 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Mon, 14 Sep 2026 14:40:32 +0100 Subject: [PATCH 57/61] refactor(log-viewer): wire the inspector on the connected lifetime The three tab views wired the inspector in their constructor and released it in disconnectedCallback, so a re-attached view lost mark, reveal and clear. The call moves beside wireCategoryColoring, which already had this shape, and all three constructors are now empty and gone. AnalysisView's suite mounted a bare element and relied on the constructor doing the wiring; it now attaches to the document and awaits updateComplete. --- .../analysis/components/AnalysisView.ts | 11 ++----- .../components/__tests__/AnalysisView.test.ts | 33 +++++++++++++------ .../call-tree/components/CalltreeView.ts | 11 ++----- .../database/components/DatabaseView.ts | 5 ++- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index abc1a2fb4..998d80973 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -168,9 +168,9 @@ export class AnalysisView extends LitElement { 'lv-find-close': (e) => void this._find(e), }); - constructor() { - super(); - + override connectedCallback(): void { + super.connectedCallback(); + this._categoryColoringOff = wireCategoryColoring(this); this._inspectorUnsubscribe = wireInspectorTab('analysis', this._emphasis, { // A row is a method bucket rather than one event, so a frame is translated // into the paths of the rows it heads. @@ -189,11 +189,6 @@ export class AnalysisView extends LitElement { }); } - override connectedCallback(): void { - super.connectedCallback(); - this._categoryColoringOff = wireCategoryColoring(this); - } - disconnectedCallback(): void { super.disconnectedCallback(); this._categoryColoringOff?.(); diff --git a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts index 453398113..17cce3888 100644 --- a/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts +++ b/log-viewer/src/features/analysis/components/__tests__/AnalysisView.test.ts @@ -31,6 +31,16 @@ jest.mock('../../../call-tree/components/BottomUpTable.js', () => ({ })); // vscode-button needs ElementInternals.setFormValue (absent in jsdom). jest.mock('#vscode-elements/vscode-button.js', () => ({})); +// VsSelect extends vscode-single-select, whose setFormValue needs an +// ElementInternals jsdom lacks; the render would upgrade it. +jest.mock('../../../../components/VsSelect.js', () => ({})); +// Connecting the view reads settings twice: firstUpdated loads the column view, +// and category colouring subscribes. This suite has no extension host to answer. +jest.mock('../../../settings/Settings.js', () => ({ + ...jest.requireActual('../../../settings/Settings.js'), + getSettings: () => Promise.resolve({}), + subscribeSettings: () => () => {}, +})); jest.mock('#vscode-elements/vscode-option.js', () => ({})); jest.mock('#vscode-elements/vscode-toolbar-button.js', () => ({})); @@ -114,15 +124,18 @@ function findRow(rows: BottomUpRow[], text: string): BottomUpRow { * A view with its table rendered against `log`. * * The app hands the log down as a property, and a row's calls are read through - * the table that built it. The table mounts in a wrapper the view finds in its - * render root, which it has none of until it is updated, so one is stood in. + * the table that built it. */ -function mountView(log: ApexLog): AnalysisView { +async function mountView(log: ApexLog): Promise { handlers.clear(); stub = { rows: [], getRowsArgs: [], revealed: [] }; const view = new AnalysisView(); + document.body.append(view); + await view.updateComplete; + // `timelineRoot` only after the first update, and `_renderAnalysis` in the same + // tick: the `updated()` it triggers reaches `isVisible`, and jsdom has no + // IntersectionObserver. Assigning the table first makes that path return early. view.timelineRoot = log; - view.tableContainer = document.createElement('div'); void view._renderAnalysis(log); return view; } @@ -134,18 +147,18 @@ describe('analysis-view selection', () => { let seen: Array<{ source: DetailSource; selection: DetailSelection | null }>; let off: () => void; - beforeEach(() => { + beforeEach(async () => { byEventIndex = []; log = recursiveLog(); roots = toBottomUpTree(log.children, logStoreFor(log).keyPathIds()); - view = mountView(log); + view = await mountView(log); seen = []; off = eventBus.on('detail:select', (detail) => seen.push(detail)); }); afterEach(() => { off(); - view.disconnectedCallback(); + view.remove(); }); function select(row: RowComponent): void { @@ -250,9 +263,9 @@ describe('analysis-view selection', () => { describe('analysis-view search lifetime', () => { let view: AnalysisView; - beforeEach(() => { + beforeEach(async () => { byEventIndex = []; - view = mountView(recursiveLog()); + view = await mountView(recursiveLog()); // What a finished search leaves behind: matches, and nothing of its own in // flight to guard against. view.totalMatches = 3; @@ -260,7 +273,7 @@ describe('analysis-view search lifetime', () => { }); afterEach(() => { - view.disconnectedCallback(); + view.remove(); }); /** What Tabulator reports, which an expand repeats with the sort in force. */ diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 59955b22f..140234826 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -182,9 +182,9 @@ export class CalltreeView extends LitElement { 'lv-find-close': (e) => void this._find(e), }); - constructor() { - super(); - + override connectedCallback(): void { + super.connectedCallback(); + this._categoryColoringOff = wireCategoryColoring(this); this._inspectorUnsubscribe = wireInspectorTab('calltree', this._emphasis, { mark: (eventIndexes) => this._markLocated(eventIndexes), reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), @@ -202,11 +202,6 @@ export class CalltreeView extends LitElement { }); } - override connectedCallback(): void { - super.connectedCallback(); - this._categoryColoringOff = wireCategoryColoring(this); - } - disconnectedCallback(): void { super.disconnectedCallback(); this._categoryColoringOff?.(); diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index fe66a7a96..2961723b1 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -100,9 +100,8 @@ export class DatabaseView extends LitElement { 'db-find-results': (e) => this._findResults(e), }); - constructor() { - super(); - + override connectedCallback(): void { + super.connectedCallback(); this._offInspector = wireInspectorTab('database', this._emphasis, { mark: (eventIndexes) => this._markLocated(eventIndexes), // The eventIndex belongs to exactly one grid, so each is offered it in turn From cd936335e8cdf859bbeba357d70888d880af0e72 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:15:00 +0100 Subject: [PATCH 58/61] fix(log-viewer): rebuild the call tree after a re-attach Detaching the view destroys its three tables, but the only build path runs when the log first arrives, so the view came back with its subscriptions and no table. It now rebuilds on connect, for the view on show, under the filters and grouping it was left with. --- log-viewer/src/core/utility/Util.ts | 22 ++ .../src/core/utility/__tests__/Util.test.ts | 42 +++- .../call-tree/components/CalltreeView.ts | 106 ++++++--- .../components/__tests__/CalltreeView.test.ts | 222 ++++++++++++++++++ 4 files changed, 363 insertions(+), 29 deletions(-) create mode 100644 log-viewer/src/features/call-tree/components/__tests__/CalltreeView.test.ts diff --git a/log-viewer/src/core/utility/Util.ts b/log-viewer/src/core/utility/Util.ts index 2f5e145ea..8c7a6e882 100644 --- a/log-viewer/src/core/utility/Util.ts +++ b/log-viewer/src/core/utility/Util.ts @@ -173,11 +173,24 @@ export function debounce(callBack: (...args: T) => unknown) }; } +/** + * Resolve true once `element` is on screen. + * + * Without a `signal` the promise waits for as long as the element stays off + * screen, holding its observer. Pass one from a caller that can ask more than + * once: aborting releases the observer and resolves false. + */ export async function isVisible( element: HTMLElement, options?: IntersectionObserverInit, + signal?: AbortSignal, ): Promise { return new Promise((resolve) => { + if (signal?.aborted) { + resolve(false); + return; + } + const observer = new IntersectionObserver((entries, observerInstance) => { for (const entry of entries) { if (entry.isIntersecting) { @@ -188,6 +201,15 @@ export async function isVisible( } }, options); + signal?.addEventListener( + 'abort', + () => { + observer.disconnect(); + resolve(false); + }, + { once: true }, + ); + observer.observe(element); }); } diff --git a/log-viewer/src/core/utility/__tests__/Util.test.ts b/log-viewer/src/core/utility/__tests__/Util.test.ts index e9903a640..05f9f18f5 100644 --- a/log-viewer/src/core/utility/__tests__/Util.test.ts +++ b/log-viewer/src/core/utility/__tests__/Util.test.ts @@ -1,9 +1,9 @@ /* * Copyright (c) 2020 Certinia Inc. All rights reserved. */ -import { describe, expect, it } from '@jest/globals'; +import { beforeEach, describe, expect, it } from '@jest/globals'; -import { computeWallClockMs, formatByteSize, formatWallClockTime } from '../Util.js'; +import { computeWallClockMs, formatByteSize, formatWallClockTime, isVisible } from '../Util.js'; describe('formatWallClockTime', () => { it('should format midnight as 00:00:00.000', () => { @@ -80,3 +80,41 @@ describe('formatByteSize', () => { expect(formatByteSize(-1_500_000)).toBe('-1.5 MB'); }); }); + +describe('isVisible', () => { + /** Observers standing, so a release can be counted. */ + let observing = 0; + + /** Reports nothing, so only the abort can settle the wait. */ + class NeverIntersects { + observe(): void { + observing++; + } + disconnect(): void { + observing--; + } + } + + beforeEach(() => { + observing = 0; + globalThis.IntersectionObserver = NeverIntersects as unknown as typeof IntersectionObserver; + }); + + it('releases its observer where the wait is aborted', async () => { + const controller = new AbortController(); + const waiting = isVisible({} as HTMLElement, undefined, controller.signal); + + controller.abort(); + + await expect(waiting).resolves.toBe(false); + expect(observing).toBe(0); + }); + + it('observes nothing for a signal that aborted first', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(isVisible({} as HTMLElement, undefined, controller.signal)).resolves.toBe(false); + expect(observing).toBe(0); + }); +}); diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 140234826..4eaa1606f 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -90,6 +90,19 @@ const timeOrderRowFormatter = (row: RowComponent): void => { /** The Name column is always shown in the call-tree tables. */ const ALWAYS_VISIBLE = ['text']; +/** The row field a Bottom Up group-by picker value groups on; empty for None. */ +function groupByField(value: string): string { + const field = value === 'Caller Namespace' ? 'callerNamespace' : value.toLowerCase(); + return field === 'none' ? '' : field; +} + +/** Where each view builds its table. */ +const CONTAINER_IDS: Record = { + 'time-order': '#call-tree-table', + aggregated: '#aggregated-tree-table', + 'bottom-up': '#bottom-up-tree-table', +}; + const DEBUG_VALUE_TYPES: ReadonlySet = new Set([ 'USER_DEBUG', 'DATAWEAVE_USER_DEBUG', @@ -160,6 +173,8 @@ export class CalltreeView extends LitElement { private viewSwitchEpoch = 0; /** Releases the category-colouring settings subscription; set while connected. */ private _categoryColoringOff: (() => void) | null = null; + /** Drops a pending wait for the view to come on screen, once per attach. */ + private _visibilityWait: AbortController | null = null; get _callTreeTableWrapper(): HTMLDivElement | null { return (this.tableContainer = this.renderRoot?.querySelector('#call-tree-table') ?? null); @@ -200,10 +215,19 @@ export class CalltreeView extends LitElement { this._revealEventIndex(eventIndex, signal), ), }); + + // A detach destroyed the tables, and `updated` builds only for the log's + // arrival. With a log already in hand this is a re-attach, and the build's + // own guard decides whether there is anything to do. + if (this.rootMethod) { + this._appendTableWhenVisible(); + } } disconnectedCallback(): void { super.disconnectedCallback(); + this._visibilityWait?.abort(); + this._visibilityWait = null; this._categoryColoringOff?.(); this._categoryColoringOff = null; this._inspectorUnsubscribe?.(); @@ -536,25 +560,7 @@ export class CalltreeView extends LitElement { return; } - if (this.viewMode === 'time-order') { - const container = this.renderRoot?.querySelector('#call-tree-table'); - if (container) { - await this._renderCallTree(container, this.rootMethod); - this._updateFiltering(); - } - } else if (this.viewMode === 'aggregated') { - const container = this.renderRoot?.querySelector('#aggregated-tree-table'); - if (container) { - await this._renderAggregatedTree(container, this.rootMethod); - this._updateFiltering(); - } - } else if (this.viewMode === 'bottom-up') { - const container = this.renderRoot?.querySelector('#bottom-up-tree-table'); - if (container) { - await this._renderBottomUpTree(container, this.rootMethod); - this._updateFiltering(); - } - } + await this._renderActiveView(); if (switchEpoch !== this.viewSwitchEpoch) { return; @@ -565,6 +571,30 @@ export class CalltreeView extends LitElement { eventBus.emit('detail:view', { source: 'calltree', view: directionOf(this.viewMode) }); } + /** Build the table for the view on show, if it has none. */ + private async _renderActiveView(): Promise { + const rootMethod = this.rootMethod; + const container = this.renderRoot?.querySelector(CONTAINER_IDS[this.viewMode]); + if (!rootMethod || !container) { + return; + } + + switch (this.viewMode) { + case 'time-order': + await this._renderCallTree(container, rootMethod); + break; + case 'aggregated': + await this._renderAggregatedTree(container, rootMethod); + break; + case 'bottom-up': + await this._renderBottomUpTree(container, rootMethod); + break; + } + // A fresh table carries none of the filters on show, so every build applies + // them โ€” a re-attach rebuilds under the filters the user left in force. + this._updateFiltering(); + } + private _destroyCurrentTable(): void { // The marker holds row elements that go with the table. this._locatedRow.clear(); @@ -588,11 +618,9 @@ export class CalltreeView extends LitElement { // Grouping renumbers the matches both ways round, and `dataGrouped` reports // only the way that leaves the table grouped. this._dropSearch(); - const fieldName = - target.value === 'Caller Namespace' ? 'callerNamespace' : target.value.toLowerCase(); if (this.bottomUpTreeTable) { // @ts-expect-error setSortedGroupBy is added by the GroupSort custom module - this.bottomUpTreeTable.setSortedGroupBy(fieldName !== 'none' ? fieldName : ''); + this.bottomUpTreeTable.setSortedGroupBy(groupByField(target.value)); } } @@ -842,15 +870,19 @@ export class CalltreeView extends LitElement { } _appendTableWhenVisible() { - if (this.calltreeTable) { + if (this._getActiveTable()) { return; } this.rootMethod = this.timelineRoot; - isVisible(this).then((isVisible) => { - this.isVisible = isVisible; - if (this.rootMethod && this._callTreeTableWrapper) { - void this._renderCallTree(this._callTreeTableWrapper, this.rootMethod); + this._visibilityWait?.abort(); + this._visibilityWait = new AbortController(); + isVisible(this, undefined, this._visibilityWait.signal).then((visible) => { + this.isVisible = visible; + // An abort cannot catch a wait that has already resolved, so the build + // asks whether the view is still here. + if (visible && this.isConnected) { + void this._renderActiveView(); } }); } @@ -1049,6 +1081,11 @@ export class CalltreeView extends LitElement { this.calltreeTable = table; this._watchTable(table, true); await tableBuilt; + if (this.calltreeTable !== table) { + // A detach destroyed this build mid-flight, and a later one owns the + // container now. + return; + } this._initTableColumns(table); this._emitDetailSelection(table); this._emitDetailLocate(table); @@ -1070,6 +1107,11 @@ export class CalltreeView extends LitElement { this.aggregatedTreeTable = table; this._watchTable(table, true); await tableBuilt; + if (this.aggregatedTreeTable !== table) { + // A detach destroyed this build mid-flight, and a later one owns the + // container now. + return; + } this._initTableColumns(table); this._emitDetailSelection(table); this._emitDetailLocate(table); @@ -1097,7 +1139,17 @@ export class CalltreeView extends LitElement { this.bottomUpTreeTable = table; this._watchTable(table, false); await tableBuilt; + if (this.bottomUpTreeTable !== table) { + // A detach destroyed this build mid-flight, and a later one owns the + // container now. + return; + } this._initTableColumns(table); + const groupBy = groupByField(this.bottomUpGroupBy); + if (groupBy) { + // @ts-expect-error setSortedGroupBy is added by the GroupSort custom module + table.setSortedGroupBy(groupBy); + } this._emitDetailSelection(table); this._emitDetailLocate(table); } diff --git a/log-viewer/src/features/call-tree/components/__tests__/CalltreeView.test.ts b/log-viewer/src/features/call-tree/components/__tests__/CalltreeView.test.ts new file mode 100644 index 000000000..271899351 --- /dev/null +++ b/log-viewer/src/features/call-tree/components/__tests__/CalltreeView.test.ts @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import type { ApexLog } from 'apex-log-parser'; +import type { Tabulator } from 'tabulator-tables'; + +// The grids bring tabulator and its module registrations, which don't load under +// jest; this suite drives only which table the view builds, and when. +jest.mock('../TimeOrderTable.js', () => ({ createTimeOrderTable: () => build('time-order') })); +jest.mock('../AggregatedTable.js', () => ({ createAggregatedTable: () => build('aggregated') })); +jest.mock('../BottomUpTable.js', () => ({ createBottomUpTable: () => build('bottom-up') })); +// vscode-button needs ElementInternals.setFormValue (absent in jsdom). +jest.mock('#vscode-elements/vscode-button.js', () => ({})); +jest.mock('#vscode-elements/vscode-option.js', () => ({})); +jest.mock('#vscode-elements/vscode-toolbar-button.js', () => ({})); +// vscode-icon re-links the codicon stylesheet by href, which jsdom has none of. +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); +// VsSelect extends vscode-single-select, whose setFormValue needs an +// ElementInternals jsdom lacks; the render would upgrade it. +jest.mock('../../../../components/VsSelect.js', () => ({})); +// Connecting the view reads settings twice: firstUpdated loads the column view, +// and category colouring subscribes. This suite has no extension host to answer. +jest.mock('../../../settings/Settings.js', () => ({ + ...jest.requireActual('../../../settings/Settings.js'), + getSettings: () => Promise.resolve({}), + subscribeSettings: () => () => {}, +})); + +import { CalltreeView } from '../CalltreeView.js'; + +/** Which table each build made, and which each teardown destroyed, in order. */ +let built: string[] = []; +let destroyed: string[] = []; +/** The filters the newest table was given, so a rebuild can be told from a reset. */ +let filtered: unknown[] = []; +/** Which tables had their columns applied, which is the tail of a build. */ +let wired: string[] = []; +/** What Bottom Up was told to group on, per build. */ +let groupedBy: string[] = []; +/** Finishes the newest build, where a test drives one that is in flight. */ +let finishBuild: (() => void) | null = null; +/** Whether a build waits to be finished by hand. */ +let holdBuilds = false; + +function build(kind: string): { table: Tabulator; tableBuilt: Promise } { + built.push(kind); + // A new table carries no filters of its own, so the record starts over with it. + filtered = []; + const table = { + element: document.createElement('div'), + on: () => {}, + getColumns: () => { + wired.push(kind); + return []; + }, + redraw: () => {}, + blockRedraw: () => {}, + restoreRedraw: () => {}, + clearFilter: () => { + filtered = []; + }, + addFilter: (filter: unknown) => filtered.push(filter), + clearFindHighlights: () => {}, + setSortedGroupBy: (field: string) => groupedBy.push(field), + destroy: () => destroyed.push(kind), + } as unknown as Tabulator; + if (!holdBuilds) { + return { table, tableBuilt: Promise.resolve() }; + } + return { table, tableBuilt: new Promise((resolve) => (finishBuild = resolve)) }; +} + +/** jsdom has no IntersectionObserver, and the build waits on one. */ +class AlwaysVisible { + private _callback: IntersectionObserverCallback; + + constructor(callback: IntersectionObserverCallback) { + this._callback = callback; + } + + observe(element: Element): void { + this._callback( + [{ isIntersecting: true, target: element } as IntersectionObserverEntry], + this as unknown as IntersectionObserver, + ); + } + disconnect(): void {} +} + +function apexLog(): ApexLog { + return { children: [], namespaces: [], governorLimits: null } as unknown as ApexLog; +} + +/** Let the build's promise chain run out: more turns than it takes, since the + * count of them is not what any test is about. */ +async function settle(): Promise { + for (let i = 0; i < 4; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +async function mountView(): Promise { + const view = new CalltreeView(); + document.body.append(view); + await view.updateComplete; + view.timelineRoot = apexLog(); + await view.updateComplete; + await settle(); + return view; +} + +describe('calltree-view table lifetime', () => { + let view: CalltreeView; + + beforeEach(async () => { + built = []; + destroyed = []; + filtered = []; + wired = []; + groupedBy = []; + finishBuild = null; + holdBuilds = false; + globalThis.IntersectionObserver = AlwaysVisible as unknown as typeof IntersectionObserver; + view = await mountView(); + }); + + afterEach(() => { + view.remove(); + }); + + it('builds the table for the log it is given', () => { + expect(built).toEqual(['time-order']); + }); + + it('rebuilds the table after a detach and a re-attach', async () => { + view.remove(); + expect(destroyed).toEqual(['time-order']); + + document.body.append(view); + await settle(); + + expect(built).toEqual(['time-order', 'time-order']); + }); + + it('rebuilds under the filters the view was left with', async () => { + view.namespaceSelected = ['ns']; + view._updateFiltering(); + const onShow = filtered.length; + expect(onShow).toBeGreaterThan(0); + + view.remove(); + document.body.append(view); + await settle(); + + // The chip still reads as on, so the rebuilt table has to read the same way. + expect(filtered).toHaveLength(onShow); + }); + + it('builds nothing where the view goes before it is seen', async () => { + view.remove(); + built = []; + // Back on screen, then gone again before the visibility answer is acted on. + document.body.append(view); + view.remove(); + await settle(); + + expect(built).toEqual([]); + }); + + it('leaves a build the detach overtook to the one that replaced it', async () => { + view.remove(); + wired = []; + holdBuilds = true; + document.body.append(view); + await settle(); + expect(built).toEqual(['time-order', 'time-order']); + + // Gone and back while the first build is still waiting on its table. + const overtaken = finishBuild!; + view.remove(); + holdBuilds = false; + document.body.append(view); + await settle(); + expect(built).toHaveLength(3); + + wired = []; + overtaken(); + await settle(); + + // The container holds the newest table now, so the overtaken build must not + // read a header that is no longer its own. + expect(wired).toEqual([]); + }); + + it('rebuilds bottom up grouped the way it was left', async () => { + await view._setViewMode('bottom-up'); + view._handleBottomUpGroupBy({ target: { value: 'Namespace' } } as unknown as Event); + await settle(); + expect(groupedBy).toEqual(['namespace']); + + view.remove(); + document.body.append(view); + await settle(); + + expect(groupedBy).toEqual(['namespace', 'namespace']); + }); + + it('rebuilds the view on show, not the one the log opened on', async () => { + await view._setViewMode('bottom-up'); + await settle(); + expect(built).toEqual(['time-order', 'bottom-up']); + + view.remove(); + document.body.append(view); + await settle(); + + expect(built).toEqual(['time-order', 'bottom-up', 'bottom-up']); + }); +}); From 6d78a0fa5ee9cfd0fc6d8245cbc9af83a95e841f Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:58:13 +0100 Subject: [PATCH 59/61] refactor(log-viewer): give the grids one owner for their columns Five grids each carried their own copy of the column view, its overrides, and the reads and writes either side of them. One controller holds that now, so a grid declares only its section, presets and tables. --- .../components/ColumnSettingsController.ts | 154 ++++++++++++ .../ColumnSettingsController.test.ts | 222 ++++++++++++++++++ .../analysis/components/AnalysisView.ts | 113 ++------- .../call-tree/components/CalltreeView.ts | 128 ++-------- .../features/database/components/DMLView.ts | 110 ++------- .../features/database/components/SOQLView.ts | 111 ++------- .../features/database/components/SOSLView.ts | 110 ++------- 7 files changed, 484 insertions(+), 464 deletions(-) create mode 100644 log-viewer/src/components/ColumnSettingsController.ts create mode 100644 log-viewer/src/components/__tests__/ColumnSettingsController.test.ts diff --git a/log-viewer/src/components/ColumnSettingsController.ts b/log-viewer/src/components/ColumnSettingsController.ts new file mode 100644 index 000000000..1ada0887f --- /dev/null +++ b/log-viewer/src/components/ColumnSettingsController.ts @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { Tabulator } from 'tabulator-tables'; + +import type { ContextMenuItem } from './ContextMenu.js'; +import { getSettings, updateSetting, type LanaSettings } from '../features/settings/Settings.js'; +import { + applyColumnView, + buildColumnMenuItems, + getColumnView, + getTableFields, + resolveColumnView, + toggleField, + type ColumnView, +} from '../tabulator/ColumnViews.js'; + +/** What a grid keeps in settings: the view on show, and the views the user edited. */ +export interface ColumnSettings { + columnView?: string; + columnOverrides?: Record; +} + +export interface ColumnSettingsOptions { + /** Settings section holding the two keys, e.g. `database.soql`. */ + section: string; + /** That same section, read off the settings object. */ + read: (settings: LanaSettings) => ColumnSettings | undefined; + /** The presets to choose from; the first is the default. */ + views: ColumnView[]; + /** Fields shown whichever view is on, e.g. the Name column. */ + alwaysVisible: string[]; + /** Every built table the view applies to. The call tree has three. */ + tables: () => Tabulator[]; +} + +/** + * Owns a grid's column view: which preset is on show, the per-view overrides the + * user edited, and both halves of that in settings. + * + * The two keys are private globalState, not registered `lana.*` settings, so + * nothing pushes a change to them and one read per host is enough. Two hosts on + * one section each read their own copy, and neither hears the other's writes. + */ +export class ColumnSettingsController implements ReactiveController { + private readonly _host: ReactiveControllerHost; + private readonly _options: ColumnSettingsOptions; + private _view: string; + private _overrides: Record = {}; + private _read: Promise | null = null; + + constructor(host: ReactiveControllerHost, options: ColumnSettingsOptions) { + this._host = host; + this._options = options; + this._view = options.views[0]!.id; + host.addController(this); + } + + hostConnected(): void { + // Once per host: a re-attach comes back to the state it left with. + this._read ??= getSettings() + .then((settings) => this._adopt(settings)) + // No extension host to ask (standalone browser): the grid keeps its defaults. + .catch(() => {}); + } + + /** The preset on show. */ + get view(): string { + return this._view; + } + + /** The presets the user has edited, which are the ones a reset applies to. */ + get editedViews(): string[] { + return Object.keys(this._overrides); + } + + /** Effective fields for a view id: the user override, else the built-in preset. */ + private fieldsFor(id: string): string[] | null { + return this._overrides[id] ?? getColumnView(this._options.views, id)?.fields ?? null; + } + + /** Apply the view on show to a table that has just been built. */ + applyTo(table: Tabulator): void { + applyColumnView(table, this.fieldsFor(this._view), this._options.alwaysVisible); + } + + /** Show `id` and remember it. */ + choose(id: string): void { + this._show(id); + updateSetting(`${this._options.section}.columnView`, id); + } + + /** Add or remove one column from the view on show, and remember it. */ + toggle(table: Tabulator, field: string): void { + this._overrides = { + ...this._overrides, + [this._view]: toggleField(this.fieldsFor(this._view), field, getTableFields(table)), + }; + this._apply(); + this._host.requestUpdate(); + updateSetting(`${this._options.section}.columnOverrides`, this._overrides); + } + + /** Give a view back its built-in columns. Defaults to the one on show. */ + reset(id: string = this._view): void { + if (!this._overrides[id]) { + return; + } + const { [id]: _dropped, ...rest } = this._overrides; + this._overrides = rest; + if (id === this._view) { + this._apply(); + } + this._host.requestUpdate(); + updateSetting(`${this._options.section}.columnOverrides`, this._overrides); + } + + /** The column header menu for `table`, against the state now. */ + menuItems(table: Tabulator): ContextMenuItem[] { + return buildColumnMenuItems( + table, + this._view, + this._options.views, + this._options.alwaysVisible, + this.editedViews, + ); + } + + private _adopt(settings: LanaSettings): void { + const stored = this._options.read(settings); + this._overrides = stored?.columnOverrides ?? {}; + this._show(resolveColumnView(this._options.views, stored?.columnView)); + } + + private _show(id: string): void { + this._view = id; + this._apply(); + this._host.requestUpdate(); + } + + /** + * A table that has never been laid out throws on the redraw this ends in, and a + * hidden tab leaves one that way. Such a table takes the view on its next build + * instead, through {@link applyTo}. + */ + private _apply(): void { + for (const table of this._options.tables()) { + if (table.element?.clientHeight) { + applyColumnView(table, this.fieldsFor(this._view), this._options.alwaysVisible); + } + } + } +} diff --git a/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts b/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts new file mode 100644 index 000000000..9cbd45c3d --- /dev/null +++ b/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts @@ -0,0 +1,222 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { Tabulator } from 'tabulator-tables'; + +// The controller reads and writes settings through the extension host, which this +// suite answers for. +jest.mock('../../features/settings/Settings.js', () => ({ + getSettings: () => { + reads++; + return Promise.resolve(stored); + }, + updateSetting: (section: string, value: unknown) => written.push([section, value]), +})); + +import { ColumnSettingsController } from '../ColumnSettingsController.js'; +import { getVisibleFields, type ColumnView } from '../../tabulator/ColumnViews.js'; + +/** What the extension host would answer with. */ +let stored: object = {}; +/** How many times the host was asked. */ +let reads = 0; +/** Every `updateSetting` the controller made, in order. */ +let written: [string, unknown][] = []; + +const VIEWS: ColumnView[] = [ + { id: 'General', fields: ['text', 'namespace', 'rowCount'] }, + { id: 'Timing', fields: ['text', 'timeTaken'] }, +]; +const ALWAYS_VISIBLE = ['text']; +const FIELDS = ['text', 'namespace', 'rowCount', 'timeTaken']; + +class FakeColumn { + visible = true; + field: string; + + constructor(field: string) { + this.field = field; + } + + getField(): string { + return this.field; + } + isVisible(): boolean { + return this.visible; + } + show(): void { + this.visible = true; + } + hide(): void { + this.visible = false; + } + getDefinition(): { title: string } { + return { title: this.field }; + } +} + +/** @param laidOut a table a hidden tab never rendered reports no height. */ +function fakeTable(laidOut = true): Tabulator { + const columns = FIELDS.map((field) => new FakeColumn(field)); + return { + getColumns: () => columns, + redraw: () => {}, + element: { clientHeight: laidOut ? 100 : 0 }, + } as unknown as Tabulator; +} + +/** The least a `ReactiveController` needs of its host. */ +class FakeHost implements ReactiveControllerHost { + readonly controllers: ReactiveController[] = []; + updates = 0; + + addController(controller: ReactiveController): void { + this.controllers.push(controller); + } + removeController(): void {} + requestUpdate(): void { + this.updates++; + } + get updateComplete(): Promise { + return Promise.resolve(true); + } + + connect(): void { + for (const controller of this.controllers) { + controller.hostConnected?.(); + } + } + disconnect(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.(); + } + } +} + +/** A connected controller over `table`, with settings already read. */ +async function connected( + table: Tabulator | null = fakeTable(), +): Promise<{ host: FakeHost; columns: ColumnSettingsController }> { + const host = new FakeHost(); + const columns = new ColumnSettingsController(host, { + section: 'database.soql', + read: (settings) => (settings as { database?: { soql?: object } }).database?.soql, + views: VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + tables: () => (table ? [table] : []), + }); + host.connect(); + await Promise.resolve(); + return { host, columns }; +} + +describe('ColumnSettingsController', () => { + beforeEach(() => { + stored = {}; + reads = 0; + written = []; + }); + + it('opens on the first view where nothing is stored', async () => { + const { columns } = await connected(); + + expect(columns.view).toBe('General'); + expect(columns.editedViews).toEqual([]); + }); + + it('opens on the stored view, under the stored overrides', async () => { + stored = { + database: { soql: { columnView: 'Timing', columnOverrides: { Timing: ['timeTaken'] } } }, + }; + const table = fakeTable(); + + const { columns } = await connected(table); + + expect(columns.view).toBe('Timing'); + expect(columns.editedViews).toEqual(['Timing']); + expect(getVisibleFields(table)).toEqual(['text', 'timeTaken']); + }); + + it('falls back to the first view where the stored one is gone', async () => { + stored = { database: { soql: { columnView: 'Retired' } } }; + + const { columns } = await connected(); + + expect(columns.view).toBe('General'); + }); + + it('shows and remembers a chosen view', async () => { + const table = fakeTable(); + const { columns } = await connected(table); + + columns.choose('Timing'); + + expect(columns.view).toBe('Timing'); + expect(getVisibleFields(table)).toEqual(['text', 'timeTaken']); + expect(written).toEqual([['database.soql.columnView', 'Timing']]); + }); + + it('takes a column out of the view on show, and remembers it', async () => { + const table = fakeTable(); + const { columns } = await connected(table); + + columns.toggle(table, 'rowCount'); + + expect(columns.editedViews).toEqual(['General']); + expect(getVisibleFields(table)).toEqual(['text', 'namespace']); + expect(written).toEqual([ + ['database.soql.columnOverrides', { General: ['text', 'namespace'] }], + ]); + }); + + it('gives a view back its built-in columns', async () => { + stored = { + database: { soql: { columnOverrides: { General: ['text'] } } }, + }; + const table = fakeTable(); + const { columns } = await connected(table); + expect(getVisibleFields(table)).toEqual(['text']); + + columns.reset(); + + expect(columns.editedViews).toEqual([]); + expect(getVisibleFields(table)).toEqual(['text', 'namespace', 'rowCount']); + expect(written).toEqual([['database.soql.columnOverrides', {}]]); + }); + + it('writes nothing for a reset of a view the user never edited', async () => { + const { columns } = await connected(); + + columns.reset(); + + expect(written).toEqual([]); + }); + + it('asks the host once, however often the view comes and goes', async () => { + const { host, columns } = await connected(); + expect(reads).toBe(1); + + columns.choose('Timing'); + host.disconnect(); + host.connect(); + await Promise.resolve(); + + // The keys are private globalState, so nothing pushes a change to re-read. + expect(reads).toBe(1); + expect(columns.view).toBe('Timing'); + }); + + it('leaves a table no tab has laid out to its next build', async () => { + const table = fakeTable(false); + const { columns } = await connected(table); + + columns.choose('Timing'); + + // Redrawing one of those throws, so the view waits for `applyTo`. + expect(getVisibleFields(table)).toEqual(FIELDS); + columns.applyTo(table); + expect(getVisibleFields(table)).toEqual(['text', 'timeTaken']); + }); +}); diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index 998d80973..fa5b2bbde 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -6,7 +6,7 @@ import '#vscode-elements/vscode-option.js'; import '../../../components/VsSelect.js'; import '#vscode-elements/vscode-toolbar-button.js'; import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import type { RowComponent, Tabulator } from 'tabulator-tables'; @@ -27,17 +27,9 @@ import { revealFirstOf, wireInspectorTab } from '../../../components/inspectorTa import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; -import { getSettings, updateSetting } from '../../settings/Settings.js'; import { createBottomUpTable } from '../../call-tree/components/BottomUpTable.js'; -import { - applyColumnView, - buildColumnMenuItems, - CALL_TREE_VIEWS, - getColumnView, - getTableFields, - resolveColumnView, - toggleField, -} from '../../../tabulator/ColumnViews.js'; +import { ColumnSettingsController } from '../../../components/ColumnSettingsController.js'; +import { CALL_TREE_VIEWS } from '../../../tabulator/ColumnViews.js'; import type { BottomUpRow } from '../../call-tree/utils/Aggregation.js'; import { findRootBucket } from '../../call-tree/utils/bucketRows.js'; import { @@ -129,12 +121,13 @@ export class AnalysisView extends LitElement { analysisTable: Tabulator | null = null; - @state() - columnView = 'General'; - - /** Per-view column overrides (view id โ†’ visible fields); empty until edited. */ - @state() - private columnOverrides: Record = {}; + private readonly _columns = new ColumnSettingsController(this, { + section: 'callTree', + read: (settings) => settings.callTree, + views: CALL_TREE_VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + tables: () => (this.analysisTable ? [this.analysisTable] : []), + }); private contextMenu: ContextMenu | null = null; tableContainer: HTMLDivElement | null = null; findMap: { [key: number]: RowComponent } = {}; @@ -251,13 +244,6 @@ export class AnalysisView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); - void this._loadColumnSettings(); - } - - private async _loadColumnSettings(): Promise { - const settings = await getSettings(); - this.columnOverrides = settings.callTree?.columnOverrides ?? {}; - this._setColumnView(resolveColumnView(CALL_TREE_VIEWS, settings.callTree?.columnView)); } updated(changedProperties: PropertyValues): void { @@ -299,14 +285,16 @@ export class AnalysisView extends LitElement { label="Column view" @change="${this._handleColumnViewChange}" @vs-reset-option="${this._onResetOption}" - .value="${this.columnView}" - .resettableValues="${Object.keys(this.columnOverrides)}" + .value="${this._columns.view}" + .resettableValues="${this._columns.editedViews}" > ${repeat( CALL_TREE_VIEWS, (view) => view.id, (view) => - html`${view.id}`, )} @@ -370,27 +358,12 @@ export class AnalysisView extends LitElement { } private _handleColumnViewChange(event: Event) { - const target = event.target as HTMLInputElement; - const id = target.value || 'General'; - this._setColumnView(id); - updateSetting('callTree.columnView', id); - } - - /** Effective fields for a view id: the user override, else the built-in preset. */ - private _columnViewFields(id: string): string[] | null { - return this.columnOverrides[id] ?? getColumnView(CALL_TREE_VIEWS, id)?.fields ?? null; - } - - private _setColumnView(id: string) { - this.columnView = id; - if (this.analysisTable) { - applyColumnView(this.analysisTable, this._columnViewFields(id), ALWAYS_VISIBLE); - } + this._columns.choose((event.target as HTMLInputElement).value || 'General'); } /** Applies the active view and wires the header menu once the table is built. */ private _initTableColumns(table: Tabulator) { - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + this._columns.applyTo(table); const header = table.element.querySelector('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -402,17 +375,7 @@ export class AnalysisView extends LitElement { if (!this.contextMenu || !this.analysisTable) { return; } - this.contextMenu.show( - buildColumnMenuItems( - this.analysisTable, - this.columnView, - CALL_TREE_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ), - x, - y, - ); + this.contextMenu.show(this._columns.menuItems(this.analysisTable), x, y); } private _openColumnMenu(event: Event) { @@ -425,13 +388,7 @@ export class AnalysisView extends LitElement { if (!this.contextMenu?.isVisible() || !this.analysisTable) { return; } - this.contextMenu.items = buildColumnMenuItems( - this.analysisTable, - this.columnView, - CALL_TREE_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ); + this.contextMenu.items = this._columns.menuItems(this.analysisTable); } private _handleColumnMenuSelect(e: CustomEvent<{ itemId: string }>) { @@ -441,47 +398,23 @@ export class AnalysisView extends LitElement { return; } if (itemId.startsWith('view:')) { - const id = itemId.slice('view:'.length); - this._setColumnView(id); - updateSetting('callTree.columnView', id); + this._columns.choose(itemId.slice('view:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { - const field = itemId.slice('col:'.length); - const fields = toggleField( - this._columnViewFields(this.columnView), - field, - getTableFields(table), - ); - this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; - applyColumnView(table, fields, ALWAYS_VISIBLE); - updateSetting('callTree.columnOverrides', this.columnOverrides); + this._columns.toggle(table, itemId.slice('col:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('reset:')) { - this._resetColumns(itemId.slice('reset:'.length)); + this._columns.reset(itemId.slice('reset:'.length)); this._refreshColumnMenu(); } } private _onResetOption(event: CustomEvent<{ value: string }>) { - this._resetColumns(event.detail.value); - } - - /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ - private _resetColumns(id: string = this.columnView) { - const table = this.analysisTable; - if (!table || !this.columnOverrides[id]) { - return; - } - const { [id]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - if (id === this.columnView) { - applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); - } - updateSetting('callTree.columnOverrides', this.columnOverrides); + this._columns.reset(event.detail.value); } _copyToClipboard() { diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 4eaa1606f..96fff458a 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -18,7 +18,6 @@ import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; -import { getSettings, updateSetting } from '../../settings/Settings.js'; import { CALLTREE_GO_TO_ROW, type CalltreeNavigationEventMap } from '../navigation.js'; import type { AggregatedRow, BottomUpRow } from '../utils/Aggregation.js'; import { findBucketRow } from '../utils/bucketRows.js'; @@ -58,15 +57,8 @@ import '../../../components/OverflowList.js'; // Table creation functions import { createAggregatedTable } from './AggregatedTable.js'; import { createBottomUpTable } from './BottomUpTable.js'; -import { - applyColumnView, - buildColumnMenuItems, - CALL_TREE_VIEWS, - getColumnView, - getTableFields, - resolveColumnView, - toggleField, -} from '../../../tabulator/ColumnViews.js'; +import { ColumnSettingsController } from '../../../components/ColumnSettingsController.js'; +import { CALL_TREE_VIEWS } from '../../../tabulator/ColumnViews.js'; import { LocatedRowIds, LocatedRowMarker, @@ -159,12 +151,14 @@ export class CalltreeView extends LitElement { tableContainer: HTMLDivElement | null = null; rootMethod: ApexLog | null = null; - @state() - columnView = 'General'; - - /** Per-view column overrides (view id โ†’ visible fields); empty until edited. */ - @state() - private columnOverrides: Record = {}; + private readonly _columns = new ColumnSettingsController(this, { + section: 'callTree', + read: (settings) => settings.callTree, + views: CALL_TREE_VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + // All three, so a mode the user has not switched back to is already right. + tables: () => this._tables, + }); private contextMenu: ContextMenu | null = null; private contextMenuRow: TimeOrderRow | null = null; @@ -247,13 +241,6 @@ export class CalltreeView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); - void this._loadColumnSettings(); - } - - private async _loadColumnSettings(): Promise { - const settings = await getSettings(); - this.columnOverrides = settings.callTree?.columnOverrides ?? {}; - this._setColumnView(resolveColumnView(CALL_TREE_VIEWS, settings.callTree?.columnView)); } static styles = [ @@ -354,8 +341,8 @@ export class CalltreeView extends LitElement { label="Column view" @change="${this._handleColumnViewChange}" @vs-reset-option="${this._onResetOption}" - .value="${this.columnView}" - .resettableValues="${Object.keys(this.columnOverrides)}" + .value="${this._columns.view}" + .resettableValues="${this._columns.editedViews}" > ${repeat( CALL_TREE_VIEWS, @@ -363,7 +350,7 @@ export class CalltreeView extends LitElement { (view) => html`${view.id}`, )} @@ -625,15 +612,7 @@ export class CalltreeView extends LitElement { } private _handleColumnViewChange(event: Event) { - const target = event.target as HTMLInputElement; - const id = target.value || 'General'; - this._setColumnView(id); - updateSetting('callTree.columnView', id); - } - - /** Effective fields for a view id: the user override, else the built-in preset. */ - private _columnViewFields(id: string): string[] | null { - return this.columnOverrides[id] ?? getColumnView(CALL_TREE_VIEWS, id)?.fields ?? null; + this._columns.choose((event.target as HTMLInputElement).value || 'General'); } private get _tables(): Tabulator[] { @@ -642,17 +621,9 @@ export class CalltreeView extends LitElement { ); } - private _setColumnView(id: string) { - this.columnView = id; - const fields = this._columnViewFields(id); - for (const table of this._tables) { - applyColumnView(table, fields, ALWAYS_VISIBLE); - } - } - /** Applies the active view and wires the header menu once a table is built. */ private _initTableColumns(table: Tabulator) { - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + this._columns.applyTo(table); const header = table.element.querySelector('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -666,17 +637,7 @@ export class CalltreeView extends LitElement { } this.contextMenuRow = null; this.contextMenuTable = table; - this.contextMenu.show( - buildColumnMenuItems( - table, - this.columnView, - CALL_TREE_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ), - clientX, - clientY, - ); + this.contextMenu.show(this._columns.menuItems(table), clientX, clientY); } private _openColumnMenu(event: Event) { @@ -693,13 +654,7 @@ export class CalltreeView extends LitElement { if (!this.contextMenu?.isVisible() || !this.contextMenuTable) { return; } - this.contextMenu.items = buildColumnMenuItems( - this.contextMenuTable, - this.columnView, - CALL_TREE_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ); + this.contextMenu.items = this._columns.menuItems(this.contextMenuTable); } private _onColumnMenuClose() { @@ -707,43 +662,8 @@ export class CalltreeView extends LitElement { this.contextMenuRow = null; } - /** Toggles a column in the active view's override, shared across all tables. */ - private _toggleColumn(field: string) { - const table = this.contextMenuTable; - if (!table) { - return; - } - const fields = toggleField( - this._columnViewFields(this.columnView), - field, - getTableFields(table), - ); - this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; - for (const t of this._tables) { - applyColumnView(t, fields, ALWAYS_VISIBLE); - } - updateSetting('callTree.columnOverrides', this.columnOverrides); - } - private _onResetOption(event: CustomEvent<{ value: string }>) { - this._resetColumns(event.detail.value); - } - - /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ - private _resetColumns(id: string = this.columnView) { - if (!this.columnOverrides[id]) { - return; - } - const { [id]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - if (id === this.columnView) { - // Resolve the restored fields once (identical for every table). - const fields = this._columnViewFields(id); - for (const table of this._tables) { - applyColumnView(table, fields, ALWAYS_VISIBLE); - } - } - updateSetting('callTree.columnOverrides', this.columnOverrides); + this._columns.reset(event.detail.value); } _handleTypeFilter(event: Event) { @@ -1310,19 +1230,19 @@ export class CalltreeView extends LitElement { // open (keepOpen), so refresh its items live and leave contextMenuTable set โ€” // it's cleared on menu-close. if (itemId.startsWith('view:')) { - const id = itemId.slice('view:'.length); - this._setColumnView(id); - updateSetting('callTree.columnView', id); + this._columns.choose(itemId.slice('view:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { - this._toggleColumn(itemId.slice('col:'.length)); - this._refreshColumnMenu(); + if (this.contextMenuTable) { + this._columns.toggle(this.contextMenuTable, itemId.slice('col:'.length)); + this._refreshColumnMenu(); + } return; } if (itemId.startsWith('reset:')) { - this._resetColumns(itemId.slice('reset:'.length)); + this._columns.reset(itemId.slice('reset:'.length)); this._refreshColumnMenu(); return; } diff --git a/log-viewer/src/features/database/components/DMLView.ts b/log-viewer/src/features/database/components/DMLView.ts index 4bd3c9d69..1f16a70e8 100644 --- a/log-viewer/src/features/database/components/DMLView.ts +++ b/log-viewer/src/features/database/components/DMLView.ts @@ -15,20 +15,12 @@ import { DomListenerController } from '../../../core/events/DomListenerControlle import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { goToRow } from '../../call-tree/navigation.js'; import { isVisible } from '../../../core/utility/Util.js'; -import { getSettings, updateSetting } from '../../settings/Settings.js'; import { LocatedRowMarker } from '../../../components/locatedRow.js'; import { reportGridLocate, stampGridEventIndex } from './gridLocate.js'; import { reportGridSelection } from './gridSelection.js'; import { selectRowByEventIndex } from './revealRow.js'; -import { - applyColumnView, - buildColumnMenuItems, - DML_VIEWS, - getColumnView, - getTableFields, - resolveColumnView, - toggleField, -} from '../../../tabulator/ColumnViews.js'; +import { ColumnSettingsController } from '../../../components/ColumnSettingsController.js'; +import { DML_VIEWS } from '../../../tabulator/ColumnViews.js'; import { DB_ROW_COUNT_WIDTH, DB_TIME_WIDTH, @@ -102,12 +94,13 @@ export class DMLView extends LitElement { totalMatches = 0; blockClearHighlights = true; - @state() - columnView = 'General'; - - /** Per-view column overrides (view id โ†’ visible fields); empty until edited. */ - @state() - private columnOverrides: Record = {}; + private readonly _columns = new ColumnSettingsController(this, { + section: 'database.dml', + read: (settings) => settings.database?.dml, + views: DML_VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + tables: () => (this.dmlTable ? [this.dmlTable] : []), + }); private contextMenu: ContextMenu | null = null; /** eventIndex of the row whose context menu is open. */ private contextMenuEventIndex: number | null = null; @@ -130,13 +123,6 @@ export class DMLView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); - void this._loadColumnSettings(); - } - - private async _loadColumnSettings(): Promise { - const settings = await getSettings(); - this.columnOverrides = settings.database?.dml?.columnOverrides ?? {}; - this._setColumnView(resolveColumnView(DML_VIEWS, settings.database?.dml?.columnView)); } updated(changedProperties: PropertyValues): void { @@ -211,12 +197,12 @@ export class DMLView extends LitElement { label="Column view" @change="${this._handleColumnViewChange}" @vs-reset-option="${this._onResetOption}" - .value="${this.columnView}" - .resettableValues="${Object.keys(this.columnOverrides)}" + .value="${this._columns.view}" + .resettableValues="${this._columns.editedViews}" > ${DML_VIEWS.map( (view) => - html`${view.id}`, )} @@ -268,28 +254,12 @@ export class DMLView extends LitElement { } private _handleColumnViewChange(event: Event) { - const id = (event.target as HTMLInputElement).value || 'General'; - this._setColumnView(id); - updateSetting('database.dml.columnView', id); - } - - /** Effective fields for a view id: the user override, else the built-in preset. */ - private _columnViewFields(id: string): string[] | null { - return this.columnOverrides[id] ?? getColumnView(DML_VIEWS, id)?.fields ?? null; - } - - private _setColumnView(id: string) { - this.columnView = id; - // Only apply once the table is laid out; otherwise tableBuilt โ†’ _initTableColumns - // applies the current view (redraw on an unrendered table throws). - if (this.dmlTable?.element?.clientHeight) { - applyColumnView(this.dmlTable, this._columnViewFields(id), ALWAYS_VISIBLE); - } + this._columns.choose((event.target as HTMLInputElement).value || 'General'); } /** Applies the active view and wires the header menu once the table is built. */ private _initTableColumns(table: Tabulator) { - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + this._columns.applyTo(table); const header = table.element.querySelector('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -301,17 +271,7 @@ export class DMLView extends LitElement { if (!this.contextMenu || !this.dmlTable) { return; } - this.contextMenu.show( - buildColumnMenuItems( - this.dmlTable, - this.columnView, - DML_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ), - x, - y, - ); + this.contextMenu.show(this._columns.menuItems(this.dmlTable), x, y); } private _openColumnMenu(event: Event) { @@ -324,13 +284,7 @@ export class DMLView extends LitElement { if (!this.contextMenu?.isVisible() || !this.dmlTable) { return; } - this.contextMenu.items = buildColumnMenuItems( - this.dmlTable, - this.columnView, - DML_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ); + this.contextMenu.items = this._columns.menuItems(this.dmlTable); } private _showRowContextMenu(event: MouseEvent, row: RowComponent) { @@ -351,47 +305,23 @@ export class DMLView extends LitElement { return; } if (itemId.startsWith('view:')) { - const id = itemId.slice('view:'.length); - this._setColumnView(id); - updateSetting('database.dml.columnView', id); + this._columns.choose(itemId.slice('view:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { - const field = itemId.slice('col:'.length); - const fields = toggleField( - this._columnViewFields(this.columnView), - field, - getTableFields(table), - ); - this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; - applyColumnView(table, fields, ALWAYS_VISIBLE); - updateSetting('database.dml.columnOverrides', this.columnOverrides); + this._columns.toggle(table, itemId.slice('col:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('reset:')) { - this._resetColumns(itemId.slice('reset:'.length)); + this._columns.reset(itemId.slice('reset:'.length)); this._refreshColumnMenu(); } } private _onResetOption(event: CustomEvent<{ value: string }>) { - this._resetColumns(event.detail.value); - } - - /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ - private _resetColumns(id: string = this.columnView) { - const table = this.dmlTable; - if (!table || !this.columnOverrides[id]) { - return; - } - const { [id]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - if (id === this.columnView) { - applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); - } - updateSetting('database.dml.columnOverrides', this.columnOverrides); + this._columns.reset(event.detail.value); } private _handleCallerNamespaceFacet(event: CustomEvent<{ selected: string[] }>) { diff --git a/log-viewer/src/features/database/components/SOQLView.ts b/log-viewer/src/features/database/components/SOQLView.ts index ba86ee6db..1445c3f4c 100644 --- a/log-viewer/src/features/database/components/SOQLView.ts +++ b/log-viewer/src/features/database/components/SOQLView.ts @@ -24,20 +24,13 @@ import { deriveSoqlObject } from '../services/sobjectClassification.js'; import { soqlGroupHeader } from '../../soql/format/groupHeader.js'; import { soqlInlineElement } from '../../soql/format/inlineCell.js'; import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; -import { getSettings, updateSetting } from '../../settings/Settings.js'; + import { LocatedRowMarker } from '../../../components/locatedRow.js'; import { reportGridLocate, stampGridEventIndex } from './gridLocate.js'; import { reportGridSelection } from './gridSelection.js'; import { selectRowByEventIndex } from './revealRow.js'; -import { - applyColumnView, - buildColumnMenuItems, - getColumnView, - getTableFields, - resolveColumnView, - SOQL_VIEWS, - toggleField, -} from '../../../tabulator/ColumnViews.js'; +import { ColumnSettingsController } from '../../../components/ColumnSettingsController.js'; +import { SOQL_VIEWS } from '../../../tabulator/ColumnViews.js'; import { DB_ROW_COUNT_WIDTH, DB_TIME_WIDTH, @@ -109,12 +102,13 @@ export class SOQLView extends LitElement { holder: HTMLElement | null = null; table: HTMLElement | null = null; - @state() - columnView = 'General'; - - /** Per-view column overrides (view id โ†’ visible fields); empty until edited. */ - @state() - private columnOverrides: Record = {}; + private readonly _columns = new ColumnSettingsController(this, { + section: 'database.soql', + read: (settings) => settings.database?.soql, + views: SOQL_VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + tables: () => (this.soqlTable ? [this.soqlTable] : []), + }); private contextMenu: ContextMenu | null = null; /** eventIndex of the row whose context menu is open. */ private contextMenuEventIndex: number | null = null; @@ -150,13 +144,6 @@ export class SOQLView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); - void this._loadColumnSettings(); - } - - private async _loadColumnSettings(): Promise { - const settings = await getSettings(); - this.columnOverrides = settings.database?.soql?.columnOverrides ?? {}; - this._setColumnView(resolveColumnView(SOQL_VIEWS, settings.database?.soql?.columnView)); } updated(changedProperties: PropertyValues): void { @@ -230,12 +217,12 @@ export class SOQLView extends LitElement { label="Column view" @change="${this._handleColumnViewChange}" @vs-reset-option="${this._onResetOption}" - .value="${this.columnView}" - .resettableValues="${Object.keys(this.columnOverrides)}" + .value="${this._columns.view}" + .resettableValues="${this._columns.editedViews}" > ${SOQL_VIEWS.map( (view) => - html`${view.id}`, )} @@ -287,28 +274,12 @@ export class SOQLView extends LitElement { } private _handleColumnViewChange(event: Event) { - const id = (event.target as HTMLInputElement).value || 'General'; - this._setColumnView(id); - updateSetting('database.soql.columnView', id); - } - - /** Effective fields for a view id: the user override, else the built-in preset. */ - private _columnViewFields(id: string): string[] | null { - return this.columnOverrides[id] ?? getColumnView(SOQL_VIEWS, id)?.fields ?? null; - } - - private _setColumnView(id: string) { - this.columnView = id; - // Only apply once the table is laid out; otherwise tableBuilt โ†’ _initTableColumns - // applies the current view (redraw on an unrendered table throws). - if (this.soqlTable?.element?.clientHeight) { - applyColumnView(this.soqlTable, this._columnViewFields(id), ALWAYS_VISIBLE); - } + this._columns.choose((event.target as HTMLInputElement).value || 'General'); } /** Applies the active view and wires the header menu once the table is built. */ private _initTableColumns(table: Tabulator) { - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + this._columns.applyTo(table); const header = table.element.querySelector('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -320,17 +291,7 @@ export class SOQLView extends LitElement { if (!this.contextMenu || !this.soqlTable) { return; } - this.contextMenu.show( - buildColumnMenuItems( - this.soqlTable, - this.columnView, - SOQL_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ), - x, - y, - ); + this.contextMenu.show(this._columns.menuItems(this.soqlTable), x, y); } private _openColumnMenu(event: Event) { @@ -343,13 +304,7 @@ export class SOQLView extends LitElement { if (!this.contextMenu?.isVisible() || !this.soqlTable) { return; } - this.contextMenu.items = buildColumnMenuItems( - this.soqlTable, - this.columnView, - SOQL_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ); + this.contextMenu.items = this._columns.menuItems(this.soqlTable); } private _showRowContextMenu(event: MouseEvent, row: RowComponent) { @@ -370,47 +325,23 @@ export class SOQLView extends LitElement { return; } if (itemId.startsWith('view:')) { - const id = itemId.slice('view:'.length); - this._setColumnView(id); - updateSetting('database.soql.columnView', id); + this._columns.choose(itemId.slice('view:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { - const field = itemId.slice('col:'.length); - const fields = toggleField( - this._columnViewFields(this.columnView), - field, - getTableFields(table), - ); - this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; - applyColumnView(table, fields, ALWAYS_VISIBLE); - updateSetting('database.soql.columnOverrides', this.columnOverrides); + this._columns.toggle(table, itemId.slice('col:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('reset:')) { - this._resetColumns(itemId.slice('reset:'.length)); + this._columns.reset(itemId.slice('reset:'.length)); this._refreshColumnMenu(); } } private _onResetOption(event: CustomEvent<{ value: string }>) { - this._resetColumns(event.detail.value); - } - - /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ - private _resetColumns(id: string = this.columnView) { - const table = this.soqlTable; - if (!table || !this.columnOverrides[id]) { - return; - } - const { [id]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - if (id === this.columnView) { - applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); - } - updateSetting('database.soql.columnOverrides', this.columnOverrides); + this._columns.reset(event.detail.value); } private _handleObjectFacet(event: CustomEvent<{ selected: string[] }>) { diff --git a/log-viewer/src/features/database/components/SOSLView.ts b/log-viewer/src/features/database/components/SOSLView.ts index 31e71872d..caa59504b 100644 --- a/log-viewer/src/features/database/components/SOSLView.ts +++ b/log-viewer/src/features/database/components/SOSLView.ts @@ -15,22 +15,14 @@ import { DomListenerController } from '../../../core/events/DomListenerControlle import type { FindEventDetail, FindEventMap } from '../../find/findEvents.js'; import { goToRow } from '../../call-tree/navigation.js'; import { isVisible } from '../../../core/utility/Util.js'; -import { getSettings, updateSetting } from '../../settings/Settings.js'; import { LocatedRowMarker } from '../../../components/locatedRow.js'; import { reportGridLocate, stampGridEventIndex } from './gridLocate.js'; import { reportGridSelection } from './gridSelection.js'; import { selectRowByEventIndex } from './revealRow.js'; import { soqlInlineElement } from '../../soql/format/inlineCell.js'; import { soqlSyntaxStyles } from '../../soql/styles/soql-syntax.css.js'; -import { - applyColumnView, - buildColumnMenuItems, - getColumnView, - getTableFields, - resolveColumnView, - SOSL_VIEWS, - toggleField, -} from '../../../tabulator/ColumnViews.js'; +import { ColumnSettingsController } from '../../../components/ColumnSettingsController.js'; +import { SOSL_VIEWS } from '../../../tabulator/ColumnViews.js'; import { DB_ROW_COUNT_WIDTH, DB_TIME_WIDTH, @@ -103,12 +95,13 @@ export class SOSLView extends LitElement { totalMatches = 0; blockClearHighlights = true; - @state() - columnView = 'General'; - - /** Per-view column overrides (view id โ†’ visible fields); empty until edited. */ - @state() - private columnOverrides: Record = {}; + private readonly _columns = new ColumnSettingsController(this, { + section: 'database.sosl', + read: (settings) => settings.database?.sosl, + views: SOSL_VIEWS, + alwaysVisible: ALWAYS_VISIBLE, + tables: () => (this.soslTable ? [this.soslTable] : []), + }); private contextMenu: ContextMenu | null = null; /** eventIndex of the row whose context menu is open. */ private contextMenuEventIndex: number | null = null; @@ -128,13 +121,6 @@ export class SOSLView extends LitElement { firstUpdated(): void { this.contextMenu = this.renderRoot.querySelector('context-menu'); - void this._loadColumnSettings(); - } - - private async _loadColumnSettings(): Promise { - const settings = await getSettings(); - this.columnOverrides = settings.database?.sosl?.columnOverrides ?? {}; - this._setColumnView(resolveColumnView(SOSL_VIEWS, settings.database?.sosl?.columnView)); } updated(changedProperties: PropertyValues): void { @@ -205,12 +191,12 @@ export class SOSLView extends LitElement { label="Column view" @change="${this._handleColumnViewChange}" @vs-reset-option="${this._onResetOption}" - .value="${this.columnView}" - .resettableValues="${Object.keys(this.columnOverrides)}" + .value="${this._columns.view}" + .resettableValues="${this._columns.editedViews}" > ${SOSL_VIEWS.map( (view) => - html`${view.id}`, )} @@ -261,28 +247,12 @@ export class SOSLView extends LitElement { } private _handleColumnViewChange(event: Event) { - const id = (event.target as HTMLInputElement).value || 'General'; - this._setColumnView(id); - updateSetting('database.sosl.columnView', id); - } - - /** Effective fields for a view id: the user override, else the built-in preset. */ - private _columnViewFields(id: string): string[] | null { - return this.columnOverrides[id] ?? getColumnView(SOSL_VIEWS, id)?.fields ?? null; - } - - private _setColumnView(id: string) { - this.columnView = id; - // Only apply once the table is laid out; otherwise tableBuilt โ†’ _initTableColumns - // applies the current view (redraw on an unrendered table throws). - if (this.soslTable?.element?.clientHeight) { - applyColumnView(this.soslTable, this._columnViewFields(id), ALWAYS_VISIBLE); - } + this._columns.choose((event.target as HTMLInputElement).value || 'General'); } /** Applies the active view and wires the header menu once the table is built. */ private _initTableColumns(table: Tabulator) { - applyColumnView(table, this._columnViewFields(this.columnView), ALWAYS_VISIBLE); + this._columns.applyTo(table); const header = table.element.querySelector('.tabulator-header'); header?.addEventListener('contextmenu', (event) => { event.preventDefault(); @@ -294,17 +264,7 @@ export class SOSLView extends LitElement { if (!this.contextMenu || !this.soslTable) { return; } - this.contextMenu.show( - buildColumnMenuItems( - this.soslTable, - this.columnView, - SOSL_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ), - x, - y, - ); + this.contextMenu.show(this._columns.menuItems(this.soslTable), x, y); } private _openColumnMenu(event: Event) { @@ -317,13 +277,7 @@ export class SOSLView extends LitElement { if (!this.contextMenu?.isVisible() || !this.soslTable) { return; } - this.contextMenu.items = buildColumnMenuItems( - this.soslTable, - this.columnView, - SOSL_VIEWS, - ALWAYS_VISIBLE, - Object.keys(this.columnOverrides), - ); + this.contextMenu.items = this._columns.menuItems(this.soslTable); } private _showRowContextMenu(event: MouseEvent, row: RowComponent) { @@ -344,47 +298,23 @@ export class SOSLView extends LitElement { return; } if (itemId.startsWith('view:')) { - const id = itemId.slice('view:'.length); - this._setColumnView(id); - updateSetting('database.sosl.columnView', id); + this._columns.choose(itemId.slice('view:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('col:')) { - const field = itemId.slice('col:'.length); - const fields = toggleField( - this._columnViewFields(this.columnView), - field, - getTableFields(table), - ); - this.columnOverrides = { ...this.columnOverrides, [this.columnView]: fields }; - applyColumnView(table, fields, ALWAYS_VISIBLE); - updateSetting('database.sosl.columnOverrides', this.columnOverrides); + this._columns.toggle(table, itemId.slice('col:'.length)); this._refreshColumnMenu(); return; } if (itemId.startsWith('reset:')) { - this._resetColumns(itemId.slice('reset:'.length)); + this._columns.reset(itemId.slice('reset:'.length)); this._refreshColumnMenu(); } } private _onResetOption(event: CustomEvent<{ value: string }>) { - this._resetColumns(event.detail.value); - } - - /** Clears a view's override, restoring its built-in columns (defaults to the active view). */ - private _resetColumns(id: string = this.columnView) { - const table = this.soslTable; - if (!table || !this.columnOverrides[id]) { - return; - } - const { [id]: _removed, ...rest } = this.columnOverrides; - this.columnOverrides = rest; - if (id === this.columnView) { - applyColumnView(table, this._columnViewFields(id), ALWAYS_VISIBLE); - } - updateSetting('database.sosl.columnOverrides', this.columnOverrides); + this._columns.reset(event.detail.value); } private _handleNamespaceFacet(event: CustomEvent<{ selected: string[] }>) { From 3a95cf4b9d9b41fc56935a412bc02a7aa32110a4 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:23:15 +0100 Subject: [PATCH 60/61] refactor(log-viewer): tie the inspector wiring to the host's connected life Three views each held an emphasis, called the wiring by hand on connect and released it on disconnect. A controller does both now and owns the emphasis, since nothing outside the subscription decides what it holds. A view that comes back lights the pick again. The flame chart keeps the plain function: it is no Lit host, and rests the emphasis itself. --- .../src/components/InspectorTabController.ts | 54 ++++++++ .../ColumnSettingsController.test.ts | 30 +---- .../__tests__/InspectorTabController.test.ts | 122 ++++++++++++++++++ .../__tests__/controllerHostStub.ts | 32 +++++ .../analysis/components/AnalysisView.ts | 40 +++--- .../call-tree/components/CalltreeView.ts | 41 +++--- .../database/components/DatabaseView.ts | 56 +++----- 7 files changed, 266 insertions(+), 109 deletions(-) create mode 100644 log-viewer/src/components/InspectorTabController.ts create mode 100644 log-viewer/src/components/__tests__/InspectorTabController.test.ts create mode 100644 log-viewer/src/components/__tests__/controllerHostStub.ts diff --git a/log-viewer/src/components/InspectorTabController.ts b/log-viewer/src/components/InspectorTabController.ts new file mode 100644 index 000000000..b3f803bea --- /dev/null +++ b/log-viewer/src/components/InspectorTabController.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +import type { DetailSource } from '../core/events/EventBus.js'; +import { InspectorEmphasis } from './inspectorEmphasis.js'; +import { wireInspectorTab, type InspectorTabSync } from './inspectorTab.js'; + +/** + * Subscribes a Lit view to the inspector for as long as it is connected, and + * holds the emphasis the two of them share. + * + * The emphasis belongs here rather than to the view because nothing outside the + * subscription decides what it holds: the view only ever drops a pick that went + * with its own selection, through {@link dropPick}. {@link wireInspectorTab} is + * still the way in for a view that is no `ReactiveControllerHost` โ€” the flame + * chart, which is a plain class and rests the emphasis on a frame of its own. + */ +export class InspectorTabController implements ReactiveController { + private readonly _emphasis = new InspectorEmphasis(); + private readonly _source: DetailSource; + private readonly _sync: InspectorTabSync; + private _off: (() => void) | null = null; + + constructor(host: ReactiveControllerHost, source: DetailSource, sync: InspectorTabSync) { + this._source = source; + this._sync = sync; + host.addController(this); + } + + hostConnected(): void { + this._off = wireInspectorTab(this._source, this._emphasis, this._sync); + // A pick outlives a detach, and the view came back with nothing lit. + const emphasised = this._emphasis.current(); + if (emphasised.length) { + this._sync.mark(emphasised); + } + } + + hostDisconnected(): void { + this._off?.(); + this._off = null; + } + + /** + * Drop a pick a picked inspector row left in the view, and mark what is left. + * For a view whose own selection has gone: the mark was never a selection of + * its own. + */ + dropPick(): void { + this._sync.mark(this._emphasis.pick([])); + } +} diff --git a/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts b/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts index 9cbd45c3d..decf37188 100644 --- a/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts +++ b/log-viewer/src/components/__tests__/ColumnSettingsController.test.ts @@ -2,7 +2,6 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ import { beforeEach, describe, expect, it } from '@jest/globals'; -import type { ReactiveController, ReactiveControllerHost } from 'lit'; import type { Tabulator } from 'tabulator-tables'; // The controller reads and writes settings through the extension host, which this @@ -17,6 +16,7 @@ jest.mock('../../features/settings/Settings.js', () => ({ import { ColumnSettingsController } from '../ColumnSettingsController.js'; import { getVisibleFields, type ColumnView } from '../../tabulator/ColumnViews.js'; +import { FakeHost } from './controllerHostStub.js'; /** What the extension host would answer with. */ let stored: object = {}; @@ -67,34 +67,6 @@ function fakeTable(laidOut = true): Tabulator { } as unknown as Tabulator; } -/** The least a `ReactiveController` needs of its host. */ -class FakeHost implements ReactiveControllerHost { - readonly controllers: ReactiveController[] = []; - updates = 0; - - addController(controller: ReactiveController): void { - this.controllers.push(controller); - } - removeController(): void {} - requestUpdate(): void { - this.updates++; - } - get updateComplete(): Promise { - return Promise.resolve(true); - } - - connect(): void { - for (const controller of this.controllers) { - controller.hostConnected?.(); - } - } - disconnect(): void { - for (const controller of this.controllers) { - controller.hostDisconnected?.(); - } - } -} - /** A connected controller over `table`, with settings already read. */ async function connected( table: Tabulator | null = fakeTable(), diff --git a/log-viewer/src/components/__tests__/InspectorTabController.test.ts b/log-viewer/src/components/__tests__/InspectorTabController.test.ts new file mode 100644 index 000000000..ad98721fd --- /dev/null +++ b/log-viewer/src/components/__tests__/InspectorTabController.test.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; +import { eventBus } from '../../core/events/EventBus.js'; +import { InspectorTabController } from '../InspectorTabController.js'; +import { FakeHost } from './controllerHostStub.js'; + +/** Every mark the view was asked for, in order. */ +let marked: readonly number[][] = []; +let cleared = 0; + +function controllerFor(host: FakeHost): InspectorTabController { + return new InspectorTabController(host, 'analysis', { + mark: (eventIndexes) => { + marked = [...marked, [...eventIndexes]]; + }, + reveal: () => {}, + clear: () => { + cleared++; + }, + }); +} + +describe('InspectorTabController', () => { + let host: FakeHost; + let inspector: InspectorTabController; + + beforeEach(() => { + marked = []; + cleared = 0; + host = new FakeHost(); + inspector = controllerFor(host); + }); + + // The bus outlives the host, so a test that connects has to let go. + afterEach(() => { + host.disconnect(); + }); + + it('hears nothing until the host connects', () => { + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1], sticky: false }); + + expect(marked).toEqual([]); + }); + + it('marks the frames the inspector points at', () => { + host.connect(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1, 2], sticky: false }); + + expect(marked).toEqual([[1, 2]]); + }); + + it('keeps a picked row lit while the pointer is elsewhere', () => { + host.connect(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [3], sticky: true }); + // The pointer leaves, which reports no frames of its own. + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [], sticky: false }); + + expect(marked).toEqual([[3], [3]]); + }); + + it('drops a pick the view no longer holds a selection for', () => { + host.connect(); + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [3], sticky: true }); + + inspector.dropPick(); + + expect(marked).toEqual([[3], []]); + }); + + it('answers another tab for nothing', () => { + host.connect(); + + eventBus.emit('inspector:locate', { source: 'calltree', eventIndexes: [1], sticky: false }); + + expect(marked).toEqual([]); + }); + + it('stops at a detach and hears again after a re-attach', () => { + host.connect(); + host.disconnect(); + + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1], sticky: false }); + expect(marked).toEqual([]); + + host.connect(); + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [1], sticky: false }); + + expect(marked).toEqual([[1]]); + }); + + it('lights a pick again for a view that comes back', () => { + host.connect(); + eventBus.emit('inspector:locate', { source: 'analysis', eventIndexes: [3], sticky: true }); + host.disconnect(); + marked = []; + + host.connect(); + + // The inspector still shows the row picked, so the view has to show it too. + expect(marked).toEqual([[3]]); + }); + + it('marks nothing for a view connecting with no pick to show', () => { + host.connect(); + + expect(marked).toEqual([]); + }); + + it('clears the view where the app-wide clear reaches its tab', () => { + host.connect(); + + eventBus.emit('selection:clear', { source: 'analysis' }); + + expect(cleared).toBe(1); + // The pick goes with the selection, so the mark goes out too. + expect(marked).toEqual([[]]); + }); +}); diff --git a/log-viewer/src/components/__tests__/controllerHostStub.ts b/log-viewer/src/components/__tests__/controllerHostStub.ts new file mode 100644 index 000000000..a0779f9df --- /dev/null +++ b/log-viewer/src/components/__tests__/controllerHostStub.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +/** + * The least a `ReactiveController` needs of its host, plus the two calls a test + * makes for Lit: `connect` and `disconnect` run the controllers' lifecycle. + */ +export class FakeHost implements ReactiveControllerHost { + readonly controllers: ReactiveController[] = []; + + addController(controller: ReactiveController): void { + this.controllers.push(controller); + } + removeController(): void {} + requestUpdate(): void {} + get updateComplete(): Promise { + return Promise.resolve(true); + } + + connect(): void { + for (const controller of this.controllers) { + controller.hostConnected?.(); + } + } + disconnect(): void { + for (const controller of this.controllers) { + controller.hostDisconnected?.(); + } + } +} diff --git a/log-viewer/src/features/analysis/components/AnalysisView.ts b/log-viewer/src/features/analysis/components/AnalysisView.ts index fa5b2bbde..231f28f0d 100644 --- a/log-viewer/src/features/analysis/components/AnalysisView.ts +++ b/log-viewer/src/features/analysis/components/AnalysisView.ts @@ -22,8 +22,8 @@ import { rowDetailSelection, rowFrames, } from '../../../components/locatedRow.js'; -import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { revealFirstOf, wireInspectorTab } from '../../../components/inspectorTab.js'; +import { InspectorTabController } from '../../../components/InspectorTabController.js'; +import { revealFirstOf } from '../../../components/inspectorTab.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { eventByEventIndex } from '../../../core/utility/EventSearch.js'; import { isVisible } from '../../../core/utility/Util.js'; @@ -150,10 +150,9 @@ export class AnalysisView extends LitElement { /** Guards the programmatic select made on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); - private _inspectorUnsubscribe: (() => void) | null = null; + private _locatedRow = new LocatedRowMarker(); private _locateIds = new LocatedRowIds(); - private _emphasis = new InspectorEmphasis(); private readonly _findBus = new DomListenerController(this, document, { 'lv-find': (e) => void this._find(e), @@ -161,33 +160,30 @@ export class AnalysisView extends LitElement { 'lv-find-close': (e) => void this._find(e), }); + private readonly _inspector = new InspectorTabController(this, 'analysis', { + // A row is a method bucket rather than one event, so a frame is translated + // into the paths of the rows it heads. + mark: (eventIndexes) => this._markLocated(eventIndexes), + // An inspector finding names one event; the grid holds it in the bucket for + // its method, so that bucket is what gets revealed. + reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), + clear: () => { + // The table reports the clear itself, which is what reaches the inspector. + this.analysisTable?.deselectRow(); + }, + // A row buckets calls, so a merged pick moves to the first of them. + revealMerged: revealFirstOf((eventIndex, signal) => this._revealEventIndex(eventIndex, signal)), + }); + override connectedCallback(): void { super.connectedCallback(); this._categoryColoringOff = wireCategoryColoring(this); - this._inspectorUnsubscribe = wireInspectorTab('analysis', this._emphasis, { - // A row is a method bucket rather than one event, so a frame is translated - // into the paths of the rows it heads. - mark: (eventIndexes) => this._markLocated(eventIndexes), - // An inspector finding names one event; the grid holds it in the bucket for - // its method, so that bucket is what gets revealed. - reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), - clear: () => { - // The table reports the clear itself, which is what reaches the inspector. - this.analysisTable?.deselectRow(); - }, - // A row buckets calls, so a merged pick moves to the first of them. - revealMerged: revealFirstOf((eventIndex, signal) => - this._revealEventIndex(eventIndex, signal), - ), - }); } disconnectedCallback(): void { super.disconnectedCallback(); this._categoryColoringOff?.(); this._categoryColoringOff = null; - this._inspectorUnsubscribe?.(); - this._inspectorUnsubscribe = null; this._locatedRow.clear(); } diff --git a/log-viewer/src/features/call-tree/components/CalltreeView.ts b/log-viewer/src/features/call-tree/components/CalltreeView.ts index 96fff458a..65164cc78 100644 --- a/log-viewer/src/features/call-tree/components/CalltreeView.ts +++ b/log-viewer/src/features/call-tree/components/CalltreeView.ts @@ -66,8 +66,8 @@ import { rowIndexStamper, rowFrames, } from '../../../components/locatedRow.js'; -import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { revealFirstOf, wireInspectorTab } from '../../../components/inspectorTab.js'; +import { InspectorTabController } from '../../../components/InspectorTabController.js'; +import { revealFirstOf } from '../../../components/inspectorTab.js'; import { createTimeOrderTable } from './TimeOrderTable.js'; /** Time Order keys its rows by event index; the grouped views key theirs by the @@ -176,11 +176,9 @@ export class CalltreeView extends LitElement { /** Guards the programmatic select made on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); - private _inspectorUnsubscribe: (() => void) | null = null; + private _locatedRow = new LocatedRowMarker(); private _locateIds = new LocatedRowIds(); - /** Which of the inspector's reports the mark follows. */ - private _emphasis = new InspectorEmphasis(); private readonly _documentBus = new DomListenerController< FindEventMap & CalltreeNavigationEventMap @@ -191,24 +189,23 @@ export class CalltreeView extends LitElement { 'lv-find-close': (e) => void this._find(e), }); + private readonly _inspector = new InspectorTabController(this, 'calltree', { + mark: (eventIndexes) => this._markLocated(eventIndexes), + reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), + clear: () => { + // The table reports the clear itself, which is what reaches the inspector. + for (const table of this._tables) { + table.deselectRow(); + } + }, + // A picked row merges calls, so the mark shows all of them while the view + // moves to the first of them. + revealMerged: revealFirstOf((eventIndex, signal) => this._revealEventIndex(eventIndex, signal)), + }); + override connectedCallback(): void { super.connectedCallback(); this._categoryColoringOff = wireCategoryColoring(this); - this._inspectorUnsubscribe = wireInspectorTab('calltree', this._emphasis, { - mark: (eventIndexes) => this._markLocated(eventIndexes), - reveal: (eventIndex, signal) => this._revealEventIndex(eventIndex, signal), - clear: () => { - // The table reports the clear itself, which is what reaches the inspector. - for (const table of this._tables) { - table.deselectRow(); - } - }, - // A picked row merges calls, so the mark shows all of them while the view - // moves to the first of them. - revealMerged: revealFirstOf((eventIndex, signal) => - this._revealEventIndex(eventIndex, signal), - ), - }); // A detach destroyed the tables, and `updated` builds only for the log's // arrival. With a log already in hand this is a re-attach, and the build's @@ -224,8 +221,6 @@ export class CalltreeView extends LitElement { this._visibilityWait = null; this._categoryColoringOff?.(); this._categoryColoringOff = null; - this._inspectorUnsubscribe?.(); - this._inspectorUnsubscribe = null; this._destroyCurrentTable(); } @@ -1088,7 +1083,7 @@ export class CalltreeView extends LitElement { if (!selection) { // The selection went with it, and so does a mark a picked inspector row // left here โ€” it was never a selection of this table. - this._markLocated(this._emphasis.pick([])); + this._inspector.dropPick(); } eventBus.emit('detail:select', { source, diff --git a/log-viewer/src/features/database/components/DatabaseView.ts b/log-viewer/src/features/database/components/DatabaseView.ts index 2961723b1..dc6c785db 100644 --- a/log-viewer/src/features/database/components/DatabaseView.ts +++ b/log-viewer/src/features/database/components/DatabaseView.ts @@ -18,8 +18,7 @@ import { DomListenerController } from '../../../core/events/DomListenerControlle import { eventBus, type StatementType } from '../../../core/events/EventBus.js'; import type { DbFindResultsEventDetail, FindEventMap } from '../../find/findEvents.js'; import { apexLimitTimeSeries } from '../../timeline/optimised/apex-limit-series.js'; -import { InspectorEmphasis } from '../../../components/inspectorEmphasis.js'; -import { wireInspectorTab } from '../../../components/inspectorTab.js'; +import { InspectorTabController } from '../../../components/InspectorTabController.js'; import { SelectionEchoGuard } from '../../../core/events/SelectionEchoGuard.js'; import { formatInteger, isVisible } from '../../../core/utility/Util.js'; import { soslRowsMetric } from '../limits.js'; @@ -87,12 +86,8 @@ export class DatabaseView extends LitElement { }; findMap = {}; - private _offInspector: (() => void) | null = null; - /** Guards the selects this view makes on the inspector's behalf. */ private _echoGuard = new SelectionEchoGuard(); - /** Which of the inspector's reports the grids' mark follows. */ - private _emphasis = new InspectorEmphasis(); private readonly _findBus = new DomListenerController(this, document, { 'lv-find': (e) => this._find(e.detail.count), @@ -100,28 +95,25 @@ export class DatabaseView extends LitElement { 'db-find-results': (e) => this._findResults(e), }); - override connectedCallback(): void { - super.connectedCallback(); - this._offInspector = wireInspectorTab('database', this._emphasis, { - mark: (eventIndexes) => this._markLocated(eventIndexes), - // The eventIndex belongs to exactly one grid, so each is offered it in turn - // until one owns it. - reveal: (eventIndex) => { - const views = this._views; - this._echoGuard.run(() => { - const owner = views.find((view) => view?.selectByEventIndex(eventIndex)); - if (owner) { - views.filter((view) => view !== owner).forEach((view) => view?.deselectRows()); - } - }); - }, - clear: () => { - // Only one grid holds the selection, and its report of the clear reaches - // the inspector the same way a click does. - this._views.forEach((view) => view?.deselectRows()); - }, - }); - } + private readonly _inspector = new InspectorTabController(this, 'database', { + mark: (eventIndexes) => this._markLocated(eventIndexes), + // The eventIndex belongs to exactly one grid, so each is offered it in turn + // until one owns it. + reveal: (eventIndex) => { + const views = this._views; + this._echoGuard.run(() => { + const owner = views.find((view) => view?.selectByEventIndex(eventIndex)); + if (owner) { + views.filter((view) => view !== owner).forEach((view) => view?.deselectRows()); + } + }); + }, + clear: () => { + // Only one grid holds the selection, and its report of the clear reaches + // the inspector the same way a click does. + this._views.forEach((view) => view?.deselectRows()); + }, + }); /** Offers the mark to every grid, since one of them owns the statement. */ private _markLocated(eventIndexes: readonly number[]): void { @@ -142,7 +134,7 @@ export class DatabaseView extends LitElement { if (eventIndex === null) { // A mark a picked inspector row left here goes with the selection: it was // never a selection of these grids. - this._markLocated(this._emphasis.pick([])); + this._inspector.dropPick(); eventBus.emit('detail:select', { source: 'database', selection: null }); return; } @@ -159,12 +151,6 @@ export class DatabaseView extends LitElement { }); } - disconnectedCallback(): void { - super.disconnectedCallback(); - this._offInspector?.(); - this._offInspector = null; - } - firstUpdated(): void { // One listener for all three grids: their reports bubble to this shadow // root and stop there, so grids added later are heard without rebinding. From 5453095999f0fb101499163188ca8525f08e0ac1 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:23:26 +0100 Subject: [PATCH 61/61] fix(log-viewer): release a visibility wait's listener when it resolves The abort listener stayed on the signal after the element came on screen, so the observer and the element it watched were held until the controller went. --- log-viewer/src/core/utility/Util.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/log-viewer/src/core/utility/Util.ts b/log-viewer/src/core/utility/Util.ts index 8c7a6e882..780dc5cee 100644 --- a/log-viewer/src/core/utility/Util.ts +++ b/log-viewer/src/core/utility/Util.ts @@ -194,6 +194,8 @@ export async function isVisible( const observer = new IntersectionObserver((entries, observerInstance) => { for (const entry of entries) { if (entry.isIntersecting) { + // The signal outlives this call, so the listener goes with the wait. + signal?.removeEventListener('abort', release); resolve(true); observerInstance.disconnect(); return; @@ -201,14 +203,11 @@ export async function isVisible( } }, options); - signal?.addEventListener( - 'abort', - () => { - observer.disconnect(); - resolve(false); - }, - { once: true }, - ); + const release = (): void => { + observer.disconnect(); + resolve(false); + }; + signal?.addEventListener('abort', release, { once: true }); observer.observe(element); });