diff --git a/src/components/ImportAppButton.tsx b/src/components/ImportAppButton.tsx new file mode 100644 index 0000000..56428c0 --- /dev/null +++ b/src/components/ImportAppButton.tsx @@ -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 ( + <> +
+ +
+ setIsDialogOpen(false)} + /> + + ); +} diff --git a/src/components/ImportAppDialog.tsx b/src/components/ImportAppDialog.tsx new file mode 100644 index 0000000..9d03d34 --- /dev/null +++ b/src/components/ImportAppDialog.tsx @@ -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(null); + const [hasAiRules, setHasAiRules] = useState(null); + const [customAppName, setCustomAppName] = useState(""); + const [nameExists, setNameExists] = useState(false); + const [isCheckingName, setIsCheckingName] = useState(false); + const navigate = useNavigate(); + const { streamMessage } = useStreamChat({ hasChatId: false }); + const { refreshApps } = useLoadApps(); + const setSelectedAppId = useSetAtom(selectedAppIdAtom); + + const checkAppName = async (name: string): Promise => { + 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, + ) => { + const newName = e.target.value; + setCustomAppName(newName); + if (newName.trim()) { + await checkAppName(newName); + } + }; + + return ( + + + + Import App + + Select an existing app folder to import into Dyad. + + + + + + + App import is an experimental feature. If you encounter any issues, + please report them using the Help button. + + + +
+ {!selectedPath ? ( + + ) : ( +
+
+
+
+

Selected folder:

+

+ {selectedPath} +

+
+ +
+
+ +
+ {nameExists && ( +

+ An app with this name already exists. Please choose a + different name: +

+ )} +
+ + + {isCheckingName && ( +
+ +
+ )} +
+
+ + {hasAiRules === false && ( + + + + + + + +

+ AI_RULES.md lets Dyad know which tech stack to use for + editing the app +

+
+
+
+ + No AI_RULES.md found. Dyad will automatically generate one + after importing. + +
+ )} + + {importAppMutation.isPending && ( +
+ + Importing app... +
+ )} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index f693ecd..71332ba 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -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(), }); diff --git a/src/ipc/handlers/import_handlers.ts b/src/ipc/handlers/import_handlers.ts new file mode 100644 index 0000000..8307cc6 --- /dev/null +++ b/src/ipc/handlers/import_handlers.ts @@ -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 => { + // 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"); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 5cf5fa2..9da44de 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -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 { + return this.ipcRenderer.invoke("import-app", params); + } + + async checkAppName(params: { + appName: string; + }): Promise<{ exists: boolean }> { + return this.ipcRenderer.invoke("check-app-name", params); + } } diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index 68c617e..b02f58c 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -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(); } diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 0f83d17..6ad3f08 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -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; +} diff --git a/src/pages/home.tsx b/src/pages/home.tsx index 84318dd..ccfb907 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -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() {
+
diff --git a/src/preload.ts b/src/preload.ts index ff7b2db..4e53390 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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