Skip to content

Commit c2932ba

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 c2932ba

File tree

4 files changed

+243
-55
lines changed

4 files changed

+243
-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: 204 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,180 @@ 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+
708+
const killProc = () => {
709+
try {
710+
if (useProcessGroup && proc.pid) {
711+
process.kill(-proc.pid, "SIGINT");
712+
} else {
713+
proc.kill();
714+
}
715+
} catch {
716+
// Process already exited.
717+
}
718+
};
719+
720+
const terminal = vscode.window.createTerminal({
721+
name: `Coder Ping: ${workspaceId}`,
722+
pty: {
723+
onDidWrite: writeEmitter.event,
724+
onDidClose: closeEmitter.event,
725+
open: () => undefined,
726+
close: () => {
727+
closed = true;
728+
killProc();
729+
writeEmitter.dispose();
730+
closeEmitter.dispose();
731+
},
732+
handleInput: (data: string) => {
733+
if (exited) {
734+
closeEmitter.fire();
735+
} else if (data === "\x03") {
736+
killProc();
737+
}
738+
},
739+
},
740+
});
741+
742+
const fireLines = (data: Buffer) => {
743+
if (closed) {
744+
return;
745+
}
746+
const lines = data
747+
.toString()
748+
.split(/\r*\n/)
749+
.filter((line) => line !== "");
750+
for (const line of lines) {
751+
writeEmitter.fire(line + "\r\n");
752+
}
753+
};
754+
755+
proc.stdout?.on("data", fireLines);
756+
proc.stderr?.on("data", fireLines);
757+
proc.on("error", (err) => {
758+
exited = true;
759+
if (closed) {
760+
return;
761+
}
762+
writeEmitter.fire(`\r\nFailed to start: ${err.message}\r\n`);
763+
writeEmitter.fire("Press any key to close.\r\n");
764+
});
765+
proc.on("close", (code) => {
766+
exited = true;
767+
if (closed) {
768+
return;
769+
}
770+
writeEmitter.fire(
771+
`\r\nProcess exited with code ${code}. Press any key to close.\r\n`,
772+
);
773+
});
774+
775+
writeEmitter.fire("Press Ctrl+C (^C) to stop.\r\n\r\n");
776+
terminal.show(false);
777+
},
778+
);
779+
}
780+
781+
private async resolveCliEnv(
782+
client: CoderApi,
783+
): Promise<{ binary: string; globalFlags: string[] }> {
784+
const baseUrl = client.getAxiosInstance().defaults.baseURL;
785+
if (!baseUrl) {
786+
throw new Error("You are not logged in");
787+
}
788+
const safeHost = toSafeHost(baseUrl);
789+
const binary = await this.cliManager.fetchBinary(client);
790+
const version = semver.parse(await cliUtils.version(binary));
791+
const featureSet = featureSetForVersion(version);
792+
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
793+
const configs = vscode.workspace.getConfiguration();
794+
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
795+
const globalFlags = getGlobalFlags(configs, auth);
796+
return { binary, globalFlags };
797+
}
798+
664799
/**
665800
* Ask the user to select a workspace. Return undefined if canceled.
666801
*/
667-
private async pickWorkspace(): Promise<Workspace | undefined> {
802+
private async pickWorkspace(options?: {
803+
title?: string;
804+
initialValue?: string;
805+
placeholder?: string;
806+
filter?: (w: Workspace) => boolean;
807+
}): Promise<Workspace | undefined> {
668808
const quickPick = vscode.window.createQuickPick();
669-
quickPick.value = "owner:me ";
670-
quickPick.placeholder = "owner:me template:go";
671-
quickPick.title = `Connect to a workspace`;
809+
quickPick.value = options?.initialValue ?? "owner:me ";
810+
quickPick.placeholder = options?.placeholder ?? "owner:me template:go";
811+
quickPick.title = options?.title ?? "Connect to a workspace";
812+
const filter = options?.filter;
813+
672814
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) => {
815+
const disposables: vscode.Disposable[] = [];
816+
disposables.push(
817+
quickPick.onDidChangeValue((value) => {
818+
quickPick.busy = true;
819+
this.extensionClient
820+
.getWorkspaces({
821+
q: value,
822+
})
823+
.then((workspaces) => {
824+
const filtered = filter
825+
? workspaces.workspaces.filter(filter)
826+
: workspaces.workspaces;
827+
lastWorkspaces = filtered;
828+
quickPick.items = filtered.map((workspace) => {
683829
let icon = "$(debug-start)";
684830
if (workspace.latest_build.status !== "running") {
685831
icon = "$(debug-stop)";
@@ -692,32 +838,40 @@ export class Commands {
692838
label: `${icon} ${workspace.owner_name} / ${workspace.name}`,
693839
detail: `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}`,
694840
};
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-
});
841+
});
842+
})
843+
.catch((ex) => {
844+
this.logger.error("Failed to fetch workspaces", ex);
845+
if (ex instanceof CertificateError) {
846+
void ex.showNotification();
847+
}
848+
})
849+
.finally(() => {
850+
quickPick.busy = false;
851+
});
852+
}),
853+
);
854+
709855
quickPick.show();
710856
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-
});
857+
disposables.push(
858+
quickPick.onDidHide(() => {
859+
resolve(undefined);
860+
}),
861+
quickPick.onDidChangeSelection((selected) => {
862+
if (selected.length < 1) {
863+
return resolve(undefined);
864+
}
865+
const workspace =
866+
lastWorkspaces[quickPick.items.indexOf(selected[0])];
867+
resolve(workspace);
868+
}),
869+
);
870+
}).finally(() => {
871+
for (const d of disposables) {
872+
d.dispose();
873+
}
874+
quickPick.dispose();
721875
});
722876
}
723877

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);

src/workspace/workspacesProvider.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,13 +396,18 @@ export class OpenableTreeItem extends vscode.TreeItem {
396396

397397
public readonly workspace: Workspace,
398398

399-
contextValue: CoderOpenableTreeItemType,
399+
baseContextValue: CoderOpenableTreeItemType,
400400
) {
401401
super(label, collapsibleState);
402402
this.id = id;
403-
this.contextValue = contextValue;
404403
this.tooltip = tooltip;
405404
this.description = description;
405+
406+
const tags: string[] = [baseContextValue];
407+
if (workspace.latest_build.status === "running") {
408+
tags.push("running");
409+
}
410+
this.contextValue = tags.join("+");
406411
}
407412

408413
override iconPath = {

0 commit comments

Comments
 (0)