Unverified 提交 2c284d0f authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Allow configuring environmental variables in panel (#626)

- [ ] Add test cases
上级 4b84b12f
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();
}
...@@ -422,7 +422,7 @@ export class PageObject { ...@@ -422,7 +422,7 @@ export class PageObject {
// Preview panel // Preview panel
//////////////////////////////// ////////////////////////////////
async selectPreviewMode(mode: "code" | "problems" | "preview") { async selectPreviewMode(mode: "code" | "problems" | "preview" | "configure") {
await this.page.getByTestId(`${mode}-mode-button`).click(); await this.page.getByTestId(`${mode}-mode-button`).click();
} }
......
aKey=aValue
\ No newline at end of file
aKey=aValue
bKey=bValue
\ No newline at end of file
bKey=bValue2
\ No newline at end of file
aKey=aValue
bKey=bValue2
\ No newline at end of file
差异被折叠。
...@@ -228,7 +228,7 @@ export function AICreditStatus({ userBudget }: { userBudget: UserBudgetInfo }) { ...@@ -228,7 +228,7 @@ export function AICreditStatus({ userBudget }: { userBudget: UserBudgetInfo }) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<div className="text-xs mt-0.5">{remaining} credits left</div> <div className="text-xs mt-0.5">{remaining} credits</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div> <div>
......
...@@ -7,7 +7,9 @@ export const selectedAppIdAtom = atom<number | null>(null); ...@@ -7,7 +7,9 @@ export const selectedAppIdAtom = atom<number | null>(null);
export const appsListAtom = atom<App[]>([]); export const appsListAtom = atom<App[]>([]);
export const appBasePathAtom = atom<string>(""); export const appBasePathAtom = atom<string>("");
export const versionsListAtom = atom<Version[]>([]); export const versionsListAtom = atom<Version[]>([]);
export const previewModeAtom = atom<"preview" | "code" | "problems">("preview"); export const previewModeAtom = atom<
"preview" | "code" | "problems" | "configure"
>("preview");
export const selectedVersionIdAtom = atom<string | null>(null); export const selectedVersionIdAtom = atom<string | null>(null);
export const appOutputAtom = atom<AppOutput[]>([]); export const appOutputAtom = atom<AppOutput[]>([]);
export const appUrlAtom = atom< export const appUrlAtom = atom<
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
Cog, Cog,
Trash2, Trash2,
AlertTriangle, AlertTriangle,
Wrench,
} from "lucide-react"; } from "lucide-react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState, useCallback } from "react";
...@@ -25,7 +26,7 @@ import { useMutation } from "@tanstack/react-query"; ...@@ -25,7 +26,7 @@ import { useMutation } from "@tanstack/react-query";
import { useCheckProblems } from "@/hooks/useCheckProblems"; import { useCheckProblems } from "@/hooks/useCheckProblems";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; 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 // Preview Header component with preview mode toggle
export const PreviewHeader = () => { export const PreviewHeader = () => {
...@@ -35,6 +36,7 @@ export const PreviewHeader = () => { ...@@ -35,6 +36,7 @@ export const PreviewHeader = () => {
const previewRef = useRef<HTMLButtonElement>(null); const previewRef = useRef<HTMLButtonElement>(null);
const codeRef = useRef<HTMLButtonElement>(null); const codeRef = useRef<HTMLButtonElement>(null);
const problemsRef = useRef<HTMLButtonElement>(null); const problemsRef = useRef<HTMLButtonElement>(null);
const configureRef = useRef<HTMLButtonElement>(null);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 }); const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
const { problemReport } = useCheckProblems(selectedAppId); const { problemReport } = useCheckProblems(selectedAppId);
const { restartApp, refreshAppIframe } = useRunApp(); const { restartApp, refreshAppIframe } = useRunApp();
...@@ -101,6 +103,9 @@ export const PreviewHeader = () => { ...@@ -101,6 +103,9 @@ export const PreviewHeader = () => {
case "problems": case "problems":
targetRef = problemsRef; targetRef = problemsRef;
break; break;
case "configure":
targetRef = configureRef;
break;
default: default:
return; return;
} }
...@@ -146,7 +151,7 @@ export const PreviewHeader = () => { ...@@ -146,7 +151,7 @@ export const PreviewHeader = () => {
<button <button
data-testid="preview-mode-button" data-testid="preview-mode-button"
ref={previewRef} ref={previewRef}
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10" className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
onClick={() => selectPanel("preview")} onClick={() => selectPanel("preview")}
> >
<Eye size={14} /> <Eye size={14} />
...@@ -155,7 +160,7 @@ export const PreviewHeader = () => { ...@@ -155,7 +160,7 @@ export const PreviewHeader = () => {
<button <button
data-testid="problems-mode-button" data-testid="problems-mode-button"
ref={problemsRef} ref={problemsRef}
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10" className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
onClick={() => selectPanel("problems")} onClick={() => selectPanel("problems")}
> >
<AlertTriangle size={14} /> <AlertTriangle size={14} />
...@@ -166,15 +171,25 @@ export const PreviewHeader = () => { ...@@ -166,15 +171,25 @@ export const PreviewHeader = () => {
</span> </span>
)} )}
</button> </button>
<button <button
data-testid="code-mode-button" data-testid="code-mode-button"
ref={codeRef} ref={codeRef}
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10" className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
onClick={() => selectPanel("code")} onClick={() => selectPanel("code")}
> >
<Code size={14} /> <Code size={14} />
<span>Code</span> <span>Code</span>
</button> </button>
<button
data-testid="configure-mode-button"
ref={configureRef}
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10 hover:bg-[var(--background)]"
onClick={() => selectPanel("configure")}
>
<Wrench size={14} />
<span>Configure</span>
</button>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenu> <DropdownMenu>
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
import { CodeView } from "./CodeView"; import { CodeView } from "./CodeView";
import { PreviewIframe } from "./PreviewIframe"; import { PreviewIframe } from "./PreviewIframe";
import { Problems } from "./Problems"; import { Problems } from "./Problems";
import { ConfigurePanel } from "./ConfigurePanel";
import { ChevronDown, ChevronUp, Logs } from "lucide-react"; import { ChevronDown, ChevronUp, Logs } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels"; import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
...@@ -113,6 +114,8 @@ export function PreviewPanel() { ...@@ -113,6 +114,8 @@ export function PreviewPanel() {
<PreviewIframe key={key} loading={loading} /> <PreviewIframe key={key} loading={loading} />
) : previewMode === "code" ? ( ) : previewMode === "code" ? (
<CodeView loading={loading} app={app} /> <CodeView loading={loading} app={app} />
) : previewMode === "configure" ? (
<ConfigurePanel />
) : ( ) : (
<Problems /> <Problems />
)} )}
......
/**
* 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"}`,
);
}
},
);
}
...@@ -38,6 +38,8 @@ import type { ...@@ -38,6 +38,8 @@ import type {
AppUpgrade, AppUpgrade,
ProblemReport, ProblemReport,
EditAppFileReturnType, EditAppFileReturnType,
GetAppEnvVarsParams,
SetAppEnvVarsParams,
} from "./ipc_types"; } from "./ipc_types";
import type { AppChatContext, ProposalResult } from "@/lib/schemas"; import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
...@@ -183,6 +185,16 @@ export class IpcClient { ...@@ -183,6 +185,16 @@ export class IpcClient {
return this.ipcRenderer.invoke("get-app", appId); 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<void> {
return this.ipcRenderer.invoke("set-app-env-vars", params);
}
public async getChat(chatId: number): Promise<Chat> { public async getChat(chatId: number): Promise<Chat> {
try { try {
const data = await this.ipcRenderer.invoke("get-chat", chatId); const data = await this.ipcRenderer.invoke("get-chat", chatId);
......
...@@ -23,6 +23,7 @@ import { registerContextPathsHandlers } from "./handlers/context_paths_handlers" ...@@ -23,6 +23,7 @@ import { registerContextPathsHandlers } from "./handlers/context_paths_handlers"
import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers"; import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
import { registerCapacitorHandlers } from "./handlers/capacitor_handlers"; import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
import { registerProblemsHandlers } from "./handlers/problems_handlers"; import { registerProblemsHandlers } from "./handlers/problems_handlers";
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
export function registerIpcHandlers() { export function registerIpcHandlers() {
// Register all IPC handlers by category // Register all IPC handlers by category
...@@ -51,4 +52,5 @@ export function registerIpcHandlers() { ...@@ -51,4 +52,5 @@ export function registerIpcHandlers() {
registerContextPathsHandlers(); registerContextPathsHandlers();
registerAppUpgradeHandlers(); registerAppUpgradeHandlers();
registerCapacitorHandlers(); registerCapacitorHandlers();
registerAppEnvVarsHandlers();
} }
...@@ -252,3 +252,17 @@ export interface AppUpgrade { ...@@ -252,3 +252,17 @@ export interface AppUpgrade {
export interface EditAppFileReturnType { export interface EditAppFileReturnType {
warning?: string; warning?: string;
} }
export interface EnvVar {
key: string;
value: string;
}
export interface SetAppEnvVarsParams {
appId: number;
envVars: EnvVar[];
}
export interface GetAppEnvVarsParams {
appId: number;
}
/**
* 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");
}
...@@ -26,6 +26,8 @@ const validInvokeChannels = [ ...@@ -26,6 +26,8 @@ const validInvokeChannels = [
"get-chat-logs", "get-chat-logs",
"list-apps", "list-apps",
"get-app", "get-app",
"get-app-env-vars",
"set-app-env-vars",
"edit-app-file", "edit-app-file",
"read-app-file", "read-app-file",
"run-app", "run-app",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论