diff --git a/CHANGELOG.md b/CHANGELOG.md index 47847615..eafbc4a3 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 @@ -41,9 +43,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 🏷️ **Call Tree names**: rows no longer carry a raw `EVENT_TYPE:` prefix in front of text that already identifies them, so `WF_CRITERIA_BEGIN: WF_CRITERIA : ON_ALL_CHANGES` reads as `WF_CRITERIA : ON_ALL_CHANGES`. Frames whose text can't stand alone keep the type, and the ones that needed naming now say what they are β€” `(code unit)`, `(constructor)`, `(managed package)`, `(flow)`. A **Type** column is available in every view from the **Columns** menu if you want the raw types back. - πŸ—‚οΈ **Call Tree + Database styling**: VS Code style tree icons, and rows indent under their group headings. ([#832]). - πŸŽ›οΈ **Modernised dropdowns**: searchable, compact controls that carry the field and value in one place (e.g. `Group: Namespace`, `Type: All`) ([#848]). -- ♻️ 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. +- ♻️ 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 d137de52..32e18207 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 9187344c..b6f17051 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/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..ca56f82f --- /dev/null +++ b/log-viewer/src/components/__tests__/LogProblemsChip.test.ts @@ -0,0 +1,93 @@ +/* + * 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, + action: null, + category: 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..8fbb9342 --- /dev/null +++ b/log-viewer/src/components/__tests__/NavBar.test.ts @@ -0,0 +1,209 @@ +/* + * 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 { + // 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], + 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, + action: null, + category: 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..c3902d39 100644 --- a/log-viewer/src/features/app/LogViewer.ts +++ b/log-viewer/src/features/app/LogViewer.ts @@ -16,10 +16,9 @@ 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 { LogIssue } from '../notifications/types.js'; +import { toLogIssue } from './logIssues.js'; +import { parserIssuesToNotifications } from './parserNotifications.js'; // styles import { globalStyles } from '../../styles/global.styles.js'; @@ -46,10 +45,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 +145,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 +226,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 +247,11 @@ 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; + // 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(toLogIssue)]; - this.parserIssues = this.parserIssuesToMessages(apexLog); + this.notifications = parserIssuesToNotifications(apexLog.parsingErrors); // Navigate to event location if requested (passed as prop to timeline-view) if (data.navigateToEventIndex !== undefined || data.navigateToTimestamp !== undefined) { @@ -261,7 +261,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 +283,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,46 +291,17 @@ 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 ''; - } - - severity = new Map([ - ['error', 'Error'], - ['unexpected', 'Warning'], - ['skip', 'Info'], - ]); - private toSeverity(errorType: 'unexpected' | 'error' | 'skip') { - return this.severity.get(errorType) || 'Info'; - } - - private parserIssuesToMessages(apexLog: ApexLog) { - const issues: Notification[] = []; - apexLog.parsingErrors.forEach((message) => { - const isUnknownType = this.isUnknownType(message); - - const logMessage = new Notification(); - logMessage.summary = isUnknownType ? message : message.slice(0, message.indexOf(':')); - logMessage.message = isUnknownType - ? html`report unsupported type` - : message.slice(message.indexOf(':') + 1); - - issues.push(logMessage); - }); - return issues; - } - - private isUnknownType(message: string) { - return message.startsWith('Unsupported log event name:'); + return { + logData: '', + error: { + summary: 'Could not read log', + message: msg, + severity: 'error', + action: null, + category: null, + timestamp: null, + }, + }; } } 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 new file mode 100644 index 00000000..30f5d660 --- /dev/null +++ b/log-viewer/src/features/notifications/__tests__/IssueList.test.ts @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + * + * @jest-environment jsdom + */ +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', () => ({})); + +import type { IssueAction, IssueSeverity, LogIssue } from '../types.js'; + +import type { IssueList } from '../components/IssueList.js'; +import '../components/IssueList.js'; + +function issue( + severity: IssueSeverity, + summary: string, + action: IssueAction | null = null, + category: LogIssue['category'] = null, +): LogIssue { + return { summary, message: `${summary} detail`, severity, action, category, 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 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')]; + const el = await mount(issues); + + expect(summaries(el)).toEqual(['limit exception', 'skipped']); + expect(issues.map((i) => i.summary)).toEqual(['skipped', 'limit exception']); + }); + + 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); + }); + + 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')]); + + 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 new file mode 100644 index 00000000..8733527f --- /dev/null +++ b/log-viewer/src/features/notifications/__tests__/NotificationCentre.test.ts @@ -0,0 +1,66 @@ +/* + * 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, + action: null, + category: 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..1fa6f905 --- /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, action: null, category: 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..a70cb9a5 --- /dev/null +++ b/log-viewer/src/features/notifications/components/IssueList.ts @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +import '#vscode-elements/vscode-icon.js'; +import { LitElement, css, html, type PropertyValues, type TemplateResult } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { markerColorCss } from '../../timeline/types/flamechart.types.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 as cards, most severe first β€” the shared body of + * both header popovers, and reusable by any surface that has issues to show. + * + * 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` + :host { + display: block; + } + + .issue { + display: flex; + gap: 8px; + padding: 8px; + border-radius: 4px; + overflow-wrap: anywhere; + 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: var(--vscode-editor-font-size); + font-family: var(--vscode-editor-font-family); + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + } + + /* 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; + } + `, + ]; + + /** + * 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); + } + }); + + // 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`` : ''}${this._card(issue, index)}`, + )}`; + } + + 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/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..f8b61156 --- /dev/null +++ b/log-viewer/src/features/notifications/types.ts @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2026 Certinia Inc. All rights reserved. + */ +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. + * + * `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; + /** 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; +} + +/** 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/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. 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); -`;