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..9fa4c727 --- /dev/null +++ b/packages/speedtest/package.json @@ -0,0 +1,21 @@ +{ + "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/shared": "workspace:*", + "@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..1a9b1e33 --- /dev/null +++ b/packages/speedtest/src/chart.ts @@ -0,0 +1,208 @@ +/** + * 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 { + /** 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. + */ +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 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; + + 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"; + + 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 ── + + ctx.strokeStyle = grid; + ctx.lineWidth = 1; + ctx.fillStyle = fg; + ctx.textAlign = "right"; + 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 / Y_GRID_LINES) * maxVal).toFixed(0), + pad.left - 12, + y + 5, + ); + } + + ctx.strokeStyle = fg; + ctx.beginPath(); + ctx.moveTo(pad.left, pad.top + plotH); + ctx.lineTo(pad.left + plotW, pad.top + plotH); + ctx.stroke(); + + ctx.textAlign = "center"; + ctx.fillStyle = fg; + 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); + } + + 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; + 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; + } + + ctx.beginPath(); + ctx.moveTo(firstPx, 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(); + + 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(); + + 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, DOT_RADIUS, 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..572ec86b --- /dev/null +++ b/packages/speedtest/src/index.css @@ -0,0 +1,97 @@ +*, +*::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); + font-size: var(--vscode-font-size); +} + +.summary { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px 48px; + margin-bottom: 24px; + text-align: center; +} + +.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; + height: 320px; + margin-bottom: 20px; +} + +.chart-container canvas { + width: 100%; + height: 100%; +} + +.actions { + display: flex; + justify-content: center; +} + +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; + 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..e124a4f7 --- /dev/null +++ b/packages/speedtest/src/index.ts @@ -0,0 +1,207 @@ +import { SpeedtestApi } from "@repo/shared"; +import { postMessage } from "@repo/webview-shared"; + +import { + DOT_THRESHOLD, + type ChartData, + 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[]; +} + +const HIT_RADIUS = 12; + +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."); + } + } + }, +); + +/** 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) { + return; + } + + cleanup?.(); + root.innerHTML = ""; + + 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); + + 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 = prepareChartData(data.intervals); + + let points: ChartPoint[] = []; + let containerWidth = container.offsetWidth; + const draw = () => { + points = renderLineChart(canvas, chartData); + containerWidth = container.offsetWidth; + }; + 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 = findNearestPoint(points, mx, my, hasDots); + + if (hit) { + tooltip.textContent = hit.label; + 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 { + tooltip.classList.remove("visible"); + } + }; + const onMouseLeave = () => tooltip.classList.remove("visible"); + canvas.addEventListener("mousemove", onMouseMove); + canvas.addEventListener("mouseleave", onMouseLeave); + + cleanup = () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + canvas.removeEventListener("mousemove", onMouseMove); + canvas.removeEventListener("mouseleave", onMouseLeave); + }; + + 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) { + 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 new file mode 100644 index 00000000..e1940bf7 --- /dev/null +++ b/packages/speedtest/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.packages.json", + "compilerOptions": { + "paths": { + "@repo/shared": ["../shared/src"], + "@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..17945fa6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -262,6 +262,25 @@ importers: specifier: 'catalog:' version: 6.0.2 + packages/speedtest: + dependencies: + '@repo/shared': + specifier: workspace:* + version: link:../shared + '@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..6c0c3d19 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,69 @@ 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() || !Number.isFinite(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 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); + + const startTime = Date.now(); + let reported = 0; + const timer = setInterval(() => { + const elapsed = Date.now() - startTime; + 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); + + 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..62988590 --- /dev/null +++ b/src/webviews/speedtest/speedtestPanel.ts @@ -0,0 +1,73 @@ +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( + (message: { method: string; params?: unknown }) => { + const handler = commandHandlers[message.method]; + if (handler) { + Promise.resolve(handler(message.params)).catch(() => { + // Best-effort — nothing useful to show the user here + }); + } + }, + ); +} 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"); + }); +});