Skip to content
Merged
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) {
throw new Error("No auth handler registered");
}
Comment thread
yazan-abu-obaideh marked this conversation as resolved.

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
78 changes: 75 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,74 @@ 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;
const client = this.remoteWorkspaceClient;
if (!workspace || !client) {
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",
});
if (duration === undefined) {
return;
}

const result = await withCancellableProgress(
async ({ signal }) => {
const baseUrl = client.getAxiosInstance().defaults.baseURL;
if (!baseUrl) {
throw new Error("No deployment URL for the connected workspace");
}
const safeHost = toSafeHost(baseUrl);
const binary = await this.cliManager.fetchBinary(client);
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);
Comment thread
yazan-abu-obaideh marked this conversation as resolved.
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) {
const doc = await vscode.workspace.openTextDocument({
content: result.value,
language: "json",
});
await vscode.window.showTextDocument(doc);
return;
}

if (result.cancelled) {
return;
}

this.logger.error("Speed test failed", result.error);
vscode.window.showErrorMessage(
`Speed test failed: ${toError(result.error).message}`,
);
}

/**
* View the logs for the currently connected workspace.
*/
Expand Down Expand Up @@ -505,7 +577,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