blob: a6eaaf9069b79accf40b58ad1760b4eb377fa8c3 [file] [log] [blame]
import { v4 as uuidv4 } from "uuid";
import { NodeRect } from "./node-rect";
import {
useStateStore,
nodeLabel,
AppState,
nodeIsConnectable,
useEnv,
useGithubRepositories,
useMode,
} from "@/lib/state";
import {
ServiceNode,
ServiceTypes,
GatewayHttpsNode,
GatewayTCPNode,
BoundEnvVar,
AppNode,
GithubNode,
Machines,
Machine,
MachinesSchema,
} from "config";
import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } from "react";
import { z } from "zod";
import { useForm, EventType, DeepPartial } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form";
import { Button } from "./ui/button";
import { Handle, Position, useNodes } from "@xyflow/react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { Textarea } from "./ui/textarea";
import { Input } from "./ui/input";
import { Switch } from "./ui/switch";
import { Label } from "./ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { Check, Code, Container, Copy, Network, Pencil, Variable } from "lucide-react";
import { Badge } from "./ui/badge";
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
import { Name } from "./node-name";
import { NodeDetailsProps } from "@/lib/types";
import { Gateway } from "@/Gateways";
import { Port } from "config";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
import { useToast } from "@/hooks/use-toast";
import { LoaderCircle } from "lucide-react";
const sourceSchema = z.object({
id: z.string().min(1, "required"),
branch: z.string(),
rootDir: z.string(),
});
const devSchema = z.object({
enabled: z.boolean(),
mode: z.enum(["VM", "PROXY"]).optional(),
});
const exposeSchema = z.object({
network: z.string().min(1, "reqired"),
subdomain: z.string().min(1, "required"),
});
const agentSchema = z.object({
model: z.enum(["gemini", "claude"]),
apiKey: z.string().optional(),
});
const proxySchema = z.object({
address: z.string().min(1, "required"),
});
const portExposeSchema = z
.object({
type: z.enum(["https", "tcp"]),
network: z.string().min(1, "Required"),
subdomain: z.string().optional(),
})
.refine(
(data) => {
if (data.type === "https" || data.type === "tcp") {
return !!data.subdomain && data.subdomain.length > 0;
}
return true;
},
{
message: "Subdomain is required",
path: ["subdomain"],
},
);
type PortExposeFormValues = z.infer<typeof portExposeSchema>;
export function NodeApp(node: ServiceNode) {
const { id, selected } = node;
const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
return (
<NodeRect id={id} selected={selected} node={node} state={node.data.state}>
<div style={{ padding: "10px 20px" }}>
{nodeLabel(node)}
<Handle
id="repository"
type={"target"}
position={Position.Left}
isConnectableStart={isConnectableRepository}
isConnectableEnd={isConnectableRepository}
isConnectable={isConnectableRepository}
/>
<Handle
id="ports"
type={"source"}
position={Position.Top}
isConnectableStart={isConnectablePorts}
isConnectableEnd={isConnectablePorts}
isConnectable={isConnectablePorts}
/>
<Handle
id="env_var"
type={"target"}
position={Position.Bottom}
isConnectableStart={true}
isConnectableEnd={true}
isConnectable={true}
/>
</div>
</NodeRect>
);
}
const schema = z.object({
name: z.string().min(1, "requried"),
type: z.enum(ServiceTypes),
});
function ExposeForm({
node,
port,
onDone,
disabled,
}: {
node: ServiceNode;
port: Port;
onDone: () => void;
disabled?: boolean;
}) {
const store = useStateStore();
const nodes = useNodes<AppNode>();
const env = useEnv();
const form = useForm<PortExposeFormValues>({
resolver: zodResolver(portExposeSchema),
mode: "onChange",
defaultValues: {
type: "https",
},
});
const onSubmit = (data: PortExposeFormValues) => {
const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network);
if (!networkNode) {
// TODO: should show an error to the user
return;
}
if (data.type === "https") {
const newNode: Omit<GatewayHttpsNode, "position"> = {
id: uuidv4(),
type: "gateway-https",
data: {
https: {
serviceId: node.id,
portId: port.id,
},
network: data.network,
subdomain: data.subdomain!,
label: "",
envVars: [],
ports: [],
},
};
store.addNode(newNode);
store.setEdges(
store.edges.concat(
{
id: uuidv4(),
source: node.id,
sourceHandle: "ports",
target: newNode.id,
targetHandle: "https",
},
{
id: uuidv4(),
source: newNode.id,
sourceHandle: "subdomain",
target: networkNode.id,
targetHandle: "subdomain",
},
),
);
} else if (data.type === "tcp") {
const existingGateway = nodes.find(
(n): n is GatewayTCPNode =>
n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain,
);
if (existingGateway) {
store.updateNodeData<"gateway-tcp">(existingGateway.id, {
exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }],
});
let edges = store.edges.concat({
id: uuidv4(),
source: node.id,
sourceHandle: "ports",
target: existingGateway.id,
targetHandle: "tcp",
});
if (
!edges.find(
(e) =>
e.source === existingGateway.id &&
e.target === networkNode.id &&
e.sourceHandle === "subdomain" &&
e.targetHandle === "subdomain",
)
) {
edges = edges.concat({
id: uuidv4(),
source: existingGateway.id,
sourceHandle: "subdomain",
target: networkNode.id,
targetHandle: "subdomain",
});
}
store.setEdges(edges);
} else {
const newNode: Omit<GatewayTCPNode, "position"> = {
id: uuidv4(),
type: "gateway-tcp",
data: {
exposed: [{ serviceId: node.id, portId: port.id }],
network: data.network,
subdomain: data.subdomain,
label: "",
envVars: [],
ports: [],
},
};
store.addNode(newNode);
store.setEdges(
store.edges.concat(
{
id: uuidv4(),
source: node.id,
sourceHandle: "ports",
target: newNode.id,
targetHandle: "tcp",
},
{
id: uuidv4(),
source: newNode.id,
sourceHandle: "subdomain",
target: networkNode.id,
targetHandle: "subdomain",
},
),
);
}
}
onDone();
};
const type = form.watch("type");
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Gateway Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="https">HTTPS</SelectItem>
<SelectItem value="tcp">TCP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="network"
render={({ field }) => (
<FormItem>
<FormLabel>Network</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a network" />
</SelectTrigger>
</FormControl>
<SelectContent>
{env.networks.map((n) => (
<SelectItem key={n.domain} value={n.domain}>
{n.name} - {n.domain}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(type === "https" || type === "tcp") && (
<FormField
control={form.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormLabel>Subdomain</FormLabel>
<FormControl>
<Input placeholder="subdomain" {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onDone} disabled={disabled}>
Cancel
</Button>
<Button type="submit" disabled={disabled || !form.formState.isValid}>
Expose
</Button>
</div>
</form>
</Form>
);
}
export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
const { data } = node;
const defaultTab = useMemo(() => {
if (data.dev?.enabled) {
return "dev";
}
return "runtime";
}, [data]);
return (
<>
{showName ? <Name node={node} disabled={disabled} /> : null}
<Tabs defaultValue={defaultTab}>
<TabsList className="w-full flex flex-row justify-between">
<TabsTrigger value="runtime">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Container /> Runtime
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Container />
</TooltipTrigger>
<TooltipContent>Runtime</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
<TabsTrigger value="ports">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Network /> Ports
<Badge className="rounded-full">{data.ports?.length ?? 0}</Badge>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1 items-center">
<Network />
</TooltipTrigger>
<TooltipContent>
Ports{" "}
<Badge variant="secondary" className="rounded-full">
{data.ports?.length ?? 0}
</Badge>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
<TabsTrigger value="vars">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Variable /> Variables
<Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge>
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1 items-center">
<Variable />
</TooltipTrigger>
<TooltipContent>
Variables{" "}
<Badge variant="secondary" className="rounded-full">
{data.envVars?.length ?? 0}
</Badge>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
{node.data.type !== "sketch:latest" && (
<TabsTrigger value="dev">
{isOverview ? (
<div className="flex flex-row gap-1 items-center">
<Code /> Dev
</div>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1 items-center">
<Code />
</TooltipTrigger>
<TooltipContent>Dev</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="runtime">
<Runtime node={node} disabled={disabled} />
</TabsContent>
<TabsContent value="ports">
<Ports node={node} disabled={disabled} isOverview={isOverview} />
</TabsContent>
<TabsContent value="vars">
<EnvVars node={node} disabled={disabled} />
</TabsContent>
{node.data.type !== "sketch:latest" && (
<TabsContent value="dev">
<Dev node={node} disabled={disabled} />
</TabsContent>
)}
</Tabs>
</>
);
}
function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
mode: "onChange",
defaultValues: {
name: data.label,
type: data.type,
},
});
useEffect(() => {
const sub = form.watch(
(
value: DeepPartial<z.infer<typeof schema>>,
{ name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
) => {
if (type !== "change") {
return;
}
switch (name) {
case "name":
if (!value.name) {
break;
}
store.updateNodeData<"app">(id, {
label: value.name,
});
break;
case "type":
if (!value.type) {
break;
}
store.updateNodeData<"app">(id, {
type: value.type,
});
break;
}
},
);
return () => sub.unsubscribe();
}, [id, form, store]);
const [typeProps, setTypeProps] = useState({});
useEffect(() => {
if (data.activeField === "type") {
setTypeProps({
open: true,
onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
});
} else {
setTypeProps({});
}
}, [id, data, store, setTypeProps]);
const setPreBuildCommands = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
store.updateNodeData<"app">(id, {
preBuildCommands: e.currentTarget.value,
});
},
[id, store],
);
const agentForm = useForm<z.infer<typeof agentSchema>>({
resolver: zodResolver(agentSchema),
mode: "onChange",
defaultValues: {
apiKey: data.model?.apiKey,
model: data.model?.name,
},
});
useEffect(() => {
const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
switch (name) {
case "model":
agentForm.setValue("apiKey", "", { shouldDirty: true });
store.updateNodeData<"app">(id, {
model: {
name: value.model,
apiKey: undefined,
},
});
break;
case "apiKey":
store.updateNodeData<"app">(id, {
model: {
name: data.model?.name,
apiKey: value.apiKey,
},
});
break;
}
});
return () => sub.unsubscribe();
}, [id, agentForm, store, data]);
return (
<>
<SourceRepo node={node} disabled={disabled} />
{node.data.type !== "sketch:latest" && (
<Form {...form}>
<form className="space-y-2">
<Label>Container Image</Label>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
value={field.value || ""}
{...typeProps}
disabled={disabled}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{node.data.type === "sketch:latest" && (
<Form {...agentForm}>
<form className="space-y-2">
<FormField
control={agentForm.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>AI Model</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
disabled={disabled}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="gemini">Gemini</SelectItem>
<SelectItem value="claude">Claude</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Label>API Key</Label>
<FormField
control={agentForm.control}
name="apiKey"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="password"
placeholder="Override AI Model API key"
{...field}
value={field.value || ""}
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{node.data.type !== "sketch:latest" && (
<>
<Label>Pre-Build Commands</Label>
<Textarea
placeholder="new line separated list of commands to run before running the service"
value={data.preBuildCommands}
onChange={setPreBuildCommands}
disabled={disabled}
/>
</>
)}
</>
);
}
function Ports({
node,
disabled,
isOverview,
}: {
node: ServiceNode;
disabled?: boolean;
isOverview?: boolean;
}): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const nodes = useNodes<AppNode>();
const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({});
const [exposingPortId, setExposingPortId] = useState<string | null>(null);
const httpsGateways = useMemo(
() => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"),
[nodes],
);
useEffect(() => {
if (!data.ports) {
setPortIngresses({});
return;
}
const newIngresses: Record<string, string[]> = {};
for (const port of data.ports) {
newIngresses[port.id] = [];
}
for (const gateway of httpsGateways) {
const https = gateway.data.https;
if (https && https.serviceId === id && https.portId && gateway.data.network && gateway.data.subdomain) {
const url = `https://${gateway.data.subdomain}.${gateway.data.network}`;
if (newIngresses[https.portId]) {
newIngresses[https.portId].push(url);
} else {
newIngresses[https.portId] = [url];
}
}
}
setPortIngresses(newIngresses);
console.log(newIngresses);
}, [id, data.ports, httpsGateways]);
const [name, setName] = useState("");
const [value, setValue] = useState("");
const onSubmit = useCallback(() => {
const portId = uuidv4();
store.updateNodeData<"app">(id, {
ports: (data.ports || []).concat({
id: portId,
name: name.toUpperCase(),
value: Number(value),
}),
envVars: (data.envVars || []).concat(
{
id: uuidv4(),
source: null,
portId,
name: `DODO_PORT_${name.toUpperCase()}`,
},
{
id: uuidv4(),
source: null,
portId,
name: `DODO_PORT_${name.toUpperCase()}`,
alias: name.toUpperCase(),
},
),
});
setName("");
setValue("");
}, [id, data, store, name, value, setName, setValue]);
const removePort = useCallback(
(portId: string) => {
// TODO(gio): this is ugly
const tcpRemoved = new Set<string>();
store.setEdges(
store.edges.filter((e) => {
if (e.source !== id || e.sourceHandle !== "ports") {
return true;
}
const tn = store.nodes.find((n) => n.id == e.target)!;
if (e.targetHandle === "https") {
const t = tn as GatewayHttpsNode;
if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
return false;
}
}
if (e.targetHandle === "tcp") {
const t = tn as GatewayTCPNode;
if (tcpRemoved.has(t.id)) {
return true;
}
if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
tcpRemoved.add(t.id);
return false;
}
}
if (e.targetHandle === "env_var") {
if (
tn &&
(tn.data.envVars || []).find(
(ev) => ev.source === id && "portId" in ev && ev.portId === portId,
)
) {
return false;
}
}
return true;
}),
);
store.nodes
.filter(
(n) =>
n.type === "gateway-https" &&
n.data.https &&
n.data.https.serviceId === id &&
n.data.https.portId === portId,
)
.forEach((n) => {
store.updateNodeData<"gateway-https">(n.id, {
https: undefined,
});
});
store.nodes
.filter((n) => n.type === "gateway-tcp")
.forEach((n) => {
const filtered = n.data.exposed.filter((e) => {
if (e.serviceId === id && e.portId === portId) {
return false;
} else {
return true;
}
});
if (filtered.length != n.data.exposed.length) {
store.updateNodeData<"gateway-tcp">(n.id, {
exposed: filtered,
});
}
});
store.nodes
.filter((n) => n.type === "app" && n.data.envVars)
.forEach((n) => {
store.updateNodeData<"app">(n.id, {
envVars: n.data.envVars.filter((ev) => {
if (ev.source === id && "portId" in ev && ev.portId === portId) {
return false;
}
return true;
}),
});
});
store.updateNodeData<"app">(id, {
ports: (data.ports || []).filter((p) => p.id !== portId),
envVars: (data.envVars || []).filter(
(ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
),
});
},
[id, data, store],
);
return (
<div className="flex flex-col gap-1">
<div className="grid grid-cols-[1fr_1fr_auto] gap-1">
{data &&
data.ports &&
data.ports.map((p) => (
<div key={p.id} className="contents">
<div className="contents">
<div className="flex items-center px-3">{p.name.toUpperCase()}</div>
<div className="flex items-center px-3">{p.value}</div>
<div className="flex items-center gap-1">
{isOverview && (
<Button
variant="outline"
onClick={() => setExposingPortId(p.id)}
disabled={disabled}
>
Expose
</Button>
)}
<Button
variant="destructive"
className="w-full"
onClick={() => removePort(p.id)}
disabled={disabled}
>
Remove
</Button>
</div>
</div>
{portIngresses[p.id]?.length > 0 && (
<div key={p.id} className="col-span-full pl-6">
{portIngresses[p.id].map((url) => (
<Gateway key={url} g={{ type: "https", address: url, name: p.name }} />
))}
</div>
)}
{exposingPortId === p.id && (
<Dialog open={true} onOpenChange={() => setExposingPortId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Expose Port {p.name}:{p.value}
</DialogTitle>
</DialogHeader>
<ExposeForm
node={node}
port={p}
onDone={() => setExposingPortId(null)}
disabled={disabled}
/>
</DialogContent>
</Dialog>
)}
</div>
))}
<div>
<Input
placeholder="name"
className="uppercase w-0 min-w-full"
disabled={disabled}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div>
<Input
placeholder="0"
className="w-0 min-w-full"
disabled={disabled}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
<div>
<Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
Add
</Button>
</div>
</div>
</div>
);
}
function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const mode = useMode();
const env = useEnv();
const store = useStateStore();
const [name, setName] = useState("");
const [value, setValue] = useState("");
const addEnvVar = useCallback(() => {
if (!name.trim() || !value.trim()) return;
store.updateNodeData<"app">(id, {
envVars: (data.envVars || []).concat({
id: uuidv4(),
source: null,
name: name.toUpperCase(),
value: value,
}),
});
setName("");
setValue("");
}, [id, data, store, name, value]);
const removeEnvVar = useCallback(
(varId: string) => {
store.updateNodeData<"app">(id, {
envVars: (data.envVars || []).filter((v) => v.id !== varId),
});
},
[id, data, store],
);
const editValueEnvVar = useCallback(
(varId: string) => {
if (disabled) return;
store.updateNodeData<"app">(id, {
envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)),
});
},
[id, data, store, disabled],
);
const saveValueEnvVar = useCallback(
(varId: string, newName: string, newValue: string) => {
store.updateNodeData<"app">(id, {
envVars: (data.envVars || []).map((v) => {
if (v.id === varId) {
return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false };
}
return v;
}),
});
},
[id, data, store],
);
const editAlias = useCallback(
(e: BoundEnvVar) => {
return () => {
if (disabled) {
return;
}
store.updateNodeData(id, {
...data,
envVars: data.envVars!.map((o) => {
if (o.id !== e.id) {
return o;
} else
return {
...o,
isEditting: true,
};
}),
});
};
},
[id, data, store, disabled],
);
const saveAlias = useCallback(
(e: BoundEnvVar, value: string, store: AppState) => {
store.updateNodeData(id, {
...data,
envVars: data.envVars!.map((o) => {
if (o.id !== e.id) {
return o;
}
if (value) {
if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) {
return {
...o,
isEditting: false,
alias: undefined,
};
} else {
return {
...o,
isEditting: false,
alias: value.toUpperCase(),
};
}
}
if ("alias" in o) {
const { alias: _, ...rest } = o;
return {
...rest,
isEditting: false,
};
}
return {
...o,
isEditting: false,
};
}),
});
},
[id, data],
);
const saveAliasOnEnter = useCallback(
(e: BoundEnvVar) => {
return (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
saveAlias(e, event.currentTarget.value, store);
} else if (event.key === "Escape") {
store.updateNodeData(id, {
...data,
envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)),
});
}
};
},
[store, saveAlias, id, data],
);
const saveAliasOnBlur = useCallback(
(e: BoundEnvVar) => {
return (event: FocusEvent<HTMLInputElement>) => {
saveAlias(e, event.currentTarget.value, store);
};
},
[store, saveAlias],
);
const envVars = useMemo(() => {
if (mode !== "deploy") {
return [];
}
return env.access
.filter((a) => a.name === data.label)
.filter((a) => a.type === "env_var")
.map((a) => ({
name: a.var.split("=", 2)[0],
value: a.var.split("=", 2)[1],
}));
}, [mode, env.access, data.label]);
const hiddenEnvVars = useMemo(() => {
return envVars
.map((v) => {
const { name, value } = v;
const match = value.match(/^(postgresql|mongodb):\/\/([^:]+):([^@]+)@([^:/]+)(?::(\d+))?\/(.+)$/);
if (match) {
const [_, protocol, username, _password, host, port, database] = match;
return {
name,
value,
hidden: `${protocol}://${username}:*****@${host}${port ? `:${port}` : ""}/${database}`,
};
}
return {
name,
value,
hidden: value,
};
})
.map((v) => {
return {
...v,
hidden: v.hidden.length > 50 ? v.hidden.slice(0, 50) + "..." : v.hidden,
};
});
}, [envVars]);
const [copied, setCopied] = useState(false);
const [blip, setBlip] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(envVars.map((v) => `${v.name}=${v.value}`).join("\n"));
setCopied(true);
setBlip(true);
setTimeout(() => setCopied(false), 1000);
setTimeout(() => setBlip(false), 300);
};
if (hiddenEnvVars.length > 0) {
return (
<div className="flex flex-col gap-1">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-1 flex-shrink">
{hiddenEnvVars.map((v) => (
<div key={v.name} className="contents">
<div className="uppercase">{v.name}</div>
<div className="min-w-0 truncate">{v.hidden}</div>
</div>
))}
</div>
<div className="flex justify-end">
<Button onClick={handleCopy} className={blip ? "bg-green-100 transition-colors" : ""}>
{copied ? (
<>
<Check className="w-4 h-4 text-green-600" /> Copy
</>
) : (
<>
<Copy className="w-4 h-4" /> Copy
</>
)}
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-1">
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1">
{data?.envVars?.map((v) => {
if ("value" in v) {
if (v.isEditting) {
return (
<div key={v.id} className="contents">
<Input
className="uppercase col-start-2"
defaultValue={v.name}
onKeyUp={(e) => {
if (e.key === "Enter") {
const nameInput = e.currentTarget;
const valueInput = nameInput.parentElement?.querySelector(
'input[placeholder="Value"]',
) as HTMLInputElement;
if (valueInput) {
saveValueEnvVar(v.id, nameInput.value, valueInput.value);
}
} else if (e.key === "Escape") {
store.updateNodeData(id, {
...data,
envVars: data.envVars!.map((o) =>
o.id === v.id ? { ...o, isEditting: false } : o,
),
});
}
}}
autoFocus
disabled={disabled}
/>
<Input
placeholder="Value"
defaultValue={v.value}
onKeyUp={(e) => {
if (e.key === "Enter") {
const valueInput = e.currentTarget;
const nameInput = valueInput.parentElement?.querySelector(
'input:not([placeholder="Value"])',
) as HTMLInputElement;
if (nameInput) {
saveValueEnvVar(v.id, nameInput.value, valueInput.value);
}
} else if (e.key === "Escape") {
store.updateNodeData(id, {
...data,
envVars: data.envVars!.map((o) =>
o.id === v.id ? { ...o, isEditting: false } : o,
),
});
}
}}
disabled={disabled}
/>
<Button
variant="destructive"
size="sm"
onClick={() => removeEnvVar(v.id)}
disabled={disabled}
>
Remove
</Button>
</div>
);
}
return (
<div
key={v.id}
className={`contents ${disabled ? "" : "cursor-text"}`}
onClick={() => editValueEnvVar(v.id)}
>
<div>{!disabled && <Pencil className="w-4 h-4" />}</div>
<div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div>
<div>{v.value}</div>
<Button
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeEnvVar(v.id);
}}
disabled={disabled}
>
Remove
</Button>
</div>
);
}
if ("name" in v) {
const value = "alias" in v ? v.alias : v.name;
if (v.isEditting) {
return (
<Input
type="text"
className="uppercase col-start-2 col-span-3"
defaultValue={value}
onKeyUp={saveAliasOnEnter(v)}
onBlur={saveAliasOnBlur(v)}
autoFocus={true}
disabled={disabled}
/>
);
}
return (
<div
key={v.id}
onClick={editAlias(v)}
className={`contents ${disabled ? "" : "cursor-text"}`}
>
{!disabled && <Pencil className="w-4 h-4" />}
<div className="col-start-2 col-span-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger className="uppercase">{value}</TooltipTrigger>
<TooltipContent>{v.name}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
}
return null;
})}
{!disabled && (
<div className="contents">
<Input
placeholder="Name"
className="uppercase col-start-2"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={disabled}
/>
<Input
placeholder="Value"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={disabled}
/>
<Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}>
Add
</Button>
</div>
)}
</div>
</div>
);
}
function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const { dev } = data;
const prevDev = usePrevious(dev);
const env = useEnv();
const store = useStateStore();
useEffect(() => {
console.log("DDDEV", prevDev, dev);
if (!dev && !prevDev) {
return;
}
if (
dev &&
prevDev &&
dev.enabled === prevDev.enabled &&
"mode" in dev &&
"mode" in prevDev &&
dev.mode === prevDev.mode
) {
return;
}
if (!dev?.enabled || dev.mode !== "VM") {
if (prevDev?.enabled && prevDev.mode === "VM") {
store.setNodes(
store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId),
);
store.setEdges(
store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId),
);
if (dev?.enabled) {
store.updateNodeData<"app">(id, {
dev: {
enabled: dev.enabled,
mode: dev.mode,
},
ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
});
} else {
store.updateNodeData<"app">(id, {
dev: {
enabled: false,
},
ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
});
}
}
} else {
if (!prevDev?.enabled || prevDev.mode !== "VM") {
const csGateway: Omit<GatewayHttpsNode, "position"> = {
id: uuidv4(),
type: "gateway-https",
data: {
readonly: true,
https: {
serviceId: id,
portId: `${id}-code-server`,
},
network: dev?.expose?.network,
subdomain: dev?.expose?.subdomain,
label: "",
envVars: [],
ports: [],
},
};
const sshGateway: Omit<GatewayTCPNode, "position"> = {
id: uuidv4(),
type: "gateway-tcp",
data: {
readonly: true,
exposed: [
{
serviceId: id,
portId: `${id}-ssh`,
},
],
network: dev?.expose?.network,
subdomain: dev?.expose?.subdomain,
label: "",
envVars: [],
ports: [],
},
};
store.addNode(csGateway);
store.addNode(sshGateway);
store.updateNodeData<"app">(id, {
dev: {
enabled: true,
mode: "VM",
expose: dev?.expose,
codeServerNodeId: csGateway.id,
sshNodeId: sshGateway.id,
},
ports: (data.ports || []).concat(
{
id: `${id}-code-server`,
name: "code-server",
value: 9090,
},
{
id: `${id}-ssh`,
name: "ssh",
value: 22,
},
),
});
let edges = store.edges.concat([
{
id: uuidv4(),
source: id,
sourceHandle: "ports",
target: csGateway.id,
targetHandle: "https",
},
{
id: uuidv4(),
source: id,
sourceHandle: "ports",
target: sshGateway.id,
targetHandle: "tcp",
},
]);
if (dev?.expose?.network !== undefined) {
edges = edges.concat([
{
id: uuidv4(),
source: csGateway.id,
sourceHandle: "subdomain",
target: dev.expose.network,
targetHandle: "subdomain",
},
{
id: uuidv4(),
source: sshGateway.id,
sourceHandle: "subdomain",
target: dev.expose.network,
targetHandle: "subdomain",
},
]);
}
store.setEdges(edges);
}
}
}, [id, data, dev, prevDev, store]);
const exposeForm = useForm<z.infer<typeof exposeSchema>>({
resolver: zodResolver(exposeSchema),
mode: "onChange",
defaultValues: {
network: dev && "expose" in dev ? dev.expose?.network : undefined,
subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined,
},
});
useEffect(() => {
const sub = exposeForm.watch(
(
value: DeepPartial<z.infer<typeof exposeSchema>>,
{ name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
) => {
const { dev } = data;
if (!dev?.enabled || dev.mode !== "VM") {
return;
}
if (name === "network") {
let edges = store.edges;
if (dev.enabled && dev.expose?.network !== undefined) {
edges = edges.filter((e) => {
if (
(e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
e.sourceHandle === "subdomain" &&
e.target === dev.expose?.network &&
e.targetHandle === "subdomain"
) {
return false;
} else {
return true;
}
});
}
if (value.network !== undefined) {
edges = edges.concat(
{
id: uuidv4(),
source: dev.codeServerNodeId,
sourceHandle: "subdomain",
target: value.network,
targetHandle: "subdomain",
},
{
id: uuidv4(),
source: dev.sshNodeId,
sourceHandle: "subdomain",
target: value.network,
targetHandle: "subdomain",
},
);
}
store.setEdges(edges);
store.updateNodeData<"app">(id, {
dev: {
...dev,
expose: {
network: value.network,
subdomain: dev.expose?.subdomain,
},
},
});
store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
} else if (name === "subdomain") {
store.updateNodeData<"app">(id, {
dev: {
...dev,
expose: {
network: dev.expose?.network,
subdomain: value.subdomain,
},
},
});
store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
}
},
);
return () => sub.unsubscribe();
}, [id, data, dev, prevDev, exposeForm, store]);
if (!dev?.enabled || dev.mode !== "VM") {
return null;
}
return (
<div>
{data.dev && data.dev.enabled && (
<Form {...exposeForm}>
<form className="space-y-2">
<Label>Network</Label>
<FormField
control={exposeForm.control}
name="network"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
value={field.value || ""}
disabled={disabled}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{env.networks.map((n) => (
<SelectItem
key={n.name}
value={n.domain}
>{`${n.name} - ${n.domain}`}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Label>Subdomain</Label>
<FormField
control={exposeForm.control}
name="subdomain"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
</div>
);
}
function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const { toast } = useToast();
const [machines, setMachines] = useState<Machines>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchMachines = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/machines", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch machines: ${response.statusText}`);
}
const machinesData = MachinesSchema.safeParse(await response.json());
if (machinesData.success) {
setMachines(
machinesData.data
.filter((m) => !m.name.startsWith("proxy-dodo-app-"))
.filter(
(m) => m.last_seen && m.last_seen.seconds * 1000 >= Date.now() - 7 * 24 * 60 * 60 * 1000,
),
);
} else {
throw new Error("Invalid machines data");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines";
setError(errorMessage);
toast({
variant: "destructive",
title: "Error",
description: errorMessage,
});
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") {
fetchMachines();
}
}, [data.dev, fetchMachines]);
const proxyForm = useForm<z.infer<typeof proxySchema>>({
resolver: zodResolver(proxySchema),
mode: "onChange",
defaultValues: {
address: data.dev && "address" in data.dev ? data.dev.address : undefined,
},
});
useEffect(() => {
const sub = proxyForm.watch((value, { name }) => {
if (name === "address" && value.address) {
store.updateNodeData<"app">(id, {
dev: {
enabled: true,
mode: "PROXY",
address: value.address,
},
});
}
});
return () => sub.unsubscribe();
}, [id, proxyForm, store]);
if (!data.dev?.enabled || data.dev.mode !== "PROXY") {
return null;
}
return (
<div className="space-y-2">
<Form {...proxyForm}>
<form className="space-y-2">
<FormField
control={proxyForm.control}
name="address"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
value={field.value || ""}
disabled={disabled || loading}
>
<FormControl>
<SelectTrigger>
{loading ? (
<div className="flex items-center gap-2">
<LoaderCircle className="h-4 w-4 animate-spin" />
<span>Loading machines...</span>
</div>
) : (
<SelectValue placeholder="Select a machine" />
)}
</SelectTrigger>
</FormControl>
<SelectContent>
{loading ? (
<div className="flex items-center justify-center p-4">
<LoaderCircle className="h-4 w-4 animate-spin" />
<span className="ml-2">Loading...</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center p-4 text-destructive">
<span className="text-sm">Failed to load machines</span>
<Button
variant="ghost"
size="sm"
className="mt-2"
onClick={fetchMachines}
>
Retry
</Button>
</div>
) : machines.length === 0 ? (
<div className="flex items-center justify-center p-4 text-muted-foreground">
<span className="text-sm">No machines available</span>
</div>
) : (
machines.map((machine: Machine) => (
<SelectItem key={machine.name} value={machine.name}>
{machine.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
);
}
function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const devForm = useForm<z.infer<typeof devSchema>>({
resolver: zodResolver(devSchema),
mode: "onChange",
defaultValues: {
enabled: data.dev ? data.dev.enabled : false,
mode: data.dev?.enabled ? data.dev.mode : undefined,
},
});
useEffect(() => {
const sub = devForm.watch((value, { name }) => {
console.log("DDDEVV", name, value, data.dev);
if (name === "enabled") {
if (value.enabled) {
if (data.dev?.enabled && data.dev.mode === "VM") {
return;
}
store.updateNodeData<"app">(id, {
dev: {
enabled: true,
mode: "VM",
},
});
devForm.setValue("mode", "VM");
} else {
store.updateNodeData<"app">(id, {
dev: {
enabled: false,
},
});
}
} else if (name === "mode") {
if (data.dev?.enabled && data.dev.mode === value.mode) {
return;
}
store.updateNodeData<"app">(id, {
dev: {
enabled: true,
mode: value.mode,
},
});
}
});
return () => sub.unsubscribe();
}, [id, data, devForm, store]);
return (
<>
<Form {...devForm}>
<form className="space-y-2">
<FormField
control={devForm.control}
name="enabled"
render={({ field }) => (
<FormItem>
<div className="flex flex-row gap-1 items-center">
<Switch
id="devEnabled"
onCheckedChange={field.onChange}
checked={field.value}
disabled={disabled}
/>
<Label htmlFor="devEnabled">Development Mode</Label>
</div>
<FormMessage />
</FormItem>
)}
/>
{data.dev?.enabled && (
<FormField
control={devForm.control}
name="mode"
render={({ field }) => (
<FormItem>
<div className="flex flex-row gap-1 items-center">
<RadioGroup
onValueChange={field.onChange}
value={field.value}
disabled={disabled}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="VM" id="vm" />
<Label htmlFor="vm">Create a VM</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="PROXY" id="proxy" />
<Label htmlFor="proxy">Proxy to existing machine</Label>
</div>
</RadioGroup>
</div>
</FormItem>
)}
/>
)}
</form>
</Form>
<DevVM node={node} disabled={disabled} />
<DevProxy node={node} disabled={disabled} />
</>
);
}
function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const nodes = useNodes<AppNode>();
const repo = useMemo(() => {
return nodes
.filter((n): n is GithubNode => n.type === "github")
.find((n) => n.id === data.repository?.repoNodeId);
}, [nodes, data.repository?.repoNodeId]);
const repos = useGithubRepositories();
const sourceForm = useForm<z.infer<typeof sourceSchema>>({
resolver: zodResolver(sourceSchema),
mode: "onChange",
defaultValues: {
id: data?.repository?.id?.toString(),
branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
},
});
useEffect(() => {
const sub = sourceForm.watch(
(
value: DeepPartial<z.infer<typeof sourceSchema>>,
{ name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
) => {
if (name === "id") {
const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
if (!newRepoId) return;
const oldGithubNodeId = data.repository?.repoNodeId;
const selectedRepo = repos.find((r) => r.id === newRepoId);
if (!selectedRepo) return;
// If a node for the selected repo already exists, connect to it.
const existingNodeForSelectedRepo = nodes
.filter((n): n is GithubNode => n.type === "github")
.find((n) => n.data.repository?.id === selectedRepo.id);
if (existingNodeForSelectedRepo) {
let { nodes, edges } = store;
if (oldGithubNodeId) {
edges = edges.filter(
(e) =>
!(
e.target === id &&
e.source === oldGithubNodeId &&
e.targetHandle === "repository"
),
);
}
edges = edges.concat({
id: uuidv4(),
source: existingNodeForSelectedRepo.id,
sourceHandle: "repository",
target: id,
targetHandle: "repository",
});
nodes = nodes.map((n) => {
if (n.id !== id) {
return n;
} else {
const sn = n as ServiceNode;
return {
...sn,
data: {
...sn.data,
repository: {
...sn.data.repository,
id: newRepoId,
repoNodeId: existingNodeForSelectedRepo.id,
},
},
};
}
});
if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
const isOldNodeStillUsed = edges.some(
(e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
);
if (!isOldNodeStillUsed) {
nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
}
}
store.setNodes(nodes);
store.setEdges(edges);
return;
}
// No node for selected repo, decide whether to update old node or create a new one.
if (oldGithubNodeId) {
const isOldNodeShared =
store.edges.filter(
(e) =>
e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
).length > 0;
if (!isOldNodeShared) {
// Update old node
store.updateNodeData<"github">(oldGithubNodeId, {
repository: {
id: selectedRepo.id,
sshURL: selectedRepo.ssh_url,
fullName: selectedRepo.full_name,
},
label: selectedRepo.full_name,
});
store.updateNodeData<"app">(id, {
repository: {
...data.repository,
id: newRepoId,
},
});
} else {
// Create new node because old one is shared
const newGithubNodeId = uuidv4();
store.addNode({
id: newGithubNodeId,
type: "github",
data: {
repository: {
id: selectedRepo.id,
sshURL: selectedRepo.ssh_url,
fullName: selectedRepo.full_name,
},
label: selectedRepo.full_name,
envVars: [],
ports: [],
},
});
let edges = store.edges;
// remove old edge
edges = edges.filter(
(e) =>
!(
e.target === id &&
e.source === oldGithubNodeId &&
e.targetHandle === "repository"
),
);
// add new edge
edges = edges.concat({
id: uuidv4(),
source: newGithubNodeId,
sourceHandle: "repository",
target: id,
targetHandle: "repository",
});
store.setEdges(edges);
store.updateNodeData<"app">(id, {
repository: {
...data.repository,
id: newRepoId,
repoNodeId: newGithubNodeId,
},
});
}
} else {
// No old github node, so create a new one
const newGithubNodeId = uuidv4();
store.addNode({
id: newGithubNodeId,
type: "github",
data: {
repository: {
id: selectedRepo.id,
sshURL: selectedRepo.ssh_url,
fullName: selectedRepo.full_name,
},
label: selectedRepo.full_name,
envVars: [],
ports: [],
},
});
store.setEdges(
store.edges.concat({
id: uuidv4(),
source: newGithubNodeId,
sourceHandle: "repository",
target: id,
targetHandle: "repository",
}),
);
store.updateNodeData<"app">(id, {
repository: {
...data.repository,
id: newRepoId,
repoNodeId: newGithubNodeId,
},
});
}
} else if (name === "branch") {
store.updateNodeData<"app">(id, {
repository: {
...data?.repository,
branch: value.branch,
},
});
} else if (name === "rootDir") {
store.updateNodeData<"app">(id, {
repository: {
...data?.repository,
rootDir: value.rootDir,
},
});
}
},
);
return () => sub.unsubscribe();
}, [id, data, sourceForm, store, nodes, repos]);
const [isExpanded, setIsExpanded] = useState(false);
// useEffect(() => {
// if (data.repository === undefined) {
// setIsExpanded(true);
// }
// }, [data.repository, setIsExpanded]);
console.log(data.repository, isExpanded, repo);
return (
<Accordion type="single" collapsible>
<AccordionItem value="repository" className="border-none">
<AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
</AccordionTrigger>
<AccordionContent className="px-1">
<Form {...sourceForm}>
<form className="space-y-2">
<Label>Repository</Label>
<FormField
control={sourceForm.control}
name="id"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{repos.map((r) => (
<SelectItem
key={r.id}
value={r.id.toString()}
>{`${r.full_name}`}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Label>Branch</Label>
<FormField
control={sourceForm.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="master"
className="lowercase"
{...field}
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Label>Root Directory</Label>
<FormField
control={sourceForm.control}
name="rootDir"
render={({ field }) => (
<FormItem>
<FormControl>
<Input placeholder="/" {...field} disabled={disabled} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}