Skip to content

Commit 7d3e54f

Browse files
committed
refactor: use discriminated unions for runtime parsing
Refactored parseRuntimeModeAndHost to return discriminated unions: - SSH: { mode: 'ssh', host: string } - Docker: { mode: 'docker', image: string } - Local/Worktree: { mode: 'local' | 'worktree' } Key changes: - Returns null instead of empty strings for invalid ssh/docker without args - buildRuntimeString now takes ParsedRuntime instead of (mode, host) - Added dockerImage to DraftWorkspaceSettings (persisted per project) - Updated CLI to use new API with proper error handling - Updated tests for new behavior
1 parent 38f1268 commit 7d3e54f

File tree

6 files changed

+173
-75
lines changed

6 files changed

+173
-75
lines changed

src/browser/components/ChatInput/useCreationWorkspace.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ function createDraftSettingsHarness(
521521
initial?: Partial<{
522522
runtimeMode: RuntimeMode;
523523
sshHost: string;
524+
dockerImage: string;
524525
trunkBranch: string;
525526
runtimeString?: string | undefined;
526527
defaultRuntimeMode?: RuntimeMode;
@@ -530,12 +531,14 @@ function createDraftSettingsHarness(
530531
runtimeMode: initial?.runtimeMode ?? "local",
531532
defaultRuntimeMode: initial?.defaultRuntimeMode ?? "worktree",
532533
sshHost: initial?.sshHost ?? "",
534+
dockerImage: initial?.dockerImage ?? "",
533535
trunkBranch: initial?.trunkBranch ?? "main",
534536
runtimeString: initial?.runtimeString,
535537
} satisfies {
536538
runtimeMode: RuntimeMode;
537539
defaultRuntimeMode: RuntimeMode;
538540
sshHost: string;
541+
dockerImage: string;
539542
trunkBranch: string;
540543
runtimeString: string | undefined;
541544
};
@@ -563,18 +566,24 @@ function createDraftSettingsHarness(
563566
state.sshHost = host;
564567
});
565568

569+
const setDockerImage = mock((image: string) => {
570+
state.dockerImage = image;
571+
});
572+
566573
return {
567574
state,
568575
setRuntimeMode,
569576
setDefaultRuntimeMode,
570577
setSshHost,
578+
setDockerImage,
571579
setTrunkBranch,
572580
getRuntimeString,
573581
snapshot(): {
574582
settings: DraftWorkspaceSettings;
575583
setRuntimeMode: typeof setRuntimeMode;
576584
setDefaultRuntimeMode: typeof setDefaultRuntimeMode;
577585
setSshHost: typeof setSshHost;
586+
setDockerImage: typeof setDockerImage;
578587
setTrunkBranch: typeof setTrunkBranch;
579588
getRuntimeString: typeof getRuntimeString;
580589
} {
@@ -585,13 +594,15 @@ function createDraftSettingsHarness(
585594
runtimeMode: state.runtimeMode,
586595
defaultRuntimeMode: state.defaultRuntimeMode,
587596
sshHost: state.sshHost,
597+
dockerImage: state.dockerImage ?? "",
588598
trunkBranch: state.trunkBranch,
589599
};
590600
return {
591601
settings,
592602
setRuntimeMode,
593603
setDefaultRuntimeMode,
594604
setSshHost,
605+
setDockerImage,
595606
setTrunkBranch,
596607
getRuntimeString,
597608
};

src/browser/hooks/useDraftWorkspaceSettings.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import { useMode } from "@/browser/contexts/ModeContext";
55
import { useModelLRU } from "./useModelLRU";
66
import {
77
type RuntimeMode,
8+
type ParsedRuntime,
89
parseRuntimeModeAndHost,
910
buildRuntimeString,
11+
RUNTIME_MODE,
1012
} from "@/common/types/runtime";
1113
import {
1214
getModelKey,
1315
getRuntimeKey,
1416
getTrunkBranchKey,
1517
getLastSshHostKey,
18+
getLastDockerImageKey,
1619
getProjectScopeId,
1720
} from "@/common/constants/storage";
1821
import type { UIMode } from "@/common/types/mode";
@@ -33,7 +36,10 @@ export interface DraftWorkspaceSettings {
3336
runtimeMode: RuntimeMode;
3437
/** Persisted default runtime for this project (used to initialize selection) */
3538
defaultRuntimeMode: RuntimeMode;
39+
/** SSH host (persisted separately from mode) */
3640
sshHost: string;
41+
/** Docker image (persisted separately from mode) */
42+
dockerImage: string;
3743
trunkBranch: string;
3844
}
3945

@@ -57,6 +63,7 @@ export function useDraftWorkspaceSettings(
5763
/** Set the default runtime mode for this project (persists via checkbox) */
5864
setDefaultRuntimeMode: (mode: RuntimeMode) => void;
5965
setSshHost: (host: string) => void;
66+
setDockerImage: (image: string) => void;
6067
setTrunkBranch: (branch: string) => void;
6168
getRuntimeString: () => string | undefined;
6269
} {
@@ -79,8 +86,9 @@ export function useDraftWorkspaceSettings(
7986
{ listener: true }
8087
);
8188

82-
// Parse default runtime string into mode (worktree when undefined)
83-
const { mode: defaultRuntimeMode } = parseRuntimeModeAndHost(defaultRuntimeString);
89+
// Parse default runtime string into mode (worktree when undefined or invalid)
90+
const parsedDefault = parseRuntimeModeAndHost(defaultRuntimeString);
91+
const defaultRuntimeMode: RuntimeMode = parsedDefault?.mode ?? RUNTIME_MODE.WORKTREE;
8492

8593
// Currently selected runtime mode for this session (initialized from default)
8694
// This allows user to select a different runtime without changing the default
@@ -106,6 +114,13 @@ export function useDraftWorkspaceSettings(
106114
{ listener: true }
107115
);
108116

117+
// Project-scoped Docker image preference (persisted separately from runtime mode)
118+
const [lastDockerImage, setLastDockerImage] = usePersistedState<string>(
119+
getLastDockerImageKey(projectPath),
120+
"",
121+
{ listener: true }
122+
);
123+
109124
// Initialize trunk branch from backend recommendation or first branch
110125
useEffect(() => {
111126
if (!trunkBranch && branches.length > 0) {
@@ -114,14 +129,31 @@ export function useDraftWorkspaceSettings(
114129
}
115130
}, [branches, recommendedTrunk, trunkBranch, setTrunkBranch]);
116131

132+
// Build ParsedRuntime from mode + stored host/image
133+
const buildParsedRuntime = (mode: RuntimeMode): ParsedRuntime | null => {
134+
switch (mode) {
135+
case RUNTIME_MODE.LOCAL:
136+
return { mode: "local" };
137+
case RUNTIME_MODE.WORKTREE:
138+
return { mode: "worktree" };
139+
case RUNTIME_MODE.SSH:
140+
return lastSshHost ? { mode: "ssh", host: lastSshHost } : null;
141+
case RUNTIME_MODE.DOCKER:
142+
return lastDockerImage ? { mode: "docker", image: lastDockerImage } : null;
143+
default:
144+
return null;
145+
}
146+
};
147+
117148
// Setter for selected runtime mode (changes current selection, does not persist)
118149
const setRuntimeMode = (newMode: RuntimeMode) => {
119150
setSelectedRuntimeMode(newMode);
120151
};
121152

122153
// Setter for default runtime mode (persists via checkbox in tooltip)
123154
const setDefaultRuntimeMode = (newMode: RuntimeMode) => {
124-
const newRuntimeString = buildRuntimeString(newMode, lastSshHost);
155+
const parsed = buildParsedRuntime(newMode);
156+
const newRuntimeString = parsed ? buildRuntimeString(parsed) : undefined;
125157
setDefaultRuntimeString(newRuntimeString);
126158
// Also update selection to match new default
127159
setSelectedRuntimeMode(newMode);
@@ -132,9 +164,15 @@ export function useDraftWorkspaceSettings(
132164
setLastSshHost(newHost);
133165
};
134166

167+
// Setter for Docker image (persisted separately so it's remembered across mode switches)
168+
const setDockerImage = (newImage: string) => {
169+
setLastDockerImage(newImage);
170+
};
171+
135172
// Helper to get runtime string for IPC calls (uses selected mode, not default)
136173
const getRuntimeString = (): string | undefined => {
137-
return buildRuntimeString(selectedRuntimeMode, lastSshHost);
174+
const parsed = buildParsedRuntime(selectedRuntimeMode);
175+
return parsed ? buildRuntimeString(parsed) : undefined;
138176
};
139177

140178
return {
@@ -145,11 +183,13 @@ export function useDraftWorkspaceSettings(
145183
runtimeMode: selectedRuntimeMode,
146184
defaultRuntimeMode,
147185
sshHost: lastSshHost,
186+
dockerImage: lastDockerImage,
148187
trunkBranch,
149188
},
150189
setRuntimeMode,
151190
setDefaultRuntimeMode,
152191
setSshHost,
192+
setDockerImage,
153193
setTrunkBranch,
154194
getRuntimeString,
155195
};

src/cli/run.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,18 +52,22 @@ function parseRuntimeConfig(value: string | undefined, srcBaseDir: string): Runt
5252
return { type: "local" };
5353
}
5454

55-
const { mode, host } = parseRuntimeModeAndHost(value);
55+
const parsed = parseRuntimeModeAndHost(value);
56+
if (!parsed) {
57+
throw new Error(
58+
`Invalid runtime: '${value}'. Use 'local', 'worktree', 'ssh <host>', or 'docker <image>'`
59+
);
60+
}
5661

57-
switch (mode) {
62+
switch (parsed.mode) {
5863
case RUNTIME_MODE.LOCAL:
5964
return { type: "local" };
6065
case RUNTIME_MODE.WORKTREE:
6166
return { type: "worktree", srcBaseDir };
6267
case RUNTIME_MODE.SSH:
63-
if (!host.trim()) {
64-
throw new Error("SSH runtime requires a host (e.g., --runtime 'ssh user@host')");
65-
}
66-
return { type: "ssh", host: host.trim(), srcBaseDir };
68+
return { type: "ssh", host: parsed.host, srcBaseDir };
69+
case RUNTIME_MODE.DOCKER:
70+
return { type: "docker", image: parsed.image };
6771
default:
6872
return { type: "local" };
6973
}

src/common/constants/storage.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ export function getLastSshHostKey(projectPath: string): string {
145145
return `lastSshHost:${projectPath}`;
146146
}
147147

148+
/**
149+
* Get the localStorage key for the last entered Docker image for a project
150+
* Stores the last entered Docker image separately from runtime mode
151+
* so it persists when switching between runtime modes
152+
* Format: "lastDockerImage:{projectPath}"
153+
*/
154+
export function getLastDockerImageKey(projectPath: string): string {
155+
return `lastDockerImage:${projectPath}`;
156+
}
157+
148158
/**
149159
* Get the localStorage key for the preferred compaction model (global)
150160
* Format: "preferredCompactionModel"

src/common/types/runtime.test.ts

Lines changed: 51 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,69 +9,96 @@ describe("parseRuntimeModeAndHost", () => {
99
});
1010
});
1111

12-
it("parses SSH mode without host", () => {
13-
expect(parseRuntimeModeAndHost("ssh")).toEqual({
14-
mode: "ssh",
15-
host: "",
12+
it("returns null for SSH mode without host", () => {
13+
expect(parseRuntimeModeAndHost("ssh")).toBeNull();
14+
});
15+
16+
it("returns null for SSH with trailing space but no host", () => {
17+
expect(parseRuntimeModeAndHost("ssh ")).toBeNull();
18+
});
19+
20+
it("parses Docker mode with image", () => {
21+
expect(parseRuntimeModeAndHost("docker ubuntu:22.04")).toEqual({
22+
mode: "docker",
23+
image: "ubuntu:22.04",
1624
});
1725
});
1826

27+
it("returns null for Docker mode without image", () => {
28+
expect(parseRuntimeModeAndHost("docker")).toBeNull();
29+
});
30+
1931
it("parses local mode", () => {
2032
expect(parseRuntimeModeAndHost("local")).toEqual({
2133
mode: "local",
22-
host: "",
34+
});
35+
});
36+
37+
it("parses worktree mode", () => {
38+
expect(parseRuntimeModeAndHost("worktree")).toEqual({
39+
mode: "worktree",
2340
});
2441
});
2542

2643
it("defaults to worktree for undefined", () => {
2744
expect(parseRuntimeModeAndHost(undefined)).toEqual({
2845
mode: "worktree",
29-
host: "",
3046
});
3147
});
3248

3349
it("defaults to worktree for null", () => {
3450
expect(parseRuntimeModeAndHost(null)).toEqual({
3551
mode: "worktree",
36-
host: "",
3752
});
3853
});
54+
55+
it("returns null for unrecognized runtime", () => {
56+
expect(parseRuntimeModeAndHost("unknown")).toBeNull();
57+
});
3958
});
4059

4160
describe("buildRuntimeString", () => {
4261
it("builds SSH string with host", () => {
43-
expect(buildRuntimeString("ssh", "user@host")).toBe("ssh user@host");
62+
expect(buildRuntimeString({ mode: "ssh", host: "user@host" })).toBe("ssh user@host");
4463
});
4564

46-
it("builds SSH string without host (persists SSH mode)", () => {
47-
expect(buildRuntimeString("ssh", "")).toBe("ssh");
65+
it("builds Docker string with image", () => {
66+
expect(buildRuntimeString({ mode: "docker", image: "ubuntu:22.04" })).toBe(
67+
"docker ubuntu:22.04"
68+
);
4869
});
4970

5071
it("returns 'local' for local mode", () => {
51-
expect(buildRuntimeString("local", "")).toBe("local");
72+
expect(buildRuntimeString({ mode: "local" })).toBe("local");
5273
});
5374

5475
it("returns undefined for worktree mode (default)", () => {
55-
expect(buildRuntimeString("worktree", "")).toBeUndefined();
56-
});
57-
58-
it("trims whitespace from host", () => {
59-
expect(buildRuntimeString("ssh", " user@host ")).toBe("ssh user@host");
76+
expect(buildRuntimeString({ mode: "worktree" })).toBeUndefined();
6077
});
6178
});
6279

6380
describe("round-trip parsing and building", () => {
64-
it("preserves SSH mode without host", () => {
65-
const built = buildRuntimeString("ssh", "");
81+
it("preserves SSH mode with host", () => {
82+
const built = buildRuntimeString({ mode: "ssh", host: "user@host" });
6683
const parsed = parseRuntimeModeAndHost(built);
67-
expect(parsed.mode).toBe("ssh");
68-
expect(parsed.host).toBe("");
84+
expect(parsed).toEqual({ mode: "ssh", host: "user@host" });
6985
});
7086

71-
it("preserves SSH mode with host", () => {
72-
const built = buildRuntimeString("ssh", "user@host");
87+
it("preserves Docker mode with image", () => {
88+
const built = buildRuntimeString({ mode: "docker", image: "node:20" });
89+
const parsed = parseRuntimeModeAndHost(built);
90+
expect(parsed).toEqual({ mode: "docker", image: "node:20" });
91+
});
92+
93+
it("preserves local mode", () => {
94+
const built = buildRuntimeString({ mode: "local" });
95+
const parsed = parseRuntimeModeAndHost(built);
96+
expect(parsed).toEqual({ mode: "local" });
97+
});
98+
99+
it("preserves worktree mode", () => {
100+
const built = buildRuntimeString({ mode: "worktree" });
73101
const parsed = parseRuntimeModeAndHost(built);
74-
expect(parsed.mode).toBe("ssh");
75-
expect(parsed.host).toBe("user@host");
102+
expect(parsed).toEqual({ mode: "worktree" });
76103
});
77104
});

0 commit comments

Comments
 (0)