From 09ee14396e23f3fd8f30be0637e51fe1006e3136 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:19:00 +0100 Subject: [PATCH 1/2] feat(log-viewer): redesign the header's problems and notifications controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify three near-duplicate notification/problem models into one LogIssue, replace two hand-rolled absolute-positioned panels with a single native-popover shell, and fold help and report-an-issue into a ••• meta menu that also holds whatever the header collapses into it as it narrows. - log problems: worst-severity glyph + count badge, monochrome like every other header glyph; severity colour stays in the panel rows and the timeline - notifications: bell + corner badge, opening a positive empty state - measured progressive-collapse ladder (meta, bell, Inspector, problems) replacing the single 420px breakpoint - fix Report an issue rendering as a blue underlined link beside plain menu rows --- CHANGELOG.md | 4 + jest.config.js | 4 + log-viewer/src/components/AnchoredPopover.ts | 185 ++++++++++ log-viewer/src/components/BadgeBase.ts | 55 --- log-viewer/src/components/HeaderMenu.ts | 134 +++++++ log-viewer/src/components/IconButton.ts | 70 ---- log-viewer/src/components/LogProblems.ts | 204 ---------- log-viewer/src/components/LogProblemsChip.ts | 93 +++++ log-viewer/src/components/LogTitle.ts | 13 +- log-viewer/src/components/NavBar.ts | 347 +++++++++++++++--- .../__tests__/AnchoredPopover.test.ts | 72 ++++ .../components/__tests__/HeaderMenu.test.ts | 111 ++++++ .../__tests__/LogProblemsChip.test.ts | 86 +++++ .../src/components/__tests__/NavBar.test.ts | 196 ++++++++++ log-viewer/src/features/app/AppHeader.ts | 12 +- log-viewer/src/features/app/LogViewer.ts | 132 ++++--- .../notifications/__tests__/IssueList.test.ts | 63 ++++ .../__tests__/NotificationCentre.test.ts | 59 +++ .../notifications/__tests__/types.test.ts | 82 +++++ .../notifications/components/IssueList.ts | 105 ++++++ .../components/NotificationButton.ts | 140 ------- .../components/NotificationCentre.ts | 61 +++ .../components/NotificationPanel.ts | 95 ----- .../src/features/notifications/types.ts | 115 ++++++ log-viewer/src/styles/headerControl.styles.ts | 53 +++ log-viewer/src/styles/menuRow.styles.ts | 40 ++ log-viewer/src/styles/notification.styles.ts | 7 - 27 files changed, 1852 insertions(+), 686 deletions(-) create mode 100644 log-viewer/src/components/AnchoredPopover.ts delete mode 100644 log-viewer/src/components/BadgeBase.ts create mode 100644 log-viewer/src/components/HeaderMenu.ts delete mode 100644 log-viewer/src/components/IconButton.ts delete mode 100644 log-viewer/src/components/LogProblems.ts create mode 100644 log-viewer/src/components/LogProblemsChip.ts create mode 100644 log-viewer/src/components/__tests__/AnchoredPopover.test.ts create mode 100644 log-viewer/src/components/__tests__/HeaderMenu.test.ts create mode 100644 log-viewer/src/components/__tests__/LogProblemsChip.test.ts create mode 100644 log-viewer/src/components/__tests__/NavBar.test.ts create mode 100644 log-viewer/src/features/notifications/__tests__/IssueList.test.ts create mode 100644 log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts create mode 100644 log-viewer/src/features/notifications/__tests__/types.test.ts create mode 100644 log-viewer/src/features/notifications/components/IssueList.ts delete mode 100644 log-viewer/src/features/notifications/components/NotificationButton.ts create mode 100644 log-viewer/src/features/notifications/components/NotificationCentre.ts delete mode 100644 log-viewer/src/features/notifications/components/NotificationPanel.ts create mode 100644 log-viewer/src/features/notifications/types.ts create mode 100644 log-viewer/src/styles/headerControl.styles.ts create mode 100644 log-viewer/src/styles/menuRow.styles.ts delete mode 100644 log-viewer/src/styles/notification.styles.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dae428b..2587ec67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ♻️ Replace `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). - 🗄️ **Database table columns** (DML, SOQL, SOSL): consolidated onto the shared Call Tree column/sort styling for a consistent look across all tables. - 🧱 **Data grids**: a crisper header/content separator and tidied grid styling across all tables. +- 🎨 **Header bar** + - **Log problems** icon now shows the most severe problem found, with a count. + - **Help & documentation** and **Report an issue** move into a `•••` menu, which also holds the controls the header drops as the window narrows. + - **Log problems** and **Notifications** panels redesigned. ### Fixed diff --git a/jest.config.js b/jest.config.js index 7c776c8c..6d0b4b07 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,6 +10,10 @@ const defaultConfig = { jsc: { target: 'esnext', parser: { decorators: true, syntax: 'typescript' }, + // Mirrors tsconfig. swc defaults this on, which would `defineProperty` every + // initialised field over the accessor lit's decorators put on the prototype — + // silently killing reactivity in tests only. + transform: { useDefineForClassFields: false }, }, }, ], diff --git a/log-viewer/src/components/AnchoredPopover.ts b/log-viewer/src/components/AnchoredPopover.ts new file mode 100644 index 00000000..939bad4d --- /dev/null +++ b/log-viewer/src/components/AnchoredPopover.ts @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +// web components +import '#vscode-elements/vscode-icon.js'; + +// styles +import { globalStyles } from '../styles/global.styles.js'; + +/** + * Trigger + panel pair for the header's drop-downs: renders the slotted trigger and + * shows the `panel` slot in a native popover anchored to it. + * + * Native `popover` (rather than a `position: absolute` div) buys two things the + * hand-rolled panels didn't have: the panel lives in the top layer so no ancestor + * `overflow` can clip it, and light-dismiss (click-outside + `Escape`) comes for + * free — no `document` click listener to add, leak or get wrong. + * + * Opens on click only. These panels contain links and navigation buttons, so a + * hover-opened panel would be a trap. + */ +@customElement('anchored-popover') +export class AnchoredPopover extends LitElement { + /** Panel `aria-label`; rendered as a visible heading only with `show-heading`. */ + @property() + heading = ''; + + /** Show `heading` at the top of the panel. Off for menus — VS Code's are untitled. */ + @property({ attribute: 'show-heading', type: Boolean }) + showHeading = false; + + /** Which side the panel aligns to. */ + @property() + align: 'start' | 'end' = 'end'; + + /** Shown in place of the `panel` slot when it has no content. */ + @property({ attribute: 'empty-message' }) + emptyMessage = ''; + + /** Queried live rather than cached: the slot's content changes without a re-render. */ + private get _panelContent(): readonly Element[] { + const slot = this.shadowRoot?.querySelector('slot[name="panel"]'); + return slot?.assignedElements({ flatten: true }) ?? []; + } + + static styles = [ + globalStyles, + css` + :host { + display: inline-flex; + flex: 0 0 auto; + } + + .trigger { + display: inline-flex; + /* The popover positions against this, so it must be the anchor rather than + :host — a display:contents/inline-flex host has no usable anchor box. */ + anchor-name: --anchored-popover-trigger; + border: 0; + padding: 0; + margin: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + } + + .panel { + position: fixed; + position-anchor: --anchored-popover-trigger; + /* Flip above / to the other side rather than running off-screen: the header + sits at the top of a panel that can be docked at either edge. */ + position-try-fallbacks: + flip-block, + flip-inline, + flip-block flip-inline; + inset: auto; + margin: 6px 0 0 0; + box-sizing: border-box; + width: 320px; + max-width: min(92vw, 320px); + max-height: 540px; + overflow-y: auto; + padding: 6px; + /* Panel content sets its own alignment — never inherit one from the header row. */ + text-align: start; + } + + :host([align='end']) .panel { + position-area: bottom span-left; + } + + :host([align='start']) .panel { + position-area: bottom span-right; + } + + .panel__head { + padding: 2px 8px 6px; + font-weight: 600; + font-size: 12px; + color: var(--vscode-foreground); + } + + .panel__empty { + display: flex; + align-items: center; + gap: 6px; + padding: 8px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + } + + /* Hidden until the slot reports content, so the empty message shows instead. */ + .panel__items--empty { + display: none; + } + `, + ]; + + render() { + // Emptiness is read from the slot, which doesn't exist on the first render — the + // firstUpdated/slotchange re-render settles it. No flash: the panel stays closed + // until the trigger is clicked. + const isEmpty = this._panelContent.length === 0; + + return html` +
+ ${ + this.showHeading && this.heading + ? html`
${this.heading}
` + : '' + } +
+ +
+ ${ + isEmpty && this.emptyMessage + ? html`
+ + ${this.emptyMessage} +
` + : '' + } +
`; + } + + /** + * Dismiss the panel. Light-dismiss covers clicks *outside*, so a command row inside + * the panel has to close it explicitly or it stays open over whatever it just did. + */ + close(): void { + const panel = this.shadowRoot?.querySelector('.panel'); + // No-op under jsdom, which implements neither `hidePopover` nor `:popover-open`. + if (typeof panel?.hidePopover === 'function' && panel.matches(':popover-open')) { + panel.hidePopover(); + } + } + + /** The slot only reports its content once it exists, so settle the empty state here. */ + override firstUpdated(): void { + this.requestUpdate(); + } + + private _onSlotChange(): void { + this.requestUpdate(); + } +} diff --git a/log-viewer/src/components/BadgeBase.ts b/log-viewer/src/components/BadgeBase.ts deleted file mode 100644 index 76dbb78d..00000000 --- a/log-viewer/src/components/BadgeBase.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (c) 2021 Certinia Inc. All rights reserved. - */ -import '#vscode-elements/vscode-badge.js'; -import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; - -// styles -import { globalStyles } from '../styles/global.styles.js'; -import { skeletonStyles } from '../styles/skeleton.styles.js'; - -@customElement('badge-base') -export class BadgeBase extends LitElement { - @property() - status: 'success' | 'failure' | 'neutral' = 'neutral'; - - @property({ type: Boolean }) - isloading = false; - - colorMap = new Map([ - ['success', 'success-tag'], - ['failure', 'failure-tag'], - ]); - - static styles = [ - globalStyles, - skeletonStyles, - css` - .tag { - --vscode-font-family: monospace; - --vscode-badge-background: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31)); - --vscode-badge-foreground: var(--vscode-editor-foreground); - - font-family: monospace; - font-size: inherit; - } - - .success-tag { - --vscode-badge-background: rgba(128, 255, 128, 0.2); - } - - .failure-tag { - --vscode-badge-background: var(--notification-error-background); - } - `, - ]; - - render() { - if (this.isloading) { - return html` `; - } - const statusTag = this.colorMap.get(this.status); - return html``; - } -} diff --git a/log-viewer/src/components/HeaderMenu.ts b/log-viewer/src/components/HeaderMenu.ts new file mode 100644 index 00000000..eedafc6d --- /dev/null +++ b/log-viewer/src/components/HeaderMenu.ts @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-icon.js'; +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { vscodeMessenger } from '../core/messaging/VSCodeExtensionMessenger.js'; + +// styles +import { globalStyles } from '../styles/global.styles.js'; +import { headerControlStyles } from '../styles/headerControl.styles.js'; +import { menuRowStyles } from '../styles/menuRow.styles.js'; + +// web components +import type { AnchoredPopover } from './AnchoredPopover.js'; +import './AnchoredPopover.js'; +import './Divider.js'; + +const REPORT_ISSUE_URL = 'https://github.com/certinia/debug-log-analyzer/issues/new/choose'; + +/** Webviews can't follow external hrefs directly — hand the URL to VS Code's own opener. */ +function reportIssueHref(): string { + return `command:vscode.open?${encodeURIComponent(JSON.stringify(REPORT_ISSUE_URL))}`; +} + +/** + * The header's `•••` meta menu: always present, holding the actions that don't earn + * permanent header space, plus whatever the header collapses into it when narrow. + * + * Unlike ``, this toggle renders whether or not anything is collapsed + * in — its own rows are always there — so a marker dot is what tells the user + * something extra has moved inside. + */ +@customElement('header-menu') +export class HeaderMenu extends LitElement { + /** + * Shows a marker dot on the toggle when a collapsed child has content. Presence only, + * on the accent colour: header chrome carries no severity colour, so at narrow widths + * the dot says *something* is in here and the menu says how bad. + */ + @property({ type: Boolean }) + marker = false; + + /** + * How many controls the header has collapsed in here. Told rather than counted: the + * slot holds one wrapper, so only the consumer knows how many sections are inside. + */ + @property({ type: Number, attribute: 'collapsed-count' }) + collapsedCount = 0; + + static styles = [ + globalStyles, + headerControlStyles, + menuRowStyles, + css` + :host { + display: inline-flex; + flex: 0 0 auto; + } + + .toggle__marker { + position: absolute; + top: 1px; + right: 2px; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: var(--vscode-activityBarBadge-background); + pointer-events: none; + } + + /* Collapsed controls arrive as full-width sections, not as a row of icons. */ + .collapsed { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + padding: 2px 4px; + } + + /* Nothing collapsed in yet — the row and its divider would frame empty space. */ + .collapsed--empty { + display: none; + } + `, + ]; + + render() { + const count = this.collapsedCount; + const label = count ? `More — ${count} collapsed item${count === 1 ? '' : 's'}` : 'More'; + + return html` + + + ${this.marker ? html`` : ''} + +
+ + ${count ? html`` : ''} + + + + Report an issue + +
+
`; + } + + /** + * A menu closes when one of its rows is used — including the rows the header collapses + * in, whose commands would otherwise act behind a panel that covers most of the view. + * Native light-dismiss only handles clicks *outside* the panel. + */ + private _onPanelClick(event: Event): void { + const activated = event + .composedPath() + .some( + (node) => + node instanceof HTMLElement && (node.localName === 'button' || node.localName === 'a'), + ); + if (activated) { + this.shadowRoot?.querySelector('anchored-popover')?.close(); + } + } +} diff --git a/log-viewer/src/components/IconButton.ts b/log-viewer/src/components/IconButton.ts deleted file mode 100644 index c1307487..00000000 --- a/log-viewer/src/components/IconButton.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2025 Certinia Inc. All rights reserved. - */ -import '#vscode-elements/vscode-toolbar-button.js'; -import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; - -// styles -import { globalStyles } from '../styles/global.styles.js'; - -@customElement('icon-button') -export class IconButton extends LitElement { - static styles = [ - globalStyles, - css` - .menu-container { - position: relative; - display: inline-flex; - } - - .badge-indicator { - background-color: var(--vscode-activityBarBadge-background); - color: var(--vscode-activityBarBadge-foreground); - position: absolute; - top: 10px; - right: 0; - font-size: 9px; - font-weight: 600; - min-width: 12px; - height: 12px; - line-height: 12px; - padding: 0 2px; - border-radius: 16px; - text-align: center; - display: inline-block; - box-sizing: border-box; - pointer-events: none; - } - `, - ]; - - @property() - icon: string = ''; - - @property() - badgeCount: number | null | undefined = null; - - @property() - ariaLabel: string = 'Icon Button'; - - @property() - title: string = 'Icon Button'; - - render() { - const indicator = - this.badgeCount !== null && this.badgeCount !== undefined - ? html`${this.badgeCount}` - : ``; - - return html``; - } -} diff --git a/log-viewer/src/components/LogProblems.ts b/log-viewer/src/components/LogProblems.ts deleted file mode 100644 index 8bd786e8..00000000 --- a/log-viewer/src/components/LogProblems.ts +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright (c) 2023 Certinia Inc. All rights reserved. - */ -import '#vscode-elements/vscode-button.js'; -import { LitElement, css, html, type TemplateResult } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; - -import { goToRow } from '../features/call-tree/navigation.js'; - -// styles -import { globalStyles } from '../styles/global.styles.js'; -import { notificationStyles } from '../styles/notification.styles.js'; -import { skeletonStyles } from '../styles/skeleton.styles.js'; - -// web components -import '../features/notifications/components/NotificationPanel.js'; -import './BadgeBase.js'; -import './Divider.js'; -import './IconButton.js'; -import './IconButtonSkeleton.js'; - -@customElement('log-problems') -export class NotificationTag extends LitElement { - @state() - open = false; - - @property() - notifications: LogProblem[] | null = null; - - colorStyles = new Map([ - ['Error', 'error'], - ['Warning', 'warning'], - ['Info', 'info'], - ]); - - sortOrder = new Map([ - ['Error', 0], - ['Warning', 1], - ['Info', 2], - ]); - - constructor() { - super(); - document.addEventListener('click', (event) => { - if (!event.composedPath().includes(this)) { - this.open = false; - } - }); - } - - static styles = [ - globalStyles, - skeletonStyles, - css` - :host { - ${notificationStyles} - display: inline-flex; - flex: 0 0 auto; - } - - .problems-container { - position: relative; - display: inline-flex; - } - - .problems-panel { - position: absolute; - top: calc(100% + 10px); - left: 50%; - transform: translateX(-50%); - } - - .log-problem { - padding: 8px 16px; - overflow-wrap: anywhere; - text-wrap: wrap; - display: flex; - gap: 8px; - border-radius: 4px; - } - - .text-container { - padding: 8px 0px 0px 0px; - } - - .error { - background-color: var(--notification-error-background); - } - - .warning { - background-color: var(--notification-warning-background); - } - - .info { - background-color: var(--notification-information-background); - } - - .button-bar { - display: flex; - align-items: center; - height: 35px; - } - - .skeleton { - width: 16px; - height: 16px; - } - `, - ]; - - render() { - if (!this.notifications) { - return html``; - } - - const count = this.notifications.length || null; - const title = count === 0 ? 'No Problems' : `${count} Problem${count === 1 ? '' : 's'}`; - const messages = this._renderNotificationMessages(); - - return html`
- - - - ${messages.length ? html`
${messages}
` : html``} -
-
`; - } - - _renderNotificationMessages() { - if (!this.notifications) { - return []; - } - - const sortOrder = new Map([ - ['Error', 0], - ['Warning', 1], - ['Info', 2], - ['None', 3], - ]); - - const messages: TemplateResult[] = []; - const sortedNotifications = [...this.notifications].sort((a, b) => { - return (sortOrder.get(a.severity) ?? 1) - (sortOrder.get(b.severity) ?? 1); - }); - const lastIndex = sortedNotifications.length - 1; - - sortedNotifications.forEach((item, index) => { - const colorStyle = this.colorStyles.get(item.severity) || ''; - - const buttonBar = - item.eventIndex !== null - ? html`
- { - if (item.eventIndex !== null) { - goToRow({ eventIndex: item.eventIndex }); - } - }} - >Go To Call Tree -
` - : ''; - - const content = html`
- ${ - item.message - ? html`
- ${item.summary} -
${item.message}
-
` - : item.summary - } - ${buttonBar} -
`; - - messages.push(html`
${content}
`); - if (index !== lastIndex) { - messages.push(html``); - } - }); - - return messages; - } - - _togglePanel() { - this.open = !this.open; - } -} - -export class LogProblem { - summary = ''; - message = ''; - severity: 'Error' | 'Warning' | 'Info' | 'none' = 'none'; - eventIndex: number | null = null; - timestamp: number | null = null; -} diff --git a/log-viewer/src/components/LogProblemsChip.ts b/log-viewer/src/components/LogProblemsChip.ts new file mode 100644 index 00000000..ba0634b5 --- /dev/null +++ b/log-viewer/src/components/LogProblemsChip.ts @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-icon.js'; +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { + SEVERITY_META, + describeIssues, + worstSeverity, + type LogIssue, +} from '../features/notifications/types.js'; + +// styles +import { globalStyles } from '../styles/global.styles.js'; +import { headerControlStyles } from '../styles/headerControl.styles.js'; +import { skeletonStyles } from '../styles/skeleton.styles.js'; + +// web components +import '../features/notifications/components/IssueList.js'; +import './AnchoredPopover.js'; +import './IconButtonSkeleton.js'; + +/** + * Header control summarising problems found in the log itself — governor limit + * exceptions, fatal errors, skipped lines. + * + * The worst severity's glyph carries the severity, a corner badge carries the total, + * so one control covers every combination; the full breakdown is in the tooltip and + * `aria-label`. At zero it stays put and shows a tick — an empty slot would say + * nothing. A bare icon button, not a pill: it belongs to the header's toolbar family + * rather than the filter bar's, and a badged glyph never changes width between logs. + */ +@customElement('log-problems') +export class LogProblemsChip extends LitElement { + /** `null` while the log is still parsing — renders a skeleton. */ + @property({ attribute: false }) + issues: readonly LogIssue[] | null = null; + + static styles = [ + globalStyles, + headerControlStyles, + skeletonStyles, + css` + :host { + display: inline-flex; + flex: 0 0 auto; + } + + /* Dimming a clean tick is de-emphasis, not severity — the glyph itself is never + tinted. Targets the element rather than the wrapper because vscode-icon sets + color on its own :host, and a specified value beats an inherited one (outer-tree + author styles do win over :host). */ + .problems--clean vscode-icon { + color: var(--vscode-descriptionForeground); + } + + .skeleton { + width: 16px; + height: 16px; + } + `, + ]; + + render() { + if (!this.issues) { + return html``; + } + + const issues = this.issues; + const worst = worstSeverity(issues); + const label = describeIssues(issues, 'problem', 'No problems'); + + return html` + + + ${worst ? html`${issues.length}` : ''} + + ${issues.length ? html`` : ''} + `; + } +} diff --git a/log-viewer/src/components/LogTitle.ts b/log-viewer/src/components/LogTitle.ts index 981e201d..409a2e60 100644 --- a/log-viewer/src/components/LogTitle.ts +++ b/log-viewer/src/components/LogTitle.ts @@ -17,6 +17,10 @@ export class LogTitle extends LitElement { @property() logPath = ''; + /** Appended to the tooltip — carries the log meta once the header collapses it. */ + @property() + details = ''; + static styles = [ globalStyles, skeletonStyles, @@ -25,7 +29,10 @@ export class LogTitle extends LitElement { --text-weight-semibold: 600; display: inline-flex; align-items: center; - min-width: 4ch; + /* Floor, not a nicety: without it the title shreds itself to nothing while the + header's controls keep their space, so nav-bar's collapse ladder never + engages. nav-bar reads this value back to budget the ladder. */ + min-width: 16ch; min-height: 1rem; max-width: 60ch; flex: 0 1 auto; @@ -65,7 +72,9 @@ export class LogTitle extends LitElement { return html`
 
`; } - return html`${this.logName}`; } diff --git a/log-viewer/src/components/NavBar.ts b/log-viewer/src/components/NavBar.ts index 1be314d1..6a9ed432 100644 --- a/log-viewer/src/components/NavBar.ts +++ b/log-viewer/src/components/NavBar.ts @@ -2,28 +2,51 @@ * Copyright (c) 2023 Certinia Inc. All rights reserved. */ import '#vscode-elements/vscode-toolbar-button.js'; -import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { LitElement, css, html, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; import { eventBus } from '../core/events/EventBus.js'; -import { vscodeMessenger } from '../core/messaging/VSCodeExtensionMessenger.js'; import { formatDuration } from '../core/utility/Util.js'; -import type { Notification } from '../features/notifications/components/NotificationPanel.js'; +import type { LogIssue } from '../features/notifications/types.js'; +import { computeVisibleCount } from './overflowFit.js'; // styles import { globalStyles } from '../styles/global.styles.js'; -import { notificationStyles } from '../styles/notification.styles.js'; +import { menuRowStyles } from '../styles/menuRow.styles.js'; // web components -import '../features/notifications/components/NotificationButton.js'; -import '../features/notifications/components/NotificationPanel.js'; -import './BadgeBase.js'; +import '../features/notifications/components/IssueList.js'; +import '../features/notifications/components/NotificationCentre.js'; import './Divider.js'; import './DotSeparator.js'; +import './HeaderMenu.js'; import './LogMeta.js'; -import './LogProblems.js'; +import './LogProblemsChip.js'; import './LogTitle.js'; +/** + * What the header sheds as it narrows, in the order it *keeps* them: log meta goes + * first (passive info, and its values survive in the title's tooltip), then the bell + * (tool-level, usually empty), then the Inspector toggle (also on the command palette, + * and the docked panel is visible either way), and log problems last — the only signal + * that can invalidate the whole analysis. Everything shed reappears inside `•••`. + */ +const CHUNKS = ['problems', 'inspector', 'bell', 'meta'] as const; +type Chunk = (typeof CHUNKS)[number]; + +/** + * Allowed per chunk on top of its measured width, for the gap its group puts before it. + * The right-hand group's gap is smaller, so those chunks are over-reserved by 2px — + * erring toward collapsing a beat early rather than toward clipping. + */ +const CHUNK_GAP = 6; + +/** + * Used until `log-title` has been laid out and its real floor can be read. `16ch` in the + * default font; measured rather than trusted, so the two can't drift. + */ +const TITLE_FLOOR_FALLBACK = 140; + @customElement('nav-bar') export class NavBar extends LitElement { @property() @@ -33,31 +56,52 @@ export class NavBar extends LitElement { logPath = ''; @property() - logSize = null; + logSize: number | null = null; @property() - logDuration = null; + logDuration: number | null = null; - @property() - notifications: Notification[] | null = null; + /** Problems found in the log. `null` while it is still parsing. */ + @property({ attribute: false }) + logProblems: readonly LogIssue[] | null = null; - @property() - parserIssues: Notification[] = []; + /** Notifications about the tool — today, parser diagnostics. */ + @property({ attribute: false }) + notifications: readonly LogIssue[] = []; + + /** How many leading `CHUNKS` stay in the header; the rest are in the `•••` menu. */ + @state() + private _visible: number = CHUNKS.length; + + /** + * Natural width per chunk, measured while it is inline. Cached rather than + * re-measured because a collapsed chunk has no box to measure — and so the cache is + * dropped whenever content that can change a width arrives, see {@link willUpdate}. + */ + private _widths = new Map(); + private _menuWidth = 0; + private _hostWidth = 0; + private _titleFloor = TITLE_FLOOR_FALLBACK; + private _resizeObserver: ResizeObserver | null = null; static styles = [ globalStyles, + menuRowStyles, css` :host { display: flex; flex-direction: column; justify-content: center; + /* VS Code's side-bar/panel title height, so this row reads as a title bar rather + than as content pressed against the top edge (and the count badges have room). */ + min-height: 35px; color: var(--vscode-editor-foreground); - ${notificationStyles} } .navbar { display: flex; - gap: 8px; + /* Wide enough to read as a gap: the count badge overhangs its control by 3px. */ + gap: 16px; justify-content: space-between; font-family: var(--vscode-font-family); align-items: center; @@ -78,64 +122,267 @@ export class NavBar extends LitElement { margin-left: -6px; } - .navbar--left-meta { + .navbar--right { display: flex; align-items: center; - gap: 6px; - min-width: 0; - flex: 0 1 auto; + gap: 4px; + flex: 0 0 auto; } - .navbar--right { + /* A collapsible unit, measured as one box. Never shrinks: a squeezed chunk would + measure narrower than it needs and the ladder would oscillate. */ + .chunk { display: flex; align-items: center; - gap: 4px; + gap: 6px; flex: 0 0 auto; } + + .menu-collapsed { + display: flex; + flex-direction: column; + gap: 4px; + } + + .menu-section__label { + padding: 4px 8px 2px; + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + } + + .menu-section__empty { + padding: 2px 8px 4px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + } `, ]; + override connectedCallback(): void { + super.connectedCallback(); + this._resizeObserver = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? 0; + // Zero width means we're hidden, not narrow — keep the current layout. + if (width > 0) { + this._hostWidth = width; + this._fit(); + } + }); + this._resizeObserver.observe(this); + } + + override disconnectedCallback(): void { + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + super.disconnectedCallback(); + } + + /** + * A new log can widen a chunk — a badge gaining a digit, or log meta replacing its + * skeleton — and a collapsed chunk has no box to re-measure. So content changes reset + * the ladder to fully inline and discard the cache; `updated` then re-measures every + * chunk from its real box and re-settles. + */ + override willUpdate(changed: PropertyValues): void { + if ( + changed.has('logSize') || + changed.has('logDuration') || + changed.has('logProblems') || + changed.has('notifications') + ) { + this._widths.clear(); + this._visible = CHUNKS.length; + } + } + + /** Re-fit after content changes: a new log can change a badge's width. */ + override updated(): void { + this._fit(); + } + render() { const sizeText = this._toSize(this.logSize), elapsedText = this._formatDuration(this.logDuration); + // Derived from CHUNKS rather than restated, so re-ordering the ladder is one edit. + const show = Object.fromEntries(CHUNKS.map((chunk, i) => [chunk, i < this._visible])) as Record< + Chunk, + boolean + >; + const collapsed = this._collapsedSections(show); return html` `; } - _goToLog() { - vscodeMessenger.send('openPath', this.logPath); + /** + * Which chunks fit. The stage is a pure function of the host width, so it can't flap: + * `computeVisibleCount` counts the leading (highest-priority) chunks that fit once the + * title's floor and the always-present `•••` are set aside. Gaps are already folded + * into the cached widths. + */ + private _fit(): void { + if (!this._hostWidth) { + return; + } + + this._measure(); + const widths = CHUNKS.map((chunk) => this._widths.get(chunk) ?? 0); + // Nothing measured yet (first paint, or the webview is hidden) — leave the layout be. + if (widths.some((width) => width === 0)) { + return; + } + + const avail = this._hostWidth - this._titleFloor - this._menuWidth; + // `_fit` also runs from `updated`, so this must settle: an unchanged stage is not a + // change, so lit schedules nothing and the loop stops. + this._visible = computeVisibleCount(widths, avail, 0, 0); + } + + private _measure(): void { + const root = this.shadowRoot; + if (!root) { + return; + } + + for (const chunk of CHUNKS) { + const width = root.querySelector(`.chunk--${chunk}`)?.offsetWidth ?? 0; + if (width > 0) { + this._widths.set(chunk, width + CHUNK_GAP); + } + } + + const menuWidth = root.querySelector('header-menu')?.offsetWidth ?? 0; + if (menuWidth > 0) { + this._menuWidth = menuWidth + CHUNK_GAP; + } + + // The floor belongs to log-title's stylesheet; read it rather than restate it in px + // here, where a font change or a `ch` tweak would silently desync the ladder. + const title = root.querySelector('log-title'); + const floor = title ? parseFloat(getComputedStyle(title).minWidth) : NaN; + if (floor > 0) { + this._titleFloor = floor; + } + } + + /** + * Collapsed controls go into the menu as content, not as their own popover triggers: + * a trigger inside an open popover means nesting native popovers and clicking twice. + */ + private _collapsedSections(show: Record): TemplateResult[] { + const sections: TemplateResult[] = []; + + if (!show.bell) { + sections.push( + this._issueSection('Notifications', this.notifications, 'Log parsed with no issues'), + ); + } + if (!show.inspector) { + sections.push( + html``, + ); + } + if (!show.problems && this.logProblems) { + sections.push( + this._issueSection('Log problems', this.logProblems, 'No problems found in this log'), + ); + } + + return sections; + } + + private _issueSection( + label: string, + issues: readonly LogIssue[], + emptyMessage: string, + ): TemplateResult { + return html``; + } + + /** + * The dot on `•••` stands in for whichever counts have left the header. Presence only, + * not severity: header chrome carries no severity colour. + */ + private _collapsedMarker(show: Record): boolean { + return ( + (!show.bell && this.notifications.length > 0) || + (!show.problems && (this.logProblems?.length ?? 0) > 0) + ); + } + + private _toggleInspector(): void { + eventBus.emit('detail:toggle', {}); } _formatDuration(duration: number | null) { diff --git a/log-viewer/src/components/__tests__/AnchoredPopover.test.ts b/log-viewer/src/components/__tests__/AnchoredPopover.test.ts new file mode 100644 index 00000000..1dbea84d --- /dev/null +++ b/log-viewer/src/components/__tests__/AnchoredPopover.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; + +// jsdom can't run the real elements (they read document.baseURI / setFormValue). +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); + +import type { AnchoredPopover } from '../AnchoredPopover.js'; +import '../AnchoredPopover.js'; + +async function mount(panelContent: string, showHeading = false): Promise { + const el = document.createElement('anchored-popover') as AnchoredPopover; + el.heading = 'Log problems'; + el.emptyMessage = 'No problems found in this log'; + if (showHeading) { + el.setAttribute('show-heading', ''); + } + el.innerHTML = `face${panelContent}`; + document.body.appendChild(el); + await el.updateComplete; + // The panel slot is only readable after the first render, which schedules a second. + await el.updateComplete; + return el; +} + +function panel(el: AnchoredPopover): HTMLElement | null { + return el.shadowRoot?.querySelector('.panel') ?? null; +} + +describe('AnchoredPopover', () => { + it('opens from the trigger via the native popover API, not a hover handler', async () => { + const el = await mount('
an issue
'); + const trigger = el.shadowRoot?.querySelector('.trigger'); + + // A popovertarget button gives click-to-open plus light-dismiss for free; hover + // opening would trap the links these panels contain. + expect(trigger?.getAttribute('popovertarget')).toBe(panel(el)?.id); + expect(panel(el)?.hasAttribute('popover')).toBe(true); + }); + + it('still opens with nothing to show, replacing the slot with the empty message', async () => { + const el = await mount(''); + + expect(el.shadowRoot?.querySelector('.panel__empty')?.textContent).toContain( + 'No problems found in this log', + ); + expect(el.shadowRoot?.querySelector('.panel__items--empty')).not.toBeNull(); + }); + + it('hides the empty message once the panel slot has content', async () => { + const el = await mount('
an issue
'); + + expect(el.shadowRoot?.querySelector('.panel__empty')).toBeNull(); + expect(el.shadowRoot?.querySelector('.panel__items--empty')).toBeNull(); + }); + + it('labels the panel with its heading without showing it — menus are untitled', async () => { + const el = await mount(''); + + expect(panel(el)?.getAttribute('aria-label')).toBe('Log problems'); + expect(el.shadowRoot?.querySelector('.panel__head')).toBeNull(); + }); + + it('renders the heading visibly only with show-heading', async () => { + const el = await mount('', true); + + expect(el.shadowRoot?.querySelector('.panel__head')?.textContent).toBe('Log problems'); + }); +}); diff --git a/log-viewer/src/components/__tests__/HeaderMenu.test.ts b/log-viewer/src/components/__tests__/HeaderMenu.test.ts new file mode 100644 index 00000000..c569a134 --- /dev/null +++ b/log-viewer/src/components/__tests__/HeaderMenu.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; + +// jsdom can't run the real elements (they read document.baseURI / setFormValue). +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); + +import type { HeaderMenu } from '../HeaderMenu.js'; +import '../HeaderMenu.js'; + +async function mount( + marker: boolean, + collapsed = '', + collapsedCount = collapsed ? 1 : 0, +): Promise { + const el = document.createElement('header-menu') as HeaderMenu; + el.marker = marker; + el.collapsedCount = collapsedCount; + el.innerHTML = collapsed; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +function rowLabels(el: HeaderMenu): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.filter-popover-row') ?? []).map( + (n) => n.querySelector('span')?.textContent ?? '', + ); +} + +describe('HeaderMenu', () => { + it('always offers its own rows, so the toggle is never empty', async () => { + expect(rowLabels(await mount(false))).toEqual(['Help & documentation', 'Report an issue']); + }); + + it('sends reporting through VS Code rather than a raw external href', async () => { + const el = await mount(false); + const href = el.shadowRoot?.querySelector('a.filter-popover-row')?.getAttribute('href') ?? ''; + + expect(href.startsWith('command:vscode.open?')).toBe(true); + expect(decodeURIComponent(href)).toContain('certinia/debug-log-analyzer/issues'); + }); + + it('gives the link row the same face as the button row, links styling included', async () => { + const el = await mount(false); + const rows = Array.from(el.shadowRoot?.querySelectorAll('.filter-popover-row') ?? []); + + expect(rows.map((n) => n.tagName)).toEqual(['BUTTON', 'A']); + expect(rows.every((n) => n.classList.contains('menu-row'))).toBe(true); + // The shared face resets `a`'s link colour and underline; nothing may re-add them inline. + expect(rows.some((n) => n.hasAttribute('style'))).toBe(false); + }); + + it('marks the toggle only when something has collapsed into it', async () => { + expect((await mount(false)).shadowRoot?.querySelector('.toggle__marker')).toBeNull(); + + const marked = await mount(true); + expect(marked.shadowRoot?.querySelector('.toggle__marker')).not.toBeNull(); + }); + + it('counts what it is holding in its label, marker or not', async () => { + const label = (el: HeaderMenu) => + el.shadowRoot?.querySelector('.toggle')?.getAttribute('aria-label'); + + expect(label(await mount(false))).toBe('More'); + // Collapsed with nothing to flag — the count is announced even without a marker. + expect(label(await mount(false, 'a'))).toBe( + 'More — 1 collapsed item', + ); + expect(label(await mount(true, 'a', 3))).toBe( + 'More — 3 collapsed items', + ); + }); + + it('closes when a collapsed command is used — light-dismiss only covers outside clicks', async () => { + const el = await mount(false, ''); + const popover = el.shadowRoot?.querySelector('anchored-popover') as unknown as { + close: () => void; + }; + const close = jest.fn(); + popover.close = close; + + // Chrome alone among the panel's contents: a click on it commands nothing. + el.shadowRoot?.querySelector('.collapsed')?.click(); + expect(close).not.toHaveBeenCalled(); + + el.querySelector('button[slot="collapsed"]')?.click(); + expect(close).toHaveBeenCalled(); + }); + + it('frames the collapsed area only once something is slotted into it', async () => { + const empty = await mount(false); + expect(empty.shadowRoot?.querySelector('.collapsed--empty')).not.toBeNull(); + expect(empty.shadowRoot?.querySelector('divider-line')).toBeNull(); + + const filled = await mount(false, 'bell'); + expect(filled.shadowRoot?.querySelector('.collapsed--empty')).toBeNull(); + expect(filled.shadowRoot?.querySelector('divider-line')).not.toBeNull(); + }); + + it('leaves its panel untitled, as VS Code menus are', async () => { + const el = await mount(false); + const popover = el.shadowRoot?.querySelector('anchored-popover'); + + expect(popover?.hasAttribute('show-heading')).toBe(false); + expect(popover?.getAttribute('heading')).toBe('More'); + }); +}); diff --git a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts new file mode 100644 index 00000000..84bd002b --- /dev/null +++ b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; + +// jsdom can't run the real elements (they read document.baseURI / setFormValue). +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); +jest.mock('#vscode-elements/vscode-button.js', () => ({})); + +import type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; + +import type { LogProblemsChip } from '../LogProblemsChip.js'; +import '../LogProblemsChip.js'; + +function issue(severity: IssueSeverity): LogIssue { + return { summary: severity, message: '', severity, eventIndex: null, timestamp: null }; +} + +async function mount(issues: readonly LogIssue[] | null): Promise { + const el = document.createElement('log-problems') as LogProblemsChip; + el.issues = issues; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +function chip(el: LogProblemsChip): HTMLElement | null { + return el.shadowRoot?.querySelector('.header-control') ?? null; +} + +function face(el: LogProblemsChip): { icon: string | null; count: string; label: string | null } { + const control = chip(el); + return { + icon: control?.querySelector('vscode-icon')?.getAttribute('name') ?? null, + count: control?.querySelector('.header-control__badge')?.textContent?.trim() ?? '', + label: control?.getAttribute('aria-label') ?? null, + }; +} + +describe('LogProblemsChip', () => { + it('shows a skeleton until the log has been parsed', async () => { + const el = await mount(null); + + expect(el.shadowRoot?.querySelector('icon-button-skeleton')).not.toBeNull(); + expect(chip(el)).toBeNull(); + }); + + it('shows a dimmed tick and no badge for a clean log', async () => { + const el = await mount([]); + + expect(face(el)).toEqual({ icon: 'pass', count: '', label: 'No problems' }); + expect(chip(el)?.classList.contains('problems--clean')).toBe(true); + }); + + it('takes its glyph from the worst severity and its count from the total', async () => { + const el = await mount([issue('error'), issue('warning'), issue('warning')]); + + expect(face(el)).toEqual({ + icon: 'error', + count: '3', + label: '3 problems — 1 error, 2 warnings', + }); + expect(chip(el)?.classList.contains('problems--clean')).toBe(false); + }); + + it('falls back to the worst severity present when there is no error', async () => { + expect(face(await mount([issue('warning'), issue('info')])).icon).toBe('warning'); + expect(face(await mount([issue('info')])).icon).toBe('info'); + }); + + it('never tints the glyph — header chrome is monochrome', async () => { + for (const severity of ['error', 'warning', 'info'] as const) { + const control = chip(await mount([issue(severity)])); + + expect(control?.querySelector('vscode-icon')?.getAttribute('style')).toBeNull(); + expect(control?.getAttribute('style')).toBeNull(); + } + }); + + it('renders the issue list only when there is something to list', async () => { + expect((await mount([])).shadowRoot?.querySelector('issue-list')).toBeNull(); + expect((await mount([issue('info')])).shadowRoot?.querySelector('issue-list')).not.toBeNull(); + }); +}); diff --git a/log-viewer/src/components/__tests__/NavBar.test.ts b/log-viewer/src/components/__tests__/NavBar.test.ts new file mode 100644 index 00000000..bafe9e10 --- /dev/null +++ b/log-viewer/src/components/__tests__/NavBar.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'; + +// jsdom can't run the real elements (they read document.baseURI / setFormValue). +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); +jest.mock('#vscode-elements/vscode-toolbar-button.js', () => ({})); +jest.mock('#vscode-elements/vscode-button.js', () => ({})); + +import type { IssueSeverity, LogIssue } from '../../features/notifications/types.js'; + +import type { NavBar } from '../NavBar.js'; +import '../NavBar.js'; + +/** Drives the observed width, standing in for a panel drag. */ +let notify: ((width: number) => void) | null = null; + +class StubResizeObserver { + private cb: ResizeObserverCallback; + + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + } + + observe(target: Element): void { + notify = (width) => + this.cb( + [{ target, contentRect: { width } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + + unobserve(): void {} + disconnect(): void {} +} + +/** + * jsdom lays nothing out, so every `offsetWidth` is 0 and the ladder would never engage. + * With these widths each chunk costs its width + the 6px gap, `•••` costs 36 and the title + * floor falls back to 140 (jsdom resolves no `min-width`), putting the stage boundaries at + * 390 / 284 / 248 / 212 px. + */ +const DEFAULT_CHUNK_WIDTHS: Readonly> = { + 'chunk--meta': 100, + 'chunk--problems': 30, + 'chunk--inspector': 30, + 'chunk--bell': 30, +}; + +/** Mutable so a test can widen a chunk the way a new log's content does. */ +let CHUNK_WIDTHS: Record = { ...DEFAULT_CHUNK_WIDTHS }; + +beforeEach(() => { + CHUNK_WIDTHS = { ...DEFAULT_CHUNK_WIDTHS }; +}); + +beforeAll(() => { + (globalThis as unknown as Record).ResizeObserver = StubResizeObserver; + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get(this: HTMLElement): number { + if (this.tagName === 'HEADER-MENU') { + return 30; + } + for (const [className, width] of Object.entries(CHUNK_WIDTHS)) { + if (this.classList.contains(className)) { + return width; + } + } + return 0; + }, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'offsetWidth'); +}); + +function issue(severity: IssueSeverity): LogIssue { + return { summary: severity, message: '', severity, eventIndex: null, timestamp: null }; +} + +async function mount( + problems: readonly LogIssue[] = [], + notifications: readonly LogIssue[] = [], +): Promise { + const el = document.createElement('nav-bar') as NavBar; + el.logName = 'test.log'; + el.logProblems = problems; + el.notifications = notifications; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +async function resize(el: NavBar, width: number): Promise { + notify?.(width); + // One update to apply the new stage, a second for the re-fit it triggers. + await el.updateComplete; + await el.updateComplete; + return el; +} + +function inlineChunks(el: NavBar): string[] { + return ['meta', 'problems', 'inspector', 'bell'].filter((chunk) => + el.shadowRoot?.querySelector(`.chunk--${chunk}`), + ); +} + +function menuSections(el: NavBar): string[] { + const menu = el.shadowRoot?.querySelector('[slot="collapsed"]'); + return Array.from(menu?.querySelectorAll('.menu-section__label, .menu-row span') ?? []).map( + (n) => n.textContent?.trim() ?? '', + ); +} + +function marker(el: NavBar): boolean { + const menu = el.shadowRoot?.querySelector('header-menu') as unknown as { + marker: boolean; + } | null; + return menu?.marker ?? false; +} + +describe('NavBar collapse ladder', () => { + it('keeps everything inline when there is room', async () => { + const el = await resize(await mount(), 800); + + expect(inlineChunks(el)).toEqual(['meta', 'problems', 'inspector', 'bell']); + expect(el.shadowRoot?.querySelector('[slot="collapsed"]')).toBeNull(); + }); + + it('sheds log meta first, keeping its values in the title tooltip', async () => { + const el = await resize(await mount(), 350); + + expect(inlineChunks(el)).toEqual(['problems', 'inspector', 'bell']); + expect(el.shadowRoot?.querySelector('[slot="collapsed"]')).toBeNull(); + }); + + it('moves the bell into the menu next', async () => { + const el = await resize(await mount([], [issue('warning')]), 270); + + expect(inlineChunks(el)).toEqual(['problems', 'inspector']); + expect(menuSections(el)).toEqual(['Notifications (1)']); + }); + + it('then the Inspector toggle, as a command row', async () => { + const el = await resize(await mount(), 230); + + expect(inlineChunks(el)).toEqual(['problems']); + expect(menuSections(el)).toEqual(['Notifications', 'Toggle Inspector']); + }); + + it('sheds log problems last, and never the ••• menu', async () => { + const el = await resize(await mount([issue('error'), issue('info')]), 180); + + expect(inlineChunks(el)).toEqual([]); + expect(menuSections(el)).toEqual(['Notifications', 'Toggle Inspector', 'Log problems (2)']); + expect(el.shadowRoot?.querySelector('header-menu')).not.toBeNull(); + }); + + it('re-measures a collapsed chunk after its content changes', async () => { + CHUNK_WIDTHS['chunk--meta'] = 40; + // 300px sheds meta at that width, so it has no box left to measure. + const el = await resize(await mount(), 300); + expect(inlineChunks(el)).toEqual(['problems', 'inspector', 'bell']); + + // A log loads: meta swaps its skeleton for real values and gets much wider. + CHUNK_WIDTHS['chunk--meta'] = 200; + el.logSize = 71_000_000; + await el.updateComplete; + await el.updateComplete; + + // 400px fits the *skeleton* width but not the real one — a stale cache would put + // meta back inline and push the right-hand group off the edge. + expect(inlineChunks(await resize(el, 400))).toEqual(['problems', 'inspector', 'bell']); + }); + + it('marks ••• once a collapsed section has content — presence, not severity', async () => { + const el = await mount([issue('error')], [issue('warning')]); + + expect(marker(await resize(el, 800))).toBe(false); + // The bell has left the header and it was carrying something. + expect(marker(await resize(el, 270))).toBe(true); + expect(marker(await resize(el, 180))).toBe(true); + }); + + it('leaves ••• unmarked when everything collapsed in is empty', async () => { + const el = await mount([], []); + + expect(marker(await resize(el, 180))).toBe(false); + expect(menuSections(el)).toEqual(['Notifications', 'Toggle Inspector', 'Log problems']); + }); +}); diff --git a/log-viewer/src/features/app/AppHeader.ts b/log-viewer/src/features/app/AppHeader.ts index 214c04e1..3879bdb9 100644 --- a/log-viewer/src/features/app/AppHeader.ts +++ b/log-viewer/src/features/app/AppHeader.ts @@ -5,7 +5,7 @@ import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import type { ApexLog } from 'apex-log-parser'; -import type { Notification } from '../notifications/components/NotificationPanel.js'; +import type { LogIssue } from '../notifications/types.js'; // web components import '../../components/LogLevels.js'; @@ -29,10 +29,10 @@ export class AppHeader extends LitElement { logSize = null; @property() logDuration = null; - @property() - notifications: Notification[] | null = null; - @property() - parserIssues: Notification[] = []; + @property({ attribute: false }) + logProblems: readonly LogIssue[] | null = null; + @property({ attribute: false }) + notifications: readonly LogIssue[] = []; @property() timelineRoot: ApexLog | null = null; @@ -59,8 +59,8 @@ export class AppHeader extends LitElement { .logPath=${this.logPath} .logSize=${this.logSize} .logDuration=${this.logDuration} + .logProblems=${this.logProblems} .notifications=${this.notifications} - .parserIssues=${this.parserIssues} > diff --git a/log-viewer/src/features/app/LogViewer.ts b/log-viewer/src/features/app/LogViewer.ts index 6724ad57..71f5aef4 100644 --- a/log-viewer/src/features/app/LogViewer.ts +++ b/log-viewer/src/features/app/LogViewer.ts @@ -16,10 +16,7 @@ import { vscodeMessenger, } from '../../core/messaging/VSCodeExtensionMessenger.js'; import { DatabaseAccess } from '../database/services/Database.js'; -import { - Notification, - type NotificationSeverity, -} from '../notifications/components/NotificationPanel.js'; +import type { IssueSeverity, LogIssue } from '../notifications/types.js'; // styles import { globalStyles } from '../../styles/global.styles.js'; @@ -46,10 +43,12 @@ export class LogViewer extends LitElement { logSize: number | null = null; @property() logDuration: number | null = null; - @property() - notifications: Notification[] | null = null; - @property() - parserIssues: Notification[] = []; + /** Problems found in the log itself. `null` until the first log is parsed. */ + @property({ attribute: false }) + logProblems: readonly LogIssue[] | null = null; + /** Notifications about the tool — today, parser diagnostics. */ + @property({ attribute: false }) + notifications: readonly LogIssue[] = []; @property() timelineRoot: ApexLog | null = null; @@ -144,8 +143,8 @@ export class LogViewer extends LitElement { .logPath=${this.logPath} .logSize=${this.logSize} .logDuration=${this.logDuration} + .logProblems=${this.logProblems} .notifications=${this.notifications} - .parserIssues=${this.parserIssues} .timelineRoot=${this.timelineRoot} > @@ -225,7 +224,16 @@ export class LogViewer extends LitElement { this.logPath = data.logPath?.trim() || ''; const logUri = data.logUri; - const logData = data.logData || (await this._readLog(logUri || '')); + const read = data.logData + ? { logData: data.logData, error: null } + : await this._readLog(logUri || ''); + const logData = read.logData; + + // Published before parsing, so a throw further down can't discard the only + // explanation the user would get. `logProblems` stays null while parsing otherwise. + if (read.error) { + this.logProblems = [read.error]; + } const apexLog = parse(logData); @@ -237,21 +245,20 @@ export class LogViewer extends LitElement { this.timelineRoot = apexLog; this.logDuration = apexLog.duration.total; - const localNotifications = Array.from(this.notifications ?? []); - apexLog.logIssues.forEach((element) => { - const severity = this.toSeverity(element.type); - - const logMessage = new Notification(); - logMessage.summary = element.summary; - logMessage.message = element.description; - logMessage.severity = severity; - logMessage.eventIndex = element.eventIndex ?? null; - logMessage.timestamp = element.startTime || null; - localNotifications.push(logMessage); - }); - this.notifications = localNotifications; - - this.parserIssues = this.parserIssuesToMessages(apexLog); + // Rebuilt per load, never appended to: both surfaces describe *this* log, so a + // previous log's problems must not carry over. + this.logProblems = [ + ...(read.error ? [read.error] : []), + ...apexLog.logIssues.map((issue): LogIssue => ({ + summary: issue.summary, + message: issue.description, + severity: toSeverity(issue.type), + eventIndex: issue.eventIndex ?? null, + timestamp: issue.startTime || null, + })), + ]; + + this.notifications = parserIssuesToNotifications(apexLog); // Navigate to event location if requested (passed as prop to timeline-view) if (data.navigateToEventIndex !== undefined || data.navigateToTimestamp !== undefined) { @@ -261,7 +268,11 @@ export class LogViewer extends LitElement { } } - async _readLog(logUri: string): Promise { + /** + * Reads the log, returning the failure as a {@link LogIssue} rather than publishing it — + * the caller owns `logProblems` so it can rebuild the list for each load. + */ + async _readLog(logUri: string): Promise<{ logData: string; error: LogIssue | null }> { let msg; if (logUri) { try { @@ -279,7 +290,7 @@ export class LogViewer extends LitElement { } chunks.push(value); } - return chunks.join(''); + return { logData: chunks.join(''), error: null }; } catch (err: unknown) { msg = (err instanceof Error ? err.message : String(err)) ?? ''; } @@ -287,47 +298,54 @@ export class LogViewer extends LitElement { msg = 'Invalid Log Path'; } - const logMessage = new Notification(); - logMessage.summary = 'Could not read log'; - logMessage.message = msg; - logMessage.severity = 'Error'; - this.notifications = [logMessage]; - return ''; + return { + logData: '', + error: { + summary: 'Could not read log', + message: msg, + severity: 'error', + eventIndex: null, + timestamp: null, + }, + }; } +} - severity = new Map([ - ['error', 'Error'], - ['unexpected', 'Warning'], - ['skip', 'Info'], - ]); - private toSeverity(errorType: 'unexpected' | 'error' | 'skip') { - return this.severity.get(errorType) || 'Info'; - } +const SEVERITY_BY_ISSUE_TYPE: ReadonlyMap = new Map([ + ['error', 'error'], + ['unexpected', 'warning'], + ['skip', 'info'], +]); - private parserIssuesToMessages(apexLog: ApexLog) { - const issues: Notification[] = []; - apexLog.parsingErrors.forEach((message) => { - const isUnknownType = this.isUnknownType(message); +function toSeverity(issueType: 'unexpected' | 'error' | 'skip'): IssueSeverity { + return SEVERITY_BY_ISSUE_TYPE.get(issueType) || 'info'; +} + +function parserIssuesToNotifications(apexLog: ApexLog): LogIssue[] { + return apexLog.parsingErrors.map((message): LogIssue => { + const unknownType = isUnknownType(message); - const logMessage = new Notification(); - logMessage.summary = isUnknownType ? message : message.slice(0, message.indexOf(':')); - logMessage.message = isUnknownType + return { + summary: unknownType ? message : message.slice(0, message.indexOf(':')), + message: unknownType ? html`report unsupported type` - : message.slice(message.indexOf(':') + 1); - - issues.push(logMessage); - }); - return issues; - } + : message.slice(message.indexOf(':') + 1), + // A parse gap means some of the log wasn't understood, which can silently skew + // every view built from it — a warning, not the untinted 'None' it used to be. + severity: 'warning', + eventIndex: null, + timestamp: null, + }; + }); +} - private isUnknownType(message: string) { - return message.startsWith('Unsupported log event name:'); - } +function isUnknownType(message: string): boolean { + return message.startsWith('Unsupported log event name:'); } interface LogDataEvent { diff --git a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts new file mode 100644 index 00000000..2e0d0b8e --- /dev/null +++ b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; + +// jsdom can't run the real elements (they read document.baseURI / setFormValue). +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); +jest.mock('#vscode-elements/vscode-button.js', () => ({})); + +import type { IssueSeverity, LogIssue } from '../types.js'; + +jest.mock('../../call-tree/navigation.js', () => ({ goToRow: jest.fn() })); + +import { goToRow } from '../../call-tree/navigation.js'; + +import type { IssueList } from '../components/IssueList.js'; +import '../components/IssueList.js'; + +const goToRowMock = goToRow as jest.Mock; + +function issue( + severity: IssueSeverity, + summary: string, + eventIndex: number | null = null, +): LogIssue { + return { summary, message: `${summary} detail`, severity, eventIndex, timestamp: null }; +} + +async function mount(issues: readonly LogIssue[]): Promise { + const el = document.createElement('issue-list') as IssueList; + el.issues = issues; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +function summaries(el: IssueList): string[] { + return Array.from(el.shadowRoot?.querySelectorAll('.issue__summary') ?? []).map( + (n) => n.textContent ?? '', + ); +} + +describe('IssueList', () => { + it('renders most severe first without mutating the input', async () => { + const issues = [issue('info', 'skipped'), issue('error', 'limit exception')]; + const el = await mount(issues); + + expect(summaries(el)).toEqual(['limit exception', 'skipped']); + expect(issues.map((i) => i.summary)).toEqual(['skipped', 'limit exception']); + }); + + it('offers call-tree navigation only for issues tied to an event', async () => { + const el = await mount([issue('error', 'has event', 42), issue('warning', 'no event')]); + + const buttons = el.shadowRoot?.querySelectorAll('.issue__nav') ?? []; + expect(buttons.length).toBe(1); + + (buttons[0] as HTMLElement).click(); + expect(goToRowMock).toHaveBeenCalledWith({ eventIndex: 42 }); + }); +}); diff --git a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts new file mode 100644 index 00000000..32d8ea64 --- /dev/null +++ b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; + +// jsdom can't run the real elements (they read document.baseURI / setFormValue). +jest.mock('#vscode-elements/vscode-icon.js', () => ({})); +jest.mock('#vscode-elements/vscode-button.js', () => ({})); + +import type { IssueSeverity, LogIssue } from '../types.js'; + +import type { NotificationCentre } from '../components/NotificationCentre.js'; +import '../components/NotificationCentre.js'; + +function issue(severity: IssueSeverity): LogIssue { + return { summary: severity, message: '', severity, eventIndex: null, timestamp: null }; +} + +async function mount(issues: readonly LogIssue[]): Promise { + const el = document.createElement('notification-centre') as NotificationCentre; + el.issues = issues; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +function badge(el: NotificationCentre): HTMLElement | null { + return el.shadowRoot?.querySelector('.header-control__badge') ?? null; +} + +describe('NotificationCentre', () => { + it('is a bell whatever the severity, so it cannot read as a second severity counter', async () => { + const el = await mount([issue('error')]); + + expect(el.shadowRoot?.querySelector('.header-control vscode-icon')?.getAttribute('name')).toBe( + 'bell', + ); + }); + + it('has no badge when there is nothing to report', async () => { + const el = await mount([]); + + expect(badge(el)).toBeNull(); + expect(el.shadowRoot?.querySelector('.header-control')?.getAttribute('aria-label')).toBe( + 'Notifications', + ); + }); + + it('badges the count and names the severities in its label', async () => { + const el = await mount([issue('warning'), issue('error')]); + + expect(badge(el)?.textContent?.trim()).toBe('2'); + expect(el.shadowRoot?.querySelector('.header-control')?.getAttribute('aria-label')).toBe( + '2 notifications — 1 error, 1 warning', + ); + }); +}); diff --git a/log-viewer/src/features/notifications/__tests__/types.test.ts b/log-viewer/src/features/notifications/__tests__/types.test.ts new file mode 100644 index 00000000..57230bdd --- /dev/null +++ b/log-viewer/src/features/notifications/__tests__/types.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { + countsBySeverity, + describeIssues, + sortBySeverity, + worstSeverity, + type IssueSeverity, + type LogIssue, +} from '../types.js'; + +function issue(severity: IssueSeverity, summary: string = severity): LogIssue { + return { summary, message: '', severity, eventIndex: null, timestamp: null }; +} + +describe('worstSeverity', () => { + it('returns null for an empty list', () => { + expect(worstSeverity([])).toBeNull(); + }); + + it('picks the most severe present regardless of order', () => { + expect(worstSeverity([issue('info'), issue('error'), issue('warning')])).toBe('error'); + expect(worstSeverity([issue('info'), issue('warning')])).toBe('warning'); + expect(worstSeverity([issue('info')])).toBe('info'); + }); +}); + +describe('countsBySeverity', () => { + it('counts most severe first and omits absent severities', () => { + expect(countsBySeverity([issue('info'), issue('error'), issue('info')])).toEqual([ + { severity: 'error', count: 1 }, + { severity: 'info', count: 2 }, + ]); + }); +}); + +describe('describeIssues', () => { + it('uses the empty label when there is nothing to report', () => { + expect(describeIssues([], 'problem', 'No problems')).toBe('No problems'); + }); + + it('gives just the total for a single severity', () => { + expect(describeIssues([issue('error')], 'problem', 'No problems')).toBe('1 problem'); + expect(describeIssues([issue('error'), issue('error')], 'problem', 'No problems')).toBe( + '2 problems', + ); + }); + + it('appends a breakdown once severities are mixed', () => { + const issues = [issue('error'), issue('warning'), issue('warning')]; + expect(describeIssues(issues, 'problem', 'No problems')).toBe( + '3 problems — 1 error, 2 warnings', + ); + }); +}); + +describe('sortBySeverity', () => { + it('orders most severe first, keeping producer order within a severity', () => { + const issues = [ + issue('info', 'first info'), + issue('warning', 'first warning'), + issue('error', 'the error'), + issue('info', 'second info'), + ]; + + expect(sortBySeverity(issues).map((i) => i.summary)).toEqual([ + 'the error', + 'first warning', + 'first info', + 'second info', + ]); + }); + + it('does not mutate the input', () => { + const issues = [issue('info'), issue('error')]; + sortBySeverity(issues); + expect(issues.map((i) => i.severity)).toEqual(['info', 'error']); + }); +}); diff --git a/log-viewer/src/features/notifications/components/IssueList.ts b/log-viewer/src/features/notifications/components/IssueList.ts new file mode 100644 index 00000000..65665260 --- /dev/null +++ b/log-viewer/src/features/notifications/components/IssueList.ts @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-button.js'; +import '#vscode-elements/vscode-icon.js'; +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { goToRow } from '../../call-tree/navigation.js'; +import { SEVERITY_META, sortBySeverity, type LogIssue } from '../types.js'; + +// styles +import { globalStyles } from '../../../styles/global.styles.js'; + +// web components +import '../../../components/Divider.js'; + +/** + * Renders a list of {@link LogIssue}s, most severe first — the shared body of both + * header popovers. + * + * Issues with an `eventIndex` navigate the call tree to that event. + */ +@customElement('issue-list') +export class IssueList extends LitElement { + @property({ attribute: false }) + issues: readonly LogIssue[] = []; + + static styles = [ + globalStyles, + css` + :host { + display: block; + } + + .issue { + display: flex; + gap: 8px; + padding: 8px; + border-radius: 4px; + overflow-wrap: anywhere; + text-wrap: wrap; + } + + .issue__body { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .issue__summary { + font-weight: 600; + font-size: 12px; + } + + .issue__message { + font-size: 12px; + color: var(--vscode-descriptionForeground); + } + + .issue__nav { + align-self: flex-start; + margin-top: 2px; + } + `, + ]; + + render() { + const issues = sortBySeverity(this.issues); + + return html`${issues.map( + (issue, index) => + html`${index > 0 ? html`` : ''} +
+ +
+ ${issue.summary} + ${issue.message ? html`${issue.message}` : ''} + ${ + issue.eventIndex !== null + ? html` this._goTo(issue.eventIndex)} + >Go To Call Tree` + : '' + } +
+
`, + )}`; + } + + private _goTo(eventIndex: number | null): void { + if (eventIndex !== null) { + goToRow({ eventIndex }); + } + } +} diff --git a/log-viewer/src/features/notifications/components/NotificationButton.ts b/log-viewer/src/features/notifications/components/NotificationButton.ts deleted file mode 100644 index 8da48207..00000000 --- a/log-viewer/src/features/notifications/components/NotificationButton.ts +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright (c) 2023 Certinia Inc. All rights reserved. - */ -import '#vscode-elements/vscode-divider.js'; -import { LitElement, css, html, type TemplateResult } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; - -// styles -import { globalStyles } from '../../../styles/global.styles.js'; -import { notificationStyles } from '../../../styles/notification.styles.js'; - -// web components -import '../../../components/IconButton.js'; -import './NotificationPanel.js'; - -@customElement('notification-button') -export class NotificationButton extends LitElement { - @state() - open = false; - - @property() - notifications: Notification[] = []; - - colorStyles = new Map([ - ['Error', 'error'], - ['Warning', 'warning'], - ['Info', 'info'], - ]); - - constructor() { - super(); - document.addEventListener('click', (event) => { - if (!event.composedPath().includes(this)) { - this.open = false; - } - }); - } - - static styles = [ - globalStyles, - css` - :host { - ${notificationStyles} - } - - .notification-panel { - position: absolute; - top: calc(100% + 10px); - right: 0px; - } - - .menu-container { - position: relative; - display: inline-flex; - } - - .notification { - padding: 8px 16px; - overflow-wrap: anywhere; - text-wrap: wrap; - display: flex; - gap: 8px; - border-radius: 4px; - } - - .text-container { - padding: 8px 0px 0px 0px; - } - - .error { - background-color: var(--notification-error-background); - } - - .warning { - background-color: var(--notification-warning-background); - } - - .info { - background-color: var(--notification-information-background); - } - `, - ]; - - render() { - const sortOrder = new Map([ - ['Error', 0], - ['Warning', 1], - ['Info', 2], - ]); - - this.notifications.sort((a, b) => { - return (sortOrder.get(a.severity) || 0) - (sortOrder.get(b.severity) || 0); - }); - - const messages: TemplateResult[] = []; - - const lastIndex = this.notifications.length - 1; - this.notifications.forEach((item, index) => { - const colorStyle = this.colorStyles.get(item.severity) || ''; - - const content = item.message - ? html`
- ${item.summary} -
${item.message}
-
` - : html`
${item.summary}
`; - - messages.push(html`
${content}
`); - if (index !== lastIndex) { - messages.push(html``); - } - }); - - const count = this.notifications.length || null; - return html``; - } - - _toggleNotifications() { - this.open = !this.open; - } -} - -export class Notification { - summary = ''; - message = ''; - severity: 'Error' | 'Warning' | 'Info' = 'Info'; - eventIndex: number | null = null; -} diff --git a/log-viewer/src/features/notifications/components/NotificationCentre.ts b/log-viewer/src/features/notifications/components/NotificationCentre.ts new file mode 100644 index 00000000..c0f1e328 --- /dev/null +++ b/log-viewer/src/features/notifications/components/NotificationCentre.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-icon.js'; +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { describeIssues, worstSeverity, type LogIssue } from '../types.js'; + +// styles +import { globalStyles } from '../../../styles/global.styles.js'; +import { headerControlStyles } from '../../../styles/headerControl.styles.js'; + +// web components +import '../../../components/AnchoredPopover.js'; +import './IssueList.js'; + +/** + * Header notification centre — messages about the tool rather than about the log. + * Today the only producer is the parser (unsupported event names, invalid lines). + * + * A bell rather than a severity glyph: the log-problems chip next door already owns + * severity, and two severity-shaped counters in one row read as one thing split in + * two. The bell carries only "are there messages"; the reassurance that the log + * parsed cleanly lives in the panel's empty state, since an empty bell can't claim it. + */ +@customElement('notification-centre') +export class NotificationCentre extends LitElement { + @property({ attribute: false }) + issues: readonly LogIssue[] = []; + + static styles = [ + globalStyles, + headerControlStyles, + css` + :host { + display: inline-flex; + flex: 0 0 auto; + } + `, + ]; + + render() { + const issues = this.issues; + const worst = worstSeverity(issues); + const label = describeIssues(issues, 'notification', 'Notifications'); + + return html` + + + ${worst ? html`${issues.length}` : ''} + + ${issues.length ? html`` : ''} + `; + } +} diff --git a/log-viewer/src/features/notifications/components/NotificationPanel.ts b/log-viewer/src/features/notifications/components/NotificationPanel.ts deleted file mode 100644 index 701a7190..00000000 --- a/log-viewer/src/features/notifications/components/NotificationPanel.ts +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) 2023 Certinia Inc. All rights reserved. - */ -import { LitElement, css, html, type TemplateResult } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; - -// styles -import { globalStyles } from '../../../styles/global.styles.js'; -import { notificationStyles } from '../../../styles/notification.styles.js'; - -@customElement('notification-panel') -export class NotificationPanel extends LitElement { - @property({ type: Boolean }) - open = false; - - static styles = [ - globalStyles, - css` - :host { - z-index: 999; - ${notificationStyles} - } - .container { - background-color: var(--vscode-editor-background); - max-height: 540px; - width: 320px; - padding: 8px 4px 8px 4px; - border: 1px solid var(--divider-background); - box-shadow: rgba(0, 0, 0, 0.5) 0px 4px 20px; - border-radius: 4px; - overflow: scroll; - } - - .closed { - display: none; - } - .notification { - padding: 8px 16px; - overflow-wrap: anywhere; - text-wrap: wrap; - display: flex; - gap: 8px; - border-radius: 4px; - } - .error-list { - display: flex; - flex-direction: column; - } - - .notification-icon { - justify-content: center; - display: flex; - flex-direction: column; - } - - .no-messages { - display: flex; - justify-content: center; - } - - .error { - background-color: var(--notification-error-background); - } - - .warning { - background-color: var(--notification-warning-background); - } - - .info { - background-color: var(--notification-information-background); - } - - .text-container { - padding: 8px 0px 0px 0px; - } - `, - ]; - - render() { - return html`
-
-

No Items!

-
-
`; - } -} - -export type NotificationSeverity = 'Error' | 'Warning' | 'Info' | 'None'; -export class Notification { - summary = ''; - message: string | TemplateResult<1> = ''; - severity: NotificationSeverity = 'None'; - eventIndex: number | null = null; - timestamp: number | null = null; -} diff --git a/log-viewer/src/features/notifications/types.ts b/log-viewer/src/features/notifications/types.ts new file mode 100644 index 00000000..ef4401f5 --- /dev/null +++ b/log-viewer/src/features/notifications/types.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { TemplateResult } from 'lit'; + +/** Severity of a single issue. Ordered most→least severe by `SEVERITY_ORDER`. */ +export type IssueSeverity = 'error' | 'warning' | 'info'; + +/** + * One entry in either header surface: a problem found in the log (governor limit + * exception, skipped lines) or a notification about the parse itself. + * + * `readonly` throughout so a consumer can't sort or edit the producer's array in + * place — renderers copy before sorting. + */ +export interface LogIssue { + readonly summary: string; + readonly message: string | TemplateResult<1>; + readonly severity: IssueSeverity; + /** Call-tree event to navigate to, or `null` when the issue isn't tied to one. */ + readonly eventIndex: number | null; + readonly timestamp: number | null; +} + +/** Sort/comparison rank — lower is more severe. */ +export const SEVERITY_ORDER: Readonly> = { + error: 0, + warning: 1, + info: 2, +}; + +interface SeverityMeta { + /** Codicon name. */ + readonly icon: string; + /** Theme colour for panel rows only — header chrome is monochrome. */ + readonly color: string; + /** Singular noun used to build the breakdown sentence. */ + readonly label: string; +} + +export const SEVERITY_META: Readonly> = { + error: { + icon: 'error', + color: 'var(--vscode-editorError-foreground)', + label: 'error', + }, + warning: { + icon: 'warning', + color: 'var(--vscode-editorWarning-foreground)', + label: 'warning', + }, + info: { + icon: 'info', + color: 'var(--vscode-editorInfo-foreground)', + label: 'info', + }, +}; + +/** The most severe severity present, or `null` for an empty list. */ +export function worstSeverity(issues: readonly LogIssue[]): IssueSeverity | null { + return issues.reduce( + (worst, issue) => + worst === null || SEVERITY_ORDER[issue.severity] < SEVERITY_ORDER[worst] + ? issue.severity + : worst, + null, + ); +} + +/** How many issues of each severity, most severe first. Severities with none are omitted. */ +export function countsBySeverity( + issues: readonly LogIssue[], +): ReadonlyArray<{ severity: IssueSeverity; count: number }> { + return (Object.keys(SEVERITY_ORDER) as IssueSeverity[]) + .map((severity) => ({ + severity, + count: issues.filter((issue) => issue.severity === severity).length, + })) + .filter(({ count }) => count > 0); +} + +/** + * Human breakdown for a tooltip / `aria-label`, e.g. + * `"3 problems — 1 error, 2 warnings"`, or `emptyLabel` when there are none. + */ +export function describeIssues( + issues: readonly LogIssue[], + noun: string, + emptyLabel: string, +): string { + if (!issues.length) { + return emptyLabel; + } + + const total = `${issues.length} ${plural(noun, issues.length)}`; + const counts = countsBySeverity(issues); + // A single-severity list would read "1 error — 1 error"; the total says it all. + if (counts.length < 2) { + return total; + } + + const breakdown = counts + .map(({ severity, count }) => `${count} ${plural(SEVERITY_META[severity].label, count)}`) + .join(', '); + return `${total} — ${breakdown}`; +} + +/** Sort a copy most severe first, preserving producer order within a severity. */ +export function sortBySeverity(issues: readonly LogIssue[]): LogIssue[] { + return [...issues].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); +} + +function plural(noun: string, count: number): string { + return count === 1 ? noun : `${noun}s`; +} diff --git a/log-viewer/src/styles/headerControl.styles.ts b/log-viewer/src/styles/headerControl.styles.ts new file mode 100644 index 00000000..6470a899 --- /dev/null +++ b/log-viewer/src/styles/headerControl.styles.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { css } from 'lit'; + +/** + * The header's bare icon buttons (log problems, notifications) and their corner count + * badge. Shared so the two controls stay one visual family, and sized to match + * `vscode-toolbar-button` so the Inspector toggle lines up with them. + * + * Chrome is monochrome: no severity colour here, on the glyph or the badge. VS Code's own + * toolbar glyphs take `icon.foreground` and its activity-bar badge is the flat accent + * whatever it counts; severity colour belongs to content rows (the panel's issue list, the + * timeline), which is what `problemsErrorIcon.foreground` is for. Don't re-add a tint — + * shape and count carry the severity here. + */ +export const headerControlStyles = css` + .header-control { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 22px; + border-radius: 4px; + color: var(--vscode-foreground); + } + + .header-control:hover { + background-color: var(--vscode-toolbar-hoverBackground); + } + + /* Bottom-right, like the activity bar's — and clear of the header's top edge, which + clipped a top-aligned badge. */ + .header-control__badge { + position: absolute; + bottom: -2px; + right: -3px; + box-sizing: border-box; + min-width: 12px; + height: 12px; + padding: 0 2px; + border-radius: 16px; + background-color: var(--vscode-activityBarBadge-background); + color: var(--vscode-activityBarBadge-foreground); + font-size: 9px; + font-weight: 600; + line-height: 12px; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; + } +`; diff --git a/log-viewer/src/styles/menuRow.styles.ts b/log-viewer/src/styles/menuRow.styles.ts new file mode 100644 index 00000000..2aa45c93 --- /dev/null +++ b/log-viewer/src/styles/menuRow.styles.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { css } from 'lit'; + +/** + * Layout half of a header-menu row. Pair with `.filter-popover-row` from + * `global.styles.ts`, which owns the look (padding, radius, hover) — that class is + * look-only, so every consumer supplies its own layout. + * + * Shared rather than copied because both `` and `` render rows + * into the same menu and must present one face. + */ +export const menuRowStyles = css` + .menu-row { + display: flex; + align-items: center; + box-sizing: border-box; + width: 100%; + gap: 8px; + background: none; + border: 0; + color: inherit; + /* Kills the UA button font, so restate the row's own size. */ + font: inherit; + font-size: var(--filter-popover-row-font-size); + text-align: left; + cursor: pointer; + } + + /* globalStyles' a:hover (0,1,1) outranks .menu-row (0,1,0), so a row that happens + to be a link would render blue and underlined next to an identical button. A menu + row is a menu row — the hover background is the only affordance. */ + a.menu-row, + a.menu-row:hover, + a.menu-row:active { + color: inherit; + text-decoration: none; + } +`; diff --git a/log-viewer/src/styles/notification.styles.ts b/log-viewer/src/styles/notification.styles.ts deleted file mode 100644 index 0aca3ff2..00000000 --- a/log-viewer/src/styles/notification.styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { css } from 'lit'; - -export const notificationStyles = css` - --notification-error-background: var(--vscode-editorError-background, rgba(255, 128, 128, 0.2)); - --notification-warning-background: rgba(128, 128, 255, 0.2); - --notification-information-background: rgb(30, 128, 255, 0.2); -`; From c79892a8750e81ad4f0f631996ad89b1359fa14b Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:23:23 +0100 Subject: [PATCH 2/2] feat(log-viewer): redesign the log problems and notification cards Each card now carries a 3px rail in the colour the timeline draws for the same issue, a summary and message that each clamp to two lines, and a stack trace that keeps its line breaks in the editor font. Clicking the message expands it; clicking the card runs its action. Activation is injected rather than built in: LogIssue carries an opaque IssueAction supplied by whoever produced the issue, so the list no longer knows about the call tree and any surface can reuse it. An unsupported log event reports itself through a prefilled GitHub bug report; an invalid log line stays static, since its text can carry customer data. --- CHANGELOG.md | 9 +- jest.config.js | 1 + lana/src/commands/LogView.ts | 10 + log-viewer/src/__tests__/setup.ts | 18 + .../__tests__/LogProblemsChip.test.ts | 9 +- .../src/components/__tests__/NavBar.test.ts | 15 +- log-viewer/src/features/app/LogViewer.ts | 59 +--- .../features/app/__tests__/logIssues.test.ts | 31 ++ .../app/__tests__/parserNotifications.test.ts | 44 +++ log-viewer/src/features/app/logIssues.ts | 31 ++ .../src/features/app/parserNotifications.ts | 61 ++++ .../call-tree/__tests__/navigation.test.ts | 25 +- .../src/features/call-tree/navigation.ts | 15 + .../notifications/__tests__/IssueList.test.ts | 236 ++++++++++++- .../__tests__/NotificationCentre.test.ts | 9 +- .../notifications/__tests__/types.test.ts | 2 +- .../notifications/components/IssueList.ts | 315 +++++++++++++++--- .../src/features/notifications/types.ts | 26 +- .../timeline/types/flamechart.types.ts | 20 +- 19 files changed, 816 insertions(+), 120 deletions(-) create mode 100644 log-viewer/src/__tests__/setup.ts create mode 100644 log-viewer/src/features/app/__tests__/logIssues.test.ts create mode 100644 log-viewer/src/features/app/__tests__/parserNotifications.test.ts create mode 100644 log-viewer/src/features/app/logIssues.ts create mode 100644 log-viewer/src/features/app/parserNotifications.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2587ec67..a5827e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 🗂️ **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]) -- 🧰 **Filter bar** (Call Tree, Database): filters now live in one toolbar above each table instead of in the column headers. Keep only the rows you care about — by **Namespace**, **Object** or **Caller Namespace** (multi-select, showing how many are picked), or by a **Row Count** / **Time Taken** min–max range; active filters are highlighted. On a narrow window the filters collapse behind a **Filter** button that opens them in a panel. ([#873]) ### Changed @@ -40,13 +42,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Truncation markers** now end where the log recovers, so trusted sections are no longer flag. ([#828]) - 🗂️ **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]). -- ♻️ Replace `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). - 🗄️ **Database table columns** (DML, SOQL, SOSL): consolidated onto the shared Call Tree column/sort styling for a consistent look across all tables. -- 🧱 **Data grids**: a crisper header/content separator and tidied grid styling across all tables. - 🎨 **Header bar** - **Log problems** icon now shows the most severe problem found, with a count. + - **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. - **Help & documentation** and **Report an issue** move into a `•••` menu, which also holds the controls the header drops as the window narrows. - - **Log problems** and **Notifications** panels redesigned. +- ♻️ Replace `webview-ui-toolkit` with [vscode-elements](https://github.com/vscode-elements/elements) for all UI controls. ([#576]). ### Fixed diff --git a/jest.config.js b/jest.config.js index 6d0b4b07..715ae32c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -35,6 +35,7 @@ export default { ...defaultConfig, displayName: 'log-viewer', rootDir: '/log-viewer', + setupFilesAfterEnv: ['/src/__tests__/setup.ts'], moduleNameMapper: { ...defaultConfig.moduleNameMapper, '^apex-log-parser$': '/../apex-log-parser/src/index.ts', diff --git a/lana/src/commands/LogView.ts b/lana/src/commands/LogView.ts index 9d415dc7..449f87f6 100644 --- a/lana/src/commands/LogView.ts +++ b/lana/src/commands/LogView.ts @@ -109,6 +109,16 @@ export class LogView { break; } + case 'openUrl': { + // https only: a webview message must not be able to hand VS Code a + // `command:` or `file:` URI to execute. + const url = typeof payload === 'string' ? payload : ''; + if (url && Uri.parse(url).scheme === 'https') { + commands.executeCommand('vscode.open', Uri.parse(url)); + } + break; + } + case 'getConfig': { const config = getConfig(); const overrides = getColumnOverrides(context.context.globalState); diff --git a/log-viewer/src/__tests__/setup.ts b/log-viewer/src/__tests__/setup.ts new file mode 100644 index 00000000..c25f5ca0 --- /dev/null +++ b/log-viewer/src/__tests__/setup.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ + +/** + * jsdom implements no layout, so it ships no `ResizeObserver` either. Components that observe + * their own size construct one on connect, so without this every such suite throws before it can + * assert anything. A suite that needs to *drive* resizes replaces this with its own stub. + */ +class NoopResizeObserver implements ResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +if (!('ResizeObserver' in globalThis)) { + (globalThis as unknown as Record).ResizeObserver = NoopResizeObserver; +} diff --git a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts index 84bd002b..ca56f82f 100644 --- a/log-viewer/src/components/__tests__/LogProblemsChip.test.ts +++ b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts @@ -15,7 +15,14 @@ import type { LogProblemsChip } from '../LogProblemsChip.js'; import '../LogProblemsChip.js'; function issue(severity: IssueSeverity): LogIssue { - return { summary: severity, message: '', severity, eventIndex: null, timestamp: null }; + return { + summary: severity, + message: '', + severity, + action: null, + category: null, + timestamp: null, + }; } async function mount(issues: readonly LogIssue[] | null): Promise { diff --git a/log-viewer/src/components/__tests__/NavBar.test.ts b/log-viewer/src/components/__tests__/NavBar.test.ts index bafe9e10..8fbb9342 100644 --- a/log-viewer/src/components/__tests__/NavBar.test.ts +++ b/log-viewer/src/components/__tests__/NavBar.test.ts @@ -26,6 +26,12 @@ class StubResizeObserver { } observe(target: Element): void { + // Only the nav bar's own observer drives the ladder — its descendants observe themselves + // too (issue-list re-measures its clamps), and capturing theirs would hijack `notify`. + if (target.tagName !== 'NAV-BAR') { + return; + } + notify = (width) => this.cb( [{ target, contentRect: { width } } as unknown as ResizeObserverEntry], @@ -80,7 +86,14 @@ afterAll(() => { }); function issue(severity: IssueSeverity): LogIssue { - return { summary: severity, message: '', severity, eventIndex: null, timestamp: null }; + return { + summary: severity, + message: '', + severity, + action: null, + category: null, + timestamp: null, + }; } async function mount( diff --git a/log-viewer/src/features/app/LogViewer.ts b/log-viewer/src/features/app/LogViewer.ts index 71f5aef4..c3902d39 100644 --- a/log-viewer/src/features/app/LogViewer.ts +++ b/log-viewer/src/features/app/LogViewer.ts @@ -16,7 +16,9 @@ import { vscodeMessenger, } from '../../core/messaging/VSCodeExtensionMessenger.js'; import { DatabaseAccess } from '../database/services/Database.js'; -import type { IssueSeverity, LogIssue } from '../notifications/types.js'; +import type { LogIssue } from '../notifications/types.js'; +import { toLogIssue } from './logIssues.js'; +import { parserIssuesToNotifications } from './parserNotifications.js'; // styles import { globalStyles } from '../../styles/global.styles.js'; @@ -247,18 +249,9 @@ export class LogViewer extends LitElement { // Rebuilt per load, never appended to: both surfaces describe *this* log, so a // previous log's problems must not carry over. - this.logProblems = [ - ...(read.error ? [read.error] : []), - ...apexLog.logIssues.map((issue): LogIssue => ({ - summary: issue.summary, - message: issue.description, - severity: toSeverity(issue.type), - eventIndex: issue.eventIndex ?? null, - timestamp: issue.startTime || null, - })), - ]; - - this.notifications = parserIssuesToNotifications(apexLog); + this.logProblems = [...(read.error ? [read.error] : []), ...apexLog.logIssues.map(toLogIssue)]; + + this.notifications = parserIssuesToNotifications(apexLog.parsingErrors); // Navigate to event location if requested (passed as prop to timeline-view) if (data.navigateToEventIndex !== undefined || data.navigateToTimestamp !== undefined) { @@ -304,50 +297,14 @@ export class LogViewer extends LitElement { summary: 'Could not read log', message: msg, severity: 'error', - eventIndex: null, + action: null, + category: null, timestamp: null, }, }; } } -const SEVERITY_BY_ISSUE_TYPE: ReadonlyMap = new Map([ - ['error', 'error'], - ['unexpected', 'warning'], - ['skip', 'info'], -]); - -function toSeverity(issueType: 'unexpected' | 'error' | 'skip'): IssueSeverity { - return SEVERITY_BY_ISSUE_TYPE.get(issueType) || 'info'; -} - -function parserIssuesToNotifications(apexLog: ApexLog): LogIssue[] { - return apexLog.parsingErrors.map((message): LogIssue => { - const unknownType = isUnknownType(message); - - return { - summary: unknownType ? message : message.slice(0, message.indexOf(':')), - message: unknownType - ? html`report unsupported type` - : message.slice(message.indexOf(':') + 1), - // A parse gap means some of the log wasn't understood, which can silently skew - // every view built from it — a warning, not the untinted 'None' it used to be. - severity: 'warning', - eventIndex: null, - timestamp: null, - }; - }); -} - -function isUnknownType(message: string): boolean { - return message.startsWith('Unsupported log event name:'); -} - interface LogDataEvent { logName?: string; logUri?: string; diff --git a/log-viewer/src/features/app/__tests__/logIssues.test.ts b/log-viewer/src/features/app/__tests__/logIssues.test.ts new file mode 100644 index 00000000..e8127a00 --- /dev/null +++ b/log-viewer/src/features/app/__tests__/logIssues.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { describe, expect, it } from '@jest/globals'; + +import { toLogIssue } from '../logIssues.js'; + +describe('toLogIssue', () => { + it('maps severity, rail category and the call-tree action', () => { + const issue = toLogIssue({ + summary: 'Skipped-Lines', + description: 'lines skipped', + type: 'skip', + eventIndex: 7, + startTime: 120, + }); + + expect(issue.severity).toBe('info'); + expect(issue.category).toBe('skip'); + expect(issue.timestamp).toBe(120); + expect(issue.action?.label).toBe('Go to call tree'); + }); + + it('leaves an issue with no event unactivatable', () => { + const issue = toLogIssue({ summary: 'Max-Size-reached', description: '', type: 'unexpected' }); + + expect(issue.action).toBeNull(); + expect(issue.category).toBe('unexpected'); + expect(issue.severity).toBe('warning'); + }); +}); diff --git a/log-viewer/src/features/app/__tests__/parserNotifications.test.ts b/log-viewer/src/features/app/__tests__/parserNotifications.test.ts new file mode 100644 index 00000000..0d8590a6 --- /dev/null +++ b/log-viewer/src/features/app/__tests__/parserNotifications.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +import { describe, expect, it } from '@jest/globals'; + +// Hoisted above the import, so the mock can't close over a `const` declared here. +jest.mock('../../../core/messaging/VSCodeExtensionMessenger.js', () => ({ + vscodeMessenger: { send: jest.fn() }, +})); + +import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js'; +import { parserIssuesToNotifications } from '../parserNotifications.js'; + +const sendMock = vscodeMessenger.send as jest.Mock; + +describe('parserIssuesToNotifications', () => { + it('offers a prefilled bug report for an unsupported log event', () => { + const [issue] = parserIssuesToNotifications(['Unsupported log event name: FLOW_START_NEW']); + + expect(issue?.summary).toBe('Unsupported log event name: FLOW_START_NEW'); + expect(issue?.severity).toBe('warning'); + expect(issue?.action?.label).toBe('Report unsupported log event'); + + issue?.action?.run(); + const [cmd, url] = sendMock.mock.calls[0] as [string, string]; + expect(cmd).toBe('openUrl'); + expect(url).toContain('https://github.com/certinia/debug-log-analyzer/issues/new'); + expect(url).toContain('template=bug_report.md'); + expect(url).toContain('labels=bug,needs-triage'); + expect(url).toContain(encodeURIComponent('Unsupported log event name: FLOW_START_NEW')); + }); + + it('leaves an invalid log line unreportable, since it echoes log text', () => { + const [issue] = parserIssuesToNotifications(['Invalid log line: 12:00:00.0 (1)|SOME_JUNK']); + + expect(issue?.summary).toBe('Invalid log line'); + // Trimmed: the card renders the message with whitespace preserved. + expect(issue?.message).toBe('12:00:00.0 (1)|SOME_JUNK'); + expect(issue?.action).toBeNull(); + expect(issue?.category).toBeNull(); + }); +}); diff --git a/log-viewer/src/features/app/logIssues.ts b/log-viewer/src/features/app/logIssues.ts new file mode 100644 index 00000000..f226d83d --- /dev/null +++ b/log-viewer/src/features/app/logIssues.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import type { LogIssue as ParsedLogIssue } from 'apex-log-parser'; + +import { goToCallTreeAction } from '../call-tree/navigation.js'; +import type { IssueSeverity, LogIssue } from '../notifications/types.js'; +import { markerTypeForIssue } from '../timeline/types/flamechart.types.js'; + +const SEVERITY_BY_ISSUE_TYPE: ReadonlyMap = new Map([ + ['error', 'error'], + ['unexpected', 'warning'], + ['skip', 'info'], +]); + +/** A parsed log issue as a card: severity, rail colour, head, and where activating it goes. */ +export function toLogIssue(issue: ParsedLogIssue): LogIssue { + return { + summary: issue.summary, + message: issue.description, + severity: toSeverity(issue.type), + action: issue.eventIndex !== undefined ? goToCallTreeAction(issue.eventIndex) : null, + // The card's rail is the colour the timeline draws for the same issue. + category: markerTypeForIssue(issue.type), + timestamp: issue.startTime || null, + }; +} + +function toSeverity(issueType: ParsedLogIssue['type']): IssueSeverity { + return SEVERITY_BY_ISSUE_TYPE.get(issueType) || 'info'; +} diff --git a/log-viewer/src/features/app/parserNotifications.ts b/log-viewer/src/features/app/parserNotifications.ts new file mode 100644 index 00000000..04bd5ccd --- /dev/null +++ b/log-viewer/src/features/app/parserNotifications.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import { vscodeMessenger } from '../../core/messaging/VSCodeExtensionMessenger.js'; +import type { IssueAction, LogIssue } from '../notifications/types.js'; + +const UNSUPPORTED_TYPE_PREFIX = 'Unsupported log event name:'; + +/** + * The parser's own diagnostics as notification-centre issues. + * + * Takes the messages rather than the log so the mapping stays independent of the parser's + * shape — the only producer of tool-level notifications today. + */ +export function parserIssuesToNotifications(parsingErrors: readonly string[]): LogIssue[] { + return parsingErrors.map((message): LogIssue => { + const eventName = unsupportedEventName(message); + + return { + summary: eventName ? message : message.slice(0, message.indexOf(':')), + // Trimmed: the card preserves whitespace for stack traces, so the separator's + // space would render as an indent. + message: eventName ? '' : message.slice(message.indexOf(':') + 1).trim(), + // A parse gap means some of the log wasn't understood, which can silently skew + // every view built from it — a warning, not the untinted 'None' it used to be. + severity: 'warning', + // Only an unsupported event name is safe to report: `Invalid log line: …` echoes log + // text that can carry customer data, so that card stays static. + action: eventName ? reportUnsupportedTypeAction(eventName) : null, + // The timeline draws no band for a parse gap, so there is no marker colour to match. + category: null, + timestamp: null, + }; + }); +} + +/** The event name from an `Unsupported log event name:` message, else `null`. */ +function unsupportedEventName(message: string): string | null { + return message.startsWith(UNSUPPORTED_TYPE_PREFIX) + ? message.slice(UNSUPPORTED_TYPE_PREFIX.length).trim() + : null; +} + +/** + * Opens a prefilled bug report for an event the parser doesn't know. Title and labels match + * `.github/ISSUE_TEMPLATE/bug_report.md`, so triage sees the same shape as a hand-filed bug. + */ +function reportUnsupportedTypeAction(eventName: string): IssueAction { + const url = + 'https://github.com/certinia/debug-log-analyzer/issues/new?template=bug_report.md&labels=bug,needs-triage&title=' + + encodeURIComponent(`🐛 bug: ${UNSUPPORTED_TYPE_PREFIX} ${eventName}`); + + return { + label: 'Report unsupported log event', + icon: 'link-external', + // A webview can't navigate itself, so the extension opens external URLs. + run: () => { + vscodeMessenger.send('openUrl', url); + }, + }; +} diff --git a/log-viewer/src/features/call-tree/__tests__/navigation.test.ts b/log-viewer/src/features/call-tree/__tests__/navigation.test.ts index 2e512995..151f956e 100644 --- a/log-viewer/src/features/call-tree/__tests__/navigation.test.ts +++ b/log-viewer/src/features/call-tree/__tests__/navigation.test.ts @@ -5,7 +5,30 @@ */ import { describe, expect, it } from '@jest/globals'; -import { CALLTREE_GO_TO_ROW, goToRow } from '../navigation.js'; +import { CALLTREE_GO_TO_ROW, goToCallTreeAction, goToRow } from '../navigation.js'; + +function captureEventIndexes(): { seen: number[]; stop: () => void } { + const seen: number[] = []; + const listener = ((e: CustomEvent<{ eventIndex: number }>) => { + seen.push(e.detail.eventIndex); + }) as EventListener; + document.addEventListener(CALLTREE_GO_TO_ROW, listener); + + return { seen, stop: () => document.removeEventListener(CALLTREE_GO_TO_ROW, listener) }; +} + +describe('goToCallTreeAction', () => { + it('is a labelled issue action that navigates when run', () => { + const { seen, stop } = captureEventIndexes(); + const action = goToCallTreeAction(7); + + expect(action.label).toBe('Go to call tree'); + action.run(); + + stop(); + expect(seen).toEqual([7]); + }); +}); describe('goToRow', () => { it('dispatches the go-to-row event on document with the eventIndex', async () => { diff --git a/log-viewer/src/features/call-tree/navigation.ts b/log-viewer/src/features/call-tree/navigation.ts index 3ab1cbf6..eb3ecca0 100644 --- a/log-viewer/src/features/call-tree/navigation.ts +++ b/log-viewer/src/features/call-tree/navigation.ts @@ -2,6 +2,8 @@ * Copyright (c) 2026 Certinia Inc. All rights reserved. */ +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'; @@ -17,3 +19,16 @@ export async function goToRow(target: { eventIndex: number }) { }), ); } + +/** + * {@link goToRow} as an issue-card action. Lives here rather than in the notifications + * feature so the issue cards stay ignorant of the call tree. + */ +export function goToCallTreeAction(eventIndex: number): IssueAction { + return { + label: 'Go to call tree', + run: () => { + void goToRow({ eventIndex }); + }, + }; +} diff --git a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts index 2e0d0b8e..30f5d660 100644 --- a/log-viewer/src/features/notifications/__tests__/IssueList.test.ts +++ b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts @@ -3,29 +3,23 @@ * * @jest-environment jsdom */ -import { describe, expect, it } from '@jest/globals'; +import { afterEach, beforeAll, describe, expect, it } from '@jest/globals'; // jsdom can't run the real elements (they read document.baseURI / setFormValue). jest.mock('#vscode-elements/vscode-icon.js', () => ({})); -jest.mock('#vscode-elements/vscode-button.js', () => ({})); -import type { IssueSeverity, LogIssue } from '../types.js'; - -jest.mock('../../call-tree/navigation.js', () => ({ goToRow: jest.fn() })); - -import { goToRow } from '../../call-tree/navigation.js'; +import type { IssueAction, IssueSeverity, LogIssue } from '../types.js'; import type { IssueList } from '../components/IssueList.js'; import '../components/IssueList.js'; -const goToRowMock = goToRow as jest.Mock; - function issue( severity: IssueSeverity, summary: string, - eventIndex: number | null = null, + action: IssueAction | null = null, + category: LogIssue['category'] = null, ): LogIssue { - return { summary, message: `${summary} detail`, severity, eventIndex, timestamp: null }; + return { summary, message: `${summary} detail`, severity, action, category, timestamp: null }; } async function mount(issues: readonly LogIssue[]): Promise { @@ -36,12 +30,65 @@ async function mount(issues: readonly LogIssue[]): Promise { return el; } +function cards(el: IssueList): HTMLElement[] { + return Array.from(el.shadowRoot?.querySelectorAll('.issue') ?? []); +} + function summaries(el: IssueList): string[] { return Array.from(el.shadowRoot?.querySelectorAll('.issue__summary') ?? []).map( (n) => n.textContent ?? '', ); } +/** + * jsdom has no layout, so nothing ever overflows its clamp. Stub the heights on the prototype + * rather than on the node: expanding swaps the message element, and a per-node stub would go with + * it, where real layout simply re-measures the same overflow. + */ +function stubOverflow(): void { + restoreOverflow = ['scrollHeight', 'clientHeight'].map((name) => { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, name); + Object.defineProperty(HTMLElement.prototype, name, { + value: name === 'scrollHeight' ? 60 : 30, + configurable: true, + }); + + return () => + original + ? Object.defineProperty(HTMLElement.prototype, name, original) + : delete (HTMLElement.prototype as unknown as Record)[name]; + }); +} + +let restoreOverflow: Array<() => unknown> = []; + +afterEach(() => { + restoreOverflow.forEach((restore) => restore()); + restoreOverflow = []; +}); + +/** Fires every observer the suite's components have registered, standing in for a popover open. */ +let resize: (() => void) | null = null; + +class CapturingResizeObserver implements ResizeObserver { + private readonly cb: ResizeObserverCallback; + + constructor(cb: ResizeObserverCallback) { + this.cb = cb; + } + + observe(): void { + resize = () => this.cb([], this); + } + + unobserve(): void {} + disconnect(): void {} +} + +beforeAll(() => { + (globalThis as unknown as Record).ResizeObserver = CapturingResizeObserver; +}); + describe('IssueList', () => { it('renders most severe first without mutating the input', async () => { const issues = [issue('info', 'skipped'), issue('error', 'limit exception')]; @@ -51,13 +98,168 @@ describe('IssueList', () => { expect(issues.map((i) => i.summary)).toEqual(['skipped', 'limit exception']); }); - it('offers call-tree navigation only for issues tied to an event', async () => { - const el = await mount([issue('error', 'has event', 42), issue('warning', 'no event')]); + it('activates by clicking the card and by the keyboard-reachable action button', async () => { + const run = jest.fn(); + const el = await mount([issue('error', 'has action', { label: 'Go somewhere', run })]); + + // The card is a group so its message can be a button: a control inside a control is + // neither valid ARIA nor navigable. + const [card] = cards(el); + expect(card?.getAttribute('role')).toBe('group'); + expect(card?.hasAttribute('tabindex')).toBe(false); + expect(card?.getAttribute('title')).toBe('has action — Go somewhere'); + expect(card?.getAttribute('aria-label')).toBe('has action — Go somewhere'); + + card?.click(); + expect(run).toHaveBeenCalledTimes(1); + + // action-icon renders a real button, whose accessible name comes from `label`. + const go = el.shadowRoot?.querySelector('vscode-icon.issue__go'); + expect(go?.hasAttribute('action-icon')).toBe(true); + expect(go?.getAttribute('label')).toBe('Go somewhere'); + expect(go?.getAttribute('name')).toBe('arrow-right'); + + // Its click bubbles to the card's single handler, so activation runs the action once. + go?.click(); + expect(run).toHaveBeenCalledTimes(2); + }); + + it('puts the action button on the summary line, not below the message', async () => { + const el = await mount([ + issue('error', 'has action', { label: 'Go somewhere', run: jest.fn() }), + ]); + + const head = el.shadowRoot?.querySelector('.issue__head'); + expect(head?.querySelector('.issue__summary')).not.toBeNull(); + expect(head?.querySelector('vscode-icon.issue__go')).not.toBeNull(); + }); + + it('uses the action icon when one is supplied', async () => { + const el = await mount([ + issue('warning', 'report me', { label: 'Report', icon: 'link-external', run: jest.fn() }), + ]); + + expect(el.shadowRoot?.querySelector('vscode-icon.issue__go')?.getAttribute('name')).toBe( + 'link-external', + ); + }); + + it('leaves an action-less card static, with no affordance', async () => { + const el = await mount([issue('info', 'skipped')]); + + const [card] = cards(el); + expect(card?.getAttribute('role')).toBe('group'); + expect(card?.classList.contains('issue--action')).toBe(false); + expect(el.shadowRoot?.querySelector('.issue__go')).toBeNull(); + }); + + it('leaves a message that fits as plain text, not a toggle', async () => { + const el = await mount([issue('info', 'skipped')]); + + expect(el.shadowRoot?.querySelector('span.issue__message')).not.toBeNull(); + expect(el.shadowRoot?.querySelector('button.issue__message')).toBeNull(); + }); + + it('toggles a clipped message on click without activating the card', async () => { + stubOverflow(); + const run = jest.fn(); + const el = await mount([issue('error', 'long', { label: 'Go somewhere', run })]); + // A second settle: the first render's `updated()` is what measures the overflow, and the + // state it sets is what turns the message into a toggle. + await el.updateComplete; + + const message = el.shadowRoot?.querySelector('button.issue__message'); + expect(message?.getAttribute('aria-expanded')).toBe('false'); + + message?.click(); + await el.updateComplete; + + expect(run).not.toHaveBeenCalled(); + const expanded = el.shadowRoot?.querySelector('button.issue__message'); + expect(expanded?.getAttribute('aria-expanded')).toBe('true'); + expect(expanded?.classList.contains('issue__clamp')).toBe(false); + + expanded?.click(); + await el.updateComplete; + + expect(run).not.toHaveBeenCalled(); + expect( + el.shadowRoot?.querySelector('button.issue__message')?.classList.contains('issue__clamp'), + ).toBe(true); + }); + + it('re-measures when it is given a size, since both popovers render it closed', async () => { + const el = await mount([issue('error', 'long', { label: 'Go somewhere', run: jest.fn() })]); + + // No layout yet — as inside a closed popover, where measuring would find nothing clipped. + expect(el.shadowRoot?.querySelector('button.issue__message')).toBeNull(); + + stubOverflow(); + resize?.(); + await el.updateComplete; + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('button.issue__message')).not.toBeNull(); + }); + + it('titles both the action button and the message toggle', async () => { + stubOverflow(); + const el = await mount([issue('error', 'long', { label: 'Go somewhere', run: jest.fn() })]); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('vscode-icon.issue__go')?.getAttribute('title')).toBe( + 'Go somewhere', + ); + + const message = el.shadowRoot?.querySelector('button.issue__message'); + expect(message?.getAttribute('title')).toBe('Show more'); + + message?.click(); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('button.issue__message')?.getAttribute('title')).toBe( + 'Show less', + ); + }); + + it('forgets what was expanded when it is given a new list', async () => { + stubOverflow(); + const el = await mount([issue('error', 'long')]); + await el.updateComplete; + el.shadowRoot?.querySelector('button.issue__message')?.click(); + await el.updateComplete; + expect(el.shadowRoot?.querySelector('button.issue__message')).not.toBeNull(); + + // Both sets are index-keyed and the element is reused across log loads, so index 0 is now + // a different issue — one whose message fits. + restoreOverflow.forEach((restore) => restore()); + restoreOverflow = []; + el.issues = [issue('info', 'fits')]; + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('button.issue__message')).toBeNull(); + expect(el.shadowRoot?.querySelector('span.issue__message')).not.toBeNull(); + }); + + it('does not activate the card when the click ends a text selection', async () => { + const run = jest.fn(); + const el = await mount([issue('error', 'selectable', { label: 'Go somewhere', run })]); + + const selection = document.getSelection(); + selection?.selectAllChildren(document.body); + cards(el)[0]?.click(); + expect(run).not.toHaveBeenCalled(); + + selection?.removeAllRanges(); + cards(el)[0]?.click(); + expect(run).toHaveBeenCalledTimes(1); + }); - const buttons = el.shadowRoot?.querySelectorAll('.issue__nav') ?? []; - expect(buttons.length).toBe(1); + it('rails on the timeline colour when the issue has a category, else its severity', async () => { + const el = await mount([issue('error', 'fatal', null, 'exception'), issue('info', 'skipped')]); - (buttons[0] as HTMLElement).click(); - expect(goToRowMock).toHaveBeenCalledWith({ eventIndex: 42 }); + const rails = Array.from(el.shadowRoot?.querySelectorAll('.issue__rail') ?? []); + expect(rails[0]?.style.backgroundColor).toBe('rgb(229, 72, 77)'); + expect(rails[1]?.getAttribute('style')).toContain('var(--vscode-editorInfo-foreground)'); }); }); diff --git a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts index 32d8ea64..8733527f 100644 --- a/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts +++ b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts @@ -15,7 +15,14 @@ import type { NotificationCentre } from '../components/NotificationCentre.js'; import '../components/NotificationCentre.js'; function issue(severity: IssueSeverity): LogIssue { - return { summary: severity, message: '', severity, eventIndex: null, timestamp: null }; + return { + summary: severity, + message: '', + severity, + action: null, + category: null, + timestamp: null, + }; } async function mount(issues: readonly LogIssue[]): Promise { diff --git a/log-viewer/src/features/notifications/__tests__/types.test.ts b/log-viewer/src/features/notifications/__tests__/types.test.ts index 57230bdd..1fa6f905 100644 --- a/log-viewer/src/features/notifications/__tests__/types.test.ts +++ b/log-viewer/src/features/notifications/__tests__/types.test.ts @@ -13,7 +13,7 @@ import { } from '../types.js'; function issue(severity: IssueSeverity, summary: string = severity): LogIssue { - return { summary, message: '', severity, eventIndex: null, timestamp: null }; + return { summary, message: '', severity, action: null, category: null, timestamp: null }; } describe('worstSeverity', () => { diff --git a/log-viewer/src/features/notifications/components/IssueList.ts b/log-viewer/src/features/notifications/components/IssueList.ts index 65665260..a70cb9a5 100644 --- a/log-viewer/src/features/notifications/components/IssueList.ts +++ b/log-viewer/src/features/notifications/components/IssueList.ts @@ -1,12 +1,11 @@ /* * Copyright (c) 2026 Certinia Inc. All rights reserved. */ -import '#vscode-elements/vscode-button.js'; import '#vscode-elements/vscode-icon.js'; -import { LitElement, css, html } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; +import { LitElement, css, html, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; -import { goToRow } from '../../call-tree/navigation.js'; +import { markerColorCss } from '../../timeline/types/flamechart.types.js'; import { SEVERITY_META, sortBySeverity, type LogIssue } from '../types.js'; // styles @@ -16,16 +15,29 @@ import { globalStyles } from '../../../styles/global.styles.js'; import '../../../components/Divider.js'; /** - * Renders a list of {@link LogIssue}s, most severe first — the shared body of both - * header popovers. + * Renders a list of {@link LogIssue}s as cards, most severe first — the shared body of + * both header popovers, and reusable by any surface that has issues to show. * - * Issues with an `eventIndex` navigate the call tree to that event. + * A card is activatable when its issue carries an {@link LogIssue.action}, whose `run` is + * the consumer's to supply: this component knows nothing about what activation does. */ @customElement('issue-list') export class IssueList extends LitElement { @property({ attribute: false }) issues: readonly LogIssue[] = []; + /** Indices of cards whose message has been expanded past the two-line clamp. */ + @state() + private _expanded = new Set(); + + /** + * Indices whose clamped message is taller than its two lines, so only those become toggles. + * Measured rather than guessed: `message` can be a template, so its rendered height is the + * only truth, and a one-line message offering to expand would expand nothing. + */ + @state() + private _clipped = new Set(); + static styles = [ globalStyles, css` @@ -42,64 +54,287 @@ export class IssueList extends LitElement { text-wrap: wrap; } + /* Both the rail's neighbours anchor to the first line, so the severity glyph and the + action button sit level with the summary however tall the card grows. */ + .issue > vscode-icon { + align-self: flex-start; + flex: 0 0 auto; + } + + /* The rail is the colour the timeline draws for the same issue — one problem seen + twice. Opaque where the timeline band is translucent: 3px needs the full hue. */ + .issue__rail { + flex: 0 0 3px; + align-self: stretch; + border-radius: 2px; + } + + .issue--action { + cursor: pointer; + } + + .issue--action:hover { + background-color: var(--vscode-list-hoverBackground); + } + .issue__body { display: flex; flex-direction: column; gap: 4px; min-width: 0; + flex: 1 1 auto; + } + + .issue__head { + display: flex; + align-items: flex-start; + gap: 8px; } .issue__summary { font-weight: 600; font-size: 12px; + flex: 1 1 auto; + min-width: 0; + } + + /* Most summaries are a short label, but a FATAL ERROR carries the whole exception: + wrap so short ones show in full, clamp so one long one can't fill the popover. The + full string stays in the card's title. */ + .issue__clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; } + /* 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: 12px; + font-size: var(--vscode-editor-font-size); + font-family: var(--vscode-editor-font-family); color: var(--vscode-descriptionForeground); + white-space: pre-wrap; } - .issue__nav { - align-self: flex-start; - margin-top: 2px; + /* A clipped message is a real button so it toggles on Enter and Space too, but it must + still read as body text — strip the button chrome, keep only the pointer. */ + button.issue__message { + display: block; + width: 100%; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + cursor: pointer; + } + + button.issue__message.issue__clamp { + /* -webkit-box beats display:block above, so restate the clamp for the button. */ + display: -webkit-box; + } + + /* Room for a second action to become a sibling, without restyling the first. */ + .issue__actions { + flex: 0 0 auto; + display: flex; + gap: 2px; } `, ]; - render() { - const issues = sortBySeverity(this.issues); + /** + * Both popovers render their list while closed, where there is no layout at all — so the list + * has to re-measure when it is given a size, not only when it renders. + */ + private readonly _resize = new ResizeObserver(() => { + this._measure(); + }); + + override connectedCallback(): void { + super.connectedCallback(); + this._resize.observe(this); + } + + override disconnectedCallback(): void { + this._resize.disconnect(); + super.disconnectedCallback(); + } + + /** Re-measure after every render: a new list, or an expansion, changes what is clipped. */ + override updated(): void { + this._measure(); + } + + private _measure(): void { + // Inside a closed popover every height is 0, so nothing measures as clipped — and caching + // that would leave the message a plain span for good. The observer re-runs this on open. + if (!this.clientHeight) { + return; + } + + const clipped = new Set(); + this.shadowRoot?.querySelectorAll('.issue__message.issue__clamp').forEach((el) => { + const index = Number(el.dataset.index); + // 1px of slack: sub-pixel line heights round scrollHeight up on their own. + if (el.scrollHeight - el.clientHeight > 1) { + clipped.add(index); + } + }); - return html`${issues.map( + // Expanded cards drop their clamp, so they no longer measure as clipped — keep them + // counted or the toggle would vanish and re-appear on the next render. + for (const index of this._expanded) { + clipped.add(index); + } + + if (!sameSet(clipped, this._clipped)) { + this._clipped = clipped; + } + } + + /** Sorted once per list: expanding a card re-renders, and the order can't have changed. */ + private _sorted: readonly LogIssue[] = []; + + override willUpdate(changed: PropertyValues): void { + if (!changed.has('issues')) { + return; + } + + this._sorted = sortBySeverity(this.issues); + // Both sets are keyed by index, so their lifetime is this list's: the element is reused + // across log loads, where index 0 becomes a different issue. + this._expanded = new Set(); + this._clipped = new Set(); + } + + render() { + return html`${this._sorted.map( (issue, index) => - html`${index > 0 ? html`` : ''} -
- -
- ${issue.summary} - ${issue.message ? html`${issue.message}` : ''} - ${ - issue.eventIndex !== null - ? html` this._goTo(issue.eventIndex)} - >Go To Call Tree` - : '' - } -
-
`, + html`${index > 0 ? html`` : ''}${this._card(issue, index)}`, )}`; } - private _goTo(eventIndex: number | null): void { - if (eventIndex !== null) { - goToRow({ eventIndex }); + private _card(issue: LogIssue, index: number): TemplateResult { + const action = issue.action; + const label = action ? `${issue.summary} — ${action.label}` : issue.summary; + + // The card is a group, not a button: its message can itself be a button, and a control + // inside a control is neither valid ARIA nor navigable. Pointer clicks still activate the + // whole card; keyboard users reach the same action through `.issue__go`. + return html`
this._activate(action) : null} + > + + +
+
+ ${issue.summary} + ${ + action + ? // action-icon is a real button, so it focuses and takes Enter/Space natively. No + // handler of its own: its click bubbles to the card's, so pointer and keyboard + // activation run the one action exactly once. + html`
+ +
` + : '' + } +
+ ${this._message(issue, index)} +
+
`; + } + + /** + * The message, as a toggle button only while it has more to show — a message that fits must + * not advertise a click that would do nothing. + */ + private _message(issue: LogIssue, index: number): TemplateResult | '' { + if (!issue.message) { + return ''; + } + + const expanded = this._expanded.has(index); + const clamp = expanded ? '' : 'issue__clamp'; + + return this._clipped.has(index) + ? html`` + : html`${issue.message}`; + } + + /** + * Runs the card's action, unless the click ended a text selection — a stack trace is there to + * be read and copied, and selecting one must not navigate away from it. + */ + private _activate(action: NonNullable): void { + if (!isCollapsed(shadowSelection(this.shadowRoot) ?? document.getSelection())) { + return; + } + + action.run(); + } + + /** Toggling the message must not activate the card it sits inside. */ + private _toggle(event: Event, index: number): void { + event.stopPropagation(); + const expanded = new Set(this._expanded); + if (!expanded.delete(index)) { + expanded.add(index); } + this._expanded = expanded; } } + +/** + * The timeline's colour for this issue where it draws one, else its severity token — + * parser notifications and read failures appear on no timeline. + */ +function railColor(issue: LogIssue): string { + return issue.category ? markerColorCss(issue.category) : SEVERITY_META[issue.severity].color; +} + +/** + * The selection inside a shadow root, where the browser exposes one. `document.getSelection()` + * can't see into shadow DOM, so Chromium (which is all a webview runs on) adds this — it just + * isn't in the DOM lib yet. + */ +function shadowSelection(root: ShadowRoot | null): Selection | null { + const getSelection = (root as unknown as { getSelection?: () => Selection | null } | null) + ?.getSelection; + + return getSelection ? getSelection.call(root) : null; +} + +function isCollapsed(selection: Selection | null): boolean { + return !selection || selection.isCollapsed; +} + +function sameSet(a: ReadonlySet, b: ReadonlySet): boolean { + return a.size === b.size && [...a].every((value) => b.has(value)); +} diff --git a/log-viewer/src/features/notifications/types.ts b/log-viewer/src/features/notifications/types.ts index ef4401f5..f8b61156 100644 --- a/log-viewer/src/features/notifications/types.ts +++ b/log-viewer/src/features/notifications/types.ts @@ -3,9 +3,26 @@ */ import type { TemplateResult } from 'lit'; +import type { MarkerType } from '../timeline/types/flamechart.types.js'; + /** Severity of a single issue. Ordered most→least severe by `SEVERITY_ORDER`. */ export type IssueSeverity = 'error' | 'warning' | 'info'; +/** + * What activating an issue card does, supplied by whoever produced the issue. + * + * Deliberately opaque: the cards render one accessible activation affordance and call + * `run`, so the same list serves call-tree navigation, an external report link or + * anything a future consumer needs — without the list knowing about any of them. + */ +export interface IssueAction { + /** Tooltip and accessible name, e.g. `Go to call tree`. */ + readonly label: string; + /** Trailing codicon name. Defaults to `arrow-right`. */ + readonly icon?: string; + readonly run: () => void; +} + /** * One entry in either header surface: a problem found in the log (governor limit * exception, skipped lines) or a notification about the parse itself. @@ -17,8 +34,13 @@ export interface LogIssue { readonly summary: string; readonly message: string | TemplateResult<1>; readonly severity: IssueSeverity; - /** Call-tree event to navigate to, or `null` when the issue isn't tied to one. */ - readonly eventIndex: number | null; + /** What clicking the card does, or `null` for a card that isn't actionable. */ + readonly action: IssueAction | null; + /** + * Which timeline marker draws this issue, so a card's rail and its band read as the + * same thing. `null` for issues the timeline doesn't draw (parser notifications). + */ + readonly category: MarkerType | null; readonly timestamp: number | null; } diff --git a/log-viewer/src/features/timeline/types/flamechart.types.ts b/log-viewer/src/features/timeline/types/flamechart.types.ts index 3870d9b4..468da349 100644 --- a/log-viewer/src/features/timeline/types/flamechart.types.ts +++ b/log-viewer/src/features/timeline/types/flamechart.types.ts @@ -11,7 +11,7 @@ //TODO: Remove deps outside timeline -import type { LogCategory, LogEvent } from 'apex-log-parser'; +import type { LogCategory, LogEvent, LogIssue } from 'apex-log-parser'; import { formatDuration } from '../../../core/utility/Util.js'; import type { PrecomputedRect } from '../optimised/RectangleCache.js'; @@ -624,6 +624,24 @@ export const MARKER_COLORS: Record = { unexpected: 0x8080ff, // light purple } as const; +/** + * The same marker colour as a CSS hex string, for DOM renderers (the issue cards' rail). + * Derived rather than restated so the canvas and the DOM can't drift apart. + */ +export function markerColorCss(type: MarkerType): string { + return `#${MARKER_COLORS[type].toString(16).padStart(6, '0')}`; +} + +/** + * The marker a parsed log issue is drawn as, so a DOM renderer can match its band. + * + * `'error'` maps to `'exception'`: `extractMarkers` drops `'error'` logIssues and the same + * failure is drawn from `log.exceptions`, so the exception hue is what's actually on screen. + */ +export function markerTypeForIssue(issueType: LogIssue['type']): MarkerType { + return issueType === 'error' ? 'exception' : issueType; +} + /** * Default transparency for full-height background bands. * Low enough to stay in the background over any editor theme.