Skip to content

Commit b11a50f

Browse files
committed
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.
1 parent 363ce20 commit b11a50f

14 files changed

Lines changed: 623 additions & 149 deletions

CHANGELOG.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,26 @@
99

1010
### Fixed
1111

12-
- Keep opening a workspace on the SSH host it already uses. v1.16.1 moved
13-
existing workspaces onto a host named after your editor, and since an editor
14-
identifies a workspace by an address that includes the host name, the
15-
workspace looked new: Cursor's chats, your window layout, and anything else
16-
kept per workspace appeared to be gone. Nothing was deleted, and a workspace
17-
that already moved comes back when you reopen it from **File > Open Recent**.
18-
- Opening a workspace from the Coder panel reuses the host it was last opened
19-
on, so it keeps its history instead of starting over.
12+
- Reopen a workspace on the SSH host it was last opened on. v1.16.1 moved
13+
existing workspaces onto a host named after your editor, and an editor
14+
remembers a window by its full address, host included, so the workspace
15+
looked new: Cursor's chats, your window layout, and anything else kept per
16+
workspace appeared to be gone. Nothing was deleted. The host now follows the
17+
workspace wherever you open it from: the Coder panel, a dashboard or
18+
devcontainer link, or **File > Open Recent**, and whether it opens as a
19+
folder or as a multi-root workspace. Only a workspace you have never opened
20+
gets your editor's host.
21+
- Mark the shared `coder-vscode` host as `(legacy)` in **File > Open Recent**,
22+
so a workspace listed on both hosts is no longer two identical lines. The
23+
folder picker lists each folder once.
24+
- Include a workspace's remote editor logs in its support bundle when
25+
`remote.SSH.serverInstallPath` is set for the shared host. The bundle looked
26+
that setting up under a host named after your editor, found nothing, and fell
27+
back to the default location.
2028
- Serve the shared `coder-vscode` host from one generated SSH config file
21-
rather than one per editor, so a connection over it always uses the CLI and
22-
credentials of the editor that started it.
29+
instead of one per editor, so a connection over it always uses the CLI and
30+
credentials of the editor that started it. **Coder: Open Generated SSH
31+
Configuration File** can open that file too.
2332

2433
## [v1.16.1](https://github.com/coder/vscode-coder/releases/tag/v1.16.1) 2026-08-24
2534

src/commands.ts

Lines changed: 102 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@ import {
5151
} from "./supportBundle/remoteServerDataPath";
5252
import { runExportTelemetryCommand } from "./telemetry/export/command";
5353
import {
54+
LegacyEditorId,
55+
currentEditorId,
5456
hostEditorId,
5557
isRemoteAuthorityCompatible,
5658
parseRemoteAuthority,
59+
sshHostOf,
60+
toLegacyAuthority,
5761
toRemoteAuthority,
5862
} from "./util/authority";
5963
import { openInBrowser, toSafeHost } from "./util/uri";
@@ -88,6 +92,12 @@ import type {
8892
PongMessage,
8993
} from "./workspace/duplicateWorkspaceIpc";
9094

95+
/** One entry from the private `_workbench.getRecentlyOpened` command. */
96+
interface RecentlyOpened {
97+
folderUri?: vscode.Uri;
98+
workspace?: { configPath?: vscode.Uri };
99+
}
100+
91101
const NO_SSH_CONFIG_MESSAGE =
92102
"No SSH config has been generated yet. It is written when you connect to a workspace.";
93103

@@ -573,15 +583,12 @@ export class Commands {
573583
);
574584
}
575585

576-
/** Open this editor's generated SSH config, picking a deployment when several exist. */
586+
/** Open the generated SSH config, picking a file when several exist. */
577587
public async openSshConfig(): Promise<void> {
578-
let configPath = this.connectedSshConfigPath();
588+
const configPath =
589+
this.connectedSshConfigPath() ?? (await this.pickSshConfigPath());
579590
if (!configPath) {
580-
const hostname = await this.pickSshHostname();
581-
if (!hostname) {
582-
return;
583-
}
584-
configPath = this.pathResolver.getSshConfigPath(hostname);
591+
return;
585592
}
586593
try {
587594
await openFile(configPath);
@@ -613,24 +620,33 @@ export class Commands {
613620
}
614621
}
615622

616-
/** Ask which deployment, when this editor has generated more than one config. */
617-
private async pickSshHostname(): Promise<string | undefined> {
618-
const hostnames = (
619-
await readdirOrEmpty(this.pathResolver.getSshConfigDir())
620-
)
621-
.map((file) => this.pathResolver.parseSshConfigFile(file))
622-
.filter((name) => name !== undefined);
623-
if (hostnames.length === 0) {
623+
/** Ask which config to open, of this editor's prefixes and the legacy one. */
624+
private async pickSshConfigPath(): Promise<string | undefined> {
625+
const files = await readdirOrEmpty(this.pathResolver.getSshConfigDir());
626+
const items = [...new Set([currentEditorId(), LegacyEditorId])].flatMap(
627+
(editorId) =>
628+
files
629+
.map((file) => this.pathResolver.parseSshConfigFile(file, editorId))
630+
.filter((safeHostname) => safeHostname !== undefined)
631+
.map((safeHostname) => ({
632+
label: safeHostname,
633+
// Which hosts it serves; two files can share a deployment.
634+
description: `coder-${editorId}.${safeHostname}--*`,
635+
path: this.pathResolver.getSshConfigPath(safeHostname, editorId),
636+
})),
637+
);
638+
if (items.length === 0) {
624639
vscode.window.showInformationMessage(NO_SSH_CONFIG_MESSAGE);
625640
return undefined;
626641
}
627-
if (hostnames.length === 1) {
628-
return hostnames[0];
642+
if (items.length === 1) {
643+
return items[0].path;
629644
}
630-
return vscode.window.showQuickPick(hostnames, {
645+
const picked = await vscode.window.showQuickPick(items, {
631646
title: "Open generated SSH configuration",
632647
placeHolder: "Select a deployment",
633648
});
649+
return picked?.path;
634650
}
635651

636652
/**
@@ -1118,6 +1134,39 @@ export class Commands {
11181134
);
11191135
}
11201136

1137+
/** Recently opened folders, and every entry including workspace files. */
1138+
private async recentlyOpened(): Promise<{
1139+
folders: vscode.Uri[];
1140+
entries: vscode.Uri[];
1141+
}> {
1142+
let recents: RecentlyOpened[] = [];
1143+
try {
1144+
// Private command; without it there is just no history to reuse.
1145+
const output: { workspaces?: RecentlyOpened[] } =
1146+
await vscode.commands.executeCommand("_workbench.getRecentlyOpened");
1147+
recents = output?.workspaces ?? [];
1148+
} catch (error) {
1149+
this.logger.warn("Failed to read recently opened folders", error);
1150+
}
1151+
return {
1152+
folders: recents.flatMap((recent) => recent.folderUri ?? []),
1153+
entries: recents.flatMap(
1154+
(recent) => recent.folderUri ?? recent.workspace?.configPath ?? [],
1155+
),
1156+
};
1157+
}
1158+
1159+
/** The host this workspace was last opened on, or this editor's own. */
1160+
private reusableAuthority(recents: vscode.Uri[], target: string): string {
1161+
const legacyAuthority = toLegacyAuthority(target);
1162+
const legacyHost = sshHostOf(legacyAuthority);
1163+
const currentHost = sshHostOf(target);
1164+
const lastUsed = recents
1165+
.map((uri) => sshHostOf(uri.authority))
1166+
.find((host) => host === currentHost || host === legacyHost);
1167+
return lastUsed === legacyHost ? legacyAuthority : target;
1168+
}
1169+
11211170
private async runOpenDevContainer(
11221171
workspaceOwner: string,
11231172
workspaceName: string,
@@ -1155,20 +1204,25 @@ export class Commands {
11551204
).toString("hex");
11561205

11571206
const type = localWorkspaceFolder ? "dev-container" : "attached-container";
1158-
const devContainerAuthority = `${type}+${devContainer}@${remoteAuthority}`;
1207+
const target = `${type}+${devContainer}@${remoteAuthority}`;
11591208

11601209
let newWindow = true;
11611210
if (!vscode.workspace.workspaceFolders?.length) {
11621211
newWindow = false;
11631212
}
11641213

1214+
const { entries } = await this.recentlyOpened();
1215+
const authority = this.reusableAuthority(entries, target);
1216+
1217+
this.logger.info("Opening devcontainer", { remoteAuthority: authority });
1218+
11651219
// Only set the memento when opening a new folder
11661220
await this.mementoManager.setStartupMode("start");
11671221
await vscode.commands.executeCommand(
11681222
"vscode.openFolder",
11691223
vscode.Uri.from({
11701224
scheme: "vscode-remote",
1171-
authority: devContainerAuthority,
1225+
authority,
11721226
path: devContainerFolder,
11731227
}),
11741228
newWindow,
@@ -1263,7 +1317,7 @@ export class Commands {
12631317
agentName,
12641318
client: this.extensionClient,
12651319
workspaceId: createWorkspaceIdentifier(item.workspace),
1266-
remoteAuthority: this.toWorkspaceAuthority(
1320+
remoteAuthority: await this.toWorkspaceAuthority(
12671321
this.extensionClient,
12681322
item.workspace,
12691323
agentName,
@@ -1290,7 +1344,7 @@ export class Commands {
12901344
status: "selected",
12911345
client: this.extensionClient,
12921346
workspaceId: createWorkspaceIdentifier(pick.workspace),
1293-
remoteAuthority: this.toWorkspaceAuthority(
1347+
remoteAuthority: await this.toWorkspaceAuthority(
12941348
this.extensionClient,
12951349
pick.workspace,
12961350
),
@@ -1303,20 +1357,24 @@ export class Commands {
13031357
* Reconstruct the authority Remote-SSH would use, defaulting to the
13041358
* first agent like the CLI does.
13051359
*/
1306-
private toWorkspaceAuthority(
1360+
private async toWorkspaceAuthority(
13071361
client: CoderApi,
13081362
workspace: Workspace,
13091363
agentName?: string,
1310-
): string | undefined {
1364+
): Promise<string | undefined> {
13111365
const baseUrl = client.getAxiosInstance().defaults.baseURL;
13121366
if (!baseUrl) {
13131367
return undefined;
13141368
}
1315-
return toRemoteAuthority(
1316-
baseUrl,
1317-
workspace.owner_name,
1318-
workspace.name,
1319-
agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name,
1369+
const { entries } = await this.recentlyOpened();
1370+
return this.reusableAuthority(
1371+
entries,
1372+
toRemoteAuthority(
1373+
baseUrl,
1374+
workspace.owner_name,
1375+
workspace.name,
1376+
agentName ?? extractAgents(workspace.latest_build.resources)[0]?.name,
1377+
),
13201378
);
13211379
}
13221380

@@ -1537,38 +1595,35 @@ export class Commands {
15371595
folderPath = agent.expanded_directory;
15381596
}
15391597

1598+
const { folders, entries } = await this.recentlyOpened();
15401599
// If the agent had no folder or we have been asked to open the most recent,
15411600
// we can try to open a recently opened folder/workspace.
15421601
if (!folderPath || openRecent) {
1543-
const output: {
1544-
workspaces: Array<{ folderUri: vscode.Uri; remoteAuthority: string }>;
1545-
} = await vscode.commands.executeCommand("_workbench.getRecentlyOpened");
1546-
const opened = output.workspaces.filter((opened) =>
1547-
isRemoteAuthorityCompatible(
1548-
opened.folderUri?.authority,
1549-
remoteAuthority,
1602+
// One entry per folder: the same path can be in the list once per host.
1603+
const paths = [
1604+
...new Set(
1605+
folders
1606+
.filter((uri) =>
1607+
isRemoteAuthorityCompatible(uri.authority, remoteAuthority),
1608+
)
1609+
.map((uri) => uri.path),
15501610
),
1551-
);
1611+
];
15521612
// openRecent will always use the most recent. Otherwise, if there are
15531613
// multiple we ask the user which to use.
1554-
if (opened.length === 1 || (opened.length > 1 && openRecent)) {
1555-
folderPath = opened[0].folderUri.path;
1556-
} else if (opened.length > 1) {
1557-
const items = opened.map((f) => f.folderUri.path);
1558-
folderPath = await vscode.window.showQuickPick(items, {
1614+
if (paths.length === 1 || (paths.length > 1 && openRecent)) {
1615+
folderPath = paths[0];
1616+
} else if (paths.length > 1) {
1617+
folderPath = await vscode.window.showQuickPick(paths, {
15591618
title: "Select a recently opened folder",
15601619
});
15611620
if (!folderPath) {
15621621
// User aborted.
15631622
return { status: "cancelled", stage: "recent_folder_picker" };
15641623
}
15651624
}
1566-
// A compatible folder can still be on the legacy coder-vscode host.
1567-
// Reopen it there, since the editor keys window state by the whole URI.
1568-
remoteAuthority =
1569-
opened.find((f) => f.folderUri.path === folderPath)?.folderUri
1570-
.authority ?? remoteAuthority;
15711625
}
1626+
remoteAuthority = this.reusableAuthority(entries, remoteAuthority);
15721627

15731628
// Only set the memento when opening a new folder/window
15741629
await this.mementoManager.setStartupMode("start");

src/core/pathResolver.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,12 @@ export class PathResolver {
8686
);
8787
}
8888

89-
/** The deployment hostname if this editor generated the file, else undefined. */
90-
public parseSshConfigFile(fileName: string): string | undefined {
91-
const prefix = `${currentEditorId()}--`;
89+
/** The deployment hostname if `editorId` named the file, else undefined. */
90+
public parseSshConfigFile(
91+
fileName: string,
92+
editorId: string = currentEditorId(),
93+
): string | undefined {
94+
const prefix = `${editorId}--`;
9295
return fileName.startsWith(prefix) && fileName.endsWith(SSH_CONFIG_EXT)
9396
? fileName.slice(prefix.length, -SSH_CONFIG_EXT.length)
9497
: undefined;

src/remote/remote.ts

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ import { getHeaderCommand } from "../settings/headers";
4040
import { escapeCommandArg, expandPath } from "../util";
4141
import {
4242
type AuthorityParts,
43+
classifySshHost,
4344
hostEditorId,
4445
parseRemoteAuthority,
46+
sshHostOf,
4547
} from "../util/authority";
4648
import { createStatusBarItem } from "../util/statusBar";
4749
import { vscodeProposed } from "../vscodeProposed";
@@ -106,6 +108,29 @@ interface RemoteSetupContext {
106108
disposables: vscode.Disposable[];
107109
}
108110

111+
/**
112+
* What Open Recent shows after the path. VS Code splits the label on the
113+
* separator, so "/" would display "/home/kyle [Coder: kyle/workspace]" as
114+
* "workspace] /home/kyle [Coder: kyle"; "∕" looks the same in the UI font.
115+
*/
116+
export function workspaceLabelSuffix(
117+
remoteAuthority: string,
118+
owner: string,
119+
workspace: string,
120+
agent?: string,
121+
): string {
122+
let suffix = `Coder: ${owner}${workspace}`;
123+
if (agent) {
124+
suffix += `∕${agent}`;
125+
}
126+
// Mark the shared host, so a workspace with an entry on each is not two
127+
// identical lines. Only a fork sees it; in VS Code it is the only host.
128+
const sshHost = sshHostOf(remoteAuthority);
129+
return sshHost && classifySshHost(sshHost) === "legacy"
130+
? `${suffix} (legacy)`
131+
: suffix;
132+
}
133+
109134
export class Remote {
110135
private readonly logger: Logger;
111136
private readonly pathResolver: PathResolver;
@@ -1082,16 +1107,6 @@ export class Remote {
10821107
workspace: string,
10831108
agent?: string,
10841109
): vscode.Disposable {
1085-
// VS Code splits based on the separator when displaying the label
1086-
// in a recently opened dialog. If the workspace suffix contains /,
1087-
// then it'll visually display weird:
1088-
// "/home/kyle [Coder: kyle/workspace]" displays as "workspace] /home/kyle [Coder: kyle"
1089-
// For this reason, we use a different / that visually appears the
1090-
// same on non-monospace fonts "∕".
1091-
let suffix = `Coder: ${owner}${workspace}`;
1092-
if (agent) {
1093-
suffix += `∕${agent}`;
1094-
}
10951110
// VS Code caches resource label formatters in it's global storage SQLite database
10961111
// under the key "memento/cachedResourceLabelFormatters2".
10971112
return vscodeProposed.workspace.registerResourceLabelFormatter({
@@ -1103,7 +1118,12 @@ export class Remote {
11031118
label: "${path}",
11041119
separator: "/",
11051120
tildify: true,
1106-
workspaceSuffix: suffix,
1121+
workspaceSuffix: workspaceLabelSuffix(
1122+
remoteAuthority,
1123+
owner,
1124+
workspace,
1125+
agent,
1126+
),
11071127
},
11081128
});
11091129
}

0 commit comments

Comments
 (0)