Skip to content

Commit 5e6d702

Browse files
committed
refactor: extract and test workspace creation helpers
- Export normalizeRuntimePreference for reuse - Extract persistWorkspaceCreationPrefill to centralize localStorage writes - Add getFirstProjectPath helper to avoid repeated Array.from calls - Guard event handler against missing projectPath detail - Add unit tests covering normalization and persistence edge cases - Update App.tsx to use getFirstProjectPath helper _Generated with `mux`_
1 parent bad9159 commit 5e6d702

File tree

3 files changed

+178
-44
lines changed

3 files changed

+178
-44
lines changed

src/App.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
} from "./constants/storage";
3838
import type { BranchListResult } from "./types/ipc";
3939
import { useTelemetry } from "./hooks/useTelemetry";
40-
import { useStartWorkspaceCreation } from "./hooks/useStartWorkspaceCreation";
40+
import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation";
4141

4242
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"];
4343

@@ -62,6 +62,7 @@ function AppInner() {
6262
// Auto-collapse sidebar on mobile by default
6363
const isMobile = typeof window !== "undefined" && window.innerWidth <= 768;
6464
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", isMobile);
65+
const defaultProjectPath = getFirstProjectPath(projects);
6566
const startWorkspaceCreation = useStartWorkspaceCreation({
6667
projects,
6768
setPendingNewWorkspaceProject,
@@ -578,9 +579,12 @@ function AppInner() {
578579
}
579580
/>
580581
</ErrorBoundary>
581-
) : pendingNewWorkspaceProject || projects.size === 1 ? (
582+
) : pendingNewWorkspaceProject || (projects.size === 1 && defaultProjectPath) ? (
582583
(() => {
583-
const projectPath = pendingNewWorkspaceProject ?? Array.from(projects.keys())[0];
584+
const projectPath = pendingNewWorkspaceProject ?? defaultProjectPath;
585+
if (!projectPath) {
586+
return null;
587+
}
584588
const projectName =
585589
projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "Project";
586590
return (
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
getFirstProjectPath,
4+
normalizeRuntimePreference,
5+
persistWorkspaceCreationPrefill,
6+
type StartWorkspaceCreationDetail,
7+
} from "./useStartWorkspaceCreation";
8+
import {
9+
getInputKey,
10+
getModelKey,
11+
getPendingScopeId,
12+
getProjectScopeId,
13+
getRuntimeKey,
14+
getTrunkBranchKey,
15+
} from "@/constants/storage";
16+
import type { ProjectConfig } from "@/config";
17+
18+
type PersistFn = typeof import("@/hooks/usePersistedState").updatePersistedState;
19+
type PersistCall = [string, unknown, unknown?];
20+
21+
describe("normalizeRuntimePreference", () => {
22+
test("returns undefined for local or empty runtime", () => {
23+
expect(normalizeRuntimePreference(undefined)).toBeUndefined();
24+
expect(normalizeRuntimePreference(" ")).toBeUndefined();
25+
expect(normalizeRuntimePreference("local")).toBeUndefined();
26+
expect(normalizeRuntimePreference("LOCAL")).toBeUndefined();
27+
});
28+
29+
test("normalizes ssh runtimes", () => {
30+
expect(normalizeRuntimePreference("ssh")).toBe("ssh");
31+
expect(normalizeRuntimePreference("ssh host")).toBe("ssh host");
32+
expect(normalizeRuntimePreference("SSH user@host")).toBe("ssh user@host");
33+
});
34+
35+
test("returns trimmed custom runtime", () => {
36+
expect(normalizeRuntimePreference(" custom-runtime ")).toBe("custom-runtime");
37+
});
38+
});
39+
40+
describe("persistWorkspaceCreationPrefill", () => {
41+
const projectPath = "/tmp/project";
42+
43+
function createPersistSpy() {
44+
const calls: PersistCall[] = [];
45+
const persist: PersistFn = ((...args: PersistCall) => {
46+
calls.push(args);
47+
}) as PersistFn;
48+
49+
return { persist, calls };
50+
}
51+
52+
test("writes provided values and normalizes whitespace", () => {
53+
const detail: StartWorkspaceCreationDetail = {
54+
projectPath,
55+
startMessage: "Ship it",
56+
model: "provider/model",
57+
trunkBranch: " main ",
58+
runtime: " ssh dev ",
59+
};
60+
const { persist, calls } = createPersistSpy();
61+
62+
persistWorkspaceCreationPrefill(projectPath, detail, persist);
63+
64+
const callMap = new Map<string, unknown>();
65+
for (const [key, value] of calls) {
66+
callMap.set(key, value);
67+
}
68+
69+
expect(callMap.get(getInputKey(getPendingScopeId(projectPath)))).toBe("Ship it");
70+
expect(callMap.get(getModelKey(getProjectScopeId(projectPath)))).toBe("provider/model");
71+
expect(callMap.get(getTrunkBranchKey(projectPath))).toBe("main");
72+
expect(callMap.get(getRuntimeKey(projectPath))).toBe("ssh dev");
73+
});
74+
75+
test("clears persisted values when empty strings are provided", () => {
76+
const detail: StartWorkspaceCreationDetail = {
77+
projectPath,
78+
trunkBranch: " ",
79+
runtime: " ",
80+
};
81+
const { persist, calls } = createPersistSpy();
82+
83+
persistWorkspaceCreationPrefill(projectPath, detail, persist);
84+
85+
const callMap = new Map<string, unknown>();
86+
for (const [key, value] of calls) {
87+
callMap.set(key, value);
88+
}
89+
90+
expect(callMap.get(getTrunkBranchKey(projectPath))).toBeUndefined();
91+
expect(callMap.get(getRuntimeKey(projectPath))).toBeUndefined();
92+
});
93+
94+
test("no-op when detail is undefined", () => {
95+
const { persist, calls } = createPersistSpy();
96+
persistWorkspaceCreationPrefill(projectPath, undefined, persist);
97+
expect(calls).toHaveLength(0);
98+
});
99+
});
100+
101+
describe("getFirstProjectPath", () => {
102+
test("returns first project path or null", () => {
103+
const emptyProjects = new Map<string, ProjectConfig>();
104+
expect(getFirstProjectPath(emptyProjects)).toBeNull();
105+
106+
const projects = new Map<string, ProjectConfig>();
107+
projects.set("/tmp/a", { path: "/tmp/a", workspaces: [] } as ProjectConfig);
108+
projects.set("/tmp/b", { path: "/tmp/b", workspaces: [] } as ProjectConfig);
109+
110+
expect(getFirstProjectPath(projects)).toBe("/tmp/a");
111+
});
112+
});

src/hooks/useStartWorkspaceCreation.ts

Lines changed: 59 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { RUNTIME_MODE, SSH_RUNTIME_PREFIX } from "@/types/runtime";
1616
export type StartWorkspaceCreationDetail =
1717
CustomEventPayloads[typeof CUSTOM_EVENTS.START_WORKSPACE_CREATION];
1818

19-
function normalizeRuntimePreference(runtime: string | undefined): string | undefined {
19+
export function normalizeRuntimePreference(runtime: string | undefined): string | undefined {
2020
if (!runtime) {
2121
return undefined;
2222
}
@@ -43,72 +43,90 @@ function normalizeRuntimePreference(runtime: string | undefined): string | undef
4343
return trimmed;
4444
}
4545

46+
export function getFirstProjectPath(projects: Map<string, ProjectConfig>): string | null {
47+
const iterator = projects.keys().next();
48+
return iterator.done ? null : iterator.value;
49+
}
50+
51+
type PersistFn = typeof updatePersistedState;
52+
53+
export function persistWorkspaceCreationPrefill(
54+
projectPath: string,
55+
detail: StartWorkspaceCreationDetail | undefined,
56+
persist: PersistFn = updatePersistedState
57+
): void {
58+
if (!detail) {
59+
return;
60+
}
61+
62+
if (detail.startMessage !== undefined) {
63+
persist(getInputKey(getPendingScopeId(projectPath)), detail.startMessage);
64+
}
65+
66+
if (detail.model !== undefined) {
67+
persist(getModelKey(getProjectScopeId(projectPath)), detail.model);
68+
}
69+
70+
if (detail.trunkBranch !== undefined) {
71+
const normalizedTrunk = detail.trunkBranch.trim();
72+
persist(
73+
getTrunkBranchKey(projectPath),
74+
normalizedTrunk.length > 0 ? normalizedTrunk : undefined
75+
);
76+
}
77+
78+
if (detail.runtime !== undefined) {
79+
const normalizedRuntime = normalizeRuntimePreference(detail.runtime);
80+
persist(getRuntimeKey(projectPath), normalizedRuntime);
81+
}
82+
}
83+
4684
interface UseStartWorkspaceCreationOptions {
4785
projects: Map<string, ProjectConfig>;
4886
setPendingNewWorkspaceProject: (projectPath: string | null) => void;
4987
setSelectedWorkspace: (selection: WorkspaceSelection | null) => void;
5088
}
5189

90+
function resolveProjectPath(projects: Map<string, ProjectConfig>, requestedPath: string): string | null {
91+
if (projects.has(requestedPath)) {
92+
return requestedPath;
93+
}
94+
95+
return getFirstProjectPath(projects);
96+
}
97+
5298
export function useStartWorkspaceCreation({
5399
projects,
54100
setPendingNewWorkspaceProject,
55101
setSelectedWorkspace,
56102
}: UseStartWorkspaceCreationOptions) {
57-
const applyWorkspaceCreationPrefill = useCallback(
58-
(projectPath: string, detail?: StartWorkspaceCreationDetail) => {
59-
if (!detail) {
60-
return;
61-
}
62-
63-
if (detail.startMessage !== undefined) {
64-
updatePersistedState(getInputKey(getPendingScopeId(projectPath)), detail.startMessage);
65-
}
66-
67-
if (detail.model) {
68-
updatePersistedState(getModelKey(getProjectScopeId(projectPath)), detail.model);
69-
}
70-
71-
if (detail.trunkBranch) {
72-
const normalizedTrunk = detail.trunkBranch.trim();
73-
updatePersistedState(
74-
getTrunkBranchKey(projectPath),
75-
normalizedTrunk.length > 0 ? normalizedTrunk : undefined
76-
);
77-
}
78-
79-
if (detail.runtime !== undefined) {
80-
const normalizedRuntime = normalizeRuntimePreference(detail.runtime);
81-
updatePersistedState(getRuntimeKey(projectPath), normalizedRuntime);
82-
}
83-
},
84-
[]
85-
);
86-
87103
const startWorkspaceCreation = useCallback(
88104
(projectPath: string, detail?: StartWorkspaceCreationDetail) => {
89-
const hasProject = projects.has(projectPath);
90-
const resolvedProjectPath = hasProject
91-
? projectPath
92-
: projects.size > 0
93-
? Array.from(projects.keys())[0]
94-
: null;
105+
const resolvedProjectPath = resolveProjectPath(projects, projectPath);
95106

96107
if (!resolvedProjectPath) {
97108
console.warn("No projects available for workspace creation");
98109
return;
99110
}
100111

101-
applyWorkspaceCreationPrefill(resolvedProjectPath, detail);
112+
persistWorkspaceCreationPrefill(resolvedProjectPath, detail);
102113
setPendingNewWorkspaceProject(resolvedProjectPath);
103114
setSelectedWorkspace(null);
104115
},
105-
[projects, applyWorkspaceCreationPrefill, setPendingNewWorkspaceProject, setSelectedWorkspace]
116+
[projects, setPendingNewWorkspaceProject, setSelectedWorkspace]
106117
);
107118

108119
useEffect(() => {
109120
const handleStartCreation = (event: Event) => {
110-
const customEvent = event as CustomEvent<StartWorkspaceCreationDetail>;
111-
startWorkspaceCreation(customEvent.detail.projectPath, customEvent.detail);
121+
const customEvent = event as CustomEvent<StartWorkspaceCreationDetail | undefined>;
122+
const detail = customEvent.detail;
123+
124+
if (!detail?.projectPath) {
125+
console.warn("START_WORKSPACE_CREATION event missing projectPath detail");
126+
return;
127+
}
128+
129+
startWorkspaceCreation(detail.projectPath, detail);
112130
};
113131

114132
window.addEventListener(

0 commit comments

Comments
 (0)