Support Next.js template & template hub (#241)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Home, Inbox, Settings, HelpCircle } from "lucide-react";
|
||||
import { Home, Inbox, Settings, HelpCircle, Store } from "lucide-react";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
@@ -38,6 +38,11 @@ const items = [
|
||||
to: "/settings",
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: "Hub",
|
||||
to: "/hub",
|
||||
icon: Store,
|
||||
},
|
||||
];
|
||||
|
||||
// Hover state types
|
||||
|
||||
@@ -12,10 +12,7 @@ import { promises as fsPromises } from "node:fs";
|
||||
|
||||
// Import our utility modules
|
||||
import { withLock } from "../utils/lock_utils";
|
||||
import {
|
||||
copyDirectoryRecursive,
|
||||
getFilesRecursively,
|
||||
} from "../utils/file_utils";
|
||||
import { getFilesRecursively } from "../utils/file_utils";
|
||||
import {
|
||||
runningApps,
|
||||
processCounter,
|
||||
@@ -35,6 +32,7 @@ import { createLoggedHandler } from "./safe_handle";
|
||||
import { getLanguageModelProviders } from "../shared/language_model_helpers";
|
||||
import { startProxy } from "../utils/start_proxy_server";
|
||||
import { Worker } from "worker_threads";
|
||||
import { createFromTemplate } from "./createFromTemplate";
|
||||
|
||||
const logger = log.scope("app_handlers");
|
||||
const handle = createLoggedHandler(logger);
|
||||
@@ -190,14 +188,10 @@ export function registerAppHandlers() {
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Why do we not use fs.cp here?
|
||||
// Because scaffold is inside ASAR and it does NOT
|
||||
// behave like a regular directory if you use fs.cp
|
||||
// https://www.electronjs.org/docs/latest/tutorial/asar-archives#limitations-of-the-node-api
|
||||
await copyDirectoryRecursive(
|
||||
path.join(__dirname, "..", "..", "scaffold"),
|
||||
await createFromTemplate({
|
||||
fullAppPath,
|
||||
);
|
||||
});
|
||||
|
||||
// Initialize git repo and create first commit
|
||||
await git.init({
|
||||
fs: fs,
|
||||
|
||||
185
src/ipc/handlers/createFromTemplate.ts
Normal file
185
src/ipc/handlers/createFromTemplate.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import git from "isomorphic-git";
|
||||
import http from "isomorphic-git/http/node";
|
||||
import { app } from "electron";
|
||||
import { copyDirectoryRecursive } from "../utils/file_utils";
|
||||
import { readSettings } from "@/main/settings";
|
||||
import { DEFAULT_TEMPLATE_ID, getTemplateOrThrow } from "@/shared/templates";
|
||||
import log from "electron-log";
|
||||
|
||||
const logger = log.scope("createFromTemplate");
|
||||
|
||||
export async function createFromTemplate({
|
||||
fullAppPath,
|
||||
}: {
|
||||
fullAppPath: string;
|
||||
}) {
|
||||
const settings = readSettings();
|
||||
const templateId = settings.selectedTemplateId ?? DEFAULT_TEMPLATE_ID;
|
||||
|
||||
if (templateId === "react") {
|
||||
await copyDirectoryRecursive(
|
||||
path.join(__dirname, "..", "..", "scaffold"),
|
||||
fullAppPath,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = getTemplateOrThrow(templateId);
|
||||
if (!template.githubUrl) {
|
||||
throw new Error(`Template ${templateId} has no GitHub URL`);
|
||||
}
|
||||
const repoCachePath = await cloneRepo(template.githubUrl);
|
||||
await copyRepoToApp(repoCachePath, fullAppPath);
|
||||
}
|
||||
|
||||
async function cloneRepo(repoUrl: string): Promise<string> {
|
||||
let orgName: string;
|
||||
let repoName: string;
|
||||
|
||||
const url = new URL(repoUrl);
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error("Repository URL must use HTTPS.");
|
||||
}
|
||||
if (url.hostname !== "github.com") {
|
||||
throw new Error("Repository URL must be a github.com URL.");
|
||||
}
|
||||
|
||||
// Pathname will be like "/org/repo" or "/org/repo.git"
|
||||
const pathParts = url.pathname.split("/").filter((part) => part.length > 0);
|
||||
|
||||
if (pathParts.length !== 2) {
|
||||
throw new Error(
|
||||
"Invalid repository URL format. Expected 'https://github.com/org/repo'",
|
||||
);
|
||||
}
|
||||
|
||||
orgName = pathParts[0];
|
||||
repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present
|
||||
|
||||
if (!orgName || !repoName) {
|
||||
// This case should ideally be caught by pathParts.length !== 2
|
||||
throw new Error(
|
||||
"Failed to parse organization or repository name from URL.",
|
||||
);
|
||||
}
|
||||
logger.info(`Parsed org: ${orgName}, repo: ${repoName} from ${repoUrl}`);
|
||||
|
||||
const cachePath = path.join(
|
||||
app.getPath("userData"),
|
||||
"templates",
|
||||
orgName,
|
||||
repoName,
|
||||
);
|
||||
|
||||
if (fs.existsSync(cachePath)) {
|
||||
try {
|
||||
logger.info(
|
||||
`Repo ${repoName} already exists in cache at ${cachePath}. Checking for updates.`,
|
||||
);
|
||||
|
||||
// Construct GitHub API URL
|
||||
const apiUrl = `https://api.github.com/repos/${orgName}/${repoName}/commits/HEAD`;
|
||||
logger.info(`Fetching remote SHA from ${apiUrl}`);
|
||||
|
||||
let remoteSha: string | undefined;
|
||||
|
||||
const response = await http.request({
|
||||
url: apiUrl,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Dyad", // GitHub API requires a User-Agent
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode === 200 && response.body) {
|
||||
// Convert AsyncIterableIterator<Uint8Array> to string
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (const chunk of response.body) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const responseBodyStr = Buffer.concat(chunks).toString("utf8");
|
||||
const commitData = JSON.parse(responseBodyStr);
|
||||
remoteSha = commitData.sha;
|
||||
if (!remoteSha) {
|
||||
throw new Error("SHA not found in GitHub API response.");
|
||||
}
|
||||
logger.info(`Successfully fetched remote SHA: ${remoteSha}`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`GitHub API request failed with status ${response.statusCode}: ${response.statusMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
const localSha = await git.resolveRef({
|
||||
fs,
|
||||
dir: cachePath,
|
||||
ref: "HEAD",
|
||||
});
|
||||
|
||||
if (remoteSha === localSha) {
|
||||
logger.info(
|
||||
`Local cache for ${repoName} is up to date (SHA: ${localSha}). Skipping clone.`,
|
||||
);
|
||||
return cachePath;
|
||||
} else {
|
||||
logger.info(
|
||||
`Local cache for ${repoName} (SHA: ${localSha}) is outdated (Remote SHA: ${remoteSha}). Removing and re-cloning.`,
|
||||
);
|
||||
fs.rmSync(cachePath, { recursive: true, force: true });
|
||||
// Proceed to clone
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`Error checking for updates or comparing SHAs for ${repoName} at ${cachePath}. Will attempt to re-clone. Error: `,
|
||||
err,
|
||||
);
|
||||
return cachePath;
|
||||
}
|
||||
}
|
||||
|
||||
fs.ensureDirSync(path.dirname(cachePath));
|
||||
|
||||
logger.info(`Cloning ${repoUrl} to ${cachePath}`);
|
||||
try {
|
||||
await git.clone({
|
||||
fs,
|
||||
http,
|
||||
dir: cachePath,
|
||||
url: repoUrl,
|
||||
singleBranch: true,
|
||||
depth: 1,
|
||||
});
|
||||
logger.info(`Successfully cloned ${repoUrl} to ${cachePath}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to clone ${repoUrl} to ${cachePath}: `, err);
|
||||
throw err; // Re-throw the error after logging
|
||||
}
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
async function copyRepoToApp(repoCachePath: string, appPath: string) {
|
||||
logger.info(`Copying from ${repoCachePath} to ${appPath}`);
|
||||
try {
|
||||
await fs.copy(repoCachePath, appPath, {
|
||||
filter: (src, _dest) => {
|
||||
const excludedDirs = ["node_modules", ".git"];
|
||||
const relativeSrc = path.relative(repoCachePath, src);
|
||||
if (excludedDirs.includes(path.basename(relativeSrc))) {
|
||||
logger.info(`Excluding ${src} from copy`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
logger.info("Finished copying repository contents.");
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Error copying repository from ${repoCachePath} to ${appPath}: `,
|
||||
err,
|
||||
);
|
||||
throw err; // Re-throw the error after logging
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,7 @@ export const UserSettingsSchema = z.object({
|
||||
enableProSaverMode: z.boolean().optional(),
|
||||
enableProLazyEditsMode: z.boolean().optional(),
|
||||
enableProSmartFilesContextMode: z.boolean().optional(),
|
||||
selectedTemplateId: z.string().optional(),
|
||||
|
||||
////////////////////////////////
|
||||
// DEPRECATED.
|
||||
|
||||
115
src/pages/hub.tsx
Normal file
115
src/pages/hub.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { DEFAULT_TEMPLATE_ID, templatesData } from "@/shared/templates";
|
||||
|
||||
const HubPage: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedTemplateId =
|
||||
settings?.selectedTemplateId || DEFAULT_TEMPLATE_ID;
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
updateSettings({ selectedTemplateId: templateId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen px-8 py-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<Button
|
||||
onClick={() => router.history.back()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 mb-4 bg-(--background-lightest) py-5"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
<header className="mb-8 text-left">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Pick your default template
|
||||
</h1>
|
||||
<p className="text-md text-gray-600 dark:text-gray-400">
|
||||
Choose a starting point for your new project.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{templatesData.map((template) => {
|
||||
const isSelected = template.id === selectedTemplateId;
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
onClick={() => handleTemplateSelect(template.id)}
|
||||
className={`
|
||||
bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden
|
||||
transform transition-all duration-300 ease-in-out
|
||||
cursor-pointer group relative
|
||||
${
|
||||
isSelected
|
||||
? "ring-2 ring-blue-500 dark:ring-blue-400 shadow-xl"
|
||||
: "hover:shadow-lg hover:-translate-y-1"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={template.imageUrl}
|
||||
alt={template.title}
|
||||
className={`w-full h-52 object-cover transition-opacity duration-300 group-hover:opacity-80 ${isSelected ? "opacity-75" : ""}`}
|
||||
/>
|
||||
{isSelected && (
|
||||
<span className="absolute top-3 right-3 bg-blue-600 text-white text-xs font-bold px-3 py-1.5 rounded-md shadow-lg">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<h2
|
||||
className={`text-lg font-semibold ${isSelected ? "text-blue-600 dark:text-blue-400" : "text-gray-900 dark:text-white"}`}
|
||||
>
|
||||
{template.title}
|
||||
</h2>
|
||||
{template.isOfficial && (
|
||||
<span
|
||||
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${isSelected ? "bg-blue-100 text-blue-700 dark:bg-blue-600 dark:text-blue-100" : "bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-200"}`}
|
||||
>
|
||||
Official
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 h-8 overflow-y-auto">
|
||||
{template.description}
|
||||
</p>
|
||||
{template.githubUrl && (
|
||||
<a
|
||||
className={`inline-flex items-center text-sm font-medium transition-colors duration-200 ${isSelected ? "text-blue-500 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" : "text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (template.githubUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
template.githubUrl,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
View on GitHub{" "}
|
||||
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HubPage;
|
||||
@@ -5,9 +5,11 @@ import { chatRoute } from "./routes/chat";
|
||||
import { settingsRoute } from "./routes/settings";
|
||||
import { providerSettingsRoute } from "./routes/settings/providers/$provider";
|
||||
import { appDetailsRoute } from "./routes/app-details";
|
||||
import { hubRoute } from "./routes/hub";
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
homeRoute,
|
||||
hubRoute,
|
||||
chatRoute,
|
||||
appDetailsRoute,
|
||||
settingsRoute.addChildren([providerSettingsRoute]),
|
||||
|
||||
9
src/routes/hub.ts
Normal file
9
src/routes/hub.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Route } from "@tanstack/react-router";
|
||||
import HubPage from "../pages/hub"; // Assuming HubPage is in src/pages/hub.tsx
|
||||
import { rootRoute } from "./root"; // Assuming rootRoute is defined in src/routes/root.ts
|
||||
|
||||
export const hubRoute = new Route({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: "/hub",
|
||||
component: HubPage,
|
||||
});
|
||||
38
src/shared/templates.ts
Normal file
38
src/shared/templates.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface Template {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
githubUrl?: string;
|
||||
isOfficial: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TEMPLATE_ID = "react";
|
||||
|
||||
export const templatesData: Template[] = [
|
||||
{
|
||||
id: "react",
|
||||
title: "React.js Template",
|
||||
description: "Uses React.js, Vite, Shadcn, Tailwind and TypeScript.",
|
||||
imageUrl:
|
||||
"https://github.com/user-attachments/assets/5b700eab-b28c-498e-96de-8649b14c16d9",
|
||||
isOfficial: true,
|
||||
},
|
||||
{
|
||||
id: "next",
|
||||
title: "Next.js Template",
|
||||
description: "Uses Next.js, React.js, Shadcn, Tailwind and TypeScript.",
|
||||
imageUrl:
|
||||
"https://github.com/user-attachments/assets/96258e4f-abce-4910-a62a-a9dff77965f2",
|
||||
githubUrl: "https://github.com/dyad-sh/nextjs-template",
|
||||
isOfficial: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getTemplateOrThrow(templateId: string): Template {
|
||||
const template = templatesData.find((template) => template.id === templateId);
|
||||
if (!template) {
|
||||
throw new Error(`Template ${templateId} not found`);
|
||||
}
|
||||
return template;
|
||||
}
|
||||
Reference in New Issue
Block a user