Skip to content

Commit dff54cc

Browse files
committed
feat: environments crud ui
1 parent fda557e commit dff54cc

11 files changed

Lines changed: 771 additions & 61 deletions

File tree

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: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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}\n'''`;
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
}
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+
}

apps/code/src/renderer/features/settings/components/SettingsDialog.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
Code,
1010
Folder,
1111
GearSix,
12+
HardDrives,
1213
Keyboard,
1314
Palette,
1415
Plugs,
@@ -23,8 +24,8 @@ import { useHotkeys } from "react-hotkeys-hook";
2324
import { AccountSettings } from "./sections/AccountSettings";
2425
import { AdvancedSettings } from "./sections/AdvancedSettings";
2526
import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings";
27+
import { EnvironmentsSettings } from "./sections/environments/EnvironmentsSettings";
2628
import { GeneralSettings } from "./sections/GeneralSettings";
27-
2829
import { McpServersSettings } from "./sections/McpServersSettings";
2930
import { PersonalizationSettings } from "./sections/PersonalizationSettings";
3031
import { ShortcutsSettings } from "./sections/ShortcutsSettings";
@@ -45,6 +46,11 @@ const SIDEBAR_ITEMS: SidebarItem[] = [
4546
{ id: "account", label: "Account", icon: <User size={16} /> },
4647
{ id: "workspaces", label: "Workspaces", icon: <Folder size={16} /> },
4748
{ id: "worktrees", label: "Worktrees", icon: <TreeStructure size={16} /> },
49+
{
50+
id: "environments",
51+
label: "Environments",
52+
icon: <HardDrives size={16} />,
53+
},
4854
{
4955
id: "personalization",
5056
label: "Personalization",
@@ -68,6 +74,7 @@ const CATEGORY_TITLES: Record<SettingsCategory, string> = {
6874
account: "Account",
6975
workspaces: "Workspaces",
7076
worktrees: "Worktrees",
77+
environments: "Environments",
7178
personalization: "Personalization",
7279
"claude-code": "Claude Code",
7380
"mcp-servers": "MCP Servers",
@@ -83,6 +90,7 @@ const CATEGORY_COMPONENTS: Record<SettingsCategory, React.ComponentType> = {
8390
account: AccountSettings,
8491
workspaces: WorkspacesSettings,
8592
worktrees: WorktreesSettings,
93+
environments: EnvironmentsSettings,
8694
personalization: PersonalizationSettings,
8795
"claude-code": ClaudeCodeSettings,
8896
"mcp-servers": McpServersSettings,

0 commit comments

Comments
 (0)