diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 20124b6fdef..ba437b1fc06 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -2,4 +2,4 @@ export { SessionHeader } from "./session-header" export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab" -export { NewSessionView } from "./session-new-view" +export { NewSessionView, MAIN_WORKTREE, CREATE_WORKTREE, BRANCH_PREFIX } from "./session-new-view" diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 68ef0cc1f2b..34836d0827d 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -5,8 +5,9 @@ import { Icon } from "@opencode-ai/ui/icon" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Select } from "@opencode-ai/ui/select" -const MAIN_WORKTREE = "main" -const CREATE_WORKTREE = "create" +export const MAIN_WORKTREE = "main" +export const CREATE_WORKTREE = "create" +export const BRANCH_PREFIX = "branch:" interface NewSessionViewProps { worktree: string @@ -17,25 +18,45 @@ export function NewSessionView(props: NewSessionViewProps) { const sync = useSync() const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) - const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) + const currentBranch = createMemo(() => sync.data?.vcs?.branch) + + // Get recent branches excluding the current one + const recentBranches = createMemo(() => { + const branches = sync.data?.branches + if (!Array.isArray(branches)) return [] + const current = currentBranch() + return branches.filter((b) => !b.current && b.name !== current).slice(0, 6) + }) + + const options = createMemo(() => { + const branches = recentBranches() + const branchOptions = branches.map((b) => `${BRANCH_PREFIX}${b.name}`) + const sboxes = sandboxes() + return [MAIN_WORKTREE, ...branchOptions, ...sboxes, CREATE_WORKTREE] + }) + const current = createMemo(() => { const selection = props.worktree if (options().includes(selection)) return selection return MAIN_WORKTREE }) - const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory) + const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data?.path?.directory ?? "") const isWorktree = createMemo(() => { const project = sync.project if (!project) return false - return sync.data.path.directory !== project.worktree + return sync.data?.path?.directory !== project.worktree }) const label = (value: string) => { if (value === MAIN_WORKTREE) { if (isWorktree()) return "Main branch" - const branch = sync.data.vcs?.branch - if (branch) return `Main branch (${branch})` - return "Main branch" + const branch = currentBranch() + if (branch) return `Current branch (${branch})` + return "Current branch" + } + + if (value.startsWith(BRANCH_PREFIX)) { + return value.slice(BRANCH_PREFIX.length) } if (value === CREATE_WORKTREE) return "Create new worktree" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0656c5fc60..06bf446eef4 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -15,6 +15,7 @@ import { type McpStatus, type LspStatus, type VcsInfo, + type BranchInfo, type PermissionRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" @@ -53,6 +54,7 @@ type State = { } lsp: LspStatus[] vcs: VcsInfo | undefined + branches: BranchInfo[] limit: number message: { [sessionID: string]: Message[] @@ -99,6 +101,7 @@ function createGlobalSync() { mcp: {}, lsp: [], vcs: undefined, + branches: [], limit: 5, message: {}, part: {}, @@ -173,6 +176,7 @@ function createGlobalSync() { sdk.mcp.status().then((x) => setStore("mcp", x.data!)), sdk.lsp.status().then((x) => setStore("lsp", x.data!)), sdk.vcs.get().then((x) => setStore("vcs", x.data)), + sdk.vcs.branches().then((x) => setStore("branches", Array.isArray(x.data) ? x.data : [])), sdk.permission.list().then((x) => { const grouped: Record = {} for (const perm of x.data ?? []) { @@ -354,6 +358,13 @@ function createGlobalSync() { } case "vcs.branch.updated": { setStore("vcs", { branch: event.properties.branch }) + // Refresh branches list when branch changes + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + directory, + throwOnError: true, + }) + sdk.vcs.branches().then((x) => setStore("branches", Array.isArray(x.data) ? x.data : [])) break } case "permission.asked": { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 69065a8fa7a..65ba598aa73 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -49,6 +49,9 @@ import { FileVisual, SortableTerminalTab, NewSessionView, + MAIN_WORKTREE, + CREATE_WORKTREE, + BRANCH_PREFIX, } from "@/components/session" import { usePlatform } from "@/context/platform" import { navMark, navParams } from "@/utils/perf" @@ -1203,15 +1206,35 @@ export default function Page() { { - if (value === "create") { + onWorktreeChange={async (value) => { + if (value === CREATE_WORKTREE) { setStore("newSessionWorktree", value) return } - setStore("newSessionWorktree", "main") + // Handle branch checkout + if (value.startsWith(BRANCH_PREFIX)) { + const branchName = value.slice(BRANCH_PREFIX.length) + const result = await sdk.client.vcs.checkout({ checkoutInput: { branch: branchName } }) + if (!result.data?.success) { + showToast({ + title: "Failed to checkout branch", + description: result.data?.error ?? "Unknown error", + variant: "error", + }) + return + } + showToast({ + title: "Switched branch", + description: `Now on branch ${branchName}`, + }) + setStore("newSessionWorktree", MAIN_WORKTREE) + return + } + + setStore("newSessionWorktree", MAIN_WORKTREE) - const target = value === "main" ? sync.project?.worktree : value + const target = value === MAIN_WORKTREE ? sync.project?.worktree : value if (!target) return if (target === sync.data.path.directory) return layout.projects.open(target) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index e434b5f8c3a..863a4e28463 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -28,6 +28,36 @@ export namespace Vcs { }) export type Info = z.infer + export const BranchInfo = z + .object({ + name: z.string(), + current: z.boolean(), + }) + .meta({ + ref: "BranchInfo", + }) + export type BranchInfo = z.infer + + export const CheckoutInput = z + .object({ + branch: z.string(), + }) + .meta({ + ref: "CheckoutInput", + }) + export type CheckoutInput = z.infer + + export const CheckoutResult = z + .object({ + success: z.boolean(), + branch: z.string(), + error: z.string().optional(), + }) + .meta({ + ref: "CheckoutResult", + }) + export type CheckoutResult = z.infer + async function currentBranch() { return $`git rev-parse --abbrev-ref HEAD` .quiet() @@ -73,4 +103,34 @@ export namespace Vcs { export async function branch() { return await state().then((s) => s.branch()) } + + export async function branches(limit = 8): Promise { + if (Instance.project.vcs !== "git") return [] + const format = "%(refname:short)" + const result = await $`git branch --sort=-committerdate --format=${format}` + .quiet() + .nothrow() + .cwd(Instance.worktree) + .text() + .catch(() => "") + const current = await branch() + return result + .split("\n") + .filter(Boolean) + .map((x) => x.trim()) + .slice(0, limit) + .map((name) => ({ name, current: name === current })) + } + + export async function checkout(branchName: string): Promise { + if (Instance.project.vcs !== "git") { + return { success: false, branch: branchName, error: "Not a git repository" } + } + const proc = await $`git checkout ${branchName}`.quiet().nothrow().cwd(Instance.worktree) + if (proc.exitCode !== 0) { + return { success: false, branch: branchName, error: proc.stderr.toString().trim() } + } + Bus.publish(Event.BranchUpdated, { branch: branchName }) + return { success: true, branch: branchName } + } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 32d7a179555..a670c59f86f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -694,6 +694,52 @@ export namespace Server { }) }, ) + .get( + "/vcs/branches", + describeRoute({ + summary: "List branches", + description: "Get a list of recent git branches sorted by commit date.", + operationId: "vcs.branches", + responses: { + 200: { + description: "List of branches", + content: { + "application/json": { + schema: resolver(Vcs.BranchInfo.array()), + }, + }, + }, + }, + }), + async (c) => { + const branches = await Vcs.branches() + return c.json(branches) + }, + ) + .post( + "/vcs/checkout", + describeRoute({ + summary: "Checkout branch", + description: "Switch to a different git branch.", + operationId: "vcs.checkout", + responses: { + 200: { + description: "Checkout result", + content: { + "application/json": { + schema: resolver(Vcs.CheckoutResult), + }, + }, + }, + }, + }), + validator("json", Vcs.CheckoutInput), + async (c) => { + const input = c.req.valid("json") + const result = await Vcs.checkout(input.branch) + return c.json(result) + }, + ) .get( "/session", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f83913ea5e1..91bb7c34481 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -10,6 +10,7 @@ import type { Auth as Auth2, AuthSetErrors, AuthSetResponses, + CheckoutInput, CommandListResponses, Config as Config2, ConfigGetResponses, @@ -157,6 +158,8 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + VcsBranchesResponses, + VcsCheckoutResponses, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, @@ -770,6 +773,60 @@ export class Vcs extends HeyApiClient { ...params, }) } + + /** + * List branches + * + * Get a list of recent git branches sorted by commit date. + */ + public branches( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/vcs/branches", + ...options, + ...params, + }) + } + + /** + * Checkout branch + * + * Switch to a different git branch. + */ + public checkout( + parameters?: { + directory?: string + checkoutInput?: CheckoutInput + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { key: "checkoutInput", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/checkout", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } } export class Session extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e423fecea42..dd82f70cd9a 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1790,6 +1790,21 @@ export type VcsInfo = { branch: string } +export type BranchInfo = { + name: string + current: boolean +} + +export type CheckoutResult = { + success: boolean + branch: string + error?: string +} + +export type CheckoutInput = { + branch: string +} + export type TextPartInput = { id?: string type: "text" @@ -2580,6 +2595,42 @@ export type VcsGetResponses = { export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] +export type VcsBranchesData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/vcs/branches" +} + +export type VcsBranchesResponses = { + /** + * List of branches + */ + 200: Array +} + +export type VcsBranchesResponse = VcsBranchesResponses[keyof VcsBranchesResponses] + +export type VcsCheckoutData = { + body?: CheckoutInput + path?: never + query?: { + directory?: string + } + url: "/vcs/checkout" +} + +export type VcsCheckoutResponses = { + /** + * Checkout result + */ + 200: CheckoutResult +} + +export type VcsCheckoutResponse = VcsCheckoutResponses[keyof VcsCheckoutResponses] + export type SessionListData = { body?: never path?: never