blob: 9eeda452520422a0d487285243bc7d3740b49239 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { v4 as uuidv4 } from "uuid";
giod0026612025-05-08 13:00:36 +00002import { NodeRect } from "./node-rect";
gioc31bf142025-06-16 07:48:20 +00003import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
gio69148322025-06-19 23:16:12 +04004import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
giod0026612025-05-08 13:00:36 +00005import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
gio5f2f1002025-03-20 18:38:48 +04006import { z } from "zod";
gio3d0bf032025-06-05 06:57:26 +00007import { useForm, EventType, DeepPartial } from "react-hook-form";
giod0026612025-05-08 13:00:36 +00008import { zodResolver } from "@hookform/resolvers/zod";
gio69ff7592025-07-03 06:27:21 +00009import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form";
giod0026612025-05-08 13:00:36 +000010import { Button } from "./ui/button";
gio33990c62025-05-06 07:51:24 +000011import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040012import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
gio5f2f1002025-03-20 18:38:48 +040013import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
gio91165612025-05-03 17:07:38 +000014import { Textarea } from "./ui/textarea";
giofcefd7c2025-05-13 08:01:07 +000015import { Input } from "./ui/input";
gio3d0bf032025-06-05 06:57:26 +000016import { Switch } from "./ui/switch";
gio48fde052025-05-14 09:48:08 +000017import { Label } from "./ui/label";
gio3d0bf032025-06-05 06:57:26 +000018import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
19import { Code, Container, Network, Pencil, Variable } from "lucide-react";
gio3d0bf032025-06-05 06:57:26 +000020import { Badge } from "./ui/badge";
21import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
gio3fb133d2025-06-13 07:20:24 +000022import { Name } from "./node-name";
23import { NodeDetailsProps } from "@/lib/types";
gio9f3d4f52025-07-04 08:42:34 +000024import { Gateway } from "@/Gateways";
gio5f2f1002025-03-20 18:38:48 +040025
26export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000027 const { id, selected } = node;
28 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
29 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
30 return (
gio69148322025-06-19 23:16:12 +040031 <NodeRect id={id} selected={selected} node={node} state={node.data.state}>
giod0026612025-05-08 13:00:36 +000032 <div style={{ padding: "10px 20px" }}>
33 {nodeLabel(node)}
34 <Handle
35 id="repository"
36 type={"target"}
37 position={Position.Left}
38 isConnectableStart={isConnectableRepository}
39 isConnectableEnd={isConnectableRepository}
40 isConnectable={isConnectableRepository}
41 />
42 <Handle
43 id="ports"
44 type={"source"}
45 position={Position.Top}
46 isConnectableStart={isConnectablePorts}
47 isConnectableEnd={isConnectablePorts}
48 isConnectable={isConnectablePorts}
49 />
50 <Handle
51 id="env_var"
52 type={"target"}
53 position={Position.Bottom}
54 isConnectableStart={true}
55 isConnectableEnd={true}
56 isConnectable={true}
57 />
58 </div>
59 </NodeRect>
60 );
gio5f2f1002025-03-20 18:38:48 +040061}
62
63const schema = z.object({
giod0026612025-05-08 13:00:36 +000064 name: z.string().min(1, "requried"),
65 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +040066});
67
gio33990c62025-05-06 07:51:24 +000068const sourceSchema = z.object({
giod0026612025-05-08 13:00:36 +000069 id: z.string().min(1, "required"),
70 branch: z.string(),
71 rootDir: z.string(),
gio33990c62025-05-06 07:51:24 +000072});
73
gio48fde052025-05-14 09:48:08 +000074const devSchema = z.object({
75 enabled: z.boolean(),
76});
77
78const exposeSchema = z.object({
79 network: z.string().min(1, "reqired"),
80 subdomain: z.string().min(1, "required"),
81});
82
gio69148322025-06-19 23:16:12 +040083const agentSchema = z.object({
gio69ff7592025-07-03 06:27:21 +000084 model: z.enum(["gemini", "claude"]),
85 apiKey: z.string().optional(),
gio69148322025-06-19 23:16:12 +040086});
87
gioe7734b22025-06-13 10:12:04 +000088export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
gio3d0bf032025-06-05 06:57:26 +000089 const { data } = node;
90 return (
91 <>
gio3fb133d2025-06-13 07:20:24 +000092 {showName ? <Name node={node} disabled={disabled} /> : null}
gio3d0bf032025-06-05 06:57:26 +000093 <Tabs defaultValue="runtime">
94 <TabsList className="w-full flex flex-row justify-between">
95 <TabsTrigger value="runtime">
gioe7734b22025-06-13 10:12:04 +000096 {isOverview ? (
97 <div className="flex flex-row gap-1 items-center">
98 <Container /> Runtime
99 </div>
100 ) : (
101 <TooltipProvider>
102 <Tooltip>
103 <TooltipTrigger>
104 <Container />
105 </TooltipTrigger>
106 <TooltipContent>Runtime</TooltipContent>
107 </Tooltip>
108 </TooltipProvider>
109 )}
gio3d0bf032025-06-05 06:57:26 +0000110 </TabsTrigger>
111 <TabsTrigger value="ports">
gioe7734b22025-06-13 10:12:04 +0000112 {isOverview ? (
113 <div className="flex flex-row gap-1 items-center">
114 <Network /> Ports
115 <Badge className="rounded-full">{data.ports?.length ?? 0}</Badge>
116 </div>
117 ) : (
118 <TooltipProvider>
119 <Tooltip>
120 <TooltipTrigger className="flex flex-row gap-1 items-center">
121 <Network />
122 </TooltipTrigger>
123 <TooltipContent>
124 Ports{" "}
125 <Badge variant="secondary" className="rounded-full">
126 {data.ports?.length ?? 0}
127 </Badge>
128 </TooltipContent>
129 </Tooltip>
130 </TooltipProvider>
131 )}
gio3d0bf032025-06-05 06:57:26 +0000132 </TabsTrigger>
133 <TabsTrigger value="vars">
gioe7734b22025-06-13 10:12:04 +0000134 {isOverview ? (
135 <div className="flex flex-row gap-1 items-center">
136 <Variable /> Variables
137 <Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge>
138 </div>
139 ) : (
140 <TooltipProvider>
141 <Tooltip>
142 <TooltipTrigger className="flex flex-row gap-1 items-center">
143 <Variable />
144 </TooltipTrigger>
145 <TooltipContent>
146 Variables{" "}
147 <Badge variant="secondary" className="rounded-full">
148 {data.envVars?.length ?? 0}
149 </Badge>
150 </TooltipContent>
151 </Tooltip>
152 </TooltipProvider>
153 )}
gio3d0bf032025-06-05 06:57:26 +0000154 </TabsTrigger>
gio69148322025-06-19 23:16:12 +0400155 {node.data.type !== "sketch:latest" && (
156 <TabsTrigger value="dev">
157 {isOverview ? (
158 <div className="flex flex-row gap-1 items-center">
159 <Code /> Dev
160 </div>
161 ) : (
162 <TooltipProvider>
163 <Tooltip>
164 <TooltipTrigger className="flex flex-row gap-1 items-center">
165 <Code />
166 </TooltipTrigger>
167 <TooltipContent>Dev</TooltipContent>
168 </Tooltip>
169 </TooltipProvider>
170 )}
171 </TabsTrigger>
172 )}
gio3d0bf032025-06-05 06:57:26 +0000173 </TabsList>
174 <TabsContent value="runtime">
175 <Runtime node={node} disabled={disabled} />
176 </TabsContent>
177 <TabsContent value="ports">
178 <Ports node={node} disabled={disabled} />
179 </TabsContent>
180 <TabsContent value="vars">
181 <EnvVars node={node} disabled={disabled} />
182 </TabsContent>
gio69148322025-06-19 23:16:12 +0400183 {node.data.type !== "sketch:latest" && (
184 <TabsContent value="dev">
185 <Dev node={node} disabled={disabled} />
186 </TabsContent>
187 )}
gio3d0bf032025-06-05 06:57:26 +0000188 </Tabs>
189 </>
190 );
191}
192
gio3d0bf032025-06-05 06:57:26 +0000193function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
194 const { id, data } = node;
195 const store = useStateStore();
giod0026612025-05-08 13:00:36 +0000196 const form = useForm<z.infer<typeof schema>>({
197 resolver: zodResolver(schema),
198 mode: "onChange",
199 defaultValues: {
200 name: data.label,
201 type: data.type,
202 },
203 });
giod0026612025-05-08 13:00:36 +0000204 useEffect(() => {
205 const sub = form.watch(
206 (
207 value: DeepPartial<z.infer<typeof schema>>,
208 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
209 ) => {
giod0026612025-05-08 13:00:36 +0000210 if (type !== "change") {
211 return;
212 }
213 switch (name) {
214 case "name":
215 if (!value.name) {
216 break;
217 }
218 store.updateNodeData<"app">(id, {
219 label: value.name,
220 });
221 break;
222 case "type":
223 if (!value.type) {
224 break;
225 }
226 store.updateNodeData<"app">(id, {
227 type: value.type,
228 });
229 break;
230 }
231 },
232 );
233 return () => sub.unsubscribe();
234 }, [id, form, store]);
giod0026612025-05-08 13:00:36 +0000235 const [typeProps, setTypeProps] = useState({});
236 useEffect(() => {
237 if (data.activeField === "type") {
238 setTypeProps({
239 open: true,
240 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
241 });
242 } else {
243 setTypeProps({});
244 }
245 }, [id, data, store, setTypeProps]);
gio3d0bf032025-06-05 06:57:26 +0000246 const setPreBuildCommands = useCallback(
247 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
248 store.updateNodeData<"app">(id, {
249 preBuildCommands: e.currentTarget.value,
giod0026612025-05-08 13:00:36 +0000250 });
251 },
gio3d0bf032025-06-05 06:57:26 +0000252 [id, store],
giod0026612025-05-08 13:00:36 +0000253 );
gio69148322025-06-19 23:16:12 +0400254 const agentForm = useForm<z.infer<typeof agentSchema>>({
255 resolver: zodResolver(agentSchema),
256 mode: "onChange",
257 defaultValues: {
gio69ff7592025-07-03 06:27:21 +0000258 apiKey: data.model?.apiKey,
259 model: data.model?.name,
gio69148322025-06-19 23:16:12 +0400260 },
261 });
262 useEffect(() => {
gio69ff7592025-07-03 06:27:21 +0000263 const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
264 switch (name) {
265 case "model":
266 agentForm.setValue("apiKey", "", { shouldDirty: true });
267 store.updateNodeData<"app">(id, {
268 model: {
269 name: value.model,
270 apiKey: undefined,
271 },
272 });
273 break;
274 case "apiKey":
275 store.updateNodeData<"app">(id, {
276 model: {
277 name: data.model?.name,
278 apiKey: value.apiKey,
279 },
280 });
281 break;
282 }
gio69148322025-06-19 23:16:12 +0400283 });
284 return () => sub.unsubscribe();
gio69ff7592025-07-03 06:27:21 +0000285 }, [id, agentForm, store, data]);
gio3d0bf032025-06-05 06:57:26 +0000286 return (
287 <>
288 <SourceRepo node={node} disabled={disabled} />
gio69148322025-06-19 23:16:12 +0400289 {node.data.type !== "sketch:latest" && (
290 <Form {...form}>
291 <form className="space-y-2">
292 <Label>Container Image</Label>
293 <FormField
294 control={form.control}
295 name="type"
296 render={({ field }) => (
297 <FormItem>
298 <Select
299 onValueChange={field.onChange}
300 value={field.value || ""}
301 {...typeProps}
302 disabled={disabled}
303 >
304 <FormControl>
305 <SelectTrigger>
306 <SelectValue />
307 </SelectTrigger>
308 </FormControl>
309 <SelectContent>
310 {ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
311 <SelectItem key={t} value={t}>
312 {t}
313 </SelectItem>
314 ))}
315 </SelectContent>
316 </Select>
317 <FormMessage />
318 </FormItem>
319 )}
320 />
321 </form>
322 </Form>
323 )}
324 {node.data.type === "sketch:latest" && (
325 <Form {...agentForm}>
326 <form className="space-y-2">
gio69148322025-06-19 23:16:12 +0400327 <FormField
328 control={agentForm.control}
gio69ff7592025-07-03 06:27:21 +0000329 name="model"
330 render={({ field }) => (
331 <FormItem>
332 <FormLabel>AI Model</FormLabel>
333 <Select
334 onValueChange={field.onChange}
335 defaultValue={field.value}
336 disabled={disabled}
337 >
338 <FormControl>
339 <SelectTrigger>
340 <SelectValue placeholder="Select a model" />
341 </SelectTrigger>
342 </FormControl>
343 <SelectContent>
344 <SelectItem value="gemini">Gemini</SelectItem>
345 <SelectItem value="claude">Claude</SelectItem>
346 </SelectContent>
347 </Select>
348 <FormMessage />
349 </FormItem>
350 )}
351 />
352 <Label>API Key</Label>
353 <FormField
354 control={agentForm.control}
355 name="apiKey"
gio69148322025-06-19 23:16:12 +0400356 render={({ field }) => (
357 <FormItem>
gio3d0bf032025-06-05 06:57:26 +0000358 <FormControl>
gio69148322025-06-19 23:16:12 +0400359 <Input
360 type="password"
gio69ff7592025-07-03 06:27:21 +0000361 placeholder="Override AI Model API key"
gio69148322025-06-19 23:16:12 +0400362 {...field}
363 value={field.value || ""}
364 disabled={disabled}
365 />
gio3d0bf032025-06-05 06:57:26 +0000366 </FormControl>
gio69148322025-06-19 23:16:12 +0400367 <FormMessage />
368 </FormItem>
369 )}
370 />
371 </form>
372 </Form>
373 )}
374 {node.data.type !== "sketch:latest" && (
375 <>
376 <Label>Pre-Build Commands</Label>
377 <Textarea
378 placeholder="new line separated list of commands to run before running the service"
379 value={data.preBuildCommands}
380 onChange={setPreBuildCommands}
381 disabled={disabled}
gio3d0bf032025-06-05 06:57:26 +0000382 />
gio69148322025-06-19 23:16:12 +0400383 </>
384 )}
gio3d0bf032025-06-05 06:57:26 +0000385 </>
giod0026612025-05-08 13:00:36 +0000386 );
gio3d0bf032025-06-05 06:57:26 +0000387}
388
389function Ports({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
390 const { id, data } = node;
391 const store = useStateStore();
gio9f3d4f52025-07-04 08:42:34 +0000392 const nodes = useNodes<AppNode>();
393 const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({});
394
395 const httpsGateways = useMemo(
396 () => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"),
397 [nodes],
398 );
399
400 useEffect(() => {
401 if (!data.ports) {
402 setPortIngresses({});
403 return;
404 }
405 const newIngresses: Record<string, string[]> = {};
406 for (const port of data.ports) {
407 newIngresses[port.id] = [];
408 }
409 for (const gateway of httpsGateways) {
410 const https = gateway.data.https;
411 if (https && https.serviceId === id && https.portId && gateway.data.network && gateway.data.subdomain) {
412 const url = `https://${gateway.data.subdomain}.${gateway.data.network}`;
413 if (newIngresses[https.portId]) {
414 newIngresses[https.portId].push(url);
415 } else {
416 newIngresses[https.portId] = [url];
417 }
418 }
419 }
420 setPortIngresses(newIngresses);
421 console.log(newIngresses);
422 }, [id, data.ports, httpsGateways]);
423
gio3d0bf032025-06-05 06:57:26 +0000424 const [name, setName] = useState("");
425 const [value, setValue] = useState("");
426 const onSubmit = useCallback(() => {
427 const portId = uuidv4();
428 store.updateNodeData<"app">(id, {
429 ports: (data.ports || []).concat({
430 id: portId,
431 name: name.toUpperCase(),
432 value: Number(value),
433 }),
gio73ac16c2025-07-03 14:38:04 +0000434 envVars: (data.envVars || []).concat(
435 {
436 id: uuidv4(),
437 source: null,
438 portId,
439 name: `DODO_PORT_${name.toUpperCase()}`,
440 },
441 {
442 id: uuidv4(),
443 source: null,
444 portId,
445 name: `DODO_PORT_${name.toUpperCase()}`,
446 alias: name.toUpperCase(),
447 },
448 ),
gio3d0bf032025-06-05 06:57:26 +0000449 });
450 setName("");
451 setValue("");
452 }, [id, data, store, name, value, setName, setValue]);
giod0026612025-05-08 13:00:36 +0000453 const removePort = useCallback(
454 (portId: string) => {
455 // TODO(gio): this is ugly
456 const tcpRemoved = new Set<string>();
giod0026612025-05-08 13:00:36 +0000457 store.setEdges(
458 store.edges.filter((e) => {
459 if (e.source !== id || e.sourceHandle !== "ports") {
460 return true;
461 }
462 const tn = store.nodes.find((n) => n.id == e.target)!;
463 if (e.targetHandle === "https") {
464 const t = tn as GatewayHttpsNode;
465 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
466 return false;
467 }
468 }
469 if (e.targetHandle === "tcp") {
470 const t = tn as GatewayTCPNode;
471 if (tcpRemoved.has(t.id)) {
472 return true;
473 }
474 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
475 tcpRemoved.add(t.id);
476 return false;
477 }
478 }
479 if (e.targetHandle === "env_var") {
480 if (
481 tn &&
482 (tn.data.envVars || []).find(
483 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
484 )
485 ) {
486 return false;
487 }
488 }
489 return true;
490 }),
491 );
492 store.nodes
493 .filter(
494 (n) =>
495 n.type === "gateway-https" &&
496 n.data.https &&
497 n.data.https.serviceId === id &&
498 n.data.https.portId === portId,
499 )
500 .forEach((n) => {
501 store.updateNodeData<"gateway-https">(n.id, {
502 https: undefined,
503 });
504 });
505 store.nodes
506 .filter((n) => n.type === "gateway-tcp")
507 .forEach((n) => {
508 const filtered = n.data.exposed.filter((e) => {
509 if (e.serviceId === id && e.portId === portId) {
510 return false;
511 } else {
512 return true;
513 }
514 });
515 if (filtered.length != n.data.exposed.length) {
516 store.updateNodeData<"gateway-tcp">(n.id, {
517 exposed: filtered,
518 });
519 }
520 });
521 store.nodes
522 .filter((n) => n.type === "app" && n.data.envVars)
523 .forEach((n) => {
524 store.updateNodeData<"app">(n.id, {
525 envVars: n.data.envVars.filter((ev) => {
526 if (ev.source === id && "portId" in ev && ev.portId === portId) {
527 return false;
528 }
529 return true;
530 }),
531 });
532 });
533 store.updateNodeData<"app">(id, {
534 ports: (data.ports || []).filter((p) => p.id !== portId),
535 envVars: (data.envVars || []).filter(
536 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
537 ),
538 });
539 },
540 [id, data, store],
541 );
gio3d0bf032025-06-05 06:57:26 +0000542 return (
543 <div className="flex flex-col gap-1">
544 <div className="grid grid-cols-[1fr_1fr_auto] gap-1">
545 {data &&
546 data.ports &&
547 data.ports.map((p) => (
548 <>
gio9f3d4f52025-07-04 08:42:34 +0000549 <div className="contents">
550 <div className="flex items-center px-3">{p.name.toUpperCase()}</div>
551 <div className="flex items-center px-3">{p.value}</div>
552 <div className="flex items-center">
553 <Button
554 variant="destructive"
555 className="w-full"
556 onClick={() => removePort(p.id)}
557 disabled={disabled}
558 >
559 Remove
560 </Button>
561 </div>
gio3d0bf032025-06-05 06:57:26 +0000562 </div>
gio9f3d4f52025-07-04 08:42:34 +0000563 {portIngresses[p.id]?.length > 0 && (
564 <div key={p.id} className="col-span-full pl-6">
565 {portIngresses[p.id].map((url) => (
566 <Gateway g={{ type: "https", address: url, name: p.name }} />
567 ))}
568 </div>
569 )}
gio3d0bf032025-06-05 06:57:26 +0000570 </>
571 ))}
572 <div>
573 <Input
574 placeholder="name"
575 className="uppercase w-0 min-w-full"
576 disabled={disabled}
577 value={name}
578 onChange={(e) => setName(e.target.value)}
579 />
580 </div>
581 <div>
582 <Input
583 placeholder="0"
584 className="w-0 min-w-full"
585 disabled={disabled}
586 value={value}
587 onChange={(e) => setValue(e.target.value)}
588 />
589 </div>
590 <div>
591 <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
592 Add
593 </Button>
594 </div>
595 </div>
596 </div>
597 );
598}
599
600function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
601 const { id, data } = node;
602 const store = useStateStore();
gio1dacf1c2025-07-03 16:39:04 +0000603 const [name, setName] = useState("");
604 const [value, setValue] = useState("");
605
606 const addEnvVar = useCallback(() => {
607 if (!name.trim() || !value.trim()) return;
608 store.updateNodeData<"app">(id, {
609 envVars: (data.envVars || []).concat({
610 id: uuidv4(),
611 source: null,
612 name: name.toUpperCase(),
613 value: value,
614 }),
615 });
616 setName("");
617 setValue("");
618 }, [id, data, store, name, value]);
619
620 const removeEnvVar = useCallback(
621 (varId: string) => {
622 store.updateNodeData<"app">(id, {
623 envVars: (data.envVars || []).filter((v) => v.id !== varId),
624 });
625 },
626 [id, data, store],
627 );
628
629 const editValueEnvVar = useCallback(
630 (varId: string) => {
631 if (disabled) return;
632 store.updateNodeData<"app">(id, {
633 envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)),
634 });
635 },
636 [id, data, store, disabled],
637 );
638
639 const saveValueEnvVar = useCallback(
640 (varId: string, newName: string, newValue: string) => {
641 store.updateNodeData<"app">(id, {
642 envVars: (data.envVars || []).map((v) => {
643 if (v.id === varId) {
644 return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false };
645 }
646 return v;
647 }),
648 });
649 },
650 [id, data, store],
651 );
652
gio3d0bf032025-06-05 06:57:26 +0000653 const editAlias = useCallback(
654 (e: BoundEnvVar) => {
655 return () => {
gioff9b5522025-07-03 13:50:30 +0000656 if (disabled) {
657 return;
658 }
gio3d0bf032025-06-05 06:57:26 +0000659 store.updateNodeData(id, {
660 ...data,
661 envVars: data.envVars!.map((o) => {
662 if (o.id !== e.id) {
663 return o;
664 } else
665 return {
666 ...o,
667 isEditting: true,
668 };
669 }),
670 });
671 };
672 },
gioff9b5522025-07-03 13:50:30 +0000673 [id, data, store, disabled],
gio3d0bf032025-06-05 06:57:26 +0000674 );
gio1dacf1c2025-07-03 16:39:04 +0000675
gio3d0bf032025-06-05 06:57:26 +0000676 const saveAlias = useCallback(
677 (e: BoundEnvVar, value: string, store: AppState) => {
678 store.updateNodeData(id, {
679 ...data,
680 envVars: data.envVars!.map((o) => {
681 if (o.id !== e.id) {
682 return o;
683 }
684 if (value) {
gio1dacf1c2025-07-03 16:39:04 +0000685 if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) {
686 return {
687 ...o,
688 isEditting: false,
689 alias: undefined,
690 };
691 } else {
692 return {
693 ...o,
694 isEditting: false,
695 alias: value.toUpperCase(),
696 };
697 }
gio3d0bf032025-06-05 06:57:26 +0000698 }
699 if ("alias" in o) {
700 const { alias: _, ...rest } = o;
701 return {
702 ...rest,
703 isEditting: false,
704 };
705 }
706 return {
707 ...o,
708 isEditting: false,
709 };
710 }),
giod0026612025-05-08 13:00:36 +0000711 });
712 },
gio3d0bf032025-06-05 06:57:26 +0000713 [id, data],
giod0026612025-05-08 13:00:36 +0000714 );
gio1dacf1c2025-07-03 16:39:04 +0000715
gio3d0bf032025-06-05 06:57:26 +0000716 const saveAliasOnEnter = useCallback(
717 (e: BoundEnvVar) => {
718 return (event: KeyboardEvent<HTMLInputElement>) => {
719 if (event.key === "Enter") {
gio3d0bf032025-06-05 06:57:26 +0000720 saveAlias(e, event.currentTarget.value, store);
gio1dacf1c2025-07-03 16:39:04 +0000721 } else if (event.key === "Escape") {
722 store.updateNodeData(id, {
723 ...data,
724 envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)),
725 });
giod0026612025-05-08 13:00:36 +0000726 }
gio3d0bf032025-06-05 06:57:26 +0000727 };
728 },
gio1dacf1c2025-07-03 16:39:04 +0000729 [store, saveAlias, id, data],
gio3d0bf032025-06-05 06:57:26 +0000730 );
gio1dacf1c2025-07-03 16:39:04 +0000731
gio3d0bf032025-06-05 06:57:26 +0000732 const saveAliasOnBlur = useCallback(
733 (e: BoundEnvVar) => {
734 return (event: FocusEvent<HTMLInputElement>) => {
735 saveAlias(e, event.currentTarget.value, store);
736 };
737 },
738 [store, saveAlias],
739 );
gio1dacf1c2025-07-03 16:39:04 +0000740
gio3d0bf032025-06-05 06:57:26 +0000741 return (
gio1dacf1c2025-07-03 16:39:04 +0000742 <div className="flex flex-col gap-1">
743 <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1">
744 {data?.envVars?.map((v) => {
745 if ("value" in v) {
746 if (v.isEditting) {
747 return (
748 <div key={v.id} className="contents">
749 <Input
750 className="uppercase col-start-2"
751 defaultValue={v.name}
752 onKeyUp={(e) => {
753 if (e.key === "Enter") {
754 const nameInput = e.currentTarget;
755 const valueInput = nameInput.parentElement?.querySelector(
756 'input[placeholder="Value"]',
757 ) as HTMLInputElement;
758 if (valueInput) {
759 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
760 }
761 } else if (e.key === "Escape") {
762 store.updateNodeData(id, {
763 ...data,
764 envVars: data.envVars!.map((o) =>
765 o.id === v.id ? { ...o, isEditting: false } : o,
766 ),
767 });
768 }
769 }}
770 autoFocus
771 disabled={disabled}
772 />
773 <Input
774 placeholder="Value"
775 defaultValue={v.value}
776 onKeyUp={(e) => {
777 if (e.key === "Enter") {
778 const valueInput = e.currentTarget;
779 const nameInput = valueInput.parentElement?.querySelector(
780 'input:not([placeholder="Value"])',
781 ) as HTMLInputElement;
782 if (nameInput) {
783 saveValueEnvVar(v.id, nameInput.value, valueInput.value);
784 }
785 } else if (e.key === "Escape") {
786 store.updateNodeData(id, {
787 ...data,
788 envVars: data.envVars!.map((o) =>
789 o.id === v.id ? { ...o, isEditting: false } : o,
790 ),
791 });
792 }
793 }}
794 disabled={disabled}
795 />
796 <Button
797 variant="destructive"
798 size="sm"
799 onClick={() => removeEnvVar(v.id)}
800 disabled={disabled}
801 >
802 Remove
803 </Button>
804 </div>
805 );
806 }
807 return (
808 <div
809 key={v.id}
810 className={`contents ${disabled ? "" : "cursor-text"}`}
811 onClick={() => editValueEnvVar(v.id)}
812 >
813 <div>{!disabled && <Pencil className="w-4 h-4" />}</div>
814 <div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div>
815 <div>{v.value}</div>
816 <Button
817 variant="destructive"
818 size="sm"
819 onClick={(e) => {
820 e.stopPropagation();
821 removeEnvVar(v.id);
822 }}
823 disabled={disabled}
824 >
825 Remove
826 </Button>
827 </div>
828 );
829 }
gio3d0bf032025-06-05 06:57:26 +0000830 if ("name" in v) {
831 const value = "alias" in v ? v.alias : v.name;
832 if (v.isEditting) {
833 return (
gio1dacf1c2025-07-03 16:39:04 +0000834 <Input
835 type="text"
836 className="uppercase col-start-2 col-span-3"
837 defaultValue={value}
838 onKeyUp={saveAliasOnEnter(v)}
839 onBlur={saveAliasOnBlur(v)}
840 autoFocus={true}
841 disabled={disabled}
842 />
gio3d0bf032025-06-05 06:57:26 +0000843 );
844 }
845 return (
gio1dacf1c2025-07-03 16:39:04 +0000846 <div
847 key={v.id}
848 onClick={editAlias(v)}
849 className={`contents ${disabled ? "" : "cursor-text"}`}
850 >
851 {!disabled && <Pencil className="w-4 h-4" />}
852 <div className="col-start-2 col-span-3">
853 <TooltipProvider>
854 <Tooltip>
855 <TooltipTrigger className="uppercase">{value}</TooltipTrigger>
856 <TooltipContent>{v.name}</TooltipContent>
857 </Tooltip>
858 </TooltipProvider>
859 </div>
860 </div>
gio3d0bf032025-06-05 06:57:26 +0000861 );
862 }
gio1dacf1c2025-07-03 16:39:04 +0000863 return null;
gio3d0bf032025-06-05 06:57:26 +0000864 })}
gio1dacf1c2025-07-03 16:39:04 +0000865 {!disabled && (
866 <div className="contents">
867 <Input
868 placeholder="Name"
869 className="uppercase col-start-2"
870 value={name}
871 onChange={(e) => setName(e.target.value)}
872 disabled={disabled}
873 />
874 <Input
875 placeholder="Value"
876 value={value}
877 onChange={(e) => setValue(e.target.value)}
878 disabled={disabled}
879 />
880 <Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}>
881 Add
882 </Button>
883 </div>
884 )}
885 </div>
886 </div>
gio3d0bf032025-06-05 06:57:26 +0000887 );
888}
889
890function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
891 const { id, data } = node;
892 const env = useEnv();
893 const store = useStateStore();
gio48fde052025-05-14 09:48:08 +0000894 const devForm = useForm<z.infer<typeof devSchema>>({
895 resolver: zodResolver(devSchema),
896 mode: "onChange",
897 defaultValues: {
898 enabled: data.dev ? data.dev.enabled : false,
899 },
900 });
901 useEffect(() => {
902 const sub = devForm.watch((value, { name }) => {
903 if (name === "enabled") {
904 if (value.enabled) {
905 const csGateway: Omit<GatewayHttpsNode, "position"> = {
906 id: uuidv4(),
907 type: "gateway-https",
908 data: {
909 readonly: true,
910 https: {
911 serviceId: id,
912 portId: `${id}-code-server`,
913 },
914 network: data.dev?.expose?.network,
915 subdomain: data.dev?.expose?.subdomain,
916 label: "",
917 envVars: [],
918 ports: [],
919 },
920 };
921 const sshGateway: Omit<GatewayTCPNode, "position"> = {
922 id: uuidv4(),
923 type: "gateway-tcp",
924 data: {
925 readonly: true,
926 exposed: [
927 {
928 serviceId: id,
929 portId: `${id}-ssh`,
930 },
931 ],
932 network: data.dev?.expose?.network,
933 subdomain: data.dev?.expose?.subdomain,
934 label: "",
935 envVars: [],
936 ports: [],
937 },
938 };
939 store.addNode(csGateway);
940 store.addNode(sshGateway);
941 store.updateNodeData<"app">(id, {
942 dev: {
943 enabled: true,
944 expose: data.dev?.expose,
945 codeServerNodeId: csGateway.id,
946 sshNodeId: sshGateway.id,
947 },
948 ports: (data.ports || []).concat(
949 {
950 id: `${id}-code-server`,
951 name: "code-server",
952 value: 9090,
953 },
954 {
955 id: `${id}-ssh`,
956 name: "ssh",
957 value: 22,
958 },
959 ),
960 });
961 let edges = store.edges.concat([
962 {
963 id: uuidv4(),
964 source: id,
965 sourceHandle: "ports",
966 target: csGateway.id,
967 targetHandle: "https",
968 },
969 {
970 id: uuidv4(),
971 source: id,
972 sourceHandle: "ports",
973 target: sshGateway.id,
974 targetHandle: "tcp",
975 },
976 ]);
977 if (data.dev?.expose?.network !== undefined) {
978 edges = edges.concat([
979 {
980 id: uuidv4(),
981 source: csGateway.id,
982 sourceHandle: "subdomain",
983 target: data.dev.expose.network,
984 targetHandle: "subdomain",
985 },
986 {
987 id: uuidv4(),
988 source: sshGateway.id,
989 sourceHandle: "subdomain",
990 target: data.dev.expose.network,
991 targetHandle: "subdomain",
992 },
993 ]);
994 }
995 store.setEdges(edges);
996 } else {
997 const { dev } = data;
998 if (dev?.enabled) {
999 store.setNodes(
1000 store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
1001 );
1002 store.setEdges(
1003 store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
1004 );
1005 }
1006 store.updateNodeData<"app">(id, {
1007 dev: {
1008 enabled: false,
1009 expose: dev?.expose,
1010 },
1011 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
1012 });
1013 }
1014 }
1015 });
1016 return () => sub.unsubscribe();
1017 }, [id, data, devForm, store]);
1018 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
1019 resolver: zodResolver(exposeSchema),
1020 mode: "onChange",
1021 defaultValues: {
1022 network: data.dev?.expose?.network,
1023 subdomain: data.dev?.expose?.subdomain,
1024 },
1025 });
1026 useEffect(() => {
1027 const sub = exposeForm.watch(
1028 (
1029 value: DeepPartial<z.infer<typeof exposeSchema>>,
1030 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
1031 ) => {
1032 const { dev } = data;
1033 if (!dev?.enabled) {
1034 return;
1035 }
1036 if (name === "network") {
1037 let edges = store.edges;
1038 if (dev.enabled && dev.expose?.network !== undefined) {
1039 edges = edges.filter((e) => {
1040 if (
1041 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
1042 e.sourceHandle === "subdomain" &&
1043 e.target === dev.expose?.network &&
1044 e.targetHandle === "subdomain"
1045 ) {
1046 return false;
1047 } else {
1048 return true;
1049 }
1050 });
1051 }
1052 if (value.network !== undefined) {
1053 edges = edges.concat(
1054 {
1055 id: uuidv4(),
1056 source: dev.codeServerNodeId,
1057 sourceHandle: "subdomain",
1058 target: value.network,
1059 targetHandle: "subdomain",
1060 },
1061 {
1062 id: uuidv4(),
1063 source: dev.sshNodeId,
1064 sourceHandle: "subdomain",
1065 target: value.network,
1066 targetHandle: "subdomain",
1067 },
1068 );
1069 }
1070 store.setEdges(edges);
1071 store.updateNodeData<"app">(id, {
1072 dev: {
1073 ...dev,
1074 expose: {
1075 network: value.network,
1076 subdomain: dev.expose?.subdomain,
1077 },
1078 },
1079 });
1080 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
1081 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
1082 } else if (name === "subdomain") {
1083 store.updateNodeData<"app">(id, {
1084 dev: {
1085 ...dev,
1086 expose: {
1087 network: dev.expose?.network,
1088 subdomain: value.subdomain,
1089 },
1090 },
1091 });
1092 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
1093 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
1094 }
1095 },
1096 );
1097 return () => sub.unsubscribe();
1098 }, [id, data, exposeForm, store]);
giod0026612025-05-08 13:00:36 +00001099 return (
1100 <>
gio48fde052025-05-14 09:48:08 +00001101 <Form {...devForm}>
1102 <form className="space-y-2">
1103 <FormField
1104 control={devForm.control}
1105 name="enabled"
1106 render={({ field }) => (
1107 <FormItem>
1108 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +00001109 <Switch
gio3ec94242025-05-16 12:46:57 +00001110 id="devEnabled"
1111 onCheckedChange={field.onChange}
1112 checked={field.value}
1113 disabled={disabled}
1114 />
gio3d0bf032025-06-05 06:57:26 +00001115 <Label htmlFor="devEnabled">Dev VM</Label>
gio48fde052025-05-14 09:48:08 +00001116 </div>
1117 <FormMessage />
1118 </FormItem>
1119 )}
1120 />
1121 </form>
1122 </Form>
gio29050d62025-05-16 04:49:26 +00001123 {data.dev && data.dev.enabled && (
1124 <Form {...exposeForm}>
1125 <form className="space-y-2">
gio3d0bf032025-06-05 06:57:26 +00001126 <Label>Network</Label>
gio29050d62025-05-16 04:49:26 +00001127 <FormField
1128 control={exposeForm.control}
1129 name="network"
1130 render={({ field }) => (
1131 <FormItem>
gio3ec94242025-05-16 12:46:57 +00001132 <Select
1133 onValueChange={field.onChange}
gio3d0bf032025-06-05 06:57:26 +00001134 value={field.value || ""}
gio3ec94242025-05-16 12:46:57 +00001135 disabled={disabled}
1136 >
gio29050d62025-05-16 04:49:26 +00001137 <FormControl>
1138 <SelectTrigger>
gio3d0bf032025-06-05 06:57:26 +00001139 <SelectValue />
gio29050d62025-05-16 04:49:26 +00001140 </SelectTrigger>
1141 </FormControl>
1142 <SelectContent>
1143 {env.networks.map((n) => (
1144 <SelectItem
1145 key={n.name}
1146 value={n.domain}
1147 >{`${n.name} - ${n.domain}`}</SelectItem>
1148 ))}
1149 </SelectContent>
1150 </Select>
1151 <FormMessage />
1152 </FormItem>
1153 )}
1154 />
gio3d0bf032025-06-05 06:57:26 +00001155 <Label>Subdomain</Label>
gio29050d62025-05-16 04:49:26 +00001156 <FormField
1157 control={exposeForm.control}
1158 name="subdomain"
1159 render={({ field }) => (
1160 <FormItem>
gio48fde052025-05-14 09:48:08 +00001161 <FormControl>
gio3d0bf032025-06-05 06:57:26 +00001162 <Input {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +00001163 </FormControl>
gio29050d62025-05-16 04:49:26 +00001164 <FormMessage />
1165 </FormItem>
1166 )}
1167 />
1168 </form>
1169 </Form>
1170 )}
giod0026612025-05-08 13:00:36 +00001171 </>
1172 );
1173}
gio3d0bf032025-06-05 06:57:26 +00001174
1175function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
1176 const { id, data } = node;
1177 const store = useStateStore();
1178 const nodes = useNodes<AppNode>();
1179 const repo = useMemo(() => {
1180 return nodes
1181 .filter((n): n is GithubNode => n.type === "github")
1182 .find((n) => n.id === data.repository?.repoNodeId);
1183 }, [nodes, data.repository?.repoNodeId]);
1184 const repos = useGithubRepositories();
1185 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
1186 resolver: zodResolver(sourceSchema),
1187 mode: "onChange",
1188 defaultValues: {
1189 id: data?.repository?.id?.toString(),
1190 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
1191 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
1192 },
1193 });
1194 useEffect(() => {
1195 const sub = sourceForm.watch(
1196 (
1197 value: DeepPartial<z.infer<typeof sourceSchema>>,
1198 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
1199 ) => {
1200 if (name === "id") {
1201 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
1202 if (!newRepoId) return;
1203
1204 const oldGithubNodeId = data.repository?.repoNodeId;
1205 const selectedRepo = repos.find((r) => r.id === newRepoId);
1206
1207 if (!selectedRepo) return;
1208
1209 // If a node for the selected repo already exists, connect to it.
1210 const existingNodeForSelectedRepo = nodes
1211 .filter((n): n is GithubNode => n.type === "github")
1212 .find((n) => n.data.repository?.id === selectedRepo.id);
1213
1214 if (existingNodeForSelectedRepo) {
1215 let { nodes, edges } = store;
1216 if (oldGithubNodeId) {
1217 edges = edges.filter(
1218 (e) =>
1219 !(
1220 e.target === id &&
1221 e.source === oldGithubNodeId &&
1222 e.targetHandle === "repository"
1223 ),
1224 );
1225 }
1226 edges = edges.concat({
1227 id: uuidv4(),
1228 source: existingNodeForSelectedRepo.id,
1229 sourceHandle: "repository",
1230 target: id,
1231 targetHandle: "repository",
1232 });
1233 nodes = nodes.map((n) => {
1234 if (n.id !== id) {
1235 return n;
1236 } else {
1237 const sn = n as ServiceNode;
1238 return {
1239 ...sn,
1240 data: {
1241 ...sn.data,
1242 repository: {
1243 ...sn.data.repository,
1244 id: newRepoId,
1245 repoNodeId: existingNodeForSelectedRepo.id,
1246 },
1247 },
1248 };
1249 }
1250 });
1251 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
1252 const isOldNodeStillUsed = edges.some(
1253 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
1254 );
1255 if (!isOldNodeStillUsed) {
1256 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
1257 }
1258 }
1259 store.setNodes(nodes);
1260 store.setEdges(edges);
1261 return;
1262 }
1263
1264 // No node for selected repo, decide whether to update old node or create a new one.
1265 if (oldGithubNodeId) {
1266 const isOldNodeShared =
1267 store.edges.filter(
1268 (e) =>
1269 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
1270 ).length > 0;
1271
1272 if (!isOldNodeShared) {
1273 // Update old node
1274 store.updateNodeData<"github">(oldGithubNodeId, {
1275 repository: {
1276 id: selectedRepo.id,
1277 sshURL: selectedRepo.ssh_url,
1278 fullName: selectedRepo.full_name,
1279 },
1280 label: selectedRepo.full_name,
1281 });
1282 store.updateNodeData<"app">(id, {
1283 repository: {
1284 ...data.repository,
1285 id: newRepoId,
1286 },
1287 });
1288 } else {
1289 // Create new node because old one is shared
1290 const newGithubNodeId = uuidv4();
1291 store.addNode({
1292 id: newGithubNodeId,
1293 type: "github",
1294 data: {
1295 repository: {
1296 id: selectedRepo.id,
1297 sshURL: selectedRepo.ssh_url,
1298 fullName: selectedRepo.full_name,
1299 },
1300 label: selectedRepo.full_name,
1301 envVars: [],
1302 ports: [],
1303 },
1304 });
1305
1306 let edges = store.edges;
1307 // remove old edge
1308 edges = edges.filter(
1309 (e) =>
1310 !(
1311 e.target === id &&
1312 e.source === oldGithubNodeId &&
1313 e.targetHandle === "repository"
1314 ),
1315 );
1316 // add new edge
1317 edges = edges.concat({
1318 id: uuidv4(),
1319 source: newGithubNodeId,
1320 sourceHandle: "repository",
1321 target: id,
1322 targetHandle: "repository",
1323 });
1324 store.setEdges(edges);
1325 store.updateNodeData<"app">(id, {
1326 repository: {
1327 ...data.repository,
1328 id: newRepoId,
1329 repoNodeId: newGithubNodeId,
1330 },
1331 });
1332 }
1333 } else {
1334 // No old github node, so create a new one
1335 const newGithubNodeId = uuidv4();
1336 store.addNode({
1337 id: newGithubNodeId,
1338 type: "github",
1339 data: {
1340 repository: {
1341 id: selectedRepo.id,
1342 sshURL: selectedRepo.ssh_url,
1343 fullName: selectedRepo.full_name,
1344 },
1345 label: selectedRepo.full_name,
1346 envVars: [],
1347 ports: [],
1348 },
1349 });
1350 store.setEdges(
1351 store.edges.concat({
1352 id: uuidv4(),
1353 source: newGithubNodeId,
1354 sourceHandle: "repository",
1355 target: id,
1356 targetHandle: "repository",
1357 }),
1358 );
1359 store.updateNodeData<"app">(id, {
1360 repository: {
1361 ...data.repository,
1362 id: newRepoId,
1363 repoNodeId: newGithubNodeId,
1364 },
1365 });
1366 }
1367 } else if (name === "branch") {
1368 store.updateNodeData<"app">(id, {
1369 repository: {
1370 ...data?.repository,
1371 branch: value.branch,
1372 },
1373 });
1374 } else if (name === "rootDir") {
1375 store.updateNodeData<"app">(id, {
1376 repository: {
1377 ...data?.repository,
1378 rootDir: value.rootDir,
1379 },
1380 });
1381 }
1382 },
1383 );
1384 return () => sub.unsubscribe();
1385 }, [id, data, sourceForm, store, nodes, repos]);
1386 const [isExpanded, setIsExpanded] = useState(false);
1387 // useEffect(() => {
1388 // if (data.repository === undefined) {
1389 // setIsExpanded(true);
1390 // }
1391 // }, [data.repository, setIsExpanded]);
1392 console.log(data.repository, isExpanded, repo);
1393 return (
1394 <Accordion type="single" collapsible>
1395 <AccordionItem value="repository" className="border-none">
1396 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
1397 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
1398 </AccordionTrigger>
1399 <AccordionContent className="px-1">
1400 <Form {...sourceForm}>
1401 <form className="space-y-2">
1402 <Label>Repository</Label>
1403 <FormField
1404 control={sourceForm.control}
1405 name="id"
1406 render={({ field }) => (
1407 <FormItem>
1408 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
1409 <FormControl>
1410 <SelectTrigger>
1411 <SelectValue />
1412 </SelectTrigger>
1413 </FormControl>
1414 <SelectContent>
1415 {repos.map((r) => (
1416 <SelectItem
1417 key={r.id}
1418 value={r.id.toString()}
1419 >{`${r.full_name}`}</SelectItem>
1420 ))}
1421 </SelectContent>
1422 </Select>
1423 <FormMessage />
1424 </FormItem>
1425 )}
1426 />
1427 <Label>Branch</Label>
1428 <FormField
1429 control={sourceForm.control}
1430 name="branch"
1431 render={({ field }) => (
1432 <FormItem>
1433 <FormControl>
1434 <Input
1435 placeholder="master"
1436 className="lowercase"
1437 {...field}
1438 disabled={disabled}
1439 />
1440 </FormControl>
1441 <FormMessage />
1442 </FormItem>
1443 )}
1444 />
1445 <Label>Root Directory</Label>
1446 <FormField
1447 control={sourceForm.control}
1448 name="rootDir"
1449 render={({ field }) => (
1450 <FormItem>
1451 <FormControl>
1452 <Input placeholder="/" {...field} disabled={disabled} />
1453 </FormControl>
1454 <FormMessage />
1455 </FormItem>
1456 )}
1457 />
1458 </form>
1459 </Form>
1460 </AccordionContent>
1461 </AccordionItem>
1462 </Accordion>
1463 );
1464}