diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index f79fbd3..21eb4c1 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -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 diff --git a/src/ipc/handlers/app_handlers.ts b/src/ipc/handlers/app_handlers.ts index 2c85e73..c978546 100644 --- a/src/ipc/handlers/app_handlers.ts +++ b/src/ipc/handlers/app_handlers.ts @@ -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, diff --git a/src/ipc/handlers/createFromTemplate.ts b/src/ipc/handlers/createFromTemplate.ts new file mode 100644 index 0000000..a58bc03 --- /dev/null +++ b/src/ipc/handlers/createFromTemplate.ts @@ -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 { + 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 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 + } +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index f87368f..4d3aebc 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -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. diff --git a/src/pages/hub.tsx b/src/pages/hub.tsx new file mode 100644 index 0000000..beee0dd --- /dev/null +++ b/src/pages/hub.tsx @@ -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 ( +
+
+ +
+

+ Pick your default template +

+

+ Choose a starting point for your new project. +

+
+ +
+ {templatesData.map((template) => { + const isSelected = template.id === selectedTemplateId; + return ( +
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" + } + `} + > +
+ {template.title} + {isSelected && ( + + Selected + + )} +
+
+
+

+ {template.title} +

+ {template.isOfficial && ( + + Official + + )} +
+

+ {template.description} +

+ {template.githubUrl && ( + { + e.stopPropagation(); + if (template.githubUrl) { + IpcClient.getInstance().openExternalUrl( + template.githubUrl, + ); + } + }} + > + View on GitHub{" "} + + + )} +
+
+ ); + })} +
+
+
+ ); +}; + +export default HubPage; diff --git a/src/router.ts b/src/router.ts index 0825923..a799289 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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]), diff --git a/src/routes/hub.ts b/src/routes/hub.ts new file mode 100644 index 0000000..79690dc --- /dev/null +++ b/src/routes/hub.ts @@ -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, +}); diff --git a/src/shared/templates.ts b/src/shared/templates.ts new file mode 100644 index 0000000..cef22c3 --- /dev/null +++ b/src/shared/templates.ts @@ -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; +}