Skip to content

Commit 5f412e0

Browse files
committed
fix: reopen a workspace on the host it already used
Opening from the Coder panel matched a recent folder with isRemoteAuthorityCompatible, which accepts a legacy 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.
1 parent 1ee92a3 commit 5f412e0

3 files changed

Lines changed: 162 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
changed the remote authority, and editors key per-workspace state to it —
1414
Cursor's agent chats among them — so that state looked lost. Workspaces
1515
opened before v1.16.1 keep the host they already had.
16+
- Reopen a workspace on the host it already used when you open it from the
17+
Coder panel, rather than moving it to this editor's own host and starting its
18+
window state over.
1619
- Serve the historical `coder-vscode` host from a single generated file rather
1720
than one per editor, so a connection over it always uses the CLI and
1821
credentials of the editor that is connecting.

src/commands.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,7 @@ export class Commands {
15211521
...options,
15221522
};
15231523
let { folderPath } = options;
1524-
const remoteAuthority = toRemoteAuthority(
1524+
let remoteAuthority = toRemoteAuthority(
15251525
baseUrl,
15261526
workspace.owner_name,
15271527
workspace.name,
@@ -1551,17 +1551,26 @@ export class Commands {
15511551
);
15521552
// openRecent will always use the most recent. Otherwise, if there are
15531553
// multiple we ask the user which to use.
1554+
let recent: (typeof opened)[number] | undefined;
15541555
if (opened.length === 1 || (opened.length > 1 && openRecent)) {
1555-
folderPath = opened[0].folderUri.path;
1556+
recent = opened[0];
15561557
} else if (opened.length > 1) {
15571558
const items = opened.map((f) => f.folderUri.path);
1558-
folderPath = await vscode.window.showQuickPick(items, {
1559+
const picked = await vscode.window.showQuickPick(items, {
15591560
title: "Select a recently opened folder",
15601561
});
1561-
if (!folderPath) {
1562+
if (!picked) {
15621563
// User aborted.
15631564
return { status: "cancelled", stage: "recent_folder_picker" };
15641565
}
1566+
recent = opened.find((f) => f.folderUri.path === picked);
1567+
}
1568+
if (recent) {
1569+
folderPath = recent.folderUri.path;
1570+
// Reopen on the host the folder already used. A compatible authority
1571+
// can still be a different string, and the editor stores per-workspace
1572+
// state under the whole URI.
1573+
remoteAuthority = recent.folderUri.authority;
15651574
}
15661575
}
15671576

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import * as vscode from "vscode";
3+
4+
import { Commands } from "@/commands";
5+
6+
import { workspace as createWorkspace } from "@repo/mocks";
7+
8+
import { createTestTelemetryService } from "../mocks/telemetry";
9+
import {
10+
createMockLogger,
11+
MockConfigurationProvider,
12+
useEditor,
13+
} from "../mocks/testHelpers";
14+
15+
import type { WorkspaceAgent } from "coder/site/src/api/typesGenerated";
16+
17+
import type { CoderApi } from "@/api/coderApi";
18+
import type { ServiceContainer } from "@/core/container";
19+
import type { DeploymentManager } from "@/deployment/deploymentManager";
20+
21+
vi.mock("@/workspace/workspacesProvider", () => ({
22+
AgentTreeItem: class {
23+
constructor(
24+
public agent: unknown,
25+
public workspace: unknown,
26+
) {}
27+
},
28+
WorkspaceTreeItem: class {},
29+
}));
30+
31+
const BASE_URL = "https://dev.coder.com";
32+
const AGENT = { name: "main", expanded_directory: undefined } as WorkspaceAgent;
33+
const CURSOR_AUTHORITY = "ssh-remote+coder-cursor.dev.coder.com--foo--bar.main";
34+
const LEGACY_AUTHORITY = "ssh-remote+coder-vscode.dev.coder.com--foo--bar.main";
35+
36+
/** A recently opened folder, as `_workbench.getRecentlyOpened` reports it. */
37+
function recent(authority: string, path: string) {
38+
return {
39+
folderUri: vscode.Uri.from({ scheme: "vscode-remote", authority, path }),
40+
remoteAuthority: authority,
41+
};
42+
}
43+
44+
/** Answer the recents query, and capture what openFolder is finally given. */
45+
function setup(recents: Array<ReturnType<typeof recent>>) {
46+
new MockConfigurationProvider();
47+
vi.mocked(vscode.commands.executeCommand).mockImplementation(
48+
(command: string) =>
49+
Promise.resolve(
50+
command === "_workbench.getRecentlyOpened"
51+
? { workspaces: recents }
52+
: undefined,
53+
),
54+
);
55+
56+
const serviceContainer = {
57+
getTelemetryService: () => createTestTelemetryService(),
58+
getLogger: () => createMockLogger(),
59+
getMementoManager: () => ({ setStartupMode: vi.fn() }),
60+
getDuplicateWorkspaceIpc: () => ({
61+
sendPing: vi.fn().mockResolvedValue(undefined),
62+
}),
63+
getPathResolver: () => ({}),
64+
getSecretsManager: () => ({}),
65+
getCliManager: () => ({}),
66+
getLoginCoordinator: () => ({}),
67+
getSpeedtestPanelFactory: () => ({}),
68+
getNetcheckPanelFactory: () => ({}),
69+
} as unknown as ServiceContainer;
70+
71+
const client = {
72+
getAxiosInstance: () => ({ defaults: { baseURL: BASE_URL } }),
73+
} as unknown as CoderApi;
74+
75+
return new Commands(serviceContainer, client, {} as DeploymentManager);
76+
}
77+
78+
/** The authority the window was finally handed, by folder or by empty window. */
79+
function openedAuthority(): string | undefined {
80+
for (const [command, arg] of vi.mocked(vscode.commands.executeCommand).mock
81+
.calls) {
82+
if (command === "vscode.openFolder") {
83+
return (arg as vscode.Uri).authority;
84+
}
85+
if (command === "vscode.newWindow") {
86+
return (arg as { remoteAuthority: string }).remoteAuthority;
87+
}
88+
}
89+
return undefined;
90+
}
91+
92+
describe("openWorkspace", () => {
93+
beforeEach(() => {
94+
vi.clearAllMocks();
95+
});
96+
97+
/** The sidebar reaches openWorkspace asking for the most recent folder. */
98+
async function openFromSidebar(commands: Commands) {
99+
const { AgentTreeItem } = await import("@/workspace/workspacesProvider");
100+
const item = new AgentTreeItem(
101+
AGENT,
102+
createWorkspace({ owner_name: "foo", name: "bar" }),
103+
);
104+
await commands.openFromSidebar(item);
105+
}
106+
107+
it("reopens a folder on the host it already used", async () => {
108+
useEditor("cursor");
109+
const commands = setup([recent(LEGACY_AUTHORITY, "/home/foo/project")]);
110+
111+
await openFromSidebar(commands);
112+
113+
// Not the coder-cursor authority this editor would mint: that URI is a
114+
// workspace the editor has never seen, so its stored state is orphaned.
115+
expect(openedAuthority()).toBe(LEGACY_AUTHORITY);
116+
});
117+
118+
it("mints this editor's host for a workspace with no history", async () => {
119+
useEditor("cursor");
120+
const commands = setup([]);
121+
122+
await openFromSidebar(commands);
123+
124+
expect(openedAuthority()).toBe(CURSOR_AUTHORITY);
125+
});
126+
127+
it("keeps this editor's host when the recent folder already uses it", async () => {
128+
useEditor("cursor");
129+
const commands = setup([recent(CURSOR_AUTHORITY, "/home/foo/project")]);
130+
131+
await openFromSidebar(commands);
132+
133+
expect(openedAuthority()).toBe(CURSOR_AUTHORITY);
134+
});
135+
136+
it("ignores recent folders from another editor", async () => {
137+
useEditor("cursor");
138+
const commands = setup([
139+
recent("ssh-remote+coder-devin.dev.coder.com--foo--bar.main", "/other"),
140+
]);
141+
142+
await openFromSidebar(commands);
143+
144+
expect(openedAuthority()).toBe(CURSOR_AUTHORITY);
145+
});
146+
});

0 commit comments

Comments
 (0)