Skip to content

Commit 5e7a459

Browse files
committed
feat: add create project functionality
1 parent b19a424 commit 5e7a459

9 files changed

Lines changed: 306 additions & 9 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Component } from "solid-js"
2+
import { createStore } from "solid-js/store"
3+
import { useDialog } from "@opencode-ai/ui/context/dialog"
4+
import { useGlobalSDK } from "@/context/global-sdk"
5+
import { useLayout } from "@/context/layout"
6+
import { Dialog } from "@opencode-ai/ui/dialog"
7+
import { TextField } from "@opencode-ai/ui/text-field"
8+
import { Button } from "@opencode-ai/ui/button"
9+
import { Spinner } from "@opencode-ai/ui/spinner"
10+
import { showToast } from "@opencode-ai/ui/toast"
11+
import { useNavigate } from "@solidjs/router"
12+
import { base64Encode } from "@opencode-ai/util/encode"
13+
14+
export const DialogCreateProject: Component = () => {
15+
const dialog = useDialog()
16+
const globalSDK = useGlobalSDK()
17+
const layout = useLayout()
18+
const navigate = useNavigate()
19+
20+
const [store, setStore] = createStore({
21+
path: "",
22+
error: undefined as string | undefined,
23+
loading: false,
24+
})
25+
26+
function openProject(directory: string) {
27+
layout.projects.open(directory)
28+
navigate(`/${base64Encode(directory)}`)
29+
}
30+
31+
async function handleSubmit(e: SubmitEvent) {
32+
e.preventDefault()
33+
34+
const path = store.path?.trim()
35+
if (!path) {
36+
setStore("error", "Project path is required")
37+
return
38+
}
39+
40+
if (!path.startsWith("/") && !path.startsWith("~/")) {
41+
setStore("error", "Path must be absolute (start with / or ~)")
42+
return
43+
}
44+
45+
setStore("error", undefined)
46+
setStore("loading", true)
47+
48+
try {
49+
const result = await globalSDK.client.project.create({ path })
50+
51+
if (result.error) {
52+
const errorMessage = (result.error as { message?: string }).message || "Failed to create project"
53+
setStore("error", errorMessage)
54+
setStore("loading", false)
55+
return
56+
}
57+
58+
const project = result.data!
59+
dialog.close()
60+
openProject(project.worktree)
61+
62+
showToast({
63+
variant: "success",
64+
icon: "circle-check",
65+
title: "Project created",
66+
description: `Created project at ${project.worktree}`,
67+
})
68+
} catch (e: unknown) {
69+
const errorMessage = e instanceof Error ? e.message : "Failed to create project"
70+
setStore("error", errorMessage)
71+
setStore("loading", false)
72+
}
73+
}
74+
75+
return (
76+
<Dialog title="Create New Project">
77+
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-5 pb-6">
78+
<div class="text-14-regular text-text-base">
79+
Enter the full path where you want to create your new project. A new directory will be created and initialized
80+
as a git repository.
81+
</div>
82+
<TextField
83+
autofocus
84+
type="text"
85+
label="Project path"
86+
placeholder="~/projects/my-new-app"
87+
name="path"
88+
value={store.path}
89+
onChange={(value) => setStore("path", value)}
90+
validationState={store.error ? "invalid" : undefined}
91+
error={store.error}
92+
/>
93+
<div class="flex gap-3 justify-end">
94+
<Button variant="ghost" onClick={() => dialog.close()} disabled={store.loading}>
95+
Cancel
96+
</Button>
97+
<Button type="submit" disabled={store.loading}>
98+
{store.loading ? (
99+
<span class="flex items-center gap-2">
100+
<Spinner class="size-4" />
101+
Creating...
102+
</span>
103+
) : (
104+
"Create Project"
105+
)}
106+
</Button>
107+
</div>
108+
</form>
109+
</Dialog>
110+
)
111+
}

packages/desktop/src/pages/home.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@ import { base64Encode } from "@opencode-ai/util/encode"
88
import { Icon } from "@opencode-ai/ui/icon"
99
import { usePlatform } from "@/context/platform"
1010
import { DateTime } from "luxon"
11+
import { useDialog } from "@opencode-ai/ui/context/dialog"
12+
import { DialogCreateProject } from "@/components/dialog-create-project"
1113

1214
export default function Home() {
1315
const sync = useGlobalSync()
1416
const layout = useLayout()
1517
const platform = usePlatform()
1618
const navigate = useNavigate()
19+
const dialog = useDialog()
1720
const homedir = createMemo(() => sync.data.path.home)
1821

1922
function openProject(directory: string) {
@@ -43,11 +46,21 @@ export default function Home() {
4346
<div class="mt-20 w-full flex flex-col gap-4">
4447
<div class="flex gap-2 items-center justify-between pl-3">
4548
<div class="text-14-medium text-text-strong">Recent projects</div>
46-
<Show when={platform.openDirectoryPickerDialog}>
47-
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
48-
Open project
49+
<div class="flex gap-2">
50+
<Button
51+
icon="plus"
52+
size="normal"
53+
class="pl-2 pr-3"
54+
onClick={() => dialog.show(() => <DialogCreateProject />)}
55+
>
56+
Create project
4957
</Button>
50-
</Show>
58+
<Show when={platform.openDirectoryPickerDialog}>
59+
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
60+
Open project
61+
</Button>
62+
</Show>
63+
</div>
5164
</div>
5265
<ul class="flex flex-col gap-2">
5366
<For
@@ -77,14 +90,19 @@ export default function Home() {
7790
<Icon name="folder-add-left" size="large" />
7891
<div class="flex flex-col gap-1 items-center justify-center">
7992
<div class="text-14-medium text-text-strong">No recent projects</div>
80-
<div class="text-12-regular text-text-weak">Get started by opening a local project</div>
93+
<div class="text-12-regular text-text-weak">Get started by creating or opening a project</div>
8194
</div>
8295
<div />
83-
<Show when={platform.openDirectoryPickerDialog}>
84-
<Button class="px-3" onClick={chooseProject}>
85-
Open project
96+
<div class="flex gap-3">
97+
<Button class="px-3" onClick={() => dialog.show(() => <DialogCreateProject />)}>
98+
Create project
8699
</Button>
87-
</Show>
100+
<Show when={platform.openDirectoryPickerDialog}>
101+
<Button class="px-3" variant="ghost" onClick={chooseProject}>
102+
Open project
103+
</Button>
104+
</Show>
105+
</div>
88106
</div>
89107
</Match>
90108
</Switch>

packages/desktop/src/pages/layout.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { Binary } from "@opencode-ai/util/binary"
4646
import { Header } from "@/components/header"
4747
import { useDialog } from "@opencode-ai/ui/context/dialog"
4848
import { DialogSelectProvider } from "@/components/dialog-select-provider"
49+
import { DialogCreateProject } from "@/components/dialog-create-project"
4950
import { useCommand } from "@/context/command"
5051
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
5152

@@ -266,6 +267,10 @@ export default function Layout(props: ParentProps) {
266267
dialog.show(() => <DialogSelectProvider />)
267268
}
268269

270+
function createProject() {
271+
dialog.show(() => <DialogCreateProject />)
272+
}
273+
269274
function navigateToProject(directory: string | undefined) {
270275
if (!directory) return
271276
const lastSession = store.lastSession[directory]
@@ -777,6 +782,17 @@ export default function Layout(props: ParentProps) {
777782
</Button>
778783
</Tooltip>
779784
</Show>
785+
<Tooltip placement="right" value="Create project" inactive={layout.sidebar.opened()}>
786+
<Button
787+
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
788+
variant="ghost"
789+
size="large"
790+
icon="folder-add-left"
791+
onClick={createProject}
792+
>
793+
<Show when={layout.sidebar.opened()}>Create project</Show>
794+
</Button>
795+
</Tooltip>
780796
{/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
781797
{/* <Button */}
782798
{/* disabled */}

packages/opencode/src/project/project.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import z from "zod"
22
import { Filesystem } from "../util/filesystem"
33
import path from "path"
44
import { $ } from "bun"
5+
import fs from "fs/promises"
56
import { Storage } from "../storage/storage"
67
import { Log } from "../util/log"
78
import { Flag } from "@/flag/flag"
89
import { Session } from "../session"
910
import { work } from "../util/queue"
1011
import { fn } from "@opencode-ai/util/fn"
12+
import { NamedError } from "@opencode-ai/util/error"
1113
import { BusEvent } from "@/bus/bus-event"
1214
import { iife } from "@/util/iife"
1315
import { GlobalBus } from "@/bus/global"
@@ -218,4 +220,52 @@ export namespace Project {
218220
return result
219221
},
220222
)
223+
224+
export const CreateError = NamedError.create("CreateProjectError", z.object({ message: z.string() }))
225+
226+
export const create = fn(
227+
z.object({
228+
path: z.string().min(1),
229+
name: z.string().optional(),
230+
}),
231+
async (input) => {
232+
const expandedPath = Filesystem.expanduser(input.path)
233+
const projectPath = path.resolve(expandedPath)
234+
235+
// Validate absolute path
236+
if (!path.isAbsolute(expandedPath)) {
237+
throw new CreateError({ message: "Path must be absolute" })
238+
}
239+
240+
// Create directory if it doesn't exist
241+
await fs.mkdir(projectPath, { recursive: true })
242+
243+
// Check if it's already a git repo with commits
244+
const gitDir = path.join(projectPath, ".git")
245+
const isGitRepo = await fs.access(gitDir).then(() => true).catch(() => false)
246+
247+
if (!isGitRepo) {
248+
// Initialize git and create empty initial commit (required for project ID which is the first commit hash)
249+
await $`git init`.cwd(projectPath).quiet()
250+
await $`git commit --allow-empty -m "Initial commit"`.cwd(projectPath).quiet()
251+
} else {
252+
// Check if there are any commits
253+
const hasCommits = await $`git rev-list -n 1 --all`.cwd(projectPath).quiet().nothrow().text()
254+
if (!hasCommits.trim()) {
255+
await $`git commit --allow-empty -m "Initial commit"`.cwd(projectPath).quiet()
256+
}
257+
}
258+
259+
// Register project using fromDirectory
260+
const project = await fromDirectory(projectPath)
261+
262+
// Set custom name if provided
263+
if (input.name) {
264+
await update({ projectID: project.id, name: input.name })
265+
return { ...project, name: input.name }
266+
}
267+
268+
return project
269+
},
270+
)
221271
}

packages/opencode/src/server/project.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,31 @@ export const ProjectRoute = new Hono()
5050
return c.json(Instance.project)
5151
},
5252
)
53+
.post(
54+
"/",
55+
describeRoute({
56+
summary: "Create project",
57+
description: "Create a new project directory and initialize it as a git repository.",
58+
operationId: "project.create",
59+
responses: {
60+
200: {
61+
description: "Created project information",
62+
content: {
63+
"application/json": {
64+
schema: resolver(Project.Info),
65+
},
66+
},
67+
},
68+
...errors(400),
69+
},
70+
}),
71+
validator("json", Project.create.schema),
72+
async (c) => {
73+
const body = c.req.valid("json")
74+
const project = await Project.create(body)
75+
return c.json(project)
76+
},
77+
)
5378
.patch(
5479
"/:projectID",
5580
describeRoute({

packages/opencode/src/server/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Command } from "../command"
2929
import { ProviderAuth } from "../provider/auth"
3030
import { Global } from "../global"
3131
import { ProjectRoute } from "./project"
32+
import { Project } from "../project/project"
3233
import { ToolRegistry } from "../tool/registry"
3334
import { zodToJsonSchema } from "zod-to-json-schema"
3435
import { SessionPrompt } from "../session/prompt"
@@ -70,6 +71,7 @@ export namespace Server {
7071
let status: ContentfulStatusCode
7172
if (err instanceof Storage.NotFoundError) status = 404
7273
else if (err instanceof Provider.ModelNotFoundError) status = 400
74+
else if (err instanceof Project.CreateError) status = 400
7375
else status = 500
7476
return c.json(err.toObject(), { status })
7577
}

packages/opencode/src/util/filesystem.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { realpathSync } from "fs"
22
import { exists } from "fs/promises"
3+
import { homedir } from "os"
34
import { dirname, join, relative } from "path"
45

56
export namespace Filesystem {
7+
export function expanduser(p: string): string {
8+
if (p === "~") return homedir()
9+
if (p.startsWith("~/")) return join(homedir(), p.slice(2))
10+
return p
11+
}
612
/**
713
* On Windows, normalize a path to its canonical casing using the filesystem.
814
* This is needed because Windows paths are case-insensitive but LSP servers

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ import type {
5050
PathGetResponses,
5151
PermissionRespondErrors,
5252
PermissionRespondResponses,
53+
ProjectCreateErrors,
54+
ProjectCreateResponses,
5355
ProjectCurrentResponses,
5456
ProjectListResponses,
5557
ProjectUpdateErrors,
@@ -228,6 +230,43 @@ export class Project extends HeyApiClient {
228230
})
229231
}
230232

233+
/**
234+
* Create project
235+
*
236+
* Create a new project directory and initialize it as a git repository.
237+
*/
238+
public create<ThrowOnError extends boolean = false>(
239+
parameters?: {
240+
directory?: string
241+
path?: string
242+
name?: string
243+
},
244+
options?: Options<never, ThrowOnError>,
245+
) {
246+
const params = buildClientParams(
247+
[parameters],
248+
[
249+
{
250+
args: [
251+
{ in: "query", key: "directory" },
252+
{ in: "body", key: "path" },
253+
{ in: "body", key: "name" },
254+
],
255+
},
256+
],
257+
)
258+
return (options?.client ?? this.client).post<ProjectCreateResponses, ProjectCreateErrors, ThrowOnError>({
259+
url: "/project",
260+
...options,
261+
...params,
262+
headers: {
263+
"Content-Type": "application/json",
264+
...options?.headers,
265+
...params.headers,
266+
},
267+
})
268+
}
269+
231270
/**
232271
* Get current project
233272
*

0 commit comments

Comments
 (0)