community templates (#691)

This commit is contained in:
Will Chen
2025-07-23 10:11:16 -07:00
committed by GitHub
parent 9edd0fa80f
commit e947eede7a
37 changed files with 544 additions and 135 deletions

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { safeStorage } from "electron";
import { readSettings, getSettingsFilePath } from "@/main/settings";
import { getUserDataPath } from "@/paths/paths";
import { UserSettings } from "@/lib/schemas";
// Mock dependencies
vi.mock("node:fs");
@@ -50,23 +51,26 @@ describe("readSettings", () => {
mockSettingsPath,
expect.stringContaining('"selectedModel"'),
);
expect(result).toEqual({
selectedModel: {
name: "auto",
provider: "auto",
},
providerSettings: {},
telemetryConsent: "unset",
telemetryUserId: expect.any(String),
hasRunBefore: false,
experiments: {},
enableProLazyEditsMode: true,
enableProSmartFilesContextMode: true,
selectedChatMode: "build",
enableAutoFixProblems: false,
enableAutoUpdate: true,
releaseChannel: "stable",
});
expect(scrubSettings(result)).toMatchInlineSnapshot(`
{
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
"experiments": {},
"hasRunBefore": false,
"providerSettings": {},
"releaseChannel": "stable",
"selectedChatMode": "build",
"selectedModel": {
"name": "auto",
"provider": "auto",
},
"selectedTemplateId": "react",
"telemetryConsent": "unset",
"telemetryUserId": "[scrubbed]",
}
`);
});
});
@@ -293,23 +297,26 @@ describe("readSettings", () => {
const result = readSettings();
expect(result).toEqual({
selectedModel: {
name: "auto",
provider: "auto",
},
providerSettings: {},
telemetryConsent: "unset",
telemetryUserId: expect.any(String),
hasRunBefore: false,
experiments: {},
enableProLazyEditsMode: true,
enableProSmartFilesContextMode: true,
selectedChatMode: "build",
enableAutoFixProblems: false,
enableAutoUpdate: true,
releaseChannel: "stable",
});
expect(scrubSettings(result)).toMatchInlineSnapshot(`
{
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"enableProLazyEditsMode": true,
"enableProSmartFilesContextMode": true,
"experiments": {},
"hasRunBefore": false,
"providerSettings": {},
"releaseChannel": "stable",
"selectedChatMode": "build",
"selectedModel": {
"name": "auto",
"provider": "auto",
},
"selectedTemplateId": "react",
"telemetryConsent": "unset",
"telemetryUserId": "[scrubbed]",
}
`);
});
it("should return default settings when JSON parsing fails", () => {
@@ -389,3 +396,10 @@ describe("readSettings", () => {
});
});
});
function scrubSettings(result: UserSettings) {
return {
...result,
telemetryUserId: "[scrubbed]",
};
}

View File

@@ -0,0 +1,51 @@
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface CommunityCodeConsentDialogProps {
isOpen: boolean;
onAccept: () => void;
onCancel: () => void;
}
export const CommunityCodeConsentDialog: React.FC<
CommunityCodeConsentDialogProps
> = ({ isOpen, onAccept, onCancel }) => {
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Community Code Notice</AlertDialogTitle>
<AlertDialogDescription className="space-y-3">
<p>
This code was created by a Dyad community member, not our core
team.
</p>
<p>
Community code can be very helpful, but since it's built
independently, it may have bugs, security risks, or could cause
issues with your system. We can't provide official support if
problems occur.
</p>
<p>
We recommend reviewing the code on GitHub first. Only proceed if
you're comfortable with these risks.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onAccept}>Accept</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,134 @@
import React, { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { CommunityCodeConsentDialog } from "./CommunityCodeConsentDialog";
import type { Template } from "@/shared/templates";
interface TemplateCardProps {
template: Template;
isSelected: boolean;
onSelect: (templateId: string) => void;
}
export const TemplateCard: React.FC<TemplateCardProps> = ({
template,
isSelected,
onSelect,
}) => {
const { settings, updateSettings } = useSettings();
const [showConsentDialog, setShowConsentDialog] = useState(false);
const handleCardClick = () => {
// If it's a community template and user hasn't accepted community code yet, show dialog
if (!template.isOfficial && !settings?.acceptedCommunityCode) {
setShowConsentDialog(true);
return;
}
// Otherwise, proceed with selection
onSelect(template.id);
};
const handleConsentAccept = () => {
// Update settings to accept community code
updateSettings({ acceptedCommunityCode: true });
// Select the template
onSelect(template.id);
// Close dialog
setShowConsentDialog(false);
};
const handleConsentCancel = () => {
// Just close dialog, don't update settings or select template
setShowConsentDialog(false);
};
const handleGithubClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (template.githubUrl) {
IpcClient.getInstance().openExternalUrl(template.githubUrl);
}
};
return (
<>
<div
onClick={handleCardClick}
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={handleGithubClick}
>
View on GitHub{" "}
<ArrowLeft className="w-4 h-4 ml-1 transform rotate-180" />
</a>
)}
</div>
</div>
<CommunityCodeConsentDialog
isOpen={showConsentDialog}
onAccept={handleConsentAccept}
onCancel={handleConsentCancel}
/>
</>
);
};

24
src/hooks/useTemplates.ts Normal file
View File

@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import { localTemplatesData, type Template } from "@/shared/templates";
export function useTemplates() {
const query = useQuery({
queryKey: ["templates"],
queryFn: async (): Promise<Template[]> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.getTemplates();
},
initialData: localTemplatesData,
meta: {
showErrorToast: true,
},
});
return {
templates: query.data,
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}

View File

@@ -5,7 +5,7 @@ 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 { getTemplateOrThrow } from "../utils/template_utils";
import log from "electron-log";
const logger = log.scope("createFromTemplate");
@@ -16,7 +16,7 @@ export async function createFromTemplate({
fullAppPath: string;
}) {
const settings = readSettings();
const templateId = settings.selectedTemplateId ?? DEFAULT_TEMPLATE_ID;
const templateId = settings.selectedTemplateId;
if (templateId === "react") {
await copyDirectoryRecursive(
@@ -26,7 +26,7 @@ export async function createFromTemplate({
return;
}
const template = getTemplateOrThrow(templateId);
const template = await getTemplateOrThrow(templateId);
if (!template.githubUrl) {
throw new Error(`Template ${templateId} has no GitHub URL`);
}

View File

@@ -0,0 +1,19 @@
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { getAllTemplates } from "../utils/template_utils";
import { localTemplatesData, type Template } from "../../shared/templates";
const logger = log.scope("template_handlers");
const handle = createLoggedHandler(logger);
export function registerTemplateHandlers() {
handle("get-templates", async (): Promise<Template[]> => {
try {
const templates = await getAllTemplates();
return templates;
} catch (error) {
logger.error("Error fetching templates:", error);
return localTemplatesData;
}
});
}

View File

@@ -52,6 +52,7 @@ import type {
UpdateChatParams,
FileAttachment,
} from "./ipc_types";
import type { Template } from "../shared/templates";
import type { AppChatContext, ProposalResult } from "@/lib/schemas";
import { showError } from "@/lib/toast";
@@ -1024,4 +1025,9 @@ export class IpcClient {
}): Promise<ProblemReport> {
return this.ipcRenderer.invoke("check-problems", params);
}
// Template methods
public async getTemplates(): Promise<Template[]> {
return this.ipcRenderer.invoke("get-templates");
}
}

View File

@@ -25,6 +25,7 @@ import { registerAppUpgradeHandlers } from "./handlers/app_upgrade_handlers";
import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
import { registerProblemsHandlers } from "./handlers/problems_handlers";
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
import { registerTemplateHandlers } from "./handlers/template_handlers";
export function registerIpcHandlers() {
// Register all IPC handlers by category
@@ -55,4 +56,5 @@ export function registerIpcHandlers() {
registerAppUpgradeHandlers();
registerCapacitorHandlers();
registerAppEnvVarsHandlers();
registerTemplateHandlers();
}

View File

@@ -0,0 +1,82 @@
import {
type Template,
type ApiTemplate,
localTemplatesData,
} from "../../shared/templates";
import log from "electron-log";
const logger = log.scope("template_utils");
// In-memory cache for API templates
let apiTemplatesCache: Template[] | null = null;
let apiTemplatesFetchPromise: Promise<Template[]> | null = null;
// Convert API template to our Template interface
function convertApiTemplate(apiTemplate: ApiTemplate): Template {
return {
id: `${apiTemplate.githubOrg}/${apiTemplate.githubRepo}`,
title: apiTemplate.title,
description: apiTemplate.description,
imageUrl: apiTemplate.imageUrl,
githubUrl: `https://github.com/${apiTemplate.githubOrg}/${apiTemplate.githubRepo}`,
isOfficial: false,
};
}
// Fetch templates from API with caching
export async function fetchApiTemplates(): Promise<Template[]> {
// Return cached data if available
if (apiTemplatesCache) {
return apiTemplatesCache;
}
// Return existing promise if fetch is already in progress
if (apiTemplatesFetchPromise) {
return apiTemplatesFetchPromise;
}
// Start new fetch
apiTemplatesFetchPromise = (async (): Promise<Template[]> => {
try {
const response = await fetch("https://api.dyad.sh/v1/templates");
if (!response.ok) {
throw new Error(
`Failed to fetch templates: ${response.status} ${response.statusText}`,
);
}
const apiTemplates: ApiTemplate[] = await response.json();
const convertedTemplates = apiTemplates.map(convertApiTemplate);
// Cache the result
apiTemplatesCache = convertedTemplates;
return convertedTemplates;
} catch (error) {
logger.error("Failed to fetch API templates:", error);
// Reset the promise so we can retry later
apiTemplatesFetchPromise = null;
return []; // Return empty array on error
}
})();
return apiTemplatesFetchPromise;
}
// Get all templates (local + API)
export async function getAllTemplates(): Promise<Template[]> {
const apiTemplates = await fetchApiTemplates();
return [...localTemplatesData, ...apiTemplates];
}
export async function getTemplateOrThrow(
templateId: string,
): Promise<Template> {
const allTemplates = await getAllTemplates();
const template = allTemplates.find((template) => template.id === templateId);
if (!template) {
throw new Error(
`Template ${templateId} not found. Please select a different template.`,
);
}
return template;
}

View File

@@ -149,9 +149,10 @@ export const UserSettingsSchema = z.object({
thinkingBudget: z.enum(["low", "medium", "high"]).optional(),
enableProLazyEditsMode: z.boolean().optional(),
enableProSmartFilesContextMode: z.boolean().optional(),
selectedTemplateId: z.string().optional(),
selectedTemplateId: z.string(),
enableSupabaseWriteSqlMigration: z.boolean().optional(),
selectedChatMode: ChatModeSchema.optional(),
acceptedCommunityCode: z.boolean().optional(),
enableAutoFixProblems: z.boolean().optional(),
enableNativeGit: z.boolean().optional(),

View File

@@ -64,7 +64,7 @@ export const showError = (message: any) => {
onCopy={() => onCopy(t)}
/>
),
{ duration: 4000 },
{ duration: 8_000 },
);
return toastId;

View File

@@ -5,6 +5,7 @@ import { UserSettingsSchema, type UserSettings, Secret } from "../lib/schemas";
import { safeStorage } from "electron";
import { v4 as uuidv4 } from "uuid";
import log from "electron-log";
import { DEFAULT_TEMPLATE_ID } from "@/shared/templates";
const logger = log.scope("settings");
@@ -26,6 +27,7 @@ const DEFAULT_SETTINGS: UserSettings = {
enableAutoFixProblems: false,
enableAutoUpdate: true,
releaseChannel: "stable",
selectedTemplateId: DEFAULT_TEMPLATE_ID,
};
const SETTINGS_FILE = "user-settings.json";

View File

@@ -3,20 +3,26 @@ 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";
import { useTemplates } from "@/hooks/useTemplates";
import { TemplateCard } from "@/components/TemplateCard";
const HubPage: React.FC = () => {
const { settings, updateSettings } = useSettings();
const router = useRouter();
const { templates, isLoading } = useTemplates();
const selectedTemplateId =
settings?.selectedTemplateId || DEFAULT_TEMPLATE_ID;
const selectedTemplateId = settings?.selectedTemplateId;
const handleTemplateSelect = (templateId: string) => {
updateSettings({ selectedTemplateId: templateId });
};
// Separate templates into official and community
const officialTemplates =
templates?.filter((template) => template.isOfficial) || [];
const communityTemplates =
templates?.filter((template) => !template.isOfficial) || [];
return (
<div className="min-h-screen px-8 py-4">
<div className="max-w-5xl mx-auto">
@@ -35,78 +41,47 @@ const HubPage: React.FC = () => {
</h1>
<p className="text-md text-gray-600 dark:text-gray-400">
Choose a starting point for your new project.
{isLoading && " Loading additional templates..."}
</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>
{/* Official Templates Section */}
{officialTemplates.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
Official templates
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{officialTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect}
/>
))}
</div>
</section>
)}
{/* Community Templates Section */}
{communityTemplates.length > 0 && (
<section className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
Community templates
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{communityTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
isSelected={template.id === selectedTemplateId}
onSelect={handleTemplateSelect}
/>
))}
</div>
</section>
)}
</div>
</div>
);

View File

@@ -100,6 +100,7 @@ const validInvokeChannels = [
"open-android",
"check-problems",
"restart-dyad",
"get-templates",
// Test-only channels
// 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

View File

@@ -7,17 +7,27 @@ export interface Template {
isOfficial: boolean;
}
export const DEFAULT_TEMPLATE_ID = "react";
// API Template interface from the external API
export interface ApiTemplate {
githubOrg: string;
githubRepo: string;
title: string;
description: string;
imageUrl: string;
}
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,
},
export const DEFAULT_TEMPLATE_ID = "react";
export const DEFAULT_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,
};
export const localTemplatesData: Template[] = [
DEFAULT_TEMPLATE,
{
id: "next",
title: "Next.js Template",
@@ -28,11 +38,3 @@ export const templatesData: 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;
}