From 7b8e058af6f9fa43148f9df6ee0d0d496dea8dd9 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 25 Aug 2026 13:15:53 +0300 Subject: [PATCH 1/3] fix: keep legacy Coder authorities instead of reopening onto per-editor hosts Reopening a legacy window on this editor's own SSH host changed the remote authority, and editors key per-workspace state to it, so Cursor's agent chats and other window state looked lost after v1.16.1. - Drop the automatic migration; a coder-vscode authority stays supported and keeps the workspace identity it already had. - Name a generated config file after the editor in the host prefix rather than the one writing it, so the legacy prefix is served by a single file. Two files declaring the same host pattern left glob order, not the connecting editor, deciding which CLI and credentials ran. - Let tests pick an editor through the vscode mock instead of overwriting env.uriScheme, restoring the previous one when the test ends. --- CHANGELOG.md | 14 +++ src/commands.ts | 39 ++++--- src/core/pathResolver.ts | 13 ++- src/remote/remote.ts | 84 +-------------- src/util/authority.ts | 17 ++-- test/mocks/testHelpers.ts | 16 ++- test/mocks/vscode.runtime.ts | 13 ++- test/unit/core/pathResolver.test.ts | 10 +- test/unit/remote/remote.test.ts | 153 +++++++--------------------- test/unit/util/authority.test.ts | 64 ++++++++---- 10 files changed, 183 insertions(+), 240 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a5e70d79..e2912e4db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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 + +- Keep opening a workspace on the SSH host it already uses. v1.16.1 moved + existing workspaces onto a host named after your editor, and since an editor + identifies a workspace by an address that includes the host name, the + workspace looked new: Cursor's chats, your window layout, and anything else + kept per workspace appeared to be gone. Nothing was deleted, and a workspace + that already moved comes back when you reopen it from **File > Open Recent**. +- Serve the shared `coder-vscode` host from one generated SSH config file + rather than one per editor, so a connection over it always uses the CLI and + credentials of the editor that started it. + ## [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..b70bf5a89a 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -51,6 +51,7 @@ import { } from "./supportBundle/remoteServerDataPath"; import { runExportTelemetryCommand } from "./telemetry/export/command"; import { + hostEditorId, isRemoteAuthorityCompatible, parseRemoteAuthority, toRemoteAuthority, @@ -574,12 +575,16 @@ export class Commands { /** Open this editor's generated SSH config, picking a deployment when several exist. */ public async openSshConfig(): Promise { - const hostname = await this.pickSshHostname(); - if (!hostname) { - return; + let configPath = this.connectedSshConfigPath(); + if (!configPath) { + const hostname = await this.pickSshHostname(); + if (!hostname) { + return; + } + configPath = this.pathResolver.getSshConfigPath(hostname); } try { - await openFile(this.pathResolver.getSshConfigPath(hostname)); + await openFile(configPath); } catch { vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE); return; @@ -590,20 +595,26 @@ 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; } + } + + /** Ask which deployment, when this editor has generated more than one config. */ + private async pickSshHostname(): Promise { const hostnames = ( await readdirOrEmpty(this.pathResolver.getSshConfigDir()) ) diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index ddd862f41a..63b55b478c 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -71,11 +71,18 @@ 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}`, ); } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index ffcdcfab6e..1100eba5e3 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -40,9 +40,8 @@ import { getHeaderCommand } from "../settings/headers"; import { escapeCommandArg, expandPath } from "../util"; import { type AuthorityParts, - classifySshHost, + hostEditorId, parseRemoteAuthority, - retargetRemoteAuthority, } from "../util/authority"; import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; @@ -160,15 +159,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 +716,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 +894,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, ); diff --git a/src/util/authority.ts b/src/util/authority.ts index a2ba9c3b9c..41b21d3510 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"; +export const LegacyAuthorityPrefix = `coder-${LegacyEditorId}`; export interface AuthorityParts { agent: string | undefined; @@ -68,10 +70,13 @@ 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)); } /** @@ -100,7 +105,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); diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 914cc45f4c..7c300ab9c7 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -6,7 +6,7 @@ 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 { SessionStore, type SessionData } from "@/deployment/sessionStore"; @@ -17,7 +17,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, @@ -65,6 +65,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 { 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/core/pathResolver.test.ts b/test/unit/core/pathResolver.test.ts index a6117dc564..796ce540a0 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,6 +127,14 @@ describe("PathResolver", () => { ); }); + 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 this editor's generated files", () => { expect( pathResolver.parseSshConfigFile("vscode--dev.coder.com.conf"), diff --git a/test/unit/remote/remote.test.ts b/test/unit/remote/remote.test.ts index 04d58288c5..5b4f47a634 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -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( diff --git a/test/unit/util/authority.test.ts b/test/unit/util/authority.test.ts index e54ec248e8..5620285e6f 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, 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,14 +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", () => { +describe("legacy authority compatibility", () => { it("leaves unrelated authorities unchanged", () => { expect(retargetRemoteAuthority("github.com")).toBe("github.com"); }); @@ -203,7 +200,7 @@ describe("authority migration", () => { ])( "preserves the $label wrapper while retargeting", ({ authority, expected }) => { - env.uriScheme = "cursor"; + useEditor("cursor"); expect(retargetRemoteAuthority(authority)).toBe(expected); }, ); @@ -229,9 +226,40 @@ describe("authority migration", () => { expected: false, }, ])("requires exact compatibility for $label", ({ authority, expected }) => { - env.uriScheme = "cursor"; + useEditor("cursor"); expect(isRemoteAuthorityCompatible(authority, CURSOR_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 363ce20a3f698856c21bfab1ead8503f594677a4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 25 Aug 2026 13:42:10 +0300 Subject: [PATCH 2/3] fix: reopen a workspace on the host it already used Opening from the Coder panel matched a recent folder with isRemoteAuthorityCompatible, which accepts the legacy coder-vscode authority, but then kept only its path and opened it under a freshly minted per-editor authority. In a fork that moved the folder to a URI the editor had never seen, orphaning the window state stored against the old one. Reuse the matched folder's own authority, so only workspaces with no history get this editor's prefix. --- CHANGELOG.md | 2 + src/commands.ts | 7 +- test/unit/commands.openWorkspace.test.ts | 119 +++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 test/unit/commands.openWorkspace.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2912e4db1..e692c26338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ workspace looked new: Cursor's chats, your window layout, and anything else kept per workspace appeared to be gone. Nothing was deleted, and a workspace that already moved comes back when you reopen it from **File > Open Recent**. +- Opening a workspace from the Coder panel reuses the host it was last opened + on, so it keeps its history instead of starting over. - Serve the shared `coder-vscode` host from one generated SSH config file rather than one per editor, so a connection over it always uses the CLI and credentials of the editor that started it. diff --git a/src/commands.ts b/src/commands.ts index b70bf5a89a..6a160065aa 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1521,7 +1521,7 @@ export class Commands { ...options, }; let { folderPath } = options; - const remoteAuthority = toRemoteAuthority( + let remoteAuthority = toRemoteAuthority( baseUrl, workspace.owner_name, workspace.name, @@ -1563,6 +1563,11 @@ export class Commands { return { status: "cancelled", stage: "recent_folder_picker" }; } } + // A compatible folder can still be on the legacy coder-vscode host. + // Reopen it there, since the editor keys window state by the whole URI. + remoteAuthority = + opened.find((f) => f.folderUri.path === folderPath)?.folderUri + .authority ?? remoteAuthority; } // Only set the memento when opening a new folder/window diff --git a/test/unit/commands.openWorkspace.test.ts b/test/unit/commands.openWorkspace.test.ts new file mode 100644 index 0000000000..dc4420c854 --- /dev/null +++ b/test/unit/commands.openWorkspace.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { Commands } from "@/commands"; + +import { workspace as createWorkspace } from "@repo/mocks"; + +import { createTestTelemetryService } from "../mocks/telemetry"; +import { + createMockLogger, + MockConfigurationProvider, + useEditor, +} from "../mocks/testHelpers"; + +import type { WorkspaceAgent } from "coder/site/src/api/typesGenerated"; + +import type { CoderApi } from "@/api/coderApi"; +import type { ServiceContainer } from "@/core/container"; +import type { DeploymentManager } from "@/deployment/deploymentManager"; + +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 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[]): Promise { + new MockConfigurationProvider(); + const workspaces = recents.map((authority) => ({ + folderUri: vscode.Uri.from({ + scheme: "vscode-remote", + authority, + path: FOLDER, + }), + })); + const executeCommand = vi + .mocked(vscode.commands.executeCommand) + .mockImplementation((command: string) => + Promise.resolve( + command === "_workbench.getRecentlyOpened" ? { workspaces } : undefined, + ), + ); + + // The constructor reads every service, so name only the ones in play. + const services: Record = { + getTelemetryService: createTestTelemetryService(), + getLogger: createMockLogger(), + getMementoManager: { setStartupMode: vi.fn() }, + getDuplicateWorkspaceIpc: { + sendPing: vi.fn().mockResolvedValue(undefined), + }, + }; + const commands = new Commands( + new Proxy({} as ServiceContainer, { + get: (_, name: string) => () => services[name] ?? {}, + }), + { + getAxiosInstance: () => ({ + defaults: { baseURL: "https://dev.coder.com" }, + }), + } as unknown as CoderApi, + {} as DeploymentManager, + ); + const { AgentTreeItem } = await import("@/workspace/workspacesProvider"); + await commands.openFromSidebar( + new AgentTreeItem( + AGENT, + createWorkspace({ owner_name: "foo", name: "bar" }), + ), + ); + + // A folder is handed off by URI, an empty window by option. + const [, handoff] = + executeCommand.mock.calls.find(([command]) => + ["vscode.openFolder", "vscode.newWindow"].includes(command), + ) ?? []; + return handoff instanceof vscode.Uri + ? handoff.authority + : (handoff as { remoteAuthority?: string } | undefined)?.remoteAuthority; +} + +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, + }, + ])("reopens the workspace on $label", async ({ recents, expected }) => { + expect(await openFromSidebar(recents)).toBe(expected); + }); +}); From 9ef304c519dde670651e6d352df1f12cf3a56daf Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 26 Aug 2026 00:35:28 +0300 Subject: [PATCH 3/3] fix: keep a workspace on the host it was last opened on Reuse was keyed on an exact folder path, which left holes. A multi-root workspace is recorded by its .code-workspace file and so has no folder URI to match; a devcontainer authority carries a container payload that only matches the identical container; and a folder the agent supplies has no entry at all until it is opened once. Each fell through to a host named after this editor while the rest of the workspace sat on the legacy one, and since an editor keys window state by the whole URI, the workspace looked like two. Decide per workspace instead: whichever of the two hosts a recent entry last connected over is the one we open on. A compatible entry can only be on this editor's host or the legacy one, so matching paths was never choosing between more than those two -- dropping it removes code and closes the holes. - Read the .code-workspace entries the private recents command returns, and tolerate an editor without that command instead of failing the open. - Look up the same host when guessing an authority for a support bundle, and try both hosts against remote.SSH.serverInstallPath, so a bundle for a workspace on the legacy host still finds its remote logs. - Swap an authority's host prefix in one place, so retargetRemoteAuthority and its new inverse toLegacyAuthority cannot drift apart. - List the legacy file in Coder: Open Generated SSH Configuration File, which walked only this editor's prefix, so a local window could never open it. --- CHANGELOG.md | 29 ++-- src/commands.ts | 153 ++++++++++++++------ src/core/pathResolver.ts | 9 +- src/remote/remote.ts | 42 ++++-- src/util/authority.ts | 25 +++- test/mocks/testHelpers.ts | 64 ++++++++ test/unit/commands.openDevContainer.test.ts | 70 +++++++++ test/unit/commands.openSshConfig.test.ts | 87 +++++++++++ test/unit/commands.openWorkspace.test.ts | 130 ++++++++++------- test/unit/core/pathResolver.test.ts | 5 +- test/unit/remote/remote.test.ts | 45 +++++- test/unit/util/authority.test.ts | 52 +++---- 12 files changed, 548 insertions(+), 163 deletions(-) create mode 100644 test/unit/commands.openDevContainer.test.ts create mode 100644 test/unit/commands.openSshConfig.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e692c26338..f894917fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,17 +9,26 @@ ### Fixed -- Keep opening a workspace on the SSH host it already uses. v1.16.1 moved - existing workspaces onto a host named after your editor, and since an editor - identifies a workspace by an address that includes the host name, the - workspace looked new: Cursor's chats, your window layout, and anything else - kept per workspace appeared to be gone. Nothing was deleted, and a workspace - that already moved comes back when you reopen it from **File > Open Recent**. -- Opening a workspace from the Coder panel reuses the host it was last opened - on, so it keeps its history instead of starting over. +- 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 - rather than one per editor, so a connection over it always uses the CLI and - credentials of the editor that started it. + 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 diff --git a/src/commands.ts b/src/commands.ts index 6a160065aa..8f9268b6d5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -51,9 +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"; @@ -88,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."; @@ -573,15 +583,12 @@ 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 { - let configPath = this.connectedSshConfigPath(); + const configPath = + this.connectedSshConfigPath() ?? (await this.pickSshConfigPath()); if (!configPath) { - const hostname = await this.pickSshHostname(); - if (!hostname) { - return; - } - configPath = this.pathResolver.getSshConfigPath(hostname); + return; } try { await openFile(configPath); @@ -613,24 +620,33 @@ export class Commands { } } - /** Ask which deployment, when this editor has generated more than one config. */ - private async pickSshHostname(): Promise { - 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; } /** @@ -1118,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, @@ -1155,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, @@ -1263,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, @@ -1290,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, ), @@ -1303,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, + ), ); } @@ -1537,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) { @@ -1563,12 +1626,8 @@ export class Commands { return { status: "cancelled", stage: "recent_folder_picker" }; } } - // A compatible folder can still be on the legacy coder-vscode host. - // Reopen it there, since the editor keys window state by the whole URI. - remoteAuthority = - opened.find((f) => f.folderUri.path === folderPath)?.folderUri - .authority ?? remoteAuthority; } + 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 63b55b478c..aa47edb461 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -86,9 +86,12 @@ export class PathResolver { ); } - /** 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 1100eba5e3..612da18f57 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -40,8 +40,10 @@ import { getHeaderCommand } from "../settings/headers"; import { escapeCommandArg, expandPath } from "../util"; import { type AuthorityParts, + classifySshHost, hostEditorId, parseRemoteAuthority, + sshHostOf, } from "../util/authority"; import { createStatusBarItem } from "../util/statusBar"; import { vscodeProposed } from "../vscodeProposed"; @@ -106,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; @@ -1082,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({ @@ -1103,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 41b21d3510..1120460ed1 100644 --- a/src/util/authority.ts +++ b/src/util/authority.ts @@ -4,7 +4,7 @@ import { toSafeHost } from "./uri"; /** The editor every host was named after before per-editor prefixes existed. */ export const LegacyEditorId = "vscode"; -export const LegacyAuthorityPrefix = `coder-${LegacyEditorId}`; +const LegacyAuthorityPrefix = `coder-${LegacyEditorId}`; export interface AuthorityParts { agent: string | undefined; @@ -79,6 +79,12 @@ 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); +} + /** * Given an authority, parse into the expected parts. * @@ -154,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( @@ -176,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 7c300ab9c7..708440cbb0 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -9,6 +9,7 @@ import * as fs from "node:fs/promises"; import { onTestFinished, vi } from "vitest"; import * as vscode from "vscode"; +import { Commands } from "@/commands"; import { SessionStore, type SessionData } from "@/deployment/sessionStore"; import { @@ -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"; @@ -651,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/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 index dc4420c854..2f7093d61f 100644 --- a/test/unit/commands.openWorkspace.test.ts +++ b/test/unit/commands.openWorkspace.test.ts @@ -1,23 +1,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { Commands } from "@/commands"; - -import { workspace as createWorkspace } from "@repo/mocks"; +import { + agent as createAgent, + resource as createResource, + workspace as createWorkspace, +} from "@repo/mocks"; -import { createTestTelemetryService } from "../mocks/telemetry"; import { - createMockLogger, + createTestCommands, MockConfigurationProvider, + mockRecentlyOpened, + openedAuthority, useEditor, } from "../mocks/testHelpers"; import type { WorkspaceAgent } from "coder/site/src/api/typesGenerated"; -import type { CoderApi } from "@/api/coderApi"; -import type { ServiceContainer } from "@/core/container"; -import type { DeploymentManager } from "@/deployment/deploymentManager"; - vi.mock("@/workspace/workspacesProvider", () => ({ AgentTreeItem: class { constructor( @@ -30,6 +29,7 @@ vi.mock("@/workspace/workspacesProvider", () => ({ 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"; @@ -38,43 +38,14 @@ 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[]): Promise { +async function openFromSidebar( + recents: string[], + path = FOLDER, + kind?: "folder" | "workspaceFile", +): Promise { new MockConfigurationProvider(); - const workspaces = recents.map((authority) => ({ - folderUri: vscode.Uri.from({ - scheme: "vscode-remote", - authority, - path: FOLDER, - }), - })); - const executeCommand = vi - .mocked(vscode.commands.executeCommand) - .mockImplementation((command: string) => - Promise.resolve( - command === "_workbench.getRecentlyOpened" ? { workspaces } : undefined, - ), - ); - - // The constructor reads every service, so name only the ones in play. - const services: Record = { - getTelemetryService: createTestTelemetryService(), - getLogger: createMockLogger(), - getMementoManager: { setStartupMode: vi.fn() }, - getDuplicateWorkspaceIpc: { - sendPing: vi.fn().mockResolvedValue(undefined), - }, - }; - const commands = new Commands( - new Proxy({} as ServiceContainer, { - get: (_, name: string) => () => services[name] ?? {}, - }), - { - getAxiosInstance: () => ({ - defaults: { baseURL: "https://dev.coder.com" }, - }), - } as unknown as CoderApi, - {} as DeploymentManager, - ); + mockRecentlyOpened(recents, path, kind); + const commands = createTestCommands({ baseUrl: BASE_URL }); const { AgentTreeItem } = await import("@/workspace/workspacesProvider"); await commands.openFromSidebar( new AgentTreeItem( @@ -82,15 +53,37 @@ async function openFromSidebar(recents: string[]): Promise { createWorkspace({ owner_name: "foo", name: "bar" }), ), ); + return openedAuthority(); +} - // A folder is handed off by URI, an empty window by option. - const [, handoff] = - executeCommand.mock.calls.find(([command]) => - ["vscode.openFolder", "vscode.newWindow"].includes(command), - ) ?? []; - return handoff instanceof vscode.Uri - ? handoff.authority - : (handoff as { remoteAuthority?: string } | undefined)?.remoteAuthority; +/** 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", () => { @@ -113,7 +106,38 @@ describe("openWorkspace", () => { 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 796ce540a0..4361ccdbab 100644 --- a/test/unit/core/pathResolver.test.ts +++ b/test/unit/core/pathResolver.test.ts @@ -135,10 +135,13 @@ describe("PathResolver", () => { ); }); - it("parses the hostname only from this editor's generated files", () => { + 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 5b4f47a634..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 { @@ -227,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 5620285e6f..555b3e3048 100644 --- a/test/unit/util/authority.test.ts +++ b/test/unit/util/authority.test.ts @@ -7,7 +7,7 @@ import { hostEditorId, isRemoteAuthorityCompatible, parseRemoteAuthority, - retargetRemoteAuthority, + toLegacyAuthority, toRemoteAuthority, } from "@/util/authority"; @@ -177,34 +177,6 @@ describe("authority construction", () => { }); describe("legacy authority compatibility", () => { - 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 }) => { - useEditor("cursor"); - expect(retargetRemoteAuthority(authority)).toBe(expected); - }, - ); - interface CompatibilityCase { label: string; authority: string | undefined; @@ -231,6 +203,28 @@ describe("legacy authority compatibility", () => { 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", () => {