Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
B
bit-pm
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
燕伟桐
bit-pm
Commits
a915d892
Unverified
提交
a915d892
authored
5月 27, 2025
作者:
Will Chen
提交者:
GitHub
5月 27, 2025
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
Support Next.js template & template hub (#241)
上级
8cfd476e
隐藏空白字符变更
内嵌
并排
正在显示
8 个修改的文件
包含
361 行增加
和
12 行删除
+361
-12
app-sidebar.tsx
src/components/app-sidebar.tsx
+6
-1
app_handlers.ts
src/ipc/handlers/app_handlers.ts
+5
-11
createFromTemplate.ts
src/ipc/handlers/createFromTemplate.ts
+185
-0
schemas.ts
src/lib/schemas.ts
+1
-0
hub.tsx
src/pages/hub.tsx
+115
-0
router.ts
src/router.ts
+2
-0
hub.ts
src/routes/hub.ts
+9
-0
templates.ts
src/shared/templates.ts
+38
-0
没有找到文件。
src/components/app-sidebar.tsx
浏览文件 @
a915d892
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 = [
...
@@ -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
...
...
src/ipc/handlers/app_handlers.ts
浏览文件 @
a915d892
...
@@ -12,10 +12,7 @@ import { promises as fsPromises } from "node:fs";
...
@@ -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";
...
@@ -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() {
...
@@ -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
,
...
...
src/ipc/handlers/createFromTemplate.ts
0 → 100644
浏览文件 @
a915d892
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
}
}
src/lib/schemas.ts
浏览文件 @
a915d892
...
@@ -120,6 +120,7 @@ export const UserSettingsSchema = z.object({
...
@@ -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.
...
...
src/pages/hub.tsx
0 → 100644
浏览文件 @
a915d892
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
;
src/router.ts
浏览文件 @
a915d892
...
@@ -5,9 +5,11 @@ import { chatRoute } from "./routes/chat";
...
@@ -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
]),
...
...
src/routes/hub.ts
0 → 100644
浏览文件 @
a915d892
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
,
});
src/shared/templates.ts
0 → 100644
浏览文件 @
a915d892
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
;
}
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论