feat(app): add workspace startup script to projects

This commit is contained in:
Adam
2026-01-22 07:41:20 -06:00
parent 287511c9b1
commit 16fad51b5e
9 changed files with 1379 additions and 300 deletions

View File

@@ -25,6 +25,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
name: defaultName(), name: defaultName(),
color: props.project.icon?.color || "pink", color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.override || "", iconUrl: props.project.icon?.override || "",
startup: props.project.commands?.start ?? "",
saving: false, saving: false,
}) })
@@ -69,15 +70,18 @@ export function DialogEditProject(props: { project: LocalProject }) {
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
if (!props.project.id) return if (!props.project.id) return
setStore("saving", true) setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim() const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
await globalSDK.client.project.update({ await globalSDK.client.project.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, directory: props.project.worktree,
name, name,
icon: { color: store.color, override: store.iconUrl }, icon: { color: store.color, override: store.iconUrl },
commands: { start },
}) })
setStore("saving", false) setStore("saving", false)
dialog.close() dialog.close()
@@ -215,6 +219,17 @@ export function DialogEditProject(props: { project: LocalProject }) {
</div> </div>
</div> </div>
</Show> </Show>
<TextField
multiline
label={language.t("dialog.project.edit.worktree.startup")}
description={language.t("dialog.project.edit.worktree.startup.description")}
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
value={store.startup}
onChange={(v) => setStore("startup", v)}
spellcheck={false}
class="max-h-40 w-full font-mono text-xs no-scrollbar"
/>
</div> </div>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">

View File

@@ -257,6 +257,9 @@ export const dict = {
"dialog.project.edit.icon.recommended": "Recommended: 128x128px", "dialog.project.edit.icon.recommended": "Recommended: 128x128px",
"dialog.project.edit.color": "Color", "dialog.project.edit.color": "Color",
"dialog.project.edit.color.select": "Select {{color}} color", "dialog.project.edit.color.select": "Select {{color}} color",
"dialog.project.edit.worktree.startup": "Workspace startup script",
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
"context.breakdown.title": "Context Breakdown", "context.breakdown.title": "Context Breakdown",
"context.breakdown.note": 'Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.', "context.breakdown.note": 'Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.',

View File

@@ -29,6 +29,11 @@ export namespace Project {
color: z.string().optional(), color: z.string().optional(),
}) })
.optional(), .optional(),
commands: z
.object({
start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
})
.optional(),
time: z.object({ time: z.object({
created: z.number(), created: z.number(),
updated: z.number(), updated: z.number(),
@@ -287,6 +292,7 @@ export namespace Project {
projectID: z.string(), projectID: z.string(),
name: z.string().optional(), name: z.string().optional(),
icon: Info.shape.icon.optional(), icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
}), }),
async (input) => { async (input) => {
const result = await Storage.update<Info>(["project", input.projectID], (draft) => { const result = await Storage.update<Info>(["project", input.projectID], (draft) => {
@@ -299,6 +305,16 @@ export namespace Project {
if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
if (input.icon.color !== undefined) draft.icon.color = input.icon.color if (input.icon.color !== undefined) draft.icon.color = input.icon.color
} }
if (input.commands?.start !== undefined) {
const start = input.commands.start || undefined
draft.commands = {
...(draft.commands ?? {}),
}
draft.commands.start = start
if (!draft.commands.start) draft.commands = undefined
}
draft.time.updated = Date.now() draft.time.updated = Date.now()
}) })
GlobalBus.emit("event", { GlobalBus.emit("event", {

View File

@@ -90,7 +90,7 @@ export const ExperimentalRoutes = lazy(() =>
"/worktree", "/worktree",
describeRoute({ describeRoute({
summary: "Create worktree", summary: "Create worktree",
description: "Create a new git worktree for the current project.", description: "Create a new git worktree for the current project and run any configured startup scripts.",
operationId: "worktree.create", operationId: "worktree.create",
responses: { responses: {
200: { 200: {

View File

@@ -56,7 +56,7 @@ export const ProjectRoutes = lazy(() =>
"/:projectID", "/:projectID",
describeRoute({ describeRoute({
summary: "Update project", summary: "Update project",
description: "Update project properties such as name, icon and color.", description: "Update project properties such as name, icon, and commands.",
operationId: "project.update", operationId: "project.update",
responses: { responses: {
200: { 200: {

View File

@@ -6,6 +6,7 @@ import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global" import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Project } from "../project/project" import { Project } from "../project/project"
import { Storage } from "../storage/storage"
import { fn } from "../util/fn" import { fn } from "../util/fn"
import { Config } from "@/config/config" import { Config } from "@/config/config"
@@ -25,7 +26,10 @@ export namespace Worktree {
export const CreateInput = z export const CreateInput = z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
startCommand: z.string().optional(), startCommand: z
.string()
.optional()
.describe("Additional startup script to run after the project's start command"),
}) })
.meta({ .meta({
ref: "WorktreeCreateInput", ref: "WorktreeCreateInput",
@@ -238,12 +242,23 @@ export namespace Worktree {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" }) throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
} }
const cmd = input?.startCommand?.trim() const project = await Storage.read<Project.Info>(["project", Instance.project.id]).catch(() => Instance.project)
if (!cmd) return info const startup = project.commands?.start?.trim()
if (startup) {
const ran = await runStartCommand(info.directory, startup)
if (ran.exitCode !== 0) {
throw new StartCommandFailedError({
message: errorText(ran) || "Project start command failed",
})
}
}
const ran = await runStartCommand(info.directory, cmd) const extra = input?.startCommand?.trim()
if (ran.exitCode !== 0) { if (extra) {
throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" }) const ran = await runStartCommand(info.directory, extra)
if (ran.exitCode !== 0) {
throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" })
}
} }
return info return info

View File

@@ -293,7 +293,7 @@ export class Project extends HeyApiClient {
/** /**
* Update project * Update project
* *
* Update project properties such as name, icon and color. * Update project properties such as name, icon, and commands.
*/ */
public update<ThrowOnError extends boolean = false>( public update<ThrowOnError extends boolean = false>(
parameters: { parameters: {
@@ -305,6 +305,12 @@ export class Project extends HeyApiClient {
override?: string override?: string
color?: string color?: string
} }
commands?: {
/**
* Startup script to run when creating a new workspace (worktree)
*/
start?: string
}
}, },
options?: Options<never, ThrowOnError>, options?: Options<never, ThrowOnError>,
) { ) {
@@ -317,6 +323,7 @@ export class Project extends HeyApiClient {
{ in: "query", key: "directory" }, { in: "query", key: "directory" },
{ in: "body", key: "name" }, { in: "body", key: "name" },
{ in: "body", key: "icon" }, { in: "body", key: "icon" },
{ in: "body", key: "commands" },
], ],
}, },
], ],
@@ -718,7 +725,7 @@ export class Worktree extends HeyApiClient {
/** /**
* Create worktree * Create worktree
* *
* Create a new git worktree for the current project. * Create a new git worktree for the current project and run any configured startup scripts.
*/ */
public create<ThrowOnError extends boolean = false>( public create<ThrowOnError extends boolean = false>(
parameters?: { parameters?: {

View File

@@ -28,6 +28,12 @@ export type Project = {
override?: string override?: string
color?: string color?: string
} }
commands?: {
/**
* Startup script to run when creating a new workspace (worktree)
*/
start?: string
}
time: { time: {
created: number created: number
updated: number updated: number
@@ -1906,6 +1912,9 @@ export type Worktree = {
export type WorktreeCreateInput = { export type WorktreeCreateInput = {
name?: string name?: string
/**
* Additional startup script to run after the project's start command
*/
startCommand?: string startCommand?: string
} }
@@ -2233,6 +2242,12 @@ export type ProjectUpdateData = {
override?: string override?: string
color?: string color?: string
} }
commands?: {
/**
* Startup script to run when creating a new workspace (worktree)
*/
start?: string
}
} }
path: { path: {
projectID: string projectID: string

File diff suppressed because it is too large Load Diff