Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default defineConfig(
"**/vite.config*.ts",
"**/createWebviewConfig.ts",
".vscode-test/**",
"test/fixtures/scripts/**",
]),

// Base ESLint recommended rules (for JS/TS/TSX files only)
Expand Down Expand Up @@ -62,6 +63,7 @@ export default defineConfig(
"error",
{ considerDefaultExhaustiveForUnions: true },
],
"@typescript-eslint/no-non-null-assertion": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{ varsIgnorePattern: "^_", argsIgnorePattern: "^_" },
Expand Down Expand Up @@ -136,6 +138,8 @@ export default defineConfig(
"@typescript-eslint/unbound-method": "off",
// Empty callbacks are common in test stubs
"@typescript-eslint/no-empty-function": "off",
// Test assertions often use non-null assertions for brevity
"@typescript-eslint/no-non-null-assertion": "off",
// Test mocks often have loose typing - relax unsafe rules
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
Expand Down
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,11 @@
"category": "Coder",
"icon": "$(refresh)"
},
{
"command": "coder.speedTest",
"title": "Run Speed Test",
"category": "Coder"
},
{
"command": "coder.viewLogs",
"title": "Coder: View Logs",
Expand Down Expand Up @@ -383,6 +388,10 @@
"command": "coder.createWorkspace",
"when": "coder.authenticated"
},
{
"command": "coder.speedTest",
"when": "coder.workspace.connected"
},
{
"command": "coder.navigateToWorkspace",
"when": "coder.workspace.connected"
Expand Down
6 changes: 5 additions & 1 deletion src/api/authInterceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,12 @@ export class AuthInterceptor implements vscode.Disposable {
return this.authRequiredPromise;
}

if (!this.onAuthRequired) {
return false;
}
Comment on lines +126 to +128
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already guard on this so I would throw here instead


this.logger.debug("Triggering re-authentication");
this.authRequiredPromise = this.onAuthRequired!(hostname);
this.authRequiredPromise = this.onAuthRequired(hostname);

try {
return await this.authRequiredPromise;
Expand Down
4 changes: 2 additions & 2 deletions src/api/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { spawn } from "node:child_process";
import * as vscode from "vscode";

import { type FeatureSet } from "../featureSet";
import { type CliAuth, getGlobalFlags } from "../settings/cli";
import { getGlobalShellFlags, type CliAuth } from "../settings/cli";
import { escapeCommandArg } from "../util";
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";

Expand Down Expand Up @@ -65,7 +65,7 @@ export async function startWorkspaceIfStoppedOrFailed(

return new Promise((resolve, reject) => {
const startArgs = [
...getGlobalFlags(vscode.workspace.getConfiguration(), auth),
...getGlobalShellFlags(vscode.workspace.getConfiguration(), auth),
"start",
"--yes",
createWorkspaceIdentifier(workspace),
Expand Down
77 changes: 74 additions & 3 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@ import { toError } from "./error/errorUtils";
import { featureSetForVersion } from "./featureSet";
import { type Logger } from "./logging/logger";
import { type LoginCoordinator } from "./login/loginCoordinator";
import { withProgress } from "./progress";
import { withCancellableProgress, withProgress } from "./progress";
import { maybeAskAgent, maybeAskUrl } from "./promptUtils";
import {
RECOMMENDED_SSH_SETTINGS,
applySettingOverrides,
} from "./remote/sshOverrides";
import { getGlobalFlags, resolveCliAuth } from "./settings/cli";
import {
getGlobalFlags,
getGlobalShellFlags,
resolveCliAuth,
} from "./settings/cli";
import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util";
import { vscodeProposed } from "./vscodeProposed";
import {
Expand Down Expand Up @@ -162,6 +166,73 @@ export class Commands {
this.logger.debug("Login complete to deployment:", url);
}

/**
* Run a speed test against the currently connected workspace and display the
* results in a new editor document.
*/
public async speedTest(): Promise<void> {
const workspace = this.workspace;
if (!workspace) {
vscode.window.showInformationMessage("No workspace connected.");
return;
}

const duration = await vscode.window.showInputBox({
title: "Speed Test Duration",
prompt: "Duration for the speed test (e.g., 5s, 10s, 1m)",
value: "5s",
validateInput: (v) => {
return /^\d+[smh]$/.test(v.trim())
? null
: "Enter a duration like 5s, 10s, or 1m";
Comment on lines +185 to +187
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue here is that this will always be incomplete, this basically accepts ANY Go duration string like 1m30s, maybe we just fail for invalid input?

},
});
if (duration === undefined) {
return;
}

const result = await withCancellableProgress(
async ({ signal }) => {
const baseUrl = this.requireExtensionBaseUrl();
const safeHost = toSafeHost(baseUrl);
const binary = await this.cliManager.fetchBinary(this.extensionClient);
Comment on lines +196 to +198
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the this.remoteWorkspaceClient since users can be connected to a deployment that is different than the one logged in from the sidebar!

const version = semver.parse(await cliUtils.version(binary));
const featureSet = featureSetForVersion(version);
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
const configs = vscode.workspace.getConfiguration();
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
const globalFlags = getGlobalFlags(configs, auth);
const workspaceName = createWorkspaceIdentifier(workspace);

return cliUtils.speedtest(binary, globalFlags, workspaceName, {
signal,
duration: duration.trim(),
});
},
{
location: vscode.ProgressLocation.Notification,
title: `Running ${duration.trim()} speed test...`,
cancellable: true,
},
);

if (!result.ok) {
if (!result.cancelled) {
Comment on lines +218 to +220
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: flatten this out like:

if(result.cancelled) {
    return
}
if (result.ok) {
...
} else {
...
}

this.logger.error("Speed test failed", result.error);
vscode.window.showErrorMessage(
`Speed test failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have toError from errorUtils.ts

);
}
return;
}

const doc = await vscode.workspace.openTextDocument({
content: result.value,
language: "json",
});
vscode.window.showTextDocument(doc);
Copy link
Copy Markdown
Collaborator

@EhabY EhabY Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's await this one for consistency actually

}

/**
* View the logs for the currently connected workspace.
*/
Expand Down Expand Up @@ -505,7 +576,7 @@ export class Commands {
const configDir = this.pathResolver.getGlobalConfigDir(safeHost);
const configs = vscode.workspace.getConfiguration();
const auth = resolveCliAuth(configs, featureSet, baseUrl, configDir);
const globalFlags = getGlobalFlags(configs, auth);
const globalFlags = getGlobalShellFlags(configs, auth);
terminal.sendText(
`${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`,
);
Expand Down
20 changes: 20 additions & 0 deletions src/core/cliUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,26 @@ export async function version(binPath: string): Promise<string> {
return json.version;
}

/**
* Run a speed test against the specified workspace and return the raw output.
* Throw if unable to execute the binary.
*/
export async function speedtest(
binPath: string,
globalFlags: string[],
workspaceName: string,
options: { signal?: AbortSignal; duration?: string },
Copy link
Copy Markdown
Collaborator

@EhabY EhabY Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love mixing two different kinds of options, one for args and one for the execution itself 🤔

Actually maybe inline this, it's very similar to the one in commands#openAppStatus. If we want to separate it out for testability then maybe we use a separate file for this? Something like core/cliExec.ts? (we'd move the version to it)

): Promise<string> {
const args = [...globalFlags, "speedtest", workspaceName, "--output", "json"];
if (options.duration) {
args.push("-t", options.duration);
}
const result = await promisify(execFile)(binPath, args, {
signal: options.signal,
});
return result.stdout;
}

export interface RemovalResult {
fileName: string;
error: unknown;
Expand Down
4 changes: 4 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
void myWorkspacesProvider.fetchAndRefresh();
void allWorkspacesProvider.fetchAndRefresh();
}),
vscode.commands.registerCommand(
"coder.speedTest",
commands.speedTest.bind(commands),
),
vscode.commands.registerCommand(
"coder.viewLogs",
commands.viewLogs.bind(commands),
Expand Down
8 changes: 5 additions & 3 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ import { type LoginCoordinator } from "../login/loginCoordinator";
import { OAuthSessionManager } from "../oauth/sessionManager";
import {
type CliAuth,
getGlobalFlags,
getGlobalFlagsRaw,
getGlobalShellFlags,
getSshFlags,
resolveCliAuth,
} from "../settings/cli";
Expand Down Expand Up @@ -674,7 +674,7 @@ export class Remote {
const vscodeConfig = vscode.workspace.getConfiguration();

const escapedBinaryPath = escapeCommandArg(binaryPath);
const globalConfig = getGlobalFlags(vscodeConfig, cliAuth);
const globalConfig = getGlobalShellFlags(vscodeConfig, cliAuth);
const logArgs = await this.getLogArgs(logDir);

if (useWildcardSSH) {
Expand Down Expand Up @@ -863,7 +863,9 @@ export class Remote {
const titleMap = new Map(settings.map((s) => [s.setting, s.title]));

return watchConfigurationChanges(settings, (changedSettings) => {
const changedTitles = changedSettings.map((s) => titleMap.get(s)!);
const changedTitles = changedSettings
.map((s) => titleMap.get(s))
.filter((t) => t !== undefined);

const message =
changedTitles.length === 1
Expand Down
27 changes: 23 additions & 4 deletions src/settings/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,36 @@ export function getGlobalFlagsRaw(
}

/**
* Returns global configuration flags for Coder CLI commands.
* Includes either `--global-config` or `--url` depending on the auth mode.
* Returns global configuration flags for Coder CLI commands with auth values
* escaped for shell use (e.g., `terminal.sendText`, `spawn({ shell: true })`).
*/
export function getGlobalShellFlags(
configs: Pick<WorkspaceConfiguration, "get">,
auth: CliAuth,
): string[] {
return buildGlobalFlags(configs, auth, escapeCommandArg);
}

/**
* Returns global configuration flags for Coder CLI commands with raw auth
* values suitable for `execFile` (no shell escaping).
*/
export function getGlobalFlags(
configs: Pick<WorkspaceConfiguration, "get">,
auth: CliAuth,
): string[] {
return buildGlobalFlags(configs, auth, (s) => s);
}

function buildGlobalFlags(
configs: Pick<WorkspaceConfiguration, "get">,
auth: CliAuth,
esc: (s: string) => string,
): string[] {
const authFlags =
auth.mode === "url"
? ["--url", escapeCommandArg(auth.url)]
: ["--global-config", escapeCommandArg(auth.configDir)];
? ["--url", esc(auth.url)]
: ["--global-config", esc(auth.configDir)];

const raw = getGlobalFlagsRaw(configs);
const filtered = stripManagedFlags(raw);
Expand Down
2 changes: 1 addition & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function findPort(text: string): number | null {
}

// Get the last match, which is the most recent port.
const lastMatch = allMatches.at(-1)!;
const lastMatch = allMatches[allMatches.length - 1];
// Each capture group corresponds to a different Remote SSH extension log format:
// [0] full match, [1] and [2] ms-vscode-remote.remote-ssh,
// [3] windsurf/open-remote-ssh/antigravity, [4] anysphere.remote-ssh
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/scripts/echo-args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* eslint-env node */
// Prints each argument on its own line, so tests can verify exact args.
process.argv.slice(2).forEach((arg) => console.log(arg));
Loading