diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index b05cd55baf..9bf0a78501 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1502,18 +1502,25 @@ export interface ProjectCopyApi { export type Endpoint25_0Input = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined } -export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray } -export type VcsStatusOperation = (input?: Endpoint25_0Input) => Effect.Effect +export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info } +export type VcsGetOperation = (input?: Endpoint25_0Input) => Effect.Effect export type Endpoint25_1Input = { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined +} +export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray } +export type VcsStatusOperation = (input?: Endpoint25_1Input) => Effect.Effect + +export type Endpoint25_2Input = { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly mode: Vcs.Mode readonly context?: number | undefined } -export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray } -export type VcsDiffOperation = (input: Endpoint25_1Input) => Effect.Effect +export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray } +export type VcsDiffOperation = (input: Endpoint25_2Input) => Effect.Effect export interface VcsApi { + readonly get: VcsGetOperation readonly status: VcsStatusOperation readonly diff: VcsDiffOperation } diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 57f009d3e7..5f975ee3b3 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -210,6 +210,8 @@ import type { Endpoint25_0Output, Endpoint25_1Input, Endpoint25_1Output, + Endpoint25_2Input, + Endpoint25_2Output, Endpoint26_0Output, Endpoint26_1Input, Endpoint26_1Output, @@ -1182,17 +1184,26 @@ const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({ const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) => preserveEffect()( + raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), + ) + +const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) => + preserveEffect()( raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)), ) -const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input) => - preserveEffect()( +const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) => + preserveEffect()( raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe( Effect.mapError(mapClientError), ), ) -const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) }) +const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ + get: Endpoint25_0(raw), + status: Endpoint25_1(raw), + diff: Endpoint25_2(raw), +}) const Endpoint26_0 = (raw: RawClient["server.debug"]) => () => preserveEffect()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 531cbd78de..4e48ef6450 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -202,6 +202,8 @@ import type { ProjectCopyRemoveOutput, ProjectCopyRefreshInput, ProjectCopyRefreshOutput, + VcsGetInput, + VcsGetOutput, VcsStatusInput, VcsStatusOutput, VcsDiffInput, @@ -1702,6 +1704,18 @@ export function make(options: ClientOptions) { ), }, vcs: { + get: (input?: VcsGetInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/vcs`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), status: (input?: VcsStatusInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 766e845ffd..425372f223 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -530,6 +530,8 @@ export type ReferenceGitSource = { export type ProjectCopyCopy = { directory: string } +export type VcsInfo = { branch?: string } + export type VcsFileStatus = { file: string additions: number @@ -4902,6 +4904,17 @@ export type ProjectCopyRefreshInput = { export type ProjectCopyRefreshOutput = void +export type VcsGetInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type VcsGetOutput = { + location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } } + data: VcsInfo +} + export type VcsStatusInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index 308520bba0..8be5669b62 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -1,14 +1,17 @@ export * as Vcs from "./vcs" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Ref, Stream } from "effect" import { FileDiff } from "@opencode-ai/schema/file-diff" +import { FileSystem } from "@opencode-ai/schema/filesystem" import { FileStatus, Mode } from "@opencode-ai/schema/vcs" +import { VcsEvent } from "@opencode-ai/schema/vcs-event" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { FSUtil } from "@opencode-ai/util/fs-util" import { Location } from "./location" import { AppProcess } from "@opencode-ai/util/process" import { VcsGit } from "./vcs/git" import { VcsHg } from "./vcs/hg" +import { Bus } from "./bus" export { FileStatus, Mode } @@ -17,6 +20,7 @@ export interface DiffOptions { } export interface Interface { + readonly branch: () => Effect.Effect readonly status: () => Effect.Effect readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect } @@ -38,8 +42,30 @@ const layer = Layer.effect( const proc = yield* AppProcess.Service const fs = yield* FSUtil.Service const location = yield* Location.Service + const bus = yield* Bus.Service const impl = adapter(proc, fs, location) + const branch = yield* Ref.make(impl ? yield* impl.branch() : undefined) + if (impl && location.vcs?.type === "git") { + yield* bus.subscribe(FileSystem.Event.Changed).pipe( + Stream.filter((event) => event.data.file.endsWith("HEAD")), + Stream.runForEach(() => + Effect.gen(function* () { + const next = yield* impl.branch() + if (next === (yield* Ref.get(branch))) return + yield* Ref.set(branch, next) + yield* bus.publish(VcsEvent.BranchUpdated, { branch: next }, { + location: { directory: location.directory, workspaceID: location.workspaceID }, + }) + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + } return Service.of({ + branch: Effect.fn("Vcs.branch")(function* () { + if (!impl) return + return yield* impl.branch() + }), status: Effect.fn("Vcs.status")(function* () { if (!impl) return [] return yield* impl.status() @@ -55,5 +81,5 @@ const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer: layer, - deps: [AppProcess.node, FSUtil.node, Location.node], + deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node], }) diff --git a/packages/core/src/vcs/git.ts b/packages/core/src/vcs/git.ts index 390348fc4f..888dc17bbc 100644 --- a/packages/core/src/vcs/git.ts +++ b/packages/core/src/vcs/git.ts @@ -20,6 +20,9 @@ export function make(proc: AppProcess.Interface, input: { directory: string; wor const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree } return { + branch: Effect.fn("VcsGit.branch")(function* () { + return yield* ctx.git.branch(ctx.directory) + }), status: Effect.fn("VcsGit.status")(function* () { const git = ctx.git const ref = (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined diff --git a/packages/core/src/vcs/hg.ts b/packages/core/src/vcs/hg.ts index 10ae0bcbfc..502071bab4 100644 --- a/packages/core/src/vcs/hg.ts +++ b/packages/core/src/vcs/hg.ts @@ -73,6 +73,9 @@ export function make( }) return { + branch: Effect.fn("VcsHg.branch")(function* () { + return yield* hg.branch() + }), status: Effect.fn("VcsHg.status")(function* () { const [items, batch] = yield* Effect.all( // Zero-context patches are enough to count changed lines. diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts index 822deff75b..c4b539f2b7 100644 --- a/packages/core/test/vcs.test.ts +++ b/packages/core/test/vcs.test.ts @@ -53,6 +53,7 @@ describe("Vcs", () => { withTmp((directory) => Effect.gen(function* () { const vcs = yield* Vcs.Service + expect(yield* vcs.branch()).toBeUndefined() expect(yield* vcs.status()).toEqual([]) expect(yield* vcs.diff("working")).toEqual([]) expect(yield* vcs.diff("branch")).toEqual([]) @@ -155,6 +156,7 @@ describe("Vcs", () => { await commitAll(directory, "initial") }) const vcs = yield* Vcs.Service + expect(yield* vcs.branch()).toBe("main") expect(yield* vcs.diff("branch")).toEqual([]) yield* Effect.promise(async () => { @@ -162,6 +164,7 @@ describe("Vcs", () => { await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n") await commitAll(directory, "feature change") }) + expect(yield* vcs.branch()).toBe("feature") const diff = yield* vcs.diff("branch") expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([ { file: "file.txt", status: "modified" }, diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 6a77ab0e47..02904e996b 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -19,6 +19,7 @@ import type { SessionPendingInfo, ShellInfo, SkillInfo, + VcsInfo, } from "@opencode-ai/client" import type { ResolvedTheme } from "@opencode-ai/theme/tui" import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" @@ -113,6 +114,11 @@ export interface Data { default(): LocationRef sync(location?: LocationRef): Promise invalidate(location?: LocationRef): void + readonly vcs: { + get(location?: LocationRef): VcsInfo | undefined + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void + } readonly agent: LocationCollection readonly command: LocationCollection readonly integration: LocationCollection diff --git a/packages/protocol/src/groups/vcs.ts b/packages/protocol/src/groups/vcs.ts index 5c2b85ceb2..2e5c28405a 100644 --- a/packages/protocol/src/groups/vcs.ts +++ b/packages/protocol/src/groups/vcs.ts @@ -13,6 +13,20 @@ const DiffQuery = Schema.Struct({ }) export const VcsGroup = HttpApiGroup.make("server.vcs") + .add( + HttpApiEndpoint.get("vcs.get", "/api/vcs", { + query: LocationQuery, + success: Location.response(Vcs.Info), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.vcs.get", + summary: "VCS information", + description: "Get version control information for the requested location.", + }), + ), + ) .add( HttpApiEndpoint.get("vcs.status", "/api/vcs/status", { query: LocationQuery, diff --git a/packages/schema/src/vcs.ts b/packages/schema/src/vcs.ts index 08127d8b51..60605eeec5 100644 --- a/packages/schema/src/vcs.ts +++ b/packages/schema/src/vcs.ts @@ -1,7 +1,12 @@ export * as Vcs from "./vcs.js" import { Schema } from "effect" -import { NonNegativeInt } from "./schema.js" +import { NonNegativeInt, optional } from "./schema.js" + +export const Info = Schema.Struct({ + branch: optional(Schema.String), +}).annotate({ identifier: "Vcs.Info" }) +export interface Info extends Schema.Schema.Type {} export const Mode = Schema.Literals(["working", "branch"]).annotate({ identifier: "Vcs.Mode" }) export type Mode = typeof Mode.Type diff --git a/packages/server/src/handlers/vcs.ts b/packages/server/src/handlers/vcs.ts index 325eed474a..44e7679853 100644 --- a/packages/server/src/handlers/vcs.ts +++ b/packages/server/src/handlers/vcs.ts @@ -7,6 +7,14 @@ import { response } from "../location" export const VcsHandler = HttpApiBuilder.group(Api, "server.vcs", (handlers) => Effect.gen(function* () { return handlers + .handle("vcs.get", () => + response( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return { branch: yield* vcs.branch() } + }), + ), + ) .handle("vcs.status", () => response( Effect.gen(function* () { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 34df09fcc0..023d0acde0 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1330,11 +1330,16 @@ export function Prompt(props: PromptProps) { if (!props.sessionID) { // No session yet: show where the next session will be created. const directory = currentLocation.ref?.directory ?? data.location.default().directory - return abbreviateHome(directory, paths.home) + const branch = data.location.vcs.get(currentLocation.ref)?.branch + const label = abbreviateHome(directory, paths.home) + return branch ? label + ":" + branch : label } if (status() !== "idle") return - const directory = data.session.get(props.sessionID)?.location.directory - return directory ? abbreviateHome(directory, paths.home) : undefined + const ref = data.session.get(props.sessionID)?.location + if (!ref) return + const label = abbreviateHome(ref.directory, paths.home) + const branch = data.location.vcs.get(ref)?.branch + return branch ? label + ":" + branch : label }) const spinnerDef = createMemo(() => { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index d588c42b4d..5a9bd5da14 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -28,6 +28,7 @@ import type { ShellInfo, SkillInfo, OpenCodeEvent, + VcsInfo, WebSearchProvider, } from "@opencode-ai/client" import type { Plugin } from "@opencode-ai/plugin/tui" @@ -49,6 +50,7 @@ type ShellWithLocation = ShellInfo & { readonly location: LocationRef } type LocationData = { info?: LocationGetOutput + vcs?: VcsInfo agent?: AgentInfo[] command?: CommandInfo[] integration?: IntegrationInfo[] @@ -905,6 +907,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.mcp.resource.invalidate(event.location) void result.location.mcp.resource.sync(event.location) break + case "vcs.branch.updated": { + const ref = event.location ?? defaultLocation() + const key = locationKey(ref) + setStore("location", key, { + ...store.location[key], + vcs: { branch: event.data.branch }, + }) + break + } } } @@ -1139,6 +1150,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ default() { return defaultLocation() }, + vcs: { + get(ref?: LocationRef) { + return store.location[locationKey(ref ?? defaultLocation())]?.vcs + }, + sync(ref?: LocationRef) { + const location = ref ?? defaultLocation() + const id = locationKey(location) + return sync.run(`location.vcs:${id}`, async () => { + const response = await client.api.vcs.get({ location: locationQuery(location) }) + const key = locationKey(response.location) + setStore("location", key, { + ...store.location[key], + vcs: response.data, + }) + }) + }, + invalidate(ref?: LocationRef) { + sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`) + }, + }, async sync(ref?: LocationRef) { const current = ref ?? defaultLocation() await sync.run(`location:${locationKey(current)}`, async () => { @@ -1161,6 +1192,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.provider.sync(location), result.location.reference.sync(location), result.location.skill.sync(location), + result.location.vcs.sync(location), result.shell.sync(location), result.session.form.sync("global", location), ]) @@ -1177,6 +1209,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.provider.invalidate(location) result.location.reference.invalidate(location) result.location.skill.invalidate(location) + result.location.vcs.invalidate(location) result.shell.invalidate(location) result.session.form.invalidate("global", location) }, diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c233a47848..0777618dcf 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -6,8 +6,14 @@ function View(props: { context: Plugin.Context }) { const directory = createMemo(() => props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined, ) + const value = createMemo(() => { + const path = directory() + if (!path) return + const branch = props.context.data.location.vcs.get(props.context.location)?.branch + return branch ? path + ":" + branch : path + }) return ( - + {(value) => } ) diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index eae2d142da..ec8d4e6760 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -127,6 +127,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType