Skip to content

Commit 4e7be71

Browse files
committed
refactor: address speedtest webview 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. 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 1d37339 commit 4e7be71

File tree

21 files changed

+419
-299
lines changed

21 files changed

+419
-299
lines changed

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
]),
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/toError";
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: 60 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,33 @@ export interface ChartPoint {
44
label: string;
55
}
66

7-
const MIN_TICK_SPACING_PX = 48;
7+
const MIN_TICK_SPACING_EM = 4;
88
const Y_GRID_LINES = 5;
9-
/** 10% padding above the max value so the line doesn't hug the top edge. */
9+
/** 10% headroom above the max value so the line doesn't hug the top edge. */
1010
const Y_HEADROOM = 1.1;
11+
const DOT_RADIUS_PX = 4;
12+
const LINE_WIDTH_PX = 2;
13+
14+
/** Plot padding in ems, so it scales with font size. */
15+
const PLOT_PAD_EM = { top: 2, right: 2, bottom: 3.5 };
16+
const Y_LABEL_GAP_EM = 1;
17+
const X_LABEL_GAP_EM = 1.5;
18+
const X_AXIS_TITLE_GAP_EM = 0.25;
19+
const Y_AXIS_TITLE_GAP_EM = 1;
1120

1221
/** Candidate x-axis tick step sizes in seconds (1s, 2s, 5s, ..., 30m, 1h). */
1322
const TICK_STEP_SECONDS = [
1423
1, 2, 5, 10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600,
1524
];
1625

26+
// Exported for tests.
1727
export function niceStep(raw: number): number {
1828
return (
1929
TICK_STEP_SECONDS.find((s) => s >= raw) ?? Math.ceil(raw / 3600) * 3600
2030
);
2131
}
2232

33+
// Exported for tests.
2334
export function formatTick(t: number, step: number): string {
2435
if (step >= 3600) {
2536
const h = t / 3600;
@@ -39,11 +50,8 @@ interface Theme {
3950
family: string;
4051
}
4152

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-
*/
53+
/** Read VS Code theme CSS vars. Canvas pixels don't inherit CSS vars, so
54+
* re-read on each render to track theme switches. */
4755
function readTheme(): Theme {
4856
const s = getComputedStyle(document.documentElement);
4957
const css = (prop: string) => s.getPropertyValue(prop).trim();
@@ -69,18 +77,21 @@ function layoutChart(
6977
samples: ChartPoint[],
7078
width: number,
7179
height: number,
80+
pxPerEm: number,
7281
family: string,
7382
) {
7483
const maxVal = samples.reduce((m, s) => Math.max(m, s.y), 1) * Y_HEADROOM;
7584
const maxX = samples.at(-1)?.x ?? 1;
76-
const xRange = maxX || 1;
7785
ctx.font = `1em ${family}`;
7886
const yLabelWidth = ctx.measureText(maxVal.toFixed(0)).width;
7987
const pad = {
80-
top: 24,
81-
right: 24,
82-
bottom: 52,
83-
left: Math.max(48, yLabelWidth + 24),
88+
top: PLOT_PAD_EM.top * pxPerEm,
89+
right: PLOT_PAD_EM.right * pxPerEm,
90+
bottom: PLOT_PAD_EM.bottom * pxPerEm,
91+
left: Math.max(
92+
PLOT_PAD_EM.right * pxPerEm,
93+
yLabelWidth + Y_LABEL_GAP_EM * pxPerEm,
94+
),
8495
};
8596
const plotW = width - pad.left - pad.right;
8697
const plotH = height - pad.top - pad.bottom;
@@ -90,9 +101,8 @@ function layoutChart(
90101
plotH,
91102
maxVal,
92103
maxX,
93-
xRange,
94104
height,
95-
tAt: (t: number) => pad.left + (t / xRange) * plotW,
105+
tAt: (t: number) => pad.left + (t / maxX) * plotW,
96106
yAt: (v: number) => pad.top + plotH - (v / maxVal) * plotH,
97107
};
98108
}
@@ -103,8 +113,9 @@ function drawAxes(
103113
ctx: CanvasRenderingContext2D,
104114
layout: Layout,
105115
theme: Theme,
116+
pxPerEm: number,
106117
): void {
107-
const { pad, plotW, plotH, maxVal, maxX, xRange, height, tAt, yAt } = layout;
118+
const { pad, plotW, plotH, maxVal, maxX, height, tAt, yAt } = layout;
108119

109120
ctx.strokeStyle = theme.grid;
110121
ctx.lineWidth = 1;
@@ -117,7 +128,11 @@ function drawAxes(
117128
ctx.moveTo(pad.left, y);
118129
ctx.lineTo(pad.left + plotW, y);
119130
ctx.stroke();
120-
ctx.fillText(v.toFixed(0), pad.left - 12, y + 5);
131+
ctx.fillText(
132+
v.toFixed(0),
133+
pad.left - Y_LABEL_GAP_EM * pxPerEm,
134+
y + pxPerEm / 3,
135+
);
121136
}
122137

123138
ctx.strokeStyle = theme.fg;
@@ -128,16 +143,24 @@ function drawAxes(
128143

129144
ctx.textAlign = "center";
130145
const step = niceStep(
131-
xRange / Math.max(1, Math.floor(plotW / MIN_TICK_SPACING_PX)),
146+
maxX / Math.max(1, Math.floor(plotW / (MIN_TICK_SPACING_EM * pxPerEm))),
132147
);
133148
for (let t = 0; t <= maxX; t += step) {
134-
ctx.fillText(formatTick(t, step), tAt(t), height - pad.bottom + 24);
149+
ctx.fillText(
150+
formatTick(t, step),
151+
tAt(t),
152+
height - pad.bottom + X_LABEL_GAP_EM * pxPerEm,
153+
);
135154
}
136155

137156
ctx.font = `0.95em ${theme.family}`;
138-
ctx.fillText("Time", pad.left + plotW / 2, height - 4);
157+
ctx.fillText(
158+
"Time",
159+
pad.left + plotW / 2,
160+
height - X_AXIS_TITLE_GAP_EM * pxPerEm,
161+
);
139162
ctx.save();
140-
ctx.translate(14, pad.top + plotH / 2);
163+
ctx.translate(Y_AXIS_TITLE_GAP_EM * pxPerEm, pad.top + plotH / 2);
141164
ctx.rotate(-Math.PI / 2);
142165
ctx.fillText("Mbps", 0, 0);
143166
ctx.restore();
@@ -187,32 +210,31 @@ function drawSeries(
187210
ctx.lineTo(tAt(samples[i].x), yAt(samples[i].y));
188211
}
189212
ctx.strokeStyle = theme.accent;
190-
ctx.lineWidth = 2;
213+
ctx.lineWidth = LINE_WIDTH_PX;
191214
ctx.stroke();
192215

193216
return samples.map((s) => {
194217
const x = tAt(s.x);
195218
const y = yAt(s.y);
196219
if (showDots) {
197220
ctx.beginPath();
198-
ctx.arc(x, y, 4, 0, Math.PI * 2);
221+
ctx.arc(x, y, DOT_RADIUS_PX, 0, Math.PI * 2);
199222
ctx.fillStyle = theme.accent;
200223
ctx.fill();
201224
}
202225
return { x, y, label: s.label };
203226
});
204227
}
205228

229+
/** Render the speedtest chart. Caller must ensure `samples` is non-empty. */
206230
export function renderLineChart(
207231
canvas: HTMLCanvasElement,
208232
samples: ChartPoint[],
209233
showDots: boolean,
210234
): 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();
235+
// Scale backing store by DPR so drawing stays crisp on high-DPI screens.
236+
const parent = canvas.parentElement ?? canvas;
237+
const { width, height } = parent.getBoundingClientRect();
216238
const dpr = window.devicePixelRatio || 1;
217239
canvas.width = width * dpr;
218240
canvas.height = height * dpr;
@@ -222,10 +244,16 @@ export function renderLineChart(
222244
}
223245
ctx.scale(dpr, dpr);
224246

247+
const pxPerEm = parseFloat(getComputedStyle(parent).fontSize) || 14;
225248
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-
: [];
249+
const layout = layoutChart(
250+
ctx,
251+
samples,
252+
width,
253+
height,
254+
pxPerEm,
255+
theme.family,
256+
);
257+
drawAxes(ctx, layout, theme, pxPerEm);
258+
return drawSeries(ctx, samples, layout, theme, showDots);
231259
}

0 commit comments

Comments
 (0)