Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 27 additions & 50 deletions .claude/rules/log-viewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,68 +5,45 @@ paths:

# log-viewer rules

Webview UI. Applies when working under `log-viewer/`.
Webview UI.

## Boundary

- MUST NOT import `vscode` or anything from `lana/`.
- Communicate with the extension via message passing only.
- Never import `vscode` or anything from `lana/`. Message passing only.

## Performance budgets
## Performance

- Parse + render: `<5MB` → `<1s`, `10MB` → `<3s`, `20MB+` → `<5s`.
- No synchronous operations >50ms blocking the extension host.
- Operations >100ms show a progress indicator.
- Benchmark against large logs from `sample-app/`.
- Parse + render: `<5MB` in `<1s`, `10MB` in `<3s`, `20MB+` in `<5s`. Benchmark on `sample-app/` logs.
- Nothing synchronous over 50ms. Show progress over 100ms.

## Theme changes
## Theme

- The panel keeps its context and is never re-created, so a theme switch has to be observed at
runtime. HTML re-themes itself through `--vscode-*`; anything drawn on a canvas does not.
Canvas code reads its colours through `themeObserver.on(…)` and repaints, and must never
re-initialise the renderer to do it.
- The panel is never re-created, so a theme switch is observed at runtime. HTML re-themes through
`--vscode-*`; canvas does not — read colours via `themeObserver.on(…)` and repaint, never
re-initialise the renderer.
- Check every change in a light and a dark theme.

## UI appearance
## Appearance

- **Never write a raw literal for a color, radius, space, shadow or border width.** Use a
`--lana-*` token, or the `--vscode-*` var the value comes from. Replace the literals in the code
you touch; older files still hold them and we convert file by file.
- **A `--lana-*` token when the value is more than one var, and the plain `--vscode-*` var when it
is not.** Give a value a token when any of these holds:
1. the role appears in three or more files;
2. VS Code has no var for it, so it is a scale or a role of ours (`--lana-space-*`,
`--lana-pane-min`);
3. the value is a chain, a `calc` or a `color-mix`, not a single var;
4. the var is absent in a VS Code we support, so a fallback is needed — `cornerRadius-*`,
`spacing-size*` and `strokeThickness` ship in no stable release.

Otherwise use the var directly. A token wrapping one already-semantic var used in one file
(`--vscode-editorCursor-foreground`) only adds a name to learn and a hop to trace.

- **Put a new token in `styles/tokens.css` as `var(--vscode-…, <literal>)`.** The literal is what a
host outside VS Code falls back to, and what covers a var VS Code has yet to register.
- **Never define or override a `--vscode-*` name.** An override is global to the webview, so
re-skinning one role changes every other consumer of that var, and shadowing a platform name
leaves no way to tell what the var means. A host outside VS Code supplies the whole `--vscode-*`
block once instead. The one exception is skinning a `vscode-elements` component, which reads its
own `--vscode-*` names: scope the override to that element, never to `:host` or `:root`.
- **Data palettes stay literal.** The timeline categories (`timeline/themes/Themes.ts`) and the
metric-strip tiers (`metric-strip/metric-strip-colors.ts`) show meaning, not chrome, so they do
not follow the host theme.
- **A component that names a token must also carry the tokens.** `globalStyles` carries them, so
`static styles = [globalStyles, …]` is enough. Without `globalStyles`, add `tokenStyles`
(`styles/tokens.styles.ts`). The document copy, which the build injects, styles only the popups
that tabulator puts in `document.body`.
- **Check each UI change in a light theme and in a dark theme.**
- Write no literal colour, radius, space, shadow or border width. Use a `--lana-*` token, or the
`--vscode-*` var the value comes from, and convert the literals in the code you touch.
- Make it a token when the role is in three or more files, is ours (`--lana-space-*`,
`--lana-pane-min`), is a chain, `calc` or `color-mix`, or the var ships in no stable VS Code
(`cornerRadius-*`, `spacing-size*`, `strokeThickness`). Otherwise use the var directly.
- New tokens go in `styles/tokens.css` as `var(--vscode-…, <literal>)`; the literal is what a host
outside VS Code gets. Never end a chain in `transparent` — the value is then defined but invisible,
and no consumer fallback can fire.
- 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`.
- Data palettes stay literal: they show meaning, not chrome (`timeline/themes/Themes.ts`,
`metric-strip/metric-strip-colors.ts`).
- A component naming a token must carry the tokens: `globalStyles`, or `tokenStyles`
(`styles/tokens.styles.ts`) without it. The document copy styles only tabulator's body popups.

## Key paths

- Timeline: `log-viewer/src/features/timeline/`
- Parser: `log-viewer/src/core/log-parser/`
- Theme observer: `log-viewer/src/core/theme/ThemeObserver.ts`
- Tokens: `log-viewer/src/styles/tokens.css`
`features/timeline/` · `core/log-parser/` · `core/theme/ThemeObserver.ts` · `styles/tokens.css`

## Testing

- Features and bug fixes include tests.
- Breaking changes to log parsing cover both old and new formats.
- Features and fixes ship with tests. Parser format changes cover the old and the new format.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ 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.

## Critical boundary

Expand Down
24 changes: 12 additions & 12 deletions log-viewer/src/components/CallTreeDetail.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { consume } from '@lit/context';
import type { LogEvent } from 'apex-log-parser';
import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
Expand All @@ -12,7 +13,8 @@ import {
} from 'tabulator-tables';

import { eventBus } from '../core/events/EventBus.js';
import { LogLoadedController } from '../core/events/LogLoadedController.js';
import { logContext } from '../core/log/logContext.js';
import type { LogStore } from '../core/log/LogStore.js';
import { formatDuration, formatInteger } from '../core/utility/Util.js';
import {
commonColumnDefaults,
Expand Down Expand Up @@ -186,16 +188,10 @@ export class CallTreeDetail extends LitElement {
'bottom-up': null,
};

// A whole-log tree can mount before the first parse finishes (the scoped
// tree cannot — a selection implies a parsed log), so rebuild when the log
// lands.
private readonly _logLoaded = new LogLoadedController(this, () => {
if (!this.wholeLog) {
return;
}
this._invalidateScope();
void this._showActive();
});
/** The log on screen, from the app root. */
@consume({ context: logContext, subscribe: true })
@property({ attribute: false })
logStore: LogStore | null = null;

constructor() {
super();
Expand Down Expand Up @@ -316,7 +312,11 @@ export class CallTreeDetail extends LitElement {
];

updated(changed: PropertyValues) {
const scopeChanged = changed.has('eventIndex') || changed.has('instances');
// Only the whole-log tree can mount before a parse, so there a new log is a new scope.
const scopeChanged =
(changed.has('logStore') && this.wholeLog) ||
changed.has('eventIndex') ||
changed.has('instances');
if (scopeChanged) {
this._invalidateScope();
}
Expand Down
18 changes: 12 additions & 6 deletions log-viewer/src/components/HotPath.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { consume } from '@lit/context';
import { LitElement, css, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';

import '#vscode-elements/vscode-icon.js';
import { LogLoadedController } from '../core/events/LogLoadedController.js';
import { logContext } from '../core/log/logContext.js';
import type { LogStore } from '../core/log/LogStore.js';
import { formatDuration } from '../core/utility/Util.js';
import {
getCurrentExecutionHighlights,
getExecutionHighlights,
type ExecutionHighlights,
type HotPathFrame,
} from '../features/call-tree/utils/ExecutionHighlights.js';
Expand All @@ -31,8 +33,11 @@ const FRAME_CAP = 10;
*/
@customElement('hot-path')
export class HotPath extends LitElement {
/** The path has to follow the log itself. */
private readonly _logLoaded = new LogLoadedController(this);
/** The log on screen, from the app root. */
@consume({ context: logContext, subscribe: true })
@property({ attribute: false })
logStore: LogStore | null = null;

private readonly _palette = new CategoryPaletteController(this);

static styles = [
Expand Down Expand Up @@ -69,7 +74,8 @@ export class HotPath extends LitElement {
];

render() {
const highlights = getCurrentExecutionHighlights();
const log = this.logStore?.log;
const highlights = log && getExecutionHighlights(log);
if (!highlights || !highlights.hotPath.length) {
return html`<p class="note">The log has no timed calls.</p>`;
}
Expand Down
18 changes: 12 additions & 6 deletions log-viewer/src/components/HotSpots.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { consume } from '@lit/context';
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';

import { LogLoadedController } from '../core/events/LogLoadedController.js';
import { logContext } from '../core/log/logContext.js';
import type { LogStore } from '../core/log/LogStore.js';
import { formatDuration } from '../core/utility/Util.js';
import {
getCurrentExecutionHighlights,
getExecutionHighlights,
type HotSpotRow,
} from '../features/call-tree/utils/ExecutionHighlights.js';
import { globalStyles } from '../styles/global.styles.js';
Expand All @@ -25,14 +27,18 @@ import { dispatchInspectorReveal } from './inspectorReveal.js';
*/
@customElement('hot-spots')
export class HotSpots extends LitElement {
/** The list has to follow the log itself. */
private readonly _logLoaded = new LogLoadedController(this);
/** The log on screen, from the app root. */
@consume({ context: logContext, subscribe: true })
@property({ attribute: false })
logStore: LogStore | null = null;

private readonly _palette = new CategoryPaletteController(this);

static styles = [globalStyles, inspectorSectionStyles, revealRowStyles];

render() {
const highlights = getCurrentExecutionHighlights();
const log = this.logStore?.log;
const highlights = log && getExecutionHighlights(log);
if (!highlights || !highlights.hotSpots.length) {
return html`<p class="note">The log has no timed calls.</p>`;
}
Expand Down
4 changes: 3 additions & 1 deletion log-viewer/src/components/__tests__/HotPath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
*/
import { beforeEach, describe, expect, it } from '@jest/globals';

import type { LogStore } from '../../core/log/LogStore.js';
import type { ExecutionHighlights } from '../../features/call-tree/utils/ExecutionHighlights.js';

jest.mock('#vscode-elements/vscode-icon.js', () => ({}));

let highlights: ExecutionHighlights | null = null;
jest.mock('../../features/call-tree/utils/ExecutionHighlights.js', () => ({
getCurrentExecutionHighlights: () => highlights,
getExecutionHighlights: () => highlights,
}));

import '../HotPath.js';
Expand All @@ -35,6 +36,7 @@ const pathOf = (frameCount: number): ExecutionHighlights => ({

const hotPath = async () => {
const element = document.createElement('hot-path');
element.logStore = { log: {} } as unknown as LogStore;
document.body.append(element);
await element.updateComplete;
return element;
Expand Down
4 changes: 3 additions & 1 deletion log-viewer/src/components/__tests__/HotSpots.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
*/
import { beforeEach, describe, expect, it } from '@jest/globals';

import type { LogStore } from '../../core/log/LogStore.js';
import type { ExecutionHighlights } from '../../features/call-tree/utils/ExecutionHighlights.js';

let highlights: ExecutionHighlights | null = null;
jest.mock('../../features/call-tree/utils/ExecutionHighlights.js', () => ({
getCurrentExecutionHighlights: () => highlights,
getExecutionHighlights: () => highlights,
}));

import '../HotSpots.js';
Expand All @@ -32,6 +33,7 @@ const spotsOf = (): ExecutionHighlights => ({

const hotSpots = async () => {
const element = document.createElement('hot-spots');
element.logStore = { log: {} } as unknown as LogStore;
document.body.append(element);
await element.updateComplete;
return element;
Expand Down
6 changes: 0 additions & 6 deletions log-viewer/src/core/events/EventBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,6 @@ export type DetailSelection =
| { kind: 'aggregate'; instances: number[]; label: string };

interface EventMap {
// A log finished parsing and the event-lookup service holds it. Whole-log
// content reads that service directly, so it needs telling once the data is
// there; the first paint happens before any log exists. No payload: a listener
// that needs the log asks the service for it, keeping one source of truth.
'log:loaded': Record<string, never>;

// Supply eventIndex (preferred — unique) OR timestamp (fallback for raw-log entry where eventIndex isn't known).

'timeline:navigate-to':
Expand Down
39 changes: 0 additions & 39 deletions log-viewer/src/core/events/LogLoadedController.ts

This file was deleted.

Loading