mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a8ee9c965 | |||
| d0bec49ec2 | |||
| 433fb4711f | |||
| e8f215bfbc | |||
| 445af9ce70 | |||
| bc51baa9a4 | |||
| ff0a0b0786 | |||
| 4eff0ee2db | |||
| cc0061f88b |
@@ -233,7 +233,7 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
let observed = 0
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
@@ -291,7 +291,13 @@ const layer = Layer.effect(
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() => Effect.sync(() => ++observed)),
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
|
||||
return observed
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), updates).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
@@ -300,12 +306,12 @@ const layer = Layer.effect(
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate()
|
||||
if (observed === target) yield* Deferred.succeed(ready, undefined)
|
||||
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Deferred.await(ready) })
|
||||
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -305,11 +305,13 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("1 second"),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* Deferred.await(completed)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -118,6 +118,7 @@ const sessionTabBindingCommands = [
|
||||
"session.tab.select.7",
|
||||
"session.tab.select.8",
|
||||
"session.tab.select.9",
|
||||
"session.tab.select.10",
|
||||
] as const
|
||||
|
||||
const pinnedSessionBindingCommands = [
|
||||
@@ -714,7 +715,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.reopen(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
...Array.from({ length: 10 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to tab ${i + 1}`,
|
||||
category: "Session",
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
@@ -140,17 +142,22 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => String(index() + 1).length + 1
|
||||
const numberWidth = () => 2
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
|
||||
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 "Start a new session"
|
||||
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
|
||||
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default)
|
||||
})
|
||||
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
|
||||
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
|
||||
const detailFades = createMemo(() => stringWidth(detail()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const background = createMemo(() => {
|
||||
if (selected()) return theme.background.action.primary.selected
|
||||
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||
@@ -183,6 +190,11 @@ 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 detailTextColor = (index: number) => {
|
||||
if (!detailFades() || index < visibleDetailParts().length - FADE_WIDTH) return detailColor()
|
||||
const position = index - (visibleDetailParts().length - FADE_WIDTH)
|
||||
return tint(detailColor(), pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
const glows = () => status().glows
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
@@ -311,7 +323,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{index() + 1}
|
||||
{sessionTabShortcutLabel(index())}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
@@ -359,7 +371,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
{detail()}
|
||||
<Show when={detailFades()} fallback={visibleDetail()}>
|
||||
<For each={visibleDetailParts()}>
|
||||
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -555,8 +571,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
// The number cell keeps one trailing space, even for double-digit tabs.
|
||||
const numberWidth = () => String(tabNumber()).length + 1
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const availableTitleWidth = () =>
|
||||
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
@@ -639,7 +655,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tabNumber()}
|
||||
{sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
|
||||
@@ -126,6 +126,7 @@ export const Definitions = {
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
|
||||
session_tab_select_10: keybind("<leader>0,ctrl+0", "Switch to tab 10"),
|
||||
|
||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
@@ -329,6 +330,7 @@ export const CommandMap = {
|
||||
session_tab_select_7: "session.tab.select.7",
|
||||
session_tab_select_8: "session.tab.select.8",
|
||||
session_tab_select_9: "session.tab.select.9",
|
||||
session_tab_select_10: "session.tab.select.10",
|
||||
stash_delete: "stash.delete",
|
||||
model_provider_list: "model.dialog.provider",
|
||||
model_favorite_toggle: "model.dialog.favorite",
|
||||
|
||||
@@ -7,6 +7,22 @@ export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export const NEW_SESSION_TAB_TITLE = "New session"
|
||||
|
||||
export function sessionTabShortcutLabel(index: number) {
|
||||
if (index >= 0 && index < 9) return String(index + 1)
|
||||
if (index === 9) return "0"
|
||||
return "·"
|
||||
}
|
||||
|
||||
export function sessionTabBranch(current: string | undefined, defaultBranch: string | undefined) {
|
||||
if (!current || current === defaultBranch) return undefined
|
||||
return current
|
||||
}
|
||||
|
||||
export function sessionTabDetail(project: string, current: string | undefined, defaultBranch: string | undefined) {
|
||||
const branch = sessionTabBranch(current, defaultBranch)
|
||||
return branch && project ? `${project}:${branch}` : (branch ?? project)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -7,8 +7,6 @@ import { createEventStream, createFetch, directory, json } from "./fixture/tui-c
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const titles: string[] = []
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
@@ -32,6 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -46,14 +45,11 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
let initialTitle!: () => void
|
||||
const initialTitleSet = new Promise<void>((resolve) => {
|
||||
initialTitle = resolve
|
||||
@@ -110,6 +106,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -134,14 +131,11 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
process.stdout.write = originalWrite
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session title generated while an untitled session is loading remains visible", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
const generatedTitle = Promise.withResolvers<void>()
|
||||
@@ -186,6 +180,7 @@ test("session title generated while an untitled session is loading remains visib
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -222,14 +217,11 @@ test("session title generated while an untitled session is loading remains visib
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session startup prompt is submitted exactly once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
@@ -279,6 +271,7 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -299,6 +292,5 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -86,6 +86,7 @@ test.each([
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
let failure: ThemeError | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const discovery = Promise.withResolvers<Record<string, unknown>>()
|
||||
|
||||
function Probe() {
|
||||
const value = useThemes()
|
||||
@@ -97,7 +98,7 @@ test.each([
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ invalid: source }) }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => discovery.promise }}>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
@@ -105,6 +106,7 @@ test.each([
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
discovery.resolve({ invalid: source })
|
||||
|
||||
try {
|
||||
await wait(() => themes?.ready === true)
|
||||
|
||||
@@ -131,6 +131,7 @@ test("preserves pinned session bindings alongside tab bindings", () => {
|
||||
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
|
||||
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
|
||||
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
|
||||
expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
|
||||
})
|
||||
|
||||
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||
|
||||
@@ -11,10 +11,42 @@ import {
|
||||
reopenSessionTab,
|
||||
seedSessionTabMotion,
|
||||
sessionTabComplete,
|
||||
sessionTabBranch,
|
||||
sessionTabDetail,
|
||||
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("separates the project and branch with a colon", () => {
|
||||
expect(sessionTabDetail("opencode", "feature/sidebar", "main")).toBe("opencode:feature/sidebar")
|
||||
expect(sessionTabDetail("opencode", "main", "main")).toBe("opencode")
|
||||
})
|
||||
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"·",
|
||||
"·",
|
||||
])
|
||||
})
|
||||
|
||||
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
|
||||
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
|
||||
|
||||
@@ -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