From 459df887fd54790fedb28063c749f3ebf66746b9 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 14 Apr 2026 18:16:11 +0300 Subject: [PATCH 1/2] feat: add speedtest visualization webview (#888) After running a speed test, results are now displayed in a lightweight Canvas-based chart (4.5KB JS) instead of raw JSON. The webview shows throughput over time with hover tooltips, a summary header, and a "View JSON" button for the raw data. - Add `packages/speedtest/` webview (vanilla TS, no framework) - Extract `createBaseWebviewConfig` from the React-specific variant so lightweight webviews can reuse the shared Vite config - Add typed IPC via `SpeedtestApi` in `@repo/shared` - Accept duration as seconds, show real-time progress bar - Expose `extensionUri` through `ServiceContainer` --- packages/shared/src/index.ts | 3 + packages/shared/src/speedtest/api.ts | 11 ++ packages/speedtest/package.json | 20 +++ packages/speedtest/src/chart.ts | 167 ++++++++++++++++++ packages/speedtest/src/css.d.ts | 1 + packages/speedtest/src/index.css | 94 ++++++++++ packages/speedtest/src/index.ts | 136 ++++++++++++++ packages/speedtest/tsconfig.json | 9 + packages/speedtest/vite.config.ts | 3 + .../webview-shared/createWebviewConfig.ts | 33 +++- pnpm-lock.yaml | 16 ++ src/commands.ts | 68 +++++-- src/core/container.ts | 6 + src/webviews/speedtest/speedtestPanel.ts | 71 ++++++++ .../webviews/speedtest/speedtestPanel.test.ts | 27 +++ 15 files changed, 641 insertions(+), 24 deletions(-) create mode 100644 packages/shared/src/speedtest/api.ts create mode 100644 packages/speedtest/package.json create mode 100644 packages/speedtest/src/chart.ts create mode 100644 packages/speedtest/src/css.d.ts create mode 100644 packages/speedtest/src/index.css create mode 100644 packages/speedtest/src/index.ts create mode 100644 packages/speedtest/tsconfig.json create mode 100644 packages/speedtest/vite.config.ts create mode 100644 src/webviews/speedtest/speedtestPanel.ts create mode 100644 test/unit/webviews/speedtest/speedtestPanel.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2da0281c..91c727e0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,3 +5,6 @@ export * from "./ipc/protocol"; export * from "./tasks/types"; export * from "./tasks/utils"; export * from "./tasks/api"; + +// Speedtest API +export { SpeedtestApi } from "./speedtest/api"; diff --git a/packages/shared/src/speedtest/api.ts b/packages/shared/src/speedtest/api.ts new file mode 100644 index 00000000..e421539d --- /dev/null +++ b/packages/shared/src/speedtest/api.ts @@ -0,0 +1,11 @@ +import { defineCommand, defineNotification } from "../ipc/protocol"; + +/** + * Speedtest webview IPC API. + */ +export const SpeedtestApi = { + /** Extension pushes JSON results to the webview */ + data: defineNotification("speedtest/data"), + /** Webview requests to open raw JSON in a text editor */ + viewJson: defineCommand("speedtest/viewJson"), +} as const; diff --git a/packages/speedtest/package.json b/packages/speedtest/package.json new file mode 100644 index 00000000..65229fcf --- /dev/null +++ b/packages/speedtest/package.json @@ -0,0 +1,20 @@ +{ + "name": "@repo/speedtest", + "version": "1.0.0", + "description": "Coder Speedtest visualization webview", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/webview-shared": "workspace:*" + }, + "devDependencies": { + "@types/vscode-webview": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts new file mode 100644 index 00000000..170332ee --- /dev/null +++ b/packages/speedtest/src/chart.ts @@ -0,0 +1,167 @@ +/** + * Lightweight canvas line chart for speedtest results. + * No dependencies — uses Canvas 2D API with VS Code theme colors. + */ + +export interface ChartPoint { + x: number; + y: number; + label: string; +} + +export interface ChartData { + labels: string[]; + values: number[]; + pointLabels: string[]; +} + +/** + * Draw a line chart on the given canvas and return hit-test positions. + */ +export function renderLineChart( + canvas: HTMLCanvasElement, + data: ChartData, +): ChartPoint[] { + const dpr = window.devicePixelRatio || 1; + const container = canvas.parentElement; + const { width, height } = container + ? container.getBoundingClientRect() + : canvas.getBoundingClientRect(); + canvas.width = width * dpr; + canvas.height = height * dpr; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + return []; + } + ctx.scale(dpr, dpr); + + const pad = { top: 24, right: 24, bottom: 52, left: 72 }; + const plotW = width - pad.left - pad.right; + const plotH = height - pad.top - pad.bottom; + const maxVal = Math.max(...data.values, 1) * 1.1; + const n = data.values.length; + + // Coordinate helpers + const xAt = (i: number) => pad.left + (i / Math.max(n - 1, 1)) * plotW; + const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH; + + // Read VS Code theme + const s = getComputedStyle(document.documentElement); + const css = (prop: string) => s.getPropertyValue(prop).trim(); + const fg = + css("--vscode-descriptionForeground") || + css("--vscode-editor-foreground") || + "#888"; + const accent = + css("--vscode-charts-blue") || + css("--vscode-terminal-ansiBlue") || + "#3794ff"; + const grid = css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)"; + const family = css("--vscode-font-family") || "sans-serif"; + + // ── Axes ── + + // Y-axis grid lines and labels + ctx.strokeStyle = grid; + ctx.lineWidth = 1; + ctx.fillStyle = fg; + ctx.font = `1em ${family}`; + ctx.textAlign = "right"; + for (let i = 0; i <= 5; i++) { + const y = yAt((i / 5) * maxVal); + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + plotW, y); + ctx.stroke(); + ctx.fillText(((i / 5) * maxVal).toFixed(0), pad.left - 12, y + 5); + } + + // Bottom axis line + ctx.strokeStyle = fg; + ctx.beginPath(); + ctx.moveTo(pad.left, pad.top + plotH); + ctx.lineTo(pad.left + plotW, pad.top + plotH); + ctx.stroke(); + + // X-axis labels (auto-thinned, deduped) + ctx.textAlign = "center"; + ctx.fillStyle = fg; + const maxLabels = Math.floor(plotW / 60); + const step = Math.max(1, Math.ceil(n / maxLabels)); + let lastDrawnLabel = ""; + let lastDrawnX = -Infinity; + for (let i = 0; i < n; i += step) { + if (data.labels[i] !== lastDrawnLabel) { + ctx.fillText(data.labels[i], xAt(i), height - pad.bottom + 24); + lastDrawnLabel = data.labels[i]; + lastDrawnX = xAt(i); + } + } + const last = n - 1; + if ( + last > 0 && + last % step !== 0 && + data.labels[last] !== lastDrawnLabel && + xAt(last) - lastDrawnX > 50 + ) { + ctx.fillText(data.labels[last], xAt(last), height - pad.bottom + 24); + } + + // Axis titles + ctx.font = `0.95em ${family}`; + ctx.fillText("Time", pad.left + plotW / 2, height - 4); + ctx.save(); + ctx.translate(14, pad.top + plotH / 2); + ctx.rotate(-Math.PI / 2); + ctx.fillText("Mbps", 0, 0); + ctx.restore(); + + if (n === 0) { + return []; + } + + // ── Series ── + + const baseline = pad.top + plotH; + + // Fill area + ctx.beginPath(); + ctx.moveTo(xAt(0), baseline); + for (let i = 0; i < n; i++) { + ctx.lineTo(xAt(i), yAt(data.values[i])); + } + ctx.lineTo(xAt(n - 1), baseline); + ctx.closePath(); + const gradient = ctx.createLinearGradient(0, pad.top, 0, baseline); + gradient.addColorStop(0, accent + "18"); + gradient.addColorStop(1, accent + "04"); + ctx.fillStyle = gradient; + ctx.fill(); + + // Line + ctx.beginPath(); + ctx.moveTo(xAt(0), yAt(data.values[0])); + for (let i = 1; i < n; i++) { + ctx.lineTo(xAt(i), yAt(data.values[i])); + } + ctx.strokeStyle = accent; + ctx.lineWidth = 2; + ctx.stroke(); + + // Dots and hit-test positions + const showDots = n <= 50; + const points: ChartPoint[] = []; + for (let i = 0; i < n; i++) { + const x = xAt(i); + const y = yAt(data.values[i]); + if (showDots) { + ctx.beginPath(); + ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.fillStyle = accent; + ctx.fill(); + } + points.push({ x, y, label: data.pointLabels[i] }); + } + return points; +} diff --git a/packages/speedtest/src/css.d.ts b/packages/speedtest/src/css.d.ts new file mode 100644 index 00000000..cbe652db --- /dev/null +++ b/packages/speedtest/src/css.d.ts @@ -0,0 +1 @@ +declare module "*.css"; diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css new file mode 100644 index 00000000..197e9a00 --- /dev/null +++ b/packages/speedtest/src/index.css @@ -0,0 +1,94 @@ +body { + margin: 0; + padding: 24px; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +.summary { + display: flex; + justify-content: center; + gap: 48px; + margin-bottom: 24px; + text-align: center; + /* Offset to align with the chart plot area (matches canvas left padding) */ + padding-left: 48px; +} + +.stat-label { + display: block; + font-size: 0.8em; + opacity: 0.6; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.stat-value { + font-size: 1.8em; + font-weight: 600; +} + +.stat-value small { + font-size: 0.55em; + font-weight: 400; + opacity: 0.7; +} + +.chart-container { + position: relative; + min-width: 400px; + height: 320px; + margin-bottom: 20px; +} + +.chart-container canvas { + width: 100%; + height: 100%; +} + +.actions { + display: flex; + justify-content: center; + padding-left: 48px; +} + +button { + padding: 6px 16px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 2px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + font: inherit; + cursor: pointer; +} + +button:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.tooltip { + position: absolute; + padding: 4px 8px; + border-radius: 3px; + background: var(--vscode-editorHoverWidget-background); + border: 1px solid var(--vscode-editorHoverWidget-border); + color: var(--vscode-editorHoverWidget-foreground); + font-size: 0.9em; + white-space: nowrap; + pointer-events: none; + transform: translateX(-50%); + opacity: 0; + transition: opacity 0.1s; +} + +.tooltip.visible { + opacity: 1; +} + +.error { + color: var(--vscode-errorForeground); + text-align: center; +} diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts new file mode 100644 index 00000000..5e285298 --- /dev/null +++ b/packages/speedtest/src/index.ts @@ -0,0 +1,136 @@ +import { SpeedtestApi } from "@repo/shared"; +import { postMessage } from "@repo/webview-shared"; + +import { type ChartPoint, renderLineChart } from "./chart"; +import "./index.css"; + +interface SpeedtestInterval { + start_time_seconds: number; + end_time_seconds: number; + throughput_mbits: number; +} + +interface SpeedtestResult { + overall: SpeedtestInterval; + intervals: SpeedtestInterval[]; +} + +let cleanup: (() => void) | undefined; + +window.addEventListener( + "message", + (event: MessageEvent<{ type: string; data?: string }>) => { + if (event.data.type === SpeedtestApi.data.method) { + const json = event.data.data ?? ""; + try { + const data = JSON.parse(json) as SpeedtestResult; + renderPage(data, () => + postMessage({ + method: SpeedtestApi.viewJson.method, + params: json, + }), + ); + } catch { + showError("Failed to parse speedtest data."); + } + } + }, +); + +function renderPage(data: SpeedtestResult, onViewJson: () => void): void { + const root = document.getElementById("root"); + if (!root) { + return; + } + + cleanup?.(); + root.innerHTML = ""; + + // Summary + const summary = document.createElement("div"); + summary.className = "summary"; + summary.innerHTML = ` +
+ Throughput + ${data.overall.throughput_mbits.toFixed(2)} Mbps +
+
+ Duration + ${data.overall.end_time_seconds.toFixed(1)}s +
+
+ Intervals + ${data.intervals.length} +
+ `; + root.appendChild(summary); + + // Chart with tooltip and resize handling + const container = document.createElement("div"); + container.className = "chart-container"; + const canvas = document.createElement("canvas"); + const tooltip = document.createElement("div"); + tooltip.className = "tooltip"; + container.append(canvas, tooltip); + root.appendChild(container); + + const chartData = { + labels: data.intervals.map((iv) => `${iv.end_time_seconds.toFixed(0)}s`), + values: data.intervals.map((iv) => iv.throughput_mbits), + pointLabels: data.intervals.map( + (iv) => + `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, + ), + }; + + let points: ChartPoint[] = []; + const draw = () => { + points = renderLineChart(canvas, chartData); + }; + draw(); + + const observer = new ResizeObserver(draw); + observer.observe(container); + + const onMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const hit = points.find( + (p) => Math.abs(p.x - mx) < 12 && Math.abs(p.y - my) < 12, + ); + if (hit) { + tooltip.textContent = hit.label; + tooltip.style.left = `${hit.x}px`; + tooltip.style.top = `${hit.y - 32}px`; + tooltip.classList.add("visible"); + } else { + tooltip.classList.remove("visible"); + } + }; + const onMouseLeave = () => tooltip.classList.remove("visible"); + canvas.addEventListener("mousemove", onMouseMove); + canvas.addEventListener("mouseleave", onMouseLeave); + + cleanup = () => { + observer.disconnect(); + canvas.removeEventListener("mousemove", onMouseMove); + canvas.removeEventListener("mouseleave", onMouseLeave); + }; + + // Actions + const actions = document.createElement("div"); + actions.className = "actions"; + const viewBtn = document.createElement("button"); + viewBtn.textContent = "View JSON"; + viewBtn.addEventListener("click", onViewJson); + actions.appendChild(viewBtn); + root.appendChild(actions); +} + +function showError(message: string): void { + const root = document.getElementById("root"); + if (root) { + root.innerHTML = `

${message}

`; + } +} diff --git a/packages/speedtest/tsconfig.json b/packages/speedtest/tsconfig.json new file mode 100644 index 00000000..d7f31093 --- /dev/null +++ b/packages/speedtest/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.packages.json", + "compilerOptions": { + "paths": { + "@repo/webview-shared": ["../webview-shared/src"] + } + }, + "include": ["src"] +} diff --git a/packages/speedtest/vite.config.ts b/packages/speedtest/vite.config.ts new file mode 100644 index 00000000..c89fc115 --- /dev/null +++ b/packages/speedtest/vite.config.ts @@ -0,0 +1,3 @@ +import { createBaseWebviewConfig } from "../webview-shared/createWebviewConfig"; + +export default createBaseWebviewConfig("speedtest", __dirname); diff --git a/packages/webview-shared/createWebviewConfig.ts b/packages/webview-shared/createWebviewConfig.ts index 484d4b07..59b85654 100644 --- a/packages/webview-shared/createWebviewConfig.ts +++ b/packages/webview-shared/createWebviewConfig.ts @@ -1,23 +1,29 @@ import babel from "@rolldown/plugin-babel"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { resolve } from "node:path"; -import { defineConfig, type UserConfig } from "vite"; +import { defineConfig, type Plugin, type UserConfig } from "vite"; /** - * Create a Vite config for a webview package + * Create a base Vite config for any webview package. + * Use this for lightweight webviews that don't need React. + * * @param webviewName - Name of the webview (used for output path) * @param dirname - __dirname of the calling config file + * @param options.entry - Entry file relative to package root (default: "src/index.ts") + * @param options.plugins - Additional Vite plugins to include */ -export function createWebviewConfig( +export function createBaseWebviewConfig( webviewName: string, dirname: string, + options?: { entry?: string; plugins?: Plugin[] }, ): UserConfig { const production = process.env.NODE_ENV === "production"; + const entry = options?.entry ?? "src/index.ts"; return defineConfig({ // Use relative URLs for assets (fonts, etc.) in CSS base: "./", - plugins: [react(), babel({ presets: [reactCompilerPreset()] })], + plugins: options?.plugins ?? [], build: { outDir: resolve(dirname, `../../dist/webviews/${webviewName}`), emptyOutDir: true, @@ -29,7 +35,7 @@ export function createWebviewConfig( chunkSizeWarningLimit: 600, rollupOptions: { // HTML is generated by the extension with CSP headers - input: resolve(dirname, "src/index.tsx"), + input: resolve(dirname, entry), output: { entryFileNames: "index.js", // Keep fonts with original names for proper CSS references @@ -51,3 +57,20 @@ export function createWebviewConfig( }, }); } + +/** + * Create a Vite config for a React-based webview package. + * Extends the base config with React and Babel plugins. + * + * @param webviewName - Name of the webview (used for output path) + * @param dirname - __dirname of the calling config file + */ +export function createWebviewConfig( + webviewName: string, + dirname: string, +): UserConfig { + return createBaseWebviewConfig(webviewName, dirname, { + entry: "src/index.tsx", + plugins: [react(), babel({ presets: [reactCompilerPreset()] })], + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05a0bfd..7c94b5b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,22 @@ importers: specifier: 'catalog:' version: 6.0.2 + packages/speedtest: + dependencies: + '@repo/webview-shared': + specifier: workspace:* + version: link:../webview-shared + devDependencies: + '@types/vscode-webview': + specifier: 'catalog:' + version: 1.57.5 + typescript: + specifier: 'catalog:' + version: 6.0.2 + vite: + specifier: 'catalog:' + version: 8.0.5(@types/node@24.10.12)(esbuild@0.28.0) + packages/tasks: dependencies: '@repo/shared': diff --git a/src/commands.ts b/src/commands.ts index b8a08aa5..64d1b1f1 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -35,6 +35,7 @@ import { import { resolveCliAuth } from "./settings/cli"; import { toRemoteAuthority, toSafeHost } from "./util"; import { vscodeProposed } from "./vscodeProposed"; +import { showSpeedtestChart } from "./webviews/speedtest/speedtestPanel"; import { AgentTreeItem, type OpenableTreeItem, @@ -64,6 +65,7 @@ export class Commands { private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; private readonly loginCoordinator: LoginCoordinator; + private readonly extensionUri: vscode.Uri; // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not @@ -87,6 +89,7 @@ export class Commands { this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); this.loginCoordinator = serviceContainer.getLoginCoordinator(); + this.extensionUri = serviceContainer.getExtensionUri(); } /** @@ -179,45 +182,72 @@ export class Commands { const { client, workspaceId } = resolved; - const duration = await vscode.window.showInputBox({ + const input = await vscode.window.showInputBox({ title: "Speed Test Duration", - prompt: "Duration for the speed test", - value: "5s", + prompt: "How long should the test run? (seconds)", + value: "5", validateInput: (value) => { - const v = value.trim(); - if (v && !cliExec.isGoDuration(v)) { - return "Invalid Go duration (e.g., 5s, 10s, 1m, 1m30s)"; + const n = Number(value.trim()); + if (!value.trim() || isNaN(n) || n <= 0) { + return "Please enter a positive number"; } return undefined; }, }); - if (duration === undefined) { + if (input === undefined) { return; } - const trimmedDuration = duration.trim(); + const seconds = Number(input.trim()); + const totalMs = seconds * 1000; const result = await withCancellableProgress( async ({ signal, progress }) => { - progress.report({ message: "Resolving CLI..." }); + progress.report({ message: "Connecting..." }); const env = await this.resolveCliEnv(client); - progress.report({ message: "Running..." }); - return cliExec.speedtest(env, workspaceId, trimmedDuration, signal); + + // Report progress based on elapsed time + const startTime = Date.now(); + let lastPercent = 0; + const timer = setInterval(() => { + const elapsed = Date.now() - startTime; + const elapsedSec = Math.floor(elapsed / 1000); + const remaining = Math.max(0, Math.ceil((totalMs - elapsed) / 1000)); + + if (remaining > 0) { + const percent = Math.min(Math.round((elapsed / totalMs) * 100), 95); + const increment = percent - lastPercent; + if (increment > 0) { + progress.report({ + message: `${elapsedSec}s / ${seconds}s`, + increment, + }); + lastPercent = percent; + } + } else { + progress.report({ message: "Collecting results..." }); + } + }, 100); + + try { + return await cliExec.speedtest( + env, + workspaceId, + `${seconds}s`, + signal, + ); + } finally { + clearInterval(timer); + } }, { location: vscode.ProgressLocation.Notification, - title: trimmedDuration - ? `Speed test for ${workspaceId} (${trimmedDuration})` - : `Speed test for ${workspaceId}`, + title: `Running speed test for ${workspaceId}`, cancellable: true, }, ); if (result.ok) { - const doc = await vscode.workspace.openTextDocument({ - content: result.value, - language: "json", - }); - await vscode.window.showTextDocument(doc); + showSpeedtestChart(this.extensionUri, result.value); return; } diff --git a/src/core/container.ts b/src/core/container.ts index ce8ca887..0e7bbc3c 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -16,6 +16,7 @@ import { SecretsManager } from "./secretsManager"; * Centralizes the creation and management of all core services. */ export class ServiceContainer implements vscode.Disposable { + private readonly extensionUri: vscode.Uri; private readonly logger: vscode.LogOutputChannel; private readonly pathResolver: PathResolver; private readonly mementoManager: MementoManager; @@ -26,6 +27,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly loginCoordinator: LoginCoordinator; constructor(context: vscode.ExtensionContext) { + this.extensionUri = context.extensionUri; this.logger = vscode.window.createOutputChannel("Coder", { log: true }); this.pathResolver = new PathResolver( context.globalStorageUri.fsPath, @@ -104,6 +106,10 @@ export class ServiceContainer implements vscode.Disposable { return this.loginCoordinator; } + getExtensionUri(): vscode.Uri { + return this.extensionUri; + } + /** * Dispose of all services and clean up resources. */ diff --git a/src/webviews/speedtest/speedtestPanel.ts b/src/webviews/speedtest/speedtestPanel.ts new file mode 100644 index 00000000..1db0a908 --- /dev/null +++ b/src/webviews/speedtest/speedtestPanel.ts @@ -0,0 +1,71 @@ +import * as vscode from "vscode"; + +import { buildCommandHandlers, SpeedtestApi } from "@repo/shared"; + +import { getWebviewHtml } from "../util"; + +/** + * Opens a webview panel to visualize speedtest results as a chart. + */ +export function showSpeedtestChart( + extensionUri: vscode.Uri, + json: string, +): void { + const panel = vscode.window.createWebviewPanel( + "coderSpeedtest", + "Speed Test Results", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(extensionUri, "dist", "webviews", "speedtest"), + ], + }, + ); + + panel.iconPath = { + light: vscode.Uri.joinPath(extensionUri, "media", "logo-black.svg"), + dark: vscode.Uri.joinPath(extensionUri, "media", "logo-white.svg"), + }; + + panel.webview.html = getWebviewHtml( + panel.webview, + extensionUri, + "speedtest", + "Speed Test Results", + ); + + const sendData = () => { + panel.webview.postMessage({ + type: SpeedtestApi.data.method, + data: json, + }); + }; + + // Send data now, and re-send whenever the panel becomes visible again + sendData(); + panel.onDidChangeViewState(() => { + if (panel.visible) { + sendData(); + } + }); + + const commandHandlers = buildCommandHandlers(SpeedtestApi, { + async viewJson(data: string) { + const doc = await vscode.workspace.openTextDocument({ + content: data, + language: "json", + }); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); + }, + }); + + panel.webview.onDidReceiveMessage( + async (message: { method: string; params?: unknown }) => { + const handler = commandHandlers[message.method]; + if (handler) { + await handler(message.params); + } + }, + ); +} diff --git a/test/unit/webviews/speedtest/speedtestPanel.test.ts b/test/unit/webviews/speedtest/speedtestPanel.test.ts new file mode 100644 index 00000000..561b2c8d --- /dev/null +++ b/test/unit/webviews/speedtest/speedtestPanel.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { buildCommandHandlers, SpeedtestApi } from "@repo/shared"; + +describe("SpeedtestApi", () => { + it("defines typed command handlers via buildCommandHandlers", async () => { + let receivedData: string | undefined; + + const handlers = buildCommandHandlers(SpeedtestApi, { + viewJson(data: string) { + receivedData = data; + }, + }); + + // Handler is keyed by the wire method name + expect(handlers[SpeedtestApi.viewJson.method]).toBeDefined(); + + // Dispatching through the handler passes the data correctly + await handlers[SpeedtestApi.viewJson.method]('{"test": true}'); + expect(receivedData).toBe('{"test": true}'); + }); + + it("uses consistent method names for notification and command", () => { + expect(SpeedtestApi.data.method).toBe("speedtest/data"); + expect(SpeedtestApi.viewJson.method).toBe("speedtest/viewJson"); + }); +}); From a37b1acb11bb9da78e593d560c23b705edfa05f4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 16 Apr 2026 18:33:28 +0300 Subject: [PATCH 2/2] refactor: improve speedtest chart accuracy and responsiveness - Time-proportional x-axis with dashed leader line from t=0 - Uniform tick labels with smart unit selection (s/m/h) that scale from seconds to hours - Dynamic y-axis padding based on measured label width - Binary search hit-test with crosshair-snap for dense data - ResizeObserver debounced via requestAnimationFrame - Tooltip clamped to container bounds - General cleanup: named constants, single-pass data prep, responsive layout, safer error handling, input validation, missing dependency --- packages/speedtest/package.json | 1 + packages/speedtest/src/chart.ts | 127 +++++++++++++++-------- packages/speedtest/src/index.css | 15 +-- packages/speedtest/src/index.ts | 109 +++++++++++++++---- packages/speedtest/tsconfig.json | 1 + pnpm-lock.yaml | 3 + src/commands.ts | 35 +++---- src/webviews/speedtest/speedtestPanel.ts | 6 +- 8 files changed, 208 insertions(+), 89 deletions(-) diff --git a/packages/speedtest/package.json b/packages/speedtest/package.json index 65229fcf..9fa4c727 100644 --- a/packages/speedtest/package.json +++ b/packages/speedtest/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@repo/shared": "workspace:*", "@repo/webview-shared": "workspace:*" }, "devDependencies": { diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts index 170332ee..1a9b1e33 100644 --- a/packages/speedtest/src/chart.ts +++ b/packages/speedtest/src/chart.ts @@ -10,11 +10,47 @@ export interface ChartPoint { } export interface ChartData { - labels: string[]; + /** Time positions for each data point (used for proportional x-spacing) */ + xValues: number[]; values: number[]; pointLabels: string[]; } +/** Points above this count are drawn as a line only (no dots). */ +export const DOT_THRESHOLD = 20; + +const DOT_RADIUS = 4; +const MIN_TICK_SPACING = 48; +const LEADER_OPACITY = 0.4; +const Y_GRID_LINES = 5; +const Y_HEADROOM = 1.1; + +/** Pick a "nice" step size that is >= the raw step. */ +const NICE_STEPS = [ + 1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600, +]; + +function niceStep(raw: number): number { + return NICE_STEPS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600; +} + +/** Build a tick formatter that uses a single unit for the entire axis. */ +function tickFormatter(step: number): (t: number) => string { + if (step >= 3600) { + return (t) => { + const h = t / 3600; + return `${Number.isInteger(h) ? h : h.toFixed(1)}h`; + }; + } + if (step >= 60) { + return (t) => { + const m = t / 60; + return `${Number.isInteger(m) ? m : m.toFixed(1)}m`; + }; + } + return (t) => `${t}s`; +} + /** * Draw a line chart on the given canvas and return hit-test positions. */ @@ -36,17 +72,11 @@ export function renderLineChart( } ctx.scale(dpr, dpr); - const pad = { top: 24, right: 24, bottom: 52, left: 72 }; - const plotW = width - pad.left - pad.right; - const plotH = height - pad.top - pad.bottom; - const maxVal = Math.max(...data.values, 1) * 1.1; const n = data.values.length; + const maxVal = Math.max(...data.values, 1) * Y_HEADROOM; + const maxX = n > 0 ? data.xValues[n - 1] : 1; + const xRange = maxX || 1; - // Coordinate helpers - const xAt = (i: number) => pad.left + (i / Math.max(n - 1, 1)) * plotW; - const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH; - - // Read VS Code theme const s = getComputedStyle(document.documentElement); const css = (prop: string) => s.getPropertyValue(prop).trim(); const fg = @@ -60,55 +90,55 @@ export function renderLineChart( const grid = css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)"; const family = css("--vscode-font-family") || "sans-serif"; + ctx.font = `1em ${family}`; + const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width; + const pad = { + top: 24, + right: 24, + bottom: 52, + left: Math.max(48, yLabelWidth + 24), + }; + const plotW = width - pad.left - pad.right; + const plotH = height - pad.top - pad.bottom; + + const tAt = (t: number) => pad.left + (t / xRange) * plotW; + const xAt = (i: number) => tAt(data.xValues[i]); + const yAt = (v: number) => pad.top + plotH - (v / maxVal) * plotH; + // ── Axes ── - // Y-axis grid lines and labels ctx.strokeStyle = grid; ctx.lineWidth = 1; ctx.fillStyle = fg; - ctx.font = `1em ${family}`; ctx.textAlign = "right"; - for (let i = 0; i <= 5; i++) { - const y = yAt((i / 5) * maxVal); + for (let i = 0; i <= Y_GRID_LINES; i++) { + const y = yAt((i / Y_GRID_LINES) * maxVal); ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + plotW, y); ctx.stroke(); - ctx.fillText(((i / 5) * maxVal).toFixed(0), pad.left - 12, y + 5); + ctx.fillText( + ((i / Y_GRID_LINES) * maxVal).toFixed(0), + pad.left - 12, + y + 5, + ); } - // Bottom axis line ctx.strokeStyle = fg; ctx.beginPath(); ctx.moveTo(pad.left, pad.top + plotH); ctx.lineTo(pad.left + plotW, pad.top + plotH); ctx.stroke(); - // X-axis labels (auto-thinned, deduped) ctx.textAlign = "center"; ctx.fillStyle = fg; - const maxLabels = Math.floor(plotW / 60); - const step = Math.max(1, Math.ceil(n / maxLabels)); - let lastDrawnLabel = ""; - let lastDrawnX = -Infinity; - for (let i = 0; i < n; i += step) { - if (data.labels[i] !== lastDrawnLabel) { - ctx.fillText(data.labels[i], xAt(i), height - pad.bottom + 24); - lastDrawnLabel = data.labels[i]; - lastDrawnX = xAt(i); - } - } - const last = n - 1; - if ( - last > 0 && - last % step !== 0 && - data.labels[last] !== lastDrawnLabel && - xAt(last) - lastDrawnX > 50 - ) { - ctx.fillText(data.labels[last], xAt(last), height - pad.bottom + 24); + const maxTicks = Math.max(1, Math.floor(plotW / MIN_TICK_SPACING)); + const tickStep = niceStep(xRange / maxTicks); + const formatTick = tickFormatter(tickStep); + for (let t = 0; t <= maxX; t += tickStep) { + ctx.fillText(formatTick(t), tAt(t), height - pad.bottom + 24); } - // Axis titles ctx.font = `0.95em ${family}`; ctx.fillText("Time", pad.left + plotW / 2, height - 4); ctx.save(); @@ -124,10 +154,23 @@ export function renderLineChart( // ── Series ── const baseline = pad.top + plotH; + const firstPx = xAt(0); + + if (data.xValues[0] > 0) { + ctx.beginPath(); + ctx.moveTo(tAt(0), baseline); + ctx.lineTo(firstPx, yAt(data.values[0])); + ctx.setLineDash([4, 4]); + ctx.strokeStyle = accent; + ctx.lineWidth = 1; + ctx.globalAlpha = LEADER_OPACITY; + ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; + } - // Fill area ctx.beginPath(); - ctx.moveTo(xAt(0), baseline); + ctx.moveTo(firstPx, baseline); for (let i = 0; i < n; i++) { ctx.lineTo(xAt(i), yAt(data.values[i])); } @@ -139,7 +182,6 @@ export function renderLineChart( ctx.fillStyle = gradient; ctx.fill(); - // Line ctx.beginPath(); ctx.moveTo(xAt(0), yAt(data.values[0])); for (let i = 1; i < n; i++) { @@ -149,15 +191,14 @@ export function renderLineChart( ctx.lineWidth = 2; ctx.stroke(); - // Dots and hit-test positions - const showDots = n <= 50; + const showDots = n <= DOT_THRESHOLD; const points: ChartPoint[] = []; for (let i = 0; i < n; i++) { const x = xAt(i); const y = yAt(data.values[i]); if (showDots) { ctx.beginPath(); - ctx.arc(x, y, 4, 0, Math.PI * 2); + ctx.arc(x, y, DOT_RADIUS, 0, Math.PI * 2); ctx.fillStyle = accent; ctx.fill(); } diff --git a/packages/speedtest/src/index.css b/packages/speedtest/src/index.css index 197e9a00..572ec86b 100644 --- a/packages/speedtest/src/index.css +++ b/packages/speedtest/src/index.css @@ -1,6 +1,13 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + body { margin: 0; padding: 24px; + min-width: 360px; background: var(--vscode-editor-background); color: var(--vscode-editor-foreground); font-family: var(--vscode-font-family); @@ -9,12 +16,11 @@ body { .summary { display: flex; + flex-wrap: wrap; justify-content: center; - gap: 48px; + gap: 16px 48px; margin-bottom: 24px; text-align: center; - /* Offset to align with the chart plot area (matches canvas left padding) */ - padding-left: 48px; } .stat-label { @@ -39,7 +45,6 @@ body { .chart-container { position: relative; - min-width: 400px; height: 320px; margin-bottom: 20px; } @@ -52,7 +57,6 @@ body { .actions { display: flex; justify-content: center; - padding-left: 48px; } button { @@ -79,7 +83,6 @@ button:hover { font-size: 0.9em; white-space: nowrap; pointer-events: none; - transform: translateX(-50%); opacity: 0; transition: opacity 0.1s; } diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts index 5e285298..e124a4f7 100644 --- a/packages/speedtest/src/index.ts +++ b/packages/speedtest/src/index.ts @@ -1,7 +1,12 @@ import { SpeedtestApi } from "@repo/shared"; import { postMessage } from "@repo/webview-shared"; -import { type ChartPoint, renderLineChart } from "./chart"; +import { + DOT_THRESHOLD, + type ChartData, + type ChartPoint, + renderLineChart, +} from "./chart"; import "./index.css"; interface SpeedtestInterval { @@ -15,6 +20,8 @@ interface SpeedtestResult { intervals: SpeedtestInterval[]; } +const HIT_RADIUS = 12; + let cleanup: (() => void) | undefined; window.addEventListener( @@ -37,6 +44,21 @@ window.addEventListener( }, ); +/** Build chart-ready data from raw speedtest intervals (single pass). */ +function prepareChartData(intervals: SpeedtestInterval[]): ChartData { + const xValues: number[] = []; + const values: number[] = []; + const pointLabels: string[] = []; + for (const iv of intervals) { + xValues.push(iv.end_time_seconds); + values.push(iv.throughput_mbits); + pointLabels.push( + `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, + ); + } + return { xValues, values, pointLabels }; +} + function renderPage(data: SpeedtestResult, onViewJson: () => void): void { const root = document.getElementById("root"); if (!root) { @@ -46,7 +68,6 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { cleanup?.(); root.innerHTML = ""; - // Summary const summary = document.createElement("div"); summary.className = "summary"; summary.innerHTML = ` @@ -65,7 +86,6 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { `; root.appendChild(summary); - // Chart with tooltip and resize handling const container = document.createElement("div"); container.className = "chart-container"; const canvas = document.createElement("canvas"); @@ -74,34 +94,36 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { container.append(canvas, tooltip); root.appendChild(container); - const chartData = { - labels: data.intervals.map((iv) => `${iv.end_time_seconds.toFixed(0)}s`), - values: data.intervals.map((iv) => iv.throughput_mbits), - pointLabels: data.intervals.map( - (iv) => - `${iv.throughput_mbits.toFixed(2)} Mbps (${iv.start_time_seconds.toFixed(0)}\u2013${iv.end_time_seconds.toFixed(0)}s)`, - ), - }; + const chartData = prepareChartData(data.intervals); let points: ChartPoint[] = []; + let containerWidth = container.offsetWidth; const draw = () => { points = renderLineChart(canvas, chartData); + containerWidth = container.offsetWidth; }; draw(); - const observer = new ResizeObserver(draw); + let rafId = 0; + const observer = new ResizeObserver(() => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(draw); + }); observer.observe(container); + const hasDots = chartData.values.length <= DOT_THRESHOLD; + const onMouseMove = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; - const hit = points.find( - (p) => Math.abs(p.x - mx) < 12 && Math.abs(p.y - my) < 12, - ); + const hit = findNearestPoint(points, mx, my, hasDots); + if (hit) { tooltip.textContent = hit.label; - tooltip.style.left = `${hit.x}px`; + const tw = tooltip.offsetWidth; + const left = Math.max(0, Math.min(hit.x - tw / 2, containerWidth - tw)); + tooltip.style.left = `${left}px`; tooltip.style.top = `${hit.y - 32}px`; tooltip.classList.add("visible"); } else { @@ -113,12 +135,12 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { canvas.addEventListener("mouseleave", onMouseLeave); cleanup = () => { + cancelAnimationFrame(rafId); observer.disconnect(); canvas.removeEventListener("mousemove", onMouseMove); canvas.removeEventListener("mouseleave", onMouseLeave); }; - // Actions const actions = document.createElement("div"); actions.className = "actions"; const viewBtn = document.createElement("button"); @@ -130,7 +152,56 @@ function renderPage(data: SpeedtestResult, onViewJson: () => void): void { function showError(message: string): void { const root = document.getElementById("root"); - if (root) { - root.innerHTML = `

${message}

`; + if (!root) { + return; + } + const p = document.createElement("p"); + p.className = "error"; + p.textContent = message; + root.replaceChildren(p); +} + +/** + * Find the nearest chart point using binary search. + * When hasDots is true (few points), requires proximity on both axes. + * When false (many points), snaps to the nearest x-position so the + * tooltip tracks the line like a crosshair. + */ +function findNearestPoint( + points: ChartPoint[], + mx: number, + my: number, + hasDots: boolean, +): ChartPoint | null { + if (points.length === 0) { + return null; + } + + let lo = 0; + let hi = points.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (points[mid].x < mx) { + lo = mid + 1; + } else { + hi = mid; + } + } + + let best = points[lo]; + if (lo > 0 && Math.abs(points[lo - 1].x - mx) < Math.abs(best.x - mx)) { + best = points[lo - 1]; + } + + if (hasDots) { + return Math.abs(best.x - mx) < HIT_RADIUS && + Math.abs(best.y - my) < HIT_RADIUS + ? best + : null; } + const avgGap = + points.length > 1 + ? (points[points.length - 1].x - points[0].x) / (points.length - 1) + : HIT_RADIUS; + return Math.abs(best.x - mx) < avgGap ? best : null; } diff --git a/packages/speedtest/tsconfig.json b/packages/speedtest/tsconfig.json index d7f31093..e1940bf7 100644 --- a/packages/speedtest/tsconfig.json +++ b/packages/speedtest/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../tsconfig.packages.json", "compilerOptions": { "paths": { + "@repo/shared": ["../shared/src"], "@repo/webview-shared": ["../webview-shared/src"] } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c94b5b5..17945fa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,9 @@ importers: packages/speedtest: dependencies: + '@repo/shared': + specifier: workspace:* + version: link:../shared '@repo/webview-shared': specifier: workspace:* version: link:../webview-shared diff --git a/src/commands.ts b/src/commands.ts index 64d1b1f1..6c0c3d19 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -188,7 +188,7 @@ export class Commands { value: "5", validateInput: (value) => { const n = Number(value.trim()); - if (!value.trim() || isNaN(n) || n <= 0) { + if (!value.trim() || !Number.isFinite(n) || n <= 0) { return "Please enter a positive number"; } return undefined; @@ -198,33 +198,30 @@ export class Commands { return; } const seconds = Number(input.trim()); - const totalMs = seconds * 1000; const result = await withCancellableProgress( async ({ signal, progress }) => { progress.report({ message: "Connecting..." }); const env = await this.resolveCliEnv(client); - // Report progress based on elapsed time const startTime = Date.now(); - let lastPercent = 0; + let reported = 0; const timer = setInterval(() => { const elapsed = Date.now() - startTime; - const elapsedSec = Math.floor(elapsed / 1000); - const remaining = Math.max(0, Math.ceil((totalMs - elapsed) / 1000)); - - if (remaining > 0) { - const percent = Math.min(Math.round((elapsed / totalMs) * 100), 95); - const increment = percent - lastPercent; - if (increment > 0) { - progress.report({ - message: `${elapsedSec}s / ${seconds}s`, - increment, - }); - lastPercent = percent; - } - } else { - progress.report({ message: "Collecting results..." }); + const pct = Math.min( + Math.round((elapsed / (seconds * 1000)) * 100), + 100, + ); + const increment = pct - reported; + if (increment > 0) { + progress.report({ + message: + pct >= 100 + ? "Collecting results..." + : `${Math.floor(elapsed / 1000)}s / ${seconds}s`, + increment, + }); + reported = pct; } }, 100); diff --git a/src/webviews/speedtest/speedtestPanel.ts b/src/webviews/speedtest/speedtestPanel.ts index 1db0a908..62988590 100644 --- a/src/webviews/speedtest/speedtestPanel.ts +++ b/src/webviews/speedtest/speedtestPanel.ts @@ -61,10 +61,12 @@ export function showSpeedtestChart( }); panel.webview.onDidReceiveMessage( - async (message: { method: string; params?: unknown }) => { + (message: { method: string; params?: unknown }) => { const handler = commandHandlers[message.method]; if (handler) { - await handler(message.params); + Promise.resolve(handler(message.params)).catch(() => { + // Best-effort — nothing useful to show the user here + }); } }, );