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 { 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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(),
|
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
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 { 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
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