## 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:
committed by
GitHub
parent
7acbe73c73
commit
348521ce82
173
e2e-tests/github-import.spec.ts
Normal file
173
e2e-tests/github-import.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
31
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal 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 };
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user