Canvas: Form to choose source repository

Change-Id: I48011d6374e036ead934815ed8e88dc0d1bb914e
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 3eea939..dcccd82 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -8,7 +8,7 @@
 import { Form, FormControl, FormField, FormItem, FormMessage } from './ui/form';
 import { Input } from './ui/input';
 import { Button } from './ui/button';
-import { Handle, Position } from "@xyflow/react";
+import { Handle, Position, useNodes } from "@xyflow/react";
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 import { PencilIcon, XIcon } from "lucide-react";
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
@@ -61,8 +61,15 @@
   value: z.coerce.number().gt(0, "can not be negative"),
 });
 
+const sourceSchema = z.object({
+  id: z.string().min(1, "required"),
+  branch: z.string(),
+  rootDir: z.string(),
+});
+
 export function NodeAppDetails({ id, data }: ServiceNode) {
   const store = useStateStore();
+  const nodes = useNodes();
   const form = useForm<z.infer<typeof schema>>({
     resolver: zodResolver(schema),
     mode: "onChange",
@@ -225,7 +232,6 @@
           return true;
         }
         if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
-          console.log(11111, e);
           tcpRemoved.add(t.id);
           return false;
         }
@@ -276,6 +282,64 @@
       preBuildCommands: e.currentTarget.value,
     });
   }, [id, store]);
+
+  const sourceForm = useForm<z.infer<typeof sourceSchema>>({
+    resolver: zodResolver(sourceSchema),
+    mode: "onChange",
+    defaultValues: {
+      id: data?.repository?.id,
+      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 }) => {
+      console.log(value);
+      if (name === "id") {
+        let edges = store.edges;
+        if (data?.repository?.id !== undefined) {
+          edges = edges.filter((e) => {
+            if (e.target === id && e.targetHandle === "repository" && e.source === data.repository.id) {
+              return false;
+            } else {
+              return true;
+            }
+          });
+        }
+        if (value.id !== undefined) {
+          edges = edges.concat({
+            id: uuidv4(),
+            source: value.id,
+            sourceHandle: "repository",
+            target: id,
+            targetHandle: "repository",
+          });
+        }
+        store.setEdges(edges);
+        store.updateNodeData<"app">(id, {
+          repository: {
+            id: value.id,
+          },
+        });
+      } 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]);
+
   return (
     <>
       <Form {...form}>
@@ -315,6 +379,56 @@
           />
         </form>
       </Form>
+      Source
+      <Form {...sourceForm}>
+        <form className="space-y-2">
+          <FormField
+            control={sourceForm.control}
+            name="id"
+            render={({ field }) => (
+              <FormItem>
+                <Select onValueChange={field.onChange} defaultValue={field.value}>
+                  <FormControl>
+                    <SelectTrigger>
+                      <SelectValue placeholder="Repository" />
+                    </SelectTrigger>
+                  </FormControl>
+                  <SelectContent>
+                    {nodes.filter((n) => n.type === "github" && n.data.address).map((n) => (
+                      <SelectItem key={n.id} value={n.id}>{`${n.data.address!}`}</SelectItem>
+                    ))}
+                  </SelectContent>
+                </Select>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+          <FormField
+            control={sourceForm.control}
+            name="branch"
+            render={({ field }) => (
+              <FormItem>
+                <FormControl>
+                  <Input placeholder="master" className="border border-black" {...field} />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+          <FormField
+            control={sourceForm.control}
+            name="rootDir"
+            render={({ field }) => (
+              <FormItem>
+                <FormControl>
+                  <Input placeholder="/" className="border border-black" {...field} />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+        </form>
+      </Form>
       Ports
       <ul>
         {data && data.ports && data.ports.map((p) => (<li key={p.id}><Button size={"icon"} variant={"ghost"} onClick={() => removePort(p.id)}><XIcon /></Button> {p.name} - {p.value}</li>))}
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 3eed93e..22353f8 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -54,7 +54,7 @@
     ingress?: Ingress[];
     expose?: PortDomain[];
     volume?: string[];
-    preBuildCommands?: string[];
+    preBuildCommands?: { bin: string }[];
 };
 
 export type Volume = {
@@ -124,7 +124,7 @@
                         auth: { enabled: false },
                     })),
                     expose: findExpose(n),
-                    preBuildCommands: [n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))],
+                    preBuildCommands: n.data.preBuildCommands ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd })) : [],
                 };
             }),
             volume: nodes.filter((n) => n.type === "volume").map((n): Volume => ({
@@ -214,7 +214,7 @@
 export function CreateValidators(): Validator {
     return SortingValidator(
         CombineValidators(
-            EmptyValidator, 
+            EmptyValidator,
             GitRepositoryValidator,
             ServiceValidator,
             GatewayHTTPSValidator,
@@ -228,7 +228,7 @@
     if (nodes.length > 0) {
         return [];
     }
-    return [{   
+    return [{
         id: "no-nodes",
         type: "FATAL",
         message: "Start by importing application source code",
@@ -254,7 +254,7 @@
         message: "Connect to service",
         onHighlight: (store) => store.setHighlightCategory("Services", true),
         onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
-} satisfies Message));
+    } satisfies Message));
     return noAddress.concat(noApp);
 }
 
@@ -269,8 +269,8 @@
         onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
         onClick: (store) => {
             store.updateNode(n.id, { selected: true });
-            store.updateNodeData<"app">(n.id, { 
-                activeField: "name" ,
+            store.updateNodeData<"app">(n.id, {
+                activeField: "name",
             });
         },
     }));
@@ -291,8 +291,8 @@
         onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
         onClick: (store) => {
             store.updateNode(n.id, { selected: true });
-            store.updateNodeData<"app">(n.id, { 
-                activeField: "type" ,
+            store.updateNodeData<"app">(n.id, {
+                activeField: "type",
             });
         },
     }));
@@ -324,7 +324,7 @@
             },
             onLooseHighlight: (store) => {
                 store.updateNode(n.id, { selected: false });
-                store.setHighlightCategory("gateways", false);    
+                store.setHighlightCategory("gateways", false);
             },
         }));
     });
@@ -339,7 +339,7 @@
             nodeId: n.id,
             message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
             onHighlight: (store) => store.updateNode(n.id, { selected: true }),
-            onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),        
+            onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
         };
     })).filter((m) => m !== undefined);
     return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 664ba6e..ee6f6d2 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -74,7 +74,7 @@
   "golang:1.24.0",
   "hugo:latest",
   "php:8.2-apache",
-  "nextjs:deno-2.0.0", 
+  "nextjs:deno-2.0.0",
   "node-23.1.0"
 ] as const;
 export type ServiceType = typeof ServiceTypes[number];
@@ -83,6 +83,11 @@
   type: ServiceType;
   repository: {
     id: string;
+  } | {
+    id: string;
+    branch: string;
+  } | {
+    id: string;
     branch: string;
     rootDir: string;
   };
@@ -173,11 +178,11 @@
         return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
       } else if (handle === "repository") {
         if (!n.data || !n.data.repository || !n.data.repository.id) {
-            return true;
+          return true;
         }
         return false;
       }
-      return false;   
+      return false;
     case "github":
       if (n.data !== undefined && n.data.address) {
         return true;
@@ -202,7 +207,7 @@
         return false;
       }
       if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
-          return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
+        return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
       }
       return true;
     case undefined: throw new Error("MUST NOT REACH!");
@@ -244,7 +249,7 @@
 export function nodeEnvVarNames(n: AppNode): string[] {
   switch (n.type) {
     case "app": return [
-      `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`, 
+      `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
       ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
     ];
     case "github": return [];
@@ -371,16 +376,18 @@
 const v: Validator = CreateValidators();
 
 export const useStateStore = create<AppState>((set, get): AppState => {
-  set({ env: {
-    "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
-    "networks": [{
-      "name": "Public",
-      "domain": "v1.dodo.cloud",
-    }, {
-      "name": "Private",
-      "domain": "p.v1.dodo.cloud",
-    }],
-  }});
+  set({
+    env: {
+      "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
+      "networks": [{
+        "name": "Public",
+        "domain": "v1.dodo.cloud",
+      }, {
+        "name": "Private",
+        "domain": "p.v1.dodo.cloud",
+      }],
+    }
+  });
   console.log(get().env);
   const setN = (nodes: AppNode[]) => {
     set({
@@ -390,18 +397,18 @@
   };
   function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
     setN(get().nodes.map((n) => {
-        if (n.id !== id) {
-          return n;
-        }
-        const nd = {
-          ...n,
-          data: {
-            ...n.data,
-            ...d,
-          },
-        };
-        return nd;
-      })
+      if (n.id !== id) {
+        return n;
+      }
+      const nd = {
+        ...n,
+        data: {
+          ...n.data,
+          ...d,
+        },
+      };
+      return nd;
+    })
     );
   };
   function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
@@ -429,7 +436,7 @@
         updateNodeData<"gateway-https">(sn.id, {
           network: tn.data.domain,
         });
-      }else if (sn.type === "gateway-tcp") {
+      } else if (sn.type === "gateway-tcp") {
         updateNodeData<"gateway-tcp">(sn.id, {
           network: tn.data.domain,
         });
@@ -628,12 +635,12 @@
     onConnect,
     refreshEnv: async () => {
       return get().env;
-        const resp = await fetch("/env");
-        if (!resp.ok) {
-          throw new Error("failed to fetch env config");
-        }
-        set({ env: envSchema.parse(await resp.json()) });
-        return get().env;
+      const resp = await fetch("/env");
+      if (!resp.ok) {
+        throw new Error("failed to fetch env config");
+      }
+      set({ env: envSchema.parse(await resp.json()) });
+      return get().env;
     },
     setProject: (projectId) => set({ projectId }),
     setProjects: (projects) => set({ projects }),