Skip to content

Commit c999682

Browse files
authored
feat: environments crud ui (#1292)
if you're reading this just go to the top of the stack and check the UI out there
1 parent 0cb5fe1 commit c999682

15 files changed

Lines changed: 788 additions & 69 deletions

File tree

apps/code/src/main/services/agent/service.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,14 @@ describe("AgentService", () => {
351351
injectSession(service, "run-1");
352352
service.recordActivity("run-1");
353353
const firstDeadline = getIdleTimeouts(service).get("run-1")?.deadline;
354+
if (firstDeadline === undefined)
355+
throw new Error("Expected firstDeadline to be defined");
354356

355357
vi.advanceTimersByTime(5 * 60 * 1000);
356358
service.recordActivity("run-1");
357359
const secondDeadline = getIdleTimeouts(service).get("run-1")?.deadline;
358360

359-
expect(secondDeadline).toBeGreaterThan(firstDeadline!);
361+
expect(secondDeadline).toBeGreaterThan(firstDeadline);
360362
});
361363

362364
it("kills idle session after timeout expires", () => {

apps/code/src/main/services/environment/schemas.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ const setupSchema = z.object({
77
});
88

99
export const environmentActionSchema = z.object({
10-
id: z.string(),
1110
name: z.string().min(1),
1211
icon: z.string().optional(),
1312
command: z.string().min(1),
@@ -38,18 +37,23 @@ export const deleteEnvironmentInput = repoPathWithIdInput;
3837
export const createEnvironmentInput = repoPathInput.extend({
3938
name: z.string().min(1),
4039
setup: setupSchema.optional(),
41-
actions: z.array(environmentActionSchema.omit({ id: true })).optional(),
40+
actions: z.array(environmentActionSchema).optional(),
4241
});
4342

4443
export const updateEnvironmentInput = repoPathWithIdInput.extend({
4544
name: z.string().min(1).optional(),
4645
setup: setupSchema.optional(),
47-
actions: z
48-
.array(environmentActionSchema.extend({ id: z.string().optional() }))
49-
.optional(),
46+
actions: z.array(environmentActionSchema).optional(),
5047
});
5148

5249
export type Environment = z.infer<typeof environmentSchema>;
5350
export type EnvironmentAction = z.infer<typeof environmentActionSchema>;
5451
export type CreateEnvironmentInput = z.infer<typeof createEnvironmentInput>;
5552
export type UpdateEnvironmentInput = z.infer<typeof updateEnvironmentInput>;
53+
54+
export function slugifyEnvironmentName(name: string): string {
55+
return name
56+
.toLowerCase()
57+
.replace(/[^a-z0-9]+/g, "-")
58+
.replace(/^-+|-+$/g, "");
59+
}

apps/code/src/main/services/environment/service.test.ts

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe("EnvironmentService", () => {
9090
expect(await readEnvFiles()).toEqual(["environment.toml"]);
9191
});
9292

93-
it("generates unique ids for actions", async () => {
93+
it("creates environment with actions", async () => {
9494
const env = await create({
9595
name: "Actions",
9696
actions: [
@@ -100,10 +100,8 @@ describe("EnvironmentService", () => {
100100
});
101101

102102
expect(env.actions).toHaveLength(2);
103-
const [a, b] = env.actions!;
104-
expect(a.id).toBeTruthy();
105-
expect(b.id).toBeTruthy();
106-
expect(a.id).not.toBe(b.id);
103+
expect(env.actions?.[0].name).toBe("Build");
104+
expect(env.actions?.[1].name).toBe("Test");
107105
});
108106

109107
it("round-trips setup script through toml", async () => {
@@ -168,26 +166,6 @@ describe("EnvironmentService", () => {
168166
expect(updated.actions).toEqual(env.actions);
169167
});
170168

171-
it("generates ids for new actions without an id", async () => {
172-
const updated = await update({
173-
id: env.id,
174-
actions: [{ name: "Run", command: "npm start" }],
175-
});
176-
177-
expect(updated.actions?.[0].id).toBeTruthy();
178-
});
179-
180-
it("preserves existing action ids", async () => {
181-
const actionId = env.actions?.[0].id;
182-
const updated = await update({
183-
id: env.id,
184-
actions: [{ id: actionId, name: "Build v2", command: "make all" }],
185-
});
186-
187-
expect(updated.actions?.[0].id).toBe(actionId);
188-
expect(updated.actions?.[0].command).toBe("make all");
189-
});
190-
191169
it("persists update to disk", async () => {
192170
await update({ id: env.id, name: "Persisted" });
193171

apps/code/src/main/services/environment/service.ts

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import fs from "node:fs/promises";
22
import path from "node:path";
33
import { injectable } from "inversify";
4-
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4+
import { parse as parseToml } from "smol-toml";
55
import {
66
type CreateEnvironmentInput,
77
type Environment,
8-
type EnvironmentAction,
98
environmentSchema,
9+
slugifyEnvironmentName,
1010
type UpdateEnvironmentInput,
1111
} from "./schemas";
1212

@@ -16,11 +16,41 @@ function environmentsDir(repoPath: string): string {
1616
return path.join(repoPath, ENVIRONMENTS_DIR);
1717
}
1818

19-
function slugify(name: string): string {
20-
return name
21-
.toLowerCase()
22-
.replace(/[^a-z0-9]+/g, "-")
23-
.replace(/^-+|-+$/g, "");
19+
function tomlString(value: string): string {
20+
if (value.includes("\n")) {
21+
return `'''\n${value}'''`;
22+
}
23+
return JSON.stringify(value);
24+
}
25+
26+
function serializeEnvironment(env: Environment): string {
27+
const lines: string[] = [];
28+
29+
lines.push(`id = ${JSON.stringify(env.id)} # DO NOT EDIT MANUALLY`);
30+
lines.push(`version = ${env.version}`);
31+
lines.push("");
32+
lines.push(`name = ${JSON.stringify(env.name)}`);
33+
34+
if (env.setup?.script) {
35+
lines.push("");
36+
lines.push("[setup]");
37+
lines.push(`script = ${tomlString(env.setup.script)}`);
38+
}
39+
40+
if (env.actions && env.actions.length > 0) {
41+
for (const action of env.actions) {
42+
lines.push("");
43+
lines.push("[[actions]]");
44+
lines.push(`name = ${JSON.stringify(action.name)}`);
45+
if (action.icon) {
46+
lines.push(`icon = ${JSON.stringify(action.icon)}`);
47+
}
48+
lines.push(`command = ${tomlString(action.command)}`);
49+
}
50+
}
51+
52+
lines.push("");
53+
return lines.join("\n");
2454
}
2555

2656
interface ScannedEnvironment {
@@ -102,25 +132,17 @@ export class EnvironmentService {
102132
const dir = environmentsDir(repoPath);
103133
await fs.mkdir(dir, { recursive: true });
104134

105-
const id = crypto.randomUUID();
106-
const actions: EnvironmentAction[] | undefined = input.actions?.map(
107-
(a) => ({
108-
...a,
109-
id: crypto.randomUUID(),
110-
}),
111-
);
112-
113135
const environment: Environment = {
114-
id,
136+
id: crypto.randomUUID(),
115137
version: 1,
116138
name: input.name,
117139
setup: input.setup,
118-
actions,
140+
actions: input.actions,
119141
};
120142

121-
const slug = slugify(input.name);
143+
const slug = slugifyEnvironmentName(input.name);
122144
const filePath = await this.uniqueFilePath(dir, slug || "environment");
123-
await fs.writeFile(filePath, stringifyToml(environment), "utf-8");
145+
await fs.writeFile(filePath, serializeEnvironment(environment), "utf-8");
124146

125147
return environment;
126148
}
@@ -136,22 +158,15 @@ export class EnvironmentService {
136158

137159
const existing = found.environment;
138160

139-
const actions: EnvironmentAction[] | undefined = input.actions?.map(
140-
(a) => ({
141-
...a,
142-
id: a.id ?? crypto.randomUUID(),
143-
}),
144-
);
145-
146161
const updated: Environment = {
147162
id: existing.id,
148163
version: existing.version,
149164
name: input.name ?? existing.name,
150165
setup: input.setup !== undefined ? input.setup : existing.setup,
151-
actions: actions !== undefined ? actions : existing.actions,
166+
actions: input.actions !== undefined ? input.actions : existing.actions,
152167
};
153168

154-
await fs.writeFile(found.filePath, stringifyToml(updated), "utf-8");
169+
await fs.writeFile(found.filePath, serializeEnvironment(updated), "utf-8");
155170

156171
return updated;
157172
}

apps/code/src/main/services/suspension/service.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,13 @@ function seedWorktreeWorkspace(
136136
repositoryId: overrides.repositoryId ?? "repo-1",
137137
mode: overrides.mode ?? "worktree",
138138
});
139-
const stored = mocks.workspaceRepo._workspaces.get(ws.id)!;
139+
const stored = mocks.workspaceRepo._workspaces.get(ws.id);
140+
if (!stored) throw new Error(`Workspace not found: ${ws.id}`);
140141
if (overrides.lastActivityAt !== undefined)
141142
stored.lastActivityAt = overrides.lastActivityAt;
142143
if (overrides.createdAt !== undefined) stored.createdAt = overrides.createdAt;
143-
const resolved = mocks.workspaceRepo.findById(ws.id)!;
144+
const resolved = mocks.workspaceRepo.findById(ws.id);
145+
if (!resolved) throw new Error(`Workspace not found: ${ws.id}`);
144146
mocks.worktreeRepo.create({
145147
workspaceId: resolved.id,
146148
name: `wt-${resolved.taskId}`,

apps/code/src/main/services/suspension/service.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -325,17 +325,21 @@ export class SuspensionService extends TypedEventEmitter<SuspensionServiceEvents
325325
const branch = await this.getCurrentBranchName(worktreePath);
326326
if (branch && branch !== "HEAD") suspendedTask.branchName = branch;
327327

328+
const checkpointId = suspendedTask.checkpointId;
329+
if (!checkpointId)
330+
throw new Error("checkpointId must be set in worktree mode");
331+
328332
await step(
329333
async () => {
330334
await this.captureWorktreeCheckpoint(
331335
folderPath,
332336
worktreePath,
333-
suspendedTask.checkpointId!,
337+
checkpointId,
334338
);
335339
},
336340
async () => {
337341
const git = createGitClient(folderPath);
338-
await deleteCheckpoint(git, suspendedTask.checkpointId!);
342+
await deleteCheckpoint(git, checkpointId);
339343
},
340344
);
341345

@@ -380,13 +384,14 @@ export class SuspensionService extends TypedEventEmitter<SuspensionServiceEvents
380384
workspace.mode === "worktree" &&
381385
suspension.checkpointId
382386
) {
387+
const checkpointId = suspension.checkpointId;
383388
await step(
384389
async () => {
385390
restoredWorktreeName = await this.restoreWorktreeFromCheckpoint(
386391
folderPath,
387392
workspace,
388393
suspension.branchName,
389-
suspension.checkpointId!,
394+
checkpointId,
390395
recreateBranch,
391396
);
392397
},
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { Combobox } from "@components/ui/combobox/Combobox";
2+
import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore";
3+
import { HardDrives, Plus } from "@phosphor-icons/react";
4+
import { Flex, Tooltip } from "@radix-ui/themes";
5+
import { useTRPC } from "@renderer/trpc/client";
6+
import { useQuery } from "@tanstack/react-query";
7+
import { useState } from "react";
8+
9+
interface EnvironmentSelectorProps {
10+
repoPath: string | null;
11+
value: string | null;
12+
onChange: (environmentId: string | null) => void;
13+
disabled?: boolean;
14+
variant?: "outline" | "ghost";
15+
}
16+
17+
export function EnvironmentSelector({
18+
repoPath,
19+
value,
20+
onChange,
21+
disabled = false,
22+
variant = "outline",
23+
}: EnvironmentSelectorProps) {
24+
const [open, setOpen] = useState(false);
25+
const trpc = useTRPC();
26+
27+
const { data: environments = [] } = useQuery({
28+
...trpc.environment.list.queryOptions({ repoPath: repoPath ?? "" }),
29+
enabled: !!repoPath,
30+
});
31+
32+
const selectedEnvironment = environments.find((env) => env.id === value);
33+
const displayText = selectedEnvironment?.name ?? "No environment";
34+
35+
const handleChange = (newValue: string) => {
36+
onChange(newValue || null);
37+
setOpen(false);
38+
};
39+
40+
const handleOpenSettings = () => {
41+
setOpen(false);
42+
useSettingsDialogStore
43+
.getState()
44+
.open("environments", { repoPath: repoPath ?? undefined });
45+
};
46+
47+
const triggerContent = (
48+
<Flex align="center" gap="1" style={{ minWidth: 0 }}>
49+
<HardDrives size={16} weight="regular" style={{ flexShrink: 0 }} />
50+
<span className="combobox-trigger-text">{displayText}</span>
51+
</Flex>
52+
);
53+
54+
return (
55+
<Tooltip content={displayText} delayDuration={300}>
56+
<Combobox.Root
57+
value={value ?? ""}
58+
onValueChange={handleChange}
59+
open={open}
60+
onOpenChange={setOpen}
61+
size="1"
62+
disabled={disabled || !repoPath}
63+
>
64+
<Combobox.Trigger variant={variant} placeholder="No environment">
65+
{triggerContent}
66+
</Combobox.Trigger>
67+
68+
<Combobox.Content>
69+
<Combobox.Input placeholder="Search environments" />
70+
<Combobox.Empty>No environments found.</Combobox.Empty>
71+
72+
<Combobox.Group heading="Environments">
73+
{environments.map((env) => (
74+
<Combobox.Item
75+
key={env.id}
76+
value={env.id}
77+
icon={<HardDrives size={11} weight="regular" />}
78+
>
79+
{env.name}
80+
</Combobox.Item>
81+
))}
82+
</Combobox.Group>
83+
84+
<Combobox.Footer>
85+
<button
86+
type="button"
87+
className="combobox-footer-button"
88+
onClick={handleOpenSettings}
89+
>
90+
<Flex
91+
align="center"
92+
gap="2"
93+
style={{ color: "var(--accent-11)" }}
94+
>
95+
<Plus size={11} weight="bold" />
96+
<span>Create local environment</span>
97+
</Flex>
98+
</button>
99+
</Combobox.Footer>
100+
</Combobox.Content>
101+
</Combobox.Root>
102+
</Tooltip>
103+
);
104+
}

0 commit comments

Comments
 (0)