Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 123 additions & 48 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.";

Expand Down Expand Up @@ -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<void> {
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;
Expand All @@ -590,36 +602,51 @@ export class Commands {
);
}

/** A connected window resolves to its own deployment; otherwise ask. */
private async pickSshHostname(): Promise<string | undefined> {
/** 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<string | undefined> {
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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
Expand All @@ -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<string | undefined> {
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,
),
);
}

Expand Down Expand Up @@ -1510,7 +1583,7 @@ export class Commands {
...options,
};
let { folderPath } = options;
const remoteAuthority = toRemoteAuthority(
let remoteAuthority = toRemoteAuthority(
baseUrl,
workspace.owner_name,
workspace.name,
Expand All @@ -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) {
Expand All @@ -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");
Expand Down
22 changes: 16 additions & 6 deletions src/core/pathResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading