Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/components/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
37 changes: 29 additions & 8 deletions packages/app/src/components/session/session-new-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
11 changes: 11 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type McpStatus,
type LspStatus,
type VcsInfo,
type BranchInfo,
type PermissionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
Expand Down Expand Up @@ -53,6 +54,7 @@ type State = {
}
lsp: LspStatus[]
vcs: VcsInfo | undefined
branches: BranchInfo[]
limit: number
message: {
[sessionID: string]: Message[]
Expand Down Expand Up @@ -99,6 +101,7 @@ function createGlobalSync() {
mcp: {},
lsp: [],
vcs: undefined,
branches: [],
limit: 5,
message: {},
part: {},
Expand Down Expand Up @@ -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<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
Expand Down Expand Up @@ -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": {
Expand Down
31 changes: 27 additions & 4 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1203,15 +1206,35 @@ export default function Page() {
<Match when={true}>
<NewSessionView
worktree={newSessionWorktree()}
onWorktreeChange={(value) => {
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)
Expand Down
60 changes: 60 additions & 0 deletions packages/opencode/src/project/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@ export namespace Vcs {
})
export type Info = z.infer<typeof Info>

export const BranchInfo = z
.object({
name: z.string(),
current: z.boolean(),
})
.meta({
ref: "BranchInfo",
})
export type BranchInfo = z.infer<typeof BranchInfo>

export const CheckoutInput = z
.object({
branch: z.string(),
})
.meta({
ref: "CheckoutInput",
})
export type CheckoutInput = z.infer<typeof CheckoutInput>

export const CheckoutResult = z
.object({
success: z.boolean(),
branch: z.string(),
error: z.string().optional(),
})
.meta({
ref: "CheckoutResult",
})
export type CheckoutResult = z.infer<typeof CheckoutResult>

async function currentBranch() {
return $`git rev-parse --abbrev-ref HEAD`
.quiet()
Expand Down Expand Up @@ -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<BranchInfo[]> {
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<CheckoutResult> {
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 }
}
}
46 changes: 46 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
57 changes: 57 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
Auth as Auth2,
AuthSetErrors,
AuthSetResponses,
CheckoutInput,
CommandListResponses,
Config as Config2,
ConfigGetResponses,
Expand Down Expand Up @@ -157,6 +158,8 @@ import type {
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
VcsBranchesResponses,
VcsCheckoutResponses,
VcsGetResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
Expand Down Expand Up @@ -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<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<VcsBranchesResponses, unknown, ThrowOnError>({
url: "/vcs/branches",
...options,
...params,
})
}

/**
* Checkout branch
*
* Switch to a different git branch.
*/
public checkout<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
checkoutInput?: CheckoutInput
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ key: "checkoutInput", map: "body" },
],
},
],
)
return (options?.client ?? this.client).post<VcsCheckoutResponses, unknown, ThrowOnError>({
url: "/vcs/checkout",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}

export class Session extends HeyApiClient {
Expand Down
Loading
Loading