Skip to content

Commit f7e9760

Browse files
committed
feat: support multiple VS Code windows to the same workspace
Add cross-window IPC via SecretStorage to detect existing connections and offer window duplication when clicking Connect on an already-connected workspace. When a user clicks Connect, the extension fires openFolder() immediately (no delay) and sends a PING via SecretStorage in parallel. If another window responds with a PONG, a notification offers: - Duplicate Window: sends a DUPLICATE command that triggers duplicateWorkspaceInNewWindow in the other window - Open Without Folder: opens a blank window connected to the remote The approach is best-effort: if no listener is active in the other window, behavior is identical to today (VS Code refocuses). Closes #783 Refs #548
1 parent d8f53c6 commit f7e9760

5 files changed

Lines changed: 531 additions & 0 deletions

File tree

src/commands.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { type DeploymentManager } from "./deployment/deploymentManager";
2525
import { CertificateError } from "./error/certificateError";
2626
import { toError } from "./error/errorUtils";
2727
import { type FeatureSet, featureSetForVersion } from "./featureSet";
28+
import { type PongMessage, type WindowIpc } from "./ipc";
2829
import { type Logger } from "./logging/logger";
2930
import { type LoginCoordinator } from "./login/loginCoordinator";
3031
import { withCancellableProgress, withProgress } from "./progress";
@@ -65,6 +66,7 @@ export class Commands {
6566
private readonly secretsManager: SecretsManager;
6667
private readonly cliManager: CliManager;
6768
private readonly loginCoordinator: LoginCoordinator;
69+
private readonly windowIpc: WindowIpc;
6870

6971
// These will only be populated when actively connected to a workspace and are
7072
// used in commands. Because commands can be executed by the user, it is not
@@ -88,6 +90,7 @@ export class Commands {
8890
this.secretsManager = serviceContainer.getSecretsManager();
8991
this.cliManager = serviceContainer.getCliManager();
9092
this.loginCoordinator = serviceContainer.getLoginCoordinator();
93+
this.windowIpc = serviceContainer.getWindowIpc();
9194
}
9295

9396
/**
@@ -1079,6 +1082,21 @@ export class Commands {
10791082

10801083
// Only set the memento when opening a new folder/window
10811084
await this.mementoManager.setStartupMode("start");
1085+
1086+
// Fire openFolder/newWindow and PING in parallel.
1087+
// The PING is best-effort: if another window responds, we offer
1088+
// "Duplicate Window" / "Open Without Folder" via a notification.
1089+
const pingPromise = this.windowIpc
1090+
.sendPing(remoteAuthority)
1091+
.then((pong) => {
1092+
if (pong) {
1093+
this.showMultiWindowNotification(pong, remoteAuthority);
1094+
}
1095+
})
1096+
.catch((err: unknown) => {
1097+
this.logger.error("IPC ping failed", err);
1098+
});
1099+
10821100
if (folderPath) {
10831101
await vscode.commands.executeCommand(
10841102
"vscode.openFolder",
@@ -1090,6 +1108,7 @@ export class Commands {
10901108
// Open this in a new window!
10911109
newWindow,
10921110
);
1111+
await pingPromise;
10931112
return true;
10941113
}
10951114

@@ -1098,8 +1117,30 @@ export class Commands {
10981117
remoteAuthority: remoteAuthority,
10991118
reuseWindow: !newWindow,
11001119
});
1120+
await pingPromise;
11011121
return true;
11021122
}
1123+
1124+
private showMultiWindowNotification(
1125+
pong: PongMessage,
1126+
remoteAuthority: string,
1127+
): void {
1128+
const message = `A window is already connected to this workspace (${pong.folder}).`;
1129+
const duplicateAction = "Duplicate Window";
1130+
const openEmptyAction = "Open Without Folder";
1131+
vscode.window
1132+
.showInformationMessage(message, duplicateAction, openEmptyAction)
1133+
.then(async (choice) => {
1134+
if (choice === duplicateAction) {
1135+
await this.windowIpc.sendDuplicate(pong.sessionId);
1136+
} else if (choice === openEmptyAction) {
1137+
await vscode.commands.executeCommand("vscode.newWindow", {
1138+
remoteAuthority,
1139+
reuseWindow: false,
1140+
});
1141+
}
1142+
});
1143+
}
11031144
}
11041145

11051146
async function openFile(filePath: string): Promise<void> {

src/core/container.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as vscode from "vscode";
22

33
import { CoderApi } from "../api/coderApi";
4+
import { WindowIpc } from "../ipc";
45
import { type Logger } from "../logging/logger";
56
import { LoginCoordinator } from "../login/loginCoordinator";
67

8+
79
import { CliCredentialManager } from "./cliCredentialManager";
810
import { CliManager } from "./cliManager";
911
import { ContextManager } from "./contextManager";
@@ -24,6 +26,7 @@ export class ServiceContainer implements vscode.Disposable {
2426
private readonly cliManager: CliManager;
2527
private readonly contextManager: ContextManager;
2628
private readonly loginCoordinator: LoginCoordinator;
29+
private readonly windowIpc: WindowIpc;
2730

2831
constructor(context: vscode.ExtensionContext) {
2932
this.logger = vscode.window.createOutputChannel("Coder", { log: true });
@@ -70,6 +73,7 @@ export class ServiceContainer implements vscode.Disposable {
7073
this.cliCredentialManager,
7174
context.extension.id,
7275
);
76+
this.windowIpc = new WindowIpc(context.secrets, this.logger);
7377
}
7478

7579
getPathResolver(): PathResolver {
@@ -104,6 +108,10 @@ export class ServiceContainer implements vscode.Disposable {
104108
return this.loginCoordinator;
105109
}
106110

111+
getWindowIpc(): WindowIpc {
112+
return this.windowIpc;
113+
}
114+
107115
/**
108116
* Dispose of all services and clean up resources.
109117
*/

src/extension.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,32 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
335335

336336
const remote = new Remote(serviceContainer, commands, ctx);
337337

338+
// Cross-window IPC: respond to PINGs from other windows and handle
339+
// DUPLICATE commands when this window is connected to a remote.
340+
const windowIpc = serviceContainer.getWindowIpc();
341+
ctx.subscriptions.push(
342+
windowIpc.onRequest(async (msg) => {
343+
const currentAuthority = vscodeProposed.env.remoteAuthority;
344+
if (!currentAuthority) {
345+
return;
346+
}
347+
348+
if (msg.type === "ping" && msg.authority === currentAuthority) {
349+
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.path ?? "";
350+
await windowIpc.sendPong(msg.id, vscode.env.sessionId, folder);
351+
}
352+
353+
if (
354+
msg.type === "duplicate" &&
355+
msg.targetSessionId === vscode.env.sessionId
356+
) {
357+
await vscode.commands.executeCommand(
358+
"workbench.action.duplicateWorkspaceInNewWindow",
359+
);
360+
}
361+
}),
362+
);
363+
338364
// Since the "onResolveRemoteAuthority:ssh-remote" activation event exists
339365
// in package.json we're able to perform actions before the authority is
340366
// resolved by the remote SSH extension.

src/ipc.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import crypto from "node:crypto";
2+
3+
import type { Disposable, SecretStorage } from "vscode";
4+
5+
import type { Logger } from "./logging/logger";
6+
7+
const REQUEST_KEY = "coder.ipc.req";
8+
const RESPONSE_KEY = "coder.ipc.res";
9+
10+
const MESSAGE_MAX_AGE_MS = 5000;
11+
12+
export interface PingMessage {
13+
type: "ping";
14+
id: string;
15+
authority: string;
16+
ts: number;
17+
}
18+
19+
export interface PongMessage {
20+
type: "pong";
21+
id: string;
22+
sessionId: string;
23+
folder: string;
24+
ts: number;
25+
}
26+
27+
export interface DuplicateMessage {
28+
type: "duplicate";
29+
id: string;
30+
targetSessionId: string;
31+
ts: number;
32+
}
33+
34+
type RequestMessage = PingMessage | DuplicateMessage;
35+
type ResponseMessage = PongMessage;
36+
37+
function isRequestMessage(msg: unknown): msg is RequestMessage {
38+
if (typeof msg !== "object" || msg === null) {
39+
return false;
40+
}
41+
const obj = msg as Record<string, unknown>;
42+
if (typeof obj.id !== "string" || typeof obj.ts !== "number") {
43+
return false;
44+
}
45+
if (obj.type === "ping") {
46+
return typeof obj.authority === "string";
47+
}
48+
if (obj.type === "duplicate") {
49+
return typeof obj.targetSessionId === "string";
50+
}
51+
return false;
52+
}
53+
54+
function isResponseMessage(msg: unknown): msg is ResponseMessage {
55+
if (typeof msg !== "object" || msg === null) {
56+
return false;
57+
}
58+
const obj = msg as Record<string, unknown>;
59+
return (
60+
obj.type === "pong" &&
61+
typeof obj.id === "string" &&
62+
typeof obj.sessionId === "string" &&
63+
typeof obj.folder === "string" &&
64+
typeof obj.ts === "number"
65+
);
66+
}
67+
68+
export class WindowIpc {
69+
constructor(
70+
private readonly secrets: SecretStorage,
71+
private readonly logger: Logger,
72+
) {}
73+
74+
async sendPing(
75+
authority: string,
76+
timeoutMs = 1000,
77+
): Promise<PongMessage | undefined> {
78+
const id = crypto.randomUUID();
79+
const msg: PingMessage = {
80+
type: "ping",
81+
id,
82+
authority,
83+
ts: Date.now(),
84+
};
85+
86+
return new Promise<PongMessage | undefined>((resolve) => {
87+
let settled = false;
88+
89+
const listener = this.secrets.onDidChange(async (e) => {
90+
if (e.key !== RESPONSE_KEY || settled) {
91+
return;
92+
}
93+
const raw = await this.secrets.get(RESPONSE_KEY);
94+
if (!raw) {
95+
return;
96+
}
97+
try {
98+
const parsed: unknown = JSON.parse(raw);
99+
if (isResponseMessage(parsed) && parsed.id === id) {
100+
settled = true;
101+
clearTimeout(timer);
102+
listener.dispose();
103+
resolve(parsed);
104+
}
105+
} catch (err) {
106+
this.logger.error("Failed to parse IPC response", err);
107+
}
108+
});
109+
110+
const timer = setTimeout(() => {
111+
if (!settled) {
112+
settled = true;
113+
listener.dispose();
114+
resolve(undefined);
115+
}
116+
}, timeoutMs);
117+
118+
Promise.resolve(
119+
this.secrets.store(REQUEST_KEY, JSON.stringify(msg)),
120+
).catch((err: unknown) => {
121+
this.logger.error("Failed to send IPC ping", err);
122+
if (!settled) {
123+
settled = true;
124+
clearTimeout(timer);
125+
listener.dispose();
126+
resolve(undefined);
127+
}
128+
});
129+
});
130+
}
131+
132+
async sendPong(
133+
pingId: string,
134+
sessionId: string,
135+
folder: string,
136+
): Promise<void> {
137+
const msg: PongMessage = {
138+
type: "pong",
139+
id: pingId,
140+
sessionId,
141+
folder,
142+
ts: Date.now(),
143+
};
144+
await this.secrets.store(RESPONSE_KEY, JSON.stringify(msg));
145+
}
146+
147+
async sendDuplicate(targetSessionId: string): Promise<void> {
148+
const msg: DuplicateMessage = {
149+
type: "duplicate",
150+
id: crypto.randomUUID(),
151+
targetSessionId,
152+
ts: Date.now(),
153+
};
154+
await this.secrets.store(REQUEST_KEY, JSON.stringify(msg));
155+
}
156+
157+
onRequest(
158+
handler: (msg: PingMessage | DuplicateMessage) => void | Promise<void>,
159+
): Disposable {
160+
return this.secrets.onDidChange(async (e) => {
161+
if (e.key !== REQUEST_KEY) {
162+
return;
163+
}
164+
const raw = await this.secrets.get(REQUEST_KEY);
165+
if (!raw) {
166+
return;
167+
}
168+
try {
169+
const parsed: unknown = JSON.parse(raw);
170+
if (!isRequestMessage(parsed)) {
171+
return;
172+
}
173+
if (Date.now() - parsed.ts > MESSAGE_MAX_AGE_MS) {
174+
this.logger.debug("Ignoring stale IPC request", parsed.type);
175+
return;
176+
}
177+
await handler(parsed);
178+
} catch (err) {
179+
this.logger.error("Error handling IPC request", err);
180+
}
181+
});
182+
}
183+
}

0 commit comments

Comments
 (0)