Import app (#189)

Fixes #163
This commit is contained in:
Will Chen
2025-05-19 14:03:10 -07:00
committed by GitHub
parent b5671c0a59
commit 6e08bc5c62
9 changed files with 490 additions and 4 deletions

View File

@@ -0,0 +1,27 @@
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
import { useState } from "react";
import { ImportAppDialog } from "./ImportAppDialog";
export function ImportAppButton() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
return (
<>
<div className="px-4 pb-1 flex justify-center">
<Button
variant="default"
size="default"
onClick={() => setIsDialogOpen(true)}
>
<Upload className="mr-2 h-4 w-4" />
Import App
</Button>
</div>
<ImportAppDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,275 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { useMutation } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
import { Folder, X, Loader2, Info } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@radix-ui/react-label";
import { useNavigate } from "@tanstack/react-router";
import { useStreamChat } from "@/hooks/useStreamChat";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useSetAtom } from "jotai";
import { useLoadApps } from "@/hooks/useLoadApps";
interface ImportAppDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [hasAiRules, setHasAiRules] = useState<boolean | null>(null);
const [customAppName, setCustomAppName] = useState<string>("");
const [nameExists, setNameExists] = useState<boolean>(false);
const [isCheckingName, setIsCheckingName] = useState<boolean>(false);
const navigate = useNavigate();
const { streamMessage } = useStreamChat({ hasChatId: false });
const { refreshApps } = useLoadApps();
const setSelectedAppId = useSetAtom(selectedAppIdAtom);
const checkAppName = async (name: string): Promise<void> => {
setIsCheckingName(true);
try {
const result = await IpcClient.getInstance().checkAppName({
appName: name,
});
setNameExists(result.exists);
} catch (error: unknown) {
showError("Failed to check app name: " + (error as any).toString());
} finally {
setIsCheckingName(false);
}
};
const selectFolderMutation = useMutation({
mutationFn: async () => {
const result = await IpcClient.getInstance().selectAppFolder();
if (!result.path || !result.name) {
throw new Error("No folder selected");
}
const aiRulesCheck = await IpcClient.getInstance().checkAiRules({
path: result.path,
});
setHasAiRules(aiRulesCheck.exists);
setSelectedPath(result.path);
// Use the folder name from the IPC response
setCustomAppName(result.name);
// Check if the app name already exists
await checkAppName(result.name);
return result;
},
onError: (error: Error) => {
showError(error.message);
},
});
const importAppMutation = useMutation({
mutationFn: async () => {
if (!selectedPath) throw new Error("No folder selected");
return IpcClient.getInstance().importApp({
path: selectedPath,
appName: customAppName,
});
},
onSuccess: async (result) => {
showSuccess(
!hasAiRules
? "App imported successfully. Dyad will automatically generate an AI_RULES.md now."
: "App imported successfully",
);
onClose();
navigate({ to: "/chat", search: { id: result.chatId } });
streamMessage({
prompt:
"Generate an AI_RULES.md file for this app. Describe the tech stack in 5-10 bullet points and describe clear rules about what libraries to use for what.",
chatId: result.chatId,
});
setSelectedAppId(result.appId);
await refreshApps();
},
onError: (error: Error) => {
showError(error.message);
},
});
const handleSelectFolder = () => {
selectFolderMutation.mutate();
};
const handleImport = () => {
importAppMutation.mutate();
};
const handleClear = () => {
setSelectedPath(null);
setHasAiRules(null);
setCustomAppName("");
setNameExists(false);
};
const handleAppNameChange = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const newName = e.target.value;
setCustomAppName(newName);
if (newName.trim()) {
await checkAppName(newName);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import App</DialogTitle>
<DialogDescription>
Select an existing app folder to import into Dyad.
</DialogDescription>
</DialogHeader>
<Alert className="border-blue-500/20 text-blue-500">
<Info className="h-4 w-4" />
<AlertDescription>
App import is an experimental feature. If you encounter any issues,
please report them using the Help button.
</AlertDescription>
</Alert>
<div className="py-4">
{!selectedPath ? (
<Button
onClick={handleSelectFolder}
disabled={selectFolderMutation.isPending}
className="w-full"
>
{selectFolderMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Folder className="mr-2 h-4 w-4" />
)}
{selectFolderMutation.isPending
? "Selecting folder..."
: "Select Folder"}
</Button>
) : (
<div className="space-y-4">
<div className="rounded-md border p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Selected folder:</p>
<p className="text-sm text-muted-foreground break-all">
{selectedPath}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="h-8 w-8 p-0 flex-shrink-0"
disabled={importAppMutation.isPending}
>
<X className="h-4 w-4" />
<span className="sr-only">Clear selection</span>
</Button>
</div>
</div>
<div className="space-y-2">
{nameExists && (
<p className="text-sm text-yellow-500">
An app with this name already exists. Please choose a
different name:
</p>
)}
<div className="relative">
<Label className="text-sm ml-2 mb-2">App name</Label>
<Input
value={customAppName}
onChange={handleAppNameChange}
placeholder="Enter new app name"
className="w-full pr-8"
disabled={importAppMutation.isPending}
/>
{isCheckingName && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
</div>
{hasAiRules === false && (
<Alert className="border-yellow-500/20 text-yellow-500 flex items-start gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 flex-shrink-0 mt-1" />
</TooltipTrigger>
<TooltipContent>
<p>
AI_RULES.md lets Dyad know which tech stack to use for
editing the app
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDescription>
No AI_RULES.md found. Dyad will automatically generate one
after importing.
</AlertDescription>
</Alert>
)}
{importAppMutation.isPending && (
<div className="flex items-center justify-center space-x-2 text-sm text-muted-foreground animate-pulse">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Importing app...</span>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={importAppMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={
!selectedPath || importAppMutation.isPending || nameExists
}
className="min-w-[80px]"
>
{importAppMutation.isPending ? <>Importing...</> : "Import"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -215,7 +215,7 @@ export function registerAppHandlers() {
const commitHash = await git.commit({
fs: fs,
dir: fullAppPath,
message: "Init from react vite template",
message: "Init Dyad app",
author: await getGitAuthor(),
});

View File

@@ -0,0 +1,147 @@
import { dialog } from "electron";
import fs from "fs/promises";
import path from "path";
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { getDyadAppPath } from "../../paths/paths";
import { apps } from "@/db/schema";
import { db } from "@/db";
import { chats } from "@/db/schema";
import { eq } from "drizzle-orm";
import git from "isomorphic-git";
import { getGitAuthor } from "../utils/git_author";
import { ImportAppParams, ImportAppResult } from "../ipc_types";
const logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger);
export function registerImportHandlers() {
// Handler for selecting an app folder
handle("select-app-folder", async () => {
const result = await dialog.showOpenDialog({
properties: ["openDirectory"],
title: "Select App Folder to Import",
});
if (result.canceled) {
return { path: null, name: null };
}
const selectedPath = result.filePaths[0];
const folderName = path.basename(selectedPath);
return { path: selectedPath, name: folderName };
});
// Handler for checking if AI_RULES.md exists
handle("check-ai-rules", async (_, { path: appPath }: { path: string }) => {
try {
await fs.access(path.join(appPath, "AI_RULES.md"));
return { exists: true };
} catch {
return { exists: false };
}
});
// Handler for checking if an app name is already taken
handle("check-app-name", async (_, { appName }: { appName: string }) => {
// Check filesystem
const appPath = getDyadAppPath(appName);
try {
await fs.access(appPath);
return { exists: true };
} catch {
// Path doesn't exist, continue checking database
}
// Check database
const existingApp = await db.query.apps.findFirst({
where: eq(apps.name, appName),
});
return { exists: !!existingApp };
});
// Handler for importing an app
handle(
"import-app",
async (
_,
{ path: sourcePath, appName }: ImportAppParams,
): Promise<ImportAppResult> => {
// Validate the source path exists
try {
await fs.access(sourcePath);
} catch {
throw new Error("Source folder does not exist");
}
const destPath = getDyadAppPath(appName);
// Check if the app already exists
const errorMessage = "An app with this name already exists";
try {
await fs.access(destPath);
throw new Error(errorMessage);
} catch (error: any) {
if (error.message === errorMessage) {
throw error;
}
}
// Copy the app folder to the Dyad apps directory, excluding node_modules
await fs.cp(sourcePath, destPath, {
recursive: true,
filter: (source) => !source.includes("node_modules"),
});
const isGitRepo = await fs
.access(path.join(destPath, ".git"))
.then(() => true)
.catch(() => false);
if (!isGitRepo) {
// Initialize git repo and create first commit
await git.init({
fs: fs,
dir: destPath,
defaultBranch: "main",
});
// Stage all files
await git.add({
fs: fs,
dir: destPath,
filepath: ".",
});
// Create initial commit
await git.commit({
fs: fs,
dir: destPath,
message: "Init Dyad app",
author: await getGitAuthor(),
});
}
// Create a new app
const [app] = await db
.insert(apps)
.values({
name: appName,
// Use the name as the path for now
path: appName,
})
.returning();
// Create an initial chat for this app
const [chat] = await db
.insert(chats)
.values({
appId: app.id,
})
.returning();
return { appId: app.id, chatId: chat.id };
},
);
logger.debug("Registered import IPC handlers");
}

View File

@@ -27,6 +27,8 @@ import type {
CreateCustomLanguageModelParams,
DoesReleaseNoteExistParams,
ApproveProposalResult,
ImportAppResult,
ImportAppParams,
} from "./ipc_types";
import type { ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast";
@@ -792,9 +794,26 @@ export class IpcClient {
});
}
// --- End window control methods ---
public async selectAppFolder(): Promise<{
path: string | null;
name: string | null;
}> {
return this.ipcRenderer.invoke("select-app-folder");
}
// --- Language Model Operations ---
public async checkAiRules(params: {
path: string;
}): Promise<{ exists: boolean }> {
return this.ipcRenderer.invoke("check-ai-rules", params);
}
// --- App Operations ---
public async importApp(params: ImportAppParams): Promise<ImportAppResult> {
return this.ipcRenderer.invoke("import-app", params);
}
async checkAppName(params: {
appName: string;
}): Promise<{ exists: boolean }> {
return this.ipcRenderer.invoke("check-app-name", params);
}
}

View File

@@ -16,6 +16,7 @@ import { registerUploadHandlers } from "./handlers/upload_handlers";
import { registerVersionHandlers } from "./handlers/version_handlers";
import { registerLanguageModelHandlers } from "./handlers/language_model_handlers";
import { registerReleaseNoteHandlers } from "./handlers/release_note_handlers";
import { registerImportHandlers } from "./handlers/import_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
@@ -37,4 +38,5 @@ export function registerIpcHandlers() {
registerVersionHandlers();
registerLanguageModelHandlers();
registerReleaseNoteHandlers();
registerImportHandlers();
}

View File

@@ -191,3 +191,13 @@ export interface ApproveProposalResult {
extraFiles?: string[];
extraFilesError?: string;
}
export interface ImportAppParams {
path: string;
appName: string;
}
export interface ImportAppResult {
appId: number;
chatId: number;
}

View File

@@ -24,6 +24,7 @@ import {
import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import { ExternalLink } from "lucide-react";
import { ImportAppButton } from "@/components/ImportAppButton";
// Adding an export for attachments
export interface HomeSubmitOptions {
@@ -165,6 +166,7 @@ export default function HomePage() {
<SetupBanner />
<div className="w-full">
<ImportAppButton />
<HomeChatInput onSubmit={handleSubmit} />
<div className="flex flex-col gap-4 mt-4">

View File

@@ -69,6 +69,10 @@ const validInvokeChannels = [
"delete-messages",
"start-chat-stream",
"does-release-note-exist",
"import-app",
"check-ai-rules",
"select-app-folder",
"check-app-name",
] as const;
// Add valid receive channels