Bladeren bron

show current git branch in tui (#4765)

Co-authored-by: GitHub Action <[email protected]>
Aiden Cline 2 maanden geleden
bovenliggende
commit
09bc8d9ca4

+ 4 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -478,7 +478,10 @@ function App() {
             <text fg={theme.textMuted}>v{Installation.VERSION}</text>
           </box>
           <box paddingLeft={1} paddingRight={1}>
-            <text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
+            <text fg={theme.textMuted}>
+              {process.cwd().replace(Global.Path.home, "~")}
+              {sync.data.vcs?.vcs?.branch ? `:${sync.data.vcs.vcs.branch}` : ""}
+            </text>
           </box>
         </box>
         <Show when={false}>

+ 9 - 0
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -14,6 +14,7 @@ import type {
   SessionStatus,
   ProviderListResponse,
   ProviderAuthMethod,
+  VcsInfo,
 } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useSDK } from "@tui/context/sdk"
@@ -59,6 +60,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
         [key: string]: McpStatus
       }
       formatter: FormatterStatus[]
+      vcs: VcsInfo | undefined
     }>({
       provider_next: {
         all: [],
@@ -82,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       lsp: [],
       mcp: {},
       formatter: [],
+      vcs: undefined,
     })
 
     const sdk = useSDK()
@@ -238,6 +241,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
           break
         }
+
+        case "vcs.changed": {
+          setStore("vcs", "vcs", { branch: event.properties.branch })
+          break
+        }
       }
     })
 
@@ -276,6 +284,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
             sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
             sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
+            sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
           ]).then(() => {
             setStore("status", "complete")
           })

+ 2 - 1
packages/opencode/src/project/bootstrap.ts

@@ -4,11 +4,11 @@ import { Format } from "../format"
 import { LSP } from "../lsp"
 import { FileWatcher } from "../file/watcher"
 import { File } from "../file"
-import { Flag } from "../flag/flag"
 import { Project } from "./project"
 import { Bus } from "../bus"
 import { Command } from "../command"
 import { Instance } from "./instance"
+import { Vcs } from "./vcs"
 import { Log } from "@/util/log"
 import { ShareNext } from "@/share/share-next"
 
@@ -21,6 +21,7 @@ export async function InstanceBootstrap() {
   await LSP.init()
   FileWatcher.init()
   File.init()
+  Vcs.init()
 
   Bus.subscribe(Command.Event.Executed, async (payload) => {
     if (payload.properties.name === Command.Default.INIT) {

+ 86 - 0
packages/opencode/src/project/vcs.ts

@@ -0,0 +1,86 @@
+import { $ } from "bun"
+import { watch, type FSWatcher } from "fs"
+import path from "path"
+import z from "zod"
+import { Log } from "@/util/log"
+import { Bus } from "@/bus"
+import { Instance } from "./instance"
+
+const log = Log.create({ service: "vcs" })
+
+export namespace Vcs {
+  export const Event = {
+    Changed: Bus.event(
+      "vcs.changed",
+      z.object({
+        branch: z.string().optional(),
+      }),
+    ),
+  }
+
+  async function currentBranch() {
+    return $`git rev-parse --abbrev-ref HEAD`
+      .quiet()
+      .nothrow()
+      .cwd(Instance.worktree)
+      .text()
+      .then((x) => x.trim())
+      .catch(() => undefined)
+  }
+
+  const state = Instance.state(
+    async () => {
+      if (Instance.project.vcs !== "git") {
+        return { branch: async () => undefined, watcher: undefined }
+      }
+      let current = await currentBranch()
+      log.info("initialized", { branch: current })
+
+      const gitDir = await $`git rev-parse --git-dir`
+        .quiet()
+        .nothrow()
+        .cwd(Instance.worktree)
+        .text()
+        .then((x) => x.trim())
+        .catch(() => undefined)
+      if (!gitDir) {
+        log.warn("failed to resolve git directory")
+        return { branch: async () => current, watcher: undefined }
+      }
+
+      const gitHead = path.join(gitDir, "HEAD")
+      let watcher: FSWatcher | undefined
+      // we should probably centralize file watching (see watcher.ts)
+      // but parcel still marked experimental rn
+      try {
+        watcher = watch(gitHead, async () => {
+          const next = await currentBranch()
+          if (next !== current) {
+            log.info("branch changed", { from: current, to: next })
+            current = next
+            Bus.publish(Event.Changed, { branch: next })
+          }
+        })
+        log.info("watching", { path: gitHead })
+      } catch (e) {
+        log.warn("failed to watch git HEAD", { error: e })
+      }
+
+      return {
+        branch: async () => current,
+        watcher,
+      }
+    },
+    async (state) => {
+      state.watcher?.close()
+    },
+  )
+
+  export async function init() {
+    return state()
+  }
+
+  export async function branch() {
+    return await state().then((s) => s.branch())
+  }
+}

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

@@ -20,6 +20,7 @@ import { MessageV2 } from "../session/message-v2"
 import { TuiRoute } from "./tui"
 import { Permission } from "../permission"
 import { Instance } from "../project/instance"
+import { Vcs } from "../project/vcs"
 import { Agent } from "../agent/agent"
 import { Auth } from "../auth"
 import { Command } from "../command"
@@ -365,6 +366,47 @@ export namespace Server {
           })
         },
       )
+      .get(
+        "/vcs",
+        describeRoute({
+          description: "Get VCS info for the current instance",
+          operationId: "vcs.get",
+          responses: {
+            200: {
+              description: "VCS info",
+              content: {
+                "application/json": {
+                  schema: resolver(
+                    z
+                      .object({
+                        worktree: z.string(),
+                        directory: z.string(),
+                        projectID: z.string(),
+                        vcs: z
+                          .object({
+                            branch: z.string(),
+                          })
+                          .optional(),
+                      })
+                      .meta({
+                        ref: "VcsInfo",
+                      }),
+                  ),
+                },
+              },
+            },
+          },
+        }),
+        async (c) => {
+          const branch = await Vcs.branch()
+          return c.json({
+            worktree: Instance.worktree,
+            directory: Instance.directory,
+            projectID: Instance.project.id,
+            vcs: Instance.project.vcs ? { branch } : undefined,
+          })
+        },
+      )
       .get(
         "/session",
         describeRoute({

+ 15 - 0
packages/sdk/js/src/gen/sdk.gen.ts

@@ -23,6 +23,8 @@ import type {
   InstanceDisposeResponses,
   PathGetData,
   PathGetResponses,
+  VcsGetData,
+  VcsGetResponses,
   SessionListData,
   SessionListResponses,
   SessionCreateData,
@@ -311,6 +313,18 @@ class Path extends _HeyApiClient {
   }
 }
 
+class Vcs extends _HeyApiClient {
+  /**
+   * Get VCS info for the current instance
+   */
+  public get<ThrowOnError extends boolean = false>(options?: Options<VcsGetData, ThrowOnError>) {
+    return (options?.client ?? this._client).get<VcsGetResponses, unknown, ThrowOnError>({
+      url: "/vcs",
+      ...options,
+    })
+  }
+}
+
 class Session extends _HeyApiClient {
   /**
    * List all sessions
@@ -995,6 +1009,7 @@ export class OpencodeClient extends _HeyApiClient {
   tool = new Tool({ client: this._client })
   instance = new Instance({ client: this._client })
   path = new Path({ client: this._client })
+  vcs = new Vcs({ client: this._client })
   session = new Session({ client: this._client })
   command = new Command({ client: this._client })
   provider = new Provider({ client: this._client })

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

@@ -589,6 +589,13 @@ export type EventSessionError = {
   }
 }
 
+export type EventVcsChanged = {
+  type: "vcs.changed"
+  properties: {
+    branch?: string
+  }
+}
+
 export type EventTuiPromptAppend = {
   type: "tui.prompt.append"
   properties: {
@@ -670,6 +677,7 @@ export type Event =
   | EventSessionDeleted
   | EventSessionDiff
   | EventSessionError
+  | EventVcsChanged
   | EventTuiPromptAppend
   | EventTuiCommandExecute
   | EventTuiToastShow
@@ -1251,6 +1259,15 @@ export type Path = {
   directory: string
 }
 
+export type VcsInfo = {
+  worktree: string
+  directory: string
+  projectID: string
+  vcs?: {
+    branch: string
+  }
+}
+
 export type NotFoundError = {
   name: "NotFoundError"
   data: {
@@ -1687,6 +1704,24 @@ export type PathGetResponses = {
 
 export type PathGetResponse = PathGetResponses[keyof PathGetResponses]
 
+export type VcsGetData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+  }
+  url: "/vcs"
+}
+
+export type VcsGetResponses = {
+  /**
+   * VCS info
+   */
+  200: VcsInfo
+}
+
+export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
+
 export type SessionListData = {
   body?: never
   path?: never