GitHub Import Feature: Import repositories/projects from GitHub (#1424) (#1454)

## Summary
Adds the ability to import GitHub repositories directly into Dyad from
the home screen, complementing the existing local folder import feature.
- GitHub Import Modal: New modal accessible from home screen via "Import
from Github" button with two Import methods
- Select project from GitHub repositories list
- Clone from any GitHub URL
- Advanced Options: Optional custom install/start commands (defaults to
project's package.json scripts)
- Auto AI_RULES Generation: Automatically generates AI_RULES.md if not
present in imported repo

closes #1424
    
<!-- This is an auto-generated description by cubic. -->
---

## Summary by cubic
Adds a GitHub import flow from the home screen so users can clone repos
via their list or any URL, with optional install/start commands and
automatic AI_RULES.md generation. Addresses Linear #1424 by enabling
seamless project setup from GitHub.

- **New Features**
  - Import modal with two tabs: Your Repositories and From URL.
- Advanced options for install/start commands with validation; defaults
used when both are empty.
- After cloning, navigate to chat and auto-generate AI_RULES.md if
missing.
- New IPC handler github:clone-repo-from-url with token auth support,
plus IpcClient method and preload channel.
- E2E tests cover modal open, auth, import via URL/repo list, and
advanced options.

- **Dependencies**
  - Added @radix-ui/react-tabs for the modal tab UI.

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Adeniji Adekunle James
2025-10-14 03:10:04 +01:00
committed by GitHub
parent 7acbe73c73
commit 348521ce82
12 changed files with 934 additions and 157 deletions

View File

@@ -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();
});

View File

@@ -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

View File

@@ -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

31
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.0", "@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": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8", "@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": { "node_modules/@radix-ui/react-toggle": {
"version": "1.1.10", "version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",

View File

@@ -115,6 +115,7 @@
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.0", "@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": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",

View File

@@ -53,7 +53,7 @@ interface ConnectedGitHubConnectorProps {
onAutoSyncComplete?: () => void; onAutoSyncComplete?: () => void;
} }
interface UnconnectedGitHubConnectorProps { export interface UnconnectedGitHubConnectorProps {
appId: number | null; appId: number | null;
folderName: string; folderName: string;
settings: any; settings: any;
@@ -287,7 +287,7 @@ function ConnectedGitHubConnector({
); );
} }
function UnconnectedGitHubConnector({ export function UnconnectedGitHubConnector({
appId, appId,
folderName, folderName,
settings, settings,
@@ -342,7 +342,6 @@ function UnconnectedGitHubConnector({
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null); const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleConnectToGithub = async () => { const handleConnectToGithub = async () => {
if (!appId) return;
setIsConnectingToGithub(true); setIsConnectingToGithub(true);
setGithubError(null); setGithubError(null);
setGithubUserCode(null); setGithubUserCode(null);
@@ -354,8 +353,6 @@ function UnconnectedGitHubConnector({
}; };
useEffect(() => { useEffect(() => {
if (!appId) return; // Don't set up listeners if appId is null initially
const cleanupFunctions: (() => void)[] = []; const cleanupFunctions: (() => void)[] = [];
// Listener for updates (user code, verification uri, status messages) // Listener for updates (user code, verification uri, status messages)
@@ -420,7 +417,7 @@ function UnconnectedGitHubConnector({
setIsConnectingToGithub(false); setIsConnectingToGithub(false);
setGithubStatusMessage(null); setGithubStatusMessage(null);
}; };
}, [appId]); // Re-run effect if appId changes }, []); // Re-run effect if appId changes
// Load available repos when GitHub is connected // Load available repos when GitHub is connected
useEffect(() => { useEffect(() => {
@@ -562,7 +559,7 @@ function UnconnectedGitHubConnector({
className="cursor-pointer w-full py-5 flex justify-center items-center gap-2" className="cursor-pointer w-full py-5 flex justify-center items-center gap-2"
size="lg" size="lg"
variant="outline" variant="outline"
disabled={isConnectingToGithub || !appId} // Also disable if appId is null disabled={isConnectingToGithub} // Also disable if appId is null
> >
Connect to GitHub Connect to GitHub
<Github className="h-5 w-5" /> <Github className="h-5 w-5" />

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -18,6 +18,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@radix-ui/react-label"; import { Label } from "@radix-ui/react-label";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import type { GithubRepository } from "@/ipc/ipc_types";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -33,24 +34,171 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "./ui/accordion"; } from "./ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useSettings } from "@/hooks/useSettings";
import { UnconnectedGitHubConnector } from "@/components/GitHubConnector";
interface ImportAppDialogProps { interface ImportAppDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; 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) { export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null); const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [hasAiRules, setHasAiRules] = useState<boolean | null>(null); const [hasAiRules, setHasAiRules] = useState<boolean | null>(null);
const [customAppName, setCustomAppName] = useState<string>(""); const [customAppName, setCustomAppName] = useState<string>("");
const [nameExists, setNameExists] = useState<boolean>(false); const [nameExists, setNameExists] = useState<boolean>(false);
const [isCheckingName, setIsCheckingName] = useState<boolean>(false); const [isCheckingName, setIsCheckingName] = useState<boolean>(false);
const [installCommand, setInstallCommand] = useState("pnpm install"); const [installCommand, setInstallCommand] = useState("");
const [startCommand, setStartCommand] = useState("pnpm dev"); const [startCommand, setStartCommand] = useState("");
const navigate = useNavigate(); const navigate = useNavigate();
const { streamMessage } = useStreamChat({ hasChatId: false }); const { streamMessage } = useStreamChat({ hasChatId: false });
const { refreshApps } = useLoadApps(); const { refreshApps } = useLoadApps();
const setSelectedAppId = useSetAtom(selectedAppIdAtom); const setSelectedAppId = useSetAtom(selectedAppIdAtom);
// GitHub import state
const [repos, setRepos] = useState<GithubRepository[]>([]);
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<HTMLInputElement>,
) => {
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<void> => { const checkAppName = async (name: string): Promise<void> => {
setIsCheckingName(true); setIsCheckingName(true);
@@ -65,7 +213,6 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
setIsCheckingName(false); setIsCheckingName(false);
} }
}; };
const selectFolderMutation = useMutation({ const selectFolderMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const result = await IpcClient.getInstance().selectAppFolder(); const result = await IpcClient.getInstance().selectAppFolder();
@@ -77,13 +224,10 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
}); });
setHasAiRules(aiRulesCheck.exists); setHasAiRules(aiRulesCheck.exists);
setSelectedPath(result.path); setSelectedPath(result.path);
// Use the folder name from the IPC response // Use the folder name from the IPC response
setCustomAppName(result.name); setCustomAppName(result.name);
// Check if the app name already exists // Check if the app name already exists
await checkAppName(result.name); await checkAppName(result.name);
return result; return result;
}, },
onError: (error: Error) => { onError: (error: Error) => {
@@ -112,8 +256,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
navigate({ to: "/chat", search: { id: result.chatId } }); navigate({ to: "/chat", search: { id: result.chatId } });
if (!hasAiRules) { if (!hasAiRules) {
streamMessage({ streamMessage({
prompt: prompt: 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.",
chatId: result.chatId, chatId: result.chatId,
}); });
} }
@@ -138,8 +281,8 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
setHasAiRules(null); setHasAiRules(null);
setCustomAppName(""); setCustomAppName("");
setNameExists(false); setNameExists(false);
setInstallCommand("pnpm install"); setInstallCommand("");
setStartCommand("pnpm dev"); setStartCommand("");
}; };
const handleAppNameChange = async ( const handleAppNameChange = async (
@@ -155,14 +298,14 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
const hasInstallCommand = installCommand.trim().length > 0; const hasInstallCommand = installCommand.trim().length > 0;
const hasStartCommand = startCommand.trim().length > 0; const hasStartCommand = startCommand.trim().length > 0;
const commandsValid = hasInstallCommand === hasStartCommand; const commandsValid = hasInstallCommand === hasStartCommand;
// Add this component inside the ImportAppDialog.tsx file, before the main component
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent> <DialogContent className="max-w-2xl max-h-[98vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Import App</DialogTitle> <DialogTitle>Import App</DialogTitle>
<DialogDescription> <DialogDescription>
Select an existing app folder to import into Dyad. Import existing app from local folder or clone from Github.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -173,7 +316,13 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
please report them using the Help button. please report them using the Help button.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Tabs defaultValue="local-folder" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="local-folder">Local Folder</TabsTrigger>
<TabsTrigger value="github-repos">Your GitHub Repos</TabsTrigger>
<TabsTrigger value="github-url">GitHub URL</TabsTrigger>
</TabsList>
<TabsContent value="local-folder" className="space-y-4">
<div className="py-4"> <div className="py-4">
{!selectedPath ? ( {!selectedPath ? (
<Button <Button
@@ -255,7 +404,9 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-sm ml-2 mb-2">Start command</Label> <Label className="text-sm ml-2 mb-2">
Start command
</Label>
<Input <Input
value={startCommand} value={startCommand}
onChange={(e) => setStartCommand(e.target.value)} onChange={(e) => setStartCommand(e.target.value)}
@@ -281,15 +432,15 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>
AI_RULES.md lets Dyad know which tech stack to use for AI_RULES.md lets Dyad know which tech stack to use
editing the app for editing the app
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<AlertDescription> <AlertDescription>
No AI_RULES.md found. Dyad will automatically generate one No AI_RULES.md found. Dyad will automatically generate
after importing. one after importing.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -325,6 +476,205 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) {
{importAppMutation.isPending ? <>Importing...</> : "Import"} {importAppMutation.isPending ? <>Importing...</> : "Import"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</TabsContent>
<TabsContent value="github-repos" className="space-y-4">
{!isAuthenticated ? (
<UnconnectedGitHubConnector
appId={null}
folderName=""
settings={settings}
refreshSettings={refreshSettings}
handleRepoSetupComplete={() => undefined}
expanded={false}
/>
) : (
<>
{loading && (
<div className="flex justify-center py-8">
<Loader2 className="animate-spin h-6 w-6" />
</div>
)}
<div className="space-y-2">
<Label className="text-sm ml-2 mb-2">
App name (optional)
</Label>
<Input
value={githubAppName}
onChange={handleGithubAppNameChange}
placeholder="Leave empty to use repository name"
className="w-full pr-8"
disabled={importing}
/>
{isCheckingGithubName && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{githubNameExists && (
<p className="text-sm text-yellow-500">
An app with this name already exists. Please choose a
different name.
</p>
)}
</div>
<div className="flex flex-col space-y-2 max-h-64 overflow-y-auto">
{!loading && repos.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No repositories found
</p>
)}
{repos.map((repo) => (
<div
key={repo.full_name}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors"
>
<div className="min-w-0 flex-1">
<p className="font-semibold truncate">{repo.name}</p>
<p className="text-sm text-muted-foreground truncate">
{repo.full_name}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleSelectRepo(repo)}
disabled={importing}
className="ml-2 flex-shrink-0"
>
{importing ? (
<Loader2 className="animate-spin h-4 w-4" />
) : (
"Import"
)}
</Button>
</div>
))}
</div>
{repos.length > 0 && (
<>
<Accordion type="single" collapsible>
<AccordionItem value="advanced-options">
<AccordionTrigger className="text-sm hover:no-underline">
Advanced options
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="grid gap-2">
<Label className="text-sm">Install command</Label>
<Input
value={installCommand}
onChange={(e) =>
setInstallCommand(e.target.value)
}
placeholder="pnpm install"
disabled={importing}
/>
</div>
<div className="grid gap-2">
<Label className="text-sm">Start command</Label>
<Input
value={startCommand}
onChange={(e) => setStartCommand(e.target.value)}
placeholder="pnpm dev"
disabled={importing}
/>
</div>
{!commandsValid && (
<p className="text-sm text-red-500">
Both commands are required when customizing.
</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
</>
)}
</>
)}
</TabsContent>
<TabsContent value="github-url" className="space-y-4">
<div className="space-y-2">
<Label className="text-sm">Repository URL</Label>
<Input
placeholder="https://github.com/user/repo.git"
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={importing}
onBlur={handleUrlBlur}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">App name (optional)</Label>
<Input
value={githubAppName}
onChange={handleGithubAppNameChange}
placeholder="Leave empty to use repository name"
disabled={importing}
/>
{isCheckingGithubName && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{githubNameExists && (
<p className="text-sm text-yellow-500">
An app with this name already exists. Please choose a
different name.
</p>
)}
</div>
<Accordion type="single" collapsible>
<AccordionItem value="advanced-options">
<AccordionTrigger className="text-sm hover:no-underline">
Advanced options
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="grid gap-2">
<Label className="text-sm">Install command</Label>
<Input
value={installCommand}
onChange={(e) => setInstallCommand(e.target.value)}
placeholder="pnpm install"
disabled={importing}
/>
</div>
<div className="grid gap-2">
<Label className="text-sm">Start command</Label>
<Input
value={startCommand}
onChange={(e) => setStartCommand(e.target.value)}
placeholder="pnpm dev"
disabled={importing}
/>
</div>
{!commandsValid && (
<p className="text-sm text-red-500">
Both commands are required when customizing.
</p>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
<Button
onClick={handleImportFromUrl}
disabled={importing || !url.trim() || !commandsValid}
className="w-full"
>
{importing ? (
<>
<Loader2 className="animate-spin mr-2 h-4 w-4" />
Importing...
</>
) : (
"Import"
)}
</Button>
</TabsContent>
</Tabs>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,17 +1,19 @@
import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron"; import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron";
import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process
import { writeSettings, readSettings } from "../../main/settings"; 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 http from "isomorphic-git/http/node";
import * as schema from "../../db/schema"; import * as schema from "../../db/schema";
import fs from "node:fs"; import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { db } from "../../db"; import { db } from "../../db";
import { apps } from "../../db/schema"; import { apps } from "../../db/schema";
import type { CloneRepoParams, CloneRepoReturnType } from "@/ipc/ipc_types";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { GithubUser } from "../../lib/schemas"; import { GithubUser } from "../../lib/schemas";
import log from "electron-log"; import log from "electron-log";
import { IS_TEST_BUILD } from "../utils/test_utils"; import { IS_TEST_BUILD } from "../utils/test_utils";
import path from "node:path"; // ← ADD THIS
const logger = log.scope("github_handlers"); const logger = log.scope("github_handlers");
@@ -627,6 +629,115 @@ async function handleDisconnectGithubRepo(
}) })
.where(eq(apps.id, appId)); .where(eq(apps.id, appId));
} }
// --- GitHub Clone Repo from URL Handler ---
async function handleCloneRepoFromUrl(
event: IpcMainInvokeEvent,
params: CloneRepoParams,
): Promise<CloneRepoReturnType> {
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 --- // --- Registration ---
export function registerGithubHandlers() { export function registerGithubHandlers() {
@@ -650,6 +761,12 @@ export function registerGithubHandlers() {
ipcMain.handle("github:disconnect", (event, args: { appId: number }) => ipcMain.handle("github:disconnect", (event, args: { appId: number }) =>
handleDisconnectGithubRepo(event, args), handleDisconnectGithubRepo(event, args),
); );
ipcMain.handle(
"github:clone-repo-from-url",
async (event, args: CloneRepoParams) => {
return await handleCloneRepoFromUrl(event, args);
},
);
} }
export async function updateAppGithubRepo({ export async function updateAppGithubRepo({

View File

@@ -65,6 +65,7 @@ import type {
UpdatePromptParamsDto, UpdatePromptParamsDto,
McpServerUpdate, McpServerUpdate,
CreateMcpServer, CreateMcpServer,
CloneRepoParams,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
import type { import type {
@@ -1277,6 +1278,11 @@ export class IpcClient {
public async deletePrompt(id: number): Promise<void> { public async deletePrompt(id: number): Promise<void> {
await this.ipcRenderer.invoke("prompts:delete", id); 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 --- // --- Help bot ---
public startHelpChat( public startHelpChat(

View File

@@ -488,3 +488,23 @@ export interface McpToolConsent {
consent: McpToolConsentType; consent: McpToolConsentType;
updatedAt: number; 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;
};

View File

@@ -129,6 +129,7 @@ const validInvokeChannels = [
"prompts:delete", "prompts:delete",
// adding app to favorite // adding app to favorite
"add-to-favorite", "add-to-favorite",
"github:clone-repo-from-url",
// Test-only channels // Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // 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 // We can't detect with IS_TEST_BUILD in the preload script because