Skip to content

Commit 3a45a3b

Browse files
committed
🤖 feat: VS Code extension prefers oRPC with fallback
Change-Id: I8f6575627504c8e081dd95f67b3be992bb1e412e Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 3f74a6d commit 3a45a3b

File tree

6 files changed

+631
-36
lines changed

6 files changed

+631
-36
lines changed

vscode/package.json

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,43 @@
2222
"development"
2323
],
2424
"activationEvents": [
25-
"onCommand:mux.openWorkspace"
25+
"onCommand:mux.openWorkspace",
26+
"onCommand:mux.configureConnection"
2627
],
2728
"main": "./out/extension.js",
2829
"contributes": {
2930
"commands": [
3031
{
3132
"command": "mux.openWorkspace",
3233
"title": "mux: Open Workspace"
34+
},
35+
{
36+
"command": "mux.configureConnection",
37+
"title": "mux: Configure Connection"
38+
}
39+
],
40+
"configuration": {
41+
"title": "mux",
42+
"properties": {
43+
"mux.connectionMode": {
44+
"type": "string",
45+
"enum": [
46+
"auto",
47+
"server-only",
48+
"file-only"
49+
],
50+
"default": "auto",
51+
"scope": "machine",
52+
"description": "How the mux VS Code extension connects to mux (prefer server, require server, or use local files)."
53+
},
54+
"mux.serverUrl": {
55+
"type": "string",
56+
"default": "",
57+
"scope": "machine",
58+
"description": "Override the mux server URL (leave empty to auto-discover). Example: http://127.0.0.1:3000"
59+
}
3360
}
34-
]
61+
}
3562
},
3663
"scripts": {
3764
"vscode:prepublish": "bun run compile",
@@ -54,3 +81,4 @@
5481
"license": "AGPL-3.0-only",
5582
"packageManager": "bun@1.1.42"
5683
}
84+

vscode/src/extension.ts

Lines changed: 281 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,178 @@
11
import * as vscode from "vscode";
2-
import { getAllWorkspaces, WorkspaceWithContext } from "./muxConfig";
3-
import { openWorkspace } from "./workspaceOpener";
2+
43
import { formatRelativeTime } from "mux/browser/utils/ui/dateTime";
54

5+
import { getAllWorkspacesFromFiles, getAllWorkspacesFromOrpc, WorkspaceWithContext } from "./muxConfig";
6+
import { checkAuth, checkServerReachable } from "./orpc/connectionCheck";
7+
import { createVscodeORPCClient } from "./orpc/client";
8+
import {
9+
clearAuthTokenOverride,
10+
discoverServerConfig,
11+
getConnectionModeSetting,
12+
storeAuthTokenOverride,
13+
type ConnectionMode,
14+
} from "./orpc/discovery";
15+
import { openWorkspace } from "./workspaceOpener";
16+
17+
let sessionPreferredMode: "orpc" | "file" | null = null;
18+
let didShowFallbackPrompt = false;
19+
20+
const ACTION_FIX_CONNECTION_CONFIG = "Fix connection config";
21+
const ACTION_USE_LOCAL_FILES = "Use local file access";
22+
const ACTION_CANCEL = "Cancel";
23+
24+
type OrpcConnectionFailure =
25+
| { kind: "unreachable"; baseUrl: string; error: string }
26+
| { kind: "unauthorized"; baseUrl: string; error: string }
27+
| { kind: "error"; baseUrl: string; error: string };
28+
29+
function formatError(error: unknown): string {
30+
return error instanceof Error ? error.message : String(error);
31+
}
32+
33+
function describeFailure(failure: OrpcConnectionFailure): string {
34+
switch (failure.kind) {
35+
case "unreachable":
36+
return `mux server is not reachable at ${failure.baseUrl}`;
37+
case "unauthorized":
38+
return `mux server rejected the auth token at ${failure.baseUrl}`;
39+
case "error":
40+
return `mux server connection failed at ${failure.baseUrl}`;
41+
}
42+
}
43+
44+
function getWarningSuffix(failure: OrpcConnectionFailure): string {
45+
if (failure.kind === "unauthorized") {
46+
return "Using local file access while mux is running can cause inconsistencies.";
47+
}
48+
return "Using local file access can cause inconsistencies.";
49+
}
50+
51+
async function tryGetWorkspacesFromOrpc(
52+
context: vscode.ExtensionContext
53+
): Promise<{ workspaces: WorkspaceWithContext[] } | { failure: OrpcConnectionFailure }> {
54+
try {
55+
const discovery = await discoverServerConfig(context);
56+
const client = createVscodeORPCClient({ baseUrl: discovery.baseUrl, authToken: discovery.authToken });
57+
58+
const reachable = await checkServerReachable(discovery.baseUrl);
59+
if (reachable.status !== "ok") {
60+
return {
61+
failure: {
62+
kind: "unreachable",
63+
baseUrl: discovery.baseUrl,
64+
error: reachable.error,
65+
},
66+
};
67+
}
68+
69+
const auth = await checkAuth(client);
70+
if (auth.status === "unauthorized") {
71+
return {
72+
failure: {
73+
kind: "unauthorized",
74+
baseUrl: discovery.baseUrl,
75+
error: auth.error,
76+
},
77+
};
78+
}
79+
if (auth.status !== "ok") {
80+
return {
81+
failure: {
82+
kind: "error",
83+
baseUrl: discovery.baseUrl,
84+
error: auth.error,
85+
},
86+
};
87+
}
88+
89+
const workspaces = await getAllWorkspacesFromOrpc(client);
90+
return { workspaces };
91+
} catch (error) {
92+
return {
93+
failure: {
94+
kind: "error",
95+
baseUrl: "unknown",
96+
error: formatError(error),
97+
},
98+
};
99+
}
100+
}
101+
102+
async function getWorkspacesForCommand(
103+
context: vscode.ExtensionContext
104+
): Promise<WorkspaceWithContext[] | null> {
105+
const modeSetting: ConnectionMode = getConnectionModeSetting();
106+
107+
if (modeSetting === "file-only" || sessionPreferredMode === "file") {
108+
sessionPreferredMode = "file";
109+
return getAllWorkspacesFromFiles();
110+
}
111+
112+
const orpcResult = await tryGetWorkspacesFromOrpc(context);
113+
if ("workspaces" in orpcResult) {
114+
sessionPreferredMode = "orpc";
115+
return orpcResult.workspaces;
116+
}
117+
118+
const failure = orpcResult.failure;
119+
120+
if (modeSetting === "server-only") {
121+
const selection = await vscode.window.showErrorMessage(
122+
`mux: ${describeFailure(failure)}. (${failure.error})`,
123+
ACTION_FIX_CONNECTION_CONFIG
124+
);
125+
126+
if (selection === ACTION_FIX_CONNECTION_CONFIG) {
127+
await configureConnectionCommand(context);
128+
}
129+
130+
return null;
131+
}
132+
133+
// modeSetting is auto.
134+
if (didShowFallbackPrompt) {
135+
sessionPreferredMode = "file";
136+
void vscode.window.showWarningMessage(
137+
`mux: ${describeFailure(failure)}. Falling back to local file access. Run "mux: Configure Connection" to fix.`
138+
);
139+
return getAllWorkspacesFromFiles();
140+
}
141+
142+
const selection = await vscode.window.showWarningMessage(
143+
`mux: ${describeFailure(failure)}. ${getWarningSuffix(failure)}`,
144+
ACTION_FIX_CONNECTION_CONFIG,
145+
ACTION_USE_LOCAL_FILES,
146+
ACTION_CANCEL
147+
);
148+
149+
if (!selection || selection === ACTION_CANCEL) {
150+
return null;
151+
}
152+
153+
didShowFallbackPrompt = true;
154+
155+
if (selection === ACTION_USE_LOCAL_FILES) {
156+
sessionPreferredMode = "file";
157+
return getAllWorkspacesFromFiles();
158+
}
159+
160+
await configureConnectionCommand(context);
161+
162+
const retry = await tryGetWorkspacesFromOrpc(context);
163+
if ("workspaces" in retry) {
164+
sessionPreferredMode = "orpc";
165+
return retry.workspaces;
166+
}
167+
168+
// Still can't connect; fall back without prompting again.
169+
sessionPreferredMode = "file";
170+
void vscode.window.showWarningMessage(
171+
`mux: ${describeFailure(retry.failure)}. Falling back to local file access. (${retry.failure.error})`
172+
);
173+
return getAllWorkspacesFromFiles();
174+
}
175+
6176
/**
7177
* Get the icon for a runtime type
8178
* - local (project-dir): $(folder) - simple folder, uses project directly
@@ -68,9 +238,12 @@ function createWorkspaceQuickPickItem(
68238
/**
69239
* Command: Open a mux workspace
70240
*/
71-
async function openWorkspaceCommand() {
241+
async function openWorkspaceCommand(context: vscode.ExtensionContext) {
72242
// Get all workspaces, this is intentionally not cached.
73-
const workspaces = await getAllWorkspaces();
243+
const workspaces = await getWorkspacesForCommand(context);
244+
if (!workspaces) {
245+
return;
246+
}
74247

75248
if (workspaces.length === 0) {
76249
const selection = await vscode.window.showInformationMessage(
@@ -138,17 +311,118 @@ async function openWorkspaceCommand() {
138311
await openWorkspace(selected.workspace);
139312
}
140313

314+
async function configureConnectionCommand(context: vscode.ExtensionContext): Promise<void> {
315+
const config = vscode.workspace.getConfiguration("mux");
316+
317+
// Small loop so users can set/clear both URL + token in one command.
318+
// Keep UX minimal: no nested quick picks or extra commands.
319+
for (;;) {
320+
const currentUrl = config.get<string>("serverUrl")?.trim() ?? "";
321+
const hasToken = (await context.secrets.get("mux.serverAuthToken")) !== undefined;
322+
323+
const pick = await vscode.window.showQuickPick(
324+
[
325+
{
326+
label: "Set server URL",
327+
description: currentUrl ? `Current: ${currentUrl}` : "Current: auto-discover",
328+
},
329+
...(currentUrl
330+
? ([{ label: "Clear server URL override", description: "Use env/lockfile/default" }] as const)
331+
: ([] as const)),
332+
{
333+
label: "Set auth token",
334+
description: hasToken ? "Current: set" : "Current: none",
335+
},
336+
...(hasToken ? ([{ label: "Clear auth token" }] as const) : ([] as const)),
337+
{ label: "Done" },
338+
],
339+
{ placeHolder: "Configure mux server connection" }
340+
);
341+
342+
if (!pick || pick.label === "Done") {
343+
return;
344+
}
345+
346+
if (pick.label === "Set server URL") {
347+
const value = await vscode.window.showInputBox({
348+
title: "mux server URL",
349+
value: currentUrl,
350+
prompt: "Example: http://127.0.0.1:3000 (leave blank for auto-discovery)",
351+
validateInput(input) {
352+
const trimmed = input.trim();
353+
if (!trimmed) {
354+
return null;
355+
}
356+
try {
357+
const url = new URL(trimmed);
358+
if (url.protocol !== "http:" && url.protocol !== "https:") {
359+
return "URL must start with http:// or https://";
360+
}
361+
return null;
362+
} catch {
363+
return "Invalid URL";
364+
}
365+
},
366+
});
367+
368+
if (value === undefined) {
369+
continue;
370+
}
371+
372+
const trimmed = value.trim();
373+
await config.update(
374+
"serverUrl",
375+
trimmed ? trimmed : undefined,
376+
vscode.ConfigurationTarget.Global
377+
);
378+
continue;
379+
}
380+
381+
if (pick.label === "Clear server URL override") {
382+
await config.update("serverUrl", undefined, vscode.ConfigurationTarget.Global);
383+
continue;
384+
}
385+
386+
if (pick.label === "Set auth token") {
387+
const token = await vscode.window.showInputBox({
388+
title: "mux server auth token",
389+
prompt: "Paste the mux server auth token",
390+
password: true,
391+
validateInput(input) {
392+
return input.trim().length > 0 ? null : "Token cannot be empty";
393+
},
394+
});
395+
396+
if (token === undefined) {
397+
continue;
398+
}
399+
400+
await storeAuthTokenOverride(context, token.trim());
401+
continue;
402+
}
403+
404+
if (pick.label === "Clear auth token") {
405+
await clearAuthTokenOverride(context);
406+
continue;
407+
}
408+
}
409+
}
410+
141411
/**
142412
* Activate the extension
143413
*/
144414
export function activate(context: vscode.ExtensionContext) {
145-
// Register the openWorkspace command
146-
const disposable = vscode.commands.registerCommand("mux.openWorkspace", openWorkspaceCommand);
415+
context.subscriptions.push(
416+
vscode.commands.registerCommand("mux.openWorkspace", () => openWorkspaceCommand(context))
417+
);
147418

148-
context.subscriptions.push(disposable);
419+
context.subscriptions.push(
420+
vscode.commands.registerCommand("mux.configureConnection", () => configureConnectionCommand(context))
421+
);
149422
}
150423

151424
/**
152425
* Deactivate the extension
153426
*/
154427
export function deactivate() {}
428+

0 commit comments

Comments
 (0)