Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 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
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 thread
yazan-abu-obaideh marked this conversation as resolved.
Outdated
},
});
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 thread
yazan-abu-obaideh marked this conversation as resolved.
Outdated
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) {
if (!result.cancelled) {
Comment thread
yazan-abu-obaideh marked this conversation as resolved.
Outdated
this.logger.error("Speed test failed", result.error);
vscode.window.showErrorMessage(
`Speed test failed: ${result.error instanceof Error ? result.error.message : String(result.error)}`,
Comment thread
yazan-abu-obaideh marked this conversation as resolved.
Outdated
);
}
return;
}

const doc = await vscode.workspace.openTextDocument({
content: result.value,
language: "json",
});
vscode.window.showTextDocument(doc);
Comment thread
yazan-abu-obaideh marked this conversation as resolved.
Outdated
}

/**
* 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