Skip to content

Commit b67feba

Browse files
committed
feat: add Coder Ping Workspace command (#749)
Add a `coder.pingWorkspace` command that runs `coder ping <workspace>` locally and streams output to a custom PTY terminal. This helps users diagnose connection problems by showing latency, P2P diagnostics, and DERP info in real-time. The command supports three invocation modes: - Command Palette: shows a workspace picker (filtered to running) - Sidebar context menu: under a new "Diagnostics" submenu - When connected: pings the current workspace automatically Other changes: - Extract `resolveCliEnv` to deduplicate CLI bootstrap boilerplate - Parameterize `pickWorkspace` with filter/title options and fix QuickPick disposal leak - Encode workspace running status in tree item contextValue using a tag-based format (e.g. `coderWorkspaceSingleAgent+running`) so status-dependent menus use regex matching
1 parent c9e552d commit b67feba

File tree

4 files changed

+264
-55
lines changed

4 files changed

+264
-55
lines changed

package.json

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,12 @@
259259
"when": "!coder.authenticated && coder.loaded"
260260
}
261261
],
262+
"submenus": [
263+
{
264+
"id": "coder.diagnostics",
265+
"label": "Diagnostics"
266+
}
267+
],
262268
"commands": [
263269
{
264270
"command": "coder.login",
@@ -363,6 +369,11 @@
363369
"command": "coder.applyRecommendedSettings",
364370
"title": "Apply Recommended SSH Settings",
365371
"category": "Coder"
372+
},
373+
{
374+
"command": "coder.pingWorkspace",
375+
"title": "Ping Workspace",
376+
"category": "Coder"
366377
}
367378
],
368379
"menus": {
@@ -391,6 +402,10 @@
391402
"command": "coder.navigateToWorkspaceSettings",
392403
"when": "coder.workspace.connected"
393404
},
405+
{
406+
"command": "coder.pingWorkspace",
407+
"when": "coder.authenticated"
408+
},
394409
{
395410
"command": "coder.workspace.update",
396411
"when": "coder.workspace.updatable"
@@ -485,18 +500,28 @@
485500
"view/item/context": [
486501
{
487502
"command": "coder.openFromSidebar",
488-
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderAgent",
503+
"when": "coder.authenticated && viewItem =~ /^coderWorkspaceSingleAgent/ || coder.authenticated && viewItem =~ /^coderAgent/",
489504
"group": "inline"
490505
},
491506
{
492507
"command": "coder.navigateToWorkspace",
493-
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderWorkspaceMultipleAgents",
508+
"when": "coder.authenticated && viewItem =~ /^coderWorkspaceSingleAgent/ || coder.authenticated && viewItem =~ /^coderWorkspaceMultipleAgents/",
494509
"group": "inline"
495510
},
496511
{
497512
"command": "coder.navigateToWorkspaceSettings",
498-
"when": "coder.authenticated && viewItem == coderWorkspaceSingleAgent || coder.authenticated && viewItem == coderWorkspaceMultipleAgents",
513+
"when": "coder.authenticated && viewItem =~ /^coderWorkspaceSingleAgent/ || coder.authenticated && viewItem =~ /^coderWorkspaceMultipleAgents/",
499514
"group": "inline"
515+
},
516+
{
517+
"submenu": "coder.diagnostics",
518+
"when": "coder.authenticated && viewItem =~ /\\+running/",
519+
"group": "navigation"
520+
}
521+
],
522+
"coder.diagnostics": [
523+
{
524+
"command": "coder.pingWorkspace"
500525
}
501526
],
502527
"statusBar/remoteIndicator": [

src/commands.ts

Lines changed: 225 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
type Workspace,
33
type WorkspaceAgent,
44
} from "coder/site/src/api/typesGenerated";
5+
import { spawn } from "node:child_process";
56
import * as fs from "node:fs/promises";
67
import * as path from "node:path";
78
import * as semver from "semver";
@@ -491,21 +492,11 @@ export class Commands {
491492
title: `Connecting to AI Agent...`,
492493
},
493494
async () => {
494-
const terminal = vscode.window.createTerminal(app.name);
495-
496-
// If workspace_name is provided, run coder ssh before the command
497-
const baseUrl = this.requireExtensionBaseUrl();
498-
const safeHost = toSafeHost(baseUrl);
499-
const binary = await this.cliManager.fetchBinary(
495+
const { binary, globalFlags } = await this.resolveCliEnv(
500496
this.extensionClient,
501497
);
502498

503-
const version = semver.parse(await cliUtils.version(binary));
504-
const featureSet = featureSetForVersion(version);
505-
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
506-
const configs = vscode.workspace.getConfiguration();
507-
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
508-
const globalFlags = getGlobalFlags(configs, auth);
499+
const terminal = vscode.window.createTerminal(app.name);
509500
terminal.sendText(
510501
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
511502
);
@@ -661,25 +652,201 @@ export class Commands {
661652
}
662653
}
663654

655+
public async pingWorkspace(item?: OpenableTreeItem): Promise<void> {
656+
let client: CoderApi;
657+
let workspaceId: string;
658+
659+
if (item) {
660+
client = this.extensionClient;
661+
workspaceId = createWorkspaceIdentifier(item.workspace);
662+
} else if (this.workspace && this.remoteWorkspaceClient) {
663+
client = this.remoteWorkspaceClient;
664+
workspaceId = createWorkspaceIdentifier(this.workspace);
665+
} else {
666+
client = this.extensionClient;
667+
const workspace = await this.pickWorkspace({
668+
title: "Ping a running workspace",
669+
initialValue: "owner:me status:running ",
670+
filter: (w) => w.latest_build.status === "running",
671+
});
672+
if (!workspace) {
673+
return;
674+
}
675+
workspaceId = createWorkspaceIdentifier(workspace);
676+
}
677+
678+
return this.spawnPing(client, workspaceId);
679+
}
680+
681+
private spawnPing(client: CoderApi, workspaceId: string): Thenable<void> {
682+
return withProgress(
683+
{
684+
location: vscode.ProgressLocation.Notification,
685+
title: `Starting ping for ${workspaceId}...`,
686+
},
687+
async () => {
688+
const { binary, globalFlags } = await this.resolveCliEnv(client);
689+
690+
const writeEmitter = new vscode.EventEmitter<string>();
691+
const closeEmitter = new vscode.EventEmitter<number | void>();
692+
693+
const args = [...globalFlags, "ping", escapeCommandArg(workspaceId)];
694+
const cmd = `${escapeCommandArg(binary)} ${args.join(" ")}`;
695+
// On Unix, spawn in a new process group so we can signal the
696+
// entire group (shell + coder binary) on Ctrl+C. On Windows,
697+
// detached opens a visible console window and negative-PID kill
698+
// is unsupported, so we fall back to proc.kill().
699+
const useProcessGroup = process.platform !== "win32";
700+
const proc = spawn(cmd, {
701+
shell: true,
702+
detached: useProcessGroup,
703+
});
704+
705+
let closed = false;
706+
let exited = false;
707+
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
708+
709+
const sendSignal = (sig: "SIGINT" | "SIGKILL") => {
710+
try {
711+
if (useProcessGroup && proc.pid) {
712+
process.kill(-proc.pid, sig);
713+
} else {
714+
proc.kill(sig);
715+
}
716+
} catch {
717+
// Process already exited.
718+
}
719+
};
720+
721+
const gracefulKill = () => {
722+
sendSignal("SIGINT");
723+
// Escalate to SIGKILL if the process doesn't exit promptly.
724+
forceKillTimer = setTimeout(() => sendSignal("SIGKILL"), 5000);
725+
};
726+
727+
const terminal = vscode.window.createTerminal({
728+
name: `Coder Ping: ${workspaceId}`,
729+
pty: {
730+
onDidWrite: writeEmitter.event,
731+
onDidClose: closeEmitter.event,
732+
open: () => {
733+
writeEmitter.fire("Press Ctrl+C (^C) to stop.\r\n\r\n");
734+
},
735+
close: () => {
736+
closed = true;
737+
clearTimeout(forceKillTimer);
738+
sendSignal("SIGKILL");
739+
writeEmitter.dispose();
740+
closeEmitter.dispose();
741+
},
742+
handleInput: (data: string) => {
743+
if (exited) {
744+
closeEmitter.fire();
745+
} else if (data === "\x03") {
746+
if (forceKillTimer) {
747+
// Second Ctrl+C: force kill immediately.
748+
clearTimeout(forceKillTimer);
749+
sendSignal("SIGKILL");
750+
} else {
751+
if (!closed) {
752+
writeEmitter.fire("\r\nStopping...\r\n");
753+
}
754+
gracefulKill();
755+
}
756+
}
757+
},
758+
},
759+
});
760+
761+
const fireLines = (data: Buffer) => {
762+
if (closed) {
763+
return;
764+
}
765+
const lines = data
766+
.toString()
767+
.split(/\r*\n/)
768+
.filter((line) => line !== "");
769+
for (const line of lines) {
770+
writeEmitter.fire(line + "\r\n");
771+
}
772+
};
773+
774+
proc.stdout?.on("data", fireLines);
775+
proc.stderr?.on("data", fireLines);
776+
proc.on("error", (err) => {
777+
exited = true;
778+
clearTimeout(forceKillTimer);
779+
if (closed) {
780+
return;
781+
}
782+
writeEmitter.fire(`\r\nFailed to start: ${err.message}\r\n`);
783+
writeEmitter.fire("Press any key to close.\r\n");
784+
});
785+
proc.on("close", (code, signal) => {
786+
exited = true;
787+
clearTimeout(forceKillTimer);
788+
if (closed) {
789+
return;
790+
}
791+
const reason = signal
792+
? `Stopped by ${signal}`
793+
: `Process exited with code ${code}`;
794+
writeEmitter.fire(`\r\n${reason}. Press any key to close.\r\n`);
795+
});
796+
797+
terminal.show(false);
798+
},
799+
);
800+
}
801+
802+
private async resolveCliEnv(
803+
client: CoderApi,
804+
): Promise<{ binary: string; globalFlags: string[] }> {
805+
const baseUrl = client.getAxiosInstance().defaults.baseURL;
806+
if (!baseUrl) {
807+
throw new Error("You are not logged in");
808+
}
809+
const safeHost = toSafeHost(baseUrl);
810+
const binary = await this.cliManager.fetchBinary(client);
811+
const version = semver.parse(await cliUtils.version(binary));
812+
const featureSet = featureSetForVersion(version);
813+
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
814+
const configs = vscode.workspace.getConfiguration();
815+
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
816+
const globalFlags = getGlobalFlags(configs, auth);
817+
return { binary, globalFlags };
818+
}
819+
664820
/**
665821
* Ask the user to select a workspace. Return undefined if canceled.
666822
*/
667-
private async pickWorkspace(): Promise<Workspace | undefined> {
823+
private async pickWorkspace(options?: {
824+
title?: string;
825+
initialValue?: string;
826+
placeholder?: string;
827+
filter?: (w: Workspace) => boolean;
828+
}): Promise<Workspace | undefined> {
668829
const quickPick = vscode.window.createQuickPick();
669-
quickPick.value = "owner:me ";
670-
quickPick.placeholder = "owner:me template:go";
671-
quickPick.title = `Connect to a workspace`;
830+
quickPick.value = options?.initialValue ?? "owner:me ";
831+
quickPick.placeholder = options?.placeholder ?? "owner:me template:go";
832+
quickPick.title = options?.title ?? "Connect to a workspace";
833+
const filter = options?.filter;
834+
672835
let lastWorkspaces: readonly Workspace[];
673-
quickPick.onDidChangeValue((value) => {
674-
quickPick.busy = true;
675-
this.extensionClient
676-
.getWorkspaces({
677-
q: value,
678-
})
679-
.then((workspaces) => {
680-
lastWorkspaces = workspaces.workspaces;
681-
const items: vscode.QuickPickItem[] = workspaces.workspaces.map(
682-
(workspace) => {
836+
const disposables: vscode.Disposable[] = [];
837+
disposables.push(
838+
quickPick.onDidChangeValue((value) => {
839+
quickPick.busy = true;
840+
this.extensionClient
841+
.getWorkspaces({
842+
q: value,
843+
})
844+
.then((workspaces) => {
845+
const filtered = filter
846+
? workspaces.workspaces.filter(filter)
847+
: workspaces.workspaces;
848+
lastWorkspaces = filtered;
849+
quickPick.items = filtered.map((workspace) => {
683850
let icon = "$(debug-start)";
684851
if (workspace.latest_build.status !== "running") {
685852
icon = "$(debug-stop)";
@@ -692,32 +859,40 @@ export class Commands {
692859
label: `${icon} ${workspace.owner_name} / ${workspace.name}`,
693860
detail: `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}`,
694861
};
695-
},
696-
);
697-
quickPick.items = items;
698-
})
699-
.catch((ex) => {
700-
this.logger.error("Failed to fetch workspaces", ex);
701-
if (ex instanceof CertificateError) {
702-
void ex.showNotification();
703-
}
704-
})
705-
.finally(() => {
706-
quickPick.busy = false;
707-
});
708-
});
862+
});
863+
})
864+
.catch((ex) => {
865+
this.logger.error("Failed to fetch workspaces", ex);
866+
if (ex instanceof CertificateError) {
867+
void ex.showNotification();
868+
}
869+
})
870+
.finally(() => {
871+
quickPick.busy = false;
872+
});
873+
}),
874+
);
875+
709876
quickPick.show();
710877
return new Promise<Workspace | undefined>((resolve) => {
711-
quickPick.onDidHide(() => {
712-
resolve(undefined);
713-
});
714-
quickPick.onDidChangeSelection((selected) => {
715-
if (selected.length < 1) {
716-
return resolve(undefined);
717-
}
718-
const workspace = lastWorkspaces[quickPick.items.indexOf(selected[0])];
719-
resolve(workspace);
720-
});
878+
disposables.push(
879+
quickPick.onDidHide(() => {
880+
resolve(undefined);
881+
}),
882+
quickPick.onDidChangeSelection((selected) => {
883+
if (selected.length < 1) {
884+
return resolve(undefined);
885+
}
886+
const workspace =
887+
lastWorkspaces[quickPick.items.indexOf(selected[0])];
888+
resolve(workspace);
889+
}),
890+
);
891+
}).finally(() => {
892+
for (const d of disposables) {
893+
d.dispose();
894+
}
895+
quickPick.dispose();
721896
});
722897
}
723898

src/extension.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
307307
"coder.applyRecommendedSettings",
308308
commands.applyRecommendedSettings.bind(commands),
309309
),
310+
vscode.commands.registerCommand(
311+
"coder.pingWorkspace",
312+
commands.pingWorkspace.bind(commands),
313+
),
310314
);
311315

312316
const remote = new Remote(serviceContainer, commands, ctx);

0 commit comments

Comments
 (0)