Unverified 提交 9869fefb authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Support dyad docker (#674)

上级 e6c92a24
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useSettings } from "@/hooks/useSettings";
import { showError } from "@/lib/toast";
import { IpcClient } from "@/ipc/ipc_client";
export function RuntimeModeSelector() {
const { settings, updateSettings } = useSettings();
if (!settings) {
return null;
}
const isDockerMode = settings?.runtimeMode2 === "docker";
const handleRuntimeModeChange = async (value: "host" | "docker") => {
try {
await updateSettings({ runtimeMode2: value });
} catch (error: any) {
showError(`Failed to update runtime mode: ${error.message}`);
}
};
return (
<div className="space-y-2">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<Label className="text-sm font-medium" htmlFor="runtime-mode">
Runtime Mode
</Label>
<Select
value={settings.runtimeMode2 ?? "host"}
onValueChange={handleRuntimeModeChange}
>
<SelectTrigger className="w-48" id="runtime-mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="host">Local (default)</SelectItem>
<SelectItem value="docker">Docker (experimental)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Choose whether to run apps directly on the local machine or in Docker
containers
</div>
</div>
{isDockerMode && (
<div className="text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
⚠️ Docker mode is <b>experimental</b> and requires{" "}
<button
type="button"
className="underline font-medium cursor-pointer"
onClick={() =>
IpcClient.getInstance().openExternalUrl(
"https://www.docker.com/products/docker-desktop/",
)
}
>
Docker Desktop
</button>{" "}
to be installed and running
</div>
)}
</div>
);
}
......@@ -51,10 +51,11 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const { isStreaming } = useStreamChat();
if (!error) return null;
const isDockerError = error.includes("Cannot connect to the Docker");
const getTruncatedError = () => {
const firstLine = error.split("\n")[0];
const snippetLength = 200;
const snippetLength = 250;
const snippet = error.substring(0, snippetLength);
return firstLine.length < snippet.length
? firstLine
......@@ -97,23 +98,27 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
<Lightbulb size={16} className=" text-red-800 dark:text-red-300" />
</div>
<span className="text-sm text-red-700 dark:text-red-200">
<span className="font-medium">Tip: </span>Check if restarting the
app fixes the error.
<span className="font-medium">Tip: </span>
{isDockerError
? "Make sure Docker Desktop is running and try restarting the app."
: "Check if restarting the app fixes the error."}
</span>
</div>
</div>
{/* AI Fix button at the bottom */}
<div className="mt-2 flex justify-end">
<button
disabled={isStreaming}
onClick={onAIFix}
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles size={14} />
<span>Fix error with AI</span>
</button>
</div>
{!isDockerError && (
<div className="mt-2 flex justify-end">
<button
disabled={isStreaming}
onClick={onAIFix}
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Sparkles size={14} />
<span>Fix error with AI</span>
</button>
</div>
)}
</div>
);
};
......
import { ChildProcess } from "node:child_process";
import { ChildProcess, spawn } from "node:child_process";
import treeKill from "tree-kill";
// Define a type for the value stored in runningApps
export interface RunningAppInfo {
process: ChildProcess;
processId: number;
isDocker: boolean;
containerName?: string;
}
// Store running app processes
......@@ -81,6 +83,49 @@ export function killProcess(process: ChildProcess): Promise<void> {
});
}
/**
* Gracefully stops a Docker container by name. Resolves even if the container doesn't exist.
*/
export function stopDockerContainer(containerName: string): Promise<void> {
return new Promise<void>((resolve) => {
const stop = spawn("docker", ["stop", containerName], { stdio: "pipe" });
stop.on("close", () => resolve());
stop.on("error", () => resolve());
});
}
/**
* Removes Docker named volumes used for an app's dependencies.
* Best-effort: resolves even if volumes don't exist.
*/
export function removeDockerVolumesForApp(appId: number): Promise<void> {
return new Promise<void>((resolve) => {
const pnpmVolume = `dyad-pnpm-${appId}`;
const rm = spawn("docker", ["volume", "rm", "-f", pnpmVolume], {
stdio: "pipe",
});
rm.on("close", () => resolve());
rm.on("error", () => resolve());
});
}
/**
* Stops an app based on its RunningAppInfo (container vs host) and removes it from the running map.
*/
export async function stopAppByInfo(
appId: number,
appInfo: RunningAppInfo,
): Promise<void> {
if (appInfo.isDocker) {
const containerName = appInfo.containerName || `dyad-app-${appId}`;
await stopDockerContainer(containerName);
} else {
await killProcess(appInfo.process);
}
runningApps.delete(appId);
}
/**
* Removes an app from the running apps map if it's the current process
* @param appId The app ID
......
......@@ -69,6 +69,9 @@ export type ProviderSetting = z.infer<typeof ProviderSettingSchema>;
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
export const RuntimeMode2Schema = z.enum(["host", "docker"]);
export type RuntimeMode2 = z.infer<typeof RuntimeMode2Schema>;
export const ChatModeSchema = z.enum(["build", "ask"]);
export type ChatMode = z.infer<typeof ChatModeSchema>;
......@@ -170,6 +173,7 @@ export const UserSettingsSchema = z.object({
enableNativeGit: z.boolean().optional(),
enableAutoUpdate: z.boolean(),
releaseChannel: ReleaseChannelSchema,
runtimeMode2: RuntimeMode2Schema.optional(),
////////////////////////////////
// E2E TESTING ONLY.
......
......@@ -23,6 +23,7 @@ import { AutoFixProblemsSwitch } from "@/components/AutoFixProblemsSwitch";
import { AutoUpdateSwitch } from "@/components/AutoUpdateSwitch";
import { ReleaseChannelSelector } from "@/components/ReleaseChannelSelector";
import { NeonIntegration } from "@/components/NeonIntegration";
import { RuntimeModeSelector } from "@/components/RuntimeModeSelector";
export default function SettingsPage() {
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
......@@ -256,6 +257,10 @@ export function GeneralSettings({ appVersion }: { appVersion: string | null }) {
<ReleaseChannelSelector />
</div>
<div className="mt-4">
<RuntimeModeSelector />
</div>
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400 mt-4">
<span className="mr-2 font-medium">App Version:</span>
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-gray-800 dark:text-gray-200 font-mono">
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论