Răsfoiți Sursa

perf(app): faster workspace creation

Adam 2 luni în urmă
părinte
comite
c4d223eb99

+ 96 - 17
packages/app/src/components/prompt-input.tsx

@@ -48,6 +48,7 @@ import { useProviders } from "@/hooks/use-providers"
 import { useCommand } from "@/context/command"
 import { Persist, persisted } from "@/utils/persist"
 import { Identifier } from "@/utils/id"
+import { Worktree as WorktreeState } from "@/utils/worktree"
 import { SessionContextUsage } from "@/components/session-context-usage"
 import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
@@ -61,6 +62,13 @@ 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"]
 
+type PendingPrompt = {
+  abort: AbortController
+  cleanup: VoidFunction
+}
+
+const pending = new Map<string, PendingPrompt>()
+
 interface PromptInputProps {
   class?: string
   ref?: (el: HTMLDivElement) => void
@@ -846,12 +854,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setStore("popover", null)
   }
 
-  const abort = () =>
-    sdk.client.session
+  const abort = () => {
+    const sessionID = params.id
+    if (!sessionID) return Promise.resolve()
+    const queued = pending.get(sessionID)
+    if (queued) {
+      queued.abort.abort()
+      queued.cleanup()
+      pending.delete(sessionID)
+      return Promise.resolve()
+    }
+    return sdk.client.session
       .abort({
-        sessionID: params.id!,
+        sessionID,
       })
       .catch(() => {})
+  }
 
   const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
     const text = prompt
@@ -1111,6 +1129,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           })
           return
         }
+        WorktreeState.pending(createdWorktree.directory)
         sessionDirectory = createdWorktree.directory
       }
 
@@ -1409,20 +1428,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     clearInput()
     addOptimisticMessage()
 
-    client.session
-      .prompt({
-        sessionID: session.id,
-        agent,
-        model,
-        messageID,
-        parts: requestParts,
-        variant,
-      })
-      .catch((err) => {
-        showToast({
-          title: language.t("prompt.toast.promptSendFailed.title"),
-          description: errorMessage(err),
-        })
+    const waitForWorktree = async () => {
+      const worktree = WorktreeState.get(sessionDirectory)
+      if (!worktree || worktree.status !== "pending") return true
+
+      setSyncStore("session_status", session.id, { type: "busy" })
+
+      const controller = new AbortController()
+
+      const cleanup = () => {
+        setSyncStore("session_status", session.id, { type: "idle" })
         removeOptimisticMessage()
         for (const item of commentItems) {
           prompt.context.add({
@@ -1435,7 +1450,71 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           })
         }
         restoreInput()
+      }
+
+      pending.set(session.id, { abort: controller, cleanup })
+
+      const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+        if (controller.signal.aborted) {
+          resolve({ status: "failed", message: "aborted" })
+          return
+        }
+        controller.signal.addEventListener(
+          "abort",
+          () => {
+            resolve({ status: "failed", message: "aborted" })
+          },
+          { once: true },
+        )
+      })
+
+      const timeoutMs = 5 * 60 * 1000
+      const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
+        setTimeout(() => {
+          resolve({ status: "failed", message: "Workspace is still preparing" })
+        }, timeoutMs)
       })
+
+      const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout])
+      pending.delete(session.id)
+      if (controller.signal.aborted) return false
+      if (result.status === "failed") throw new Error(result.message)
+      return true
+    }
+
+    const send = async () => {
+      const ok = await waitForWorktree()
+      if (!ok) return
+      await client.session.prompt({
+        sessionID: session.id,
+        agent,
+        model,
+        messageID,
+        parts: requestParts,
+        variant,
+      })
+    }
+
+    void send().catch((err) => {
+      pending.delete(session.id)
+      setSyncStore("session_status", session.id, { type: "idle" })
+      showToast({
+        title: language.t("prompt.toast.promptSendFailed.title"),
+        description: errorMessage(err),
+      })
+      removeOptimisticMessage()
+      for (const item of commentItems) {
+        prompt.context.add({
+          type: "file",
+          path: item.path,
+          selection: item.selection,
+          comment: item.comment,
+          commentID: item.commentID,
+          preview: item.preview,
+        })
+      }
+      restoreInput()
+    })
   }
 
   return (

+ 40 - 10
packages/app/src/pages/layout.tsx

@@ -56,6 +56,7 @@ import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
 import { retry } from "@opencode-ai/util/retry"
 import { playSound, soundSrc } from "@/utils/sound"
+import { Worktree as WorktreeState } from "@/utils/worktree"
 
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -332,6 +333,18 @@ export default function Layout(props: ParentProps) {
     const cooldownMs = 5000
 
     const unsub = globalSDK.event.listen((e) => {
+      if (e.details?.type === "worktree.ready") {
+        setBusy(e.name, false)
+        WorktreeState.ready(e.name)
+        return
+      }
+
+      if (e.details?.type === "worktree.failed") {
+        setBusy(e.name, false)
+        WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
+        return
+      }
+
       if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
       const title =
         e.details.type === "permission.asked"
@@ -551,6 +564,7 @@ export default function Layout(props: ParentProps) {
     const project = currentProject()
     if (!project) return
 
+    const local = project.worktree
     const dirs = [project.worktree, ...(project.sandboxes ?? [])]
     const existing = store.workspaceOrder[project.worktree]
     if (!existing) {
@@ -558,9 +572,9 @@ export default function Layout(props: ParentProps) {
       return
     }
 
-    const keep = existing.filter((d) => dirs.includes(d))
-    const missing = dirs.filter((d) => !existing.includes(d))
-    const merged = [...keep, ...missing]
+    const keep = existing.filter((d) => d !== local && dirs.includes(d))
+    const missing = dirs.filter((d) => d !== local && !existing.includes(d))
+    const merged = [local, ...missing, ...keep]
 
     if (merged.length !== existing.length) {
       setStore("workspaceOrder", project.worktree, merged)
@@ -1434,17 +1448,22 @@ export default function Layout(props: ParentProps) {
 
   function workspaceIds(project: LocalProject | undefined) {
     if (!project) return []
-    const dirs = [project.worktree, ...(project.sandboxes ?? [])]
+    const local = project.worktree
+    const dirs = [local, ...(project.sandboxes ?? [])]
     const active = currentProject()
     const directory = active?.worktree === project.worktree && params.dir ? base64Decode(params.dir) : undefined
-    const next = directory && directory !== project.worktree && !dirs.includes(directory) ? [...dirs, directory] : dirs
+    const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
+    const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
 
     const existing = store.workspaceOrder[project.worktree]
-    if (!existing) return next
-
-    const keep = existing.filter((d) => next.includes(d))
-    const missing = next.filter((d) => !existing.includes(d))
-    return [...keep, ...missing]
+    if (!existing) return extra ? [...dirs, extra] : dirs
+
+    const keep = existing.filter((d) => d !== local && dirs.includes(d))
+    const missing = dirs.filter((d) => d !== local && !existing.includes(d))
+    const merged = [local, ...(pending && extra ? [extra] : []), ...missing, ...keep]
+    if (!extra) return merged
+    if (pending) return merged
+    return [...merged, extra]
   }
 
   function handleWorkspaceDragStart(event: unknown) {
@@ -2237,8 +2256,19 @@ export default function Layout(props: ParentProps) {
 
       if (!created?.directory) return
 
+      setBusy(created.directory, true)
+      WorktreeState.pending(created.directory)
+      setStore("workspaceExpanded", created.directory, true)
+      setStore("workspaceOrder", current.worktree, (prev) => {
+        const existing = prev ?? []
+        const local = current.worktree
+        const next = existing.filter((d) => d !== local && d !== created.directory)
+        return [local, created.directory, ...next]
+      })
+
       globalSync.child(created.directory)
       navigate(`/${base64Encode(created.directory)}/session`)
+      layout.mobileSidebar.hide()
     }
 
     command.register(() => [

+ 58 - 0
packages/app/src/utils/worktree.ts

@@ -0,0 +1,58 @@
+const normalize = (directory: string) => directory.replace(/[\\/]+$/, "")
+
+type State =
+  | {
+      status: "pending"
+    }
+  | {
+      status: "ready"
+    }
+  | {
+      status: "failed"
+      message: string
+    }
+
+const state = new Map<string, State>()
+const waiters = new Map<string, Array<(state: State) => void>>()
+
+export const Worktree = {
+  get(directory: string) {
+    return state.get(normalize(directory))
+  },
+  pending(directory: string) {
+    const key = normalize(directory)
+    const current = state.get(key)
+    if (current && current.status !== "pending") return
+    state.set(key, { status: "pending" })
+  },
+  ready(directory: string) {
+    const key = normalize(directory)
+    state.set(key, { status: "ready" })
+    const list = waiters.get(key)
+    if (!list) return
+    waiters.delete(key)
+    for (const fn of list) fn({ status: "ready" })
+  },
+  failed(directory: string, message: string) {
+    const key = normalize(directory)
+    state.set(key, { status: "failed", message })
+    const list = waiters.get(key)
+    if (!list) return
+    waiters.delete(key)
+    for (const fn of list) fn({ status: "failed", message })
+  },
+  wait(directory: string) {
+    const key = normalize(directory)
+    const current = state.get(key)
+    if (current && current.status !== "pending") return Promise.resolve(current)
+
+    return new Promise<State>((resolve) => {
+      const list = waiters.get(key)
+      if (!list) {
+        waiters.set(key, [resolve])
+        return
+      }
+      list.push(resolve)
+    })
+  },
+}

+ 16 - 0
packages/opencode/src/project/project.ts

@@ -338,6 +338,22 @@ export namespace Project {
     return valid
   }
 
+  export async function addSandbox(projectID: string, directory: string) {
+    const result = await Storage.update<Info>(["project", projectID], (draft) => {
+      const sandboxes = draft.sandboxes ?? []
+      if (!sandboxes.includes(directory)) sandboxes.push(directory)
+      draft.sandboxes = sandboxes
+      draft.time.updated = Date.now()
+    })
+    GlobalBus.emit("event", {
+      payload: {
+        type: Event.Updated.type,
+        properties: result,
+      },
+    })
+    return result
+  }
+
   export async function removeSandbox(projectID: string, directory: string) {
     const result = await Storage.update<Info>(["project", projectID], (draft) => {
       const sandboxes = draft.sandboxes ?? []

+ 102 - 17
packages/opencode/src/worktree/index.ts

@@ -5,12 +5,33 @@ import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { Global } from "../global"
 import { Instance } from "../project/instance"
+import { InstanceBootstrap } from "../project/bootstrap"
 import { Project } from "../project/project"
 import { Storage } from "../storage/storage"
 import { fn } from "../util/fn"
-import { Config } from "@/config/config"
+import { Log } from "../util/log"
+import { BusEvent } from "@/bus/bus-event"
+import { GlobalBus } from "@/bus/global"
 
 export namespace Worktree {
+  const log = Log.create({ service: "worktree" })
+
+  export const Event = {
+    Ready: BusEvent.define(
+      "worktree.ready",
+      z.object({
+        name: z.string(),
+        branch: z.string(),
+      }),
+    ),
+    Failed: BusEvent.define(
+      "worktree.failed",
+      z.object({
+        message: z.string(),
+      }),
+    ),
+  }
+
   export const Info = z
     .object({
       name: z.string(),
@@ -234,7 +255,7 @@ export namespace Worktree {
     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}`
+    const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
       .quiet()
       .nothrow()
       .cwd(Instance.worktree)
@@ -242,24 +263,88 @@ export namespace Worktree {
       throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
     }
 
-    const project = await Storage.read<Project.Info>(["project", Instance.project.id]).catch(() => Instance.project)
-    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",
-        })
-      }
-    }
+    await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
 
+    const projectID = Instance.project.id
     const extra = input?.startCommand?.trim()
-    if (extra) {
-      const ran = await runStartCommand(info.directory, extra)
-      if (ran.exitCode !== 0) {
-        throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" })
+    setTimeout(() => {
+      const start = async () => {
+        const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
+        if (populated.exitCode !== 0) {
+          const message = errorText(populated) || "Failed to populate worktree"
+          log.error("worktree checkout failed", { directory: info.directory, message })
+          GlobalBus.emit("event", {
+            directory: info.directory,
+            payload: {
+              type: Event.Failed.type,
+              properties: {
+                message,
+              },
+            },
+          })
+          return
+        }
+
+        const booted = await Instance.provide({
+          directory: info.directory,
+          init: InstanceBootstrap,
+          fn: () => undefined,
+        })
+          .then(() => true)
+          .catch((error) => {
+            const message = error instanceof Error ? error.message : String(error)
+            log.error("worktree bootstrap failed", { directory: info.directory, message })
+            GlobalBus.emit("event", {
+              directory: info.directory,
+              payload: {
+                type: Event.Failed.type,
+                properties: {
+                  message,
+                },
+              },
+            })
+            return false
+          })
+        if (!booted) return
+
+        GlobalBus.emit("event", {
+          directory: info.directory,
+          payload: {
+            type: Event.Ready.type,
+            properties: {
+              name: info.name,
+              branch: info.branch,
+            },
+          },
+        })
+
+        const project = await Storage.read<Project.Info>(["project", projectID]).catch(() => undefined)
+        const startup = project?.commands?.start?.trim() ?? ""
+
+        const run = async (cmd: string, kind: "project" | "worktree") => {
+          const ran = await runStartCommand(info.directory, cmd)
+          if (ran.exitCode === 0) return true
+          log.error("worktree start command failed", {
+            kind,
+            directory: info.directory,
+            message: errorText(ran),
+          })
+          return false
+        }
+
+        if (startup) {
+          const ok = await run(startup, "project")
+          if (!ok) return
+        }
+        if (extra) {
+          await run(extra, "worktree")
+        }
       }
-    }
+
+      void start().catch((error) => {
+        log.error("worktree start task failed", { directory: info.directory, error })
+      })
+    }, 0)
 
     return info
   })

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

@@ -866,6 +866,21 @@ export type EventPtyDeleted = {
   }
 }
 
+export type EventWorktreeReady = {
+  type: "worktree.ready"
+  properties: {
+    name: string
+    branch: string
+  }
+}
+
+export type EventWorktreeFailed = {
+  type: "worktree.failed"
+  properties: {
+    message: string
+  }
+}
+
 export type Event =
   | EventInstallationUpdated
   | EventInstallationUpdateAvailable
@@ -907,6 +922,8 @@ export type Event =
   | EventPtyUpdated
   | EventPtyExited
   | EventPtyDeleted
+  | EventWorktreeReady
+  | EventWorktreeFailed
 
 export type GlobalEvent = {
   directory: string