diff --git a/e2e-tests/env_var.spec.ts b/e2e-tests/env_var.spec.ts new file mode 100644 index 0000000..d9af7f0 --- /dev/null +++ b/e2e-tests/env_var.spec.ts @@ -0,0 +1,62 @@ +import { expect } from "@playwright/test"; +import { test } from "./helpers/test_helper"; +import path from "path"; +import fs from "fs"; + +test("env var", async ({ po }) => { + await po.sendPrompt("tc=1"); + const appPath = await po.getCurrentAppPath(); + + await po.selectPreviewMode("configure"); + + // Create a new env var + await po.page + .getByRole("button", { name: "Add Environment Variable" }) + .click(); + await po.page.getByRole("textbox", { name: "Key" }).click(); + await po.page.getByRole("textbox", { name: "Key" }).fill("aKey"); + + await po.page.getByRole("textbox", { name: "Value" }).click(); + await po.page.getByRole("textbox", { name: "Value" }).fill("aValue"); + + await po.page.getByRole("button", { name: "Save" }).click(); + await snapshotEnvVar({ appPath, name: "create-aKey" }); + + // Create second env var + await po.page + .getByRole("button", { name: "Add Environment Variable" }) + .click(); + await po.page.getByRole("textbox", { name: "Key" }).click(); + await po.page.getByRole("textbox", { name: "Key" }).fill("bKey"); + + await po.page.getByRole("textbox", { name: "Value" }).click(); + await po.page.getByRole("textbox", { name: "Value" }).fill("bValue"); + + await po.page.getByRole("button", { name: "Save" }).click(); + await snapshotEnvVar({ appPath, name: "create-bKey" }); + + // Edit second env var + await po.page.getByTestId("edit-env-var-bKey").click(); + await po.page.getByRole("textbox", { name: "Value" }).click(); + await po.page.getByRole("textbox", { name: "Value" }).fill("bValue2"); + await po.page.getByTestId("save-edit-env-var").click(); + await snapshotEnvVar({ appPath, name: "edit-bKey" }); + + // Delete first env var + await po.page.getByTestId("delete-env-var-aKey").click(); + await snapshotEnvVar({ appPath, name: "delete-aKey" }); +}); + +async function snapshotEnvVar({ + appPath, + name, +}: { + appPath: string; + name: string; +}) { + expect(() => { + const envFile = path.join(appPath, ".env.local"); + const envFileContent = fs.readFileSync(envFile, "utf8"); + expect(envFileContent).toMatchSnapshot({ name }); + }).toPass(); +} diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index adb3a94..edb9935 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -422,7 +422,7 @@ export class PageObject { // Preview panel //////////////////////////////// - async selectPreviewMode(mode: "code" | "problems" | "preview") { + async selectPreviewMode(mode: "code" | "problems" | "preview" | "configure") { await this.page.getByTestId(`${mode}-mode-button`).click(); } diff --git a/e2e-tests/snapshots/env_var.spec.ts_create-aKey b/e2e-tests/snapshots/env_var.spec.ts_create-aKey new file mode 100644 index 0000000..8224d30 --- /dev/null +++ b/e2e-tests/snapshots/env_var.spec.ts_create-aKey @@ -0,0 +1 @@ +aKey=aValue \ No newline at end of file diff --git a/e2e-tests/snapshots/env_var.spec.ts_create-bKey b/e2e-tests/snapshots/env_var.spec.ts_create-bKey new file mode 100644 index 0000000..7c4b43f --- /dev/null +++ b/e2e-tests/snapshots/env_var.spec.ts_create-bKey @@ -0,0 +1,2 @@ +aKey=aValue +bKey=bValue \ No newline at end of file diff --git a/e2e-tests/snapshots/env_var.spec.ts_delete-aKey b/e2e-tests/snapshots/env_var.spec.ts_delete-aKey new file mode 100644 index 0000000..627b72c --- /dev/null +++ b/e2e-tests/snapshots/env_var.spec.ts_delete-aKey @@ -0,0 +1 @@ +bKey=bValue2 \ No newline at end of file diff --git a/e2e-tests/snapshots/env_var.spec.ts_edit-bKey b/e2e-tests/snapshots/env_var.spec.ts_edit-bKey new file mode 100644 index 0000000..eb4e3eb --- /dev/null +++ b/e2e-tests/snapshots/env_var.spec.ts_edit-bKey @@ -0,0 +1,2 @@ +aKey=aValue +bKey=bValue2 \ No newline at end of file diff --git a/src/__tests__/app_env_vars_utils.test.ts b/src/__tests__/app_env_vars_utils.test.ts new file mode 100644 index 0000000..ceee45e --- /dev/null +++ b/src/__tests__/app_env_vars_utils.test.ts @@ -0,0 +1,534 @@ +import { parseEnvFile, serializeEnvFile } from "@/ipc/utils/app_env_var_utils"; +import { describe, it, expect } from "vitest"; + +describe("parseEnvFile", () => { + it("should parse basic key=value pairs", () => { + const content = `API_KEY=abc123 +DATABASE_URL=postgres://localhost:5432/mydb +PORT=3000`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should handle quoted values and remove quotes", () => { + const content = `API_KEY="abc123" +DATABASE_URL='postgres://localhost:5432/mydb' +MESSAGE="Hello World"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "MESSAGE", value: "Hello World" }, + ]); + }); + + it("should skip empty lines", () => { + const content = `API_KEY=abc123 + +DATABASE_URL=postgres://localhost:5432/mydb + + +PORT=3000`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should skip comment lines", () => { + const content = `# This is a comment +API_KEY=abc123 +# Another comment +DATABASE_URL=postgres://localhost:5432/mydb +# PORT=3000 (commented out) +DEBUG=true`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "DEBUG", value: "true" }, + ]); + }); + + it("should handle values with spaces", () => { + const content = `MESSAGE="Hello World" +DESCRIPTION='This is a long description' +TITLE=My App Title`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "MESSAGE", value: "Hello World" }, + { key: "DESCRIPTION", value: "This is a long description" }, + { key: "TITLE", value: "My App Title" }, + ]); + }); + + it("should handle values with special characters", () => { + const content = `PASSWORD="p@ssw0rd!#$%" +URL="https://example.com/api?key=123&secret=456" +REGEX="^[a-zA-Z0-9]+$"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "PASSWORD", value: "p@ssw0rd!#$%" }, + { key: "URL", value: "https://example.com/api?key=123&secret=456" }, + { key: "REGEX", value: "^[a-zA-Z0-9]+$" }, + ]); + }); + + it("should handle empty values", () => { + const content = `EMPTY_VAR= +QUOTED_EMPTY="" +ANOTHER_VAR=value`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "EMPTY_VAR", value: "" }, + { key: "QUOTED_EMPTY", value: "" }, + { key: "ANOTHER_VAR", value: "value" }, + ]); + }); + + it("should handle values with equals signs", () => { + const content = `EQUATION="2+2=4" +CONNECTION_STRING="server=localhost;user=admin;password=secret"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "EQUATION", value: "2+2=4" }, + { + key: "CONNECTION_STRING", + value: "server=localhost;user=admin;password=secret", + }, + ]); + }); + + it("should trim whitespace around keys and values", () => { + const content = ` API_KEY = abc123 + DATABASE_URL = "postgres://localhost:5432/mydb" + PORT = 3000 `; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should skip malformed lines without equals sign", () => { + const content = `API_KEY=abc123 +MALFORMED_LINE +DATABASE_URL=postgres://localhost:5432/mydb +ANOTHER_MALFORMED +PORT=3000`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]); + }); + + it("should skip lines with equals sign at the beginning", () => { + const content = `API_KEY=abc123 +=invalid_line +DATABASE_URL=postgres://localhost:5432/mydb`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + ]); + }); + + it("should handle mixed quote types in values", () => { + const content = `MESSAGE="He said 'Hello World'" +COMMAND='echo "Hello World"'`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "MESSAGE", value: "He said 'Hello World'" }, + { key: "COMMAND", value: 'echo "Hello World"' }, + ]); + }); + + it("should handle empty content", () => { + const result = parseEnvFile(""); + expect(result).toEqual([]); + }); + + it("should handle content with only comments and empty lines", () => { + const content = `# Comment 1 + +# Comment 2 + +# Comment 3`; + + const result = parseEnvFile(content); + expect(result).toEqual([]); + }); + + it("should handle values that start with hash symbol when quoted", () => { + const content = `HASH_VALUE="#hashtag" +COMMENT_LIKE="# This looks like a comment but it's a value" +ACTUAL_COMMENT=value +# This is an actual comment`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "HASH_VALUE", value: "#hashtag" }, + { + key: "COMMENT_LIKE", + value: "# This looks like a comment but it's a value", + }, + { key: "ACTUAL_COMMENT", value: "value" }, + ]); + }); + + it("should skip comments that look like key=value pairs", () => { + const content = `API_KEY=abc123 +# SECRET_KEY=should_be_ignored +DATABASE_URL=postgres://localhost:5432/mydb +# PORT=3000 +DEBUG=true`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "DEBUG", value: "true" }, + ]); + }); + + it("should handle values containing comment symbols", () => { + const content = `GIT_COMMIT_MSG="feat: add new feature # closes #123" +SQL_QUERY="SELECT * FROM users WHERE id = 1 # Get user by ID" +MARKDOWN_HEADING="# Main Title" +SHELL_COMMENT="echo 'hello' # prints hello"`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "GIT_COMMIT_MSG", value: "feat: add new feature # closes #123" }, + { + key: "SQL_QUERY", + value: "SELECT * FROM users WHERE id = 1 # Get user by ID", + }, + { key: "MARKDOWN_HEADING", value: "# Main Title" }, + { key: "SHELL_COMMENT", value: "echo 'hello' # prints hello" }, + ]); + }); + + it("should handle inline comments after key=value pairs", () => { + const content = `API_KEY=abc123 # This is the API key +DATABASE_URL=postgres://localhost:5432/mydb # Database connection +PORT=3000 # Server port +DEBUG=true # Enable debug mode`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123 # This is the API key" }, + { + key: "DATABASE_URL", + value: "postgres://localhost:5432/mydb # Database connection", + }, + { key: "PORT", value: "3000 # Server port" }, + { key: "DEBUG", value: "true # Enable debug mode" }, + ]); + }); + + it("should handle quoted values with inline comments", () => { + const content = `MESSAGE="Hello World" # Greeting message +PASSWORD="secret#123" # Password with hash +URL="https://example.com#section" # URL with fragment`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "MESSAGE", value: "Hello World" }, + { key: "PASSWORD", value: "secret#123" }, + { key: "URL", value: "https://example.com#section" }, + ]); + }); + + it("should handle complex mixed comment scenarios", () => { + const content = `# Configuration file +API_KEY=abc123 +# Database settings +DATABASE_URL="postgres://localhost:5432/mydb" +# PORT=5432 (commented out) +DATABASE_NAME=myapp + +# Feature flags +FEATURE_A=true # Enable feature A +FEATURE_B="false" # Disable feature B +# FEATURE_C=true (disabled) + +# URLs with fragments +HOMEPAGE="https://example.com#home" +DOCS_URL=https://docs.example.com#getting-started # Documentation link`; + + const result = parseEnvFile(content); + expect(result).toEqual([ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "DATABASE_NAME", value: "myapp" }, + { key: "FEATURE_A", value: "true # Enable feature A" }, + { key: "FEATURE_B", value: "false" }, + { key: "HOMEPAGE", value: "https://example.com#home" }, + { + key: "DOCS_URL", + value: "https://docs.example.com#getting-started # Documentation link", + }, + ]); + }); +}); + +describe("serializeEnvFile", () => { + it("should serialize basic key=value pairs", () => { + const envVars = [ + { key: "API_KEY", value: "abc123" }, + { key: "DATABASE_URL", value: "postgres://localhost:5432/mydb" }, + { key: "PORT", value: "3000" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`API_KEY=abc123 +DATABASE_URL=postgres://localhost:5432/mydb +PORT=3000`); + }); + + it("should quote values with spaces", () => { + const envVars = [ + { key: "MESSAGE", value: "Hello World" }, + { key: "DESCRIPTION", value: "This is a long description" }, + { key: "SIMPLE", value: "no_spaces" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`MESSAGE="Hello World" +DESCRIPTION="This is a long description" +SIMPLE=no_spaces`); + }); + + it("should quote values with special characters", () => { + const envVars = [ + { key: "PASSWORD", value: "p@ssw0rd!#$%" }, + { key: "URL", value: "https://example.com/api?key=123&secret=456" }, + { key: "SIMPLE", value: "simple123" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`PASSWORD="p@ssw0rd!#$%" +URL="https://example.com/api?key=123&secret=456" +SIMPLE=simple123`); + }); + + it("should escape quotes in values", () => { + const envVars = [ + { key: "MESSAGE", value: 'He said "Hello World"' }, + { key: "COMMAND", value: 'echo "test"' }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`MESSAGE="He said \\"Hello World\\"" +COMMAND="echo \\"test\\""`); + }); + + it("should handle empty values", () => { + const envVars = [ + { key: "EMPTY_VAR", value: "" }, + { key: "ANOTHER_VAR", value: "value" }, + { key: "ALSO_EMPTY", value: "" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`EMPTY_VAR= +ANOTHER_VAR=value +ALSO_EMPTY=`); + }); + + it("should quote values with hash symbols", () => { + const envVars = [ + { key: "PASSWORD", value: "secret#123" }, + { key: "COMMENT", value: "This has # in it" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`PASSWORD="secret#123" +COMMENT="This has # in it"`); + }); + + it("should quote values with single quotes", () => { + const envVars = [ + { key: "MESSAGE", value: "Don't worry" }, + { key: "SQL", value: "SELECT * FROM 'users'" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`MESSAGE="Don't worry" +SQL="SELECT * FROM 'users'"`); + }); + + it("should handle values with equals signs", () => { + const envVars = [ + { key: "EQUATION", value: "2+2=4" }, + { + key: "CONNECTION_STRING", + value: "server=localhost;user=admin;password=secret", + }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`EQUATION="2+2=4" +CONNECTION_STRING="server=localhost;user=admin;password=secret"`); + }); + + it("should handle mixed scenarios", () => { + const envVars = [ + { key: "SIMPLE", value: "value" }, + { key: "WITH_SPACES", value: "hello world" }, + { key: "WITH_QUOTES", value: 'say "hello"' }, + { key: "EMPTY", value: "" }, + { key: "SPECIAL_CHARS", value: "p@ssw0rd!#$%" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`SIMPLE=value +WITH_SPACES="hello world" +WITH_QUOTES="say \\"hello\\"" +EMPTY= +SPECIAL_CHARS="p@ssw0rd!#$%"`); + }); + + it("should handle empty array", () => { + const result = serializeEnvFile([]); + expect(result).toBe(""); + }); + + it("should handle complex escaped quotes", () => { + const envVars = [ + { key: "COMPLEX", value: "This is \"complex\" with 'mixed' quotes" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`COMPLEX="This is \\"complex\\" with 'mixed' quotes"`); + }); + + it("should handle values that start with hash symbol", () => { + const envVars = [ + { key: "HASHTAG", value: "#trending" }, + { key: "COMMENT_LIKE", value: "# This looks like a comment" }, + { key: "MARKDOWN_HEADING", value: "# Main Title" }, + { key: "NORMAL_VALUE", value: "no_hash_here" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`HASHTAG="#trending" +COMMENT_LIKE="# This looks like a comment" +MARKDOWN_HEADING="# Main Title" +NORMAL_VALUE=no_hash_here`); + }); + + it("should handle values containing comment symbols", () => { + const envVars = [ + { key: "GIT_COMMIT", value: "feat: add feature # closes #123" }, + { key: "SQL_QUERY", value: "SELECT * FROM users # Get all users" }, + { key: "SHELL_CMD", value: "echo 'hello' # prints hello" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`GIT_COMMIT="feat: add feature # closes #123" +SQL_QUERY="SELECT * FROM users # Get all users" +SHELL_CMD="echo 'hello' # prints hello"`); + }); + + it("should handle URLs with fragments that contain hash symbols", () => { + const envVars = [ + { key: "HOMEPAGE", value: "https://example.com#home" }, + { key: "DOCS_URL", value: "https://docs.example.com#getting-started" }, + { key: "API_ENDPOINT", value: "https://api.example.com/v1#section" }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`HOMEPAGE="https://example.com#home" +DOCS_URL="https://docs.example.com#getting-started" +API_ENDPOINT="https://api.example.com/v1#section"`); + }); + + it("should handle values with hash symbols and other special characters", () => { + const envVars = [ + { key: "COMPLEX_PASSWORD", value: "p@ssw0rd#123!&" }, + { key: "REGEX_PATTERN", value: "^[a-zA-Z0-9#]+$" }, + { + key: "MARKDOWN_CONTENT", + value: "# Title\n\nSome content with = and & symbols", + }, + ]; + + const result = serializeEnvFile(envVars); + expect(result).toBe(`COMPLEX_PASSWORD="p@ssw0rd#123!&" +REGEX_PATTERN="^[a-zA-Z0-9#]+$" +MARKDOWN_CONTENT="# Title\n\nSome content with = and & symbols"`); + }); +}); + +describe("parseEnvFile and serializeEnvFile integration", () => { + it("should be able to parse what it serializes", () => { + const originalEnvVars = [ + { key: "API_KEY", value: "abc123" }, + { key: "MESSAGE", value: "Hello World" }, + { key: "PASSWORD", value: 'secret"123' }, + { key: "EMPTY", value: "" }, + { key: "SPECIAL", value: "p@ssw0rd!#$%" }, + ]; + + const serialized = serializeEnvFile(originalEnvVars); + const parsed = parseEnvFile(serialized); + + expect(parsed).toEqual(originalEnvVars); + }); + + it("should handle round-trip with complex values", () => { + const originalEnvVars = [ + { key: "URL", value: "https://example.com/api?key=123&secret=456" }, + { key: "REGEX", value: "^[a-zA-Z0-9]+$" }, + { key: "COMMAND", value: 'echo "Hello World"' }, + { key: "EQUATION", value: "2+2=4" }, + ]; + + const serialized = serializeEnvFile(originalEnvVars); + const parsed = parseEnvFile(serialized); + + expect(parsed).toEqual(originalEnvVars); + }); + + it("should handle round-trip with comment-like values", () => { + const originalEnvVars = [ + { key: "HASHTAG", value: "#trending" }, + { + key: "COMMENT_LIKE", + value: "# This looks like a comment but it's a value", + }, + { key: "GIT_COMMIT", value: "feat: add feature # closes #123" }, + { key: "URL_WITH_FRAGMENT", value: "https://example.com#section" }, + { key: "MARKDOWN_HEADING", value: "# Main Title" }, + { key: "COMPLEX_VALUE", value: "password#123=secret&token=abc" }, + ]; + + const serialized = serializeEnvFile(originalEnvVars); + const parsed = parseEnvFile(serialized); + + expect(parsed).toEqual(originalEnvVars); + }); +}); diff --git a/src/app/TitleBar.tsx b/src/app/TitleBar.tsx index 5a9ce1c..68205fa 100644 --- a/src/app/TitleBar.tsx +++ b/src/app/TitleBar.tsx @@ -228,7 +228,7 @@ export function AICreditStatus({ userBudget }: { userBudget: UserBudgetInfo }) { return ( -
{remaining} credits left
+
{remaining} credits
diff --git a/src/atoms/appAtoms.ts b/src/atoms/appAtoms.ts index 384e217..a2984ea 100644 --- a/src/atoms/appAtoms.ts +++ b/src/atoms/appAtoms.ts @@ -7,7 +7,9 @@ export const selectedAppIdAtom = atom(null); export const appsListAtom = atom([]); export const appBasePathAtom = atom(""); export const versionsListAtom = atom([]); -export const previewModeAtom = atom<"preview" | "code" | "problems">("preview"); +export const previewModeAtom = atom< + "preview" | "code" | "problems" | "configure" +>("preview"); export const selectedVersionIdAtom = atom(null); export const appOutputAtom = atom([]); export const appUrlAtom = atom< diff --git a/src/components/preview_panel/ConfigurePanel.tsx b/src/components/preview_panel/ConfigurePanel.tsx new file mode 100644 index 0000000..94c8c49 --- /dev/null +++ b/src/components/preview_panel/ConfigurePanel.tsx @@ -0,0 +1,401 @@ +import { useState, useCallback } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@/components/ui/tooltip"; +import { + Trash2, + Edit2, + Plus, + Save, + X, + HelpCircle, + ArrowRight, +} from "lucide-react"; +import { showError, showSuccess } from "@/lib/toast"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { IpcClient } from "@/ipc/ipc_client"; +import { useNavigate } from "@tanstack/react-router"; + +const EnvironmentVariablesTitle = () => ( +
+ Environment Variables + Local + + + + + +

+ To modify environment variables for Supabase or production, +
+ access your hosting provider's console and update them there. +

+
+
+
+); + +export const ConfigurePanel = () => { + const selectedAppId = useAtomValue(selectedAppIdAtom); + const queryClient = useQueryClient(); + + const [editingKey, setEditingKey] = useState(null); + const [editingKeyValue, setEditingKeyValue] = useState(""); + const [editingValue, setEditingValue] = useState(""); + const [newKey, setNewKey] = useState(""); + const [newValue, setNewValue] = useState(""); + const [isAddingNew, setIsAddingNew] = useState(false); + const navigate = useNavigate(); + + // Query to get environment variables + const { + data: envVars = [], + isLoading, + error, + } = useQuery({ + queryKey: ["app-env-vars", selectedAppId], + queryFn: async () => { + if (!selectedAppId) return []; + const ipcClient = IpcClient.getInstance(); + return await ipcClient.getAppEnvVars({ appId: selectedAppId }); + }, + enabled: !!selectedAppId, + }); + + // Mutation to save environment variables + const saveEnvVarsMutation = useMutation({ + mutationFn: async (newEnvVars: { key: string; value: string }[]) => { + if (!selectedAppId) throw new Error("No app selected"); + const ipcClient = IpcClient.getInstance(); + return await ipcClient.setAppEnvVars({ + appId: selectedAppId, + envVars: newEnvVars, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["app-env-vars", selectedAppId], + }); + showSuccess("Environment variables saved"); + }, + onError: (error) => { + showError(`Failed to save environment variables: ${error}`); + }, + }); + + const handleAdd = useCallback(() => { + if (!newKey.trim() || !newValue.trim()) { + showError("Both key and value are required"); + return; + } + + // Check for duplicate keys + if (envVars.some((envVar) => envVar.key === newKey.trim())) { + showError("Environment variable with this key already exists"); + return; + } + + const newEnvVars = [ + ...envVars, + { key: newKey.trim(), value: newValue.trim() }, + ]; + saveEnvVarsMutation.mutate(newEnvVars); + setNewKey(""); + setNewValue(""); + setIsAddingNew(false); + }, [newKey, newValue, envVars, saveEnvVarsMutation]); + + const handleEdit = useCallback((envVar: { key: string; value: string }) => { + setEditingKey(envVar.key); + setEditingKeyValue(envVar.key); + setEditingValue(envVar.value); + }, []); + + const handleSaveEdit = useCallback(() => { + if (!editingKeyValue.trim() || !editingValue.trim()) { + showError("Both key and value are required"); + return; + } + + // Check for duplicate keys (excluding the current one being edited) + if ( + envVars.some( + (envVar) => + envVar.key === editingKeyValue.trim() && envVar.key !== editingKey, + ) + ) { + showError("Environment variable with this key already exists"); + return; + } + + const newEnvVars = envVars.map((envVar) => + envVar.key === editingKey + ? { key: editingKeyValue.trim(), value: editingValue.trim() } + : envVar, + ); + saveEnvVarsMutation.mutate(newEnvVars); + setEditingKey(null); + setEditingKeyValue(""); + setEditingValue(""); + }, [editingKey, editingKeyValue, editingValue, envVars, saveEnvVarsMutation]); + + const handleCancelEdit = useCallback(() => { + setEditingKey(null); + setEditingKeyValue(""); + setEditingValue(""); + }, []); + + const handleDelete = useCallback( + (key: string) => { + const newEnvVars = envVars.filter((envVar) => envVar.key !== key); + saveEnvVarsMutation.mutate(newEnvVars); + }, + [envVars, saveEnvVarsMutation], + ); + + const handleCancelAdd = useCallback(() => { + setIsAddingNew(false); + setNewKey(""); + setNewValue(""); + }, []); + + // Show loading state + if (isLoading) { + return ( +
+ + + + + + + +
+
+ Loading environment variables... +
+
+
+
+
+ ); + } + + // Show error state + if (error) { + return ( +
+ + + + + + + +
+
+ Error loading environment variables: {error.message} +
+
+
+
+
+ ); + } + + // Show no app selected state + if (!selectedAppId) { + return ( +
+ + + + + + + +
+
+ Select an app to manage environment variables +
+
+
+
+
+ ); + } + + return ( +
+ + + + + + + + {/* Add new environment variable form */} + {isAddingNew ? ( +
+
+ + setNewKey(e.target.value)} + autoFocus + /> +
+
+ + setNewValue(e.target.value)} + /> +
+
+ + +
+
+ ) : ( + + )} + + {/* List of existing environment variables */} +
+ {envVars.length === 0 ? ( +

+ No environment variables configured +

+ ) : ( + envVars.map((envVar) => ( +
+ {editingKey === envVar.key ? ( + <> +
+ setEditingKeyValue(e.target.value)} + placeholder="Key" + className="h-8" + /> + setEditingValue(e.target.value)} + placeholder="Value" + className="h-8" + /> +
+
+ + +
+ + ) : ( + <> +
+
+ {envVar.key} +
+
+ {envVar.value} +
+
+
+ + +
+ + )} +
+ )) + )} +
+ + {/* More app configurations button */} +
+ +
+
+
+
+ ); +}; diff --git a/src/components/preview_panel/PreviewHeader.tsx b/src/components/preview_panel/PreviewHeader.tsx index 21fc738..aaa5be2 100644 --- a/src/components/preview_panel/PreviewHeader.tsx +++ b/src/components/preview_panel/PreviewHeader.tsx @@ -9,6 +9,7 @@ import { Cog, Trash2, AlertTriangle, + Wrench, } from "lucide-react"; import { motion } from "framer-motion"; import { useEffect, useRef, useState, useCallback } from "react"; @@ -25,7 +26,7 @@ import { useMutation } from "@tanstack/react-query"; import { useCheckProblems } from "@/hooks/useCheckProblems"; import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; -export type PreviewMode = "preview" | "code" | "problems"; +export type PreviewMode = "preview" | "code" | "problems" | "configure"; // Preview Header component with preview mode toggle export const PreviewHeader = () => { @@ -35,6 +36,7 @@ export const PreviewHeader = () => { const previewRef = useRef(null); const codeRef = useRef(null); const problemsRef = useRef(null); + const configureRef = useRef(null); const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }); const { problemReport } = useCheckProblems(selectedAppId); const { restartApp, refreshAppIframe } = useRunApp(); @@ -101,6 +103,9 @@ export const PreviewHeader = () => { case "problems": targetRef = problemsRef; break; + case "configure": + targetRef = configureRef; + break; default: return; } @@ -146,7 +151,7 @@ export const PreviewHeader = () => { + +
diff --git a/src/components/preview_panel/PreviewPanel.tsx b/src/components/preview_panel/PreviewPanel.tsx index d73f5a5..0403f38 100644 --- a/src/components/preview_panel/PreviewPanel.tsx +++ b/src/components/preview_panel/PreviewPanel.tsx @@ -9,6 +9,7 @@ import { import { CodeView } from "./CodeView"; import { PreviewIframe } from "./PreviewIframe"; import { Problems } from "./Problems"; +import { ConfigurePanel } from "./ConfigurePanel"; import { ChevronDown, ChevronUp, Logs } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels"; @@ -113,6 +114,8 @@ export function PreviewPanel() { ) : previewMode === "code" ? ( + ) : previewMode === "configure" ? ( + ) : ( )} diff --git a/src/ipc/handlers/app_env_vars_handlers.ts b/src/ipc/handlers/app_env_vars_handlers.ts new file mode 100644 index 0000000..0b1ea56 --- /dev/null +++ b/src/ipc/handlers/app_env_vars_handlers.ts @@ -0,0 +1,81 @@ +/** + * DO NOT USE LOGGER HERE. + * Environment variables are sensitive and should not be logged. + */ +import { ipcMain } from "electron"; +import * as fs from "fs"; +import * as path from "path"; +import { db } from "../../db"; +import { apps } from "../../db/schema"; +import { eq } from "drizzle-orm"; +import { getDyadAppPath } from "../../paths/paths"; +import { GetAppEnvVarsParams, SetAppEnvVarsParams } from "../ipc_types"; +import { parseEnvFile, serializeEnvFile } from "../utils/app_env_var_utils"; + +export function registerAppEnvVarsHandlers() { + // Handler to get app environment variables + ipcMain.handle( + "get-app-env-vars", + async (event, { appId }: GetAppEnvVarsParams) => { + try { + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), + }); + + if (!app) { + throw new Error("App not found"); + } + + const appPath = getDyadAppPath(app.path); + const envFilePath = path.join(appPath, ".env.local"); + + // If .env.local doesn't exist, return empty array + try { + await fs.promises.access(envFilePath); + } catch { + return []; + } + + const content = await fs.promises.readFile(envFilePath, "utf8"); + const envVars = parseEnvFile(content); + + return envVars; + } catch (error) { + console.error("Error getting app environment variables:", error); + throw new Error( + `Failed to get environment variables: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, + ); + + // Handler to set app environment variables + ipcMain.handle( + "set-app-env-vars", + async (event, { appId, envVars }: SetAppEnvVarsParams) => { + try { + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), + }); + + if (!app) { + throw new Error("App not found"); + } + + const appPath = getDyadAppPath(app.path); + const envFilePath = path.join(appPath, ".env.local"); + + // Serialize environment variables to .env.local format + const content = serializeEnvFile(envVars); + + // Write to .env.local file + await fs.promises.writeFile(envFilePath, content, "utf8"); + } catch (error) { + console.error("Error setting app environment variables:", error); + throw new Error( + `Failed to set environment variables: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, + ); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index b92ec5c..b2c34ff 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -38,6 +38,8 @@ import type { AppUpgrade, ProblemReport, EditAppFileReturnType, + GetAppEnvVarsParams, + SetAppEnvVarsParams, } from "./ipc_types"; import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import { showError } from "@/lib/toast"; @@ -183,6 +185,16 @@ export class IpcClient { return this.ipcRenderer.invoke("get-app", appId); } + public async getAppEnvVars( + params: GetAppEnvVarsParams, + ): Promise<{ key: string; value: string }[]> { + return this.ipcRenderer.invoke("get-app-env-vars", params); + } + + public async setAppEnvVars(params: SetAppEnvVarsParams): Promise { + return this.ipcRenderer.invoke("set-app-env-vars", params); + } + public async getChat(chatId: number): Promise { try { const data = await this.ipcRenderer.invoke("get-chat", chatId); diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index c5e8ac4..abb4904 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -23,6 +23,7 @@ import { registerContextPathsHandlers } from "./handlers/context_paths_handlers" import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers"; import { registerCapacitorHandlers } from "./handlers/capacitor_handlers"; import { registerProblemsHandlers } from "./handlers/problems_handlers"; +import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; export function registerIpcHandlers() { // Register all IPC handlers by category @@ -51,4 +52,5 @@ export function registerIpcHandlers() { registerContextPathsHandlers(); registerAppUpgradeHandlers(); registerCapacitorHandlers(); + registerAppEnvVarsHandlers(); } diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 88e8897..7f7066f 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -252,3 +252,17 @@ export interface AppUpgrade { export interface EditAppFileReturnType { warning?: string; } + +export interface EnvVar { + key: string; + value: string; +} + +export interface SetAppEnvVarsParams { + appId: number; + envVars: EnvVar[]; +} + +export interface GetAppEnvVarsParams { + appId: number; +} diff --git a/src/ipc/utils/app_env_var_utils.ts b/src/ipc/utils/app_env_var_utils.ts new file mode 100644 index 0000000..6aa552e --- /dev/null +++ b/src/ipc/utils/app_env_var_utils.ts @@ -0,0 +1,71 @@ +/** + * DO NOT USE LOGGER HERE. + * Environment variables are sensitive and should not be logged. + */ + +import { EnvVar } from "../ipc_types"; + +// Helper function to parse .env.local file content +export function parseEnvFile(content: string): EnvVar[] { + const envVars: EnvVar[] = []; + const lines = content.split("\n"); + + for (const line of lines) { + const trimmedLine = line.trim(); + + // Skip empty lines and comments + if (!trimmedLine || trimmedLine.startsWith("#")) { + continue; + } + + // Parse key=value pairs + const equalIndex = trimmedLine.indexOf("="); + if (equalIndex > 0) { + const key = trimmedLine.substring(0, equalIndex).trim(); + const value = trimmedLine.substring(equalIndex + 1).trim(); + + // Handle quoted values with potential inline comments + let cleanValue = value; + if (value.startsWith('"')) { + // Find the closing quote, handling escaped quotes + let endQuoteIndex = -1; + for (let i = 1; i < value.length; i++) { + if (value[i] === '"' && value[i - 1] !== "\\") { + endQuoteIndex = i; + break; + } + } + if (endQuoteIndex !== -1) { + cleanValue = value.slice(1, endQuoteIndex); + // Unescape escaped quotes + cleanValue = cleanValue.replace(/\\"/g, '"'); + } + } else if (value.startsWith("'")) { + // Find the closing quote for single quotes + const endQuoteIndex = value.indexOf("'", 1); + if (endQuoteIndex !== -1) { + cleanValue = value.slice(1, endQuoteIndex); + } + } + // For unquoted values, keep everything as-is (including potential # symbols) + + envVars.push({ key, value: cleanValue }); + } + } + + return envVars; +} + +// Helper function to serialize environment variables to .env.local format +export function serializeEnvFile(envVars: EnvVar[]): string { + return envVars + .map(({ key, value }) => { + // Add quotes if value contains spaces or special characters + const needsQuotes = /[\s#"'=&?]/.test(value); + const quotedValue = needsQuotes + ? `"${value.replace(/"/g, '\\"')}"` + : value; + return `${key}=${quotedValue}`; + }) + .join("\n"); +} diff --git a/src/preload.ts b/src/preload.ts index 4394445..ebb16f7 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -26,6 +26,8 @@ const validInvokeChannels = [ "get-chat-logs", "list-apps", "get-app", + "get-app-env-vars", + "set-app-env-vars", "edit-app-file", "read-app-file", "run-app",