diff --git a/src/commands.ts b/src/commands.ts index 19daaa57..05caf4de 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -21,6 +21,7 @@ import { type Logger } from "./logging/logger"; import { type LoginCoordinator } from "./login/loginCoordinator"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; +import { vscodeProposed } from "./vscodeProposed"; import { AgentTreeItem, type OpenableTreeItem, @@ -28,7 +29,6 @@ import { } from "./workspace/workspacesProvider"; export class Commands { - private readonly vscodeProposed: typeof vscode; private readonly logger: Logger; private readonly pathResolver: PathResolver; private readonly mementoManager: MementoManager; @@ -53,7 +53,6 @@ export class Commands { private readonly extensionClient: CoderApi, private readonly deploymentManager: DeploymentManager, ) { - this.vscodeProposed = serviceContainer.getVsCodeProposed(); this.logger = serviceContainer.getLogger(); this.pathResolver = serviceContainer.getPathResolver(); this.mementoManager = serviceContainer.getMementoManager(); @@ -492,7 +491,7 @@ export class Commands { if (!this.workspace || !this.remoteWorkspaceClient) { return; } - const action = await this.vscodeProposed.window.showWarningMessage( + const action = await vscodeProposed.window.showWarningMessage( "Update Workspace", { useCustom: true, diff --git a/src/core/binaryLock.ts b/src/core/binaryLock.ts index ab934c72..ae6e2c3c 100644 --- a/src/core/binaryLock.ts +++ b/src/core/binaryLock.ts @@ -3,6 +3,7 @@ import * as lockfile from "proper-lockfile"; import * as vscode from "vscode"; import { type Logger } from "../logging/logger"; +import { vscodeProposed } from "../vscodeProposed"; import * as downloadProgress from "./downloadProgress"; @@ -21,10 +22,7 @@ type LockRelease = () => Promise; * VS Code windows downloading the same binary. */ export class BinaryLock { - constructor( - private readonly vscodeProposed: typeof vscode, - private readonly output: Logger, - ) {} + constructor(private readonly output: Logger) {} /** * Acquire the lock, or wait for another process if the lock is held. @@ -78,7 +76,7 @@ export class BinaryLock { binPath: string, progressLogPath: string, ): Promise { - return await this.vscodeProposed.window.withProgress( + return await vscodeProposed.window.withProgress( { location: vscode.ProgressLocation.Notification, title: "Another window is downloading the Coder CLI binary", diff --git a/src/core/cliManager.ts b/src/core/cliManager.ts index 8676e066..0c5572e6 100644 --- a/src/core/cliManager.ts +++ b/src/core/cliManager.ts @@ -14,6 +14,7 @@ import * as vscode from "vscode"; import { errToStr } from "../api/api-helper"; import { type Logger } from "../logging/logger"; import * as pgp from "../pgp"; +import { vscodeProposed } from "../vscodeProposed"; import { BinaryLock } from "./binaryLock"; import * as cliUtils from "./cliUtils"; @@ -24,11 +25,10 @@ export class CliManager { private readonly binaryLock: BinaryLock; constructor( - private readonly vscodeProposed: typeof vscode, private readonly output: Logger, private readonly pathResolver: PathResolver, ) { - this.binaryLock = new BinaryLock(vscodeProposed, output); + this.binaryLock = new BinaryLock(output); } /** @@ -200,7 +200,7 @@ export class CliManager { version: string, reason: string, ): Promise { - const choice = await this.vscodeProposed.window.showErrorMessage( + const choice = await vscodeProposed.window.showErrorMessage( `${reason}. Run version ${version} anyway?`, "Run", ); @@ -621,7 +621,7 @@ export class CliManager { options.push("Download signature"); } options.push("Run without verification"); - const action = await this.vscodeProposed.window.showWarningMessage( + const action = await vscodeProposed.window.showWarningMessage( status === 404 ? "Signature not found" : "Failed to download signature", { useCustom: true, @@ -675,7 +675,7 @@ export class CliManager { this.output, ); } catch (error) { - const action = await this.vscodeProposed.window.showWarningMessage( + const action = await vscodeProposed.window.showWarningMessage( // VerificationError should be the only thing that throws, but // unfortunately caught errors are always type unknown. error instanceof pgp.VerificationError diff --git a/src/core/container.ts b/src/core/container.ts index 6411ef46..7ea0b76e 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -22,10 +22,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly contextManager: ContextManager; private readonly loginCoordinator: LoginCoordinator; - constructor( - context: vscode.ExtensionContext, - private readonly vscodeProposed: typeof vscode = vscode, - ) { + constructor(context: vscode.ExtensionContext) { this.logger = vscode.window.createOutputChannel("Coder", { log: true }); this.pathResolver = new PathResolver( context.globalStorageUri.fsPath, @@ -37,25 +34,16 @@ export class ServiceContainer implements vscode.Disposable { context.globalState, this.logger, ); - this.cliManager = new CliManager( - this.vscodeProposed, - this.logger, - this.pathResolver, - ); + this.cliManager = new CliManager(this.logger, this.pathResolver); this.contextManager = new ContextManager(context); this.loginCoordinator = new LoginCoordinator( this.secretsManager, this.mementoManager, - this.vscodeProposed, this.logger, context.extension.id, ); } - getVsCodeProposed(): typeof vscode { - return this.vscodeProposed; - } - getPathResolver(): PathResolver { return this.pathResolver; } diff --git a/src/error/certificateError.ts b/src/error/certificateError.ts index 57f38e14..a8f78f5c 100644 --- a/src/error/certificateError.ts +++ b/src/error/certificateError.ts @@ -1,4 +1,4 @@ -import * as vscode from "vscode"; +import { vscodeProposed } from "../vscodeProposed"; /** * Base class for certificate-related errors that can display notifications to users. @@ -23,7 +23,7 @@ export abstract class CertificateError extends Error { const message = !modal && title ? `${title}: ${this.detail}` : title || this.detail; - return await vscode.window.showErrorMessage( + return await vscodeProposed.window.showErrorMessage( message, { modal, useCustom: modal, detail: this.detail }, ...(items ?? []), diff --git a/src/extension.ts b/src/extension.ts index e873eed7..a33bc3f1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,6 +19,7 @@ import { OAuthSessionManager } from "./oauth/sessionManager"; import { Remote } from "./remote/remote"; import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; +import { initVscodeProposed } from "./vscodeProposed"; import { WorkspaceProvider, WorkspaceQuery, @@ -54,7 +55,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } - const serviceContainer = new ServiceContainer(ctx, vscodeProposed); + // Initialize the global vscodeProposed module for use throughout the extension + initVscodeProposed(vscodeProposed); + + const serviceContainer = new ServiceContainer(ctx); ctx.subscriptions.push(serviceContainer); const output = serviceContainer.getLogger(); @@ -185,12 +189,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const commands = new Commands(serviceContainer, client, deploymentManager); ctx.subscriptions.push( - registerUriHandler( - serviceContainer, - deploymentManager, - commands, - vscodeProposed, - ), + registerUriHandler(serviceContainer, deploymentManager, commands), vscode.commands.registerCommand( "coder.login", commands.login.bind(commands), diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 1abb342c..06e63087 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -8,6 +8,7 @@ import { CertificateError } from "../error/certificateError"; import { OAuthAuthorizer } from "../oauth/authorizer"; import { buildOAuthTokenData } from "../oauth/utils"; import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils"; +import { vscodeProposed } from "../vscodeProposed"; import type { User } from "coder/site/src/api/typesGenerated"; @@ -37,7 +38,6 @@ export class LoginCoordinator implements vscode.Disposable { constructor( private readonly secretsManager: SecretsManager, private readonly mementoManager: MementoManager, - private readonly vscodeProposed: typeof vscode, private readonly logger: Logger, extensionId: string, ) { @@ -78,7 +78,7 @@ export class LoginCoordinator implements vscode.Disposable { const { safeHostname, url, detailPrefix, message } = options; return this.executeWithGuard(async () => { // Show dialog promise - const dialogPromise = this.vscodeProposed.window + const dialogPromise = vscodeProposed.window .showErrorMessage( message || "Authentication Required", { @@ -291,9 +291,11 @@ export class LoginCoordinator implements vscode.Disposable { if (isAutoLogin) { this.logger.warn("Failed to log in to Coder server:", message); } else if (err instanceof CertificateError) { - void err.showNotification("Failed to log in to Coder server"); + void err.showNotification("Failed to log in to Coder server", { + modal: true, + }); } else { - void this.vscodeProposed.window.showErrorMessage( + void vscodeProposed.window.showErrorMessage( "Failed to log in to Coder server", { detail: message, diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 8dab3c3c..3c2e406e 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -43,6 +43,7 @@ import { expandPath, parseRemoteAuthority, } from "../util"; +import { vscodeProposed } from "../vscodeProposed"; import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; import { @@ -62,8 +63,6 @@ export interface RemoteDetails extends vscode.Disposable { } export class Remote { - // We use the proposed API to get access to useCustom in dialogs. - private readonly vscodeProposed: typeof vscode; private readonly logger: Logger; private readonly pathResolver: PathResolver; private readonly cliManager: CliManager; @@ -76,7 +75,6 @@ export class Remote { private readonly commands: Commands, private readonly extensionContext: vscode.ExtensionContext, ) { - this.vscodeProposed = serviceContainer.getVsCodeProposed(); this.logger = serviceContainer.getLogger(); this.pathResolver = serviceContainer.getPathResolver(); this.cliManager = serviceContainer.getCliManager(); @@ -257,7 +255,7 @@ export class Remote { // Server versions before v0.14.1 don't support the vscodessh command! if (!featureSet.vscodessh) { - await this.vscodeProposed.window.showErrorMessage( + await vscodeProposed.window.showErrorMessage( "Incompatible Server", { detail: @@ -293,16 +291,15 @@ export class Remote { } switch (error.response?.status) { case 404: { - const result = - await this.vscodeProposed.window.showInformationMessage( - `That workspace doesn't exist!`, - { - modal: true, - detail: `${workspaceName} cannot be found on ${baseUrlRaw}. Maybe it was deleted...`, - useCustom: true, - }, - "Open Workspace", - ); + const result = await vscodeProposed.window.showInformationMessage( + `That workspace doesn't exist!`, + { + modal: true, + detail: `${workspaceName} cannot be found on ${baseUrlRaw}. Maybe it was deleted...`, + useCustom: true, + }, + "Open Workspace", + ); disposables.forEach((d) => { d.dispose(); }); @@ -334,7 +331,6 @@ export class Remote { workspace, workspaceClient, this.logger, - this.vscodeProposed, this.contextManager, ); disposables.push( @@ -351,12 +347,11 @@ export class Remote { featureSet, this.logger, this.pathResolver, - this.vscodeProposed, ); disposables.push(stateMachine); try { - workspace = await this.vscodeProposed.window.withProgress( + workspace = await vscodeProposed.window.withProgress( { location: vscode.ProgressLocation.Notification, cancellable: false, @@ -431,10 +426,10 @@ export class Remote { // Do some janky setting manipulation. this.logger.info("Modifying settings..."); - const remotePlatforms = this.vscodeProposed.workspace + const remotePlatforms = vscodeProposed.workspace .getConfiguration() .get>("remote.SSH.remotePlatform", {}); - const connTimeout = this.vscodeProposed.workspace + const connTimeout = vscodeProposed.workspace .getConfiguration() .get("remote.SSH.connectTimeout"); @@ -864,7 +859,7 @@ export class Remote { continue; } - const result = await this.vscodeProposed.window.showErrorMessage( + const result = await vscodeProposed.window.showErrorMessage( "Unexpected SSH Config Option", { useCustom: true, @@ -986,7 +981,7 @@ export class Remote { } // VS Code caches resource label formatters in it's global storage SQLite database // under the key "memento/cachedResourceLabelFormatters2". - return this.vscodeProposed.workspace.registerResourceLabelFormatter({ + return vscodeProposed.workspace.registerResourceLabelFormatter({ scheme: "vscode-remote", // authority is optional but VS Code prefers formatters that most // accurately match the requested authority, so we include it. diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index b34aa7b4..09b57a12 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -6,6 +6,7 @@ import { } from "../api/workspace"; import { maybeAskAgent } from "../promptUtils"; import { type AuthorityParts } from "../util"; +import { vscodeProposed } from "../vscodeProposed"; import { TerminalSession } from "./terminalSession"; @@ -44,7 +45,6 @@ export class WorkspaceStateMachine implements vscode.Disposable { private readonly featureSet: FeatureSet, private readonly logger: Logger, private readonly pathResolver: PathResolver, - private readonly vscodeProposed: typeof vscode, ) { this.terminal = new TerminalSession("Workspace Build"); } @@ -231,7 +231,7 @@ export class WorkspaceStateMachine implements vscode.Disposable { } private async confirmStart(workspaceName: string): Promise { - const action = await this.vscodeProposed.window.showInformationMessage( + const action = await vscodeProposed.window.showInformationMessage( `Unable to connect to the workspace ${workspaceName} because it is not running. Start the workspace?`, { useCustom: true, diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 21344026..d3017607 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -7,6 +7,7 @@ import { type DeploymentManager } from "../deployment/deploymentManager"; import { CALLBACK_PATH } from "../oauth/utils"; import { maybeAskUrl } from "../promptUtils"; import { toSafeHost } from "../util"; +import { vscodeProposed } from "../vscodeProposed"; interface UriRouteContext { params: URLSearchParams; @@ -30,7 +31,6 @@ export function registerUriHandler( serviceContainer: ServiceContainer, deploymentManager: DeploymentManager, commands: Commands, - vscodeProposed: typeof vscode, ): vscode.Disposable { const output = serviceContainer.getLogger(); diff --git a/src/vscodeProposed.ts b/src/vscodeProposed.ts new file mode 100644 index 00000000..a0e7f69d --- /dev/null +++ b/src/vscodeProposed.ts @@ -0,0 +1,63 @@ +/** + * This module provides access to VS Code's proposed APIs. + * + * ## Why do we need proposed APIs? + * + * We use proposed APIs for features not yet in the stable VS Code API: + * + * 1. **`useCustom` in MessageOptions** - When `useCustom: true`, VS Code uses its + * custom dialog renderer instead of the native OS dialog, regardless of the user's + * `window.dialogStyle` setting. This ensures consistent dialog appearance and + * behavior across all platforms. + * + * 2. **`registerResourceLabelFormatter`** - Allows customizing how remote URIs are + * displayed in the VS Code UI (e.g., showing workspace names instead of raw URIs). + * + * ## How it works + * + * The Remote SSH extension has access to these proposed APIs (via `enabledApiProposals` + * in its package.json). When we detect the Remote SSH extension, we use + * `createRequire()` from its extension path to get a vscode module with the + * proposed APIs enabled. + * + * **Important:** During remote connection resolution, we've observed that UI APIs + * (like `window.showErrorMessage`) may only work reliably when called through the + * vscode module obtained from the Remote SSH extension's context, rather than our + * own extension's `import * as vscode from "vscode"`. This is likely because the + * Remote SSH extension activates first (handling `onResolveRemoteAuthority`) and + * its vscode module binding is fully established before our resolver code runs. + * + * @see {@link file://./typings/vscode.proposed.resolvers.d.ts} for the TypeScript + * declarations of these proposed APIs. + * + * The proxy falls back to regular `vscode` if the proposed API hasn't been + * initialized yet, so it's safe to use during early startup or in tests. + */ + +import * as vscode from "vscode"; + +let _vscodeProposed: typeof vscode | undefined; + +/** + * Initialize the proposed vscode API. Called once during extension activation + * after obtaining the proposed API from the Remote SSH extension. + * + * @throws Error if called more than once + */ +export function initVscodeProposed(proposed: typeof vscode): void { + if (_vscodeProposed !== undefined) { + throw new Error("vscodeProposed has already been initialized"); + } + _vscodeProposed = proposed; +} + +/** + * A proxy that provides access to the proposed VS Code API. + * Before initialization, falls back to regular vscode. + * After initVscodeProposed() is called, uses the proposed API. + */ +export const vscodeProposed: typeof vscode = new Proxy({} as typeof vscode, { + get(_target, prop: keyof typeof vscode) { + return (_vscodeProposed ?? vscode)[prop]; + }, +}); diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 8b510f36..2c3d3cd9 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -9,6 +9,7 @@ import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; import { type CoderApi } from "../api/coderApi"; import { type ContextManager } from "../core/contextManager"; import { type Logger } from "../logging/logger"; +import { vscodeProposed } from "../vscodeProposed"; import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; /** @@ -41,8 +42,6 @@ export class WorkspaceMonitor implements vscode.Disposable { workspace: Workspace, private readonly client: CoderApi, private readonly logger: Logger, - // We use the proposed API to get access to useCustom in dialogs. - private readonly vscodeProposed: typeof vscode, private readonly contextManager: ContextManager, ) { this.name = createWorkspaceIdentifier(workspace); @@ -69,14 +68,12 @@ export class WorkspaceMonitor implements vscode.Disposable { workspace: Workspace, client: CoderApi, logger: Logger, - vscodeProposed: typeof vscode, contextManager: ContextManager, ): Promise { const monitor = new WorkspaceMonitor( workspace, client, logger, - vscodeProposed, contextManager, ); @@ -181,7 +178,7 @@ export class WorkspaceMonitor implements vscode.Disposable { workspace.latest_build.status !== "running" ) { this.notifiedNotRunning = true; - this.vscodeProposed.window + vscodeProposed.window .showInformationMessage( `${this.name} is no longer running!`, { diff --git a/test/unit/core/binaryLock.test.ts b/test/unit/core/binaryLock.test.ts index bab76e1a..84558953 100644 --- a/test/unit/core/binaryLock.test.ts +++ b/test/unit/core/binaryLock.test.ts @@ -11,6 +11,10 @@ import { vi.mock("vscode"); +vi.mock("@/vscodeProposed", () => ({ + vscodeProposed: vscode, +})); + // Mock proper-lockfile vi.mock("proper-lockfile", () => ({ lock: vi.fn(), @@ -41,7 +45,7 @@ describe("BinaryLock", () => { mockProgress = new MockProgressReporter(); mockRelease = vi.fn().mockResolvedValue(undefined); - binaryLock = new BinaryLock(vscode, mockLogger); + binaryLock = new BinaryLock(mockLogger); }); describe("acquireLockOrWait", () => { diff --git a/test/unit/core/cliManager.concurrent.test.ts b/test/unit/core/cliManager.concurrent.test.ts index 457d8a31..96ca3529 100644 --- a/test/unit/core/cliManager.concurrent.test.ts +++ b/test/unit/core/cliManager.concurrent.test.ts @@ -25,6 +25,11 @@ import { } from "../../mocks/testHelpers"; vi.mock("@/pgp"); + +vi.mock("@/vscodeProposed", () => ({ + vscodeProposed: vscode, +})); + vi.mock("@/core/cliUtils", async () => { const actual = await vi.importActual("@/core/cliUtils"); return { @@ -75,7 +80,6 @@ function setupManager(testDir: string): CliManager { mockConfig.set("coder.disableSignatureVerification", true); return new CliManager( - vscode, createMockLogger(), new PathResolver(testDir, "/code/log"), ); diff --git a/test/unit/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index ded3767d..e037a0e0 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -50,6 +50,10 @@ vi.mock("proper-lockfile", () => ({ vi.mock("@/pgp"); +vi.mock("@/vscodeProposed", () => ({ + vscodeProposed: vscode, +})); + vi.mock("@/core/cliUtils", async () => { const actual = await vi.importActual("@/core/cliUtils"); @@ -89,7 +93,6 @@ describe("CliManager", () => { mockProgress = new MockProgressReporter(); mockUI = new MockUserInteraction(); manager = new CliManager( - vscode, createMockLogger(), new PathResolver(BASE_PATH, "/code/log"), ); @@ -575,7 +578,7 @@ describe("CliManager", () => { it("handles binary with spaces in path", async () => { const pathWithSpaces = "/path with spaces/bin"; const resolver = new PathResolver(pathWithSpaces, "/log"); - const manager = new CliManager(vscode, createMockLogger(), resolver); + const manager = new CliManager(createMockLogger(), resolver); withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test label"); diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index 1bb1a487..8b850a3b 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -65,6 +65,10 @@ vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn(), })); +vi.mock("@/vscodeProposed", () => ({ + vscodeProposed: vscode, +})); + // Mock CoderApi to control getAuthenticatedUser behavior const mockGetAuthenticatedUser = vi.hoisted(() => vi.fn()); vi.mock("@/api/coderApi", async (importOriginal) => { @@ -116,7 +120,6 @@ function createTestContext() { const coordinator = new LoginCoordinator( secretsManager, mementoManager, - vscode, logger, "coder.coder-remote", ); @@ -305,7 +308,6 @@ describe("LoginCoordinator", () => { const coordinator = new LoginCoordinator( secretsManager, mementoManager, - vscode, logger, "coder.coder-remote", ); diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index ef069110..b8f0c3e5 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -21,6 +21,10 @@ import type { LoginCoordinator, LoginOptions } from "@/login/loginCoordinator"; vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn() })); +vi.mock("@/vscodeProposed", () => ({ + vscodeProposed: vscode, +})); + const TEST_URL = "https://coder.example.com"; const TEST_HOSTNAME = "coder.example.com"; @@ -90,17 +94,14 @@ function createTestContext() { return { dispose: vi.fn() }; }); - const showErrorMessage = vi.fn().mockResolvedValue(undefined); - const vscodeProposed = { - ...vscode, - window: { ...vscode.window, showErrorMessage }, - } as typeof vscode; + const showErrorMessage = vi + .mocked(vscode.window.showErrorMessage) + .mockResolvedValue(undefined); registerUriHandler( container, deploymentManager as unknown as DeploymentManager, commands as unknown as Commands, - vscodeProposed, ); return {