From b28f33a73440ca19eb36472f03f04a9515651a37 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 26 Aug 2026 01:38:05 +0300 Subject: [PATCH 1/2] fix: keep a workspace on the SSH host it already uses (#1092) v1.16.1 moved existing workspaces onto a host named after the editor and reopened the window on it. Editors key window state by the whole folder URI, host included, so the workspace read as new and Cursor's chats, layout, and every extension's workspaceState looked gone. Fixes #1094. - Drop the automatic migration; a coder-vscode authority stays supported and regenerates its own SSH config block on connect. - Decide the host per workspace from the recently opened list: whichever of the two hosts it last connected over wins, so the Coder panel, dashboard and devcontainer links, Open Recent, and support bundles all agree. Only a workspace with no history gets the editor's own host. - Compare SSH hosts instead of folder paths, which also covers multi-root workspaces, devcontainers, and a directory the agent supplies. - Serve the shared coder-vscode host from one generated config file, named after the editor in the host prefix rather than the one writing it, so glob order no longer decides whose CLI and credentials run. - Mark the shared host (legacy) in Open Recent, and list its file in Coder: Open Generated SSH Configuration File. Closes #1094 --- CHANGELOG.md | 25 +++ src/commands.ts | 171 ++++++++++++----- src/core/pathResolver.ts | 22 ++- src/remote/remote.ts | 124 ++++-------- src/util/authority.ts | 40 ++-- test/mocks/testHelpers.ts | 80 +++++++- test/mocks/vscode.runtime.ts | 13 +- test/unit/commands.openDevContainer.test.ts | 70 +++++++ test/unit/commands.openSshConfig.test.ts | 87 +++++++++ test/unit/commands.openWorkspace.test.ts | 143 ++++++++++++++ test/unit/core/pathResolver.test.ts | 15 +- test/unit/remote/remote.test.ts | 198 ++++++++------------ test/unit/util/authority.test.ts | 114 ++++++----- 13 files changed, 778 insertions(+), 324 deletions(-) create mode 100644 test/unit/commands.openDevContainer.test.ts create mode 100644 test/unit/commands.openSshConfig.test.ts create mode 100644 test/unit/commands.openWorkspace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a5e70d79..f894917fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> +## Unreleased + +### Fixed + +- Reopen a workspace on the SSH host it was last opened on. v1.16.1 moved + existing workspaces onto a host named after your editor, and an editor + remembers a window by its full address, host included, so the workspace + looked new: Cursor's chats, your window layout, and anything else kept per + workspace appeared to be gone. Nothing was deleted. The host now follows the + workspace wherever you open it from: the Coder panel, a dashboard or + devcontainer link, or **File > Open Recent**, and whether it opens as a + folder or as a multi-root workspace. Only a workspace you have never opened + gets your editor's host. +- Mark the shared `coder-vscode` host as `(legacy)` in **File > Open Recent**, + so a workspace listed on both hosts is no longer two identical lines. The + folder picker lists each folder once. +- Look for a workspace's remote logs under the host it opens on when collecting + a support bundle for a workspace you are not connected to. The lookup always + used a host named after your editor, so a `remote.SSH.serverInstallPath` set + for the shared host was missed and the bundle fell back to the default. +- Serve the shared `coder-vscode` host from one generated SSH config file + instead of one per editor, so a connection over it always uses the CLI and + credentials of the editor that started it. **Coder: Open Generated SSH + Configuration File** can open that file too. + ## [v1.16.1](https://github.com/coder/vscode-coder/releases/tag/v1.16.1) 2026-08-24 ### Added diff --git a/src/commands.ts b/src/commands.ts index 4bab9e9fa2..8f9268b6d5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -51,8 +51,13 @@ import { } from "./supportBundle/remoteServerDataPath"; import { runExportTelemetryCommand } from "./telemetry/export/command"; import { + LegacyEditorId, + currentEditorId, + hostEditorId, isRemoteAuthorityCompatible, parseRemoteAuthority, + sshHostOf, + toLegacyAuthority, toRemoteAuthority, } from "./util/authority"; import { openInBrowser, toSafeHost } from "./util/uri"; @@ -87,6 +92,12 @@ import type { PongMessage, } from "./workspace/duplicateWorkspaceIpc"; +/** One entry from the private `_workbench.getRecentlyOpened` command. */ +interface RecentlyOpened { + folderUri?: vscode.Uri; + workspace?: { configPath?: vscode.Uri }; +} + const NO_SSH_CONFIG_MESSAGE = "No SSH config has been generated yet. It is written when you connect to a workspace."; @@ -572,14 +583,15 @@ export class Commands { ); } - /** Open this editor's generated SSH config, picking a deployment when several exist. */ + /** Open the generated SSH config, picking a file when several exist. */ public async openSshConfig(): Promise { - const hostname = await this.pickSshHostname(); - if (!hostname) { + const configPath = + this.connectedSshConfigPath() ?? (await this.pickSshConfigPath()); + if (!configPath) { return; } try { - await openFile(this.pathResolver.getSshConfigPath(hostname)); + await openFile(configPath); } catch { vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE); return; @@ -590,36 +602,51 @@ export class Commands { ); } - /** A connected window resolves to its own deployment; otherwise ask. */ - private async pickSshHostname(): Promise { + /** The file serving this window's connection, if it has one. */ + private connectedSshConfigPath(): string | undefined { try { // remoteAuthority is a proposed API; our own vscode module may not read it. - const remoteAuthority = vscodeProposed.env.remoteAuthority; - if (remoteAuthority) { - const parts = parseRemoteAuthority(remoteAuthority); - if (parts) { - return parts.safeHostname; - } - } + const authority = vscodeProposed.env.remoteAuthority; + const parts = authority ? parseRemoteAuthority(authority) : null; + return parts + ? this.pathResolver.getSshConfigPath( + parts.safeHostname, + hostEditorId(parts.sshHost), + ) + : undefined; } catch { - // Malformed Coder authority or unavailable API; fall through to the picker. + // Malformed Coder authority or unavailable API; fall back to the picker. + return undefined; } - const hostnames = ( - await readdirOrEmpty(this.pathResolver.getSshConfigDir()) - ) - .map((file) => this.pathResolver.parseSshConfigFile(file)) - .filter((name) => name !== undefined); - if (hostnames.length === 0) { + } + + /** Ask which config to open, of this editor's prefixes and the legacy one. */ + private async pickSshConfigPath(): Promise { + const files = await readdirOrEmpty(this.pathResolver.getSshConfigDir()); + const items = [...new Set([currentEditorId(), LegacyEditorId])].flatMap( + (editorId) => + files + .map((file) => this.pathResolver.parseSshConfigFile(file, editorId)) + .filter((safeHostname) => safeHostname !== undefined) + .map((safeHostname) => ({ + label: safeHostname, + // Which hosts it serves; two files can share a deployment. + description: `coder-${editorId}.${safeHostname}--*`, + path: this.pathResolver.getSshConfigPath(safeHostname, editorId), + })), + ); + if (items.length === 0) { vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE); return undefined; } - if (hostnames.length === 1) { - return hostnames[0]; + if (items.length === 1) { + return items[0].path; } - return vscode.window.showQuickPick(hostnames, { + const picked = await vscode.window.showQuickPick(items, { title: "Open generated SSH configuration", placeHolder: "Select a deployment", }); + return picked?.path; } /** @@ -1107,6 +1134,43 @@ export class Commands { ); } + /** Recently opened folders, and every entry including workspace files. */ + private async recentlyOpened(): Promise<{ + folders: vscode.Uri[]; + entries: vscode.Uri[]; + }> { + let recents: RecentlyOpened[] = []; + try { + // Private command; without it there is just no history to reuse. + const output: { workspaces?: RecentlyOpened[] } = + await vscode.commands.executeCommand("_workbench.getRecentlyOpened"); + recents = output?.workspaces ?? []; + } catch (error) { + this.logger.warn("Failed to read recently opened folders", error); + } + return { + folders: recents.flatMap((recent) => recent.folderUri ?? []), + entries: recents.flatMap( + (recent) => recent.folderUri ?? recent.workspace?.configPath ?? [], + ), + }; + } + + /** + * The host this workspace was last opened on, or this editor's own. Only the + * host carries over, never a recent entry's authority itself: that can name + * another devcontainer of the same workspace. + */ + private reusableAuthority(recents: vscode.Uri[], target: string): string { + const legacyAuthority = toLegacyAuthority(target); + const legacyHost = sshHostOf(legacyAuthority); + const currentHost = sshHostOf(target); + const lastUsed = recents + .map((uri) => sshHostOf(uri.authority)) + .find((host) => host === currentHost || host === legacyHost); + return lastUsed === legacyHost ? legacyAuthority : target; + } + private async runOpenDevContainer( workspaceOwner: string, workspaceName: string, @@ -1144,20 +1208,25 @@ export class Commands { ).toString("hex"); const type = localWorkspaceFolder ? "dev-container" : "attached-container"; - const devContainerAuthority = `${type}+${devContainer}@${remoteAuthority}`; + const target = `${type}+${devContainer}@${remoteAuthority}`; let newWindow = true; if (!vscode.workspace.workspaceFolders?.length) { newWindow = false; } + const { entries } = await this.recentlyOpened(); + const authority = this.reusableAuthority(entries, target); + + this.logger.info("Opening devcontainer", { remoteAuthority: authority }); + // Only set the memento when opening a new folder await this.mementoManager.setStartupMode("start"); await vscode.commands.executeCommand( "vscode.openFolder", vscode.Uri.from({ scheme: "vscode-remote", - authority: devContainerAuthority, + authority, path: devContainerFolder, }), newWindow, @@ -1252,7 +1321,7 @@ export class Commands { agentName, client: this.extensionClient, workspaceId: createWorkspaceIdentifier(item.workspace), - remoteAuthority: this.toWorkspaceAuthority( + remoteAuthority: await this.toWorkspaceAuthority( this.extensionClient, item.workspace, agentName, @@ -1279,7 +1348,7 @@ export class Commands { status: "selected", client: this.extensionClient, workspaceId: createWorkspaceIdentifier(pick.workspace), - remoteAuthority: this.toWorkspaceAuthority( + remoteAuthority: await this.toWorkspaceAuthority( this.extensionClient, pick.workspace, ), @@ -1292,20 +1361,24 @@ export class Commands { * Reconstruct the authority Remote-SSH would use, defaulting to the * first agent like the CLI does. */ - private toWorkspaceAuthority( + private async toWorkspaceAuthority( client: CoderApi, workspace: Workspace, agentName?: string, - ): string | undefined { + ): Promise { const baseUrl = client.getAxiosInstance().defaults.baseURL; if (!baseUrl) { return undefined; } - return toRemoteAuthority( - baseUrl, - workspace.owner_name, - workspace.name, - agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name, + const { entries } = await this.recentlyOpened(); + return this.reusableAuthority( + entries, + toRemoteAuthority( + baseUrl, + workspace.owner_name, + workspace.name, + agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name, + ), ); } @@ -1510,7 +1583,7 @@ export class Commands { ...options, }; let { folderPath } = options; - const remoteAuthority = toRemoteAuthority( + let remoteAuthority = toRemoteAuthority( baseUrl, workspace.owner_name, workspace.name, @@ -1526,25 +1599,26 @@ export class Commands { folderPath = agent.expanded_directory; } + const { folders, entries } = await this.recentlyOpened(); // If the agent had no folder or we have been asked to open the most recent, // we can try to open a recently opened folder/workspace. if (!folderPath || openRecent) { - const output: { - workspaces: Array<{ folderUri: vscode.Uri; remoteAuthority: string }>; - } = await vscode.commands.executeCommand("_workbench.getRecentlyOpened"); - const opened = output.workspaces.filter((opened) => - isRemoteAuthorityCompatible( - opened.folderUri?.authority, - remoteAuthority, + // One entry per folder: the same path can be in the list once per host. + const paths = [ + ...new Set( + folders + .filter((uri) => + isRemoteAuthorityCompatible(uri.authority, remoteAuthority), + ) + .map((uri) => uri.path), ), - ); + ]; // openRecent will always use the most recent. Otherwise, if there are // multiple we ask the user which to use. - if (opened.length === 1 || (opened.length > 1 && openRecent)) { - folderPath = opened[0].folderUri.path; - } else if (opened.length > 1) { - const items = opened.map((f) => f.folderUri.path); - folderPath = await vscode.window.showQuickPick(items, { + if (paths.length === 1 || (paths.length > 1 && openRecent)) { + folderPath = paths[0]; + } else if (paths.length > 1) { + folderPath = await vscode.window.showQuickPick(paths, { title: "Select a recently opened folder", }); if (!folderPath) { @@ -1553,6 +1627,7 @@ export class Commands { } } } + remoteAuthority = this.reusableAuthority(entries, remoteAuthority); // Only set the memento when opening a new folder/window await this.mementoManager.setStartupMode("start"); diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index ddd862f41a..aa47edb461 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -71,17 +71,27 @@ export class PathResolver { return path.join(platformDataDir(), "coder.coder-remote", "ssh"); } - /** This editor's generated SSH config for one deployment. */ - public getSshConfigPath(safeHostname: string): string { + /** + * Generated SSH config for one deployment, named after the editor in the + * host prefix rather than the one writing it: two files declaring the same + * host pattern would leave glob order to decide which one ssh reads. + */ + public getSshConfigPath( + safeHostname: string, + editorId: string = currentEditorId(), + ): string { return path.join( this.getSshConfigDir(), - `${currentEditorId()}--${safeHostname}${SSH_CONFIG_EXT}`, + `${editorId}--${safeHostname}${SSH_CONFIG_EXT}`, ); } - /** The deployment hostname if this editor generated the file, else undefined. */ - public parseSshConfigFile(fileName: string): string | undefined { - const prefix = `${currentEditorId()}--`; + /** The deployment hostname if `editorId` named the file, else undefined. */ + public parseSshConfigFile( + fileName: string, + editorId: string = currentEditorId(), + ): string | undefined { + const prefix = `${editorId}--`; return fileName.startsWith(prefix) && fileName.endsWith(SSH_CONFIG_EXT) ? fileName.slice(prefix.length, -SSH_CONFIG_EXT.length) : undefined; diff --git a/src/remote/remote.ts b/src/remote/remote.ts index ffcdcfab6e..612da18f57 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -41,8 +41,9 @@ import { escapeCommandArg, expandPath } from "../util"; import { type AuthorityParts, classifySshHost, + hostEditorId, parseRemoteAuthority, - retargetRemoteAuthority, + sshHostOf, } from "../util/authority"; import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; @@ -107,6 +108,29 @@ interface RemoteSetupContext { disposables: vscode.Disposable[]; } +/** + * What Open Recent shows after the path. VS Code splits the label on the + * separator, so "/" would display "/home/kyle [Coder: kyle/workspace]" as + * "workspace] /home/kyle [Coder: kyle"; "∕" looks the same in the UI font. + */ +export function workspaceLabelSuffix( + remoteAuthority: string, + owner: string, + workspace: string, + agent?: string, +): string { + let suffix = `Coder: ${owner}∕${workspace}`; + if (agent) { + suffix += `∕${agent}`; + } + // Mark the shared host, so a workspace with an entry on each is not two + // identical lines. Only a fork sees it; in VS Code it is the only host. + const sshHost = sshHostOf(remoteAuthority); + return sshHost && classifySshHost(sshHost) === "legacy" + ? `${suffix} (legacy)` + : suffix; +} + export class Remote { private readonly logger: Logger; private readonly pathResolver: PathResolver; @@ -160,15 +184,6 @@ export class Remote { return; } - // parseRemoteAuthority returned null for foreign hosts, so this is - // either the current editor's authority or a migratable legacy one. - if (classifySshHost(parts.sshHost) === "legacy") { - if (await this.migrateLegacyAuthority(remoteAuthority, startupMode)) { - return; - } - // Not reopened: keep going so the legacy host still connects. - } - this.logger.info("Setting up remote connection", { remoteAuthority, hostname: parts.safeHostname, @@ -726,70 +741,6 @@ export class Remote { return undefined; } - /** - * Reopen the window on this editor's own authority. Returns false when the - * workspace cannot be reopened losslessly, so the caller connects over the - * legacy host instead. - */ - private async migrateLegacyAuthority( - remoteAuthority: string, - startupMode: StartupMode, - ): Promise { - const migratedAuthority = retargetRemoteAuthority(remoteAuthority); - const workspaceFile = vscode.workspace.workspaceFile; - const workspaceFolders = vscode.workspace.workspaceFolders ?? []; - const savedWorkspaceFile = - workspaceFile?.scheme === "untitled" ? undefined : workspaceFile; - if (!savedWorkspaceFile && workspaceFolders.length > 1) { - this.logger.warn( - "Cannot migrate an unsaved multi-root workspace; connecting over the legacy host", - remoteAuthority, - ); - // Fire-and-forget: the connection proceeds either way. - void vscode.window - .showWarningMessage( - "This workspace still opens over the old coder-vscode SSH host. " + - "To switch it to this editor's own host, save the workspace, then reload the window.", - "Learn More", - ) - .then(async (choice) => { - if (choice === "Learn More") { - await vscode.env.openExternal( - vscode.Uri.parse( - "https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces", - ), - ); - } - }); - return false; - } - - await this.serviceContainer - .getMementoManager() - .setStartupMode(startupMode === "none" ? "start" : startupMode); - this.logger.info("Migrating legacy remote authority", { - from: remoteAuthority, - to: migratedAuthority, - }); - - const currentUri = savedWorkspaceFile ?? workspaceFolders[0]?.uri; - if (currentUri) { - await vscode.commands.executeCommand( - "vscode.openFolder", - currentUri.with({ - authority: retargetRemoteAuthority(currentUri.authority), - }), - false, - ); - return true; - } - await vscode.commands.executeCommand("vscode.newWindow", { - remoteAuthority: migratedAuthority, - reuseWindow: true, - }); - return true; - } - private async resolveRemoteBinary(workspaceClient: Api): Promise { if ( this.extensionContext.extensionMode === vscode.ExtensionMode.Production @@ -968,14 +919,14 @@ export class Remote { featureSet: FeatureSet, cliAuth: CliAuth, ): Promise { - // Taken from the authority, so an unmigrated legacy host keeps working. - const { hostPrefix, safeHostname } = parts; - // One file per (editor, deployment); the user's config gains one shared include. + // Taken from the authority, so a legacy host keeps working. + const { hostPrefix, safeHostname, sshHost } = parts; + // One file per (host prefix, deployment); the user's config gains one shared include. const sshConfig = new SshConfig(this.getMainSshConfigPath(), this.logger); await sshConfig.load(); // Never loaded: update() regenerates it without reading the old content. const coderConfig = new SshConfig( - this.pathResolver.getSshConfigPath(safeHostname), + this.pathResolver.getSshConfigPath(safeHostname, hostEditorId(sshHost)), this.logger, ); @@ -1156,16 +1107,6 @@ export class Remote { workspace: string, agent?: string, ): vscode.Disposable { - // VS Code splits based on the separator when displaying the label - // in a recently opened dialog. If the workspace suffix contains /, - // then it'll visually display weird: - // "/home/kyle [Coder: kyle/workspace]" displays as "workspace] /home/kyle [Coder: kyle" - // For this reason, we use a different / that visually appears the - // same on non-monospace fonts "∕". - let suffix = `Coder: ${owner}∕${workspace}`; - if (agent) { - suffix += `∕${agent}`; - } // VS Code caches resource label formatters in it's global storage SQLite database // under the key "memento/cachedResourceLabelFormatters2". return vscodeProposed.workspace.registerResourceLabelFormatter({ @@ -1177,7 +1118,12 @@ export class Remote { label: "${path}", separator: "/", tildify: true, - workspaceSuffix: suffix, + workspaceSuffix: workspaceLabelSuffix( + remoteAuthority, + owner, + workspace, + agent, + ), }, }); } diff --git a/src/util/authority.ts b/src/util/authority.ts index a2ba9c3b9c..1120460ed1 100644 --- a/src/util/authority.ts +++ b/src/util/authority.ts @@ -2,7 +2,9 @@ import * as vscode from "vscode"; import { toSafeHost } from "./uri"; -export const LegacyAuthorityPrefix = "coder-vscode"; +/** The editor every host was named after before per-editor prefixes existed. */ +export const LegacyEditorId = "vscode"; +const LegacyAuthorityPrefix = `coder-${LegacyEditorId}`; export interface AuthorityParts { agent: string | undefined; @@ -68,10 +70,19 @@ export function classifySshHost(sshHost: string): AuthorityClassification { return "foreign"; } -function authorityPrefix(classification: AuthorityClassification): string { - return classification === "legacy" - ? LegacyAuthorityPrefix - : currentAuthorityPrefix(); +function editorIdFor(classification: AuthorityClassification): string { + return classification === "legacy" ? LegacyEditorId : currentEditorId(); +} + +/** Editor named in a host's prefix; legacy hosts stay VS Code's in any editor. */ +export function hostEditorId(sshHost: string): string { + return editorIdFor(classifySshHost(sshHost)); +} + +/** The SSH host an authority connects over, nested or not. */ +export function sshHostOf(authority: string): string | undefined { + const sshHostStart = getSshHostStart(authority); + return sshHostStart === undefined ? undefined : authority.slice(sshHostStart); } /** @@ -100,7 +111,7 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { } // The classification guarantees the host starts with ".". - const prefix = `${authorityPrefix(classification)}.`; + const prefix = `coder-${editorIdFor(classification)}.`; const parts = sshHost.slice(prefix.length).split("--"); if (parts.length < 3) { throw new Error(invalidAuthorityMessage); @@ -149,17 +160,20 @@ export function toRemoteAuthority( return remoteAuthority; } -export function retargetRemoteAuthority(authority: string): string { +/** + * The same authority on the shared legacy host, keeping any wrapper. Returns it + * unchanged when the host is not this editor's, the legacy one included. + */ +export function toLegacyAuthority(authority: string): string { const sshHostStart = getSshHostStart(authority); if (sshHostStart === undefined) { return authority; } - + const currentPrefix = currentAuthorityPrefix(); const sshHost = authority.slice(sshHostStart); - if (classifySshHost(sshHost) !== "legacy") { - return authority; - } - return `${authority.slice(0, sshHostStart)}${currentAuthorityPrefix()}${sshHost.slice(LegacyAuthorityPrefix.length)}`; + return sshHost.startsWith(`${currentPrefix}.`) + ? `${authority.slice(0, sshHostStart)}${LegacyAuthorityPrefix}${sshHost.slice(currentPrefix.length)}` + : authority; } export function isRemoteAuthorityCompatible( @@ -171,6 +185,6 @@ export function isRemoteAuthorityCompatible( } return ( authority === targetAuthority || - retargetRemoteAuthority(authority) === targetAuthority + authority === toLegacyAuthority(targetAuthority) ); } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 914cc45f4c..708440cbb0 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -6,9 +6,10 @@ import axios, { type InternalAxiosRequestConfig, } from "axios"; import * as fs from "node:fs/promises"; -import { vi } from "vitest"; +import { onTestFinished, vi } from "vitest"; import * as vscode from "vscode"; +import { Commands } from "@/commands"; import { SessionStore, type SessionData } from "@/deployment/sessionStore"; import { @@ -17,7 +18,7 @@ import { } from "@repo/mocks"; import { createTestTelemetryService } from "./telemetry"; -import { window as vscodeWindow } from "./vscode.runtime"; +import { env as vscodeEnv, window as vscodeWindow } from "./vscode.runtime"; import type { Experiment, @@ -39,6 +40,7 @@ import type { ContextManager } from "@/core/contextManager"; import type { MementoManager } from "@/core/mementoManager"; import type { PathResolver } from "@/core/pathResolver"; import type { SecretsManager } from "@/core/secretsManager"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { Deployment } from "@/deployment/types"; import type { Logger } from "@/logging/logger"; import type { LoginCoordinator } from "@/login/loginCoordinator"; @@ -65,6 +67,18 @@ interface LoginCoordinatorLike { ensureLoggedInWithDialog: LoginCoordinator["ensureLoggedInWithDialog"]; } +/** + * Run the rest of the test as the given editor, which is the identity + * `vscode.env.uriScheme` reports and the extension names its SSH hosts after. + * The previous editor is restored when the test ends. + */ +export function useEditor(uriScheme: string): void { + const previous = vscodeEnv.__setUriScheme(uriScheme); + onTestFinished(() => { + vscodeEnv.__setUriScheme(previous); + }); +} + export function makeNetworkInfo( overrides: Partial = {}, ): NetworkInfo { @@ -639,6 +653,68 @@ export function createMockServiceContainer( } as ServiceContainer; } +/** Build `Commands`; services left unnamed stand in as empty objects. */ +export function createTestCommands( + options: { + services?: Record; + baseUrl?: string; + client?: Partial; + } = {}, +): Commands { + const services: Record = { + getTelemetryService: createTestTelemetryService(), + getLogger: createMockLogger(), + getMementoManager: { setStartupMode: vi.fn() }, + getDuplicateWorkspaceIpc: { + sendPing: vi.fn().mockResolvedValue(undefined), + }, + ...options.services, + }; + return new Commands( + new Proxy({} as ServiceContainer, { + get: (_, name: string) => () => services[name] ?? {}, + }), + { + getAxiosInstance: () => ({ defaults: { baseURL: options.baseUrl } }), + ...options.client, + } as unknown as CoderApi, + {} as DeploymentManager, + ); +} + +/** Recently opened entries on one path; a multi-root file has no folder URI. */ +export function mockRecentlyOpened( + authorities: string[], + path: string, + kind: "folder" | "workspaceFile" = "folder", +): void { + const workspaces = authorities.map((authority) => { + const uri = vscode.Uri.from({ scheme: "vscode-remote", authority, path }); + return kind === "folder" + ? { folderUri: uri } + : { workspace: { id: "workspace-1", configPath: uri } }; + }); + vi.mocked(vscode.commands.executeCommand).mockImplementation( + (command: string) => + Promise.resolve( + command === "_workbench.getRecentlyOpened" ? { workspaces } : undefined, + ), + ); +} + +/** The authority a window was handed: a folder by URI, an empty window by option. */ +export function openedAuthority(): string | undefined { + const [, handoff] = + vi + .mocked(vscode.commands.executeCommand) + .mock.calls.find(([command]) => + ["vscode.openFolder", "vscode.newWindow"].includes(command), + ) ?? []; + return handoff instanceof vscode.Uri + ? handoff.authority + : (handoff as { remoteAuthority?: string } | undefined)?.remoteAuthority; +} + /** Update the mocked active color theme and fire onDidChangeActiveColorTheme. */ export function setActiveColorTheme(kind: vscode.ColorThemeKind): void { vscodeWindow.__setActiveColorThemeKind(kind); diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 252ae68af8..d63b2efcf7 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -237,6 +237,8 @@ export const workspace = { onDidChangeWorkspaceFolders.fire(e), }; +let uriScheme = "vscode"; + export const env = { appName: "Visual Studio Code", appRoot: "/app", @@ -245,8 +247,17 @@ export const env = { sessionId: "test-session-id", remoteName: undefined as string | undefined, shell: "/bin/bash", - uriScheme: "vscode", + get uriScheme(): string { + return uriScheme; + }, openExternal: vi.fn(), + + // test-only trigger, returning the scheme it replaced: + __setUriScheme: (scheme: string): string => { + const previous = uriScheme; + uriScheme = scheme; + return previous; + }, }; export const extensions = { diff --git a/test/unit/commands.openDevContainer.test.ts b/test/unit/commands.openDevContainer.test.ts new file mode 100644 index 0000000000..e2a30a7ab2 --- /dev/null +++ b/test/unit/commands.openDevContainer.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + createTestCommands, + MockConfigurationProvider, + mockRecentlyOpened, + openedAuthority, + useEditor, +} from "../mocks/testHelpers"; + +const FOLDER = "/workspaces/project"; +const CONTAINER = "my-container"; + +/** The hex payload the extension derives from the arguments below. */ +const PAYLOAD = Buffer.from( + JSON.stringify({ + containerName: CONTAINER, + hostPath: undefined, + configFile: undefined, + localDocker: false, + }), + "utf-8", +).toString("hex"); + +const authorityFor = (editorId: string, payload = PAYLOAD) => + `attached-container+${payload}@ssh-remote+coder-${editorId}.dev.coder.com--foo--bar.devcontainer`; + +const CURSOR = authorityFor("cursor"); +const LEGACY = authorityFor("vscode"); + +/** + * Open the devcontainer as the URI handler does, with the given authorities + * standing in for recently opened folders. + */ +async function openDevContainer( + recents: string[], +): Promise { + new MockConfigurationProvider(); + mockRecentlyOpened(recents, FOLDER); + const commands = createTestCommands({ baseUrl: "https://dev.coder.com" }); + await commands.openDevContainer( + "foo", + "bar", + "devcontainer", + CONTAINER, + FOLDER, + ); + return openedAuthority(); +} + +describe("openDevContainer", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEditor("cursor"); + }); + + // How the two hosts are chosen between is covered in openWorkspace; these + // cases are about carrying the container payload across that choice. + it.each([ + { label: "its own host with no history", recents: [], expected: CURSOR }, + { label: "the legacy host it used", recents: [LEGACY], expected: LEGACY }, + { + label: "the legacy host another devcontainer in the workspace used", + recents: [authorityFor("vscode", "abcdef")], + expected: LEGACY, + }, + ])("reopens the devcontainer on $label", async ({ recents, expected }) => { + expect(await openDevContainer(recents)).toBe(expected); + }); +}); diff --git a/test/unit/commands.openSshConfig.test.ts b/test/unit/commands.openSshConfig.test.ts new file mode 100644 index 0000000000..f029e0bc5e --- /dev/null +++ b/test/unit/commands.openSshConfig.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { PathResolver } from "@/core/pathResolver"; + +import { + createTestCommands, + MockConfigurationProvider, + useEditor, +} from "../mocks/testHelpers"; + +const pathResolver = new PathResolver("/data", "/logs"); +const configPath = (editorId: string) => + pathResolver.getSshConfigPath("dev.coder.com", editorId); +const CURSOR_FILE = "cursor--dev.coder.com.conf"; +const LEGACY_FILE = "vscode--dev.coder.com.conf"; + +vi.mock("node:fs/promises", async (importOriginal) => ({ + ...(await importOriginal()), + readdir: vi.fn(), +})); + +/** Open the generated config with the given files on disk, and report the path. */ +async function openSshConfig( + files: string[], + remoteAuthority?: string, +): Promise { + new MockConfigurationProvider(); + const { readdir } = await import("node:fs/promises"); + vi.mocked(readdir).mockResolvedValue(files as never); + vi.mocked(vscode.env).remoteAuthority = remoteAuthority; + + const opened: string[] = []; + vi.mocked(vscode.window.showTextDocument).mockImplementation( + (uri: unknown) => { + opened.push((uri as vscode.Uri).fsPath); + return Promise.resolve({} as vscode.TextEditor); + }, + ); + const commands = createTestCommands({ + services: { getPathResolver: pathResolver }, + }); + await commands.openSshConfig(); + return opened[0]; +} + +describe("openSshConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEditor("cursor"); + }); + + it("opens the file serving the connected window, legacy host included", async () => { + expect( + await openSshConfig( + [CURSOR_FILE, LEGACY_FILE], + "ssh-remote+coder-vscode.dev.coder.com--foo--bar.main", + ), + ).toBe(configPath("vscode")); + }); + + it("opens the only file without asking", async () => { + expect(await openSshConfig([LEGACY_FILE])).toBe(configPath("vscode")); + expect(vscode.window.showQuickPick).not.toHaveBeenCalled(); + }); + + it("offers both prefixes serving one deployment", async () => { + vi.mocked(vscode.window.showQuickPick).mockImplementation( + (items: unknown) => + Promise.resolve( + (items as Array<{ description: string }>).find( + (item) => item.description === "coder-vscode.dev.coder.com--*", + ), + ) as never, + ); + expect(await openSshConfig([CURSOR_FILE, LEGACY_FILE])).toBe( + configPath("vscode"), + ); + const [items] = vi.mocked(vscode.window.showQuickPick).mock.calls[0]; + expect(items).toHaveLength(2); + }); + + it("ignores files for prefixes this editor never connects over", async () => { + expect(await openSshConfig(["devin--dev.coder.com.conf"])).toBeUndefined(); + expect(vscode.window.showInformationMessage).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/commands.openWorkspace.test.ts b/test/unit/commands.openWorkspace.test.ts new file mode 100644 index 0000000000..2f7093d61f --- /dev/null +++ b/test/unit/commands.openWorkspace.test.ts @@ -0,0 +1,143 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { + agent as createAgent, + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; + +import { + createTestCommands, + MockConfigurationProvider, + mockRecentlyOpened, + openedAuthority, + useEditor, +} from "../mocks/testHelpers"; + +import type { WorkspaceAgent } from "coder/site/src/api/typesGenerated"; + +vi.mock("@/workspace/workspacesProvider", () => ({ + AgentTreeItem: class { + constructor( + public agent: unknown, + public workspace: unknown, + ) {} + }, + WorkspaceTreeItem: class {}, +})); + +const AGENT = { name: "main" } as WorkspaceAgent; +const FOLDER = "/home/foo/project"; +const BASE_URL = "https://dev.coder.com"; +const CURSOR = "ssh-remote+coder-cursor.dev.coder.com--foo--bar.main"; +const LEGACY = "ssh-remote+coder-vscode.dev.coder.com--foo--bar.main"; +const DEVIN = "ssh-remote+coder-devin.dev.coder.com--foo--bar.main"; + +/** + * Open the workspace from the sidebar, with the given authorities standing in + * for recently opened folders, and report the authority the window was handed. + */ +async function openFromSidebar( + recents: string[], + path = FOLDER, + kind?: "folder" | "workspaceFile", +): Promise { + new MockConfigurationProvider(); + mockRecentlyOpened(recents, path, kind); + const commands = createTestCommands({ baseUrl: BASE_URL }); + const { AgentTreeItem } = await import("@/workspace/workspacesProvider"); + await commands.openFromSidebar( + new AgentTreeItem( + AGENT, + createWorkspace({ owner_name: "foo", name: "bar" }), + ), + ); + return openedAuthority(); +} + +/** Open as a link does, without openRecent, from an agent built as given. */ +async function openFromLink( + recents: string[], + agent: Partial = {}, +): Promise { + new MockConfigurationProvider(); + mockRecentlyOpened(recents, FOLDER); + const commands = createTestCommands({ + baseUrl: BASE_URL, + client: { + getWorkspaceByOwnerAndName: vi.fn().mockResolvedValue( + createWorkspace({ + owner_name: "foo", + name: "bar", + latest_build: { + resources: [createResource({ agents: [createAgent(agent)] })], + }, + }), + ), + }, + }); + await commands.open({ + workspaceOwner: "foo", + workspaceName: "bar", + agentName: "main", + source: "uri", + }); + return openedAuthority(); +} + +describe("openWorkspace", () => { + beforeEach(() => { + vi.clearAllMocks(); + useEditor("cursor"); + }); + + interface RecentCase { + label: string; + recents: string[]; + expected: string; + } + it.each([ + { label: "the legacy host it used", recents: [LEGACY], expected: LEGACY }, + { label: "its own host", recents: [CURSOR], expected: CURSOR }, + { label: "its own host with no history", recents: [], expected: CURSOR }, + { + label: "its own host, ignoring another editor's", + recents: [DEVIN], + expected: CURSOR, + }, + { + label: "the host it used last, of the two it has used", + recents: [CURSOR, LEGACY], + expected: CURSOR, + }, + { + label: "the legacy host it used last", + recents: [LEGACY, CURSOR], + expected: LEGACY, + }, + ])("reopens the workspace on $label", async ({ recents, expected }) => { + expect(await openFromSidebar(recents)).toBe(expected); + }); + + it("reopens a multi-root workspace on the host its file used", async () => { + expect( + await openFromSidebar( + [LEGACY], + "/home/foo/project.code-workspace", + "workspaceFile", + ), + ).toBe(LEGACY); + }); + + it("reuses the host of a directory the agent supplies", async () => { + expect(await openFromLink([LEGACY], { expanded_directory: FOLDER })).toBe( + LEGACY, + ); + }); + + it("does not ask between one folder recorded on both hosts", async () => { + expect(await openFromLink([CURSOR, LEGACY])).toBe(CURSOR); + expect(vscode.window.showQuickPick).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/core/pathResolver.test.ts b/test/unit/core/pathResolver.test.ts index a6117dc564..4361ccdbab 100644 --- a/test/unit/core/pathResolver.test.ts +++ b/test/unit/core/pathResolver.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PathResolver } from "@/core/pathResolver"; -import { MockConfigurationProvider } from "../../mocks/testHelpers"; +import { MockConfigurationProvider, useEditor } from "../../mocks/testHelpers"; import { expectPathsEqual } from "../../utils/platform"; describe("PathResolver", () => { @@ -127,10 +127,21 @@ describe("PathResolver", () => { ); }); - it("parses the hostname only from this editor's generated files", () => { + it("names a legacy host's file after VS Code, whichever editor asks", () => { + useEditor("cursor"); + expectPathsEqual( + pathResolver.getSshConfigPath("dev.coder.com", "vscode"), + path.join(pathResolver.getSshConfigDir(), "vscode--dev.coder.com.conf"), + ); + }); + + it("parses the hostname only from the named editor's files", () => { expect( pathResolver.parseSshConfigFile("vscode--dev.coder.com.conf"), ).toBe("dev.coder.com"); + expect( + pathResolver.parseSshConfigFile("cursor--dev.coder.com.conf", "cursor"), + ).toBe("dev.coder.com"); expect( pathResolver.parseSshConfigFile("cursor--dev.coder.com.conf"), ).toBeUndefined(); diff --git a/test/unit/remote/remote.test.ts b/test/unit/remote/remote.test.ts index 04d58288c5..50ec7c46c5 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { MementoManager } from "@/core/mementoManager"; import { PathResolver } from "@/core/pathResolver"; import { SecretsManager } from "@/core/secretsManager"; -import { Remote } from "@/remote/remote"; +import { Remote, workspaceLabelSuffix } from "@/remote/remote"; import { createTestTelemetryService } from "../../mocks/telemetry"; import { @@ -16,6 +16,7 @@ import { LogCollector, MockConfigurationProvider, MockUserInteraction, + useEditor, } from "../../mocks/testHelpers"; import type { Commands } from "@/commands"; @@ -26,7 +27,6 @@ const mockWorkspace = vscode.workspace as typeof vscode.workspace & { workspaceFile: vscode.Uri | undefined; workspaceFolders: vscode.WorkspaceFolder[]; }; -const mockEnv = vscode.env as typeof vscode.env & { uriScheme: string }; vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); @@ -93,7 +93,6 @@ describe("Remote", () => { vol.reset(); mockWorkspace.workspaceFile = undefined; mockWorkspace.workspaceFolders = []; - mockEnv.uriScheme = "vscode"; }); type UriOptions = Partial< @@ -118,126 +117,46 @@ describe("Remote", () => { return mockWorkspace.workspaceFolders; }; - it("migrates a legacy folder with its full URI", async () => { - mockEnv.uriScheme = "cursor"; - const { remote, mementoManager } = createRemote(); - setWorkspace([ - createUri("/workspace", { - query: "window=active", - fragment: "selection", - }), - ]); - - await expect( - remote.setup(REMOTE_AUTHORITY, "none", REMOTE_SSH_EXTENSION_ID), - ).resolves.toBeUndefined(); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - "vscode.openFolder", - createUri("/workspace", { - authority: CURSOR_REMOTE_AUTHORITY, - query: "window=active", - fragment: "selection", - }), - false, - ); - expect(await mementoManager.getAndClearStartupMode()).toBe("start"); - }); - - it("migrates a saved multi-root workspace file", async () => { - mockEnv.uriScheme = "cursor"; - const { remote, mementoManager } = createRemote(); - setWorkspace( - [createUri("/first-folder"), createUri("/second-folder")], - createUri("/project.code-workspace", { - query: "window=active", - fragment: "selection", - }), - ); - - await expect( - remote.setup(REMOTE_AUTHORITY, "none", REMOTE_SSH_EXTENSION_ID), - ).resolves.toBeUndefined(); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - "vscode.openFolder", - createUri("/project.code-workspace", { - authority: CURSOR_REMOTE_AUTHORITY, - query: "window=active", - fragment: "selection", - }), - false, - ); - expect(await mementoManager.getAndClearStartupMode()).toBe("start"); - }); - - interface EmptyWindowMigrationCase { - startupMode: "none" | "start" | "update"; - expectedStartupMode: "start" | "update"; + interface LegacyWindowCase { + label: string; + open: () => void; } - it.each([ - { startupMode: "none", expectedStartupMode: "start" }, - { startupMode: "start", expectedStartupMode: "start" }, - { startupMode: "update", expectedStartupMode: "update" }, - ])( - "migrates an empty window and preserves $startupMode startup mode", - async ({ startupMode, expectedStartupMode }) => { - mockEnv.uriScheme = "cursor"; - const { remote, mementoManager } = createRemote(); - - await expect( - remote.setup(REMOTE_AUTHORITY, startupMode, REMOTE_SSH_EXTENSION_ID), - ).resolves.toBeUndefined(); - - expect(vscode.commands.executeCommand).toHaveBeenCalledWith( - "vscode.newWindow", - { - remoteAuthority: CURSOR_REMOTE_AUTHORITY, - reuseWindow: true, - }, - ); - expect(await mementoManager.getAndClearStartupMode()).toBe( - expectedStartupMode, - ); + it.each([ + { label: "a folder", open: () => setWorkspace([createUri("/workspace")]) }, + { + label: "a saved multi-root workspace", + open: () => + setWorkspace( + [createUri("/first-folder"), createUri("/second-folder")], + createUri("/project.code-workspace"), + ), }, - ); - - it.each([ - { choice: undefined, docsUrls: [] }, { - choice: "Learn More", - docsUrls: [expect.stringContaining("multi-root-workspaces")], + label: "an untitled multi-root workspace", + open: () => + setWorkspace( + [createUri("/first-folder"), createUri("/second-folder")], + createUri("/Untitled-1.code-workspace", { + scheme: "untitled", + authority: "", + }), + ), }, + { label: "an empty window", open: () => setWorkspace() }, ])( - "sets up an untitled multi-root workspace on the old host (choice: $choice)", - async ({ choice, docsUrls }) => { - mockEnv.uriScheme = "cursor"; - const { - remote, - ensureLoggedInWithDialog, - mementoManager, - userInteraction, - } = createRemote(); - setWorkspace( - [createUri("/first-folder"), createUri("/second-folder")], - createUri("/Untitled-1.code-workspace", { - scheme: "untitled", - authority: "", - }), - ); - userInteraction.setResponse(/coder-vscode SSH host/, choice); + "connects $label over the legacy host without reopening it", + async ({ open }) => { + useEditor("cursor"); + const { remote, ensureLoggedInWithDialog, userInteraction } = + createRemote(); + open(); await expect( - remote.setup(REMOTE_AUTHORITY, "update", REMOTE_SSH_EXTENSION_ID), + remote.setup(REMOTE_AUTHORITY, "none", REMOTE_SSH_EXTENSION_ID), ).resolves.toBeUndefined(); - const warning = userInteraction - .getMessageCalls() - .find((call) => call.level === "warning"); - expect(warning?.message).toContain("coder-vscode SSH host"); - expect(warning?.items).toEqual(["Learn More"]); - expect(userInteraction.getExternalUrls()).toEqual(docsUrls); - // Setup continues over the legacy host instead of reopening the window. + // Reopening would change the authority, and with it the identity the + // editor keeps window state under. expect(ensureLoggedInWithDialog).toHaveBeenCalledOnce(); expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith( "vscode.openFolder", @@ -248,12 +167,14 @@ describe("Remote", () => { "vscode.newWindow", expect.anything(), ); - expect(await mementoManager.getAndClearStartupMode()).toBe("none"); + expect( + userInteraction.getMessageCalls().find((c) => c.level === "warning"), + ).toBeUndefined(); }, ); it("continues setup for the current authority without reopening", async () => { - mockEnv.uriScheme = "cursor"; + useEditor("cursor"); const { remote, ensureLoggedInWithDialog } = createRemote(); await expect( @@ -273,7 +194,7 @@ describe("Remote", () => { }); it("ignores a foreign authority", async () => { - mockEnv.uriScheme = "cursor"; + useEditor("cursor"); const { remote, ensureLoggedInWithDialog, mementoManager } = createRemote(); await expect( @@ -306,3 +227,46 @@ describe("Remote", () => { }); }); }); + +describe("workspaceLabelSuffix", () => { + it.each([ + { + label: "this editor's", + editor: "cursor", + host: "coder-cursor", + agent: "main", + expected: "Coder: foo∕bar∕main", + }, + { + label: "the shared", + editor: "cursor", + host: "coder-vscode", + agent: "main", + expected: "Coder: foo∕bar∕main (legacy)", + }, + { + label: "VS Code's own", + editor: "vscode", + host: "coder-vscode", + agent: "main", + expected: "Coder: foo∕bar∕main", + }, + { + label: "this editor's, agentless", + editor: "cursor", + host: "coder-cursor", + agent: undefined, + expected: "Coder: foo∕bar", + }, + ])("labels $label host in $editor", ({ editor, host, agent, expected }) => { + useEditor(editor); + expect( + workspaceLabelSuffix( + `ssh-remote+${host}.dev.coder.com--foo--bar.main`, + "foo", + "bar", + agent, + ), + ).toBe(expected); + }); +}); diff --git a/test/unit/util/authority.test.ts b/test/unit/util/authority.test.ts index e54ec248e8..555b3e3048 100644 --- a/test/unit/util/authority.test.ts +++ b/test/unit/util/authority.test.ts @@ -1,17 +1,18 @@ -import { afterEach, describe, expect, it } from "vitest"; -import * as vscode from "vscode"; +import { describe, expect, it } from "vitest"; import { type AuthorityClassification, type AuthorityParts, classifySshHost, + hostEditorId, isRemoteAuthorityCompatible, parseRemoteAuthority, - retargetRemoteAuthority, + toLegacyAuthority, toRemoteAuthority, } from "@/util/authority"; -const env = vscode.env as typeof vscode.env & { uriScheme: string }; +import { useEditor } from "../../mocks/testHelpers"; + const CURSOR_AUTHORITY = "ssh-remote+coder-cursor.dev.coder.com--foo--bar.main"; const LEGACY_AUTHORITY = "ssh-remote+coder-vscode.dev.coder.com--foo--bar.main"; const DEVIN_AUTHORITY = "ssh-remote+coder-devin.dev.coder.com--foo--bar.main"; @@ -25,10 +26,6 @@ const parts = (prefix: string): AuthorityParts => ({ workspace: "bar", }); -afterEach(() => { - env.uriScheme = "vscode"; -}); - describe("parseRemoteAuthority", () => { interface ClassificationCase { editor: string; @@ -52,7 +49,7 @@ describe("parseRemoteAuthority", () => { ])( "classifies $prefix as $expected in $editor", ({ editor, prefix, expected }) => { - env.uriScheme = editor; + useEditor(editor); expect(classifySshHost(parts(prefix).sshHost)).toBe(expected); }, ); @@ -70,7 +67,7 @@ describe("parseRemoteAuthority", () => { { editor: "vscode", sshHost: "coder-vscode.dev.coder.com--foo--.main" }, { editor: "vscode", sshHost: "coder-vscode.dev.coder.com--foo--bar." }, ])("rejects malformed current or legacy authority", ({ editor, sshHost }) => { - env.uriScheme = editor; + useEditor(editor); expect(() => parseRemoteAuthority(`ssh-remote+${sshHost}`)).toThrow( "Invalid Coder SSH authority", ); @@ -79,7 +76,7 @@ describe("parseRemoteAuthority", () => { it("ignores unrelated and malformed foreign authorities", () => { expect(parseRemoteAuthority("github.com")).toBeNull(); expect(parseRemoteAuthority("ssh-remote+coder-vscode")).toBeNull(); - env.uriScheme = "cursor"; + useEditor("cursor"); expect( parseRemoteAuthority("ssh-remote+coder-devin.dev.coder.com--foo"), ).toBeNull(); @@ -88,7 +85,7 @@ describe("parseRemoteAuthority", () => { it.each(["vscode", "cursor"])( "ignores deployment-unaware historical hosts in %s", (editor) => { - env.uriScheme = editor; + useEditor(editor); // Old versions created hosts matching the preserved `Host coder-vscode--*` block. expect( parseRemoteAuthority("ssh-remote+coder-vscode--user--workspace.main"), @@ -141,7 +138,7 @@ describe("parseRemoteAuthority", () => { authority: `attached-container+def@dev-container+abc@${CURSOR_AUTHORITY}`, }, ])("parses $label wrapper", ({ authority }) => { - env.uriScheme = "cursor"; + useEditor("cursor"); expect(parseRemoteAuthority(authority)).toStrictEqual( parts("coder-cursor"), ); @@ -150,7 +147,7 @@ describe("parseRemoteAuthority", () => { describe("authority construction", () => { it("preserves the editor URI scheme and integrates with toSafeHost", () => { - env.uriScheme = "cursor--dev"; + useEditor("cursor--dev"); expect( toRemoteAuthority("https://ほげ", "alice", "workspace", "main"), ).toBe("ssh-remote+coder-cursor--dev.xn--18j4d--alice--workspace.main"); @@ -163,7 +160,7 @@ describe("authority construction", () => { }); it("formats the current host prefix", () => { - env.uriScheme = "vscode-insiders"; + useEditor("vscode-insiders"); expect( parseRemoteAuthority( "ssh-remote+coder-vscode-insiders.dev.coder.com--foo--bar", @@ -172,42 +169,14 @@ describe("authority construction", () => { }); it("rejects an empty editor URI scheme at prefix construction", () => { - env.uriScheme = ""; + useEditor(""); expect(() => toRemoteAuthority("https://dev.coder.com", "foo", "bar", undefined), ).toThrow("must not be empty"); }); }); -describe("authority migration", () => { - it("leaves unrelated authorities unchanged", () => { - expect(retargetRemoteAuthority("github.com")).toBe("github.com"); - }); - - interface RetargetCase { - label: string; - authority: string; - expected: string; - } - it.each([ - { - label: "plain", - authority: LEGACY_AUTHORITY, - expected: CURSOR_AUTHORITY, - }, - { - label: "multiply nested", - authority: `attached-container+def@dev-container+abc@${LEGACY_AUTHORITY}`, - expected: `attached-container+def@dev-container+abc@${CURSOR_AUTHORITY}`, - }, - ])( - "preserves the $label wrapper while retargeting", - ({ authority, expected }) => { - env.uriScheme = "cursor"; - expect(retargetRemoteAuthority(authority)).toBe(expected); - }, - ); - +describe("legacy authority compatibility", () => { interface CompatibilityCase { label: string; authority: string | undefined; @@ -229,9 +198,62 @@ describe("authority migration", () => { expected: false, }, ])("requires exact compatibility for $label", ({ authority, expected }) => { - env.uriScheme = "cursor"; + useEditor("cursor"); expect(isRemoteAuthorityCompatible(authority, CURSOR_AUTHORITY)).toBe( expected, ); }); + + it.each([ + { label: "plain", authority: CURSOR_AUTHORITY, expected: LEGACY_AUTHORITY }, + { + label: "multiply nested", + authority: `attached-container+def@dev-container+abc@${CURSOR_AUTHORITY}`, + expected: `attached-container+def@dev-container+abc@${LEGACY_AUTHORITY}`, + }, + { + label: "already legacy", + authority: LEGACY_AUTHORITY, + expected: LEGACY_AUTHORITY, + }, + { label: "foreign", authority: DEVIN_AUTHORITY, expected: DEVIN_AUTHORITY }, + { label: "non-remote", authority: "github.com", expected: "github.com" }, + ])( + "moves the $label authority to the legacy host", + ({ authority, expected }) => { + useEditor("cursor"); + expect(toLegacyAuthority(authority)).toBe(expected); + }, + ); +}); + +describe("hostEditorId", () => { + interface HostEditorCase { + editor: string; + sshHost: string; + expected: string; + } + it.each([ + { + editor: "cursor", + sshHost: "coder-cursor.dev--foo--bar", + expected: "cursor", + }, + { + editor: "cursor", + sshHost: "coder-vscode.dev--foo--bar", + expected: "vscode", + }, + { + editor: "vscode", + sshHost: "coder-vscode.dev--foo--bar", + expected: "vscode", + }, + ])( + "$editor serves $sshHost from $expected's file", + ({ editor, sshHost, expected }) => { + useEditor(editor); + expect(hostEditorId(sshHost)).toBe(expected); + }, + ); }); From 6208cb37f558b4fec833c6f0041ec5d6d642a482 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 26 Aug 2026 01:40:31 +0300 Subject: [PATCH 2/2] chore: release v1.16.2 (#1096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v1.16.2, stacked on #1092: bumps `package.json` to 1.16.2 and cuts the changelog's `Unreleased` section as the v1.16.2 release. ## Changes - `package.json`: version 1.16.1 → 1.16.2 - `CHANGELOG.md`: `## Unreleased` → `## [v1.16.2](https://github.com/coder/vscode-coder/releases/tag/v1.16.2) 2026-08-25` ## Notes - Based on `fix/keep-legacy-coder-authority` (#1092), so its fixes are what v1.16.2 ships. **Retarget this PR to `main` once #1092 merges.** - The release tag must point at a commit where `package.json` reads 1.16.2 — `release.yaml` validates tag against `package.json` and requires the tag to be an ancestor of `origin/main`. This pull request was created by Coder Agents on behalf of @EhabY. --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f894917fe8..135c3fe27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> -## Unreleased +## [v1.16.2](https://github.com/coder/vscode-coder/releases/tag/v1.16.2) 2026-08-25 ### Fixed diff --git a/package.json b/package.json index 6de3d32ecc..203b9b23d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.16.1", + "version": "1.16.2", "description": "Open any workspace with a single click.", "categories": [ "Other"