From 8d1cb861cf2389311e87d189bc1f250f83c349e4 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:07:01 +0000 Subject: [PATCH 1/6] chore: update codicons version and add new icons --- log-viewer/package.json | 2 +- log-viewer/src/styles/codicon.css | 237 ++++++++++++++++++++++++++++-- 2 files changed, 223 insertions(+), 16 deletions(-) diff --git a/log-viewer/package.json b/log-viewer/package.json index 34bb0bca..9ede3f7c 100644 --- a/log-viewer/package.json +++ b/log-viewer/package.json @@ -6,7 +6,7 @@ "version": "0.1.0", "dependencies": { "@apexdevtools/apex-parser": "^4.4.0", - "@vscode/codicons": "^0.0.36", + "@vscode/codicons": "^0.0.44", "@vscode/webview-ui-toolkit": "^1.4.0", "lit": "^3.3.1", "pixi.js": "^8.5.2", diff --git a/log-viewer/src/styles/codicon.css b/log-viewer/src/styles/codicon.css index b36799a7..960679d0 100644 --- a/log-viewer/src/styles/codicon.css +++ b/log-viewer/src/styles/codicon.css @@ -112,15 +112,6 @@ .codicon-person-filled:before { content: '\ea67'; } -.codicon-git-branch:before { - content: '\ea68'; -} -.codicon-git-branch-create:before { - content: '\ea68'; -} -.codicon-git-branch-delete:before { - content: '\ea68'; -} .codicon-source-control:before { content: '\ea68'; } @@ -265,9 +256,6 @@ .codicon-file:before { content: '\ea7b'; } -.codicon-file-text:before { - content: '\ea7b'; -} .codicon-more:before { content: '\ea7c'; } @@ -811,6 +799,9 @@ .codicon-json:before { content: '\eb0f'; } +.codicon-bracket:before { + content: '\eb0f'; +} .codicon-kebab-vertical:before { content: '\eb10'; } @@ -1489,9 +1480,6 @@ .codicon-pie-chart:before { content: '\ebe4'; } -.codicon-bracket:before { - content: '\eb0f'; -} .codicon-bracket-dot:before { content: '\ebe5'; } @@ -1753,6 +1741,225 @@ .codicon-sort-percentage:before { content: '\ec33'; } +.codicon-attach:before { + content: '\ec34'; +} +.codicon-go-to-editing-session:before { + content: '\ec35'; +} +.codicon-edit-session:before { + content: '\ec36'; +} +.codicon-code-review:before { + content: '\ec37'; +} +.codicon-copilot-warning:before { + content: '\ec38'; +} +.codicon-python:before { + content: '\ec39'; +} +.codicon-copilot-large:before { + content: '\ec3a'; +} +.codicon-copilot-warning-large:before { + content: '\ec3b'; +} +.codicon-keyboard-tab:before { + content: '\ec3c'; +} +.codicon-copilot-blocked:before { + content: '\ec3d'; +} +.codicon-copilot-not-connected:before { + content: '\ec3e'; +} +.codicon-flag:before { + content: '\ec3f'; +} +.codicon-lightbulb-empty:before { + content: '\ec40'; +} +.codicon-symbol-method-arrow:before { + content: '\ec41'; +} +.codicon-copilot-unavailable:before { + content: '\ec42'; +} +.codicon-repo-pinned:before { + content: '\ec43'; +} +.codicon-keyboard-tab-above:before { + content: '\ec44'; +} +.codicon-keyboard-tab-below:before { + content: '\ec45'; +} +.codicon-git-pull-request-done:before { + content: '\ec46'; +} +.codicon-mcp:before { + content: '\ec47'; +} +.codicon-extensions-large:before { + content: '\ec48'; +} +.codicon-layout-panel-dock:before { + content: '\ec49'; +} +.codicon-layout-sidebar-left-dock:before { + content: '\ec4a'; +} +.codicon-layout-sidebar-right-dock:before { + content: '\ec4b'; +} +.codicon-copilot-in-progress:before { + content: '\ec4c'; +} +.codicon-copilot-error:before { + content: '\ec4d'; +} +.codicon-copilot-success:before { + content: '\ec4e'; +} +.codicon-chat-sparkle:before { + content: '\ec4f'; +} +.codicon-search-sparkle:before { + content: '\ec50'; +} +.codicon-edit-sparkle:before { + content: '\ec51'; +} +.codicon-copilot-snooze:before { + content: '\ec52'; +} +.codicon-send-to-remote-agent:before { + content: '\ec53'; +} +.codicon-comment-discussion-sparkle:before { + content: '\ec54'; +} +.codicon-chat-sparkle-warning:before { + content: '\ec55'; +} +.codicon-chat-sparkle-error:before { + content: '\ec56'; +} +.codicon-collection:before { + content: '\ec57'; +} +.codicon-new-collection:before { + content: '\ec58'; +} +.codicon-thinking:before { + content: '\ec59'; +} +.codicon-build:before { + content: '\ec5a'; +} +.codicon-comment-discussion-quote:before { + content: '\ec5b'; +} +.codicon-cursor:before { + content: '\ec5c'; +} +.codicon-eraser:before { + content: '\ec5d'; +} +.codicon-file-text:before { + content: '\ec5e'; +} +.codicon-quotes:before { + content: '\ec60'; +} +.codicon-rename:before { + content: '\ec61'; +} +.codicon-run-with-deps:before { + content: '\ec62'; +} +.codicon-debug-connected:before { + content: '\ec63'; +} +.codicon-strikethrough:before { + content: '\ec64'; +} +.codicon-open-in-product:before { + content: '\ec65'; +} +.codicon-index-zero:before { + content: '\ec66'; +} +.codicon-agent:before { + content: '\ec67'; +} +.codicon-edit-code:before { + content: '\ec68'; +} +.codicon-repo-selected:before { + content: '\ec69'; +} +.codicon-skip:before { + content: '\ec6a'; +} +.codicon-merge-into:before { + content: '\ec6b'; +} +.codicon-git-branch-changes:before { + content: '\ec6c'; +} +.codicon-git-branch-staged-changes:before { + content: '\ec6d'; +} +.codicon-git-branch-conflicts:before { + content: '\ec6e'; +} +.codicon-git-branch:before { + content: '\ec6f'; +} +.codicon-git-branch-create:before { + content: '\ec6f'; +} +.codicon-git-branch-delete:before { + content: '\ec6f'; +} +.codicon-search-large:before { + content: '\ec70'; +} +.codicon-terminal-git-bash:before { + content: '\ec71'; +} +.codicon-window-active:before { + content: '\ec72'; +} +.codicon-forward:before { + content: '\ec73'; +} +.codicon-download:before { + content: '\ec74'; +} +.codicon-clockface:before { + content: '\ec75'; +} +.codicon-unarchive:before { + content: '\ec76'; +} +.codicon-session-in-progress:before { + content: '\ec77'; +} +.codicon-collection-small:before { + content: '\ec78'; +} +.codicon-vm-small:before { + content: '\ec79'; +} +.codicon-cloud-small:before { + content: '\ec7a'; +} .codicon-git-fetch:before { content: '\f101'; } +.codicon-vm-pending:before { + content: '\f102'; +} From 149dbccea9074065c5132fd8cd5051df915f12a3 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:17:47 +0000 Subject: [PATCH 2/6] feat: implement wall-clock time display in timeline features --- .../__tests__/ApexLogParser.test.ts | 48 ++++++++++++++ apex-log-parser/src/LogEvents.ts | 40 +++++++++-- log-viewer/src/core/utility/Util.ts | 43 ++++++++++++ .../src/core/utility/__tests__/Util.test.ts | 61 +++++++++++++++++ .../timeline/__tests__/tooltip.test.ts | 66 +++++++++++++++++++ .../timeline/components/TimelineFlameChart.ts | 7 ++ .../timeline/components/TimelineView.ts | 54 ++++++++++++++- .../timeline/optimised/ApexLogTimeline.ts | 14 ++++ .../features/timeline/optimised/FlameChart.ts | 21 ++++++ .../optimised/TimelineTooltipManager.ts | 26 +++++++- .../optimised/time-axis/MeshAxisRenderer.ts | 30 ++++++++- .../timeline/types/flamechart.types.ts | 5 ++ 12 files changed, 406 insertions(+), 9 deletions(-) create mode 100644 log-viewer/src/core/utility/__tests__/Util.test.ts diff --git a/apex-log-parser/__tests__/ApexLogParser.test.ts b/apex-log-parser/__tests__/ApexLogParser.test.ts index e244752d..7ec51f90 100644 --- a/apex-log-parser/__tests__/ApexLogParser.test.ts +++ b/apex-log-parser/__tests__/ApexLogParser.test.ts @@ -1480,3 +1480,51 @@ describe('Aggregating Totals', () => { ); }); }); + +describe('ApexLog.startTime tests', () => { + it('should parse startTime from first child log line', () => { + const logData = + '10:29:24.6 (6329577)|EXECUTION_STARTED\n' + + '10:29:24.6 (6400000)|METHOD_ENTRY|[1]|01p000000000000|MyClass.myMethod()\n' + + '10:29:24.7 (7000000)|METHOD_EXIT|[1]|01p000000000000|MyClass.myMethod()\n' + + '10:29:24.7 (7100000)|EXECUTION_FINISHED'; + + const log = parse(logData); + expect(log.startTime).toBe(37764600); + }); + + it('should set startTime to null when no children', () => { + const log = parse(''); + expect(log.startTime).toBeNull(); + }); + + it('should parse midnight time (00:00:00.0) as 0', () => { + const logData = '00:00:00.0 (100)|EXECUTION_STARTED\n' + '00:00:00.0 (200)|EXECUTION_FINISHED'; + + const log = parse(logData); + expect(log.startTime).toBe(0); + }); + + it('should parse end-of-day time (23:59:59.9)', () => { + const logData = '23:59:59.9 (100)|EXECUTION_STARTED\n' + '23:59:59.9 (200)|EXECUTION_FINISHED'; + + const log = parse(logData); + expect(log.startTime).toBe(86399900); + }); + + it('should parse multi-digit fraction (.12) as 120ms', () => { + const logData = + '14:30:05.12 (100)|EXECUTION_STARTED\n' + '14:30:05.12 (200)|EXECUTION_FINISHED'; + + const log = parse(logData); + expect(log.startTime).toBe(52205120); + }); + + it('should parse 3-digit fraction (.123) as 123ms', () => { + const logData = + '14:30:05.123 (100)|EXECUTION_STARTED\n' + '14:30:05.123 (200)|EXECUTION_FINISHED'; + + const log = parse(logData); + expect(log.startTime).toBe(52205123); + }); +}); diff --git a/apex-log-parser/src/LogEvents.ts b/apex-log-parser/src/LogEvents.ts index a973dae6..2ded1810 100644 --- a/apex-log-parser/src/LogEvents.ts +++ b/apex-log-parser/src/LogEvents.ts @@ -350,6 +350,13 @@ export class ApexLog extends LogEvent { snapshots: [], }; + /** + * The wall-clock time of the first event, in milliseconds since midnight. + * Parsed from the `HH:MM:SS.f` portion of the first log line. + * Null if no wall-clock time could be parsed. + */ + startTime: number | null = null; + /** * The endtime with nodes of 0 duration excluded */ @@ -360,10 +367,16 @@ export class ApexLog extends LogEvent { } setTimes() { - this.timestamp = - this.children.find((child) => { - return child.timestamp; - })?.timestamp || 0; + const firstChild = this.children.find((child) => { + return child.timestamp; + }); + this.timestamp = firstChild?.timestamp || 0; + + // Parse wall-clock time from the first child's log line (HH:MM:SS.f before the '(') + if (firstChild?.logLine) { + this.startTime = parseWallClockTime(firstChild.logLine); + } + // We do not just want to use the very last exitStamp because it could be CUMULATIVE_USAGE which is not really part of the code execution time but does have a later time. let endTime; const reverseLen = this.children.length - 1; @@ -412,6 +425,25 @@ export function parseVfNamespace(text: string): string { return text.substring(secondSlash + 1, sep); } +/** + * Parses the wall-clock time from a log line's timestamp portion. + * Log lines start with `HH:MM:SS.f (nanoseconds)|...` + * Returns milliseconds since midnight, or null if parsing fails. + */ +function parseWallClockTime(logLine: string): number | null { + const match = /^(\d{1,2}):(\d{2}):(\d{2})\.(\d+)\s/.exec(logLine); + if (!match) { + return null; + } + + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const fraction = Number(match[4]!.padEnd(3, '0')); + + return (hours * 3600 + minutes * 60 + seconds) * 1000 + fraction; +} + export function parseRows(text: string | null | undefined): number { if (!text) { return 0; diff --git a/log-viewer/src/core/utility/Util.ts b/log-viewer/src/core/utility/Util.ts index b5f9ddd7..141bf66c 100644 --- a/log-viewer/src/core/utility/Util.ts +++ b/log-viewer/src/core/utility/Util.ts @@ -90,6 +90,49 @@ export function formatTimeRange(startTimeNs: number, endTimeNs: number): string return `${formatDuration(startTimeNs)} → ${formatDuration(endTimeNs)}`; } +/** + * Formats milliseconds-since-midnight to `HH:MM:SS.mmm` wall-clock time string. + * + * @param ms - Milliseconds since midnight (0–86,400,000) + * @returns Formatted time string like "14:30:05.122" + */ +export function formatWallClockTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const millis = Math.round(ms % 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600) % 24; + + return ( + String(hours).padStart(2, '0') + + ':' + + String(minutes).padStart(2, '0') + + ':' + + String(seconds).padStart(2, '0') + + '.' + + String(millis).padStart(3, '0') + ); +} + +/** + * Computes the wall-clock time (ms since midnight) for any event given: + * - The wall-clock start time of the log (from ApexLog.startTime) + * - The nanosecond timestamp of the first event (ApexLog.timestamp) + * - The nanosecond timestamp of the target event + * + * @param startTimeMs - Wall-clock time of the first event, in ms since midnight + * @param firstTimestampNs - Nanosecond timestamp of the first event + * @param eventTimestampNs - Nanosecond timestamp of the event to compute + * @returns Wall-clock time in milliseconds since midnight + */ +export function computeWallClockMs( + startTimeMs: number, + firstTimestampNs: number, + eventTimestampNs: number, +): number { + return startTimeMs + (eventTimestampNs - firstTimestampNs) / 1_000_000; +} + export function debounce(callBack: (...args: T) => unknown) { let requestId: number = 0; diff --git a/log-viewer/src/core/utility/__tests__/Util.test.ts b/log-viewer/src/core/utility/__tests__/Util.test.ts new file mode 100644 index 00000000..0e195f97 --- /dev/null +++ b/log-viewer/src/core/utility/__tests__/Util.test.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2020 Certinia Inc. All rights reserved. + */ + +import { computeWallClockMs, formatWallClockTime } from '../Util.js'; + +describe('formatWallClockTime', () => { + it('should format midnight as 00:00:00.000', () => { + expect(formatWallClockTime(0)).toBe('00:00:00.000'); + }); + + it('should format a mid-day time', () => { + // 14:30:05.122 = (14*3600 + 30*60 + 5) * 1000 + 122 = 52205122 + expect(formatWallClockTime(52205122)).toBe('14:30:05.122'); + }); + + it('should format end-of-day time', () => { + // 23:59:59.999 + expect(formatWallClockTime(86399999)).toBe('23:59:59.999'); + }); + + it('should pad single-digit hours, minutes, seconds', () => { + // 01:02:03.004 + expect(formatWallClockTime(3723004)).toBe('01:02:03.004'); + }); + + it('should handle exact seconds (no fractional ms)', () => { + // 10:00:00.000 + expect(formatWallClockTime(36000000)).toBe('10:00:00.000'); + }); + + it('should handle sub-millisecond precision by rounding', () => { + // 1000.5 ms → rounds to 1001 ms fraction → 00:00:01.001 + expect(formatWallClockTime(1000.5)).toBe('00:00:01.001'); + }); +}); + +describe('computeWallClockMs', () => { + it('should return startTime when event is the first event', () => { + const result = computeWallClockMs(37764600, 6329577, 6329577); + expect(result).toBe(37764600); + }); + + it('should compute wall-clock for a later event', () => { + // Event is 1ms (1,000,000 ns) after first event + const result = computeWallClockMs(37764600, 6329577, 7329577); + expect(result).toBe(37764601); + }); + + it('should compute wall-clock for an event 1 second later', () => { + // 1 second = 1,000,000,000 ns + const result = computeWallClockMs(37764600, 6329577, 1006329577); + expect(result).toBe(37765600); + }); + + it('should handle fractional nanosecond differences', () => { + // 500,000 ns = 0.5 ms + const result = computeWallClockMs(0, 0, 500000); + expect(result).toBe(0.5); + }); +}); diff --git a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts index d5d8c926..51427ba6 100644 --- a/log-viewer/src/features/timeline/__tests__/tooltip.test.ts +++ b/log-viewer/src/features/timeline/__tests__/tooltip.test.ts @@ -316,6 +316,72 @@ describe('TimelineTooltipManager', () => { tooltipManager.hide(); }); + + it('should display wall-clock time row when apexLog has startTime', () => { + tooltipManager.destroy(); + const mockApexLog = { + startTime: 37764600, // 10:29:24.600 + timestamp: 6329577, // first event nanosecond offset + governorLimits: { + dmlStatements: { limit: 150 }, + dmlRows: { limit: 10000 }, + soqlQueries: { limit: 100 }, + queryRows: { limit: 50000 }, + soslQueries: { limit: 20 }, + }, + }; + + tooltipManager = new TimelineTooltipManager(container, { + categoryColors: {}, + cursorOffset: 10, + enableFlip: true, + apexLog: mockApexLog as never, + }); + + // Event at timestamp 6329577ns with duration 1,000,000ns + const event = createEvent(6329577, 1_000_000); + + tooltipManager.show(event, 100, 100); + + const tooltip = container.querySelector('#timeline-tooltip') as HTMLElement; + expect(tooltip.textContent).toContain('time:'); + expect(tooltip.textContent).toContain('10:29:24.600'); + // End time should be ~1ms later + expect(tooltip.textContent).toContain('10:29:24.601'); + expect(tooltip.textContent).toContain('→'); + + tooltipManager.hide(); + }); + + it('should not display wall-clock time row when apexLog has no startTime', () => { + tooltipManager.destroy(); + tooltipManager = new TimelineTooltipManager(container, { + categoryColors: {}, + cursorOffset: 10, + enableFlip: true, + apexLog: { startTime: null, timestamp: 0 } as never, + }); + + const event = createEvent(0, 1_000_000); + + tooltipManager.show(event, 100, 100); + + const tooltip = container.querySelector('#timeline-tooltip') as HTMLElement; + expect(tooltip.textContent).not.toContain('time:'); + + tooltipManager.hide(); + }); + + it('should not display wall-clock time row when no apexLog', () => { + const event = createEvent(0, 1_000_000); + + tooltipManager.show(event, 100, 100); + + const tooltip = container.querySelector('#timeline-tooltip') as HTMLElement; + expect(tooltip.textContent).not.toContain('time:'); + + tooltipManager.hide(); + }); }); describe('positioning - basic', () => { diff --git a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts index c8a3a2fe..33cbea7f 100644 --- a/log-viewer/src/features/timeline/components/TimelineFlameChart.ts +++ b/log-viewer/src/features/timeline/components/TimelineFlameChart.ts @@ -159,6 +159,13 @@ export class TimelineFlameChart extends LitElement { } } + /** + * Set the time display mode on the axis (called by parent TimelineView). + */ + public setTimeDisplayMode(mode: 'elapsed' | 'wallClock'): void { + this.apexLogTimeline?.setTimeDisplayMode(mode); + } + // ============================================================================ // CLEANUP // ============================================================================ diff --git a/log-viewer/src/features/timeline/components/TimelineView.ts b/log-viewer/src/features/timeline/components/TimelineView.ts index 9f3c8c5b..3878e650 100644 --- a/log-viewer/src/features/timeline/components/TimelineView.ts +++ b/log-viewer/src/features/timeline/components/TimelineView.ts @@ -1,8 +1,8 @@ /* * Copyright (c) 2023 Certinia Inc. All rights reserved. */ -import { LitElement, css, html } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; +import { LitElement, css, html, unsafeCSS } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; import type { ApexLog } from 'apex-log-parser'; import { VSCodeExtensionMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; @@ -12,7 +12,11 @@ import { type TimelineGroup, keyMap, setColors } from '../services/Timeline.js'; import { DEFAULT_THEME_NAME, type TimelineColors } from '../themes/Themes.js'; import { addCustomThemes, getTheme } from '../themes/ThemeSelector.js'; +import type { TimeDisplayMode } from '../types/flamechart.types.js'; +import type { TimelineFlameChart } from './TimelineFlameChart.js'; + // styles +import codiconStyles from '../../../styles/codicon.css'; import { globalStyles } from '../../../styles/global.styles.js'; // web components @@ -53,14 +57,23 @@ export class TimelineView extends LitElement { @state() private useLegacyTimeline: boolean | null = null; + @state() + private timeDisplayMode: TimeDisplayMode = 'elapsed'; + + @query('timeline-flame-chart') + private flameChartRef!: TimelineFlameChart; + constructor() { super(); } static styles = [ globalStyles, + unsafeCSS(codiconStyles), css` :host { + --button-icon-hover-background: var(--vscode-toolbar-hoverBackground); + display: flex; flex-direction: column; flex: 1; @@ -68,6 +81,19 @@ export class TimelineView extends LitElement { width: 100%; height: 90%; } + + .timeline-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + flex: 0 0 auto; + } + + vscode-button { + height: 22px; + width: 22px; + } `, ]; @@ -102,7 +128,24 @@ export class TimelineView extends LitElement { } if (!this.useLegacyTimeline) { - return html` + this.toggleTimeDisplay()} + > + + + ` + : ''} + `; } + private toggleTimeDisplay(): void { + this.timeDisplayMode = this.timeDisplayMode === 'elapsed' ? 'wallClock' : 'elapsed'; + this.flameChartRef?.setTimeDisplayMode(this.timeDisplayMode); + } + private setTheme(themeName: string) { this.activeTheme = themeName ?? DEFAULT_THEME_NAME; this.timelineKeys = this.toTimelineKeys(getTheme(themeName)); diff --git a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts index e476b063..11efd946 100644 --- a/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts +++ b/log-viewer/src/features/timeline/optimised/ApexLogTimeline.ts @@ -262,6 +262,20 @@ export class ApexLogTimeline { this.flamechart.requestRender(); } + /** + * Set time display mode (elapsed vs wall-clock) for axis labels. + * Only has effect when apexLog has a valid startTime. + */ + public setTimeDisplayMode(mode: 'elapsed' | 'wallClock'): void { + if (!this.apexLog) { + return; + } + + const startTime = this.apexLog.startTime ?? 0; + const firstTimestamp = this.apexLog.timestamp; + this.flamechart.setTimeDisplayMode(mode, startTime, firstTimestamp); + } + /** * Clean up resources. */ diff --git a/log-viewer/src/features/timeline/optimised/FlameChart.ts b/log-viewer/src/features/timeline/optimised/FlameChart.ts index d120e866..584679f0 100644 --- a/log-viewer/src/features/timeline/optimised/FlameChart.ts +++ b/log-viewer/src/features/timeline/optimised/FlameChart.ts @@ -813,6 +813,27 @@ export class FlameChart { this.updateMetricStripVisibility(); } + /** + * Set the time display mode for the axis (elapsed vs wall-clock). + * In wall-clock mode, axis labels show HH:MM:SS.mmm instead of elapsed ms. + */ + public setTimeDisplayMode( + mode: 'elapsed' | 'wallClock', + startTimeMs: number, + firstTimestampNs: number, + ): void { + if (this.axisRenderer instanceof MeshAxisRenderer) { + this.axisRenderer.setTimeDisplayMode(mode, startTimeMs, firstTimestampNs); + + if (!this.state) { + return; + } + this.state.needsRender = true; + this.state.renderDirty.background = true; + this.scheduleRender(); + } + } + /** * Update metric strip visibility based on whether there's data to display. * Hides the metric strip container and gap if no governor limit data exists. diff --git a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts index ba68e175..ca29456d 100644 --- a/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts +++ b/log-viewer/src/features/timeline/optimised/TimelineTooltipManager.ts @@ -10,7 +10,11 @@ */ import type { ApexLog, LogEvent } from 'apex-log-parser'; -import { formatDuration } from '../../../core/utility/Util.js'; +import { + computeWallClockMs, + formatDuration, + formatWallClockTime, +} from '../../../core/utility/Util.js'; import type { TimelineMarker } from '../types/flamechart.types.js'; /** @@ -276,6 +280,26 @@ export class TimelineTooltipManager { rows.push({ label: 'total:', value: val }); } + // Wall-clock time row (only if startTime is available) + const apexLog = this.options.apexLog; + if (apexLog?.startTime !== null && apexLog?.timestamp !== undefined) { + const startWallClock = computeWallClockMs( + apexLog.startTime, + apexLog.timestamp, + event.timestamp, + ); + let timeVal = formatWallClockTime(startWallClock); + if (event.exitStamp) { + const endWallClock = computeWallClockMs( + apexLog.startTime, + apexLog.timestamp, + event.exitStamp, + ); + timeVal += ` → ${formatWallClockTime(endWallClock)}`; + } + rows.push({ label: 'time:', value: timeVal }); + } + const govLimits = this.options.apexLog?.governorLimits; if (event.dmlCount.total) { rows.push({ diff --git a/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts b/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts index a563ac51..d9fa2b79 100644 --- a/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts +++ b/log-viewer/src/features/timeline/optimised/time-axis/MeshAxisRenderer.ts @@ -22,6 +22,7 @@ */ import { Container, Geometry, Mesh, Shader, Text } from 'pixi.js'; +import { formatWallClockTime } from '../../../../core/utility/Util.js'; import type { ViewportState } from '../../types/flamechart.types.js'; import { RectangleGeometry, type ViewportTransform } from '../RectangleGeometry.js'; import { createRectangleShader } from '../RectangleShader.js'; @@ -73,6 +74,13 @@ export class MeshAxisRenderer { /** Grid line color */ private gridLineColor: number; + /** Time display mode: 'elapsed' (default) or 'wallClock' */ + private displayMode: 'elapsed' | 'wallClock' = 'elapsed'; + /** Wall-clock time of the first event in ms since midnight (for wallClock mode) */ + private startTimeMs = 0; + /** Nanosecond timestamp of the first event (for wallClock mode) */ + private firstTimestampNs = 0; + constructor(container: Container, config?: Partial) { this.parentContainer = container; @@ -177,6 +185,20 @@ export class MeshAxisRenderer { } } + /** + * Set the time display mode for axis labels. + * In 'wallClock' mode, labels show wall-clock time (HH:MM:SS.mmm) instead of elapsed time. + */ + public setTimeDisplayMode( + mode: 'elapsed' | 'wallClock', + startTimeMs: number, + firstTimestampNs: number, + ): void { + this.displayMode = mode; + this.startTimeMs = startTimeMs; + this.firstTimestampNs = firstTimestampNs; + } + /** * Apply alpha to a color by pre-multiplying into ABGR format for the shader. * The shader expects colors in ABGR format with alpha in the high byte. @@ -382,6 +404,9 @@ export class MeshAxisRenderer { let rectIndex = 0; + const isWallClockDisplay = this.displayMode === 'wallClock'; + const isIntervalMsOrMore = tickInterval.interval >= NS_PER_MS; + // Render all ticks in range for (let i = firstTickIndex; i <= lastTickIndex; i++) { const time = i * tickInterval.interval; @@ -415,7 +440,10 @@ export class MeshAxisRenderer { // Add label at top if requested (only when showLabels is enabled) if (showLabels && shouldShowLabel && this.screenSpaceContainer) { const timeMs = time / NS_PER_MS; - const labelText = this.formatMilliseconds(timeMs); + const useWallClock = isWallClockDisplay && (isIntervalMsOrMore || time % NS_PER_MS === 0); + const labelText = useWallClock + ? formatWallClockTime(this.startTimeMs + (time - this.firstTimestampNs) / NS_PER_MS) + : this.formatMilliseconds(timeMs); // Only show label if not empty (skip zero) if (labelText) { diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index 61a9a0fe..cfce2f74 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -532,6 +532,11 @@ export class TimelineError extends Error { */ export type MarkerType = 'error' | 'skip' | 'unexpected'; +/** + * Controls whether the timeline axis shows elapsed time or wall-clock time. + */ +export type TimeDisplayMode = 'elapsed' | 'wallClock'; + /** * Represents a time range in the log where Marker occurred. * Extracted from ApexLog.logIssues during timeline initialization. From 8dccccf2b883b9c41ae6767ebb7bd1fff9fe5de6 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:24:52 +0000 Subject: [PATCH 3/6] docs: update changelog with wall-clock time toggle --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f870dbc..8082a4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Marker Navigation**: Click markers to select; arrow Left/Right to navigate between markers. - **Clear Selection**: Press `Escape` to deselect the current frame or marker. - **⏱️ Time Axis Auto-Spacing**: Markers intelligently and naturally auto-space as you zoom. + - **🕐 Wall-Clock Time**: Toggle between elapsed time and wall-clock time (HH:MM:SS.mmm) on the time axis. Click the clock button in the timeline toolbar to switch modes. Tooltips also show the wall-clock start → end time for each event. ([#685]) - **🔍 Search + Highlight**: Dims non-matches for fast scanning. - **Timeline Categories**: Redesigned timeline categories for clearer, more meaningful event grouping. ([#98]) - Apex (Apex code), Automation (Workflow, NBA), Callout, Code Unit, DML, SOQL, System (System, Visualforce), Validation @@ -475,6 +476,7 @@ Skipped due to adopting odd numbering for pre releases and even number for relea +[#685]: https://github.com/certinia/debug-log-analyzer/issues/685 [#98]: https://github.com/certinia/debug-log-analyzer/issues/98 [#204]: https://github.com/certinia/debug-log-analyzer/issues/204 [#714]: https://github.com/certinia/debug-log-analyzer/issues/714 From aa8fd5fd671ca9cb9d7150ee92d1b60e52f56b89 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:25:49 +0000 Subject: [PATCH 4/6] feat: add wall-clock time display option in timeline documentation --- README.md | 1 + lana-docs-site/docs/docs/features/timeline.md | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 45f42d21..8c4a1f8d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ The Timeline view shows a live visualization of your Salesforce Apex log executi - **📏 Measure Range** – `Shift+Drag` to measure the duration between any two points. Resize edges, double-click to zoom. - **🔍 Area Zoom** – `Alt/Option+Drag` to select a region and instantly zoom to fit. - **Tooltips** – Hover for duration, event name, SOQL/DML/Exception counts, SOQL/DML rows, and more. +- **🕐 Wall-Clock Time** – Toggle between elapsed and real-time (HH:MM:SS.mmm) on the time axis via the toolbar clock button. - **Cotext Menu Actions** – Right-click for context actions; `Cmd/Ctrl+Click` to jump directly to the Call Tree; `Cmd/Ctrl+C` to copy frame names. - **19 Curated Themes** – Choose from beautiful, optimized color themes or create your own via Settings. - **Adaptive Frame Detail** – Level-of-detail bucketing reveals richer detail as you zoom while keeping performance snappy. diff --git a/lana-docs-site/docs/docs/features/timeline.md b/lana-docs-site/docs/docs/features/timeline.md index 43b0784d..8caeb752 100644 --- a/lana-docs-site/docs/docs/features/timeline.md +++ b/lana-docs-site/docs/docs/features/timeline.md @@ -258,6 +258,15 @@ Use `Alt/Option+Drag` to select a time range and instantly zoom to fit it. This Release the mouse button to zoom the viewport to fit the selected area exactly. +## Wall-Clock Time + +The timeline supports switching between two time display modes: + +- **Elapsed Time** (default): Time relative to the start of the log (e.g., `0 ms`, `1500 ms`, `2.5 s`) +- **Wall-Clock Time**: Actual time of day from the log (e.g., `14:30:05.122`) + +Click the clock icon button in the top-right corner of the timeline toolbar to toggle between modes. + ## Tooltip Date: Fri, 13 Feb 2026 11:31:44 +0000 Subject: [PATCH 5/6] feat: refine timeline features and enhance navigation controls in README --- README.md | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8c4a1f8d..35478c9e 100644 --- a/README.md +++ b/README.md @@ -87,19 +87,12 @@ Use `Log: Retrieve Apex Log And Show Analysis` from the Command Palette. The Timeline view shows a live visualization of your Salesforce Apex log execution — including methods, SOQL queries, DML operations, workflows, flows, and more. - **⚡ Fast** – Blazing-fast zoom, pan, and rendering even on massive logs (500k+ lines). -- **🎯 Frame Selection & Navigation** – Click to select frames, use arrow keys to navigate the call stack, double-click or press `Enter` to zoom and focus. -- **Zoom & Pan** – Navigate your logs down to 0.001 ms with precision zoom. `W`/`S` keys or scroll wheel for zoom; `A`/`D` keys or drag for pan. -- **Dynamic Frame Labels** – Instantly see method names on timeline frames for faster scanning. -- **🗺️ Minimap** – Bird's-eye view with skyline overview showing call stack depth, viewport lens for navigation, and instant teleport to any position. -- **📊 Governor Limits Strip** – At-a-glance limit usage with traffic light coloring (safe/warning/critical/breach). Expand for detailed step chart. -- **📏 Measure Range** – `Shift+Drag` to measure the duration between any two points. Resize edges, double-click to zoom. -- **🔍 Area Zoom** – `Alt/Option+Drag` to select a region and instantly zoom to fit. -- **Tooltips** – Hover for duration, event name, SOQL/DML/Exception counts, SOQL/DML rows, and more. +- **🗺️ Minimap** – Bird's-eye view with skyline density overview, viewport lens, and instant teleport. +- **📊 Governor Limits Strip** – At-a-glance limit usage with traffic light coloring. Expand for detailed step chart. +- **📏 Measure & Zoom** – `Shift+Drag` to measure durations, `Alt/Option+Drag` to area-zoom, precision keyboard controls. - **🕐 Wall-Clock Time** – Toggle between elapsed and real-time (HH:MM:SS.mmm) on the time axis via the toolbar clock button. -- **Cotext Menu Actions** – Right-click for context actions; `Cmd/Ctrl+Click` to jump directly to the Call Tree; `Cmd/Ctrl+C` to copy frame names. -- **19 Curated Themes** – Choose from beautiful, optimized color themes or create your own via Settings. -- **Adaptive Frame Detail** – Level-of-detail bucketing reveals richer detail as you zoom while keeping performance snappy. -- **Stacked by Time** – See how execution time is distributed across nested method calls and system events. + +Also: Frame Selection & Navigation, Dynamic Frame Labels, Adaptive Frame Detail, Tooltips, Context Menu, Search & Highlight, 19 Curated Themes. ![Flame Chart](https://raw.githubusercontent.com/certinia/debug-log-analyzer/main/lana/assets/v1.18/lana-timeline.png) From 7813a59320f5bd6aa6ee194e09e7d9a6d1413cd5 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:43:37 +0000 Subject: [PATCH 6/6] build: update @vscode/codicons to version 0.0.44 in pnpm-lock.yaml --- pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 676b4661..74ef1221 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,8 +179,8 @@ importers: specifier: ^4.4.0 version: 4.4.1 '@vscode/codicons': - specifier: ^0.0.36 - version: 0.0.36 + specifier: ^0.0.44 + version: 0.0.44 '@vscode/webview-ui-toolkit': specifier: ^1.4.0 version: 1.4.0(react@19.1.1) @@ -2919,8 +2919,8 @@ packages: cpu: [x64] os: [win32] - '@vscode/codicons@0.0.36': - resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==} + '@vscode/codicons@0.0.44': + resolution: {integrity: sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ==} '@vscode/webview-ui-toolkit@1.4.0': resolution: {integrity: sha512-modXVHQkZLsxgmd5yoP3ptRC/G8NBDD+ob+ngPiWNQdlrH6H1xR/qgOBD85bfU3BhOB5sZzFWBwwhp9/SfoHww==} @@ -11613,7 +11613,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vscode/codicons@0.0.36': {} + '@vscode/codicons@0.0.44': {} '@vscode/webview-ui-toolkit@1.4.0(react@19.1.1)': dependencies: