mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
feat(tui): show session branches in vertical tabs
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
adaptiveSessionTabLayout,
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabBranch,
|
||||
sessionTabComplete,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
@@ -148,9 +149,24 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
|
||||
if (tab === NEW_SESSION_TAB)
|
||||
return { project: Locale.takeWidth("Start a new session", titleWidth()), branch: undefined }
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
const projectLabel = projectName(project(), value?.location.directory) ?? ""
|
||||
const vcs = value ? data.location.vcs.info(value.location) : undefined
|
||||
const branch = sessionTabBranch(vcs?.branch.current, vcs?.branch.default)
|
||||
if (!branch) return { project: Locale.takeWidth(projectLabel, titleWidth()), branch: undefined }
|
||||
|
||||
const separatorWidth = projectLabel ? 1 : 0
|
||||
const branchWidth = Math.min(
|
||||
branch.length,
|
||||
Math.max(1, titleWidth() - Math.min(projectLabel.length, 12) - separatorWidth),
|
||||
)
|
||||
const projectWidth = Math.max(0, titleWidth() - branchWidth - separatorWidth)
|
||||
return {
|
||||
project: Locale.takeWidth(projectLabel, projectWidth),
|
||||
branch: Locale.truncateLeft(branch, branchWidth),
|
||||
}
|
||||
})
|
||||
const background = createMemo(() => {
|
||||
if (selected()) return theme.background.action.primary.selected
|
||||
@@ -184,6 +200,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const branchColor = createMemo(() => tint(detailColor(), accent(), 0.45))
|
||||
const glows = () => status().glows
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
@@ -359,9 +376,21 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
backgroundColor={pulseBackground()}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
{detail()}
|
||||
</text>
|
||||
<Show when={detail().project}>
|
||||
{(project) => (
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
{project()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={detail().branch}>
|
||||
{(branch) => (
|
||||
<text fg={branchColor()} wrapMode="none" selectable={false}>
|
||||
{detail().project ? " " : ""}
|
||||
{branch()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -13,6 +13,11 @@ export function sessionTabShortcutLabel(index: number) {
|
||||
return "·"
|
||||
}
|
||||
|
||||
export function sessionTabBranch(current: string | undefined, defaultBranch: string | undefined) {
|
||||
if (!current || current === defaultBranch) return undefined
|
||||
return current
|
||||
}
|
||||
|
||||
export type SessionTabHistory = {
|
||||
entries: readonly string[]
|
||||
index: number
|
||||
|
||||
@@ -157,9 +157,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
})
|
||||
})
|
||||
|
||||
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
|
||||
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
|
||||
// connection slots and switches still render from a warm cache.
|
||||
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
|
||||
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
|
||||
// the first connection slots and switches still render from a warm cache.
|
||||
const openTabSessions = createMemo(() =>
|
||||
state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
@@ -171,8 +171,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (client.connection.status() !== "connected") return
|
||||
const sessionIDs = openTabSessions()
|
||||
if (sessionIDs === "") return
|
||||
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
|
||||
let stale = false
|
||||
void (async () => {
|
||||
await Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
|
||||
if (stale) return
|
||||
const locations = new Map(
|
||||
sessionIDs
|
||||
.split("\n")
|
||||
.map((sessionID) => data.session.get(sessionID)?.location)
|
||||
.filter((location) => location !== undefined)
|
||||
.map((location) => [`${location.directory}\n${location.workspaceID ?? ""}`, location]),
|
||||
)
|
||||
await Promise.allSettled(Array.from(locations.values(), (location) => data.location.vcs.sync(location)))
|
||||
})()
|
||||
const timer = setTimeout(async () => {
|
||||
const sessions = state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
|
||||
@@ -11,11 +11,19 @@ import {
|
||||
reopenSessionTab,
|
||||
seedSessionTabMotion,
|
||||
sessionTabComplete,
|
||||
sessionTabBranch,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabShortcutLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
test("shows only non-default session branches", () => {
|
||||
expect(sessionTabBranch("main", "main")).toBeUndefined()
|
||||
expect(sessionTabBranch("feature/sidebar", "main")).toBe("feature/sidebar")
|
||||
expect(sessionTabBranch("feature/sidebar", undefined)).toBe("feature/sidebar")
|
||||
expect(sessionTabBranch(undefined, "main")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
"1",
|
||||
|
||||
@@ -27,7 +27,14 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
||||
|
||||
async function renderSessionTabs(
|
||||
initialSessionID: string,
|
||||
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
|
||||
options?: {
|
||||
state?: string
|
||||
title?: string
|
||||
home?: boolean
|
||||
persisted?: string[]
|
||||
sessionGate?: Promise<void>
|
||||
sessionDirectories?: Record<string, string>
|
||||
},
|
||||
) {
|
||||
const temporary = options?.state ? undefined : await tmpdir()
|
||||
const state = options?.state ?? temporary!.path
|
||||
@@ -44,7 +51,16 @@ async function renderSessionTabs(
|
||||
}
|
||||
const events = createEventStream()
|
||||
const sessions: string[] = []
|
||||
const vcsLocations: string[] = []
|
||||
const calls = createFetch(async (url) => {
|
||||
if (url.pathname === "/api/vcs") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
vcsLocations.push(requested)
|
||||
return json({
|
||||
location: { directory: requested },
|
||||
data: { branch: { current: "main", default: "main" } },
|
||||
})
|
||||
}
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
if (!sessionID) return undefined
|
||||
sessions.push(sessionID)
|
||||
@@ -54,7 +70,7 @@ async function renderSessionTabs(
|
||||
id: sessionID,
|
||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
@@ -104,6 +120,7 @@ async function renderSessionTabs(
|
||||
route,
|
||||
data,
|
||||
sessions,
|
||||
vcsLocations,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
async destroy() {
|
||||
@@ -134,6 +151,21 @@ test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("loads VCS metadata for each persisted tab location", async () => {
|
||||
const other = `${directory}/other-worktree`
|
||||
const setup = await renderSessionTabs("first", {
|
||||
home: true,
|
||||
persisted: ["first", "second"],
|
||||
sessionDirectories: { second: other },
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => setup.vcsLocations.includes(other))
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("stores session tabs for the current working directory by default", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user