diff --git a/e2e-tests/github-import.spec.ts b/e2e-tests/github-import.spec.ts new file mode 100644 index 0000000..a3cec26 --- /dev/null +++ b/e2e-tests/github-import.spec.ts @@ -0,0 +1,173 @@ +import { expect } from "@playwright/test"; +import { test } from "./helpers/test_helper"; + +test("should open GitHub import modal from home", async ({ po }) => { + await po.setUp(); + // Click the "Import from Github" button + await po.page.getByRole("button", { name: "Import App" }).click(); + // Verify modal opened with import UI (showing all tabs even when not authenticated) + await expect( + po.page.getByRole("heading", { name: "Import App" }), + ).toBeVisible(); + await expect( + po.page.getByText( + "Import existing app from local folder or clone from Github", + ), + ).toBeVisible(); + + // All tabs should be visible + await expect( + po.page.getByRole("tab", { name: "Local Folder" }), + ).toBeVisible(); + await expect( + po.page.getByRole("tab", { name: "Your GitHub Repos" }), + ).toBeVisible(); + await expect(po.page.getByRole("tab", { name: "GitHub URL" })).toBeVisible(); + // Local Folder tab should be active by default + await expect( + po.page.getByRole("button", { name: "Select Folder" }), + ).toBeVisible(); + // Switch to Your GitHub Repos tab - should show GitHub connector + await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click(); + await expect( + po.page.getByRole("button", { name: "Connect to GitHub" }), + ).toBeVisible(); +}); + +test("should connect to GitHub and show import UI", async ({ po }) => { + await po.setUp(); + // Open modal + await po.page.getByRole("button", { name: "Import App" }).click(); + // Switch to Your GitHub Repos tab - should show GitHub connector when not authenticated + await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click(); + // Connect to GitHub (reuse existing connector) + await po.page.getByRole("button", { name: "Connect to GitHub" }).click(); + // Wait for device flow code + await expect(po.page.locator("text=FAKE-CODE")).toBeVisible(); + // After connection, should show repositories list instead of connector + await expect(po.page.getByText("testuser/existing-app")).toBeVisible(); + // Should be able to see all tabs + await expect( + po.page.getByRole("tab", { name: "Your GitHub Repos" }), + ).toBeVisible(); + await expect(po.page.getByRole("tab", { name: "GitHub URL" })).toBeVisible(); + await expect( + po.page.getByRole("tab", { name: "Local Folder" }), + ).toBeVisible(); +}); + +test("should import GitHub URL", async ({ po }) => { + await po.setUp(); + // Open modal and connect + await po.page.getByRole("button", { name: "Import App" }).click(); + await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click(); + await po.page.getByRole("button", { name: "Connect to GitHub" }).click(); + await expect(po.page.locator("text=FAKE-CODE")).toBeVisible(); + // Switch to "GitHub URL" tab + await po.page.getByRole("tab", { name: "GitHub URL" }).click(); + // Enter URL + await po.page + .getByPlaceholder("https://github.com/user/repo.git") + .fill("https://github.com/dyad-sh/nextjs-template.git"); + + // Click import + await po.page.getByRole("button", { name: "Import", exact: true }).click(); + // Should close modal and navigate to chat + await expect( + po.page.getByRole("heading", { name: "Import App" }), + ).not.toBeVisible(); + // Verify AI_RULES generation prompt was sent +}); + +test("should import from repository list", async ({ po }) => { + await po.setUp(); + + // Open modal and connect + await po.page.getByRole("button", { name: "Import App" }).click(); + // Switch to Your GitHub Repos tab - should show GitHub connector when not authenticated + await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click(); + await po.page.getByRole("button", { name: "Connect to GitHub" }).click(); + await expect(po.page.locator("text=FAKE-CODE")).toBeVisible(); + + // Switch to Your GitHub Repos tab + await po.page.getByRole("tab", { name: "Your GitHub Repos" }).click(); + + // Should show repositories list + await expect(po.page.getByText("testuser/existing-app")).toBeVisible(); + + // Click the first Import button in the repo list + await po.page.getByRole("button", { name: "Import" }).first().click(); + + // Should close modal and navigate to chat + await expect( + po.page.getByRole("heading", { name: "Import App" }), + ).not.toBeVisible(); + + // Verify AI_RULES generation prompt + await po.snapshotMessages(); +}); + +test("should support advanced options with custom commands", async ({ po }) => { + await po.setUp(); + + // Open modal and connect + await po.page.getByRole("button", { name: "Import App" }).click(); + // Go to GitHub URL tab + await po.page.getByRole("tab", { name: "GitHub URL" }).click(); + await po.page + .getByPlaceholder("https://github.com/user/repo.git") + .fill("https://github.com/dyad-sh/nextjs-template.git"); + + // Open advanced options + await po.page.getByRole("button", { name: "Advanced options" }).click(); + + // Fill one command - should show error + await po.page.getByPlaceholder("pnpm install").fill("npm install"); + await expect( + po.page.getByText("Both commands are required when customizing"), + ).toBeVisible(); + await expect( + po.page.getByRole("button", { name: "Import", exact: true }), + ).toBeDisabled(); + + // Fill both commands + await po.page.getByPlaceholder("pnpm dev").fill("npm start"); + + await expect( + po.page.getByRole("button", { name: "Import", exact: true }), + ).toBeEnabled(); + await expect( + po.page.getByText("Both commands are required when customizing"), + ).not.toBeVisible(); + + // Import with custom commands + await po.page.getByRole("button", { name: "Import", exact: true }).click(); + + await expect( + po.page.getByRole("heading", { name: "Import App" }), + ).not.toBeVisible(); +}); + +test("should allow empty commands to use defaults", async ({ po }) => { + await po.setUp(); + + // Open modal and connect + await po.page.getByRole("button", { name: "Import App" }).click(); + + // Go to GitHub URL tab + await po.page.getByRole("tab", { name: "GitHub URL" }).click(); + await po.page + .getByPlaceholder("https://github.com/user/repo.git") + .fill("https://github.com/dyad-sh/nextjs-template.git"); + + // Commands are empty by default, so import should be enabled + await expect( + po.page.getByRole("button", { name: "Import", exact: true }), + ).toBeEnabled(); + + await po.page.getByRole("button", { name: "Import", exact: true }).click(); + + await expect( + po.page.getByRole("heading", { name: "Import App" }), + ).not.toBeVisible(); +}); diff --git a/e2e-tests/snapshots/github-import.spec.ts_should-import-from-URL-1.aria.yml b/e2e-tests/snapshots/github-import.spec.ts_should-import-from-URL-1.aria.yml new file mode 100644 index 0000000..dc23c3e --- /dev/null +++ b/e2e-tests/snapshots/github-import.spec.ts_should-import-from-URL-1.aria.yml @@ -0,0 +1,14 @@ +- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./ +- img +- text: file1.txt +- button "Edit": + - img +- img +- text: file1.txt +- paragraph: More EOM +- button: + - img +- img +- text: less than a minute ago +- button "Retry": + - img \ No newline at end of file diff --git a/e2e-tests/snapshots/github-import.spec.ts_should-import-from-repository-list-1.aria.yml b/e2e-tests/snapshots/github-import.spec.ts_should-import-from-repository-list-1.aria.yml new file mode 100644 index 0000000..dc23c3e --- /dev/null +++ b/e2e-tests/snapshots/github-import.spec.ts_should-import-from-repository-list-1.aria.yml @@ -0,0 +1,14 @@ +- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./ +- img +- text: file1.txt +- button "Edit": + - img +- img +- text: file1.txt +- paragraph: More EOM +- button: + - img +- img +- text: less than a minute ago +- button "Retry": + - img \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 0b9d114..e95feb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.2.0", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", @@ -5515,6 +5516,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", diff --git a/package.json b/package.json index 14d4063..5813a6d 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.2.0", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", diff --git a/src/components/GitHubConnector.tsx b/src/components/GitHubConnector.tsx index f2b7718..dd4113f 100644 --- a/src/components/GitHubConnector.tsx +++ b/src/components/GitHubConnector.tsx @@ -53,7 +53,7 @@ interface ConnectedGitHubConnectorProps { onAutoSyncComplete?: () => void; } -interface UnconnectedGitHubConnectorProps { +export interface UnconnectedGitHubConnectorProps { appId: number | null; folderName: string; settings: any; @@ -287,7 +287,7 @@ function ConnectedGitHubConnector({ ); } -function UnconnectedGitHubConnector({ +export function UnconnectedGitHubConnector({ appId, folderName, settings, @@ -342,7 +342,6 @@ function UnconnectedGitHubConnector({ const debounceTimeoutRef = useRef(null); const handleConnectToGithub = async () => { - if (!appId) return; setIsConnectingToGithub(true); setGithubError(null); setGithubUserCode(null); @@ -354,8 +353,6 @@ function UnconnectedGitHubConnector({ }; useEffect(() => { - if (!appId) return; // Don't set up listeners if appId is null initially - const cleanupFunctions: (() => void)[] = []; // Listener for updates (user code, verification uri, status messages) @@ -420,7 +417,7 @@ function UnconnectedGitHubConnector({ setIsConnectingToGithub(false); setGithubStatusMessage(null); }; - }, [appId]); // Re-run effect if appId changes + }, []); // Re-run effect if appId changes // Load available repos when GitHub is connected useEffect(() => { @@ -562,7 +559,7 @@ function UnconnectedGitHubConnector({ className="cursor-pointer w-full py-5 flex justify-center items-center gap-2" size="lg" variant="outline" - disabled={isConnectingToGithub || !appId} // Also disable if appId is null + disabled={isConnectingToGithub} // Also disable if appId is null > Connect to GitHub diff --git a/src/components/ImportAppDialog.tsx b/src/components/ImportAppDialog.tsx index c845e6a..629d2fe 100644 --- a/src/components/ImportAppDialog.tsx +++ b/src/components/ImportAppDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Dialog, DialogContent, @@ -18,6 +18,7 @@ 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 type { GithubRepository } from "@/ipc/ipc_types"; import { Tooltip, TooltipContent, @@ -33,24 +34,171 @@ import { AccordionItem, AccordionTrigger, } from "./ui/accordion"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useSettings } from "@/hooks/useSettings"; +import { UnconnectedGitHubConnector } from "@/components/GitHubConnector"; interface ImportAppDialogProps { isOpen: boolean; onClose: () => void; } - +export const AI_RULES_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."; 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 [installCommand, setInstallCommand] = useState("pnpm install"); - const [startCommand, setStartCommand] = useState("pnpm dev"); + const [installCommand, setInstallCommand] = useState(""); + const [startCommand, setStartCommand] = useState(""); const navigate = useNavigate(); const { streamMessage } = useStreamChat({ hasChatId: false }); const { refreshApps } = useLoadApps(); const setSelectedAppId = useSetAtom(selectedAppIdAtom); + // GitHub import state + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(false); + const [url, setUrl] = useState(""); + const [importing, setImporting] = useState(false); + const { settings, refreshSettings } = useSettings(); + const isAuthenticated = !!settings?.githubAccessToken; + + const [githubAppName, setGithubAppName] = useState(""); + const [githubNameExists, setGithubNameExists] = useState(false); + const [isCheckingGithubName, setIsCheckingGithubName] = useState(false); + useEffect(() => { + if (isOpen) { + setGithubAppName(""); + setGithubNameExists(false); + // Fetch GitHub repos if authenticated + if (isAuthenticated) { + fetchRepos(); + } + } + }, [isOpen, isAuthenticated]); + + const fetchRepos = async () => { + setLoading(true); + try { + const fetchedRepos = await IpcClient.getInstance().listGithubRepos(); + setRepos(fetchedRepos); + } catch (err: unknown) { + showError("Failed to fetch repositories.: " + (err as any).toString()); + } finally { + setLoading(false); + } + }; + const handleUrlBlur = async () => { + if (!url.trim()) return; + const repoName = extractRepoNameFromUrl(url); + if (repoName) { + setGithubAppName(repoName); + setIsCheckingGithubName(true); + try { + const result = await IpcClient.getInstance().checkAppName({ + appName: repoName, + }); + setGithubNameExists(result.exists); + } catch (error: unknown) { + showError("Failed to check app name: " + (error as any).toString()); + } finally { + setIsCheckingGithubName(false); + } + } + }; + const extractRepoNameFromUrl = (url: string): string | null => { + const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + return match ? match[2] : null; + }; + const handleImportFromUrl = async () => { + setImporting(true); + try { + const match = extractRepoNameFromUrl(url); + const repoName = match ? match[2] : ""; + const appName = githubAppName.trim() || repoName; + const result = await IpcClient.getInstance().cloneRepoFromUrl({ + url, + installCommand: installCommand.trim() || undefined, + startCommand: startCommand.trim() || undefined, + appName, + }); + if ("error" in result) { + showError(result.error); + setImporting(false); + return; + } + setSelectedAppId(result.app.id); + showSuccess(`Successfully imported ${result.app.name}`); + const chatId = await IpcClient.getInstance().createChat(result.app.id); + navigate({ to: "/chat", search: { id: chatId } }); + if (!result.hasAiRules) { + streamMessage({ + prompt: AI_RULES_PROMPT, + chatId, + }); + } + onClose(); + } catch (error: unknown) { + showError("Failed to import repository: " + (error as any).toString()); + } finally { + setImporting(false); + } + }; + + const handleSelectRepo = async (repo: GithubRepository) => { + setImporting(true); + + try { + const appName = githubAppName.trim() || repo.name; + const result = await IpcClient.getInstance().cloneRepoFromUrl({ + url: `https://github.com/${repo.full_name}.git`, + installCommand: installCommand.trim() || undefined, + startCommand: startCommand.trim() || undefined, + appName, + }); + if ("error" in result) { + showError(result.error); + setImporting(false); + return; + } + setSelectedAppId(result.app.id); + showSuccess(`Successfully imported ${result.app.name}`); + const chatId = await IpcClient.getInstance().createChat(result.app.id); + navigate({ to: "/chat", search: { id: chatId } }); + if (!result.hasAiRules) { + streamMessage({ + prompt: AI_RULES_PROMPT, + chatId, + }); + } + onClose(); + } catch (error: unknown) { + showError("Failed to import repository: " + (error as any).toString()); + } finally { + setImporting(false); + } + }; + + const handleGithubAppNameChange = async ( + e: React.ChangeEvent, + ) => { + const newName = e.target.value; + setGithubAppName(newName); + if (newName.trim()) { + setIsCheckingGithubName(true); + try { + const result = await IpcClient.getInstance().checkAppName({ + appName: newName, + }); + setGithubNameExists(result.exists); + } catch (error: unknown) { + showError("Failed to check app name: " + (error as any).toString()); + } finally { + setIsCheckingGithubName(false); + } + } + }; const checkAppName = async (name: string): Promise => { setIsCheckingName(true); @@ -65,7 +213,6 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { setIsCheckingName(false); } }; - const selectFolderMutation = useMutation({ mutationFn: async () => { const result = await IpcClient.getInstance().selectAppFolder(); @@ -77,13 +224,10 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { }); 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) => { @@ -112,8 +256,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { navigate({ to: "/chat", search: { id: result.chatId } }); if (!hasAiRules) { 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.", + prompt: AI_RULES_PROMPT, chatId: result.chatId, }); } @@ -138,8 +281,8 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { setHasAiRules(null); setCustomAppName(""); setNameExists(false); - setInstallCommand("pnpm install"); - setStartCommand("pnpm dev"); + setInstallCommand(""); + setStartCommand(""); }; const handleAppNameChange = async ( @@ -155,14 +298,14 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { const hasInstallCommand = installCommand.trim().length > 0; const hasStartCommand = startCommand.trim().length > 0; const commandsValid = hasInstallCommand === hasStartCommand; - + // Add this component inside the ImportAppDialog.tsx file, before the main component return ( - + Import App - Select an existing app folder to import into Dyad. + Import existing app from local folder or clone from Github. @@ -173,158 +316,365 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { please report them using the Help button. - -
- {!selectedPath ? ( - ) : ( - - )} - {selectFolderMutation.isPending - ? "Selecting folder..." - : "Select Folder"} - - ) : ( -
-
-
-
-

Selected folder:

-

- {selectedPath} -

+
+
+
+
+

Selected folder:

+

+ {selectedPath} +

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

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

+
+ {nameExists && ( +

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

+ )} +
+ + + {isCheckingName && ( +
+ +
+ )} +
+
+ + + + + Advanced options + + +
+ + setInstallCommand(e.target.value)} + placeholder="pnpm install" + disabled={importAppMutation.isPending} + /> +
+
+ + setStartCommand(e.target.value)} + placeholder="pnpm dev" + disabled={importAppMutation.isPending} + /> +
+ {!commandsValid && ( +

+ Both commands are required when customizing. +

+ )} +
+
+
+ + {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... +
+ )} +
+ )} +
+ + + + + + + + {!isAuthenticated ? ( + undefined} + expanded={false} + /> + ) : ( + <> + {loading && ( +
+ +
)} -
- + +
+ - {isCheckingName && ( + {isCheckingGithubName && (
)} + {githubNameExists && ( +

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

+ )}
-
- - - - Advanced options - - -
- - setInstallCommand(e.target.value)} - placeholder="pnpm install" - disabled={importAppMutation.isPending} - /> -
-
- - setStartCommand(e.target.value)} - placeholder="pnpm dev" - disabled={importAppMutation.isPending} - /> -
- {!commandsValid && ( -

- Both commands are required when customizing. -

- )} -
-
-
- - {hasAiRules === false && ( - - - - - - - -

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

+ {!loading && repos.length === 0 && ( +

+ No repositories found +

+ )} + {repos.map((repo) => ( +
+
+

{repo.name}

+

+ {repo.full_name}

- - - - - No AI_RULES.md found. Dyad will automatically generate one - after importing. - - - )} - - {importAppMutation.isPending && ( -
- - Importing app... +
+ +
+ ))}
+ + {repos.length > 0 && ( + <> + + + + Advanced options + + +
+ + + setInstallCommand(e.target.value) + } + placeholder="pnpm install" + disabled={importing} + /> +
+
+ + setStartCommand(e.target.value)} + placeholder="pnpm dev" + disabled={importing} + /> +
+ {!commandsValid && ( +

+ Both commands are required when customizing. +

+ )} +
+
+
+ + )} + + )} + + +
+ + setUrl(e.target.value)} + disabled={importing} + onBlur={handleUrlBlur} + /> +
+
+ + + {isCheckingGithubName && ( +
+ +
+ )} + {githubNameExists && ( +

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

)}
- )} -
- - - - + + + + Advanced options + + +
+ + setInstallCommand(e.target.value)} + placeholder="pnpm install" + disabled={importing} + /> +
+
+ + setStartCommand(e.target.value)} + placeholder="pnpm dev" + disabled={importing} + /> +
+ {!commandsValid && ( +

+ Both commands are required when customizing. +

+ )} +
+
+
+ + +
+
); diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..859eea6 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/ipc/handlers/github_handlers.ts b/src/ipc/handlers/github_handlers.ts index 2c0509a..deb51f2 100644 --- a/src/ipc/handlers/github_handlers.ts +++ b/src/ipc/handlers/github_handlers.ts @@ -1,17 +1,19 @@ import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron"; import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process import { writeSettings, readSettings } from "../../main/settings"; -import git from "isomorphic-git"; +import git, { clone } from "isomorphic-git"; import http from "isomorphic-git/http/node"; import * as schema from "../../db/schema"; import fs from "node:fs"; import { getDyadAppPath } from "../../paths/paths"; import { db } from "../../db"; import { apps } from "../../db/schema"; +import type { CloneRepoParams, CloneRepoReturnType } from "@/ipc/ipc_types"; import { eq } from "drizzle-orm"; import { GithubUser } from "../../lib/schemas"; import log from "electron-log"; import { IS_TEST_BUILD } from "../utils/test_utils"; +import path from "node:path"; // ← ADD THIS const logger = log.scope("github_handlers"); @@ -627,6 +629,115 @@ async function handleDisconnectGithubRepo( }) .where(eq(apps.id, appId)); } +// --- GitHub Clone Repo from URL Handler --- +async function handleCloneRepoFromUrl( + event: IpcMainInvokeEvent, + params: CloneRepoParams, +): Promise { + const { url, installCommand, startCommand, appName } = params; + try { + const settings = readSettings(); + const accessToken = settings.githubAccessToken?.value; + const urlPattern = /github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/; + const match = url.match(urlPattern); + if (!match) { + return { + error: + "Invalid GitHub URL. Expected format: https://github.com/owner/repo.git", + }; + } + const [, owner, repoName] = match; + if (accessToken) { + const repoResponse = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repoName}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + }, + }, + ); + if (!repoResponse.ok) { + return { + error: "Repository not found or you do not have access to it.", + }; + } + } + const finalAppName = appName && appName.trim() ? appName.trim() : repoName; + const existingApp = await db.query.apps.findFirst({ + where: eq(apps.name, finalAppName), + }); + + if (existingApp) { + return { error: `An app named "${finalAppName}" already exists.` }; + } + + const appPath = getDyadAppPath(finalAppName); + if (!fs.existsSync(appPath)) { + fs.mkdirSync(appPath, { recursive: true }); + } + // Use authenticated URL if token exists, otherwise use public HTTPS URL + const cloneUrl = accessToken + ? IS_TEST_BUILD + ? `${GITHUB_GIT_BASE}/${owner}/${repoName}.git` + : `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repoName}.git` + : `https://github.com/${owner}/${repoName}.git`; // Changed: use public HTTPS URL instead of original url + try { + await clone({ + fs, + http, + dir: appPath, + url: cloneUrl, + onAuth: accessToken + ? () => ({ + username: accessToken, + password: "x-oauth-basic", + }) + : undefined, + singleBranch: false, + }); + } catch (cloneErr) { + logger.error("[GitHub Handler] Clone failed:", cloneErr); + return { + error: + "Failed to clone repository. Please check the URL and try again.", + }; + } + const aiRulesPath = path.join(appPath, "AI_RULES.md"); + const hasAiRules = fs.existsSync(aiRulesPath); + const [newApp] = await db + .insert(schema.apps) + .values({ + name: finalAppName, + path: finalAppName, + createdAt: new Date(), + updatedAt: new Date(), + githubOrg: owner, + githubRepo: repoName, + githubBranch: "main", + installCommand: installCommand || null, + startCommand: startCommand || null, + }) + .returning(); + logger.log(`Successfully cloned repo ${owner}/${repoName} to ${appPath}`); + // Return success object + return { + app: { + ...newApp, + files: [], + supabaseProjectName: null, + vercelTeamSlug: null, + }, + hasAiRules, + }; + } catch (err: any) { + // Catch any remaining unexpected errors and return an error object + logger.error("[GitHub Handler] Unexpected error in clone flow:", err); + return { + error: err.message || "An unexpected error occurred during cloning.", + }; + } +} // --- Registration --- export function registerGithubHandlers() { @@ -650,6 +761,12 @@ export function registerGithubHandlers() { ipcMain.handle("github:disconnect", (event, args: { appId: number }) => handleDisconnectGithubRepo(event, args), ); + ipcMain.handle( + "github:clone-repo-from-url", + async (event, args: CloneRepoParams) => { + return await handleCloneRepoFromUrl(event, args); + }, + ); } export async function updateAppGithubRepo({ diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index 6ef8c54..4563425 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -65,6 +65,7 @@ import type { UpdatePromptParamsDto, McpServerUpdate, CreateMcpServer, + CloneRepoParams, } from "./ipc_types"; import type { Template } from "../shared/templates"; import type { @@ -1277,6 +1278,11 @@ export class IpcClient { public async deletePrompt(id: number): Promise { await this.ipcRenderer.invoke("prompts:delete", id); } + public async cloneRepoFromUrl( + params: CloneRepoParams, + ): Promise<{ app: App; hasAiRules: boolean } | { error: string }> { + return this.ipcRenderer.invoke("github:clone-repo-from-url", params); + } // --- Help bot --- public startHelpChat( diff --git a/src/ipc/ipc_types.ts b/src/ipc/ipc_types.ts index 21e2f27..a236b96 100644 --- a/src/ipc/ipc_types.ts +++ b/src/ipc/ipc_types.ts @@ -488,3 +488,23 @@ export interface McpToolConsent { consent: McpToolConsentType; updatedAt: number; } +export interface CloneRepoParams { + url: string; + installCommand?: string; + startCommand?: string; + appName: string; +} + +export interface GithubRepository { + name: string; + full_name: string; + private: boolean; +} +export type CloneRepoReturnType = + | { + app: App; + hasAiRules: boolean; + } + | { + error: string; + }; diff --git a/src/preload.ts b/src/preload.ts index 1907c93..7843fe8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -129,6 +129,7 @@ const validInvokeChannels = [ "prompts:delete", // adding app to favorite "add-to-favorite", + "github:clone-repo-from-url", // Test-only channels // These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // We can't detect with IS_TEST_BUILD in the preload script because