Skip to content

Commit 04e4c55

Browse files
committed
refactor: address speedtest webview self-review feedback
Validate CLI output with Zod on the extension side so the webview trusts typed data and stops hand-parsing JSON. Share SpeedtestResult types via @repo/shared and flatten the message payload. Webview cleanup: newspaper-ordered index.ts with a main() entrypoint, em-scaled chart layout, named constants in place of magic numbers, empty samples handled with a message, and a subscribeNotification helper that useIpc now delegates to. Pure helpers (niceStep, formatTick, findNearest*, toChartSamples) live in chartUtils.ts for easy unit testing. Panel: extract webview panel logic into SpeedtestPanelFactory, a ServiceContainer-owned class that takes extensionUri + logger in its constructor and exposes show(payload). Surfaces webview handler errors through the logger and tracks every subscription in a disposables array. Drops the now-unused isGoDuration helper. Tests: cover SpeedtestPanelFactory end-to-end with a reusable createMockWebviewPanel harness in testHelpers, plus chartUtils and renderLineChart unit tests. Canvas 2D is stubbed globally in test/webview/setup.ts so chart tests don't need per-test plumbing. Protocol: buildApiHook and useIpc now take RequestDef, CommandDef, and NotificationDef directly in both overloads, no casts needed. Platform: move toError into shared with a serialize hook so the extension keeps util.inspect output, rename createBaseWebviewConfig to createWebviewConfig, add createReactWebviewConfig, extract reportElapsedProgress for reuse by other long running commands, and drop the createWebviewConfig eslint ignore.
1 parent 8a47d07 commit 04e4c55

34 files changed

Lines changed: 1099 additions & 524 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
- The **Coder: Workspace Build** output channel is no longer created when reconnecting to an
1313
already-running workspace, so the Output panel doesn't pop open empty.
1414

15+
### Changed
16+
17+
- **Coder: Speed Test Workspace** results now render in an interactive throughput chart with
18+
hover tooltips, a summary header, and a real-time progress bar while the CLI runs. A View JSON
19+
action exposes the raw output.
20+
1521
## [v1.14.4-pre](https://github.com/coder/vscode-coder/releases/tag/v1.14.4-pre) 2026-04-20
1622

1723
### Added

eslint.config.mjs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ export default defineConfig(
1717
"**/*.d.ts",
1818
"vitest.config.ts",
1919
"**/vite.config*.ts",
20-
"**/createWebviewConfig.ts",
2120
".vscode-test/**",
2221
"test/fixtures/scripts/**",
2322
]),

packages/shared/src/error/utils.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/** Convert any thrown value into an Error. Pass `serialize` (e.g. `util.inspect`
2+
* in Node) for richer object formatting; the default `JSON.stringify` will
3+
* throw on circular inputs and fall through to `defaultMsg`. */
4+
export function toError(
5+
value: unknown,
6+
defaultMsg?: string,
7+
serialize: (value: unknown) => string = JSON.stringify,
8+
): Error {
9+
if (value instanceof Error) {
10+
return value;
11+
}
12+
13+
if (typeof value === "string") {
14+
return new Error(value);
15+
}
16+
17+
if (
18+
value !== null &&
19+
typeof value === "object" &&
20+
"message" in value &&
21+
typeof value.message === "string"
22+
) {
23+
const error = new Error(value.message);
24+
if ("name" in value && typeof value.name === "string") {
25+
error.name = value.name;
26+
}
27+
return error;
28+
}
29+
30+
if (value === null || value === undefined) {
31+
return new Error(defaultMsg ?? "Unknown error");
32+
}
33+
34+
try {
35+
return new Error(serialize(value));
36+
} catch {
37+
return new Error(defaultMsg ?? "Non-serializable error object");
38+
}
39+
}

packages/shared/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1+
// IPC protocol types
12
export * from "./ipc/protocol";
23

4+
// Error utilities
5+
export { toError } from "./error/utils";
6+
7+
// Tasks types, utilities, and API
38
export * from "./tasks/types";
49
export * from "./tasks/utils";
510
export * from "./tasks/api";
611

7-
export { SpeedtestApi, type SpeedtestData } from "./speedtest/api";
12+
// Speedtest API
13+
export {
14+
SpeedtestApi,
15+
type SpeedtestData,
16+
type SpeedtestInterval,
17+
type SpeedtestResult,
18+
} from "./speedtest/api";

packages/shared/src/ipc/protocol.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,26 +166,34 @@ export function buildApiHook<
166166
api: Api,
167167
ipc: {
168168
request: <P, R>(
169-
def: { method: string; _types?: { params: P; response: R } },
169+
def: RequestDef<P, R>,
170170
...args: P extends void ? [] : [params: P]
171171
) => Promise<R>;
172172
command: <P>(
173-
def: { method: string; _types?: { params: P } },
173+
def: CommandDef<P>,
174174
...args: P extends void ? [] : [params: P]
175175
) => void;
176176
onNotification: <D>(
177-
def: { method: string; _types?: { data: D } },
177+
def: NotificationDef<D>,
178178
cb: (data: D) => void,
179179
) => () => void;
180180
},
181181
): ApiHook<Api>;
182182
export function buildApiHook(
183-
api: Record<string, { kind: string; method: string }>,
183+
api: Record<
184+
string,
185+
| RequestDef<unknown, unknown>
186+
| CommandDef<unknown>
187+
| NotificationDef<unknown>
188+
>,
184189
ipc: {
185-
request: (def: { method: string }, params?: unknown) => Promise<unknown>;
186-
command: (def: { method: string }, params?: unknown) => void;
190+
request: (
191+
def: RequestDef<unknown, unknown>,
192+
params?: unknown,
193+
) => Promise<unknown>;
194+
command: (def: CommandDef<unknown>, params?: unknown) => void;
187195
onNotification: (
188-
def: { method: string },
196+
def: NotificationDef<unknown>,
189197
cb: (data: unknown) => void,
190198
) => () => void;
191199
},
Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import { defineCommand, defineNotification } from "../ipc/protocol";
22

3+
export interface SpeedtestInterval {
4+
start_time_seconds: number;
5+
end_time_seconds: number;
6+
throughput_mbits: number;
7+
}
8+
9+
export interface SpeedtestResult {
10+
overall: SpeedtestInterval;
11+
intervals: SpeedtestInterval[];
12+
}
13+
314
export interface SpeedtestData {
4-
json: string;
515
workspaceName: string;
16+
result: SpeedtestResult;
617
}
718

819
export const SpeedtestApi = {
9-
/** Extension pushes results to the webview */
20+
/** Extension pushes parsed results to the webview */
1021
data: defineNotification<SpeedtestData>("speedtest/data"),
1122
/** Webview requests to open raw JSON in a text editor */
12-
viewJson: defineCommand<string>("speedtest/viewJson"),
23+
viewJson: defineCommand<void>("speedtest/viewJson"),
1324
} as const;

packages/speedtest/src/chart.ts

Lines changed: 68 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,20 @@
1-
export interface ChartPoint {
2-
x: number;
3-
y: number;
4-
label: string;
5-
}
1+
import { type ChartPoint, formatTick, niceStep } from "./chartUtils";
62

7-
const MIN_TICK_SPACING_PX = 48;
3+
const MIN_TICK_SPACING_EM = 4;
84
const Y_GRID_LINES = 5;
9-
/** 10% padding above the max value so the line doesn't hug the top edge. */
5+
/** 10% headroom above the max so the line doesn't hug the top edge. */
106
const Y_HEADROOM = 1.1;
11-
12-
/** Candidate x-axis tick step sizes in seconds (1s, 2s, 5s, ..., 30m, 1h). */
13-
const TICK_STEP_SECONDS = [
14-
1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600,
15-
];
16-
17-
export function niceStep(raw: number): number {
18-
return (
19-
TICK_STEP_SECONDS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600
20-
);
21-
}
22-
23-
export function formatTick(t: number, step: number): string {
24-
if (step >= 3600) {
25-
const h = t / 3600;
26-
return `${Number.isInteger(h) ? h : h.toFixed(1)}h`;
27-
}
28-
if (step >= 60) {
29-
const m = t / 60;
30-
return `${Number.isInteger(m) ? m : m.toFixed(1)}m`;
31-
}
32-
return `${t}s`;
33-
}
7+
const DOT_RADIUS_PX = 4;
8+
const LINE_WIDTH_PX = 2;
9+
10+
const PLOT_PAD_EM = { top: 2, right: 2, bottom: 3.5 };
11+
const Y_LABEL_GAP_EM = 1;
12+
const X_LABEL_GAP_EM = 1.5;
13+
const X_AXIS_TITLE_GAP_EM = 0.25;
14+
const Y_AXIS_TITLE_GAP_EM = 1;
15+
/** Room reserved for the rotated "Mbps" title. */
16+
const Y_AXIS_TITLE_ROOM_EM = 1.5;
17+
const LEFT_PAD_EM = Y_AXIS_TITLE_GAP_EM + Y_AXIS_TITLE_ROOM_EM + Y_LABEL_GAP_EM;
3418

3519
interface Theme {
3620
fg: string;
@@ -39,27 +23,27 @@ interface Theme {
3923
family: string;
4024
}
4125

42-
/**
43-
* Read VS Code theme colors from CSS custom properties on <html>. Canvas
44-
* pixels don't inherit CSS vars, so we re-read on each render to pick up
45-
* theme switches.
46-
*/
26+
/** Canvas pixels don't inherit CSS vars, so re-read on every render. */
4727
function readTheme(): Theme {
4828
const s = getComputedStyle(document.documentElement);
4929
const css = (prop: string) => s.getPropertyValue(prop).trim();
5030
return {
5131
fg:
32+
css("--vscode-charts-foreground") ||
5233
css("--vscode-descriptionForeground") ||
5334
css("--vscode-editor-foreground") ||
5435
"#888",
55-
// Use the button color so the accent tracks the theme; charts-* vars
56-
// are fixed hues by design.
36+
// focusBorder tracks the theme's accent; charts.blue is a fixed hue
37+
// kept as a late fallback.
5738
accent:
58-
css("--vscode-button-background") ||
39+
css("--vscode-chart-line") ||
5940
css("--vscode-focusBorder") ||
6041
css("--vscode-charts-blue") ||
6142
"#3794ff",
62-
grid: css("--vscode-editorWidget-border") || "rgba(128,128,128,0.15)",
43+
grid:
44+
css("--vscode-chart-guide") ||
45+
css("--vscode-charts-lines") ||
46+
"rgba(127, 127, 127, 0.35)",
6347
family: css("--vscode-font-family") || "sans-serif",
6448
};
6549
}
@@ -69,18 +53,21 @@ function layoutChart(
6953
samples: ChartPoint[],
7054
width: number,
7155
height: number,
56+
pxPerEm: number,
7257
family: string,
7358
) {
7459
const maxVal = samples.reduce((m, s) => Math.max(m, s.y), 1) * Y_HEADROOM;
7560
const maxX = samples.at(-1)?.x ?? 1;
76-
const xRange = maxX || 1;
7761
ctx.font = `1em ${family}`;
7862
const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width;
7963
const pad = {
80-
top: 24,
81-
right: 24,
82-
bottom: 52,
83-
left: Math.max(48, yLabelWidth + 24),
64+
top: PLOT_PAD_EM.top * pxPerEm,
65+
right: PLOT_PAD_EM.right * pxPerEm,
66+
bottom: PLOT_PAD_EM.bottom * pxPerEm,
67+
left: Math.max(
68+
PLOT_PAD_EM.right * pxPerEm,
69+
yLabelWidth + LEFT_PAD_EM * pxPerEm,
70+
),
8471
};
8572
const plotW = width - pad.left - pad.right;
8673
const plotH = height - pad.top - pad.bottom;
@@ -90,9 +77,8 @@ function layoutChart(
9077
plotH,
9178
maxVal,
9279
maxX,
93-
xRange,
9480
height,
95-
tAt: (t: number) => pad.left + (t / xRange) * plotW,
81+
tAt: (t: number) => pad.left + (t / maxX) * plotW,
9682
yAt: (v: number) => pad.top + plotH - (v / maxVal) * plotH,
9783
};
9884
}
@@ -103,8 +89,9 @@ function drawAxes(
10389
ctx: CanvasRenderingContext2D,
10490
layout: Layout,
10591
theme: Theme,
92+
pxPerEm: number,
10693
): void {
107-
const { pad, plotW, plotH, maxVal, maxX, xRange, height, tAt, yAt } = layout;
94+
const { pad, plotW, plotH, maxVal, maxX, height, tAt, yAt } = layout;
10895

10996
ctx.strokeStyle = theme.grid;
11097
ctx.lineWidth = 1;
@@ -117,7 +104,11 @@ function drawAxes(
117104
ctx.moveTo(pad.left, y);
118105
ctx.lineTo(pad.left + plotW, y);
119106
ctx.stroke();
120-
ctx.fillText(v.toFixed(0), pad.left - 12, y + 5);
107+
ctx.fillText(
108+
v.toFixed(0),
109+
pad.left - Y_LABEL_GAP_EM * pxPerEm,
110+
y + pxPerEm / 3,
111+
);
121112
}
122113

123114
ctx.strokeStyle = theme.fg;
@@ -128,16 +119,24 @@ function drawAxes(
128119

129120
ctx.textAlign = "center";
130121
const step = niceStep(
131-
xRange / Math.max(1, Math.floor(plotW / MIN_TICK_SPACING_PX)),
122+
maxX / Math.max(1, Math.floor(plotW / (MIN_TICK_SPACING_EM * pxPerEm))),
132123
);
133124
for (let t = 0; t <= maxX; t += step) {
134-
ctx.fillText(formatTick(t, step), tAt(t), height - pad.bottom + 24);
125+
ctx.fillText(
126+
formatTick(t, step),
127+
tAt(t),
128+
height - pad.bottom + X_LABEL_GAP_EM * pxPerEm,
129+
);
135130
}
136131

137132
ctx.font = `0.95em ${theme.family}`;
138-
ctx.fillText("Time", pad.left + plotW / 2, height - 4);
133+
ctx.fillText(
134+
"Time",
135+
pad.left + plotW / 2,
136+
height - X_AXIS_TITLE_GAP_EM * pxPerEm,
137+
);
139138
ctx.save();
140-
ctx.translate(14, pad.top + plotH / 2);
139+
ctx.translate(Y_AXIS_TITLE_GAP_EM * pxPerEm, pad.top + plotH / 2);
141140
ctx.rotate(-Math.PI / 2);
142141
ctx.fillText("Mbps", 0, 0);
143142
ctx.restore();
@@ -187,32 +186,31 @@ function drawSeries(
187186
ctx.lineTo(tAt(samples[i].x), yAt(samples[i].y));
188187
}
189188
ctx.strokeStyle = theme.accent;
190-
ctx.lineWidth = 2;
189+
ctx.lineWidth = LINE_WIDTH_PX;
191190
ctx.stroke();
192191

193192
return samples.map((s) => {
194193
const x = tAt(s.x);
195194
const y = yAt(s.y);
196195
if (showDots) {
197196
ctx.beginPath();
198-
ctx.arc(x, y, 4, 0, Math.PI * 2);
197+
ctx.arc(x, y, DOT_RADIUS_PX, 0, Math.PI * 2);
199198
ctx.fillStyle = theme.accent;
200199
ctx.fill();
201200
}
202201
return { x, y, label: s.label };
203202
});
204203
}
205204

205+
/** Render the speedtest chart. Caller must ensure `samples` is non-empty. */
206206
export function renderLineChart(
207207
canvas: HTMLCanvasElement,
208208
samples: ChartPoint[],
209209
showDots: boolean,
210210
): ChartPoint[] {
211-
// Scale the backing store by DPR for crisp rendering on high-DPI
212-
// displays. ctx.scale lets draw calls keep using CSS pixels.
213-
const { width, height } = (
214-
canvas.parentElement ?? canvas
215-
).getBoundingClientRect();
211+
// Scale backing store by DPR so drawing stays crisp on high-DPI screens.
212+
const parent = canvas.parentElement ?? canvas;
213+
const { width, height } = parent.getBoundingClientRect();
216214
const dpr = window.devicePixelRatio || 1;
217215
canvas.width = width * dpr;
218216
canvas.height = height * dpr;
@@ -222,10 +220,16 @@ export function renderLineChart(
222220
}
223221
ctx.scale(dpr, dpr);
224222

223+
const pxPerEm = parseFloat(getComputedStyle(parent).fontSize) || 14;
225224
const theme = readTheme();
226-
const layout = layoutChart(ctx, samples, width, height, theme.family);
227-
drawAxes(ctx, layout, theme);
228-
return samples.length > 0
229-
? drawSeries(ctx, samples, layout, theme, showDots)
230-
: [];
225+
const layout = layoutChart(
226+
ctx,
227+
samples,
228+
width,
229+
height,
230+
pxPerEm,
231+
theme.family,
232+
);
233+
drawAxes(ctx, layout, theme, pxPerEm);
234+
return drawSeries(ctx, samples, layout, theme, showDots);
231235
}

0 commit comments

Comments
 (0)