Support Next.js template & template hub (#241)

This commit is contained in:
Will Chen
2025-05-27 00:16:30 -07:00
committed by GitHub
parent 8cfd476ea9
commit a915d892f7
8 changed files with 361 additions and 12 deletions

View File

@@ -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 { Link, useRouterState } from "@tanstack/react-router";
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
@@ -38,6 +38,11 @@ const items = [
to: "/settings", to: "/settings",
icon: Settings, icon: Settings,
}, },
{
title: "Hub",
to: "/hub",
icon: Store,
},
]; ];
// Hover state types // Hover state types

View File

@@ -12,10 +12,7 @@ import { promises as fsPromises } from "node:fs";
// Import our utility modules // Import our utility modules
import { withLock } from "../utils/lock_utils"; import { withLock } from "../utils/lock_utils";
import { import { getFilesRecursively } from "../utils/file_utils";
copyDirectoryRecursive,
getFilesRecursively,
} from "../utils/file_utils";
import { import {
runningApps, runningApps,
processCounter, processCounter,
@@ -35,6 +32,7 @@ import { createLoggedHandler } from "./safe_handle";
import { getLanguageModelProviders } from "../shared/language_model_helpers"; import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server"; import { startProxy } from "../utils/start_proxy_server";
import { Worker } from "worker_threads"; import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate";
const logger = log.scope("app_handlers"); const logger = log.scope("app_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -190,14 +188,10 @@ export function registerAppHandlers() {
}) })
.returning(); .returning();
// Why do we not use fs.cp here? await createFromTemplate({
// 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"),
fullAppPath, fullAppPath,
); });
// Initialize git repo and create first commit // Initialize git repo and create first commit
await git.init({ await git.init({
fs: fs, fs: fs,

View 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
}
}

View File

@@ -120,6 +120,7 @@ export const UserSettingsSchema = z.object({
enableProSaverMode: z.boolean().optional(), enableProSaverMode: z.boolean().optional(),
enableProLazyEditsMode: z.boolean().optional(), enableProLazyEditsMode: z.boolean().optional(),
enableProSmartFilesContextMode: z.boolean().optional(), enableProSmartFilesContextMode: z.boolean().optional(),
selectedTemplateId: z.string().optional(),
//////////////////////////////// ////////////////////////////////
// DEPRECATED. // DEPRECATED.

115
src/pages/hub.tsx Normal file
View 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;

View File

@@ -5,9 +5,11 @@ import { chatRoute } from "./routes/chat";
import { settingsRoute } from "./routes/settings"; import { settingsRoute } from "./routes/settings";
import { providerSettingsRoute } from "./routes/settings/providers/$provider"; import { providerSettingsRoute } from "./routes/settings/providers/$provider";
import { appDetailsRoute } from "./routes/app-details"; import { appDetailsRoute } from "./routes/app-details";
import { hubRoute } from "./routes/hub";
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
hubRoute,
chatRoute, chatRoute,
appDetailsRoute, appDetailsRoute,
settingsRoute.addChildren([providerSettingsRoute]), settingsRoute.addChildren([providerSettingsRoute]),

9
src/routes/hub.ts Normal file
View 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
View 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;
}