blob: fb461c2dd0fdfc981a0a9d62011b4df3a51fc845 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { Category, defaultCategories } from "./categories";
2import { CreateValidators, Validator } from "./config";
giod0026612025-05-08 13:00:36 +00003import { GitHubService, GitHubServiceImpl } from "./github";
4import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange } from "@xyflow/react";
5import { addEdge, applyEdgeChanges, applyNodeChanges, Connection, EdgeChange, useNodes } from "@xyflow/react";
6import type { DeepPartial } from "react-hook-form";
7import { v4 as uuidv4 } from "uuid";
gio5f2f1002025-03-20 18:38:48 +04008import { z } from "zod";
giod0026612025-05-08 13:00:36 +00009import { create } from "zustand";
gio5f2f1002025-03-20 18:38:48 +040010
11export type InitData = {
giod0026612025-05-08 13:00:36 +000012 label: string;
13 envVars: BoundEnvVar[];
14 ports: Port[];
gio5f2f1002025-03-20 18:38:48 +040015};
16
17export type NodeData = InitData & {
giod0026612025-05-08 13:00:36 +000018 activeField?: string | undefined;
19 state: string | null;
gio5f2f1002025-03-20 18:38:48 +040020};
21
22export type PortConnectedTo = {
giod0026612025-05-08 13:00:36 +000023 serviceId: string;
24 portId: string;
25};
gio5f2f1002025-03-20 18:38:48 +040026
gioaba9a962025-04-25 14:19:40 +000027export type NetworkData = NodeData & {
giod0026612025-05-08 13:00:36 +000028 domain: string;
gioaba9a962025-04-25 14:19:40 +000029};
30
31export type NetworkNode = Node<NetworkData> & {
giod0026612025-05-08 13:00:36 +000032 type: "network";
gioaba9a962025-04-25 14:19:40 +000033};
34
gio5f2f1002025-03-20 18:38:48 +040035export type GatewayHttpsData = NodeData & {
giod0026612025-05-08 13:00:36 +000036 network?: string;
37 subdomain?: string;
38 https?: PortConnectedTo;
39 auth?: {
40 enabled: boolean;
41 groups: string[];
42 noAuthPathPatterns: string[];
43 };
gio5f2f1002025-03-20 18:38:48 +040044};
45
46export type GatewayHttpsNode = Node<GatewayHttpsData> & {
giod0026612025-05-08 13:00:36 +000047 type: "gateway-https";
gio5f2f1002025-03-20 18:38:48 +040048};
49
50export type GatewayTCPData = NodeData & {
giod0026612025-05-08 13:00:36 +000051 network?: string;
52 subdomain?: string;
53 exposed: PortConnectedTo[];
54 selected?: {
55 serviceId?: string;
56 portId?: string;
57 };
gio5f2f1002025-03-20 18:38:48 +040058};
59
60export type GatewayTCPNode = Node<GatewayTCPData> & {
giod0026612025-05-08 13:00:36 +000061 type: "gateway-tcp";
gio5f2f1002025-03-20 18:38:48 +040062};
63
64export type Port = {
giod0026612025-05-08 13:00:36 +000065 id: string;
66 name: string;
67 value: number;
gio5f2f1002025-03-20 18:38:48 +040068};
69
gio91165612025-05-03 17:07:38 +000070export const ServiceTypes = [
giod0026612025-05-08 13:00:36 +000071 "deno:2.2.0",
72 "golang:1.20.0",
73 "golang:1.22.0",
74 "golang:1.24.0",
75 "hugo:latest",
76 "php:8.2-apache",
77 "nextjs:deno-2.0.0",
78 "node-23.1.0",
gio91165612025-05-03 17:07:38 +000079] as const;
giod0026612025-05-08 13:00:36 +000080export type ServiceType = (typeof ServiceTypes)[number];
gio5f2f1002025-03-20 18:38:48 +040081
82export type ServiceData = NodeData & {
giod0026612025-05-08 13:00:36 +000083 type: ServiceType;
84 repository:
85 | {
86 id: string;
87 }
88 | {
89 id: string;
90 branch: string;
91 }
92 | {
93 id: string;
94 branch: string;
95 rootDir: string;
96 };
97 env: string[];
98 volume: string[];
99 preBuildCommands: string;
100 isChoosingPortToConnect: boolean;
gio5f2f1002025-03-20 18:38:48 +0400101};
102
103export type ServiceNode = Node<ServiceData> & {
giod0026612025-05-08 13:00:36 +0000104 type: "app";
gio5f2f1002025-03-20 18:38:48 +0400105};
106
107export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
108
109export type VolumeData = NodeData & {
giod0026612025-05-08 13:00:36 +0000110 type: VolumeType;
111 size: string;
112 attachedTo: string[];
gio5f2f1002025-03-20 18:38:48 +0400113};
114
115export type VolumeNode = Node<VolumeData> & {
giod0026612025-05-08 13:00:36 +0000116 type: "volume";
gio5f2f1002025-03-20 18:38:48 +0400117};
118
119export type PostgreSQLData = NodeData & {
giod0026612025-05-08 13:00:36 +0000120 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400121};
122
123export type PostgreSQLNode = Node<PostgreSQLData> & {
giod0026612025-05-08 13:00:36 +0000124 type: "postgresql";
gio5f2f1002025-03-20 18:38:48 +0400125};
126
127export type MongoDBData = NodeData & {
giod0026612025-05-08 13:00:36 +0000128 volumeId: string;
gio5f2f1002025-03-20 18:38:48 +0400129};
130
131export type MongoDBNode = Node<MongoDBData> & {
giod0026612025-05-08 13:00:36 +0000132 type: "mongodb";
gio5f2f1002025-03-20 18:38:48 +0400133};
134
135export type GithubData = NodeData & {
giod0026612025-05-08 13:00:36 +0000136 repository?: {
137 id: number;
138 sshURL: string;
139 };
gio5f2f1002025-03-20 18:38:48 +0400140};
141
142export type GithubNode = Node<GithubData> & {
giod0026612025-05-08 13:00:36 +0000143 type: "github";
gio5f2f1002025-03-20 18:38:48 +0400144};
145
146export type NANode = Node<NodeData> & {
giod0026612025-05-08 13:00:36 +0000147 type: undefined;
gio5f2f1002025-03-20 18:38:48 +0400148};
149
giod0026612025-05-08 13:00:36 +0000150export type AppNode =
151 | NetworkNode
152 | GatewayHttpsNode
153 | GatewayTCPNode
154 | ServiceNode
155 | VolumeNode
156 | PostgreSQLNode
157 | MongoDBNode
158 | GithubNode
159 | NANode;
gio5f2f1002025-03-20 18:38:48 +0400160
161export function nodeLabel(n: AppNode): string {
giod0026612025-05-08 13:00:36 +0000162 switch (n.type) {
163 case "network":
164 return n.data.domain;
165 case "app":
166 return n.data.label || "Service";
167 case "github":
168 return n.data.repository?.sshURL || "Github";
169 case "gateway-https": {
170 if (n.data && n.data.network && n.data.subdomain) {
171 return `https://${n.data.subdomain}.${n.data.network}`;
172 } else {
173 return "HTTPS Gateway";
174 }
175 }
176 case "gateway-tcp": {
177 if (n.data && n.data.network && n.data.subdomain) {
178 return `${n.data.subdomain}.${n.data.network}`;
179 } else {
180 return "TCP Gateway";
181 }
182 }
183 case "mongodb":
184 return n.data.label || "MongoDB";
185 case "postgresql":
186 return n.data.label || "PostgreSQL";
187 case "volume":
188 return n.data.label || "Volume";
189 case undefined:
190 throw new Error("MUST NOT REACH!");
191 }
gio5f2f1002025-03-20 18:38:48 +0400192}
193
194export function nodeIsConnectable(n: AppNode, handle: string): boolean {
giod0026612025-05-08 13:00:36 +0000195 switch (n.type) {
196 case "network":
197 return true;
198 case "app":
199 if (handle === "ports") {
200 return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
201 } else if (handle === "repository") {
202 if (!n.data || !n.data.repository || !n.data.repository.id) {
203 return true;
204 }
205 return false;
206 }
207 return false;
208 case "github":
209 if (n.data.repository?.id !== undefined) {
210 return true;
211 }
212 return false;
213 case "gateway-https":
214 if (handle === "subdomain") {
215 return n.data.network === undefined;
216 }
217 return n.data === undefined || n.data.https === undefined;
218 case "gateway-tcp":
219 if (handle === "subdomain") {
220 return n.data.network === undefined;
221 }
222 return true;
223 case "mongodb":
224 return true;
225 case "postgresql":
226 return true;
227 case "volume":
228 if (n.data === undefined || n.data.type === undefined) {
229 return false;
230 }
231 if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
232 return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
233 }
234 return true;
235 case undefined:
236 throw new Error("MUST NOT REACH!");
237 }
gio5f2f1002025-03-20 18:38:48 +0400238}
239
giod0026612025-05-08 13:00:36 +0000240export type BoundEnvVar =
241 | {
242 id: string;
243 source: string | null;
244 }
245 | {
246 id: string;
247 source: string | null;
248 name: string;
249 isEditting: boolean;
250 }
251 | {
252 id: string;
253 source: string | null;
254 name: string;
255 alias: string;
256 isEditting: boolean;
257 }
258 | {
259 id: string;
260 source: string | null;
261 portId: string;
262 name: string;
263 alias: string;
264 isEditting: boolean;
265 };
gio5f2f1002025-03-20 18:38:48 +0400266
267export type EnvVar = {
giod0026612025-05-08 13:00:36 +0000268 name: string;
269 value: string;
gio5f2f1002025-03-20 18:38:48 +0400270};
271
giob41ecae2025-04-24 08:46:50 +0000272export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
giod0026612025-05-08 13:00:36 +0000273 return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
giob41ecae2025-04-24 08:46:50 +0000274}
275
gio5f2f1002025-03-20 18:38:48 +0400276export function nodeEnvVarNames(n: AppNode): string[] {
giod0026612025-05-08 13:00:36 +0000277 switch (n.type) {
278 case "app":
279 return [
280 `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
281 ...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
282 ];
283 case "github":
284 return [];
285 case "gateway-https":
286 return [];
287 case "gateway-tcp":
288 return [];
289 case "mongodb":
290 return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
291 case "postgresql":
292 return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
293 case "volume":
294 return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
295 case undefined:
296 throw new Error("MUST NOT REACH");
297 default:
298 throw new Error("MUST NOT REACH");
299 }
gio5f2f1002025-03-20 18:38:48 +0400300}
301
302export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
303
304export type MessageType = "INFO" | "WARNING" | "FATAL";
305
306export type Message = {
giod0026612025-05-08 13:00:36 +0000307 id: string;
308 type: MessageType;
309 nodeId?: string;
310 message: string;
311 onHighlight?: (state: AppState) => void;
312 onLooseHighlight?: (state: AppState) => void;
313 onClick?: (state: AppState) => void;
gio5f2f1002025-03-20 18:38:48 +0400314};
315
316export const envSchema = z.object({
gio7d813702025-05-08 18:29:52 +0000317 managerAddr: z.optional(z.string().min(1)),
giod0026612025-05-08 13:00:36 +0000318 deployKey: z.optional(z.string().min(1)),
319 networks: z
320 .array(
321 z.object({
322 name: z.string().min(1),
323 domain: z.string().min(1),
324 }),
325 )
326 .default([]),
327 integrations: z.object({
328 github: z.boolean(),
329 }),
gio3a921b82025-05-10 07:36:09 +0000330 services: z.array(z.string()),
gio5f2f1002025-03-20 18:38:48 +0400331});
332
333export type Env = z.infer<typeof envSchema>;
334
gio7f98e772025-05-07 11:00:14 +0000335const defaultEnv: Env = {
gio7d813702025-05-08 18:29:52 +0000336 managerAddr: undefined,
giod0026612025-05-08 13:00:36 +0000337 deployKey: undefined,
338 networks: [],
339 integrations: {
340 github: false,
341 },
gio3a921b82025-05-10 07:36:09 +0000342 services: [],
gio7f98e772025-05-07 11:00:14 +0000343};
344
gio5f2f1002025-03-20 18:38:48 +0400345export type Project = {
giod0026612025-05-08 13:00:36 +0000346 id: string;
347 name: string;
348};
gio5f2f1002025-03-20 18:38:48 +0400349
gio7f98e772025-05-07 11:00:14 +0000350export type IntegrationsConfig = {
giod0026612025-05-08 13:00:36 +0000351 github: boolean;
gio7f98e772025-05-07 11:00:14 +0000352};
353
354type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
355type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
356
gio5f2f1002025-03-20 18:38:48 +0400357export type AppState = {
giod0026612025-05-08 13:00:36 +0000358 projectId: string | undefined;
359 projects: Project[];
360 nodes: AppNode[];
361 edges: Edge[];
362 categories: Category[];
363 messages: Message[];
364 env: Env;
365 githubService: GitHubService | null;
366 setHighlightCategory: (name: string, active: boolean) => void;
367 onNodesChange: OnNodesChange<AppNode>;
368 onEdgesChange: OnEdgesChange;
369 onConnect: OnConnect;
370 setNodes: (nodes: AppNode[]) => void;
371 setEdges: (edges: Edge[]) => void;
372 setProject: (projectId: string | undefined) => void;
373 updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
374 updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
375 replaceEdge: (c: Connection, id?: string) => void;
376 refreshEnv: () => Promise<void>;
gio5f2f1002025-03-20 18:38:48 +0400377};
378
379const projectIdSelector = (state: AppState) => state.projectId;
380const categoriesSelector = (state: AppState) => state.categories;
381const messagesSelector = (state: AppState) => state.messages;
gio7f98e772025-05-07 11:00:14 +0000382const githubServiceSelector = (state: AppState) => state.githubService;
gio5f2f1002025-03-20 18:38:48 +0400383const envSelector = (state: AppState) => state.env;
384
385export function useProjectId(): string | undefined {
giod0026612025-05-08 13:00:36 +0000386 return useStateStore(projectIdSelector);
gio5f2f1002025-03-20 18:38:48 +0400387}
388
389export function useCategories(): Category[] {
giod0026612025-05-08 13:00:36 +0000390 return useStateStore(categoriesSelector);
gio5f2f1002025-03-20 18:38:48 +0400391}
392
393export function useMessages(): Message[] {
giod0026612025-05-08 13:00:36 +0000394 return useStateStore(messagesSelector);
gio5f2f1002025-03-20 18:38:48 +0400395}
396
397export function useNodeMessages(id: string): Message[] {
giod0026612025-05-08 13:00:36 +0000398 return useMessages().filter((m) => m.nodeId === id);
gio5f2f1002025-03-20 18:38:48 +0400399}
400
401export function useNodeLabel(id: string): string {
giod0026612025-05-08 13:00:36 +0000402 return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
gio5f2f1002025-03-20 18:38:48 +0400403}
404
405export function useNodePortName(id: string, portId: string): string {
giod0026612025-05-08 13:00:36 +0000406 return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
gio5f2f1002025-03-20 18:38:48 +0400407}
408
gio5f2f1002025-03-20 18:38:48 +0400409export function useEnv(): Env {
giod0026612025-05-08 13:00:36 +0000410 return useStateStore(envSelector);
gio7f98e772025-05-07 11:00:14 +0000411}
412
413export function useGithubService(): GitHubService | null {
giod0026612025-05-08 13:00:36 +0000414 return useStateStore(githubServiceSelector);
gio5f2f1002025-03-20 18:38:48 +0400415}
416
417const v: Validator = CreateValidators();
418
419export const useStateStore = create<AppState>((set, get): AppState => {
giod0026612025-05-08 13:00:36 +0000420 const setN = (nodes: AppNode[]) => {
421 set((state) => ({
422 ...state,
423 nodes,
gio5cf364c2025-05-08 16:01:21 +0000424 messages: v(nodes),
giod0026612025-05-08 13:00:36 +0000425 }));
426 };
gio7f98e772025-05-07 11:00:14 +0000427
giod0026612025-05-08 13:00:36 +0000428 function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
429 setN(
430 get().nodes.map((n) => {
431 if (n.id === id) {
432 return {
433 ...n,
434 data: {
435 ...n.data,
436 ...data,
437 },
438 } as Extract<AppNode, { type: T }>;
439 }
440 return n;
441 }),
442 );
443 }
gio7f98e772025-05-07 11:00:14 +0000444
giod0026612025-05-08 13:00:36 +0000445 function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
446 setN(
447 get().nodes.map((n) => {
448 if (n.id === id) {
449 return {
450 ...n,
451 ...node,
452 } as Extract<AppNode, { type: T }>;
453 }
454 return n;
455 }),
456 );
457 }
gio7f98e772025-05-07 11:00:14 +0000458
giod0026612025-05-08 13:00:36 +0000459 function onConnect(c: Connection) {
460 const { nodes, edges } = get();
461 set({
462 edges: addEdge(c, edges),
463 });
464 const sn = nodes.filter((n) => n.id === c.source)[0]!;
465 const tn = nodes.filter((n) => n.id === c.target)[0]!;
466 if (tn.type === "network") {
467 if (sn.type === "gateway-https") {
468 updateNodeData<"gateway-https">(sn.id, {
469 network: tn.data.domain,
470 });
471 } else if (sn.type === "gateway-tcp") {
472 updateNodeData<"gateway-tcp">(sn.id, {
473 network: tn.data.domain,
474 });
475 }
476 }
477 if (tn.type === "app") {
478 if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
479 const sourceEnvVars = nodeEnvVarNames(sn);
480 if (sourceEnvVars.length === 0) {
481 throw new Error("MUST NOT REACH!");
482 }
483 const id = uuidv4();
484 if (sourceEnvVars.length === 1) {
485 updateNode<"app">(c.target, {
486 ...tn,
487 data: {
488 ...tn.data,
489 envVars: [
490 ...(tn.data.envVars || []),
491 {
492 id: id,
493 source: c.source,
494 name: sourceEnvVars[0],
495 isEditting: false,
496 },
497 ],
498 },
499 });
500 } else {
501 updateNode<"app">(c.target, {
502 ...tn,
503 data: {
504 ...tn.data,
505 envVars: [
506 ...(tn.data.envVars || []),
507 {
508 id: id,
509 source: c.source,
510 },
511 ],
512 },
513 });
514 }
515 }
516 if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
517 const sourcePorts = sn.data.ports || [];
518 const id = uuidv4();
519 if (sourcePorts.length === 1) {
520 updateNode<"app">(c.target, {
521 ...tn,
522 data: {
523 ...tn.data,
524 envVars: [
525 ...(tn.data.envVars || []),
526 {
527 id: id,
528 source: c.source,
529 name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
530 portId: sourcePorts[0].id,
531 isEditting: false,
532 },
533 ],
534 },
535 });
536 }
537 }
538 }
539 if (c.sourceHandle === "volume") {
540 updateNodeData<"volume">(c.source, {
541 attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
542 });
543 }
544 if (c.targetHandle === "volume") {
545 if (tn.type === "postgresql" || tn.type === "mongodb") {
546 updateNodeData(c.target, {
547 volumeId: c.source,
548 });
549 }
550 }
551 if (c.targetHandle === "https") {
552 if ((sn.data.ports || []).length === 1) {
553 updateNodeData<"gateway-https">(c.target, {
554 https: {
555 serviceId: c.source,
556 portId: sn.data.ports![0].id,
557 },
558 });
559 } else {
560 updateNodeData<"gateway-https">(c.target, {
561 https: {
562 serviceId: c.source,
563 portId: "", // TODO(gio)
564 },
565 });
566 }
567 }
568 if (c.targetHandle === "tcp") {
569 const td = tn.data as GatewayTCPData;
570 if ((sn.data.ports || []).length === 1) {
571 updateNodeData<"gateway-tcp">(c.target, {
572 exposed: (td.exposed || []).concat({
573 serviceId: c.source,
574 portId: sn.data.ports![0].id,
575 }),
576 });
577 } else {
578 updateNodeData<"gateway-tcp">(c.target, {
579 selected: {
580 serviceId: c.source,
581 portId: undefined,
582 },
583 });
584 }
585 }
586 if (sn.type === "app") {
587 if (c.sourceHandle === "ports") {
588 updateNodeData<"app">(sn.id, {
589 isChoosingPortToConnect: true,
590 });
591 }
592 }
593 if (tn.type === "app") {
594 if (c.targetHandle === "repository") {
595 updateNodeData<"app">(tn.id, {
596 repository: {
597 id: c.source,
598 branch: "master",
599 rootDir: "/",
600 },
601 });
602 }
603 }
604 }
605 return {
606 projectId: undefined,
607 projects: [],
608 nodes: [],
609 edges: [],
610 categories: defaultCategories,
611 messages: v([]),
612 env: defaultEnv,
613 githubService: null,
614 setHighlightCategory: (name, active) => {
615 set({
616 categories: get().categories.map((c) => {
617 if (c.title.toLowerCase() !== name.toLowerCase()) {
618 return c;
619 } else {
620 return {
621 ...c,
622 active,
623 };
624 }
625 }),
626 });
627 },
628 onNodesChange: (changes) => {
629 const nodes = applyNodeChanges(changes, get().nodes);
630 setN(nodes);
631 },
632 onEdgesChange: (changes) => {
633 set({
634 edges: applyEdgeChanges(changes, get().edges),
635 });
636 },
637 setNodes: (nodes) => {
638 setN(nodes);
639 },
640 setEdges: (edges) => {
641 set({ edges });
642 },
643 replaceEdge: (c, id) => {
644 let change: EdgeChange;
645 if (id === undefined) {
646 change = {
647 type: "add",
648 item: {
649 id: uuidv4(),
650 ...c,
651 },
652 };
653 onConnect(c);
654 } else {
655 change = {
656 type: "replace",
657 id,
658 item: {
659 id,
660 ...c,
661 },
662 };
663 }
664 set({
665 edges: applyEdgeChanges([change], get().edges),
666 });
667 },
668 updateNode,
669 updateNodeData,
670 onConnect,
671 refreshEnv: async () => {
672 const projectId = get().projectId;
673 let env: Env = defaultEnv;
gio7f98e772025-05-07 11:00:14 +0000674
giod0026612025-05-08 13:00:36 +0000675 try {
676 if (projectId) {
677 const response = await fetch(`/api/project/${projectId}/env`);
678 if (response.ok) {
679 const data = await response.json();
680 const result = envSchema.safeParse(data);
681 if (result.success) {
682 env = result.data;
683 } else {
684 console.error("Invalid env data:", result.error);
685 }
686 }
687 }
688 } catch (error) {
689 console.error("Failed to fetch integrations:", error);
690 } finally {
691 set({ env: env });
692 if (env.integrations.github) {
693 set({ githubService: new GitHubServiceImpl(projectId!) });
694 } else {
695 set({ githubService: null });
696 }
697 }
698 },
699 setProject: (projectId) => {
700 set({
701 projectId,
702 });
703 if (projectId) {
704 get().refreshEnv();
705 }
706 },
707 };
gio5f2f1002025-03-20 18:38:48 +0400708});