mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3111fe15f | |||
| 84fd347afa |
@@ -1316,7 +1316,15 @@ export function write(
|
||||
}).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
|
||||
{ concurrency: 8, discard: true },
|
||||
)
|
||||
yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
|
||||
// Format the manifest with the same prettier settings as the repo-wide
|
||||
// format pass, so `check:generated` stays clean after the generate bot
|
||||
// reformats the tree.
|
||||
const manifestJson = JSON.stringify(output.files.map((file) => file.path).sort())
|
||||
const manifestContent = yield* Effect.tryPromise({
|
||||
try: () => format(manifestJson, { filepath: manifest, parser: "json", printWidth: 120 }),
|
||||
catch: (error) => new GenerationError({ reason: `Failed to format ${manifest}: ${String(error)}` }),
|
||||
})
|
||||
yield* fs.writeFileString(manifest, manifestContent)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("HttpApiCodegen.write", () => {
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ path: "/generated/session.ts", content: "export const session = {}\n" },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '["session.ts"]\n' },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabDetail,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
@@ -149,15 +148,10 @@ 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 "Start a new session"
|
||||
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
|
||||
const value = session()
|
||||
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)
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
})
|
||||
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)
|
||||
@@ -190,11 +184,6 @@ 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(() => {
|
||||
@@ -371,11 +360,7 @@ 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}>
|
||||
<Show when={detailFades()} fallback={visibleDetail()}>
|
||||
<For each={visibleDetailParts()}>
|
||||
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
{detail()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -13,16 +13,6 @@ export function sessionTabShortcutLabel(index: number) {
|
||||
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 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.
|
||||
// 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.
|
||||
const openTabSessions = createMemo(() =>
|
||||
state()
|
||||
.tabs.map((tab) => tab.sessionID)
|
||||
@@ -171,19 +171,8 @@ 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)
|
||||
|
||||
@@ -84,6 +84,7 @@ import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { undoMessage } from "./undo"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
createSessionRows,
|
||||
@@ -656,19 +657,24 @@ export function Session() {
|
||||
group: "Session",
|
||||
slash: { name: "undo" },
|
||||
run: () => {
|
||||
const admitted = pendingUsers().at(-1)
|
||||
const boundary = session()?.revert?.messageID
|
||||
const message = messages().findLast(
|
||||
(message): message is SessionMessageUser =>
|
||||
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
|
||||
)
|
||||
const message = admitted
|
||||
? { id: admitted.id, ...admitted.data }
|
||||
: messages().findLast(
|
||||
(message): message is SessionMessageUser =>
|
||||
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
|
||||
)
|
||||
if (!message) {
|
||||
toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
void client.api.session.revert
|
||||
.stage({ sessionID: route.sessionID, messageID: message.id })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
void undoMessage(client.api, {
|
||||
sessionID: route.sessionID,
|
||||
messageID: message.id,
|
||||
pending: admitted !== undefined,
|
||||
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
prompt()?.set({
|
||||
...projectedPromptInput(message),
|
||||
pasted: [],
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
|
||||
export async function undoMessage(
|
||||
client: OpenCodeClient,
|
||||
input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean },
|
||||
) {
|
||||
const revert = () => client.session.revert.stage(input).then(() => undefined)
|
||||
if (!input.pending) return revert()
|
||||
|
||||
return client.session.pending.cancel({ sessionID: input.sessionID, inputID: input.messageID }).catch((error) => {
|
||||
if (typeof error !== "object" || error === null || !("_tag" in error) || error._tag !== "ConflictError") throw error
|
||||
return revert()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { undoMessage } from "../../../src/routes/session/undo"
|
||||
|
||||
test.each([
|
||||
{ name: "projected", pending: false, cancelStatus: 204, expected: ["revert"] },
|
||||
{ name: "pending", pending: true, cancelStatus: 204, expected: ["cancel"] },
|
||||
{ name: "promoted race", pending: true, cancelStatus: 409, expected: ["cancel", "revert"] },
|
||||
])("undo routes $name messages", async ({ pending, cancelStatus, expected }) => {
|
||||
const calls: string[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const operation = request.method === "DELETE" ? "cancel" : "revert"
|
||||
calls.push(operation)
|
||||
if (operation === "cancel") {
|
||||
if (cancelStatus === 409)
|
||||
return Response.json({ _tag: "ConflictError", message: "Input was promoted" }, { status: 409 })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return Response.json({ data: { messageID: "msg_user" } })
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
),
|
||||
})
|
||||
|
||||
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending })
|
||||
|
||||
expect(calls).toEqual([...expected])
|
||||
})
|
||||
|
||||
test("undo does not reinterpret transport failures as promotion races", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: Object.assign(
|
||||
async () => {
|
||||
throw new Error("offline")
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
),
|
||||
})
|
||||
|
||||
await expect(
|
||||
undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending: true }),
|
||||
).rejects.toMatchObject({ reason: "Transport" })
|
||||
})
|
||||
@@ -11,25 +11,11 @@ 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",
|
||||
|
||||
@@ -27,14 +27,7 @@ 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>
|
||||
sessionDirectories?: Record<string, string>
|
||||
},
|
||||
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
|
||||
) {
|
||||
const temporary = options?.state ? undefined : await tmpdir()
|
||||
const state = options?.state ?? temporary!.path
|
||||
@@ -51,16 +44,7 @@ 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)
|
||||
@@ -70,7 +54,7 @@ async function renderSessionTabs(
|
||||
id: sessionID,
|
||||
title: sessionID === initialSessionID ? options?.title : undefined,
|
||||
projectID: "project",
|
||||
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
@@ -120,7 +104,6 @@ async function renderSessionTabs(
|
||||
route,
|
||||
data,
|
||||
sessions,
|
||||
vcsLocations,
|
||||
state,
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
async destroy() {
|
||||
@@ -151,21 +134,6 @@ 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