diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f17c53..6a08519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Change Log +## [2.0.0] + +Three things change without being asked to, which is what the major version is for. No setting was removed or renamed, so existing configuration carries over untouched. + +- The readings are now five status bar entries — CPU, GPU, Battery, Memory and Disk — rather than one. Anyone who had hidden or repositioned the single entry will need to do it again, and in a narrow window VS Code may drop the lower-priority entries (Memory, then Disk) where before it had one entry to fit. In exchange, each hover answers for its own group, each can be hidden on its own from the status bar's right-click menu, where they now appear by name, and a group whose metrics are all switched off or unavailable disappears rather than sitting there empty. +- `systemvitals.updatefrequencyms` now defaults to 10 seconds rather than 2. VS Code redraws an open hover the instant its content changes, so details rebuilt every couple of seconds flicker and resize while being read. One interval governs the reading and its details together, so a panel never shows a different sample from the entry behind it. Lower the setting for livelier numbers. +- The minimum VS Code version is now 1.74, up from 1.53, which is what markdown status bar tooltips require. Installs on older VS Code stay on 1.0.1. +- CPU temperature now sits with the other CPU readings rather than at the end of the line, so the CPU group is contiguous. + +Everything else: + +- Hovering a reading now opens a details panel for it. Each expands into the figures the status bar has no room for: the user/system split and per-core load bars behind the CPU percentage, cached and swap memory behind the memory fraction, the GPU's name and core count, battery health and cycle count, and used, free and total for every volume regardless of which one `systemvitals.disk.format` picks. A panel covers exactly what is on show, so a metric switched off is absent from both. +- Clicking a reading opens a details view: the same figures as the hover, for every group at once, in something that stays put while it is read and updates in place, with the group you clicked outlined. It docks in the panel beside Terminal and Problems, directly above the readings, rather than taking an editor tab. It closes by its title bar ✕, by clicking the same reading again, or from the command palette; clicking a different reading moves the outline instead of closing. Also on the palette as "System Vitals: Show Details". +- The panel carries no System Vitals tab until one is asked for, and closing the view removes the tab rather than leaving an empty shell. The view reads the samples the status bar already takes rather than polling on its own, so it costs nothing while closed. +- Each group in the details view has a Settings button that opens the Settings editor filtered to that group, so the disk group lands on the disk settings rather than the full list. +- Added a note to the GPU panel explaining that its memory allocation is what the driver has claimed from shared system memory, not a fixed VRAM capacity. +- Each metric is now sampled once per update and feeds both the status bar and its hover, so the details cost no extra polling. + ## [1.0.1] - Disk space is now usable on macOS without configuration. It previously listed all eight APFS volumes that macOS reports for a single physical disk, and the most obvious of them was misleading: `/` is the sealed read-only system snapshot, which reports roughly 95% free however full the machine is. The data volume is now shown in its place, labelled `/`. diff --git a/README.md b/README.md index 992f9a1..013e480 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,25 @@ Display CPU frequency and usage, GPU utilization, memory consumption, disk space - **Memory** — consumed out of total - **Disk** — space remaining or used, per drive - **Battery** — percentage remaining, hidden automatically on devices without a battery +- **Hover or click for detail** — every metric expands into the figures the status bar has no room for, as a hover or as a panel that stays put -## Screenshot +## Screenshots ![System Vitals in the VS Code status bar](images/statusbar.png) -CPU usage, CPU frequency, GPU utilization and memory, running on an Apple Silicon Mac. GPU memory, disk space and CPU temperature are available as well, and are off by default. +CPU usage, CPU frequency, GPU utilization and GPU memory, running on an Apple Silicon Mac. Each group is its own status bar entry. Disk space and CPU temperature are available as well, and are off by default. + +![The hover for the GPU reading](images/hover.png) + +Hovering a reading explains it, and nothing else. Here the GPU entry gives the accelerator's name and core count, its utilization, and memory in use against the driver's allocation — with a note about what that allocation actually is. + +![The details view docked in the panel](images/details.png) + +Clicking a reading opens the details view in the panel, beside Terminal and Problems, with the group you clicked outlined — the GPU here. Note that every figure matches the reading behind it in the status bar, down to the last decimal: one sample feeds both, so they cannot drift apart. ## Requirements -None beyond VS Code 1.53 or newer. The `systeminformation` module is bundled with the extension, and macOS GPU statistics are read from the IOKit registry with `ioreg`, which requires no additional software and no elevated privileges. +None beyond VS Code 1.74 or newer. The `systeminformation` module is bundled with the extension, and macOS GPU statistics are read from the IOKit registry with `ioreg`, which requires no additional software and no elevated privileges. ## Extension Settings @@ -35,12 +44,47 @@ None beyond VS Code 1.53 or newer. The `systeminformation` module is bundled wit - `systemvitals.gpu.unit`: Unit used for GPU memory (GB-B). - `systemvitals.disk.format`: Configures how the disk space is displayed (percentage remaining/used, absolute remaining, used out of totel). - `systemvitals.disk.drives`: Drives to show, by mount point or device name. For example, `C:` on Windows, `/home` or `/dev/sda1` on Linux. Leave empty to pick sensible volumes automatically; see Disk Space below. -- `systemvitals.updatefrequencyms`: How frequently to query systeminformation. The minimum is 200 ms as to prevent accidentally updating so fast as to freeze up your machine. +- `systemvitals.updatefrequencyms`: How frequently to query systeminformation, 10 seconds by default. This governs the hover details as well as the status bar; see Hovering for Detail below for why the default is unhurried. The minimum is 200 ms as to prevent accidentally updating so fast as to freeze up your machine. - `systemvitals.freq.unit`: Unit used for the CPU frequency (GHz-Hz). - `systemvitals.mem.unit`: Unit used for the RAM consumption (GB-B). +- `systemvitals.show.precision`: Number of decimal places shown for each figure, 0 to 2. Applies to the readings and to the details alike. - `systemvitals.alignLeft`: Toggles the alignment of the status bar. - `systemvitals.color`: Color of the status bar text in hex code (for example, #FFFFFF is white). The color must be in the format #RRGGBB, using hex digits. +## Hover and Click for Detail + +The status bar has room for one number per metric. Hovering a reading gives that number its context; clicking opens the same detail in a view that stays put. + +The readings sit in the status bar as five separate groups — CPU, GPU, Battery, Memory and Disk — and each answers for itself: hovering the GPU reading explains the GPU and nothing else. Each group can also be hidden on its own from the status bar's right-click menu, where they appear as "System Vitals GPU" and so on. + +- **CPU** — the processor's name and core count, the user/system split behind the usage percentage, a bar per core showing whether the machine is evenly busy or one core is pinned, the 1/5/15 minute load averages, and the frequency spread across cores where the platform reports one. +- **GPU** — the accelerator's name and core count, utilization, and memory in use alongside the driver's allocation. +- **Memory** — memory in use as a percentage of total, memory still available, cache, and swap. Cache is memory the system hands back the moment anything wants it, which is why "used" so often looks alarming and is not. +- **Disk** — used, free, total and percentage for every volume shown, whichever single figure `systemvitals.disk.format` picks for the status bar. +- **Battery** — charge, whether it is charging or plugged in and idle, time remaining or time until full, health as a percentage of design capacity, and cycle count. + +A hover elaborates on what is displayed, so a metric you have switched off, or one your machine cannot report, is absent from both — and a group with nothing left to show disappears from the status bar rather than sitting there empty. Rows whose underlying figure is unavailable are left out rather than shown as zero. Each metric is sampled once per update and feeds both the status bar and the hover, so the detail costs no extra polling. + +### The details view + +A hover lasts only as long as you hold the pointer still. Clicking a reading opens the same detail as a **System Vitals** tab in the panel, beside Terminal and Problems — directly above the readings themselves. It shows every group at once, updates in place, and outlines the group you clicked so you can find it. + +It closes three ways, whichever you reach for first: + +- the **✕** in its title bar +- clicking the same reading again +- **System Vitals: Close Details** on the command palette + +Clicking a *different* reading keeps it open and moves the outline to that group instead. **System Vitals: Show Details** on the palette opens it without singling out any group. + +Each group has a **Settings** button that opens the Settings editor filtered to that group — the disk group lands on the disk format and drive settings rather than the full list. + +The panel carries no System Vitals tab until you ask for one, and closing the view takes the tab away again rather than leaving an empty shell behind. The view reads the same samples the status bar does rather than polling on its own, so it costs nothing while closed. It is treated as transient, so reloading the window closes it too; click a reading to bring it back. + +### Why updates are unhurried + +VS Code redraws an open hover the instant its content changes, so details rebuilt every couple of seconds flicker and resize while you are trying to read them. `systemvitals.updatefrequencyms` therefore defaults to 10 seconds, and it governs the reading and its details together — a panel always says exactly what the entry behind it says, never a sample or two out of date. Lower it if you want livelier numbers and don't mind the hover redrawing under the pointer. + ## GPU Monitoring GPU statistics are **macOS only**, and work on Apple Silicon (M-series) as well as Intel Macs. They are read from the IOKit registry with `ioreg`, which requires no elevated privileges — unlike `powermetrics`, which needs `sudo`. On any machine that does not report GPU statistics, both GPU metrics hide themselves automatically rather than showing an error. diff --git a/images/details.png b/images/details.png new file mode 100644 index 0000000..7381d2f Binary files /dev/null and b/images/details.png differ diff --git a/images/hover.png b/images/hover.png new file mode 100644 index 0000000..3fbc078 Binary files /dev/null and b/images/hover.png differ diff --git a/images/statusbar.png b/images/statusbar.png index 1a759cd..0768cd6 100644 Binary files a/images/statusbar.png and b/images/statusbar.png differ diff --git a/package-lock.json b/package-lock.json index febf5dd..c1f5134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { "name": "system-vitals", - "version": "1.0.1", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "system-vitals", - "version": "1.0.1", + "version": "2.0.0", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "systeminformation": "^4.27.11" }, "devDependencies": { "@types/node": "^20.19.43", - "@types/vscode": "1.53.0", + "@types/vscode": "1.74.0", "tslint": "^5.8.0", "typescript": "^5.9.3" }, "engines": { - "vscode": "^1.53.0" + "vscode": "^1.74.0" } }, "node_modules/@babel/code-frame": { @@ -58,9 +58,9 @@ } }, "node_modules/@types/vscode": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.53.0.tgz", - "integrity": "sha512-XjFWbSPOM0EKIT2XhhYm3D3cx3nn3lshMUcWNy1eqefk+oqRuBq8unVb6BYIZqXy9lQZyeUl7eaBCOZWv+LcXQ==", + "version": "1.74.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.74.0.tgz", + "integrity": "sha512-LyeCIU3jb9d38w0MXFwta9r0Jx23ugujkAxdwLTNCyspdZTKUc43t7ppPbCiPoQ/Ivd/pnDFZrb4hWd45wrsgA==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 474a0d8..65c04cd 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,11 @@ "name": "system-vitals", "displayName": "System Vitals", "description": "CPU, GPU, memory, disk and battery monitoring in the VS Code status bar, with native Apple Silicon GPU support.", - "version": "1.0.1", + "version": "2.0.0", "publisher": "SachinSachdeva", "license": "SEE LICENSE IN LICENSE.md", "engines": { - "vscode": "^1.53.0" + "vscode": "^1.74.0" }, "categories": [ "Other" @@ -28,6 +28,53 @@ ], "main": "./out/extension", "contributes": { + "commands": [ + { + "command": "systemvitals.showDetails", + "title": "Show Details", + "category": "System Vitals" + }, + { + "command": "systemvitals.hideDetails", + "title": "Close Details", + "category": "System Vitals", + "icon": "$(close)" + } + ], + "menus": { + "view/title": [ + { + "command": "systemvitals.hideDetails", + "when": "view == systemvitals.details", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "systemvitals.hideDetails", + "when": "systemvitals.detailsRequested" + } + ] + }, + "viewsContainers": { + "panel": [ + { + "id": "systemvitals", + "title": "System Vitals", + "icon": "images/icon.png" + } + ] + }, + "views": { + "systemvitals": [ + { + "id": "systemvitals.details", + "name": "Details", + "type": "webview", + "when": "systemvitals.detailsRequested" + } + ] + }, "configuration": { "type": "object", "title": "System Vitals", @@ -104,9 +151,9 @@ }, "systemvitals.updatefrequencyms": { "type": "number", - "default": 2000, + "default": 10000, "minimum": 200, - "description": "How long in ms between updates to resources." + "description": "How long in ms between updates to resources. This governs the hover details as well as the status bar, so the two always agree. VS Code redraws an open hover whenever its content changes, so short intervals make the details flicker while you are reading them." }, "systemvitals.freq.unit": { "type": "string", @@ -168,7 +215,7 @@ }, "devDependencies": { "@types/node": "^20.19.43", - "@types/vscode": "1.53.0", + "@types/vscode": "1.74.0", "tslint": "^5.8.0", "typescript": "^5.9.3" } diff --git a/src/appleGpu.ts b/src/appleGpu.ts index 39bb32c..573ddf6 100644 --- a/src/appleGpu.ts +++ b/src/appleGpu.ts @@ -16,6 +16,10 @@ export interface GpuStats { inUseMemory: number; /** "Alloc system memory", in bytes. */ allocatedMemory: number; + /** The accelerator's "model", such as "Apple M4". Null when the node does not name itself. */ + model: string | null; + /** The accelerator's "gpu-core-count". Null when the node does not report one. */ + coreCount: number | null; } // Recurse one level from each IOAccelerator node so we pick up its properties, @@ -34,6 +38,12 @@ const CACHE_WINDOW_MS: number = 100; const PERFORMANCE_STATISTICS_PATTERN = /"PerformanceStatistics" = \{([^}]*)\}/; +// Descriptive properties sit beside PerformanceStatistics in the same node, +// either side of it, so they are read from the start of that node onwards. +const NODE_MARKER = '+-o '; +const MODEL_PATTERN = /"model" = "([^"]*)"/; +const CORE_COUNT_PATTERN = /"gpu-core-count" = (\d+)/; + /** * Extracts GPU statistics from the output of * `ioreg -r -d 1 -w 0 -c IOAccelerator`. @@ -57,13 +67,30 @@ export function parsePerformanceStatistics(ioregOutput: string): GpuStats | null return null; } + // Anchored at the node that supplied the statistics, so a machine whose + // first accelerator reports nothing cannot lend its name to the second. + let node: string = ioregOutput.slice(nodeStart(ioregOutput, statisticsBlock.index)); + let model = MODEL_PATTERN.exec(node); + let coreCount = CORE_COUNT_PATTERN.exec(node); + return { utilization: utilization, inUseMemory: readStatistic(statistics, "In use system memory") || 0, allocatedMemory: readStatistic(statistics, "Alloc system memory") || 0, + model: model === null ? null : model[1], + coreCount: coreCount === null ? null : parseInt(coreCount[1], 10), }; } +/** + * Where the node containing the given offset begins, or the start of the dump + * when the output carries no node headers at all. + */ +function nodeStart(ioregOutput: string, offset: number): number { + let start: number = ioregOutput.lastIndexOf(NODE_MARKER, offset); + return start === -1 ? 0 : start; +} + /** * Reads a single `"key"=` entry out of a PerformanceStatistics dict. * diff --git a/src/extension.ts b/src/extension.ts index 4abbdae..bed763a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,17 +1,99 @@ 'use strict'; -import { window, Disposable, ExtensionContext, StatusBarAlignment, StatusBarItem, workspace, WorkspaceConfiguration } from 'vscode'; +import { commands, window, Disposable, ExtensionContext, MarkdownString, StatusBarAlignment, StatusBarItem, workspace, WorkspaceConfiguration } from 'vscode'; +import * as os from 'os'; import { Units, UnitLabels, DiskSpaceFormat, DiskSpaceFormatMappings, FreqMappings, MemMappings } from './constants'; -import { AppleGpuSampler } from './appleGpu'; +import { AppleGpuSampler, GpuStats } from './appleGpu'; import { Volume, selectVolumes, volumeLabel } from './disk'; +import { Detail, DetailRow, Section, formatCoreLoads, formatInterval, formatMinutes, renderTooltip } from './tooltip'; +import { DetailsView, PanelSection } from './panel'; var si = require('systeminformation'); +// Clicking an entry opens the details view at that section, and clicking the +// same one again closes it. The second command is the cross in the view's own +// title bar. +const DETAILS_COMMAND: string = 'systemvitals.showDetails'; +const HIDE_DETAILS_COMMAND: string = 'systemvitals.hideDetails'; + +// The panel's per-section Settings buttons open the settings narrowed to that +// section. The @ext: filter scopes the search to this extension, so the terms +// below need only tell the sections apart rather than be unique across every +// setting VS Code has. +const SETTINGS_COMMAND: string = 'workbench.action.openSettings'; + +/** + * The groups the display is divided into. + * + * Each is a status bar entry of its own, which is what gives every metric a + * hover that explains that metric rather than all of them, and a click that + * configures it. The identifiers are stable so that a section hidden from the + * status bar's context menu stays hidden, and the names are what that menu + * shows. + */ +const SECTION_CPU: Section = { id: 'systemvitals.cpu', name: 'CPU', icon: '$(pulse)', settingsFilter: 'cpu' }; +const SECTION_GPU: Section = { id: 'systemvitals.gpu', name: 'GPU', icon: '$(circuit-board)', settingsFilter: 'gpu' }; +const SECTION_BATTERY: Section = { id: 'systemvitals.battery', name: 'Battery', icon: '$(plug)', settingsFilter: 'battery' }; +const SECTION_MEMORY: Section = { id: 'systemvitals.memory', name: 'Memory', icon: '$(ellipsis)', settingsFilter: 'mem' }; +const SECTION_DISK: Section = { id: 'systemvitals.disk', name: 'Disk', icon: '$(database)', settingsFilter: 'disk' }; + +/** + * How long between samples, when the setting says nothing. + * + * This governs the hover as well as the status bar, so that the two never + * disagree about the same instant. It is unhurried for a reason: VS Code + * redraws an open hover whenever its content changes, so a short interval + * leaves the details flickering and resizing under the pointer while they are + * being read. + */ +const DEFAULT_UPDATE_FREQUENCY_MS: number = 10000; + +/** + * The processor's name and core count, read once. + * + * It cannot change while VS Code is running, and os.cpus() walks every core to + * report it. systeminformation's si.cpu() would be the more natural source, but + * 4.27 throws while parsing sysctl output on Apple Silicon. + */ +const PROCESSOR: string = describeProcessor(); + export function activate(context: ExtensionContext) { - var resourceMonitor: ResMon = new ResMon(); + // Asked for rather than written down, so that renaming the extension cannot + // leave the entries opening a settings search that matches nothing. + var resourceMonitor: ResMon = new ResMon(`@ext:${context.extension.id}`); resourceMonitor.StartUpdating(); context.subscriptions.push(resourceMonitor); } +function describeProcessor(): string { + let cores = os.cpus(); + if (cores.length === 0) { + return ""; + } + return `${cores[0].model} · ${cores.length} cores`; +} + +/** + * The 1, 5 and 15 minute load averages, or null where they are meaningless. + * + * Windows has no such measure and Node reports zeroes there rather than + * failing, which would otherwise render as a row of convincing-looking noughts. + */ +function loadAverage(): string | null { + let averages = os.loadavg(); + if (averages.every(average => average === 0)) { + return null; + } + return averages.map(average => average.toFixed(2)).join(', '); +} + +/** + * What a resource contributes to a single update: the text it puts in the + * status bar, and the rows it adds to its section's hover. + */ +interface ResourceRender extends Detail { + text: string; +} + abstract class Resource { // A WorkspaceConfiguration is an immutable snapshot, so this is replaced // via setConfig() on every update tick. Holding the object handed over at @@ -20,14 +102,20 @@ abstract class Resource { protected _isShownByDefault: boolean; protected _configKey: string; protected _maxWidth: number; + private _section: Section; - constructor(config: WorkspaceConfiguration, isShownByDefault: boolean, configKey: string) { + constructor(config: WorkspaceConfiguration, isShownByDefault: boolean, configKey: string, section: Section) { this._config = config; this._isShownByDefault = isShownByDefault; this._configKey = configKey; + this._section = section; this._maxWidth = 0; } + public getSection(): Section { + return this._section; + } + /** * Points this resource at a freshly read configuration, so that changed * settings apply on the next tick rather than at the next window reload. @@ -48,20 +136,27 @@ abstract class Resource { this._maxWidth = 0; } - public async getResourceDisplay(): Promise { + /** + * Samples this resource once, yielding both what the status bar shows and + * what the hover elaborates on, or null when the resource is hidden. + * + * One sample feeds both so that the hover costs no extra polling, and so + * the two can never disagree about the same instant. + */ + public async getResourceRender(): Promise { if (await this.isShown()) { - let display: string = await this.getDisplay(); - this._maxWidth = Math.max(this._maxWidth, display.length); + let render: ResourceRender = await this.render(); + this._maxWidth = Math.max(this._maxWidth, render.text.length); // Pad out to the correct length such that the length doesn't change - return display.padEnd(this._maxWidth, ' '); + return { text: render.text.padEnd(this._maxWidth, ' '), rows: render.rows, note: render.note }; } return null; } - protected abstract getDisplay(): Promise; + protected abstract render(): Promise; protected async isShown(): Promise { return Promise.resolve(this._config.get(`show.${this._configKey}`, this._isShownByDefault)); @@ -78,51 +173,99 @@ abstract class Resource { } return `${(bytes/unit).toFixed(this.getPrecision())} ${UnitLabels[unit]}`; } + + protected formatWithUnit(bytes: number, unit: string): string { + return `${(bytes / MemMappings[unit]).toFixed(this.getPrecision())} ${unit}`; + } + + /** + * Formats a percentage, treating the unreportable as zero. + * + * Several of these figures are ratios the platform can hand back as NaN — + * per-core load between two samples in the same tick, or a fraction of a + * zero-byte volume — and "NaN%" in a status bar helps nobody. + */ + protected formatPercent(value: number): string { + return `${(Number.isFinite(value) ? value : 0).toFixed(this.getPrecision())}%`; + } } class CpuUsage extends Resource { constructor(config: WorkspaceConfiguration) { - super(config, true, "cpuusage"); + super(config, true, "cpuusage", SECTION_CPU); } - async getDisplay(): Promise { + async render(): Promise { let currentLoad = await si.currentLoad(); - return `$(pulse) ${(100 - currentLoad.currentload_idle).toFixed(this.getPrecision())}%`; + let usage = 100 - currentLoad.currentload_idle; + + return { + text: `$(pulse) ${usage.toFixed(this.getPrecision())}%`, + rows: this.getRows(currentLoad, usage), + }; } -} + private getRows(currentLoad: any, usage: number): DetailRow[] { + let rows: DetailRow[] = []; -class CpuTemp extends Resource { + if (PROCESSOR !== "") { + rows.push({ label: "Processor", value: PROCESSOR }); + } - constructor(config: WorkspaceConfiguration) { - super(config, false, "cputemp"); - } + // The split matters: a machine at 90% system time is usually waiting on + // something, where 90% user time is work getting done. + rows.push({ + label: "Usage", + value: `${this.formatPercent(usage)} (${this.formatPercent(currentLoad.currentload_user)} user, ` + + `${this.formatPercent(currentLoad.currentload_system)} system)`, + }); - protected async isShown(): Promise { - // If the CPU temp sensor cannot retrieve a valid temperature, disallow its reporting. - var cpuTemp = (await si.cpuTemperature()).main; - let hasCpuTemp = cpuTemp !== -1; - return hasCpuTemp && await super.isShown(); - } + let cores = formatCoreLoads((currentLoad.cpus || []).map((cpu: any) => cpu.load)); + if (cores !== "") { + rows.push({ label: "Cores", value: cores }); + } - async getDisplay(): Promise { - let currentTemps = await si.cpuTemperature(); - return `$(flame) ${(currentTemps.main).toFixed(this.getPrecision())} C`; + let averages = loadAverage(); + if (averages !== null) { + rows.push({ label: "Load average", value: `${averages} (1, 5, 15 min)` }); + } + + return rows; } + } class CpuFreq extends Resource { constructor(config: WorkspaceConfiguration) { - super(config, true, "cpufreq"); + super(config, true, "cpufreq", SECTION_CPU); } - async getDisplay(): Promise { + async render(): Promise { let cpuCurrentSpeed = await si.cpuCurrentspeed(); // systeminformation returns frequency in terms of GHz by default let speedHz = parseFloat(cpuCurrentSpeed.avg) * Units.G; let formattedWithUnits = this.getFormattedWithUnits(speedHz); - return `$(dashboard) ${(formattedWithUnits)}`; + + return { + text: `$(dashboard) ${(formattedWithUnits)}`, + rows: this.getRows(cpuCurrentSpeed, formattedWithUnits), + }; + } + + private getRows(cpuCurrentSpeed: any, average: string): DetailRow[] { + let rows: DetailRow[] = [{ label: "Frequency", value: average }]; + + // Identical wherever the platform reports one figure for every core, + // which covers Apple Silicon and most of Windows, so the spread is only + // worth a row when there is one. + if (cpuCurrentSpeed.min !== cpuCurrentSpeed.max) { + let slowest = this.getFormattedWithUnits(parseFloat(cpuCurrentSpeed.min) * Units.G); + let fastest = this.getFormattedWithUnits(parseFloat(cpuCurrentSpeed.max) * Units.G); + rows.push({ label: "Across cores", value: `${slowest} to ${fastest}` }); + } + + return rows; } getFormattedWithUnits(speedHz: number): string { @@ -132,6 +275,42 @@ class CpuFreq extends Resource { } } +class CpuTemp extends Resource { + + constructor(config: WorkspaceConfiguration) { + super(config, false, "cputemp", SECTION_CPU); + } + + protected async isShown(): Promise { + // If the CPU temp sensor cannot retrieve a valid temperature, disallow its reporting. + var cpuTemp = (await si.cpuTemperature()).main; + let hasCpuTemp = cpuTemp !== -1; + return hasCpuTemp && await super.isShown(); + } + + async render(): Promise { + let currentTemps = await si.cpuTemperature(); + let rows: DetailRow[] = [ + { label: "Temperature", value: this.formatCelsius(currentTemps.main) }, + ]; + + // main is an average across packages on machines that have several, so + // the hottest sensor is the one that throttles first. + if (currentTemps.max > currentTemps.main) { + rows.push({ label: "Hottest sensor", value: this.formatCelsius(currentTemps.max) }); + } + + return { + text: `$(flame) ${this.formatCelsius(currentTemps.main)}`, + rows: rows, + }; + } + + private formatCelsius(temperature: number): string { + return `${(temperature).toFixed(this.getPrecision())} C`; + } +} + /** * Base for the macOS GPU metrics, which all come from one IOKit registry read. * @@ -143,12 +322,12 @@ abstract class AppleGpuResource extends Resource { protected _sampler: AppleGpuSampler; constructor(config: WorkspaceConfiguration, isShownByDefault: boolean, configKey: string, sampler: AppleGpuSampler) { - super(config, isShownByDefault, configKey); + super(config, isShownByDefault, configKey, SECTION_GPU); this._sampler = sampler; } protected async isShown(): Promise { - // The sampler caches briefly, so this read and the one in getDisplay() + // The sampler caches briefly, so this read and the one in render() // share a single ioreg invocation. let stats = await this._sampler.sample(); return stats !== null && await super.isShown(); @@ -161,12 +340,28 @@ class GpuUsage extends AppleGpuResource { super(config, true, "gpu", sampler); } - async getDisplay(): Promise { + async render(): Promise { let stats = await this._sampler.sample(); if (stats === null) { - return ""; + return { text: "", rows: [] }; + } + + return { + text: `$(circuit-board) ${(stats.utilization).toFixed(this.getPrecision())}%`, + rows: this.getRows(stats), + }; + } + + private getRows(stats: GpuStats): DetailRow[] { + let rows: DetailRow[] = []; + + if (stats.model !== null) { + let cores = stats.coreCount === null ? "" : ` · ${stats.coreCount} cores`; + rows.push({ label: "Graphics", value: `${stats.model}${cores}` }); } - return `$(circuit-board) ${(stats.utilization).toFixed(this.getPrecision())}%`; + + rows.push({ label: "Utilization", value: this.formatPercent(stats.utilization) }); + return rows; } } @@ -176,25 +371,37 @@ class GpuMemory extends AppleGpuResource { super(config, false, "gpumem", sampler); } - async getDisplay(): Promise { + async render(): Promise { let stats = await this._sampler.sample(); if (stats === null) { - return ""; + return { text: "", rows: [] }; } + let unit = this._config.get('gpu.unit', "GB"); var memDivisor = MemMappings[unit]; // Apple Silicon shares system memory with the CPU, so this is memory // currently mapped out of memory the driver has claimed, not VRAM. let inUseWithUnits = stats.inUseMemory / memDivisor; let allocatedWithUnits = stats.allocatedMemory / memDivisor; - return `$(server) ${(inUseWithUnits).toFixed(this.getPrecision())}/${(allocatedWithUnits).toFixed(this.getPrecision())} ${unit}`; + + return { + text: `$(server) ${(inUseWithUnits).toFixed(this.getPrecision())}/${(allocatedWithUnits).toFixed(this.getPrecision())} ${unit}`, + rows: [ + { label: "Memory in use", value: this.formatWithUnit(stats.inUseMemory, unit) }, + { label: "Driver allocation", value: this.formatWithUnit(stats.allocatedMemory, unit) }, + ], + // The one figure here people reliably misread, and the status bar + // has no room to say so. + note: "The GPU shares one pool of memory with the CPU. The allocation is what the driver " + + "has claimed from the system so far, not a fixed VRAM capacity, so it moves over time.", + }; } } class Battery extends Resource { constructor(config: WorkspaceConfiguration) { - super(config, true, "battery"); + super(config, true, "battery", SECTION_BATTERY); } protected async isShown(): Promise { @@ -202,33 +409,102 @@ class Battery extends Resource { return hasBattery && await super.isShown(); } - async getDisplay(): Promise { + async render(): Promise { let rawBattery = await si.battery(); var percentRemaining = Math.min(Math.max(rawBattery.percent, 0), 100); - return `$(plug) ${percentRemaining}%`; + + return { + text: `$(plug) ${percentRemaining}%`, + rows: this.getRows(rawBattery, percentRemaining), + }; + } + + private getRows(rawBattery: any, percentRemaining: number): DetailRow[] { + let rows: DetailRow[] = [ + { label: "Charge", value: `${percentRemaining}%` }, + { label: "State", value: this.getState(rawBattery) }, + ]; + + let remaining = formatMinutes(rawBattery.timeremaining); + if (remaining !== null) { + rows.push({ label: rawBattery.ischarging ? "Until full" : "Remaining", value: remaining }); + } + + // Batteries wear: a pack holding 4600 of the 5000 mAh it was built for + // is at 92% health however full its charge reads. + if (rawBattery.designedcapacity > 0 && rawBattery.maxcapacity > 0) { + let health = rawBattery.maxcapacity / rawBattery.designedcapacity * 100; + rows.push({ label: "Health", value: `${this.formatPercent(health)} of design capacity` }); + } + + if (rawBattery.cyclecount > 0) { + rows.push({ label: "Cycles", value: `${rawBattery.cyclecount}` }); + } + + return rows; + } + + private getState(rawBattery: any): string { + if (rawBattery.ischarging) { + return "Charging"; + } + if (rawBattery.acconnected) { + return "Plugged in, not charging"; + } + return "On battery"; } } class Memory extends Resource { constructor(config: WorkspaceConfiguration) { - super(config, true, "mem"); + super(config, true, "mem", SECTION_MEMORY); } - - async getDisplay() : Promise { + + async render() : Promise { let unit = this._config.get('mem.unit', "GB"); var memDivisor = MemMappings[unit]; let memoryData = await si.mem(); let memoryUsedWithUnits = memoryData.active / memDivisor; let memoryTotalWithUnits = memoryData.total / memDivisor; - return `$(ellipsis) ${(memoryUsedWithUnits).toFixed(this.getPrecision())}/${(memoryTotalWithUnits).toFixed(this.getPrecision())} ${unit}`; + + return { + text: `$(ellipsis) ${(memoryUsedWithUnits).toFixed(this.getPrecision())}/${(memoryTotalWithUnits).toFixed(this.getPrecision())} ${unit}`, + rows: this.getRows(memoryData, unit), + }; + } + + private getRows(memoryData: any, unit: string): DetailRow[] { + let rows: DetailRow[] = [ + { + label: "In use", + value: `${this.formatWithUnit(memoryData.active, unit)} of ${this.formatWithUnit(memoryData.total, unit)}` + + ` (${this.formatPercent(memoryData.active / memoryData.total * 100)})`, + }, + { label: "Available", value: this.formatWithUnit(memoryData.available, unit) }, + ]; + + // Cache is memory the system will hand back the moment anything wants + // it, which is why "used" so often looks alarming and is not. + if (memoryData.buffcache > 0) { + rows.push({ label: "Cached", value: this.formatWithUnit(memoryData.buffcache, unit) }); + } + + if (memoryData.swaptotal > 0) { + rows.push({ + label: "Swap", + value: `${this.formatWithUnit(memoryData.swapused, unit)} of ${this.formatWithUnit(memoryData.swaptotal, unit)}`, + }); + } + + return rows; } } class DiskSpace extends Resource { constructor(config: WorkspaceConfiguration) { - super(config, false, "disk"); + super(config, false, "disk", SECTION_DISK); } getFormat(): DiskSpaceFormat { @@ -263,53 +539,257 @@ class DiskSpace extends Resource { } } - async getDisplay(): Promise { + async render(): Promise { let fsSizes: Volume[] = await si.fsSize(); let volumes = selectVolumes(fsSizes, this.getDrives(), process.platform); let formattedDrives = volumes.map(volume => this.getFormattedDiskSpace(volume)); - return "$(database) " + formattedDrives.join(", "); + + return { + text: "$(database) " + formattedDrives.join(", "), + rows: volumes.map(volume => this.getRow(volume)), + }; + } + + /** + * The disk.format setting picks one of four ways to state a volume's + * space for the status bar. The hover has room for all of them, so it + * gives the figures the setting left out. + */ + private getRow(volume: Volume): DetailRow { + return { + label: volumeLabel(volume, process.platform), + value: `${this.convertBytesToLargestUnit(volume.used)} of ${this.convertBytesToLargestUnit(volume.size)} used` + + ` (${this.formatPercent(volume.use)}), ${this.convertBytesToLargestUnit(volume.size - volume.used)} free`, + }; + } +} + +/** + * One section's status bar entry: the resources it shows, and the hover that + * explains them. + */ +class SectionMonitor { + private _section: Section; + private _resources: Resource[]; + private _delimiter: string; + private _item: StatusBarItem; + private _isVisible: boolean; + + // The markdown the hover was last given, so that a sample which changed + // nothing does not redraw it. + private _markdown: string; + + constructor(section: Section, resources: Resource[], alignment: StatusBarAlignment, priority: number, color: string) { + this._section = section; + this._resources = resources; + this._delimiter = " "; + this._isVisible = false; + this._markdown = ""; + this._item = this.createItem(alignment, priority, color); + } + + public getSection(): Section { + return this._section; + } + + public getResources(): Resource[] { + return this._resources; + } + + public addResource(resource: Resource) { + this._resources.push(resource); + } + + public getAlignment(): StatusBarAlignment { + return this._item.alignment; + } + + public setColor(color: string) { + this._item.color = color; + } + + /** + * Moves this section's entry to the other end of the status bar, which the + * API only allows by replacing the entry. + */ + public realign(alignment: StatusBarAlignment, priority: number, color: string) { + this._item.dispose(); + this._isVisible = false; + this._markdown = ""; + this._item = this.createItem(alignment, priority, color); + } + + /** + * Samples every resource in this section and shows the result, hiding the + * entry entirely when the section has nothing to report. + */ + public async update(): Promise { + let pendingUpdates = this._resources.map(resource => resource.getResourceRender()); + let renders = (await Promise.all(pendingUpdates)).filter(render => render !== null) as ResourceRender[]; + + if (renders.length === 0) { + this.setVisible(false); + return null; + } + + // All three from the one sample, so the reading, the hover explaining + // it and the panel always describe the same instant. + this._item.text = renders.map(render => render.text).join(this._delimiter); + this.setTooltip(renderTooltip(this._section, renders)); + this.setVisible(true); + + return { + id: this._section.id, + name: this._section.name, + rows: renders.reduce((all, render) => all.concat(render.rows), [] as DetailRow[]), + notes: renders.map(render => render.note).filter(note => note !== undefined) as string[], + }; + } + + public dispose() { + this._item.dispose(); + } + + private createItem(alignment: StatusBarAlignment, priority: number, color: string): StatusBarItem { + let item = window.createStatusBarItem(this._section.id, alignment, priority); + // Named, so the status bar's context menu offers "System Vitals CPU" + // rather than an extension identifier repeated five times. + item.name = `System Vitals ${this._section.name}`; + item.color = color; + // Clicking an entry opens the details panel at this section, which is + // also what makes VS Code give it a pointer and a hover highlight. + item.command = { + title: `Show System Vitals ${this._section.name} details`, + command: DETAILS_COMMAND, + arguments: [this._section.id], + }; + return item; + } + + private setVisible(isVisible: boolean) { + if (isVisible === this._isVisible) { + return; + } + + this._isVisible = isVisible; + if (isVisible) { + this._item.show(); + } else { + this._item.hide(); + } + } + + /** + * Hands the hover its new content, unless a sample left it saying exactly + * what it already said. + * + * VS Code redraws a visible hover the instant its tooltip changes, and it + * cannot tell that the replacement is identical, so a disk reading that + * held steady would otherwise flicker for nothing. + */ + private setTooltip(markdown: string) { + if (markdown === this._markdown) { + return; + } + + this._markdown = markdown; + this._item.tooltip = markdown === "" ? undefined : this.buildTooltip(markdown); + } + + private buildTooltip(markdown: string): MarkdownString { + // The second argument renders $(icon) the way the status bar does. The + // hover deliberately stays untrusted: the click handles the one command + // worth offering, so no command link has to survive a volume name. + return new MarkdownString(markdown, true); } } class ResMon { - private _statusBarItem: StatusBarItem; private _config: WorkspaceConfiguration; - private _delimiter: string; private _updating: boolean; - private _resources: Resource[]; + private _sections: SectionMonitor[]; private _configListener: Disposable; + private _commandListeners: Disposable[]; + private _extensionFilter: string; + + // The view's tab appears in the panel only once a reading has been clicked, + // and goes away again when it is closed, so nothing is spent on a view + // nobody has asked for. + private _details: DetailsView; + private _snapshots: PanelSection[]; - constructor() { + constructor(extensionFilter: string) { this._config = workspace.getConfiguration('systemvitals'); - this._delimiter = " "; this._updating = false; - this._statusBarItem = window.createStatusBarItem(this._config.get('alignLeft') ? StatusBarAlignment.Left : StatusBarAlignment.Right); - this._statusBarItem.color = this._getColor(); - this._statusBarItem.show(); + this._extensionFilter = extensionFilter; + this._details = new DetailsView(sectionId => this.openSettings(sectionId)); + this._snapshots = []; // The GPU resources share one sampler so that a tick costs a single // read of the IOKit registry rather than one per resource. let gpuSampler = new AppleGpuSampler(); - // Add all resources to monitor - this._resources = []; - this._resources.push(new CpuUsage(this._config)); - this._resources.push(new CpuFreq(this._config)); - this._resources.push(new GpuUsage(this._config, gpuSampler)); - this._resources.push(new GpuMemory(this._config, gpuSampler)); - this._resources.push(new Battery(this._config)); - this._resources.push(new Memory(this._config)); - this._resources.push(new DiskSpace(this._config)); - this._resources.push(new CpuTemp(this._config)); + // Add all resources to monitor. Resources sharing a section are kept + // together, since a section is one entry in the status bar. + let resources: Resource[] = [ + new CpuUsage(this._config), + new CpuFreq(this._config), + new CpuTemp(this._config), + new GpuUsage(this._config, gpuSampler), + new GpuMemory(this._config, gpuSampler), + new Battery(this._config), + new Memory(this._config), + new DiskSpace(this._config), + ]; + + this._sections = this.createSections(resources, this._getAlignment()); // Settings that shorten a resource's display would otherwise leave the // status bar padded out at the old width for the rest of the session. this._configListener = workspace.onDidChangeConfiguration(event => { if (event.affectsConfiguration('systemvitals')) { - this._resources.forEach(resource => resource.resetWidth()); + this.forEachResource(resource => resource.resetWidth()); } }); + + // Registered rather than only wired to the readings, so the view can be + // opened and closed from the command palette as well as by clicking. + this._commandListeners = [ + commands.registerCommand(DETAILS_COMMAND, (sectionId?: string) => this.showDetails(sectionId)), + commands.registerCommand(HIDE_DETAILS_COMMAND, () => this._details.hide()), + ]; + } + + /** + * Opens the details view at the section that was clicked, or closes it + * again if that is already what it is showing. + */ + private showDetails(sectionId?: string) { + // Clicking the reading that is already on show closes it again. The + // palette command arrives without a section and only ever opens, since + // a command called Show Details that hides them would be a poor joke. + if (sectionId !== undefined && this._details.isShowing(sectionId)) { + this._details.hide(); + return; + } + + // Seeded with the last sample so the view has something to show at + // once, rather than staying blank until the next update comes round. + this._details.update(this._snapshots, this._getCadence()); + this._details.reveal(sectionId); + } + + private openSettings(sectionId: string) { + let section = this._sections + .map(monitor => monitor.getSection()) + .filter(candidate => candidate.id === sectionId)[0]; + let filter = section === undefined ? this._extensionFilter : `${this._extensionFilter} ${section.settingsFilter}`; + commands.executeCommand(SETTINGS_COMMAND, filter); + } + + private _getCadence(): string { + return `Refreshing every ${formatInterval(this._config.get('updatefrequencyms', DEFAULT_UPDATE_FREQUENCY_MS))}`; } public StartUpdating() { @@ -320,7 +800,41 @@ class ResMon { public StopUpdating() { this._updating = false; } - + + /** + * Groups the resources into one entry per section, in the order they are + * registered. + * + * Status bar entries are ordered by descending priority, so counting down + * from zero keeps the sections left to right in that same order while + * leaving the leftmost of them where the single entry used to sit. + */ + private createSections(resources: Resource[], alignment: StatusBarAlignment): SectionMonitor[] { + let sections: SectionMonitor[] = []; + let color = this._getColor(); + + resources.forEach(resource => { + let section = resource.getSection(); + let existing = sections.filter(candidate => candidate.getSection().id === section.id)[0]; + if (existing !== undefined) { + existing.addResource(resource); + return; + } + + sections.push(new SectionMonitor(section, [resource], alignment, -sections.length, color)); + }); + + return sections; + } + + private forEachResource(visit: (resource: Resource) => void) { + this._sections.forEach(section => section.getResources().forEach(visit)); + } + + private _getAlignment(): StatusBarAlignment { + return this._config.get('alignLeft') ? StatusBarAlignment.Left : StatusBarAlignment.Right; + } + private _getColor() : string { const defaultColor = "#FFFFFF"; @@ -342,36 +856,34 @@ class ResMon { // pointed at the new one or they would keep reading activation-time // values. this._config = workspace.getConfiguration('systemvitals'); - this._resources.forEach(resource => resource.setConfig(this._config)); - - // Update the status bar item's styling - let proposedAlignment = this._config.get('alignLeft') ? StatusBarAlignment.Left : StatusBarAlignment.Right; - if (proposedAlignment !== this._statusBarItem.alignment) { - this._statusBarItem.dispose(); - this._statusBarItem = window.createStatusBarItem(proposedAlignment); - this._statusBarItem.color = this._getColor(); - this._statusBarItem.show(); - } else { - this._statusBarItem.color = this._getColor(); - } - - // Get the display of the requested resources - let pendingUpdates: Promise[] = this._resources.map(resource => resource.getResourceDisplay()); - - // Wait for the resources to update - this._statusBarItem.text = await Promise.all(pendingUpdates).then(finishedUpdates => { - // Remove nulls, join with delimiter - return finishedUpdates.filter(update => update !== null).join(this._delimiter); + this.forEachResource(resource => resource.setConfig(this._config)); + + // Update each entry's styling + let proposedAlignment = this._getAlignment(); + let color = this._getColor(); + this._sections.forEach((section, index) => { + if (proposedAlignment !== section.getAlignment()) { + section.realign(proposedAlignment, -index, color); + } else { + section.setColor(color); + } }); - setTimeout(() => this.update(), this._config.get('updatefrequencyms', 2000)); + // Sample every section, each of which shows its own result + let snapshots = await Promise.all(this._sections.map(section => section.update())); + this._snapshots = snapshots.filter(snapshot => snapshot !== null) as PanelSection[]; + this._details.update(this._snapshots, this._getCadence()); + + setTimeout(() => this.update(), this._config.get('updatefrequencyms', DEFAULT_UPDATE_FREQUENCY_MS)); } } dispose() { this.StopUpdating(); this._configListener.dispose(); - this._statusBarItem.dispose(); + this._commandListeners.forEach(listener => listener.dispose()); + this._sections.forEach(section => section.dispose()); + this._details.dispose(); } } diff --git a/src/panel.ts b/src/panel.ts new file mode 100644 index 0000000..d16db60 --- /dev/null +++ b/src/panel.ts @@ -0,0 +1,159 @@ +'use strict'; +import { commands, window, CancellationToken, Disposable, WebviewView, WebviewViewProvider, WebviewViewResolveContext } from 'vscode'; +import { DetailRow } from './tooltip'; +import { getPanelHtml } from './panelHtml'; + +/** + * One section as the details view shows it. + */ +export interface PanelSection { + id: string; + name: string; + rows: DetailRow[]; + notes: string[]; +} + +const VIEW_ID: string = 'systemvitals.details'; + +// The view is contributed behind this context key, so the panel carries no +// System Vitals tab until one is asked for, and closing the view takes the tab +// away again rather than leaving an empty shell behind. +const CONTEXT_KEY: string = 'systemvitals.detailsRequested'; + +/** + * The details view: what the hovers show, for every section at once, docked in + * the panel just above the status bar it belongs to. + * + * It stays put while it is read, unlike a hover, and unlike an editor tab it + * goes away for good when closed. + */ +export class DetailsView implements WebviewViewProvider { + private _view: WebviewView | undefined; + private _registration: Disposable; + + // The last data sampled, held so the view can be answered the moment it + // asks. It is torn down whenever it is closed or hidden behind another + // panel tab, and waiting a whole update interval to redraw on the way back + // would show an empty view. + private _sections: PanelSection[]; + private _cadence: string; + + // Which section the view is singling out, kept so it can be re-sent + // whenever the view rebuilds itself. + private _focused: string | undefined; + + // What a section's Settings button should open. + private _onSettings: (sectionId: string) => void; + + constructor(onSettings: (sectionId: string) => void) { + this._sections = []; + this._cadence = ""; + this._registration = window.registerWebviewViewProvider(VIEW_ID, this, { + // Nothing is worth keeping alive while hidden: the view rebuilds + // itself from the next sample, which is never far away. + webviewOptions: { retainContextWhenHidden: false }, + }); + this._onSettings = onSettings; + } + + public resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, token: CancellationToken) { + this._view = webviewView; + webviewView.webview.options = { + enableScripts: true, + // Nothing is loaded from disk: the document is self-contained. + localResourceRoots: [], + }; + webviewView.webview.html = getPanelHtml(createNonce()); + + webviewView.webview.onDidReceiveMessage(message => { + if (message.type === 'ready') { + this.post(); + this.postFocus(); + } else if (message.type === 'settings') { + this._onSettings(message.id); + } + }); + + webviewView.onDidDispose(() => { + this._view = undefined; + this._focused = undefined; + }); + } + + /** + * Whether the view is on screen showing the given section, which is what + * makes clicking the same reading twice close it again. + */ + public isShowing(sectionId: string): boolean { + return this._view !== undefined && this._view.visible && this._focused === sectionId; + } + + /** + * Brings the view up, and the given section to attention with it. + */ + public async reveal(sectionId?: string) { + this._focused = sectionId; + + // The context key has to land before the view can be focused, or there + // is no view yet to focus. + await commands.executeCommand('setContext', CONTEXT_KEY, true); + await commands.executeCommand(`${VIEW_ID}.focus`); + this.postFocus(); + } + + /** + * Takes the view away, tab and all. + */ + public hide() { + this._focused = undefined; + commands.executeCommand('setContext', CONTEXT_KEY, false); + } + + public update(sections: PanelSection[], cadence: string) { + this._sections = sections; + this._cadence = cadence; + if (this._view !== undefined && this._view.visible) { + this.post(); + } + } + + public dispose() { + this._registration.dispose(); + } + + private post() { + if (this._view !== undefined) { + this._view.webview.postMessage({ type: 'sections', sections: this._sections, cadence: this._cadence }); + } + } + + /** + * Tells the view which section to single out. + * + * Sent from _focused every time rather than handed over once: a message + * posted before the view's script has attached its listener is dropped, and + * the view is torn down and rebuilt every time it is hidden behind another + * panel tab. Re-sending on each handshake covers both, so the outline + * survives a round trip to the terminal and back. + */ + private postFocus() { + if (this._view === undefined || this._focused === undefined) { + return; + } + + this._view.webview.postMessage({ type: 'focus', id: this._focused }); + } +} + +/** + * A one-off token for the content security policy, so that the view's own + * script and stylesheet are the only ones it will run. + */ +function createNonce(): string { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let nonce = ''; + for (let index = 0; index < 32; index++) { + nonce += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); + } + return nonce; +} diff --git a/src/panelHtml.ts b/src/panelHtml.ts new file mode 100644 index 0000000..4b8505c --- /dev/null +++ b/src/panelHtml.ts @@ -0,0 +1,272 @@ +'use strict'; + +/** + * The details panel's document. + * + * Only the shell is built here. Every figure is filled in by the script below + * from data the extension posts, written with textContent rather than markup, + * so a volume named after a script tag is displayed as a volume named after a + * script tag. That is also why the content security policy can forbid + * everything except this one script and stylesheet. + */ +export function getPanelHtml(nonce: string): string { + return ` + + + + + +System Vitals + + + + +

Sampling…

+ + +`; +} diff --git a/src/test/appleGpu.test.ts b/src/test/appleGpu.test.ts index a337e56..37742cb 100644 --- a/src/test/appleGpu.test.ts +++ b/src/test/appleGpu.test.ts @@ -32,6 +32,36 @@ test('does not mistake the "(driver)" sibling for in-use memory', () => { assert.strictEqual(stats!.inUseMemory, 356089856); }); +test('reads the accelerator name and core count for the hover', () => { + let stats = parsePerformanceStatistics(M4_SAMPLE); + + assert.strictEqual(stats!.model, 'Apple M4'); + assert.strictEqual(stats!.coreCount, 10); +}); + +test('a node that names itself nothing still reports its statistics', () => { + let stats = parsePerformanceStatistics('"PerformanceStatistics" = {"Device Utilization %"=42}'); + + assert.strictEqual(stats!.utilization, 42); + assert.strictEqual(stats!.model, null); + assert.strictEqual(stats!.coreCount, null); +}); + +test('the name comes from the node that supplied the statistics', () => { + // A Mac with a discrete GPU lists both accelerators. If the first reports + // no statistics, its name must not be attached to the second one's numbers. + let dualGpu = '+-o IntelAccelerator \n' + + ' {\n "model" = "Intel UHD Graphics 630"\n "gpu-core-count" = 24\n }\n' + + '+-o AMDRadeonAccelerator \n' + + ' {\n "PerformanceStatistics" = {"Device Utilization %"=88}\n' + + ' "model" = "AMD Radeon Pro 5500M"\n "gpu-core-count" = 24\n }\n'; + + let stats = parsePerformanceStatistics(dualGpu); + + assert.strictEqual(stats!.utilization, 88); + assert.strictEqual(stats!.model, 'AMD Radeon Pro 5500M'); +}); + test('returns null rather than throwing on unusable input', () => { assert.strictEqual(parsePerformanceStatistics(''), null); assert.strictEqual(parsePerformanceStatistics('not ioreg output at all'), null); diff --git a/src/test/panelHtml.test.ts b/src/test/panelHtml.test.ts new file mode 100644 index 0000000..5f1bc08 --- /dev/null +++ b/src/test/panelHtml.test.ts @@ -0,0 +1,46 @@ +import * as assert from 'assert'; +import { test } from 'node:test'; +import { getPanelHtml } from '../panelHtml'; + +const NONCE = 'abc123'; + +test('the panel loads nothing from anywhere', () => { + // A webview that can reach the network is a webview that can leak what it + // is displaying, and this one displays the machine it runs on. + let html = getPanelHtml(NONCE); + + assert.ok(html.indexOf("default-src 'none'") !== -1, 'the policy must deny by default'); + assert.strictEqual(html.indexOf('http://'), -1); + assert.strictEqual(html.indexOf('https://'), -1); +}); + +test('only the panel\'s own script and stylesheet may run', () => { + let html = getPanelHtml(NONCE); + + assert.ok(html.indexOf(`script-src 'nonce-${NONCE}'`) !== -1); + assert.ok(html.indexOf(`style-src 'nonce-${NONCE}'`) !== -1); + assert.ok(html.indexOf(`