Skip to content

Commit d25724d

Browse files
committed
🤖 fix: preserve SSH/Docker runtime selection
Fixes a regression where editing SSH host / Docker image could reset the selection back to the project default runtime.\n\nAlso fixes SSHRuntime.resolvePath() so ~ and ~/path expand correctly on the remote host, and adds coverage for resolvePath in runtime integration tests.
1 parent b7b1c96 commit d25724d

File tree

6 files changed

+246
-19
lines changed

6 files changed

+246
-19
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2+
import { renderHook, act, cleanup, waitFor } from "@testing-library/react";
3+
import { GlobalWindow } from "happy-dom";
4+
import { useState } from "react";
5+
import {
6+
getLastDockerImageKey,
7+
getLastSshHostKey,
8+
getRuntimeKey,
9+
} from "@/common/constants/storage";
10+
import { useDraftWorkspaceSettings } from "./useDraftWorkspaceSettings";
11+
12+
// A minimal in-memory persisted-state implementation.
13+
// We keep it here (rather than relying on real localStorage) so tests remain deterministic.
14+
const persisted = new Map<string, unknown>();
15+
16+
void mock.module("@/browser/hooks/usePersistedState", () => {
17+
return {
18+
usePersistedState: <T>(key: string, defaultValue: T) => {
19+
const [value, setValue] = useState<T>(() => {
20+
return persisted.has(key) ? (persisted.get(key) as T) : defaultValue;
21+
});
22+
23+
const setAndPersist = (next: T) => {
24+
persisted.set(key, next);
25+
setValue(next);
26+
};
27+
28+
return [value, setAndPersist] as const;
29+
},
30+
};
31+
});
32+
33+
void mock.module("@/browser/hooks/useModelLRU", () => ({
34+
useModelLRU: () => ({ recentModels: ["test-model"] }),
35+
}));
36+
37+
void mock.module("@/browser/hooks/useThinkingLevel", () => ({
38+
useThinkingLevel: () => ["medium", () => undefined] as const,
39+
}));
40+
41+
void mock.module("@/browser/contexts/ModeContext", () => ({
42+
useMode: () => ["plan", () => undefined] as const,
43+
}));
44+
45+
describe("useDraftWorkspaceSettings", () => {
46+
beforeEach(() => {
47+
persisted.clear();
48+
49+
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
50+
globalThis.document = globalThis.window.document;
51+
});
52+
53+
afterEach(() => {
54+
cleanup();
55+
globalThis.window = undefined as unknown as Window & typeof globalThis;
56+
globalThis.document = undefined as unknown as Document;
57+
});
58+
59+
test("does not reset selected runtime to the default while editing SSH host", async () => {
60+
const projectPath = "/tmp/project";
61+
62+
const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"));
63+
64+
act(() => {
65+
result.current.setSelectedRuntime({ mode: "ssh", host: "dev@host" });
66+
});
67+
68+
await waitFor(() => {
69+
expect(result.current.settings.selectedRuntime).toEqual({ mode: "ssh", host: "dev@host" });
70+
});
71+
});
72+
73+
test("seeds SSH host from the remembered value when switching modes", async () => {
74+
const projectPath = "/tmp/project";
75+
persisted.set(getRuntimeKey(projectPath), undefined);
76+
persisted.set(getLastSshHostKey(projectPath), "remembered@host");
77+
78+
const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"));
79+
80+
act(() => {
81+
// Simulate UI switching into ssh mode with an empty field.
82+
result.current.setSelectedRuntime({ mode: "ssh", host: "" });
83+
});
84+
85+
await waitFor(() => {
86+
expect(result.current.settings.selectedRuntime).toEqual({
87+
mode: "ssh",
88+
host: "remembered@host",
89+
});
90+
});
91+
92+
expect(persisted.get(getLastSshHostKey(projectPath))).toBe("remembered@host");
93+
});
94+
95+
test("seeds Docker image from the remembered value when switching modes", async () => {
96+
const projectPath = "/tmp/project";
97+
persisted.set(getRuntimeKey(projectPath), undefined);
98+
persisted.set(getLastDockerImageKey(projectPath), "ubuntu:22.04");
99+
100+
const { result } = renderHook(() => useDraftWorkspaceSettings(projectPath, ["main"], "main"));
101+
102+
act(() => {
103+
// Simulate UI switching into docker mode with an empty field.
104+
result.current.setSelectedRuntime({ mode: "docker", image: "" });
105+
});
106+
107+
await waitFor(() => {
108+
expect(result.current.settings.selectedRuntime).toEqual({
109+
mode: "docker",
110+
image: "ubuntu:22.04",
111+
});
112+
});
113+
114+
expect(persisted.get(getLastDockerImageKey(projectPath))).toBe("ubuntu:22.04");
115+
});
116+
});

src/browser/hooks/useDraftWorkspaceSettings.ts

Lines changed: 86 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useState, useEffect } from "react";
2-
import { usePersistedState } from "./usePersistedState";
3-
import { useThinkingLevel } from "./useThinkingLevel";
1+
import { useState, useEffect, useRef } from "react";
2+
import { usePersistedState } from "@/browser/hooks/usePersistedState";
3+
import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel";
44
import { useMode } from "@/browser/contexts/ModeContext";
5-
import { useModelLRU } from "./useModelLRU";
5+
import { useModelLRU } from "@/browser/hooks/useModelLRU";
66
import {
77
type RuntimeMode,
88
type ParsedRuntime,
@@ -109,6 +109,37 @@ export function useDraftWorkspaceSettings(
109109
{ listener: true }
110110
);
111111

112+
// If the default runtime string contains a host/image (e.g. older persisted values like "ssh devbox"),
113+
// prefer it as the initial remembered value.
114+
useEffect(() => {
115+
if (
116+
parsedDefault?.mode === RUNTIME_MODE.SSH &&
117+
!lastSshHost.trim() &&
118+
parsedDefault.host.trim()
119+
) {
120+
setLastSshHost(parsedDefault.host);
121+
}
122+
if (
123+
parsedDefault?.mode === RUNTIME_MODE.DOCKER &&
124+
!lastDockerImage.trim() &&
125+
parsedDefault.image.trim()
126+
) {
127+
setLastDockerImage(parsedDefault.image);
128+
}
129+
}, [
130+
projectPath,
131+
parsedDefault,
132+
lastSshHost,
133+
lastDockerImage,
134+
setLastSshHost,
135+
setLastDockerImage,
136+
]);
137+
138+
const defaultSshHost =
139+
parsedDefault?.mode === RUNTIME_MODE.SSH ? parsedDefault.host : lastSshHost;
140+
const defaultDockerImage =
141+
parsedDefault?.mode === RUNTIME_MODE.DOCKER ? parsedDefault.image : lastDockerImage;
142+
112143
// Build ParsedRuntime from mode + stored host/image
113144
// Defined as a function so it can be used in both useState init and useEffect
114145
const buildRuntimeForMode = (
@@ -132,13 +163,49 @@ export function useDraftWorkspaceSettings(
132163
// Currently selected runtime for this session (initialized from default)
133164
// Uses discriminated union: SSH has host, Docker has image
134165
const [selectedRuntime, setSelectedRuntimeState] = useState<ParsedRuntime>(() =>
135-
buildRuntimeForMode(defaultRuntimeMode, lastSshHost, lastDockerImage)
166+
buildRuntimeForMode(defaultRuntimeMode, defaultSshHost, defaultDockerImage)
136167
);
137168

138-
// Sync selected runtime when default changes (e.g., from checkbox or project switch)
169+
const prevProjectPathRef = useRef<string | null>(null);
170+
const prevDefaultRuntimeModeRef = useRef<RuntimeMode | null>(null);
171+
172+
// When switching projects or changing the persisted default mode, reset the selection.
173+
// Importantly: do NOT reset selection when lastSshHost/lastDockerImage changes while typing.
139174
useEffect(() => {
140-
setSelectedRuntimeState(buildRuntimeForMode(defaultRuntimeMode, lastSshHost, lastDockerImage));
141-
}, [defaultRuntimeMode, lastSshHost, lastDockerImage]);
175+
const projectChanged = prevProjectPathRef.current !== projectPath;
176+
const defaultModeChanged = prevDefaultRuntimeModeRef.current !== defaultRuntimeMode;
177+
178+
if (projectChanged || defaultModeChanged) {
179+
setSelectedRuntimeState(
180+
buildRuntimeForMode(defaultRuntimeMode, defaultSshHost, defaultDockerImage)
181+
);
182+
}
183+
184+
prevProjectPathRef.current = projectPath;
185+
prevDefaultRuntimeModeRef.current = defaultRuntimeMode;
186+
}, [projectPath, defaultRuntimeMode, defaultSshHost, defaultDockerImage]);
187+
188+
// When the user switches into SSH/Docker mode, seed the field with the remembered host/image.
189+
// This avoids clearing the last host/image when the UI switches modes with an empty field.
190+
const prevSelectedRuntimeModeRef = useRef<RuntimeMode | null>(null);
191+
useEffect(() => {
192+
const prevMode = prevSelectedRuntimeModeRef.current;
193+
if (prevMode !== selectedRuntime.mode) {
194+
if (selectedRuntime.mode === RUNTIME_MODE.SSH) {
195+
if (!selectedRuntime.host.trim() && lastSshHost.trim()) {
196+
setSelectedRuntimeState({ mode: RUNTIME_MODE.SSH, host: lastSshHost });
197+
}
198+
}
199+
200+
if (selectedRuntime.mode === RUNTIME_MODE.DOCKER) {
201+
if (!selectedRuntime.image.trim() && lastDockerImage.trim()) {
202+
setSelectedRuntimeState({ mode: RUNTIME_MODE.DOCKER, image: lastDockerImage });
203+
}
204+
}
205+
}
206+
207+
prevSelectedRuntimeModeRef.current = selectedRuntime.mode;
208+
}, [selectedRuntime, lastSshHost, lastDockerImage]);
142209

143210
// Initialize trunk branch from backend recommendation or first branch
144211
useEffect(() => {
@@ -151,11 +218,17 @@ export function useDraftWorkspaceSettings(
151218
// Setter for selected runtime (also persists host/image for future mode switches)
152219
const setSelectedRuntime = (runtime: ParsedRuntime) => {
153220
setSelectedRuntimeState(runtime);
154-
// Persist host/image so they're remembered when switching modes
155-
if (runtime.mode === "ssh") {
156-
setLastSshHost(runtime.host);
157-
} else if (runtime.mode === "docker") {
158-
setLastDockerImage(runtime.image);
221+
222+
// Persist host/image so they're remembered when switching modes.
223+
// Avoid wiping the remembered value when the UI switches modes with an empty field.
224+
if (runtime.mode === RUNTIME_MODE.SSH) {
225+
if (runtime.host.trim()) {
226+
setLastSshHost(runtime.host);
227+
}
228+
} else if (runtime.mode === RUNTIME_MODE.DOCKER) {
229+
if (runtime.image.trim()) {
230+
setLastDockerImage(runtime.image);
231+
}
159232
}
160233
};
161234

src/common/types/runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ export type ParsedRuntime =
4040
* Format: "ssh <host>" -> { mode: "ssh", host: "<host>" }
4141
* "docker <image>" -> { mode: "docker", image: "<image>" }
4242
* "worktree" -> { mode: "worktree" }
43-
* "local" or undefined -> { mode: "local" }
43+
* "local" -> { mode: "local" }
44+
* undefined/null -> { mode: "worktree" } (default)
4445
*
4546
* Note: "ssh" or "docker" without arguments returns null (invalid).
4647
* Use this for UI state management (localStorage, form inputs).

src/node/runtime/Runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* srcBaseDir (base directory for all workspaces):
1616
* - Where mux stores ALL workspace directories
1717
* - Local: ~/.mux/src (tilde expanded to full path by LocalRuntime)
18-
* - SSH: /home/user/workspace (must be absolute path, no tilde allowed)
18+
* - SSH: /home/user/workspace (tilde paths are allowed and are resolved before use)
1919
*
2020
* Workspace Path Computation:
2121
* {srcBaseDir}/{projectName}/{workspaceName}

src/node/runtime/SSHRuntime.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -291,10 +291,24 @@ export class SSHRuntime extends RemoteRuntime {
291291
// ===== Runtime interface implementations =====
292292

293293
async resolvePath(filePath: string): Promise<string> {
294-
// Use shell to expand tildes on remote system
295-
// Bash will expand ~ automatically when we echo the unquoted variable
296-
// This works with BusyBox (doesn't require GNU coreutils)
297-
const command = `bash -c 'p=${shescape.quote(filePath)}; echo $p'`;
294+
// Expand ~ on the remote host.
295+
// Note: `p='~/x'; echo "$p"` does NOT expand ~ (tilde expansion happens before assignment),
296+
// so we do explicit expansion via shell logic.
297+
const script = [
298+
`p=${shescape.quote(filePath)}`,
299+
'if [ "$p" = "~" ]; then',
300+
' echo "$HOME"',
301+
'elif [ "${p#\\~/}" != "$p" ]; then',
302+
' echo "$HOME/${p#\\~/}"',
303+
'elif [ "${p#/}" != "$p" ]; then',
304+
' echo "$p"',
305+
"else",
306+
' echo "$PWD/$p"',
307+
"fi",
308+
].join("\n");
309+
310+
const command = `bash -lc ${shescape.quote(script)}`;
311+
298312
// Use 10 second timeout for path resolution to allow for slower SSH connections
299313
return this.execSSHCommand(command, 10000);
300314
}

tests/runtime/runtime.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,29 @@ describeIntegration("Runtime integration tests", () => {
193193
); // 15 second timeout for test (includes workspace creation overhead)
194194
});
195195

196+
describe("resolvePath() - Path resolution", () => {
197+
test.concurrent("expands ~ to the home directory", async () => {
198+
const runtime = createRuntime();
199+
200+
const resolved = await runtime.resolvePath("~");
201+
202+
if (type === "ssh") {
203+
expect(resolved).toBe("/home/testuser");
204+
} else {
205+
expect(resolved).toBe(os.homedir());
206+
}
207+
});
208+
209+
test.concurrent("expands ~/path by prefixing the home directory", async () => {
210+
const runtime = createRuntime();
211+
212+
const home = await runtime.resolvePath("~");
213+
const resolved = await runtime.resolvePath("~/mux");
214+
215+
expect(resolved).toBe(`${home}/mux`);
216+
});
217+
});
218+
196219
describe("readFile() - File reading", () => {
197220
test.concurrent("reads file contents", async () => {
198221
const runtime = createRuntime();

0 commit comments

Comments
 (0)