From e947eede7a74c80363bdae3a1f932945fd418335 Mon Sep 17 00:00:00 2001 From: Will Chen Date: Wed, 23 Jul 2025 10:11:16 -0700 Subject: [PATCH] community templates (#691) --- e2e-tests/helpers/test_helper.ts | 4 +- e2e-tests/problems.spec.ts | 2 +- e2e-tests/select_component.spec.ts | 2 +- ....ts_auto-update---disable-and-enable-1.txt | 1 + ....ts_auto-update---disable-and-enable-2.txt | 1 + ...ontext_window.spec.ts_context-window-4.txt | 1 + ...-change-from-stable-to-beta-and-back-1.txt | 1 + ...-change-from-stable-to-beta-and-back-2.txt | 1 + ...telemetry.spec.ts_telemetry---accept-1.txt | 1 + ...telemetry.spec.ts_telemetry---accept-2.txt | 1 + .../telemetry.spec.ts_telemetry---later-1.txt | 1 + .../telemetry.spec.ts_telemetry---later-2.txt | 1 + ...telemetry.spec.ts_telemetry---reject-1.txt | 1 + ...telemetry.spec.ts_telemetry---reject-2.txt | 1 + ...mmunity.spec.ts_template---community-1.txt | 20 +++ ...mmunity.spec.ts_template---community-2.txt | 21 +++ ...te-nextjs.spec.ts_create-next-js-app-1.txt | 21 +++ ...nking_budget.spec.ts_thinking-budget-1.txt | 1 + ...nking_budget.spec.ts_thinking-budget-3.txt | 1 + ...nking_budget.spec.ts_thinking-budget-5.txt | 1 + e2e-tests/template-community.spec.ts | 19 +++ e2e-tests/template-create-nextjs.spec.ts | 3 +- src/__tests__/readSettings.test.ts | 82 ++++++----- src/components/CommunityCodeConsentDialog.tsx | 51 +++++++ src/components/TemplateCard.tsx | 134 ++++++++++++++++++ src/hooks/useTemplates.ts | 24 ++++ src/ipc/handlers/createFromTemplate.ts | 6 +- src/ipc/handlers/template_handlers.ts | 19 +++ src/ipc/ipc_client.ts | 6 + src/ipc/ipc_host.ts | 2 + src/ipc/utils/template_utils.ts | 82 +++++++++++ src/lib/schemas.ts | 3 +- src/lib/toast.tsx | 2 +- src/main/settings.ts | 2 + src/pages/hub.tsx | 121 +++++++--------- src/preload.ts | 1 + src/shared/templates.ts | 38 ++--- 37 files changed, 544 insertions(+), 135 deletions(-) create mode 100644 e2e-tests/snapshots/template-community.spec.ts_template---community-1.txt create mode 100644 e2e-tests/snapshots/template-community.spec.ts_template---community-2.txt create mode 100644 e2e-tests/snapshots/template-create-nextjs.spec.ts_create-next-js-app-1.txt create mode 100644 e2e-tests/template-community.spec.ts create mode 100644 src/components/CommunityCodeConsentDialog.tsx create mode 100644 src/components/TemplateCard.tsx create mode 100644 src/hooks/useTemplates.ts create mode 100644 src/ipc/handlers/template_handlers.ts create mode 100644 src/ipc/utils/template_utils.ts diff --git a/e2e-tests/helpers/test_helper.ts b/e2e-tests/helpers/test_helper.ts index edb9935..7625f8a 100644 --- a/e2e-tests/helpers/test_helper.ts +++ b/e2e-tests/helpers/test_helper.ts @@ -876,11 +876,11 @@ export class PageObject { await this.page.getByRole("link", { name: "Hub" }).click(); } - private async selectTemplate(templateName: string) { + async selectTemplate(templateName: string) { await this.page.getByRole("img", { name: templateName }).click(); } - async selectHubTemplate(templateName: "Next.js Template") { + async goToHubAndSelectTemplate(templateName: "Next.js Template") { await this.goToHubTab(); await this.selectTemplate(templateName); await this.goToAppsTab(); diff --git a/e2e-tests/problems.spec.ts b/e2e-tests/problems.spec.ts index 81badff..4e11ca4 100644 --- a/e2e-tests/problems.spec.ts +++ b/e2e-tests/problems.spec.ts @@ -112,7 +112,7 @@ export default App; test("problems - manual edit (next.js)", async ({ po }) => { await po.setUp({ enableAutoFixProblems: true }); - await po.selectHubTemplate("Next.js Template"); + await po.goToHubAndSelectTemplate("Next.js Template"); await po.sendPrompt("tc=1"); const appPath = await po.getCurrentAppPath(); diff --git a/e2e-tests/select_component.spec.ts b/e2e-tests/select_component.spec.ts index 3831d10..3e43ac8 100644 --- a/e2e-tests/select_component.spec.ts +++ b/e2e-tests/select_component.spec.ts @@ -81,7 +81,7 @@ testSkipIfWindows("upgrade app to select component", async ({ po }) => { testSkipIfWindows("select component next.js", async ({ po }) => { await po.setUp(); - await po.selectHubTemplate("Next.js Template"); + await po.goToHubAndSelectTemplate("Next.js Template"); await po.sendPrompt("tc=basic"); await po.clickTogglePreviewPanel(); diff --git a/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-1.txt b/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-1.txt index 7d57db0..4f464e8 100644 --- a/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-1.txt +++ b/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-1.txt @@ -11,6 +11,7 @@ "lastShownReleaseNotesVersion": "[scrubbed]", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": false, diff --git a/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-2.txt b/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-2.txt index de5b5fe..281cd57 100644 --- a/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-2.txt +++ b/e2e-tests/snapshots/auto_update.spec.ts_auto-update---disable-and-enable-2.txt @@ -11,6 +11,7 @@ "lastShownReleaseNotesVersion": "[scrubbed]", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/context_window.spec.ts_context-window-4.txt b/e2e-tests/snapshots/context_window.spec.ts_context-window-4.txt index d9fe5fa..f86a076 100644 --- a/e2e-tests/snapshots/context_window.spec.ts_context-window-4.txt +++ b/e2e-tests/snapshots/context_window.spec.ts_context-window-4.txt @@ -13,6 +13,7 @@ "maxChatTurnsInContext": 5, "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-1.txt b/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-1.txt index 9679f90..5d35995 100644 --- a/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-1.txt +++ b/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-1.txt @@ -11,6 +11,7 @@ "lastShownReleaseNotesVersion": "[scrubbed]", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-2.txt b/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-2.txt index de5b5fe..281cd57 100644 --- a/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-2.txt +++ b/e2e-tests/snapshots/release_channel.spec.ts_release-channel---change-from-stable-to-beta-and-back-2.txt @@ -11,6 +11,7 @@ "lastShownReleaseNotesVersion": "[scrubbed]", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-1.txt b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-1.txt index 728da7e..b02db82 100644 --- a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-1.txt +++ b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-1.txt @@ -10,6 +10,7 @@ "experiments": {}, "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-2.txt b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-2.txt index 26da4cb..5bfa0ca 100644 --- a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-2.txt +++ b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---accept-2.txt @@ -11,6 +11,7 @@ "lastShownReleaseNotesVersion": "[scrubbed]", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-1.txt b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-1.txt index 728da7e..b02db82 100644 --- a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-1.txt +++ b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-1.txt @@ -10,6 +10,7 @@ "experiments": {}, "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-2.txt b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-2.txt index de5b5fe..281cd57 100644 --- a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-2.txt +++ b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---later-2.txt @@ -11,6 +11,7 @@ "lastShownReleaseNotesVersion": "[scrubbed]", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-1.txt b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-1.txt index 728da7e..b02db82 100644 --- a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-1.txt +++ b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-1.txt @@ -10,6 +10,7 @@ "experiments": {}, "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-2.txt b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-2.txt index f96c256..56c238b 100644 --- a/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-2.txt +++ b/e2e-tests/snapshots/telemetry.spec.ts_telemetry---reject-2.txt @@ -11,6 +11,7 @@ "lastShownReleaseNotesVersion": "[scrubbed]", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/template-community.spec.ts_template---community-1.txt b/e2e-tests/snapshots/template-community.spec.ts_template---community-1.txt new file mode 100644 index 0000000..281cd57 --- /dev/null +++ b/e2e-tests/snapshots/template-community.spec.ts_template---community-1.txt @@ -0,0 +1,20 @@ +{ + "selectedModel": { + "name": "auto", + "provider": "auto" + }, + "providerSettings": {}, + "telemetryConsent": "unset", + "telemetryUserId": "[UUID]", + "hasRunBefore": true, + "experiments": {}, + "lastShownReleaseNotesVersion": "[scrubbed]", + "enableProLazyEditsMode": true, + "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", + "selectedChatMode": "build", + "enableAutoFixProblems": false, + "enableAutoUpdate": true, + "releaseChannel": "stable", + "isTestMode": true +} \ No newline at end of file diff --git a/e2e-tests/snapshots/template-community.spec.ts_template---community-2.txt b/e2e-tests/snapshots/template-community.spec.ts_template---community-2.txt new file mode 100644 index 0000000..ae8943a --- /dev/null +++ b/e2e-tests/snapshots/template-community.spec.ts_template---community-2.txt @@ -0,0 +1,21 @@ +{ + "selectedModel": { + "name": "auto", + "provider": "auto" + }, + "providerSettings": {}, + "telemetryConsent": "unset", + "telemetryUserId": "[UUID]", + "hasRunBefore": true, + "experiments": {}, + "lastShownReleaseNotesVersion": "[scrubbed]", + "enableProLazyEditsMode": true, + "enableProSmartFilesContextMode": true, + "selectedTemplateId": "jeff-kazzee/dyad-template-angular", + "selectedChatMode": "build", + "acceptedCommunityCode": true, + "enableAutoFixProblems": false, + "enableAutoUpdate": true, + "releaseChannel": "stable", + "isTestMode": true +} \ No newline at end of file diff --git a/e2e-tests/snapshots/template-create-nextjs.spec.ts_create-next-js-app-1.txt b/e2e-tests/snapshots/template-create-nextjs.spec.ts_create-next-js-app-1.txt new file mode 100644 index 0000000..1332f0b --- /dev/null +++ b/e2e-tests/snapshots/template-create-nextjs.spec.ts_create-next-js-app-1.txt @@ -0,0 +1,21 @@ +{ + "selectedModel": { + "name": "test-model", + "provider": "custom::testing", + "customModelId": 1 + }, + "providerSettings": {}, + "telemetryConsent": "unset", + "telemetryUserId": "[UUID]", + "hasRunBefore": true, + "experiments": {}, + "lastShownReleaseNotesVersion": "[scrubbed]", + "enableProLazyEditsMode": true, + "enableProSmartFilesContextMode": true, + "selectedTemplateId": "next", + "selectedChatMode": "build", + "enableAutoFixProblems": false, + "enableAutoUpdate": true, + "releaseChannel": "stable", + "isTestMode": true +} \ No newline at end of file diff --git a/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-1.txt b/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-1.txt index ea85ddb..2b90eb5 100644 --- a/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-1.txt +++ b/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-1.txt @@ -20,6 +20,7 @@ "thinkingBudget": "low", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-3.txt b/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-3.txt index 2cc2d59..a5ac511 100644 --- a/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-3.txt +++ b/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-3.txt @@ -20,6 +20,7 @@ "thinkingBudget": "medium", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-5.txt b/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-5.txt index ac7a9ba..b198ccc 100644 --- a/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-5.txt +++ b/e2e-tests/snapshots/thinking_budget.spec.ts_thinking-budget-5.txt @@ -20,6 +20,7 @@ "thinkingBudget": "high", "enableProLazyEditsMode": true, "enableProSmartFilesContextMode": true, + "selectedTemplateId": "react", "selectedChatMode": "build", "enableAutoFixProblems": false, "enableAutoUpdate": true, diff --git a/e2e-tests/template-community.spec.ts b/e2e-tests/template-community.spec.ts new file mode 100644 index 0000000..f52883c --- /dev/null +++ b/e2e-tests/template-community.spec.ts @@ -0,0 +1,19 @@ +import { test } from "./helpers/test_helper"; + +test("template - community", async ({ po }) => { + await po.goToHubTab(); + // This is a community template, so we should see the consent dialog + await po.selectTemplate("Angular"); + await po.page.getByRole("button", { name: "Cancel" }).click(); + await po.snapshotSettings(); + + await po.selectTemplate("Angular"); + await po.page.getByRole("button", { name: "Accept" }).click(); + await po.page + .locator("section") + .filter({ hasText: "Community" }) + .locator("div") + .first() + .click(); + await po.snapshotSettings(); +}); diff --git a/e2e-tests/template-create-nextjs.spec.ts b/e2e-tests/template-create-nextjs.spec.ts index 06fcfe5..a63fd52 100644 --- a/e2e-tests/template-create-nextjs.spec.ts +++ b/e2e-tests/template-create-nextjs.spec.ts @@ -3,7 +3,8 @@ import { expect } from "@playwright/test"; test("create next.js app", async ({ po }) => { await po.setUp(); - await po.selectHubTemplate("Next.js Template"); + await po.goToHubAndSelectTemplate("Next.js Template"); + await po.snapshotSettings(); // Create an app await po.sendPrompt("tc=edit-made-with-dyad"); diff --git a/src/__tests__/readSettings.test.ts b/src/__tests__/readSettings.test.ts index 214591c..f2b400f 100644 --- a/src/__tests__/readSettings.test.ts +++ b/src/__tests__/readSettings.test.ts @@ -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]", + }; +} diff --git a/src/components/CommunityCodeConsentDialog.tsx b/src/components/CommunityCodeConsentDialog.tsx new file mode 100644 index 0000000..a073a58 --- /dev/null +++ b/src/components/CommunityCodeConsentDialog.tsx @@ -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 ( + !open && onCancel()}> + + + Community Code Notice + +

+ This code was created by a Dyad community member, not our core + team. +

+

+ 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. +

+

+ We recommend reviewing the code on GitHub first. Only proceed if + you're comfortable with these risks. +

+
+
+ + Cancel + Accept + +
+
+ ); +}; diff --git a/src/components/TemplateCard.tsx b/src/components/TemplateCard.tsx new file mode 100644 index 0000000..c165440 --- /dev/null +++ b/src/components/TemplateCard.tsx @@ -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 = ({ + 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 ( + <> +
+
+ {template.title} + {isSelected && ( + + Selected + + )} +
+
+
+

+ {template.title} +

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

+ {template.description} +

+ {template.githubUrl && ( + + View on GitHub{" "} + + + )} +
+
+ + + + ); +}; diff --git a/src/hooks/useTemplates.ts b/src/hooks/useTemplates.ts new file mode 100644 index 0000000..071da00 --- /dev/null +++ b/src/hooks/useTemplates.ts @@ -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 => { + 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, + }; +} diff --git a/src/ipc/handlers/createFromTemplate.ts b/src/ipc/handlers/createFromTemplate.ts index a58bc03..26756f8 100644 --- a/src/ipc/handlers/createFromTemplate.ts +++ b/src/ipc/handlers/createFromTemplate.ts @@ -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`); } diff --git a/src/ipc/handlers/template_handlers.ts b/src/ipc/handlers/template_handlers.ts new file mode 100644 index 0000000..6209115 --- /dev/null +++ b/src/ipc/handlers/template_handlers.ts @@ -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 => { + try { + const templates = await getAllTemplates(); + return templates; + } catch (error) { + logger.error("Error fetching templates:", error); + return localTemplatesData; + } + }); +} diff --git a/src/ipc/ipc_client.ts b/src/ipc/ipc_client.ts index a5749a2..087ba4a 100644 --- a/src/ipc/ipc_client.ts +++ b/src/ipc/ipc_client.ts @@ -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 { return this.ipcRenderer.invoke("check-problems", params); } + + // Template methods + public async getTemplates(): Promise { + return this.ipcRenderer.invoke("get-templates"); + } } diff --git a/src/ipc/ipc_host.ts b/src/ipc/ipc_host.ts index 50a3262..de6efd6 100644 --- a/src/ipc/ipc_host.ts +++ b/src/ipc/ipc_host.ts @@ -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(); } diff --git a/src/ipc/utils/template_utils.ts b/src/ipc/utils/template_utils.ts new file mode 100644 index 0000000..11874bf --- /dev/null +++ b/src/ipc/utils/template_utils.ts @@ -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 | 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 { + // 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 => { + 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 { + const apiTemplates = await fetchApiTemplates(); + return [...localTemplatesData, ...apiTemplates]; +} + +export async function getTemplateOrThrow( + templateId: string, +): Promise