Sfoglia il codice sorgente

feat: add managed git worktrees (#6674)

Adam 1 mese fa
parent
commit
052de3c556

+ 110 - 12
packages/app/src/components/prompt-input.tsx

@@ -12,6 +12,7 @@ import {
   usePrompt,
   ImageAttachmentPart,
   AgentPart,
+  FileAttachmentPart,
 } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
@@ -33,6 +34,12 @@ import { persisted } from "@/utils/persist"
 import { Identifier } from "@/utils/id"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { usePermission } from "@/context/permission"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
+import { Binary } from "@opencode-ai/util/binary"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode } from "@opencode-ai/util/encode"
 
 const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
 const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -40,6 +47,8 @@ const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
 interface PromptInputProps {
   class?: string
   ref?: (el: HTMLDivElement) => void
+  newSessionWorktree?: string
+  onNewSessionWorktreeReset?: () => void
 }
 
 const PLACEHOLDERS = [
@@ -83,6 +92,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const navigate = useNavigate()
   const sdk = useSDK()
   const sync = useSync()
+  const globalSync = useGlobalSync()
+  const platform = usePlatform()
   const local = useLocal()
   const files = useFile()
   const prompt = usePrompt()
@@ -1164,18 +1175,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setStore("historyIndex", -1)
     setStore("savedPrompt", null)
 
+    const projectDirectory = sdk.directory
+    const isNewSession = !params.id
+    const worktreeSelection = props.newSessionWorktree ?? "main"
+
+    let sessionDirectory = projectDirectory
+    let client = sdk.client
+
+    if (isNewSession) {
+      if (worktreeSelection === "create") {
+        const createdWorktree = await client.worktree
+          .create({ directory: projectDirectory })
+          .then((x) => x.data)
+          .catch((err) => {
+            showToast({
+              title: "Failed to create worktree",
+              description: err?.data?.message ?? (err instanceof Error ? err.message : "Request failed"),
+            })
+            return undefined
+          })
+
+        if (!createdWorktree?.directory) {
+          showToast({
+            title: "Failed to create worktree",
+            description: "Request failed",
+          })
+          return
+        }
+        sessionDirectory = createdWorktree.directory
+      } else if (worktreeSelection !== "main") {
+        sessionDirectory = worktreeSelection
+      }
+
+      if (sessionDirectory !== projectDirectory) {
+        client = createOpencodeClient({
+          baseUrl: sdk.url,
+          fetch: platform.fetch,
+          directory: sessionDirectory,
+          throwOnError: true,
+        })
+      }
+    }
+
+    if (isNewSession) {
+      if (sessionDirectory !== projectDirectory) {
+        globalSync.child(sessionDirectory)
+      }
+      props.onNewSessionWorktreeReset?.()
+    }
+
     let existing = info()
-    if (!existing) {
-      const created = await sdk.client.session.create()
+    if (!existing && isNewSession) {
+      const created = await client.session.create()
       existing = created.data ?? undefined
-      if (existing) navigate(existing.id)
+      if (existing) navigate(`/${base64Encode(sessionDirectory)}/session/${existing.id}`)
     }
     if (!existing) return
 
-    const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
-    const fileAttachments = currentPrompt.filter(
-      (part) => part.type === "file",
-    ) as import("@/context/prompt").FileAttachmentPart[]
+    const toAbsolutePath = (path: string) =>
+      path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
+    const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
     const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
 
     const fileAttachmentParts = fileAttachments.map((attachment) => {
@@ -1275,7 +1334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const variant = local.model.variant.current()
 
     if (isShellMode) {
-      sdk.client.session
+      client.session
         .shell({
           sessionID: existing.id,
           agent,
@@ -1293,7 +1352,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const commandName = cmdName.slice(1)
       const customCommand = sync.data.command.find((c) => c.name === commandName)
       if (customCommand) {
-        sdk.client.session
+        client.session
           .command({
             sessionID: existing.id,
             command: commandName,
@@ -1328,15 +1387,54 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       messageID,
     }))
 
-    sync.session.addOptimisticMessage({
+    const addOptimisticMessage = (input: {
+      sessionID: string
+      messageID: string
+      parts: Part[]
+      agent: string
+      model: { providerID: string; modelID: string }
+    }) => {
+      if (sessionDirectory === projectDirectory) {
+        sync.session.addOptimisticMessage(input)
+        return
+      }
+
+      const [, setStore] = globalSync.child(sessionDirectory)
+      const message: Message = {
+        id: input.messageID,
+        sessionID: input.sessionID,
+        role: "user",
+        time: { created: Date.now() },
+        agent: input.agent,
+        model: input.model,
+      }
+
+      setStore(
+        produce((draft) => {
+          const messages = draft.message[input.sessionID]
+          if (!messages) {
+            draft.message[input.sessionID] = [message]
+          } else {
+            const result = Binary.search(messages, input.messageID, (m) => m.id)
+            messages.splice(result.index, 0, message)
+          }
+          draft.part[input.messageID] = input.parts
+            .filter((p) => !!p?.id)
+            .slice()
+            .sort((a, b) => a.id.localeCompare(b.id))
+        }),
+      )
+    }
+
+    addOptimisticMessage({
       sessionID: existing.id,
       messageID,
-      parts: optimisticParts,
+      parts: optimisticParts as unknown as Part[],
       agent,
       model,
     })
 
-    sdk.client.session
+    client.session
       .prompt({
         sessionID: existing.id,
         agent,

+ 7 - 10
packages/app/src/components/session/session-header.tsx

@@ -1,4 +1,4 @@
-import { createMemo, createResource, Show } from "solid-js"
+import { createEffect, createMemo, createResource, Show } from "solid-js"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout } from "@/context/layout"
 import { useCommand } from "@/context/command"
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useSync } from "@/context/sync"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { getFilename } from "@opencode-ai/util/path"
-import { base64Encode } from "@opencode-ai/util/encode"
+import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
 import { iife } from "@opencode-ai/util/iife"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -31,10 +31,11 @@ export function SessionHeader() {
   const dialog = useDialog()
   const sync = useSync()
 
+  const projectDirectory = createMemo(() => base64Decode(params.dir ?? ""))
+
   const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
   const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
   const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
-  const branch = createMemo(() => sync.data.vcs?.branch)
 
   function navigateToProject(directory: string) {
     navigate(`/${base64Encode(directory)}`)
@@ -60,12 +61,8 @@ export function SessionHeader() {
             <div class="hidden xl:flex items-center gap-2">
               <Select
                 options={layout.projects.list().map((project) => project.worktree)}
-                current={sync.directory}
-                label={(x) => {
-                  const name = getFilename(x)
-                  const b = x === sync.directory ? branch() : undefined
-                  return b ? `${name}:${b}` : name
-                }}
+                current={sync.project?.worktree ?? projectDirectory()}
+                label={(x) => getFilename(x)}
                 onSelect={(x) => (x ? navigateToProject(x) : undefined)}
                 class="text-14-regular text-text-base"
                 variant="ghost"
@@ -191,7 +188,7 @@ export function SessionHeader() {
                     let shareURL = session.share?.url
                     if (!shareURL) {
                       shareURL = await globalSDK.client.session
-                        .share({ sessionID: session.id, directory: sync.directory })
+                        .share({ sessionID: session.id, directory: projectDirectory() })
                         .then((r) => r.data?.share?.url)
                         .catch((e) => {
                           console.error("Failed to share session", e)

+ 46 - 2
packages/app/src/components/session/session-new-view.tsx

@@ -1,12 +1,41 @@
-import { Show } from "solid-js"
+import { Show, createMemo } from "solid-js"
 import { DateTime } from "luxon"
 import { useSync } from "@/context/sync"
 import { Icon } from "@opencode-ai/ui/icon"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { Select } from "@opencode-ai/ui/select"
 
-export function NewSessionView() {
+const MAIN_WORKTREE = "main"
+const CREATE_WORKTREE = "create"
+
+interface NewSessionViewProps {
+  worktree: string
+  onWorktreeChange: (value: string) => void
+}
+
+export function NewSessionView(props: NewSessionViewProps) {
   const sync = useSync()
 
+  const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
+  const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
+  const current = createMemo(() => {
+    const selection = props.worktree
+    if (options().includes(selection)) return selection
+    return MAIN_WORKTREE
+  })
+
+  const label = (value: string) => {
+    if (value === MAIN_WORKTREE) {
+      const branch = sync.data.vcs?.branch
+      if (branch) return `Current branch (${branch})`
+      return "Main branch"
+    }
+
+    if (value === CREATE_WORKTREE) return "Create new worktree"
+
+    return getFilename(value)
+  }
+
   return (
     <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
       <div class="text-20-medium text-text-weaker">New session</div>
@@ -17,6 +46,21 @@ export function NewSessionView() {
           <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
         </div>
       </div>
+      <div class="flex justify-center items-center gap-1">
+        <Icon name="branch" size="small" />
+        <Select
+          options={options()}
+          current={current()}
+          value={(x) => x}
+          label={label}
+          onSelect={(value) => {
+            props.onWorktreeChange(value ?? MAIN_WORKTREE)
+          }}
+          size="normal"
+          variant="ghost"
+          class="text-12-medium"
+        />
+      </div>
       <Show when={sync.project}>
         {(project) => (
           <div class="flex justify-center items-center gap-3">

+ 95 - 75
packages/app/src/pages/layout.tsx

@@ -172,9 +172,9 @@ export default function Layout(props: ParentProps) {
       const perm = e.details.properties
       if (permission.autoResponds(perm)) return
 
-      const sessionKey = `${directory}:${perm.sessionID}`
       const [store] = globalSync.child(directory)
       const session = store.session.find((s) => s.id === perm.sessionID)
+      const sessionKey = `${directory}:${perm.sessionID}`
 
       const sessionTitle = session?.title ?? "New session"
       const projectName = getFilename(directory)
@@ -665,14 +665,13 @@ export default function Layout(props: ParentProps) {
       <>
         <div
           data-session-id={props.session.id}
-          class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
+          class="group/session relative w-full rounded-md cursor-default transition-colors
                  hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
-          style={{ "padding-left": "16px" }}
         >
           <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
             <A
               href={`${props.slug}/session/${props.session.id}`}
-              class="flex flex-col min-w-0 text-left w-full focus:outline-none"
+              class="flex flex-col min-w-0 text-left w-full focus:outline-none pl-4 pr-2 py-1"
             >
               <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
                 <span
@@ -740,10 +739,17 @@ export default function Layout(props: ParentProps) {
   const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.project.worktree)
     const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
-    const slug = createMemo(() => base64Encode(props.project.worktree))
+    const defaultWorktree = createMemo(() => base64Encode(props.project.worktree))
     const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
     const [store, setProjectStore] = globalSync.child(props.project.worktree)
-    const sessions = createMemo(() => store.session.toSorted(sortSessions))
+    const stores = createMemo(() =>
+      [props.project.worktree, ...(props.project.sandboxes ?? [])].map((dir) => globalSync.child(dir)[0]),
+    )
+    const sessions = createMemo(() =>
+      stores()
+        .flatMap((store) => store.session.filter((session) => session.directory === store.path.directory))
+        .toSorted(sortSessions),
+    )
     const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
     const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
     const loadMoreSessions = async () => {
@@ -799,7 +805,7 @@ export default function Layout(props: ParentProps) {
                     </DropdownMenu.Portal>
                   </DropdownMenu>
                   <TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
-                    <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
+                    <IconButton as={A} href={`${defaultWorktree()}/session`} icon="plus-small" variant="ghost" />
                   </TooltipKeybind>
                 </div>
               </Button>
@@ -807,7 +813,12 @@ export default function Layout(props: ParentProps) {
                 <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
                   <For each={rootSessions()}>
                     {(session) => (
-                      <SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
+                      <SessionItem
+                        session={session}
+                        slug={base64Encode(session.directory)}
+                        project={props.project}
+                        mobile={props.mobile}
+                      />
                     )}
                   </For>
                   <Show when={rootSessions().length === 0}>
@@ -819,7 +830,7 @@ export default function Layout(props: ParentProps) {
                         <div class="flex-1 min-w-0">
                           <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
                             <A
-                              href={`${slug()}/session`}
+                              href={`${defaultWorktree()}/session`}
                               class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
                             >
                               <div class="flex items-center self-stretch gap-6 justify-between">
@@ -875,76 +886,85 @@ export default function Layout(props: ParentProps) {
   const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
     const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
     return (
-      <>
-        <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
+      <div class="flex flex-col self-stretch h-full items-center justify-between overflow-hidden min-h-0">
+        <div class="flex flex-col items-start self-stretch gap-4 min-h-0">
           <Show when={!sidebarProps.mobile}>
-            <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
-              <Mark class="shrink-0" />
-            </A>
-          </Show>
-          <Show when={!sidebarProps.mobile}>
-            <TooltipKeybind
-              class="shrink-0"
-              placement="right"
-              title="Toggle sidebar"
-              keybind={command.keybind("sidebar.toggle")}
-              inactive={expanded()}
-            >
-              <Button
-                variant="ghost"
-                size="large"
-                class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
-                onClick={layout.sidebar.toggle}
-              >
-                <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                  <Icon
-                    name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
-                    size="small"
-                    class="group-hover/sidebar-toggle:hidden"
-                  />
-                  <Icon
-                    name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
-                    size="small"
-                    class="hidden group-hover/sidebar-toggle:inline-block"
-                  />
-                  <Icon
-                    name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
-                    size="small"
-                    class="hidden group-active/sidebar-toggle:inline-block"
-                  />
-                </div>
-                <Show when={layout.sidebar.opened()}>
-                  <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
-                    Toggle sidebar
-                  </div>
-                </Show>
-              </Button>
-            </TooltipKeybind>
-          </Show>
-          <DragDropProvider
-            onDragStart={handleDragStart}
-            onDragEnd={handleDragEnd}
-            onDragOver={handleDragOver}
-            collisionDetector={closestCenter}
-          >
-            <DragDropSensors />
-            <ConstrainDragXAxis />
             <div
-              ref={(el) => {
-                if (!sidebarProps.mobile) scrollContainerRef = el
+              classList={{
+                "border-b border-border-weak-base w-full h-12 ml-px flex items-center pl-1.75 shrink-0": true,
+                "justify-start": expanded(),
               }}
-              class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
             >
-              <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
-                <For each={layout.projects.list()}>
-                  {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
-                </For>
-              </SortableProvider>
+              <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2 w-full" data-tauri-drag-region>
+                <Mark class="shrink-0" />
+              </A>
             </div>
-            <DragOverlay>
-              <ProjectDragOverlay />
-            </DragOverlay>
-          </DragDropProvider>
+          </Show>
+          <div class="flex flex-col items-start self-stretch gap-4 px-2 overflow-hidden min-h-0">
+            <Show when={!sidebarProps.mobile}>
+              <TooltipKeybind
+                class="shrink-0"
+                placement="right"
+                title="Toggle sidebar"
+                keybind={command.keybind("sidebar.toggle")}
+                inactive={expanded()}
+              >
+                <Button
+                  variant="ghost"
+                  size="large"
+                  class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
+                  onClick={layout.sidebar.toggle}
+                >
+                  <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                    <Icon
+                      name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
+                      size="small"
+                      class="group-hover/sidebar-toggle:hidden"
+                    />
+                    <Icon
+                      name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
+                      size="small"
+                      class="hidden group-hover/sidebar-toggle:inline-block"
+                    />
+                    <Icon
+                      name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
+                      size="small"
+                      class="hidden group-active/sidebar-toggle:inline-block"
+                    />
+                  </div>
+                  <Show when={layout.sidebar.opened()}>
+                    <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
+                      Toggle sidebar
+                    </div>
+                  </Show>
+                </Button>
+              </TooltipKeybind>
+            </Show>
+            <DragDropProvider
+              onDragStart={handleDragStart}
+              onDragEnd={handleDragEnd}
+              onDragOver={handleDragOver}
+              collisionDetector={closestCenter}
+            >
+              <DragDropSensors />
+              <ConstrainDragXAxis />
+              <div
+                ref={(el) => {
+                  if (!sidebarProps.mobile) scrollContainerRef = el
+                }}
+                class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
+              >
+                <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
+                  <For each={layout.projects.list()}>
+                    {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
+                  </For>
+                </SortableProvider>
+              </div>
+              <DragOverlay>
+                <ProjectDragOverlay />
+              </DragOverlay>
+            </DragDropProvider>
+          </div>
         </div>
         <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
           <Switch>
@@ -1017,7 +1037,7 @@ export default function Layout(props: ParentProps) {
             </Button>
           </Tooltip>
         </div>
-      </>
+      </div>
     )
   }
 

+ 13 - 15
packages/app/src/pages/session.tsx

@@ -218,20 +218,12 @@ export default function Page() {
     return sync.data.message[id] !== undefined
   })
   const emptyUserMessages: UserMessage[] = []
-  const userMessages = createMemo(
-    () => messages().filter((m) => m.role === "user") as UserMessage[],
-    emptyUserMessages,
-    { equals: same },
-  )
-  const visibleUserMessages = createMemo(
-    () => {
-      const revert = revertMessageID()
-      if (!revert) return userMessages()
-      return userMessages().filter((m) => m.id < revert)
-    },
-    emptyUserMessages,
-    { equals: same },
-  )
+  const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
+  const visibleUserMessages = createMemo(() => {
+    const revert = revertMessageID()
+    if (!revert) return userMessages()
+    return userMessages().filter((m) => m.id < revert)
+  }, emptyUserMessages)
   const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
 
   createEffect(
@@ -256,6 +248,7 @@ export default function Page() {
     mobileTab: "session" as "session" | "review",
     ignoreScrollSpy: false,
     initialScrollDone: !params.id,
+    newSessionWorktree: "main",
   })
 
   const activeMessage = createMemo(() => {
@@ -1017,7 +1010,10 @@ export default function Page() {
                 </Show>
               </Match>
               <Match when={true}>
-                <NewSessionView />
+                <NewSessionView
+                  worktree={store.newSessionWorktree}
+                  onWorktreeChange={(value) => setStore("newSessionWorktree", value)}
+                />
               </Match>
             </Switch>
           </div>
@@ -1034,6 +1030,8 @@ export default function Page() {
                 ref={(el) => {
                   inputRef = el
                 }}
+                newSessionWorktree={store.newSessionWorktree}
+                onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
               />
             </div>
           </div>

+ 36 - 2
packages/opencode/src/project/project.ts

@@ -1,4 +1,5 @@
 import z from "zod"
+import fs from "fs/promises"
 import { Filesystem } from "../util/filesystem"
 import path from "path"
 import { $ } from "bun"
@@ -31,6 +32,7 @@ export namespace Project {
         updated: z.number(),
         initialized: z.number().optional(),
       }),
+      sandboxes: z.array(z.string()).optional(),
     })
     .meta({
       ref: "Project",
@@ -76,12 +78,16 @@ export namespace Project {
             worktree,
             vcs: "git",
           }
-        worktree = await $`git rev-parse --show-toplevel`
+        worktree = await $`git rev-parse --git-common-dir`
           .quiet()
           .nothrow()
           .cwd(worktree)
           .text()
-          .then((x) => path.resolve(worktree, x.trim()))
+          .then((x) => {
+            const dirname = path.dirname(x.trim())
+            if (dirname === ".") return worktree
+            return dirname
+          })
         return { id, worktree, vcs: "git" }
       }
 
@@ -218,4 +224,32 @@ export namespace Project {
       return result
     },
   )
+
+  export async function addSandbox(projectID: string, directory: string) {
+    const result = await Storage.update<Info>(["project", projectID], (draft) => {
+      if (!draft.sandboxes) draft.sandboxes = []
+      if (!draft.sandboxes.includes(directory)) {
+        draft.sandboxes.push(directory)
+      }
+      draft.time.updated = Date.now()
+    })
+    GlobalBus.emit("event", {
+      payload: {
+        type: Event.Updated.type,
+        properties: result,
+      },
+    })
+    return result
+  }
+
+  export async function sandboxes(projectID: string) {
+    const project = await Storage.read<Info>(["project", projectID]).catch(() => undefined)
+    if (!project?.sandboxes) return []
+    const valid: string[] = []
+    for (const dir of project.sandboxes) {
+      const stat = await fs.stat(dir).catch(() => undefined)
+      if (stat?.isDirectory()) valid.push(dir)
+    }
+    return valid
+  }
 }

+ 50 - 0
packages/opencode/src/server/server.ts

@@ -21,6 +21,7 @@ import { Format } from "../format"
 import { MessageV2 } from "../session/message-v2"
 import { TuiRoute } from "./tui"
 import { Instance } from "../project/instance"
+import { Project } from "../project/project"
 import { Vcs } from "../project/vcs"
 import { Agent } from "../agent/agent"
 import { Auth } from "../auth"
@@ -49,6 +50,7 @@ import { Pty } from "@/pty"
 import { PermissionNext } from "@/permission/next"
 import { Installation } from "@/installation"
 import { MDNS } from "./mdns"
+import { Worktree } from "../worktree"
 
 // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -79,6 +81,7 @@ export namespace Server {
           let status: ContentfulStatusCode
           if (err instanceof Storage.NotFoundError) status = 404
           else if (err instanceof Provider.ModelNotFoundError) status = 400
+          else if (err.name.startsWith("Worktree")) status = 400
           else status = 500
           return c.json(err.toObject(), { status })
         }
@@ -610,6 +613,53 @@ export namespace Server {
           })
         },
       )
+      .post(
+        "/experimental/worktree",
+        describeRoute({
+          summary: "Create worktree",
+          description: "Create a new git worktree for the current project.",
+          operationId: "worktree.create",
+          responses: {
+            200: {
+              description: "Worktree created",
+              content: {
+                "application/json": {
+                  schema: resolver(Worktree.Info),
+                },
+              },
+            },
+            ...errors(400),
+          },
+        }),
+        validator("json", Worktree.create.schema),
+        async (c) => {
+          const body = c.req.valid("json")
+          const worktree = await Worktree.create(body)
+          return c.json(worktree)
+        },
+      )
+      .get(
+        "/experimental/worktree",
+        describeRoute({
+          summary: "List worktrees",
+          description: "List all sandbox worktrees for the current project.",
+          operationId: "worktree.list",
+          responses: {
+            200: {
+              description: "List of worktree directories",
+              content: {
+                "application/json": {
+                  schema: resolver(z.array(z.string())),
+                },
+              },
+            },
+          },
+        }),
+        async (c) => {
+          const sandboxes = await Project.sandboxes(Instance.project.id)
+          return c.json(sandboxes)
+        },
+      )
       .get(
         "/vcs",
         describeRoute({

+ 215 - 0
packages/opencode/src/worktree/index.ts

@@ -0,0 +1,215 @@
+import { $ } from "bun"
+import fs from "fs/promises"
+import path from "path"
+import z from "zod"
+import { NamedError } from "@opencode-ai/util/error"
+import { Global } from "../global"
+import { Instance } from "../project/instance"
+import { Project } from "../project/project"
+import { fn } from "../util/fn"
+
+export namespace Worktree {
+  export const Info = z
+    .object({
+      name: z.string(),
+      branch: z.string(),
+      directory: z.string(),
+    })
+    .meta({
+      ref: "Worktree",
+    })
+
+  export type Info = z.infer<typeof Info>
+
+  export const CreateInput = z
+    .object({
+      name: z.string().optional(),
+      startCommand: z.string().optional(),
+    })
+    .meta({
+      ref: "WorktreeCreateInput",
+    })
+
+  export type CreateInput = z.infer<typeof CreateInput>
+
+  export const NotGitError = NamedError.create(
+    "WorktreeNotGitError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+
+  export const NameGenerationFailedError = NamedError.create(
+    "WorktreeNameGenerationFailedError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+
+  export const CreateFailedError = NamedError.create(
+    "WorktreeCreateFailedError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+
+  export const StartCommandFailedError = NamedError.create(
+    "WorktreeStartCommandFailedError",
+    z.object({
+      message: z.string(),
+    }),
+  )
+
+  const ADJECTIVES = [
+    "brave",
+    "calm",
+    "clever",
+    "cosmic",
+    "crisp",
+    "curious",
+    "eager",
+    "gentle",
+    "glowing",
+    "happy",
+    "hidden",
+    "jolly",
+    "kind",
+    "lucky",
+    "mighty",
+    "misty",
+    "neon",
+    "nimble",
+    "playful",
+    "proud",
+    "quick",
+    "quiet",
+    "shiny",
+    "silent",
+    "stellar",
+    "sunny",
+    "swift",
+    "tidy",
+    "witty",
+  ] as const
+
+  const NOUNS = [
+    "cabin",
+    "cactus",
+    "canyon",
+    "circuit",
+    "comet",
+    "eagle",
+    "engine",
+    "falcon",
+    "forest",
+    "garden",
+    "harbor",
+    "island",
+    "knight",
+    "lagoon",
+    "meadow",
+    "moon",
+    "mountain",
+    "nebula",
+    "orchid",
+    "otter",
+    "panda",
+    "pixel",
+    "planet",
+    "river",
+    "rocket",
+    "sailor",
+    "squid",
+    "star",
+    "tiger",
+    "wizard",
+    "wolf",
+  ] as const
+
+  function pick<const T extends readonly string[]>(list: T) {
+    return list[Math.floor(Math.random() * list.length)]
+  }
+
+  function slug(input: string) {
+    return input
+      .trim()
+      .toLowerCase()
+      .replace(/[^a-z0-9]+/g, "-")
+      .replace(/^-+/, "")
+      .replace(/-+$/, "")
+  }
+
+  function randomName() {
+    return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
+  }
+
+  async function exists(target: string) {
+    return fs
+      .stat(target)
+      .then(() => true)
+      .catch(() => false)
+  }
+
+  function outputText(input: Uint8Array | undefined) {
+    if (!input?.length) return ""
+    return new TextDecoder().decode(input).trim()
+  }
+
+  function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
+    return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
+  }
+
+  async function candidate(root: string, base?: string) {
+    for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
+      const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
+      const branch = `opencode/${name}`
+      const directory = path.join(root, name)
+
+      if (await exists(directory)) continue
+
+      const ref = `refs/heads/${branch}`
+      const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
+      if (branchCheck.exitCode === 0) continue
+
+      return Info.parse({ name, branch, directory })
+    }
+
+    throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
+  }
+
+  async function runStartCommand(directory: string, cmd: string) {
+    if (process.platform === "win32") {
+      return $`cmd /c ${cmd}`.nothrow().cwd(directory)
+    }
+    return $`bash -lc ${cmd}`.nothrow().cwd(directory)
+  }
+
+  export const create = fn(CreateInput.optional(), async (input) => {
+    if (Instance.project.vcs !== "git") {
+      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+    }
+
+    const root = path.join(Global.Path.data, "worktree", Instance.project.id)
+    await fs.mkdir(root, { recursive: true })
+
+    const base = input?.name ? slug(input.name) : ""
+    const info = await candidate(root, base || undefined)
+
+    const created = await $`git worktree add -b ${info.branch} ${info.directory}`.nothrow().cwd(Instance.worktree)
+    if (created.exitCode !== 0) {
+      throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
+    }
+
+    await Project.addSandbox(Instance.project.id, info.directory)
+
+    const cmd = input?.startCommand?.trim()
+    if (!cmd) return info
+
+    const ran = await runStartCommand(info.directory, cmd)
+    if (ran.exitCode !== 0) {
+      throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" })
+    }
+
+    return info
+  })
+}

+ 62 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -150,6 +150,10 @@ import type {
   TuiShowToastResponses,
   TuiSubmitPromptResponses,
   VcsGetResponses,
+  WorktreeCreateErrors,
+  WorktreeCreateInput,
+  WorktreeCreateResponses,
+  WorktreeListResponses,
 } from "./types.gen.js"
 
 export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<
@@ -683,6 +687,62 @@ export class Path extends HeyApiClient {
   }
 }
 
+export class Worktree extends HeyApiClient {
+  /**
+   * List worktrees
+   *
+   * List all sandbox worktrees for the current project.
+   */
+  public list<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+    return (options?.client ?? this.client).get<WorktreeListResponses, unknown, ThrowOnError>({
+      url: "/experimental/worktree",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Create worktree
+   *
+   * Create a new git worktree for the current project.
+   */
+  public create<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      worktreeCreateInput?: WorktreeCreateInput
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { key: "worktreeCreateInput", map: "body" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).post<WorktreeCreateResponses, WorktreeCreateErrors, ThrowOnError>({
+      url: "/experimental/worktree",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+}
+
 export class Vcs extends HeyApiClient {
   /**
    * Get VCS info
@@ -2789,6 +2849,8 @@ export class OpencodeClient extends HeyApiClient {
 
   path = new Path({ client: this.client })
 
+  worktree = new Worktree({ client: this.client })
+
   vcs = new Vcs({ client: this.client })
 
   session = new Session({ client: this.client })

+ 57 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -32,6 +32,7 @@ export type Project = {
     updated: number
     initialized?: number
   }
+  sandboxes?: Array<string>
 }
 
 export type EventProjectUpdated = {
@@ -1686,6 +1687,17 @@ export type Path = {
   directory: string
 }
 
+export type Worktree = {
+  name: string
+  branch: string
+  directory: string
+}
+
+export type WorktreeCreateInput = {
+  name?: string
+  startCommand?: string
+}
+
 export type VcsInfo = {
   branch: string
 }
@@ -2408,6 +2420,51 @@ export type PathGetResponses = {
 
 export type PathGetResponse = PathGetResponses[keyof PathGetResponses]
 
+export type WorktreeListData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/experimental/worktree"
+}
+
+export type WorktreeListResponses = {
+  /**
+   * List of worktree directories
+   */
+  200: Array<string>
+}
+
+export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses]
+
+export type WorktreeCreateData = {
+  body?: WorktreeCreateInput
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/experimental/worktree"
+}
+
+export type WorktreeCreateErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors]
+
+export type WorktreeCreateResponses = {
+  /**
+   * Worktree created
+   */
+  200: Worktree
+}
+
+export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses]
+
 export type VcsGetData = {
   body?: never
   path?: never

File diff suppressed because it is too large
+ 0 - 0
packages/ui/src/components/icon.tsx


Some files were not shown because too many files changed in this diff