Compare commits

..

1 Commits

Author SHA1 Message Date
James Long ebed189984 feat(tui): render diffs in plugin panel slots 2026-09-04 21:38:16 +00:00
97 changed files with 1588 additions and 2899 deletions
-2
View File
@@ -695,8 +695,6 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
}
}
@@ -490,24 +490,20 @@ async function openDraft(
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === "/api/worktree") {
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
if (path === `/api/worktree/${projectID}`) {
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
},
)
await page.route(`**/api/worktree/${projectID}`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
})
await page.route("**/api/session", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: Record<string, unknown> = route.request().postDataJSON()
@@ -87,24 +87,16 @@ test("recording a new session shortcut stays in settings until recording finishe
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await inventory.promise
await route.fallback()
},
)
await page.route("**/api/worktree/*", async (route) => {
await inventory.promise
await route.fallback()
})
await page.route("**/api/session?*", async (route) => {
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
await route.fallback()
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest(
(request) =>
new URL(request.url()).pathname === "/api/worktree" &&
new URL(request.url()).searchParams.get("location[directory]") === directory &&
request.method() === "GET",
)
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
@@ -118,57 +110,16 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const refresh = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await refresh.promise
await route.fallback()
},
)
await page.route("**/api/worktree/*", async (route) => {
await refresh.promise
await route.fallback()
})
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: [
{ directory },
...sandboxes.filter((item) => !removed.has(item)).map((directory) => ({ directory, strategy: "git" })),
],
})
}
if (route.request().method() === "DELETE") {
removed.add(route.request().postDataJSON().directory)
return route.fulfill({ status: 204 })
}
return route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
await expect(remove).toBeEnabled()
const deleting = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "DELETE",
)
await remove.click()
const request = await deleting
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
})
test("extensions opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
@@ -1,321 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { createMockServerHandler, type MockServerConfig } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
const initial = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const remote = "http://127.0.0.1:4097"
const session = {
id: "ses_web_onboarding",
projectID: "project_web_onboarding",
directory: "/home/remote/project",
title: "Session from the newly connected server",
time: { created: 1, updated: 1 },
}
test("failed initial connection offers server setup instead of an indefinite loading screen", async ({ page }) => {
await fixture(page, initial, "failed")
await page.goto("/")
await expectOnboarding(page)
const onboarding = page.getByRole("region", { name: "Connect a server", exact: true })
await expect(onboarding.getByText(/OpenCode web interface/)).toBeVisible()
await expect(onboarding.getByText(/Run OpenCode on the machine with your code/)).toBeVisible()
await expect(onboarding.getByText(/Check that this server is running and reachable/)).toBeVisible()
await onboarding.getByRole("button", { name: "Manage servers", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeEnabled()
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expectOnboarding(page)
})
test("a configured server with health and event requests still pending leaves setup usable", async ({ page }) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "pending")
await configure(page, remote, "Pending remote")
const health = page.waitForRequest(`${remote}/api/health`)
const events = page.waitForRequest(`${remote}/api/event`)
await page.goto("/")
await Promise.all([health, events])
await expectOnboarding(page)
await page.getByRole("button", { name: "Add remote server", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
await expect(editor.getByPlaceholder("http://localhost:4096", { exact: true })).toBeFocused()
await editor.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(editor).toBeHidden()
await expectOnboarding(page)
})
test("the setup dialog supports keyboard entry, Escape, cancel, and clean reopening", async ({ page }) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "failed")
await page.goto("/")
await expectOnboarding(page)
const add = page.getByRole("button", { name: "Add remote server", exact: true })
await add.focus()
await add.press("Enter")
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
const url = editor.getByPlaceholder("http://localhost:4096", { exact: true })
const name = editor.getByPlaceholder("Localhost", { exact: true })
const password = editor.getByPlaceholder("password", { exact: true })
await expect(url).toBeFocused()
await url.fill(remote)
await page.keyboard.press("Tab")
await expect(name).toBeFocused()
await name.fill("Cancelled remote")
await page.keyboard.press("Tab")
await expect(password).toBeFocused()
await password.fill("fixture-password")
await page.keyboard.press("Escape")
await expect(editor).toBeHidden()
await expect(add).toBeFocused()
await add.press("Space")
await expect(url).toBeFocused()
await expect(url).toHaveValue("")
await expect(name).toHaveValue("")
await expect(password).toHaveValue("")
await editor.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(editor).toBeHidden()
await expect(add).toBeFocused()
await expectOnboarding(page)
await expect(page.getByText("Cancelled remote", { exact: true })).toHaveCount(0)
})
test("adding a healthy remote focuses it and loads its sessions without another server selection", async ({ page }) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "connected", [session])
await page.goto("/")
await expectOnboarding(page)
await page.getByRole("button", { name: "Add remote server", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
await expect(editor.getByPlaceholder("http://localhost:4096", { exact: true })).toBeFocused()
await editor.getByPlaceholder("http://localhost:4096", { exact: true }).fill(remote)
await editor.getByPlaceholder("Localhost", { exact: true }).fill("Working remote")
await editor.getByPlaceholder("Localhost", { exact: true }).press("Enter")
await expect(editor).toBeHidden()
await expectSessions(page, "Working remote")
await page.reload()
await expectSessions(page, "Working remote")
})
test("a returning configured remote shows its sessions even when the initial server is unavailable", async ({
page,
}) => {
await fixture(page, initial, "failed")
await fixture(page, remote, "connected", [session])
await configure(page, remote, "Saved remote")
await page.goto("/")
await expectSessions(page, "Saved remote")
})
test("a healthy initial server retains the normal session search", async ({ page }) => {
await fixture(page, initial, "connected", [session])
await page.goto("/")
await expectSessions(page)
})
for (const origin of ["https://opencode.ai", "https://beta.opencode.ai"]) {
test(`${origin} starts with zero servers and no localhost connection attempts`, async ({ page, baseURL }) => {
const requests = await proxyHosted(page, baseURL, origin)
await page.goto(origin)
await expectOnboarding(page)
await expect(page.getByText(/Could not connect/)).toHaveCount(0)
await page.getByRole("button", { name: "Manage servers", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(settings.getByText("No servers yet", { exact: true })).toBeVisible()
expect(requests).toEqual([])
})
}
test("hosted web preserves saved remote servers without inserting a local server", async ({ page, baseURL }) => {
const origin = "https://beta.opencode.ai"
const server = "https://remote.example.test"
const requests = await proxyHosted(page, baseURL, origin)
await fixture(page, server, "connected", [session])
await configure(page, server, "Saved remote")
await page.goto(origin)
await expectSessions(page)
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await expect(settings.getByText("Saved remote", { exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "More options", exact: true })).toHaveCount(1)
expect(requests.length).toBeGreaterThan(0)
expect(requests.every((url) => new URL(url).origin === server)).toBe(true)
})
test("a server added on hosted web can be removed again", async ({ page, baseURL }) => {
const origin = "https://beta.opencode.ai"
const server = "https://remote.example.test"
const requests = await proxyHosted(page, baseURL, origin)
await fixture(page, server, "connected", [session])
await page.goto(origin)
await expectOnboarding(page)
await page.getByRole("button", { name: "Add remote server", exact: true }).click()
const editor = page.getByRole("dialog", { name: "Add server", exact: true })
await expect(editor.getByPlaceholder("http://localhost:4096", { exact: true })).toBeFocused()
await editor.getByPlaceholder("http://localhost:4096", { exact: true }).fill(server)
await editor.getByPlaceholder("Localhost", { exact: true }).fill("Removable remote")
await editor.getByRole("button", { name: "Add server", exact: true }).click()
await expect(editor).toBeHidden()
await expectSessions(page)
await page.getByRole("button", { name: "Settings", exact: true }).click()
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Servers", exact: true }).click()
await expect(settings.getByText("Removable remote", { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "More options", exact: true }).click()
await page.getByRole("menuitem", { name: "Delete", exact: true }).click()
await expect(settings.getByText("No servers yet", { exact: true })).toBeVisible()
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expectOnboarding(page)
await expect(page.getByText(/Could not connect/)).toHaveCount(0)
await page.reload()
await expectOnboarding(page)
await expect(page.getByText("Removable remote", { exact: true })).toHaveCount(0)
expect(requests.every((url) => new URL(url).origin === server)).toBe(true)
})
test("reconnecting a loaded server preserves an in-progress session rename", async ({ page }) => {
const stream = await fixture(page, initial, "connected", [session])
if (!stream) throw new Error("Connected fixture must provide an event stream")
await page.goto("/")
await expectSessions(page)
const connected = await stream.waitForConnection()
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: session.title })
await row.click({ button: "right" })
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
const editor = page.getByRole("textbox", { name: "Rename", exact: true })
await expect(editor).toBeFocused()
await editor.fill("Keep this unfinished rename")
await stream.disconnect()
await stream.waitForConnection({ after: connected.id })
await expect(editor).toBeFocused()
await expect(editor).toHaveValue("Keep this unfinished rename")
await expect(page.getByRole("heading", { name: "Connect a server", exact: true })).toHaveCount(0)
await editor.press("Escape")
await expectSessions(page)
})
for (const displayName of ["RemoteDevelopmentServer".repeat(10), undefined]) {
test(`disconnected ${displayName ? "server name" : "server URL"} wraps at a narrow viewport`, async ({ page }) => {
const url = `https://${"development-".repeat(4)}server.${"remote-".repeat(7)}example.test`
await page.setViewportSize({ width: 360, height: 740 })
await fixture(page, initial, "failed")
await fixture(page, url, "failed")
await configure(page, url, displayName)
await page.goto("/")
await expectOnboarding(page)
const onboarding = page.getByRole("region", { name: "Connect a server", exact: true })
const identity = onboarding.getByText(displayName ?? url.replace(/^https?:\/\//, ""), { exact: true })
await expect(identity).toBeVisible()
await expect(onboarding.getByRole("button", { name: "Add remote server", exact: true })).toBeInViewport()
await expect(onboarding.getByRole("button", { name: "Manage servers", exact: true })).toBeInViewport()
await expect
.poll(() => identity.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await expect
.poll(() => onboarding.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
.toBeLessThanOrEqual(1)
})
}
async function expectOnboarding(page: Page) {
await expect(page.getByRole("heading", { name: "Connect a server", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Add remote server", exact: true })).toBeEnabled()
await expect(page.getByRole("textbox", { name: /^Search sessions/ })).toHaveCount(0)
await expect(page.getByText("Loading", { exact: true })).toHaveCount(0)
}
async function expectSessions(page: Page, server?: string) {
const sessions = page.getByRole("region", { name: "Recent sessions", exact: true })
await expect(sessions.getByText(session.title, { exact: true })).toBeVisible()
await expect(
page.getByRole("textbox", { name: server ? `Search sessions in ${server}` : "Search sessions", exact: true }),
).toBeEditable()
await expect(page.getByRole("heading", { name: "Connect a server", exact: true })).toHaveCount(0)
await expect(page.getByRole("button", { name: "Add remote server", exact: true })).toHaveCount(0)
await expect(page.getByText("Loading", { exact: true })).toHaveCount(0)
}
async function configure(page: Page, url: string, displayName?: string) {
await page.addInitScript(
({ url, displayName }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url }, displayName }] }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ home: { selection: { server: url } } }))
},
{ url, displayName },
)
}
async function proxyHosted(page: Page, source: string | undefined, origin: string) {
if (!source) throw new Error("Hosted-origin tests require the local app baseURL")
const requests: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname.startsWith("/api/")) requests.push(request.url())
})
// Keep the real hostname-dependent app code, but serve every asset locally.
await page.routeWebSocket("**/*", (socket) => socket.close())
await page.route(`${origin}/**`, async (route) => {
const url = new URL(route.request().url())
if (url.pathname.startsWith("/api/")) return route.abort("connectionrefused")
const response = await route.fetch({ url: new URL(url.pathname + url.search, source).href })
await route.fulfill({ response })
})
return requests
}
async function fixture(
page: Page,
server: string,
state: "connected" | "failed" | "pending",
sessions: MockServerConfig["sessions"] = [],
) {
const stream = state === "connected" ? await installSseTransport(page, { server }) : undefined
const transport = createMockServerHandler({
provider: [],
directory: session.directory,
project: {
id: session.projectID,
canonical: session.directory,
vcs: "git",
time: { created: 1, updated: 1 },
sandboxes: [],
},
sessions,
pageMessages: () => ({ items: [] }),
})
page.on("close", () => void transport.dispose())
await page.route("**/api/**", async (route) => {
if (new URL(route.request().url()).origin !== server) return route.fallback()
if (state === "failed") return route.abort("connectionrefused")
// Deliberately leave both health and SSE unanswered until the isolated page closes.
if (state === "pending") return
const headers = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
}
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers })
const response = await transport.handler(
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
)
await route.fulfill({
status: response.status,
headers: { ...Object.fromEntries(response.headers), ...headers },
body: Buffer.from(await response.arrayBuffer()),
})
})
return stream
}
@@ -97,9 +97,7 @@ for (const theme of ["light", "dark"] as const) {
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
@@ -201,13 +199,10 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
events: () => events.splice(0),
})
// Keep authoritative inventory independent of the raw project's empty sandboxes.
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
},
)
await page.route(`**/api/worktree/${projectID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
})
if (draft)
await page.addInitScript(
({ root, server }) => {
@@ -227,9 +222,7 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
)
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
)
await page.goto(
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
+8 -4
View File
@@ -71,23 +71,27 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
success: NoContent,
}),
)
+1 -1
View File
@@ -95,7 +95,7 @@ export function AppBaseProviders(
export function AppInterface(props: {
children?: JSX.Element
defaultServer?: ServerConnection.Key
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
router?: Component<BaseRouterProps>
@@ -8,7 +8,6 @@
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="session-composer-dock"] [data-component="composer"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
+11 -15
View File
@@ -75,26 +75,22 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
const standalone = isStandalone()
root.dataset.standalone = String(standalone)
if (standalone) restorePwaRoute()
const server: ServerConnection.Http | undefined = web.currentServerUrl
? {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
: undefined
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: web.currentServerUrl,
...auth,
},
}
render(
() => (
<PlatformProvider value={web.platform}>
<AppBaseProviders locale={locale}>
<AppInterface
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
canonicalLocalServer={
server ? ServerConnection.key(server) : ServerConnection.Key.make("http://localhost:49374")
}
servers={server ? [server] : []}
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
canonicalLocalServer={ServerConnection.key(server)}
servers={[server]}
>
{standalone && <PwaRoutePersistence />}
</AppInterface>
-79
View File
@@ -1,79 +0,0 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { serverName } from "@/runtime/server/registry"
import { useSettingsDialog } from "@/settings/command"
import type { HomeController } from "./model"
export function HomeConnect(props: { home: HomeController }) {
const language = useLanguage()
const dialog = useDialog()
const settings = useSettingsDialog("servers")
const server = props.home.server.focused
let button: HTMLButtonElement | undefined
const disconnected = () => {
const conn = server()
return (
conn &&
(props.home.server.health(conn)?.healthy === false || props.home.server.focusedContext()?.sdk.connection.error())
)
}
const add = async () => {
const { DialogServer } = await import("@/servers/connect/dialog")
void dialog.show(() => (
<DialogServer
mode="add"
onAdd={props.home.selection.focusServer}
onCloseAutoFocus={(event) => {
event.preventDefault()
if (button?.isConnected) button.focus()
}}
/>
))
}
return (
<section aria-labelledby="home-connect-title" class="min-w-0 px-3 py-8 md:px-6 lg:py-12">
<div class="mx-auto flex w-full max-w-[440px] flex-col items-start gap-6 lg:mt-12">
<div class="flex size-12 items-center justify-center rounded-xl bg-v2-background-bg-layer-02 text-v2-icon-icon-base">
<Icon name="server" size="large" />
</div>
<div class="flex min-w-0 flex-col gap-3">
<h1 id="home-connect-title" class="text-20-medium text-v2-text-text-base">
{language.t("home.connect.title")}
</h1>
<p class="text-14-regular leading-[var(--line-height-base)] text-v2-text-text-muted">
{language.t("home.connect.description")}
</p>
<p class="text-[13px] leading-[var(--line-height-base)] text-v2-text-text-muted">
{language.t("home.connect.instructions")}
</p>
</div>
<div class="flex w-full flex-wrap items-center gap-2">
<Button ref={button} variant="contrast" size="large" icon="plus" onClick={add}>
{language.t("home.connect.add")}
</Button>
<Button variant="ghost-muted" size="large" onClick={settings}>
{language.t("home.connect.manage")}
</Button>
</div>
<Show when={disconnected()}>
<div class="flex w-full min-w-0 flex-col gap-1 rounded-lg bg-v2-background-bg-layer-01 p-3 text-12-regular leading-[var(--line-height-base)]">
<span class="text-v2-text-text-muted">{language.t("home.connect.unavailable")}</span>
<bdi class="[overflow-wrap:anywhere] text-v2-text-text-base">{serverName(server())}</bdi>
</div>
</Show>
<a
href="https://opencode.ai/v2/docs/troubleshooting/#check-the-background-service"
target="_blank"
rel="noopener noreferrer"
class="text-12-regular text-v2-text-text-muted underline underline-offset-4 hover:text-v2-text-text-base"
>
{language.t("home.connect.help")}
</a>
</div>
</section>
)
}
+1 -5
View File
@@ -201,11 +201,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
<div class={props.dropdown ? "" : "pr-3"}>
<Show
when={props.projects.length > 0}
fallback={
<Show when={props.servers[0]}>
{(server) => <HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
</Show>
}
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
>
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
<Show when={props.dropdown}>
+2 -15
View File
@@ -1,8 +1,6 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { createMediaQuery } from "@solid-primitives/media"
import { Show } from "solid-js"
import { usePlatform } from "@/runtime/platform/platform"
import { HomeConnect } from "./connect"
import { createHomeController } from "./model"
import { createHomeProjectsController } from "./projects/controller"
import { HomeUtilityNav } from "./projects/view"
@@ -13,7 +11,6 @@ import { createHomeSessionsController } from "./sessions/controller"
import { HomeSessions } from "./sessions/region"
export function Home() {
const platform = usePlatform()
const mobile = createMediaQuery("(max-width: 767px)")
const home = createHomeController()
const projects = createHomeProjectsController(home)
@@ -27,7 +24,7 @@ export function Home() {
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
`}
>
<Show when={mobile() && home.server.list().length > 0}>
<Show when={mobile()}>
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
<HomeProjects projects={projects} scroll={scroll} dropdown />
</div>
@@ -49,17 +46,7 @@ export function Home() {
<Show when={!mobile()}>
<HomeProjects projects={projects} scroll={scroll} />
</Show>
{/* A disabled session query is pending, not loading. Keep loaded sessions mounted during reconnects. */}
<Show
when={
platform.platform !== "web" ||
!sessions.data.loading() ||
home.server.focusedContext()?.sdk.connection.status() === "connected"
}
fallback={<HomeConnect home={home} />}
>
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
</Show>
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
</div>
</ScrollView>
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
@@ -318,10 +318,8 @@ export function createHomeSessionsController(home: HomeController) {
dialog.show(() => <DeleteDialog server={server} session={session} />),
},
tab: {
isOpen: (record: HomeSessionRecord) => {
const server = home.selection.value().server
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
},
isOpen: (record: HomeSessionRecord) =>
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
},
}
}
+38 -43
View File
@@ -2,7 +2,6 @@ import type { HomeScrollController } from "../scroll"
import type { HomeSessionSearchController } from "./search"
import type { HomeSessionsController } from "./controller"
import { HomeSessionsView } from "./view"
import { Show } from "solid-js"
export function HomeSessions(props: {
sessions: HomeSessionsController
@@ -10,47 +9,43 @@ export function HomeSessions(props: {
scroll: HomeScrollController
}) {
return (
<Show when={props.sessions.session.server()}>
{(server) => (
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)}
</Show>
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
searchValue={props.search.query.value()}
searchPlaceholder={props.search.query.placeholder()}
searchOpen={props.search.query.open()}
searchLoading={props.search.result.loading()}
searchResults={props.search.result.list()}
searchActive={props.search.result.active()}
searchNoResultsLabel={props.search.result.noResultsLabel()}
titleOpacity={props.scroll.header.titleOpacity}
isOpenTab={props.sessions.tab.isOpen}
onCreateSession={props.sessions.session.create}
onOpenSession={props.sessions.session.open}
onArchiveSession={props.sessions.session.archive}
onRenameSession={props.sessions.session.rename}
onExportSession={props.sessions.session.export}
onDeleteSession={props.sessions.session.showDelete}
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
onSetContent={props.scroll.header.setContent}
onSetHeader={props.scroll.header.setHeader}
onWheel={props.scroll.viewport.containWheel}
onSetSearchRoot={props.search.element.setRoot}
onSetSearchInput={props.search.element.setInput}
onSetSearchList={props.search.element.setList}
onSearchFocus={props.search.query.focus}
onSearchInput={props.search.query.input}
onSearchClose={props.search.query.close}
onSearchMove={props.search.result.move}
onSearchSelectActive={props.search.result.selectActive}
onSearchHighlight={props.search.result.highlight}
onSearchSelect={props.search.result.select}
/>
)
}
@@ -1,6 +0,0 @@
import { expect, test } from "bun:test"
import { popularProviders } from "./order"
test("lists OpenCode Go before Zen and other popular providers", () => {
expect(popularProviders.slice(0, 2)).toEqual(["opencode-go", "opencode"])
})
+1 -1
View File
@@ -1,6 +1,6 @@
export const popularProviders = [
"opencode-go",
"opencode",
"opencode-go",
"anthropic",
"github-copilot",
"openai",
@@ -110,7 +110,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
+1 -1
View File
@@ -13,7 +13,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
-9
View File
@@ -655,15 +655,6 @@ export const dict = {
"home.sessions.search.noResults": "No sessions found for {{query}}",
"home.sessions.empty": "Nothing here yet",
"home.sessions.empty.description": "Create a session to get started",
"home.connect.title": "Connect a server",
"home.connect.description":
"This is the OpenCode web interface. Connect your own server to access your projects and sessions.",
"home.connect.instructions":
"Run OpenCode on the machine with your code, then add its server address and password here.",
"home.connect.add": "Add remote server",
"home.connect.manage": "Manage servers",
"home.connect.unavailable": "Could not connect. Check that this server is running and reachable from your browser.",
"home.connect.help": "Learn about connecting to a server",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
+1 -1
View File
@@ -53,7 +53,7 @@ export function createWebPlatform(version: string) {
}
function getCurrentServerUrl() {
if (location.hostname === "opencode.ai" || location.hostname.endsWith(".opencode.ai")) return undefined
if (location.hostname.includes("opencode.ai")) return "http://localhost:49374"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
@@ -7,6 +7,9 @@ import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
baseUrl: "http://opencode.local",
@@ -76,32 +79,26 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads each project's inventory through its own location using the real client", async () => {
test("loads projects from the current endpoint", async () => {
const calls: string[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
calls.push(directory)
return Response.json([
{ directory },
{ directory: `${directory}/clone` },
{ directory: `${directory}/copy`, strategy: "git" },
])
},
{ preconnect() {} },
),
})
const projects = {
list: async () => [
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/clone` },
{ directory: `/${projectID}/copy`, strategy: "git" },
]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
@@ -112,30 +109,24 @@ describe("query keys", () => {
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["/a", "/b"])
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
if (url.pathname === "/api/project")
return Response.json([
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
])
const directory = url.searchParams.get("location[directory]")
if (url.pathname !== "/api/worktree" || !directory) throw new Error(`Unexpected request: ${url}`)
if (directory === "/b") return Response.json({ message: "unavailable" }, { status: 503 })
return Response.json([{ directory: "/a/copy", strategy: "git" }])
},
{ preconnect() {} },
),
})
const projects = {
list: async () => [
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
if (projectID === "b") throw new Error("unavailable")
return [{ directory: "/a/copy", strategy: "git" as const }]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
@@ -79,7 +79,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ location: { directory: project.canonical } })
.list({ projectID: project.id })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
+1 -4
View File
@@ -194,7 +194,7 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
name: "Server",
gate: true,
init: (props: {
defaultServer?: ServerConnection.Key
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
@@ -250,9 +250,6 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
}
return {
get canonicalLocalServer() {
return props.canonicalLocalServer
},
get list() {
return allServers()
},
+3 -9
View File
@@ -23,14 +23,11 @@ type FormMode = "list" | "add" | "edit"
export const DialogServer: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
onAdd?: (server: ServerConnection.Http) => void
onCloseAutoFocus?: (event: Event) => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const form = createFormController({
onSelect: () => dialog.close(),
onAdd: (server) => props.onAdd?.(server),
})
const [opened, setOpened] = createSignal(false)
@@ -66,7 +63,7 @@ export const DialogServer: Component<{
}
return (
<Dialog fit class="settings-server-dialog" onCloseAutoFocus={props.onCloseAutoFocus}>
<Dialog fit class="settings-server-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{title()}</DialogTitle>
</DialogHeader>
@@ -131,9 +128,7 @@ export const DialogServer: Component<{
)
}
function createFormController(
options: { onSelect?: () => void; onAdd?: (server: ServerConnection.Http) => void } = {},
) {
function createFormController(options: { onSelect?: () => void } = {}) {
const server = useServers()
const tabs = useTabs()
const global = useGlobal()
@@ -217,8 +212,7 @@ function createFormController(
}
reset()
const added = add(connection)
if (added) options.onAdd?.(added)
add(connection)
options.onSelect?.()
},
}))
@@ -3,9 +3,11 @@ import { useLocation } from "@solidjs/router"
import { ComposerEditor } from "@/composer/editor/editor"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerEditor } from "@/composer/editor/interaction"
import { useLanguage } from "@/runtime/i18n/language"
import type { PendingSession } from "@/shell/tabs/tabs"
export function PreparingComposer(props: { pending: PendingSession }) {
const language = useLanguage()
const location = useLocation()
let element: HTMLElement | undefined
const editor = createComposerEditor({
@@ -18,6 +20,7 @@ export function PreparingComposer(props: { pending: PendingSession }) {
},
view: {
draftOnly: true,
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
},
})
+1 -1
View File
@@ -64,7 +64,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
provider: { all: providers.all(), default: providers.default(), connected: [] },
}}
>
<div data-component="session-preparing" data-workspace-session class="min-h-0 flex-1 overflow-y-auto">
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
<SessionIdentityHeader sessionID={props.sessionID} />
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
<SessionUserMessage
@@ -40,7 +40,8 @@ export function SessionWorkspaceMenu(props: {
if (!open) return
const sdk = serverSDK
void sdk.api.worktree
.list({ location: { directory: props.directory } })
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
@@ -58,7 +58,6 @@ export const SettingsProviders: Component<{
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
)
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
})
const popular = createMemo(() => {
+3 -3
View File
@@ -1,13 +1,13 @@
import { type JSX, type ParentProps, Show } from "solid-js"
import { type ParentProps, Show } from "solid-js"
import { useGlobal } from "@/runtime/server/runtime"
import { ModelsProvider } from "@/providers/models/models"
import { ServerProvider } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
export function SettingsServerScope(props: ParentProps<{ directory?: string; fallback?: JSX.Element }>) {
export function SettingsServerScope(props: ParentProps<{ directory?: string }>) {
const global = useGlobal()
return (
<Show when={global.settings.server.selected()} keyed fallback={props.fallback}>
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
{(server) => (
<SettingsServerDataScope server={server} directory={props.directory}>
{props.children}
+1 -18
View File
@@ -206,24 +206,7 @@ export const SettingsScreen: Component = () => {
<Tabs.Content value="projects" class="settings-panel">
<SettingsProjects />
</Tabs.Content>
<SettingsServerScope
directory={directory()}
fallback={
<For each={["workspaces", "providers", "models", "extensions"]}>
{(value) => (
<Tabs.Content value={value} class="settings-panel">
<div class="flex flex-col items-start gap-4 p-6">
<h2 class="settings-tab-title">{language.t("home.connect.title")}</h2>
<p class="text-v2-text-text-muted">{language.t("home.connect.description")}</p>
<Button variant="contrast" onClick={() => surface.open("servers")}>
{language.t("home.connect.manage")}
</Button>
</div>
</Tabs.Content>
)}
</For>
}
>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces activeDirectory={directory()} />
</Tabs.Content>
@@ -65,7 +65,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ location: { directory: project.canonical } })
.list({ projectID: project.id })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
@@ -180,7 +180,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
@@ -14,15 +14,6 @@ describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
test("starts without a server and preserves a saved selection", () => {
const restore = Schema.decodeUnknownSync(Persistence.withInitial(layoutPersistence, initialLayout()))
expect(restore({}).home.selection.server).toBeUndefined()
expect(Schema.is(layoutSchema)(initialLayout())).toBe(true)
expect(restore({ home: { selection: { server: "https://remote.example" } } }).home.selection.server).toBe(
ServerConnection.Key.make("https://remote.example"),
)
})
test("uses supplied initial preferences after legacy migration", () => {
const initial = initialLayout(ServerConnection.Key.make("remote"))
initial.sidebar.width = 420
+3 -3
View File
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
home: Persistence.struct({
selection: Persistence.struct({
server: Schema.optional(TabStorage.ServerKey),
server: TabStorage.ServerKey,
directory: Schema.optional(Schema.String),
}),
}),
@@ -223,7 +223,7 @@ export const layoutPersistence = Persistence.migrate(
),
)
export function initialLayout(server?: ServerConnection.Key): typeof layoutSchema.Type {
export function initialLayout(server: ServerConnection.Key): typeof layoutSchema.Type {
return {
sidebar: { opened: false, width: DEFAULT_SIDEBAR_WIDTH, workspaces: {}, workspacesDefault: false },
terminal: { height: DEFAULT_TERMINAL_HEIGHT, opened: false },
@@ -247,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const [store, setStore, _, ready] = persisted(
{ ...Persist.global("layout"), previousKey: "layout.v6" },
layoutPersistence,
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
initialLayout(ServerConnection.key(servers.list[0])),
)
const [ephemeral, setEphemeral] = createStore({
reviewPanelSource: "other" as ReviewPanelSource,
-3
View File
@@ -141,8 +141,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!ready() || !recentReady()) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
// Hosted web no longer auto-connects to localhost; retain its tabs so re-adding it recovers them.
if (servers.canonicalLocalServer) serversSet.add(servers.canonicalLocalServer)
const next = store.filter((tab) => serversSet.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
@@ -169,7 +167,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!closedReady()) return
const serversSet = new Set(servers.list.map(ServerConnection.key))
if (servers.canonicalLocalServer) serversSet.add(servers.canonicalLocalServer)
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
})
+7 -3
View File
@@ -7,27 +7,30 @@ import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the server destination and clone-local main for $name (cached: $cached)", async (input) => {
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
@@ -63,9 +66,10 @@ describe("worktree creation", () => {
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
"http://localhost:3000/api/worktree/proj_clone",
)
expect(
requests
+3 -1
View File
@@ -1,5 +1,6 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
@@ -10,10 +11,11 @@ export async function createWorktree(input: {
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
location: { directory: input.directory },
projectID: project.id,
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
// Populate the client cache before the destination session mounts.
await input.data.location.syncInfo({ directory: created.directory })
+1 -1
View File
@@ -75,7 +75,7 @@ const make = Effect.gen(function* () {
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "notify"
return values.findLast((value) => value !== undefined) ?? "auto"
})
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
+9 -13
View File
@@ -1931,37 +1931,33 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type WorktreeListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeListInput = { readonly projectID: Project.ID }
export type WorktreeListOutput = Worktree.List
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeCreateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy?: Worktree.StrategyID | undefined
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory?: AbsolutePath | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type WorktreeCreateOutput = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeRemoveInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly projectID: Project.ID
readonly directory: AbsolutePath
readonly force: boolean
}
export type WorktreeRemoveOutput = void
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
export type WorktreeRefreshInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
export type WorktreeRefreshOutput = void
export type WorktreeRefreshOperation<E = never> = (
input?: WorktreeRefreshInput,
input: WorktreeRefreshInput,
) => Effect.Effect<WorktreeRefreshOutput, E>
export interface WorktreeApi<E = never> {
+12 -12
View File
@@ -1451,21 +1451,21 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
preserveEffect<WorktreeListOutput>()(
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
preserveEffect<WorktreeCreateOutput>()(
raw["worktree.create"]({
query: { location: input?.["location"] },
params: { projectID: input["projectID"] },
payload: {
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1473,14 +1473,14 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: W
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
preserveEffect<WorktreeRemoveOutput>()(
raw["worktree.remove"]({
query: { location: input["location"] },
params: { projectID: input["projectID"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
preserveEffect<WorktreeRefreshOutput>()(
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
-1
View File
@@ -20,7 +20,6 @@ export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type VcsApi = Client["vcs"]
export type WorktreeApi = Client["worktree"]
export interface CatalogApi {
readonly provider: ProviderApi
+12 -16
View File
@@ -1968,30 +1968,28 @@ export function make(options: ClientOptions) {
),
},
worktree: {
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/worktree`,
query: { location: input?.["location"] },
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/worktree`,
query: { location: input?.["location"] },
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
body: {
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -2003,8 +2001,7 @@ export function make(options: ClientOptions) {
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/worktree`,
query: { location: input["location"] },
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -2012,12 +2009,11 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/worktree/refresh`,
query: { location: input?.["location"] },
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+16 -31
View File
@@ -430,8 +430,6 @@ export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type ConfigWorktree = { directory: string }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
@@ -1995,7 +1993,6 @@ export type ConfigEntry =
}
websearch?: false | { provider: "random" | (string & {}) }
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
worktree?: ConfigWorktree
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
@@ -6080,51 +6077,45 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type WorktreeListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy?: {
readonly strategy?: string
readonly projectID: { readonly projectID: string }["projectID"]
readonly strategy: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly directory: string
readonly name?: string
}["strategy"]
readonly from?: {
readonly strategy?: string
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly directory: string
readonly name?: string
}["from"]
readonly branch?: {
readonly strategy?: string
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly directory: string
readonly name?: string
}["branch"]
readonly directory?: {
readonly strategy?: string
readonly directory: {
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly directory: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy?: string
readonly strategy: string
readonly from?: string
readonly branch?: string
readonly directory?: string
readonly directory: string
readonly name?: string
}["name"]
}
@@ -6132,20 +6123,14 @@ export type WorktreeCreateInput = {
export type WorktreeCreateOutput = WorktreeInfo
export type WorktreeRemoveInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly projectID: { readonly projectID: string }["projectID"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
export type WorktreeRemoveOutput = void
export type WorktreeRefreshInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeRefreshOutput = void
+9 -40
View File
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("all worktree operations use location-based routes without a project parameter", async () => {
test("worktree methods use the global project contract", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -350,25 +350,27 @@ test("all worktree operations use location-based routes without a project parame
},
})
expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }])
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
expect(
await client.worktree.create({
projectID: "proj_test",
strategy: "git",
directory: "/tmp/worktrees",
name: "api",
}),
).toEqual({ directory: "/tmp/worktrees/api" })
await client.worktree.remove({
projectID: "proj_test",
directory: "/tmp/worktrees/api",
force: false,
})
await client.worktree.refresh()
await client.worktree.refresh({ projectID: "proj_test" })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/worktree"],
["POST", "http://localhost:3000/api/worktree"],
["DELETE", "http://localhost:3000/api/worktree"],
["POST", "http://localhost:3000/api/worktree/refresh"],
["GET", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test"],
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
])
expect(await requests[1]?.json()).toEqual({
strategy: "git",
@@ -378,39 +380,6 @@ test("all worktree operations use location-based routes without a project parame
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("worktree operations send the configuration location separately from their payload", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/configured/task", strategy: "git" }])
if (request.method === "DELETE" || new URL(request.url).pathname.endsWith("/refresh"))
return new Response(null, { status: 204 })
return Response.json({ directory: "/configured/task" })
},
})
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
directory: "/configured/task",
})
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[0]?.json()).toEqual({ name: "task" })
await client.worktree.remove({
location: { directory: "/repo/nested" },
directory: "/configured/task",
force: true,
})
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
{ directory: "/configured/task", strategy: "git" },
])
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
})
test("workspace.destroy returns the transition result", async () => {
let request: Request | undefined
const client = OpenCode.make({
-1
View File
@@ -209,7 +209,6 @@ export function normalize(input: unknown): Result {
media: Info.fields.media,
tool_output: Info.fields.tool_output,
websearch: Info.fields.websearch,
worktree: Info.fields.worktree,
warming: Info.fields.warming,
}
Object.entries(nativeAtomic).forEach(([key, schema]) => {
@@ -1,35 +0,0 @@
export * as ConfigWorktreePlugin from "./worktree.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import path from "path"
import { Config } from "../../config.js"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
import { Worktree } from "../../worktree.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.worktree",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const worktrees = yield* Worktree.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
yield* worktrees.transform((editor) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.worktree) continue
const directory = entry.info.worktree.directory
editor.configure({
directory: AbsolutePath.make(
directory.startsWith("~/")
? path.join(global.home, directory.slice(2))
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
),
})
}
})
}),
})
+1 -3
View File
@@ -25,7 +25,6 @@ import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { WorktreeRefresh } from "./worktree/refresh.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
@@ -72,8 +71,7 @@ const nodes = [
PluginHooks.node,
InstancePlugins.node,
PluginSupervisor.node,
WorktreeRefresh.node,
Worktree.node,
Worktree.refreshNode,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
-40
View File
@@ -30,7 +30,6 @@ import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { Worktree } from "../worktree.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
@@ -70,7 +69,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
const sessions = yield* Session.Service
const persistentPty = yield* PersistentPty.Service
const locations = yield* LocationServiceMap.Service
const worktrees = yield* Worktree.Service
const locationInfo = () =>
new Location.Info({
directory: location.directory,
@@ -90,24 +88,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
const atWorktree = <A, E>(
ref: Location.Ref | undefined,
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
) => {
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
if (!ref || isCurrentLocation(ref)) return run(worktrees)
return Effect.gen(function* () {
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
const plugins = yield* Plugin.Service
const target = yield* Worktree.Service
yield* plugins.awaitActivation
return yield* run(target)
}).pipe(Effect.provide(locations.get(ref)))
}
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
@@ -482,25 +462,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
})
}),
},
worktree: {
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
reload: worktrees.reload,
transform: (callback) =>
worktrees.transform((editor) =>
callback({
add: (definition) =>
editor.add({
id: Worktree.StrategyID.make(definition.id),
create: (input) => definition.create(input).pipe(Effect.flatMap(decodeWorktree)),
remove: (input) => definition.remove(input),
list: (directory) => definition.list(directory).pipe(Effect.flatMap(decodeWorktrees)),
}),
}),
),
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
create: (input) =>
@@ -549,7 +510,6 @@ export const requirements = LayerNode.group([
Tool.node,
Vcs.node,
WebSearch.node,
Worktree.node,
Generate.node,
Permission.node,
PluginHooks.node,
-5
View File
@@ -27,8 +27,6 @@ import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
import { Worktree } from "../worktree.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileMutation } from "../file-mutation.js"
@@ -137,7 +135,6 @@ const services = [
ToolOutput.Service,
Watcher.Service,
WellKnown.Service,
Worktree.Service,
] as const
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
@@ -186,7 +183,6 @@ export const requirements = LayerNode.group([
ToolOutput.node,
Watcher.node,
WellKnown.node,
Worktree.node,
])
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
@@ -236,7 +232,6 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
+148 -152
View File
@@ -1,13 +1,12 @@
export * as Worktree from "./worktree.js"
import { Context, Effect, Layer, Schema } from "effect"
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { ProjectSchema } from "./project/schema.js"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
@@ -21,10 +20,8 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { State } from "./state.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export { OperationError } from "@opencode-ai/schema/worktree"
export const StrategyID = Worktree.StrategyID
export type StrategyID = typeof StrategyID.Type
@@ -35,6 +32,11 @@ export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Worktree.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: ProjectSchema.ID,
}).annotate({ identifier: "Worktree.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
@@ -44,10 +46,16 @@ export type RefreshResult = typeof RefreshResult.Type
export const Info = Worktree.Info
export type Info = typeof Info.Type
export const ListInput = Worktree.ListInput
export type ListInput = typeof ListInput.Type
export const List = Worktree.List
export type List = typeof List.Type
export const ListEntry = Worktree.ListEntry
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "worktree"]),
}).annotate({ identifier: "Worktree.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
@@ -70,9 +78,9 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
{ strategy: StrategyID },
) {}
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
"Worktree.UnsupportedLocationError",
{ directory: AbsolutePath },
export class DuplicateStrategyError extends Schema.TaggedError<DuplicateStrategyError>()(
"Worktree.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
export type Error =
@@ -81,8 +89,6 @@ export type Error =
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| UnsupportedLocationError
| Worktree.OperationError
| AppProcess.AppProcessError
| Git.WorktreeError
@@ -92,78 +98,67 @@ export interface Strategy {
sourceDirectory: AbsolutePath
directory: AbsolutePath
branch?: string
}) => Effect.Effect<Info, unknown>
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export const Event = Worktree.Event
interface StoredInput {
readonly projectID: ProjectSchema.ID
readonly directory: AbsolutePath
readonly strategy?: string
readonly replace?: boolean
}
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export interface Editor {
readonly add: (strategy: Strategy) => void
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<List, Error>
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: () => Effect.Effect<RefreshResult, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const worktrees = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
const result = yield* worktrees.refresh({ projectID: location.project.id })
yield* Effect.logInfo("worktree refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const database = yield* Database.Service
const db = database.db
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const location = yield* Location.Service
const global = yield* Global.Service
const projectID = location.project.id
const local = location.workspaceID
? Effect.fail(new UnsupportedLocationError({ directory: location.directory }))
: Effect.void
const gitStrategy = yield* WorktreeGit.make
const state = State.create({
name: "worktree",
initial: () => ({
directory: AbsolutePath.make(path.join(global.data, "worktree", projectID.slice(0, 6))),
strategies: new Map<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
selected: gitStrategy.id,
}),
editor: (value): Editor => ({
configure: (settings) => {
value.directory = settings.directory
},
add: (strategy) => {
value.strategies.delete(strategy.id)
value.strategies.set(strategy.id, strategy)
value.selected = strategy.id
},
}),
})
const changed = Effect.fnUntraced(function* (update: boolean) {
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const ops = {
list: Effect.fnUntraced(function* () {
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -173,7 +168,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -182,21 +177,24 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
}),
primary: Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
return yield* db
.select({ directory: ProjectTable.worktree })
.from(ProjectTable)
.where(eq(ProjectTable.id, projectID))
.get()
.pipe(Effect.orDie)
}),
create: (input: StoredInput, tx?: Transaction) =>
(tx ?? db)
.insert(WorktreeTable)
.values({
project_id: projectID,
directory: input.directory,
strategy: input.strategy,
})
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: {
strategy: input.strategy ?? null,
},
// Discovery may claim an unowned row, but never replace another strategy's ownership.
setWhere: input.replace ? undefined : input.strategy ? isNull(WorktreeTable.strategy) : sql`false`,
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
: isNotNull(WorktreeTable.strategy),
})
.returning({ directory: WorktreeTable.directory })
.get()
@@ -204,7 +202,7 @@ const layer = Layer.effect(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
remove: (directory: AbsolutePath, tx?: Transaction) =>
remove: (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) =>
(tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
@@ -216,57 +214,62 @@ const layer = Layer.effect(
),
}
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
const sourceDirectory = input ?? location.project.directory
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
const gitStrategy = yield* WorktreeGit.make
yield* register(gitStrategy).pipe(Effect.orDie)
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined, projectID: ProjectSchema.ID) {
const sourceDirectory = input ?? (yield* ops.primary(projectID))?.directory
if (!sourceDirectory) return yield* new SourceDirectoryNotFoundError({ projectID })
const resolved = yield* canonical(fs, sourceDirectory)
if ((yield* ops.find(resolved)) === undefined)
if ((yield* ops.find(projectID, resolved)) === undefined)
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
return resolved
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
const found = strategies.get(id)
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("Worktree.create")(function* (input: CreateInput = {}) {
yield* local
const current = state.get()
const selected = yield* getStrategy(input.strategy ?? current.selected, current.strategies)
const directory = input.directory ?? current.directory
const sourceDirectory = yield* source(input.from)
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.from, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(directory, name))
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
while (yield* fs.existsSafe(worktreeDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const created = yield* selected
.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
const result = { directory: yield* canonical(fs, created.directory) }
if (result.directory !== (yield* canonical(fs, worktreeDirectory)))
return yield* new InvalidDirectoryError({ directory: result.directory })
const result = yield* selected.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
yield* changed(
input.projectID,
yield* ops.create({
projectID: input.projectID,
directory: result.directory,
strategy: selected.id,
replace: true,
strategy: input.strategy,
}),
)
const project = yield* db
.select({ commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, projectID))
.where(eq(ProjectTable.id, input.projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
@@ -291,71 +294,70 @@ const layer = Layer.effect(
})
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
yield* local
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(worktreeDirectory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
yield* strategy
.remove({
directory: worktreeDirectory,
force: input.force,
})
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
yield* changed(yield* ops.remove(worktreeDirectory))
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: worktreeDirectory,
force: input.force,
})
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
})
const refresh = Effect.fn("Worktree.refresh")(function* () {
yield* local
const stored = yield* ops.list()
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
const stored = yield* ops.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const strategies = Array.from(state.get().strategies.values()).toReversed()
const discovered = new Map<AbsolutePath, StoredInput>()
// A location's plugin instances only discover its own checkout, not sibling clones.
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
for (const strategy of strategies) {
const entries = yield* strategy.list(location.project.directory).pipe(
Effect.mapError((error) => operationError(strategy.id, "list", error)),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
)
for (const entry of entries) {
const directory = yield* canonical(fs, entry.directory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
)
if (!directory || discovered.has(directory)) continue
discovered.set(directory, {
directory,
strategy: entry.type === "worktree" ? strategy.id : undefined,
})
}
}
}
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(Array.from(registry.values()), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "worktree" ? strategy.id : undefined,
})),
),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const changes = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.filter(Array.from(discovered.values()), (item) => ops.create(item, tx)).pipe(
Effect.map((items) => items.map((item) => item.directory)),
),
removed: Effect.filter(removed, (directory) => ops.remove(directory, tx)),
updated: Effect.filter(discovered, (item) =>
ops.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
},
tx,
),
).pipe(Effect.map((items) => items.map((item) => item.directory))),
removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)),
}),
)
.pipe(Effect.orDie)
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
transform: state.transform,
reload: state.reload,
list: Effect.fn("Worktree.list")(function* () {
yield* refresh()
return yield* ops.list()
}),
register,
list: ops.list,
create,
remove,
refresh,
@@ -363,20 +365,14 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
})
function operationError(strategy: StrategyID, operation: string, error: unknown) {
if (
error instanceof Git.WorktreeError ||
error instanceof DirectoryUnavailableError ||
error instanceof Worktree.OperationError
)
return error
return new Worktree.OperationError({
message: `Worktree strategy ${strategy} failed to ${operation}: ${error instanceof globalThis.Error ? error.message : String(error)}`,
})
}
export const refreshNode = makeLocationNode({
name: "worktree-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
-28
View File
@@ -1,28 +0,0 @@
export * as WorktreeRefresh from "./refresh.js"
import { Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Location } from "../location.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Worktree } from "../worktree.js"
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const worktrees = yield* Worktree.Service
if (location.workspaceID) return
yield* plugins.awaitActivation.pipe(
Effect.andThen(worktrees.refresh()),
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
)
}),
)
export const node = makeLocationNode({
name: "worktree-refresh",
layer,
deps: [Worktree.node, Location.node, Plugin.node, PluginSupervisor.node],
})
-2
View File
@@ -32,7 +32,6 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Worktree } from "@opencode-ai/core/worktree"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
@@ -95,7 +94,6 @@ export const PluginTestLayer = AppNodeBuilder.build(
Vcs.node,
Watcher.node,
WebSearch.node,
Worktree.node,
]),
[
Location.node.replace(tempLocationLayer),
-8
View File
@@ -145,14 +145,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused vcs.transform"),
reload: () => Effect.die("unused vcs.reload"),
},
worktree: overrides.worktree ?? {
list: () => Effect.die("unused worktree.list"),
create: () => Effect.die("unused worktree.create"),
remove: () => Effect.die("unused worktree.remove"),
refresh: () => Effect.die("unused worktree.refresh"),
transform: () => Effect.die("unused worktree.transform"),
reload: () => Effect.die("unused worktree.reload"),
},
websearch: overrides.websearch ?? {
providers: () => Effect.die("unused websearch.providers"),
query: () => Effect.die("unused websearch.query"),
@@ -1,52 +0,0 @@
import { expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { Plugin } from "@opencode-ai/plugin"
import type { WorktreeDefinition } from "@opencode-ai/plugin/effect/worktree"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { State } from "@opencode-ai/core/state"
import { it } from "../lib/effect"
import { host } from "./host"
it.live("Promise worktree callbacks receive interruption through their AbortSignal", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<AbortSignal>()
const state = State.create({
initial: () => new Map<string, WorktreeDefinition>(),
editor: (value) => ({
add: (definition: WorktreeDefinition) => {
value.set(definition.id, definition)
},
}),
})
const context = host()
const plugin = PluginPromise.fromPromise(
Plugin.define({
id: "cancel-worktree",
async setup(ctx) {
await ctx.worktree.transform((editor) =>
editor.add({
id: "cancel",
create: (_input, { signal }) =>
new Promise<never>((_resolve, reject) => {
signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })
Effect.runSync(Deferred.succeed(started, signal))
}),
remove: async () => {},
list: async () => [],
}),
)
},
}),
)
yield* plugin.effect(host({ worktree: { ...context.worktree, transform: state.transform, reload: state.reload } }))
const strategy = state.get().get("cancel")
if (!strategy) return yield* Effect.die("Strategy was not registered")
const fiber = yield* strategy.create({ sourceDirectory: "/source", directory: "/target" }).pipe(Effect.forkScoped)
const signal = yield* Deferred.await(started)
expect(signal.aborted).toBe(false)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(signal.aborted).toBe(true)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
+1
View File
@@ -114,6 +114,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreWorktree.CreateInput, Worktree.CreateInput],
[coreWorktree.RemoveInput, Worktree.RemoveInput],
[coreWorktree.Info, Worktree.Info],
[coreWorktree.ListInput, Worktree.ListInput],
[coreWorktree.List, Worktree.List],
[coreWorktree.Event, Worktree.Event],
[corePty.Info, Pty.Info],
+39 -352
View File
@@ -3,7 +3,7 @@ import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { and, eq, isNull } from "drizzle-orm"
import { Context, Effect, Exit, Fiber, Layer, Queue, Scope, Stream } from "effect"
import { Effect, Fiber, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -15,64 +15,14 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { WorktreeGit } from "@opencode-ai/core/worktree/git"
import { Location } from "@opencode-ai/core/location"
import { Global } from "@opencode-ai/util/global"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Config } from "@opencode-ai/core/config"
import { ConfigWorktreePlugin } from "@opencode-ai/core/config/plugin/worktree"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Workspace } from "@opencode-ai/schema/workspace"
import { host } from "./plugin/host"
import { initRepo } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
class Fixture extends Context.Service<Fixture, Effect.Success<ReturnType<typeof makeFixture>>>()("WorktreeFixture") {}
const infrastructure = AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node]))
const it = testEffect(
Layer.unwrap(
Effect.gen(function* () {
const input = yield* makeFixture()
const database = yield* Database.Service
const bus = yield* Bus.Service
return Layer.mergeAll(
Layer.succeed(Fixture, input),
Config.testLayer(),
worktreeLayer(input.sourceDirectory, input.projectID, database, bus, input.root.path),
)
}),
).pipe(Layer.provideMerge(infrastructure)),
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
const projectIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
)
const projectIt = it
function worktreeLayer(
directory: AbsolutePath,
projectID: Project.ID,
database: Database.Interface,
bus: Bus.Interface,
data: string,
workspaceID?: Workspace.ID,
) {
return AppNodeBuilder.build(LayerNode.group([Worktree.node, Git.node, FSUtil.node, Location.node, Global.node]), [
Database.node.replace(Layer.succeed(Database.Service, database)),
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
Global.node.replace(Global.layerWith({ data })),
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of({
directory,
workspaceID,
project: { id: projectID, directory, canonical: directory },
}),
),
),
]).pipe(Layer.fresh)
}
function abs(input: string) {
return AbsolutePath.make(input)
@@ -80,11 +30,7 @@ function abs(input: string) {
const gitWorktree = Worktree.StrategyID.make("git")
const setup = Effect.fnUntraced(function* () {
return yield* Fixture
})
function makeFixture() {
function setup() {
return Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -161,6 +107,7 @@ describe("Worktree", () => {
const unavailable = Worktree.StrategyID.make("acme/missing")
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: unavailable,
from: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-strategy`),
@@ -184,6 +131,7 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-source`),
@@ -211,6 +159,7 @@ describe("Worktree", () => {
yield* Effect.yieldNow
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
@@ -224,39 +173,13 @@ describe("Worktree", () => {
)
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
it.live("defaults to the TUI worktree directory and suffixes duplicate names", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const global = yield* Global.Service
const parent = path.join(global.data, "worktree", "worktr")
const created = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
const duplicate = yield* worktree.create({
strategy: gitWorktree,
from: input.sourceDirectory,
name: "task",
})
expect(created.directory).toBe(abs(path.join(parent, "task")))
expect(duplicate.directory).toBe(abs(path.join(parent, "task-2")))
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, ".git")).exists())).toBe(true)
yield* worktree.remove({ directory: created.directory, force: false })
yield* worktree.remove({ directory: duplicate.directory, force: false })
}),
)
it.live("runs the project setup script with worktree paths", () =>
Effect.gen(function* () {
const input = yield* setup()
@@ -276,6 +199,7 @@ describe("Worktree", () => {
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
directory: parent,
name: "worktree",
@@ -286,7 +210,7 @@ describe("Worktree", () => {
created.directory,
created.directory,
])
yield* worktree.remove({ directory: created.directory, force: true })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
}),
)
@@ -309,12 +233,9 @@ describe("Worktree", () => {
.quiet()
})
const projects = yield* Project.Service
const worktrees = yield* Worktree.Service
const initial = yield* projects.resolve(main)
const selected = yield* projects.resolve(clone)
const database = yield* Database.Service
const bus = yield* Bus.Service
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
const worktrees = Context.get(context, Worktree.Service)
yield* projects.update({
projectID: initial.id,
commands: {
@@ -324,6 +245,7 @@ describe("Worktree", () => {
})
const created = yield* worktrees.create({
projectID: selected.id,
strategy: gitWorktree,
from: selected.canonical,
directory: abs(path.join(root.path, "worktrees")),
@@ -354,6 +276,7 @@ describe("Worktree", () => {
})
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
branch: "feature-base",
directory: parent,
@@ -377,6 +300,7 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
branch: "--no-checkout",
directory: parent,
@@ -397,6 +321,7 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: abs(path.join(temp, "does-not-exist")),
directory: abs(`${input.root.path}-missing-directory`),
@@ -422,6 +347,7 @@ describe("Worktree", () => {
]).pipe(Effect.asVoid),
)
const source = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: sourceParent,
@@ -434,6 +360,7 @@ describe("Worktree", () => {
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: source.directory,
directory: targetParent,
@@ -441,8 +368,8 @@ describe("Worktree", () => {
})
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
yield* worktree.remove({ directory: created.directory, force: false })
yield* worktree.remove({ directory: source.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
}),
)
@@ -454,6 +381,7 @@ describe("Worktree", () => {
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
@@ -461,7 +389,9 @@ describe("Worktree", () => {
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
const error = yield* worktree.remove({ directory: created.directory, force: false }).pipe(Effect.flip)
const error = yield* worktree
.remove({ projectID: input.projectID, directory: created.directory, force: false })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Git.WorktreeError)
if (error instanceof Git.WorktreeError) {
@@ -471,7 +401,7 @@ describe("Worktree", () => {
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
yield* worktree.remove({ directory: created.directory, force: true })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
}),
)
@@ -489,7 +419,9 @@ describe("Worktree", () => {
.run()
.pipe(Effect.orDie)
const error = yield* worktree.remove({ directory: unavailable, force: false }).pipe(Effect.flip)
const error = yield* worktree
.remove({ projectID: input.projectID, directory: unavailable, force: false })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
@@ -508,6 +440,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
@@ -522,7 +455,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
).toBe(true)
yield* worktree.remove({ directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
}),
)
@@ -543,6 +476,7 @@ describe("Worktree", () => {
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: parent,
@@ -558,6 +492,7 @@ describe("Worktree", () => {
it.live("does not publish an event when refresh finds no directory changes", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const bus = yield* Bus.Service
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
@@ -567,7 +502,7 @@ describe("Worktree", () => {
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* worktree.refresh()
yield* worktree.refresh({ projectID: input.projectID })
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
@@ -606,7 +541,7 @@ describe("Worktree", () => {
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
const existing = abs(yield* Effect.promise(() => fs.realpath(unchanged)))
expect(yield* worktree.refresh()).toEqual({ updated: [discovered], removed: [] })
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
expect(yield* stored(input.projectID)).toEqual(
[
@@ -619,7 +554,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
expect(yield* worktree.refresh()).toEqual({
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({
updated: [],
removed: [discovered, existing].toSorted(),
})
@@ -640,7 +575,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* worktree.refresh()
yield* worktree.refresh({ projectID: input.projectID })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
@@ -659,7 +594,7 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const worktree = yield* Worktree.Service
yield* worktree.refresh()
yield* worktree.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
@@ -667,15 +602,9 @@ describe("Worktree", () => {
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* input.db
.delete(WorktreeTable)
.where(eq(WorktreeTable.project_id, input.projectID))
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
expect(yield* worktree.refresh()).toEqual({
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
updated: [],
removed: [],
})
@@ -693,251 +622,9 @@ describe("Worktree", () => {
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [missing] })
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
}),
)
it.live("defaults to Git and configured directory without depending on Config", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const parent = abs(path.join(input.root.path, "configured"))
const registration = yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
const created = yield* worktrees.create({ name: "configured" })
expect(created.directory).toBe(abs(path.join(parent, "configured")))
expect(yield* worktrees.list()).toContainEqual({
directory: created.directory,
strategy: "git",
})
yield* registration.dispose
const fallback = yield* worktrees.create({ name: "default" })
expect(fallback.directory).toBe(
abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "default")),
)
}),
)
it.live("selects the last active registration and restores earlier strategies on disposal", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
const parent = abs(path.join(input.root.path, "strategies"))
const first = yield* worktrees.transform((editor) =>
editor.add({ ...git, id: Worktree.StrategyID.make("first") }),
)
const scope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void))
const second = yield* worktrees
.transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("second") }))
.pipe(Effect.provideService(Scope.Scope, scope))
yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
const created = yield* worktrees.create({ name: "second" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" })
yield* Scope.close(scope, Exit.void)
yield* second.dispose
const earlier = yield* worktrees.create({ name: "first" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: earlier.directory, strategy: "first" })
yield* first.dispose
const fallback = yield* worktrees.create({ name: "git" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: fallback.directory, strategy: "git" })
const error = yield* worktrees.remove({ directory: created.directory, force: false }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
yield* worktrees.refresh()
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "second" })
}),
)
it.live("does not fall back to Git when a registered strategy fails", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) =>
editor.add({
...git,
id: Worktree.StrategyID.make("broken"),
create: () => Effect.fail(new Error("backend failed")),
}),
)
const parent = abs(path.join(input.root.path, "failures"))
const error = yield* worktrees.create({ directory: parent, name: "failure" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.OperationError)
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
const explicit = yield* worktrees.create({
directory: parent,
name: "explicit",
strategy: gitWorktree,
})
expect(yield* stored(input.projectID)).toContainEqual({ directory: explicit.directory, strategy: "git" })
}),
)
it.live("rejects a source override belonging to another project", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const projects = yield* Project.Service
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
yield* Effect.promise(() => initRepo(other.path))
const resolved = yield* projects.resolve(abs(other.path))
expect(resolved.id).not.toBe(input.projectID)
const error = yield* worktrees.create({ from: abs(other.path), name: "nope" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.projectID).toBe(input.projectID)
}),
)
it.live("cannot remove a worktree from another project through the current location", () =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
const projects = yield* Project.Service
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
yield* Effect.promise(() => initRepo(other.path))
const linked = abs(path.join(other.path, "linked"))
yield* Effect.promise(() => $`git worktree add --detach ${linked} HEAD`.cwd(other.path).quiet())
const resolved = yield* projects.resolve(linked)
const error = yield* worktrees.remove({ directory: linked, force: true }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.InvalidDirectoryError)
expect(yield* stored(resolved.id)).toContainEqual({ directory: linked, strategy: "git" })
expect(yield* Effect.promise(() => fs.stat(linked).then((item) => item.isDirectory()))).toBe(true)
}),
)
it.live("rejects workspace-qualified locations before running worktree operations", () =>
Effect.gen(function* () {
const input = yield* setup()
const database = yield* Database.Service
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const context = yield* Layer.build(
worktreeLayer(
input.sourceDirectory,
input.projectID,
database,
bus,
input.root.path,
Workspace.ID.make("wrk_remote"),
),
)
const worktrees = Context.get(context, Worktree.Service)
const directory = abs(path.join(input.root.path, "not-created"))
const errors = yield* Effect.all([
worktrees.list().pipe(Effect.flip),
worktrees.create({ directory, name: "task" }).pipe(Effect.flip),
worktrees.remove({ directory: input.sourceDirectory, force: true }).pipe(Effect.flip),
worktrees.refresh().pipe(Effect.flip),
])
for (const error of errors) expect(error).toBeInstanceOf(Worktree.UnsupportedLocationError)
expect(yield* fs.existsSafe(directory)).toBe(false)
expect(yield* fs.isDir(input.sourceDirectory)).toBe(true)
}),
)
it.live("list invokes the location's strategies before returning inventory", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
const directory = abs(path.join(input.root.path, "discovered"))
yield* Effect.promise(() => fs.mkdir(directory))
const sources: AbsolutePath[] = []
yield* worktrees.transform((editor) =>
editor.add({
...git,
id: Worktree.StrategyID.make("discovered-copy"),
list: (sourceDirectory) =>
Effect.sync(() => {
sources.push(sourceDirectory)
return [{ directory, type: "worktree" as const }]
}),
}),
)
expect(yield* worktrees.list()).toContainEqual({ directory, strategy: "discovered-copy" })
expect(sources).toEqual([input.sourceDirectory])
expect(yield* stored(input.projectID)).toContainEqual({ directory, strategy: "discovered-copy" })
yield* Effect.promise(() => fs.rmdir(directory))
expect(yield* worktrees.list()).not.toContainEqual({ directory, strategy: "discovered-copy" })
expect(sources).toEqual([input.sourceDirectory, input.sourceDirectory])
}),
)
it.live("list surfaces strategy discovery failures", () =>
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) =>
editor.add({
...git,
id: Worktree.StrategyID.make("broken-discovery"),
list: () => Effect.fail(new Error("Cannot enumerate worktrees")),
}),
)
const error = yield* worktrees.list().pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.OperationError)
if (error instanceof Worktree.OperationError) expect(error.message).toContain("Cannot enumerate worktrees")
}),
)
it.live("applies directory config through the adapter and restores defaults after config removal", () =>
Effect.gen(function* () {
const input = yield* setup()
const config = yield* Config.Test
const worktrees = yield* Worktree.Service
const bus = yield* Bus.Service
const reloaded = yield* Queue.unbounded<void>()
const documents = [
new Document({
type: "document",
path: abs(path.join(input.root.path, "opencode.json")),
info: new Info({ worktree: { directory: "outer" } }),
}),
new Document({
type: "document",
path: abs(path.join(input.root.path, "nested/opencode.json")),
info: new Info({ worktree: { directory: "copies" } }),
}),
]
yield* config.setEntries(documents)
const git = yield* WorktreeGit.make
yield* worktrees.transform((editor) => editor.add({ ...git, id: Worktree.StrategyID.make("custom") }))
yield* ConfigWorktreePlugin.Plugin.effect(
host({ event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)) } }),
).pipe(
Effect.provideService(Worktree.Service, {
...worktrees,
reload: () => worktrees.reload().pipe(Effect.tap(() => Queue.offer(reloaded, undefined))),
}),
)
const first = yield* worktrees.create({ name: "one" })
expect(first.directory).toBe(abs(path.join(input.root.path, "nested/copies/one")))
expect(yield* stored(input.projectID)).toContainEqual({ directory: first.directory, strategy: "custom" })
yield* config.setEntries(documents.slice(0, 1))
yield* bus.publish(Event.Updated, {})
yield* Queue.take(reloaded)
const second = yield* worktrees.create({ name: "two" })
expect(second.directory).toBe(abs(path.join(input.root.path, "outer/two")))
yield* config.setEntries([])
yield* bus.publish(Event.Updated, {})
yield* Queue.take(reloaded)
const third = yield* worktrees.create({ name: "three" })
expect(third.directory).toBe(abs(path.join(input.root.path, "worktree", input.projectID.slice(0, 6), "three")))
}),
)
it.effect("normalization retains worktree directory and rejects invalid configuration", () =>
Effect.sync(() => {
expect(ConfigNormalize.normalize({ worktree: { directory: "./copies" } })).toMatchObject({
type: "normalized",
encoded: { worktree: { directory: "./copies" } },
diagnostics: [],
})
for (const worktree of [{ directory: " " }, { directory: 12 }, {}]) {
const result = ConfigNormalize.normalize({ worktree })
expect(result.diagnostics.length).toBeGreaterThan(0)
if (result.type === "normalized") expect(result.encoded).not.toHaveProperty("worktree")
}
}),
)
})
-1
View File
@@ -16,4 +16,3 @@ export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { Worktree } from "@opencode-ai/schema/worktree"
-2
View File
@@ -20,7 +20,6 @@ import type { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { VcsDomain } from "./vcs.js"
import type { WebSearchDomain } from "./websearch.js"
import type { WorktreeDomain } from "./worktree.js"
export interface Context {
readonly app: App
@@ -48,7 +47,6 @@ export interface Context {
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
readonly worktree: WorktreeDomain
}
export interface Plugin<R = Scope.Scope> {
-21
View File
@@ -1,21 +0,0 @@
import type { WorktreeApi } from "@opencode-ai/client/effect/api"
import type { Effect } from "effect"
import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js"
import type { Transform } from "./registration.js"
export interface WorktreeDefinition {
readonly id: string
readonly create: (input: WorktreeCreateInput) => Effect.Effect<WorktreeResult, unknown>
readonly remove: (input: WorktreeRemoveInput) => Effect.Effect<void, unknown>
readonly list: (sourceDirectory: string) => Effect.Effect<readonly WorktreeEntry[], unknown>
}
export interface WorktreeEditor {
/** Registers an implementation and selects it as the default. Later active registrations win. */
add(definition: WorktreeDefinition): void
}
export interface WorktreeDomain extends WorktreeApi<unknown> {
readonly transform: Transform<WorktreeEditor>
readonly reload: () => Effect.Effect<void>
}
-22
View File
@@ -234,7 +234,6 @@ export function fromPromise(plugin: Plugin) {
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const WorktreeEndpoints = ClientApi.groups["server.worktree"].endpoints
const context = yield* Effect.context<Scope.Scope>()
const streams = yield* makeStreams()
@@ -539,27 +538,6 @@ export function fromPromise(plugin: Plugin) {
}),
),
},
worktree: {
list: adaptApiMethod(WorktreeEndpoints["worktree.list"], host.worktree.list),
create: adaptApiMethod(WorktreeEndpoints["worktree.create"], host.worktree.create),
remove: adaptApiMethod(WorktreeEndpoints["worktree.remove"], host.worktree.remove),
refresh: adaptApiMethod(WorktreeEndpoints["worktree.refresh"], host.worktree.refresh),
reload: () => run(host.worktree.reload()),
transform: (callback) =>
register(
host.worktree.transform((editor) =>
callback({
add: (definition) =>
editor.add({
id: definition.id,
create: (input) => attempt((signal) => definition.create(input, { signal })),
remove: (input) => attempt((signal) => definition.remove(input, { signal })),
list: (directory) => attempt((signal) => definition.list(directory, { signal })),
}),
}),
),
),
},
session: {
hook: (name, callback, options) =>
register(
-1
View File
@@ -17,4 +17,3 @@ export { Rpc } from "@opencode-ai/schema/rpc"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { Worktree } from "@opencode-ai/schema/worktree"
-2
View File
@@ -20,7 +20,6 @@ import type { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { VcsDomain } from "./vcs.js"
import type { WebSearchDomain } from "./websearch.js"
import type { WorktreeDomain } from "./worktree.js"
export interface Context {
readonly app: App
@@ -48,7 +47,6 @@ export interface Context {
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
readonly worktree: WorktreeDomain
}
export type Cleanup = () => Promise<void> | void
-23
View File
@@ -1,23 +0,0 @@
import type { WorktreeApi } from "@opencode-ai/client/promise/api"
import type { WorktreeCreateInput, WorktreeEntry, WorktreeRemoveInput, WorktreeResult } from "../worktree.js"
import type { Transform } from "./registration.js"
export interface WorktreeDefinition {
readonly id: string
readonly create: (input: WorktreeCreateInput, context: { readonly signal: AbortSignal }) => Promise<WorktreeResult>
readonly remove: (input: WorktreeRemoveInput, context: { readonly signal: AbortSignal }) => Promise<void>
readonly list: (
sourceDirectory: string,
context: { readonly signal: AbortSignal },
) => Promise<readonly WorktreeEntry[]>
}
export interface WorktreeEditor {
/** Registers an implementation and selects it as the default. Later active registrations win. */
add(definition: WorktreeDefinition): void
}
export interface WorktreeDomain extends WorktreeApi {
readonly transform: Transform<WorktreeEditor>
readonly reload: () => Promise<void>
}
-19
View File
@@ -1,19 +0,0 @@
export interface WorktreeCreateInput {
readonly sourceDirectory: string
readonly directory: string
/** Starting ref, not the name of a new branch. Reject unsupported refs rather than ignoring them. */
readonly branch?: string
}
export interface WorktreeRemoveInput {
readonly directory: string
readonly force: boolean
}
export interface WorktreeResult {
readonly directory: string
}
export interface WorktreeEntry extends WorktreeResult {
readonly type: "root" | "worktree"
}
+56 -209
View File
@@ -11997,50 +11997,18 @@
"summary": "List references"
}
},
"/api/worktree": {
"/api/worktree/{projectID}": {
"get": {
"tags": ["worktree"],
"operationId": "v2.worktree.list",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12056,18 +12024,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -12083,7 +12044,7 @@
}
}
},
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
"description": "List known local worktrees for a project.",
"summary": "List worktrees"
},
"post": {
@@ -12091,44 +12052,12 @@
"operationId": "v2.worktree.create",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12171,13 +12100,32 @@
}
}
},
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
"description": "Create a worktree for a project and run its configured setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Worktree.CreateInput"
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["strategy", "directory"],
"additionalProperties": false
}
}
},
@@ -12189,44 +12137,12 @@
"operationId": "v2.worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12262,13 +12178,23 @@
}
}
},
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
"description": "Remove a managed worktree from a project.",
"summary": "Remove worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Worktree.RemoveInput"
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
}
}
},
@@ -12276,50 +12202,18 @@
}
}
},
"/api/worktree/refresh": {
"/api/worktree/{projectID}/refresh": {
"post": {
"tags": ["worktree"],
"operationId": "v2.worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12355,7 +12249,7 @@
}
}
},
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
"description": "Reconcile stored worktrees with the project repositories.",
"summary": "Refresh worktrees"
}
},
@@ -14252,9 +14146,6 @@
]
}
},
"worktree": {
"$ref": "#/components/schemas/Config.Worktree"
},
"warming": {
"anyOf": [
{
@@ -14572,16 +14463,6 @@
},
"additionalProperties": false
},
"Config.Worktree": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"ConfigWebSearch.InfoEncoded": {
"type": "object",
"properties": {
@@ -19695,27 +19576,6 @@
"additionalProperties": false,
"description": "Reports whether this request destroyed an existing workspace."
},
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
},
"Worktree.Directory": {
"type": "object",
"properties": {
@@ -19745,19 +19605,6 @@
"$ref": "#/components/schemas/Worktree.Directory"
}
},
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
"type": "object",
"properties": {
@@ -19891,7 +19738,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project worktree management routes."
},
{
"name": "workspace",
+2 -2
View File
@@ -54,7 +54,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof WorktreeGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
@@ -89,6 +88,7 @@ type ApiGroups<
| typeof ServerGroup
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| typeof WorkspaceGroup
| typeof GenerateGroup
| typeof PersistentPtyGroup
@@ -176,7 +176,7 @@ const makeApiFromGroup = <
.add(PersistentPtyGroup)
.add(ShellGroup.middleware(locationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(WorkspaceGroup)
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
+41 -49
View File
@@ -1,9 +1,9 @@
import { Project } from "@opencode-ai/schema/project"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Schema } from "effect"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/worktree"
const root = "/api/worktree/:projectID"
export class WorktreeError extends Schema.Error<WorktreeError>("WorktreeError")(
{
@@ -16,69 +16,61 @@ export class WorktreeError extends Schema.Error<WorktreeError>("WorktreeError")(
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"]))
const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"]))
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.add(
HttpApiEndpoint.get("worktree.list", root, {
query: LocationQuery,
params: { projectID: Project.ID },
success: Worktree.List,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
summary: "List worktrees",
description:
"Discover worktrees through the requested location's strategies and return its project's inventory.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
summary: "List worktrees",
description: "List known local worktrees for a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.create", root, {
query: LocationQuery,
payload: Worktree.CreateInput,
params: { projectID: Project.ID },
payload: CreatePayload,
success: Worktree.Info,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description:
"Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description: "Create a worktree for a project and run its configured setup script.",
}),
),
)
.add(
HttpApiEndpoint.delete("worktree.remove", root, {
query: LocationQuery,
payload: Worktree.RemoveInput,
params: { projectID: Project.ID },
payload: RemovePayload,
success: HttpApiSchema.NoContent,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from the requested location's project using its recorded strategy.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
query: LocationQuery,
params: { projectID: Project.ID },
success: HttpApiSchema.NoContent,
error: WorktreeError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
summary: "Refresh worktrees",
description: "Discover worktrees from the requested location and reconcile the shared project inventory.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
summary: "Refresh worktrees",
description: "Reconcile stored worktrees with the project repositories.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Location-scoped worktree management routes." }))
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." }))
-4
View File
@@ -20,7 +20,6 @@ import { ConfigWebSearch } from "./config/websearch.js"
import { ConfigToolOutput } from "./config/tool-output.js"
import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
import { ConfigWorktree } from "./config/worktree.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: optional(Schema.String).annotate({
@@ -99,9 +98,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
worktree: ConfigWorktree.Info.pipe(optional).annotate({
description: "Directory defaults for local worktree creation",
}),
warming: ConfigWarming.Warming.pipe(optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
-10
View File
@@ -1,10 +0,0 @@
export * as ConfigWorktree from "./worktree.js"
import { Schema } from "effect"
export const Info = Schema.Struct({
directory: Schema.Trim.pipe(Schema.check(Schema.isNonEmpty())).annotate({
description: "Parent directory for new worktrees, relative to the declaring config file when not absolute",
}),
}).annotate({ identifier: "Config.Worktree" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+9 -15
View File
@@ -2,6 +2,7 @@ export * as Worktree from "./worktree.js"
import { Schema } from "effect"
import { durable, ephemeral, inventory } from "./event.js"
import { ProjectID } from "./project-id.js"
import { AbsolutePath, optional } from "./schema.js"
import { Project } from "./project.js"
@@ -9,18 +10,17 @@ export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Sc
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Schema.Struct({
strategy: optional(StrategyID),
projectID: ProjectID,
strategy: StrategyID,
from: optional(AbsolutePath),
branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))),
directory: optional(AbsolutePath).annotate({
description:
"Parent directory for the new worktree. Uses the location's configuration, then defaults to the server's data directory under worktree/<first six project ID characters>.",
}),
directory: AbsolutePath,
name: optional(Schema.String),
}).annotate({ identifier: "Worktree.CreateInput" })
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
export const RemoveInput = Schema.Struct({
projectID: ProjectID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "Worktree.RemoveInput" })
@@ -37,16 +37,10 @@ export const Directory = Schema.Struct({
}).annotate({ identifier: "Worktree.Directory" })
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "worktree"]),
}).annotate({ identifier: "Worktree.ListEntry" })
export interface ListEntry extends Schema.Schema.Type<typeof ListEntry> {}
export class OperationError extends Schema.TaggedError<OperationError>()("Worktree.OperationError", {
message: Schema.String,
forceRequired: optional(Schema.Boolean),
}) {}
export const ListInput = Schema.Struct({
projectID: ProjectID,
}).annotate({ identifier: "Worktree.ListInput" })
export interface ListInput extends Schema.Schema.Type<typeof ListInput> {}
export const List = Schema.Array(Directory).annotate({ identifier: "Worktree.List" })
export type List = typeof List.Type
-9
View File
@@ -10,15 +10,6 @@ import { AbsolutePath } from "../src/schema.js"
import { WebSearch } from "../src/websearch.js"
describe("Config.Entry", () => {
test("accepts directory-only worktree config and omits it when absent", () => {
const decode = Schema.decodeUnknownSync(Config.Info)
const input = { worktree: { directory: "../worktrees" } }
expect(Schema.encodeSync(Config.Info)(decode(input))).toEqual(input)
expect(Schema.encodeSync(Config.Info)(new Config.Info({ worktree: undefined }))).not.toHaveProperty("worktree")
expect(() => decode({ worktree: {} })).toThrow()
expect(() => decode({ worktree: { directory: " " } })).toThrow()
expect(() => decode({ worktree: { directory: false } })).toThrow()
})
test("round-trips canonical provider IDs without changing config keys", () => {
const input = { providers: { "console-anthropic": { canonical: "anthropic" } } }
const decoded = Schema.decodeUnknownSync(Config.Info)(input)
@@ -184,6 +184,7 @@ describe("contract hygiene", () => {
Model.Variant,
Project.Current,
Worktree.Directory,
Worktree.ListInput,
Worktree.List,
Project.Icon,
Project.Commands,
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Worktree } from "../src/worktree.js"
describe("Worktree.CreateInput", () => {
test("allows the server to choose the destination", () => {
const input = Schema.decodeUnknownSync(Worktree.CreateInput)({
strategy: "git",
})
expect(input.directory).toBeUndefined()
expect(Schema.encodeSync(Worktree.CreateInput)({ ...input, directory: undefined })).toEqual({
strategy: "git",
})
})
test("preserves an explicit destination", () => {
const input = { strategy: "git", directory: "/custom/worktrees" }
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)(input))).toEqual(
input,
)
})
})
test("worktree mutation inputs do not require a project or explicit creation defaults", () => {
const value = Schema.decodeUnknownSync(Worktree.CreateInput)({ name: "task" })
expect(Schema.encodeSync(Worktree.CreateInput)(value)).toEqual({ name: "task" })
expect(Schema.encodeSync(Worktree.CreateInput)(Schema.decodeUnknownSync(Worktree.CreateInput)({}))).toEqual({})
expect(Worktree.CreateInput.fields).not.toHaveProperty("projectID")
expect(Worktree.RemoveInput.fields).not.toHaveProperty("projectID")
})
test("inventory contains only the directory and its owning strategy", () => {
const value = Schema.decodeUnknownSync(Worktree.Directory)({ directory: "/repo/task", strategy: "git" })
expect(Schema.encodeSync(Worktree.Directory)(value)).toEqual({ directory: "/repo/task", strategy: "git" })
})
test("strategy failures can request force confirmation without Core or Git dependencies", () => {
const value = new Worktree.OperationError({ message: "Dirty worktree", forceRequired: true })
expect(value.forceRequired).toBe(true)
expect(
Schema.encodeSync(Worktree.OperationError)(new Worktree.OperationError({ message: "Failed" })),
).not.toHaveProperty("forceRequired")
})
-2
View File
@@ -36,8 +36,6 @@
"effect": "catalog:"
},
"devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
+20 -24
View File
@@ -1,31 +1,31 @@
import { Git } from "@opencode-ai/core/git"
import { Worktree } from "@opencode-ai/core/worktree"
import { Plugin } from "@opencode-ai/core/plugin"
import { WorktreeError } from "@opencode-ai/protocol/groups/worktree"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
export const WorktreeHandler = HttpApiBuilder.group(Api, "server.worktree", (handlers) =>
handlers
.handle("worktree.list", () => run((worktrees) => worktrees.list()))
.handle("worktree.create", (ctx) => run((worktrees) => worktrees.create(ctx.payload)))
.handle("worktree.remove", (ctx) =>
run((worktrees) => worktrees.remove(ctx.payload)).pipe(Effect.as(HttpApiSchema.NoContent.make())),
)
.handle("worktree.refresh", () =>
run((worktrees) => worktrees.refresh()).pipe(Effect.as(HttpApiSchema.NoContent.make())),
),
)
function run<A>(action: (service: Worktree.Interface) => Effect.Effect<A, Worktree.Error>) {
return Effect.gen(function* () {
const plugins = yield* Plugin.Service
Effect.gen(function* () {
const worktrees = yield* Worktree.Service
yield* plugins.awaitActivation
return yield* action(worktrees)
}).pipe(badRequest)
}
return handlers
.handle("worktree.list", (ctx) => worktrees.list(ctx.params.projectID))
.handle("worktree.create", (ctx) =>
badRequest(worktrees.create({ ...ctx.payload, projectID: ctx.params.projectID })),
)
.handle("worktree.remove", (ctx) =>
badRequest(worktrees.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
.handle("worktree.refresh", (ctx) =>
badRequest(worktrees.refresh({ projectID: ctx.params.projectID })).pipe(
Effect.as(HttpApiSchema.NoContent.make()),
),
)
}),
)
function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
return effect.pipe(
@@ -35,10 +35,7 @@ function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
name: "WorktreeError",
data: {
message: message(error),
forceRequired:
error instanceof Git.WorktreeError || error instanceof Worktree.OperationError
? error.forceRequired
: undefined,
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
},
}),
),
@@ -54,6 +51,5 @@ function message(error: Worktree.Error) {
if (error instanceof Worktree.DirectoryUnavailableError) return `Worktree directory unavailable: ${error.directory}`
if (error instanceof Worktree.InvalidDirectoryError) return `Invalid worktree directory: ${error.directory}`
if (error instanceof Worktree.StrategyUnavailableError) return `Worktree strategy unavailable: ${error.strategy}`
if (error instanceof Worktree.UnsupportedLocationError) return "Worktree operations only support local locations"
return error.message
}
+2
View File
@@ -30,6 +30,7 @@ import { PluginUpdate } from "@opencode-ai/core/plugin/update"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Workspace } from "@opencode-ai/core/workspace"
import { Worktree } from "@opencode-ai/core/worktree"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -55,6 +56,7 @@ const applicationServiceNodes = [
httpClient,
Job.node,
Project.node,
Worktree.node,
Session.node,
Instance.node,
SessionTransfer.node,
@@ -1,13 +0,0 @@
import { Plugin } from "@opencode-ai/plugin"
export default Plugin.define({
id: "test.worktree-delegate",
async setup(ctx) {
const directory = ctx.options.directory
if (typeof directory !== "string") throw new Error("Missing target location")
await ctx.worktree.create({
location: { directory },
name: "delegated",
})
},
})
@@ -1,47 +0,0 @@
import { Plugin, Worktree } from "@opencode-ai/plugin"
export default Plugin.define({
id: "test.worktree",
async setup(ctx) {
const id = typeof ctx.options.strategy === "string" ? ctx.options.strategy : "test-copy"
await ctx.worktree.transform((editor) =>
editor.add({
id,
async create(input, { signal }) {
await git(
input.sourceDirectory,
["worktree", "add", "--detach", "--", input.directory, input.branch ?? "HEAD"],
signal,
)
await ctx.storage.set(`tree:${input.directory}`, input.sourceDirectory)
return { directory: input.directory }
},
async remove(input, { signal }) {
const source = await ctx.storage.get(`tree:${input.directory}`)
if (typeof source !== "string") throw new Worktree.OperationError({ message: "Worktree source not found" })
// Windows cannot remove the working directory of the Git process itself.
await git(
source,
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
signal,
!input.force,
)
await ctx.storage.remove(`tree:${input.directory}`)
},
async list(sourceDirectory, { signal }) {
signal.throwIfAborted()
const rows = await ctx.storage.scan({ prefix: "tree:" })
return rows.entries
.filter((row) => row.value === sourceDirectory)
.map((row) => ({ directory: row.key.slice(5), type: "worktree" as const }))
},
}),
)
},
})
async function git(directory: string, args: string[], signal: AbortSignal, forceRequired = false) {
const child = Bun.spawn(["git", "-C", directory, ...args], { stdout: "pipe", stderr: "pipe", signal })
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
if (code !== 0) throw new Worktree.OperationError({ message: stderr, forceRequired })
}
+47 -213
View File
@@ -6,223 +6,57 @@ import { Effect } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { OpenCode } from "@opencode-ai/client"
import { initRepo } from "../../core/test/fixture/git"
it.live(
"lists, creates, and removes worktrees through the same location",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const url = new URL("/api/worktree", server.base)
url.searchParams.set("location[directory]", project)
it.live("lists, creates, and removes worktrees by project ID", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-endpoint-")))
const project = path.join(tmp.path, "project")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
const server = yield* startServer(path.join(tmp.path, "config"))
const location = new URL("/api/location", server.base)
location.searchParams.set("location[directory]", project)
const resolved = yield* Effect.promise(() =>
fetch(location, { headers: server.headers }).then((response) => response.json()),
)
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
throw new Error("Expected resolved project")
const url = new URL(`/api/worktree/${resolved.project.id}`, server.base)
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const initial = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(initial).toEqual([{ directory: project }])
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const created = yield* Effect.promise(() =>
fetch(url, {
method: "POST",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
}).then((response) => response.json()),
)
expect(created).toEqual({ directory: path.join(destination, "api") })
const listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({
directory: path.join(destination, "api"),
strategy: "git",
})
const listed = yield* Effect.promise(() =>
fetch(url, { headers: server.headers }).then((response) => response.json()),
)
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
}),
30_000,
const removed = yield* Effect.promise(() =>
fetch(url, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
}),
)
expect(removed.status).toBe(204)
}),
)
it.live(
"derives the project and creation defaults when the SDK omits its input",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-default-location-")))
const project = path.join(tmp.path, "project")
const config = path.join(tmp.path, "config")
const destination = path.join(tmp.path, "copies")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await initRepo(project)
await fs.mkdir(config)
await Bun.write(path.join(config, "opencode.json"), JSON.stringify({ worktree: { directory: destination } }))
})
const server = yield* startServer(config)
const api = OpenCode.make({
baseUrl: server.base,
headers: { ...server.headers, "x-opencode-directory": encodeURIComponent(project) },
})
yield* Effect.promise(async () => {
const created = await api.worktree.create()
expect(path.dirname(created.directory)).toBe(destination)
await api.worktree.refresh()
expect(await api.worktree.list()).toContainEqual({
directory: created.directory,
strategy: "git",
})
await api.worktree.remove({ directory: created.directory, force: false })
expect(await api.worktree.list()).toEqual([{ directory: project }])
})
}),
30_000,
)
it.live(
"uses checkout-local plugins and configuration for clones sharing a project",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-plugins-")))
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
const nested = path.join(first, "nested")
const config = path.join(tmp.path, "config")
const destination = path.join(tmp.path, "worktrees")
yield* Effect.promise(async () => {
await fs.mkdir(first)
await initRepo(first)
await $`git remote add origin git@github.com:example/worktree-fixture.git`.cwd(first).quiet()
await $`git clone --no-hardlinks ${first} ${second}`.quiet()
await $`git remote set-url origin https://github.com/example/worktree-fixture.git`.cwd(second).quiet()
await fs.mkdir(nested)
await fs.mkdir(config)
await Bun.write(path.join(config, "opencode.json"), JSON.stringify({ worktree: { directory: destination } }))
await Bun.write(
path.join(nested, "opencode.json"),
JSON.stringify({
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "test-copy" } },
],
}),
)
})
const server = yield* startServer(config)
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
const a = await api.location.get({ location: { directory: nested } })
const b = await api.location.get({ location: { directory: second } })
expect(a.project.id).toBe(b.project.id)
const custom = await api.worktree.create({ location: { directory: nested }, name: "custom" })
const builtin = await api.worktree.create({ location: { directory: second }, name: "builtin" })
expect(custom.directory).toBe(path.join(destination, "custom"))
expect(builtin.directory).toBe(path.join(destination, "builtin"))
const otherRows = await api.worktree.list({ location: { directory: second } })
expect(otherRows).toContainEqual({ directory: custom.directory, strategy: "test-copy" })
const rows = await api.worktree.list({ location: { directory: nested } })
expect(rows).toContainEqual({
directory: custom.directory,
strategy: "test-copy",
})
expect(rows).toContainEqual({ directory: builtin.directory, strategy: "git" })
await Bun.write(path.join(custom.directory, "dirty.txt"), "keep me")
const remove = new URL("/api/worktree", server.base)
remove.searchParams.set("location[directory]", second)
const unavailable = await fetch(remove, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: custom.directory, force: true }),
})
expect(unavailable.status).toBe(400)
expect(await unavailable.json()).toMatchObject({
data: { message: "Worktree strategy unavailable: test-copy" },
})
expect(await Bun.file(path.join(custom.directory, "dirty.txt")).text()).toBe("keep me")
remove.searchParams.set("location[directory]", nested)
const failure = await fetch(remove, {
method: "DELETE",
headers: { ...server.headers, "content-type": "application/json" },
body: JSON.stringify({ directory: custom.directory, force: false }),
})
expect(failure.status).toBe(400)
expect(await failure.json()).toMatchObject({ data: { forceRequired: true } })
expect(await Bun.file(path.join(custom.directory, "dirty.txt")).text()).toBe("keep me")
await api.worktree.remove({
location: { directory: nested },
directory: custom.directory,
force: true,
})
await api.worktree.remove({
location: { directory: second },
directory: builtin.directory,
force: false,
})
expect((await api.worktree.list({ location: { directory: nested } })).filter((row) => row.strategy)).toEqual([])
})
}),
30_000,
)
it.live(
"plugin calls await a different location's strategy and directory configuration",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-delegate-")))
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target")
const destination = path.join(tmp.path, "copies")
yield* Effect.promise(async () => {
for (const directory of [source, target]) {
await fs.mkdir(directory)
await initRepo(directory)
await $`git remote add origin git@github.com:example/delegate-fixture.git`.cwd(directory).quiet()
}
await Bun.write(
path.join(source, "opencode.json"),
JSON.stringify({
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-delegate"), options: { directory: target } },
],
}),
)
await Bun.write(
path.join(target, "opencode.json"),
JSON.stringify({
worktree: { directory: destination },
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "target-copy" } },
],
}),
)
})
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
await api.location.get({ location: { directory: source } })
const url = new URL("/api/plugin/await-activation", server.base)
url.searchParams.set("location[directory]", source)
expect((await fetch(url, { method: "POST", headers: server.headers })).status).toBe(204)
expect(await api.worktree.list({ location: { directory: target } })).toContainEqual({
directory: path.join(destination, "delegated"),
strategy: "target-copy",
})
})
}),
30_000,
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -1,6 +1,6 @@
const providers = [
"opencode-go",
"opencode",
"opencode-go",
"anthropic",
"openai",
"google",
@@ -15,8 +15,8 @@ const provider = {
}
const popular = [
{ id: "opencode-go", name: "OpenCode Go", models: {} },
{ id: "opencode", name: "OpenCode Zen", models: {} },
{ id: "opencode-go", name: "OpenCode Go", models: {} },
{ id: "openai", name: "OpenAI", models: {} },
provider,
{ id: "google", name: "Google", models: {} },
@@ -27,7 +27,6 @@ type ProjectDirectory = WorktreeListOutput[number]
type DialogMoveSessionProps = {
projectID: string
location?: { directory: string; workspaceID?: string }
current?: MoveSessionSelection
onSelect: (selection: MoveSessionSelection) => void
onCurrentChange?: (selection: MoveSessionSelection) => void
@@ -46,11 +45,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const toast = useToast()
const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts()
const location = createMemo(() => sessionData.location.info(props.location))
const worktreeLocation = () => ({
directory: props.location?.directory ?? location()?.directory ?? paths.cwd,
workspace: props.location?.workspaceID ?? location()?.workspaceID,
})
const location = createMemo(() => sessionData.location.info())
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -81,10 +76,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
})
const [directories, { refetch }] = createResource(
() => (props.fixture || props.initialRemoving ? undefined : worktreeLocation()),
async (location, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
() => (props.fixture || props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try {
const directories = await client.api.worktree.list({ location })
await client.api.worktree.refresh({ projectID })
const directories = await client.api.worktree.list({ projectID })
setLoadError(undefined)
return directories
} catch (error) {
@@ -227,13 +223,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
setToDelete(undefined)
setRemoving(selected.directory)
setWorking(true)
const request = {
directory: selected.directory,
location: worktreeLocation(),
}
const error = await client.api.worktree
.remove({
...request,
projectID: props.projectID,
directory: selected.directory,
force: false,
})
.then(
@@ -258,7 +251,8 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
reopen(selected.directory)
const forcedError = await client.api.worktree
.remove({
...request,
projectID: props.projectID,
directory: selected.directory,
force: true,
})
.then(
+5 -2
View File
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import path from "path"
import { useTuiPaths } from "../../context/runtime"
import { errorMessage } from "../../util/error"
import { useDialog } from "../../ui/dialog"
@@ -37,7 +38,10 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const project = data.location.info(location)?.project
if (!project) throw new Error("Unable to determine current project")
const result = await client.api.worktree.create({
location: { directory: location.directory, workspace: location.workspaceID },
projectID: project.id,
strategy: "git",
from: project.canonical,
directory: path.join(paths.worktree, project.id.slice(0, 6)),
name,
})
const directory = result.directory
@@ -70,7 +74,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
dialog.replace(() => (
<DialogMoveSession
projectID={projectID}
location={session?.location ?? homeLocation()}
current={
destination() ??
(session
+1
View File
@@ -84,6 +84,7 @@ export const Definitions = {
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
@@ -0,0 +1,82 @@
import type { Plugin } from "@opencode-ai/plugin/tui"
import type { PanelInput } from "@opencode-ai/plugin/tui/context"
import { MouseButton } from "@opentui/core"
import { createMemo, For, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { stringWidth } from "../../util/string-width"
export function DiffViewerFooter(props: { context: Plugin.Context; panel: PanelInput; onHelp: () => void }) {
const theme = useTheme()
const key = (...ids: string[]) =>
ids
.map((id) => props.context.keymap.shortcuts(id)[0])
.filter(Boolean)
.join("/")
const focus = () => key("pane.focus.right")
const help = () => key("diff.help")
const hints = createMemo(() => {
const available = props.panel.width - 4 - stringWidth(`${help()} see all`) - 2
return [
{ key: key("pane.focus.left"), label: "focus session" },
{ key: key("diff.toggle_fullscreen"), label: "full screen" },
{ key: key("diff.down", "diff.up"), label: "scroll" },
{ key: key("diff.next_file", "diff.previous_file"), label: "files" },
{ key: key("diff.next_hunk", "diff.previous_hunk"), label: "hunks" },
{ key: key("diff.close"), label: "close" },
]
.filter((hint) => hint.key)
.reduce<{ width: number; items: { key: string; label: string }[] }>(
(result, hint) => {
const width = result.width + (result.items.length ? 2 : 0) + stringWidth(`${hint.key} ${hint.label}`)
return width <= available ? { width, items: [...result.items, hint] } : result
},
{ width: 0, items: [] },
).items
})
return (
<box id="diff-footer" height={3} paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexShrink={0}>
<Show
when={props.panel.focused}
fallback={
<text
fg={theme.text.subdued}
wrapMode="none"
truncate
onMouseUp={(event) => {
if (event.button === MouseButton.LEFT) props.panel.focus()
}}
>
<span style={{ fg: theme.text.default }}>{focus() || "click"}</span>
{" focus diff"}
</text>
}
>
<box flexDirection="row" gap={2}>
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
<For each={hints()}>
{(hint, index) => (
<>
{index() ? " " : ""}
<span style={{ fg: theme.text.default }}>{hint.key}</span>
{` ${hint.label}`}
</>
)}
</For>
</text>
<text
fg={theme.text.subdued}
flexShrink={0}
wrapMode="none"
onMouseUp={(event) => {
if (event.button === MouseButton.LEFT) props.onHelp()
}}
>
<span style={{ fg: theme.text.default }}>{help()}</span>
{help() ? " see all" : "see all"}
</text>
</box>
</Show>
</box>
)
}
@@ -2,7 +2,7 @@
import type { FileDiffInfo, LocationRef } from "@opencode-ai/client"
import type { Vcs } from "@opencode-ai/schema/vcs"
import { Plugin } from "@opencode-ai/plugin/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
import type { KeymapCommand, PanelInput, Route } from "@opencode-ai/plugin/tui/context"
import {
MouseButton,
TextAttributes,
@@ -15,6 +15,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { DiffFileMenu } from "./diff-viewer-file-menu"
import { DiffViewerFooter } from "./diff-viewer-footer"
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
import { DialogSelect } from "../../ui/dialog-select"
import { EmptyBorder } from "../../ui/border"
@@ -23,7 +24,7 @@ import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useThemes } from "../../context/theme"
import { useTheme, useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
import {
allExpandedFileTreeDirectories,
@@ -76,27 +77,22 @@ function diffSourceLabel(mode: DiffMode) {
return "Uncommitted"
}
function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
function DiffViewer(props: {
context: Plugin.Context
sessionID?: string
initialMode?: DiffMode
panel?: PanelInput
onClose: () => void
}) {
const config = useConfig()
const [memory, updateMemory] = props.context.storage.memory<{
source?: DiffMode
bases: Record<string, string>
}>("review", { initial: { bases: {} } })
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
| {
mode?: DiffMode
sessionID?: string
returnRoute?: Route
}
| undefined
}
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const [mode, setMode] = createSignal(props.initialMode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const sessionID = params()?.sessionID
const sessionID = props.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default()
@@ -158,51 +154,66 @@ function DiffViewer(props: { context: Plugin.Context }) {
if (!base) return "Base not reported"
return `vs ${base.name}`
}
return (
<DiffViewerContent
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
panel={props.panel}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={props.onClose}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
/>
)
}
function DiffRoute(props: {
context: Plugin.Context
data?: { mode?: DiffMode; sessionID?: string; returnRoute?: Route }
}) {
const dimensions = useTerminalDimensions()
return (
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
<DiffViewerContent
<DiffViewer
context={props.context}
files={result()?.files ?? []}
loading={diff.loading}
error={diff.error}
mode={mode()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
preferences={config.data.diffs}
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
onPreferencesChange={(value) => {
void config
.update((draft) => {
draft.diffs = { ...draft.diffs, ...value }
})
.catch(() => {})
}}
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
onSwitchSource={(mode) => {
updateMemory((draft) => {
draft.source = mode
})
setMode(mode)
}}
onChooseBase={() => {
const target = { ...location() }
const key = baseKey()
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
props.context.ui.dialog.show(() => (
<DiffBaseDialog
context={props.context}
location={target}
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
onSelect={(ref) =>
updateMemory((draft) => {
draft.bases[key] = ref
})
}
/>
))
}}
sessionID={props.data?.sessionID}
initialMode={props.data?.mode}
onClose={() => props.context.ui.router.navigate(props.data?.returnRoute ?? { type: "home" })}
/>
</box>
)
@@ -267,6 +278,7 @@ export function DiffViewerContent(props: {
navigation?: "tree" | "list"
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
preferences?: DiffPreferences
panel?: PanelInput
onPreferencesChange?: (value: DiffPreferences) => void
onClose: () => void
onSwitchSource: (mode: DiffMode) => void
@@ -276,19 +288,21 @@ export function DiffViewerContent(props: {
const dimensions = useTerminalDimensions()
const config = useConfig()
const dialog = props.context.ui.dialog
const theme = useThemes().current
const theme = useTheme()
const currentSyntax = useThemes().currentSyntax
const files = () => props.files
const docked = () => props.panel?.presentation === "panel"
const width = () => props.panel?.width ?? dimensions().width
const mode = () => props.mode
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
const showFileTree = createMemo(
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
() => !docked() && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
)
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
const fileTreeWidth = createMemo(() =>
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
)
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (docked() ? 2 : 4))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
const view = createMemo(() =>
@@ -308,8 +322,6 @@ export function DiffViewerContent(props: {
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
onCleanup(() => dialog.clear())
createEffect(() => {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
setSelectedFileIndex(undefined)
@@ -517,6 +529,7 @@ export function DiffViewerContent(props: {
if (event.button !== MouseButton.RIGHT) return
event.preventDefault()
event.stopPropagation()
props.panel?.focus()
setFileMenu({ fileIndex, x: event.x, y: event.y })
}
@@ -636,7 +649,9 @@ export function DiffViewerContent(props: {
id: "diff.toggle_file_tree",
title: "Toggle diff viewer file tree",
group: "VCS",
enabled: () => !docked(),
run() {
if (docked()) return
const next = !fileTreeEnabled()
setFileTreeEnabled(next)
props.onPreferencesChange?.({ tree: next })
@@ -684,6 +699,13 @@ export function DiffViewerContent(props: {
props.onPreferencesChange?.({ view: next })
},
},
{
id: "diff.toggle_fullscreen",
title: "Toggle diff viewer full screen",
group: "VCS",
enabled: () => props.panel !== undefined,
run: () => props.panel?.toggleFullscreen(),
},
{
id: "diff.help",
title: "Show more diff viewer shortcuts",
@@ -749,7 +771,14 @@ export function DiffViewerContent(props: {
}
const openHelpDialog = () => {
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
dialog.show(() => (
<DiffViewerHelpDialog
context={props.context}
single={singlePatch()}
fullscreen={props.panel !== undefined}
tree={!docked()}
/>
))
dialog.set({ size: "medium", centered: true })
}
@@ -875,7 +904,13 @@ export function DiffViewerContent(props: {
/>
</Show>
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
<box
flexGrow={1}
minWidth={0}
minHeight={0}
paddingLeft={docked() ? 1 : 2}
paddingRight={docked() ? 1 : 2}
>
<box
id="diff-patch-top-edge"
ref={(edge: BoxRenderable) => {
@@ -954,7 +989,7 @@ export function DiffViewerContent(props: {
zIndex={1}
backgroundColor={background()}
paddingLeft={1}
paddingRight={1}
paddingRight={docked() ? 0 : 1}
paddingBottom={1}
>
<box flexGrow={1} minWidth={0}>
@@ -1057,7 +1092,10 @@ export function DiffViewerContent(props: {
</Match>
</Switch>
</box>
<Show when={!showFileTree()}>
<Show when={docked() ? props.panel : undefined}>
{(panel) => <DiffViewerFooter context={props.context} panel={panel()} onHelp={openHelpDialog} />}
</Show>
<Show when={!showFileTree() && !docked()}>
<box position="absolute" top={0} right={0} width={1} height={1}>
<HelpShortcut compact />
</box>
@@ -1077,7 +1115,7 @@ export function DiffViewerContent(props: {
)
}
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean; tree: boolean }) {
const dimensions = useTerminalDimensions()
const theme = props.context.theme.contextual.elevated
const shortcut =
@@ -1115,8 +1153,11 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
rows: [
{ shortcut: shortcut("diff.toggle_view"), label: "Split / unified" },
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
...(props.tree ? [{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" }] : []),
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
...(props.fullscreen
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
: []),
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
],
},
@@ -1185,6 +1226,13 @@ function Commands(props: { context: Plugin.Context }) {
palette: true,
run() {
const route = props.context.ui.router.current()
props.context.ui.dialog.clear()
const current = props.context.ui.panel.current()
if (route.type === "session" && current?.sessionID === route.sessionID && current.name === ROUTE) {
props.context.ui.panel.close()
return
}
if (props.context.ui.panel.open(ROUTE)) return
const returnRoute: Route =
route.type === "home"
? { type: "home" }
@@ -1204,7 +1252,6 @@ function Commands(props: { context: Plugin.Context }) {
returnRoute,
},
})
props.context.ui.dialog.clear()
},
},
],
@@ -1215,9 +1262,17 @@ function Commands(props: { context: Plugin.Context }) {
export default Plugin.define({
id: "opencode.diffs",
setup(context) {
context.ui.slot({
append: "session.panel",
render: (panel) => (
<Show when={panel.name === ROUTE}>
<DiffViewer context={context} sessionID={panel.sessionID} panel={panel} onClose={panel.close} />
</Show>
),
})
context.ui.router.register({
name: ROUTE,
render: () => <DiffViewer context={context} />,
render: ({ data }) => <DiffRoute context={context} data={data} />,
})
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
},
+2 -2
View File
@@ -144,8 +144,8 @@ function UpdateNotification() {
onMouseUp={() => update.open?.("notification")}
>
<UpdateMessage
title={state.type === "installed" ? "Update installed" : "Update available"}
description={`Version ${state.version} is ${state.type === "installed" ? "installed" : "available"}. Click for more details`}
title="Update available"
description={`Version ${state.version} is available. Click for more details`}
backdrop={
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
}
@@ -69,6 +69,28 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}
})
test.each([80, 160])("standalone full-screen diff does not expose panel-only toggling at %i columns", async (width) => {
const viewer = await renderDiffViewer([], {
width,
initialRoute: {
type: "plugin",
id: "opencode.diffs",
name: "diff",
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
},
})
try {
const command = viewer.commands.get("diff.toggle_fullscreen")
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
const route = viewer.current()
viewer.app.mockInput.pressKey("f")
await viewer.app.flush()
expect(viewer.current()).toEqual(route)
} finally {
viewer.app.renderer.destroy()
}
})
test("ctrl+c closes the diff viewer without exiting the application", async () => {
const viewer = await renderDiffViewer([])
@@ -1918,6 +1940,7 @@ async function renderDiffViewer(
},
ui: {
dialog: createDialogApi(dialog, (render) => render()),
panel: { open: () => false, close: () => {}, current: () => undefined },
router: {
register(page: Page) {
if (page.name === "diff") renderDiff = page.render
+12 -37
View File
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import path from "path"
import { InputRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { usePromptMove } from "../../../src/component/prompt/move"
@@ -28,7 +29,7 @@ test.each([
{ name: "an uncached session in a linked worktree", directory: linked, worktree: linked },
{ name: "a session in a linked worktree subdirectory", directory: `${linked}/packages/tui`, worktree: linked },
{ name: "the home/default location", directory: `${clone}/packages/tui`, home: true },
])("passes the current location and uses server worktree defaults for $name", async (input) => {
])("creates from the clone's main worktree for $name", async (input) => {
const fixture = await renderMove(input)
try {
await fixture.data.project.sync()
@@ -45,7 +46,9 @@ test.each([
await fixture.create()
expect(fixture.requests).toEqual([{ payload: { name: "fresh" }, directory: input.directory, workspace: null }])
expect(fixture.requests).toEqual([
{ strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" },
])
expect(fixture.data.location.info({ directory: created })?.project.canonical).toBe(clone)
expect(fixture.reads.locations.filter((directory) => directory === input.directory)).toHaveLength(1)
expect(fixture.reads.session).toBe(input.home ? 0 : 1)
@@ -70,11 +73,11 @@ test.each([
const frame = await fixture.create()
expect(fixture.reads.worktrees).toEqual([selected.directory])
expect(fixture.reads.worktrees).toEqual(["proj_test"])
expect(frame).toContain(clone)
expect(frame.indexOf(clone)).toBeLessThan(frame.indexOf(main))
expect(fixture.requests).toEqual([
{ payload: { name: "fresh" }, directory: `${clone}/packages/tui`, workspace: input.workspaceID ?? null },
{ strategy: "git", from: clone, directory: path.join("/tmp/opencode", "proj_t"), name: "fresh" },
])
expect(fixture.data.location.info(selected)?.project.canonical).toBe(clone)
expect(fixture.moves).toEqual([])
@@ -83,23 +86,6 @@ test.each([
}
})
test("removal uses the current configuration location, not the destination directory", async () => {
const fixture = await renderMove({ directory: clone, home: true })
try {
await fixture.move.open()
await fixture.app.waitForFrame((frame) => frame.includes("Move session") && frame.includes(linked))
await fixture.app.mockInput.typeText("linked")
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(clone))
fixture.app.mockInput.pressKey("d", { ctrl: true })
await fixture.app.waitForFrame((frame) => frame.includes("again to confirm"))
fixture.app.mockInput.pressKey("d", { ctrl: true })
await fixture.app.waitFor(() => fixture.removals.length === 1)
expect(fixture.removals).toEqual([{ payload: { directory: linked, force: false }, directory: clone }])
} finally {
fixture.app.renderer.destroy()
}
})
test.each([
{ name: "session", unavailable: "session" as const },
{ name: "location", unavailable: "location" as const },
@@ -129,7 +115,6 @@ async function renderMove(input: {
}) {
const launch = input.launch ?? (input.home ? input.directory : main)
const requests: unknown[] = []
const removals: unknown[] = []
const moves: unknown[] = []
const reads = { session: 0, locations: [] as string[], worktrees: [] as string[] }
const calls = createFetch(async (url, request) => {
@@ -171,30 +156,21 @@ async function renderMove(input: {
},
})
}
if (url.pathname === "/api/worktree") {
if (url.pathname === "/api/worktree/proj_test" || url.pathname === "/api/worktree/proj_launch") {
if (request.method === "GET") {
const directory = url.searchParams.get("location[directory]") ?? launch
reads.worktrees.push(directory)
reads.worktrees.push(url.pathname.slice("/api/worktree/".length))
return json(
directory === launch && input.launchProjectID
url.pathname === "/api/worktree/proj_launch"
? [{ directory: launch }]
: [{ directory: main }, { directory: clone }, { directory: linked, strategy: "git" }],
)
}
if (request.method === "POST") {
requests.push({
payload: await request.json(),
directory: url.searchParams.get("location[directory]"),
workspace: url.searchParams.get("location[workspace]"),
})
requests.push(await request.json())
return json({ directory: created })
}
if (request.method === "DELETE") {
removals.push({ payload: await request.json(), directory: url.searchParams.get("location[directory]") })
return new Response(null, { status: 204 })
}
}
if (url.pathname === "/api/worktree/refresh") return new Response(null, { status: 204 })
if (url.pathname === "/api/worktree/proj_launch/refresh") return new Response(null, { status: 204 })
if (url.pathname === "/api/session/ses_clone/move") {
moves.push(await request.json())
return new Response(null, { status: 204 })
@@ -253,7 +229,6 @@ async function renderMove(input: {
toast,
location,
requests,
removals,
moves,
reads,
async create() {
+1
View File
@@ -209,6 +209,7 @@ test("centralizes named command defaults and resolves explicit none", () => {
"diff.next_hunk": "]",
"diff.previous_hunk": "[",
"diff.mark_reviewed": "m",
"diff.toggle_fullscreen": "f",
"diff.help": "?,shift+?,shift+/",
}
const config = resolve({}, { terminalSuspend: true })
+2 -2
View File
@@ -112,12 +112,12 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
})
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
if (url.pathname === "/api/project") return json([])
if (url.pathname === "/api/worktree") {
if (url.pathname === "/api/worktree/proj_test") {
if (request.method === "GET") return json([{ directory: worktree }])
if (request.method === "POST") return json({ directory: `${worktree}/created` })
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/worktree/refresh") return new Response(null, { status: 204 })
if (url.pathname === "/api/worktree/proj_test/refresh") return new Response(null, { status: 204 })
if (url.pathname === "/api/shell")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
+625
View File
@@ -0,0 +1,625 @@
import { expect, test } from "bun:test"
import {
DiffRenderable,
InputRenderable,
MouseButton,
type Renderable,
ScrollBoxRenderable,
TextareaRenderable,
} from "@opentui/core"
import type { FileDiffInfo, FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client"
import type { Config } from "../src/config"
import { createAppFixture } from "./fixture/app"
import { directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test("/diff focuses the production session panel without sending typing to the prompt", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path)
await ready(setup)
const prompt = editor(setup)
const transcript = node(setup, "msg_panel_11")
expect(setup.requests.base).toHaveLength(0)
expect(setup.requests.diff).toHaveLength(0)
await setup.mockInput.typeText("/diff")
await setup.waitForFrame((frame) => frame.includes("Open diff viewer"))
setup.mockInput.pressEnter()
await panelReady(setup)
const panel = node(setup, "session-panel")
const patches = scroll(setup)
expect(panel.width).toBeLessThan(setup.renderer.width)
expect(setup.renderer.currentFocusedEditor).toBeNull()
expect(node(setup, "msg_panel_11")).toBe(transcript)
await setup.mockInput.typeText("xyz")
setup.mockInput.pressEnter()
await setup.flush()
expect(prompt.plainText).toBe("")
expect(setup.requests.prompt).toHaveLength(0)
focus(setup, "left")
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
await setup.mockInput.typeText("fjm")
await setup.flush()
expect(prompt.plainText).toBe("fjm")
expect(panel.width).toBeLessThan(setup.renderer.width)
expect(patches.scrollTop).toBe(0)
expect(text(setup, "diff-review-count")).toBe("0/4")
expect(text(setup, "diff-footer")).toContain("focus diff")
focus(setup, "right")
await setup.waitFor(() => setup.renderer.currentFocusedRenderable === panel)
setup.mockInput.pressKey("j")
await setup.waitFor(() => patches.scrollTop > 0)
expect(prompt.plainText).toBe("fjm")
setup.mockInput.pressKey("q")
await setup.waitFor(() => !setup.renderer.root.findDescendantById("session-panel"))
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
expect(node(setup, "msg_panel_11")).toBe(transcript)
expect(prompt.plainText).toBe("fjm")
expect(setup.requests.prompt).toHaveLength(0)
})
test("repeated fullscreen toggles retain the viewer, transcript, scroll, and reviewed files", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path)
await ready(setup)
const prompt = editor(setup)
const transcript = node(setup, "msg_panel_11")
setup.mockInput.pressKey("F6")
await panelReady(setup)
const panel = node(setup, "session-panel")
const patches = scroll(setup)
const splitWidth = panel.width
const history = descendants(setup.renderer.root).find(
(item) => item instanceof ScrollBoxRenderable && item.findDescendantById("msg_panel_11"),
)
if (!(history instanceof ScrollBoxRenderable)) throw new Error("Missing session transcript scrollbox")
history.scrollTo(4)
await setup.flush()
const historyTop = history.scrollTop
expect(historyTop).toBeGreaterThan(0)
setup.mockInput.pressKey("m")
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
await setup.flush()
setup.mockInput.pressKey("n")
await setup.waitFor(() => patches.scrollTop > 0)
setup.mockInput.pressKey("j")
await setup.flush()
const top = patches.scrollTop
const header = node(setup, "diff-file-header-1")
const diffs = descendants(patches).filter((item) => item instanceof DiffRenderable)
expect(diffs).toHaveLength(3)
expect(top).toBeGreaterThan(0)
for (const fullscreen of [true, false, true, false]) {
setup.mockInput.pressKey("f")
await setup.waitFor(() => node(setup, "session-panel").width === (fullscreen ? setup.renderer.width : splitWidth))
await setup.flush()
expect(node(setup, "session-panel")).toBe(panel)
expect(scroll(setup)).toBe(patches)
expect(patches.scrollTop).toBe(top)
expect(node(setup, "diff-file-header-1")).toBe(header)
const current = descendants(patches).filter((item) => item instanceof DiffRenderable)
expect(current).toHaveLength(diffs.length)
current.forEach((item, index) => expect(item).toBe(diffs[index]))
expect(text(setup, "diff-review-count")).toBe("1/4")
expect(node(setup, "msg_panel_11")).toBe(transcript)
expect(transcript.isDestroyed).toBe(false)
expect(history.isDestroyed).toBe(false)
expect(history.scrollTop).toBe(historyTop)
expect(prompt.isDestroyed).toBe(false)
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
expect(setup.requests.base).toHaveLength(1)
expect(setup.requests.diff).toHaveLength(1)
}
setup.mockInput.pressKey("HOME")
await setup.waitForFrame((frame) => frame.includes("file00.txt") && text(setup, "diff-file-header-0").includes("✓"))
expect(text(setup, "diff-file-header-0")).toContain("✓")
expect(setup.requests.diff[0].searchParams.get("location[directory]")).toBe(directory)
expect(setup.requests.diff[0].searchParams.get("base")).toBe("refs/heads/v2")
})
test("docked tree commands leave the full-screen tree preference unchanged", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path, { diffs: { tree: true } })
await ready(setup)
setup.mockInput.pressKey("F6")
await panelReady(setup)
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
setup.mockInput.pressKey("b")
await setup.flush()
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
setup.mockInput.pressKey("f")
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-files") !== undefined)
setup.mockInput.pressKey("b")
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-files") === undefined)
setup.mockInput.pressKey("f")
await setup.waitFor(() => node(setup, "session-panel").width < setup.renderer.width)
setup.mockInput.pressKey("f")
await setup.waitFor(() => node(setup, "session-panel").width === setup.renderer.width)
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
expect(setup.requests.diff).toHaveLength(1)
})
test("right-clicking an inactive diff focuses its screen-relative file menu", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path)
await ready(setup)
const prompt = editor(setup)
setup.mockInput.pressKey("F6")
await panelReady(setup)
const panel = node(setup, "session-panel")
focus(setup, "left")
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
const header = node(setup, "diff-file-header-0")
await setup.mockMouse.click(header.x + 1, header.y, MouseButton.RIGHT)
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-file-menu") !== undefined)
const menu = node(setup, "diff-file-menu")
expect(menu.x).toBe(Math.min(header.x + 1, setup.renderer.width - menu.width))
expect(menu.x + menu.width).toBeLessThanOrEqual(setup.renderer.width)
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
setup.mockInput.pressEnter()
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
expect(setup.renderer.root.findDescendantById("diff-file-menu")).toBeUndefined()
expect(prompt.plainText).toBe("")
})
test.each([60, 80])("a panel opened at %i columns can split after growing without remounting", async (width) => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path, { width })
await ready(setup)
const prompt = editor(setup)
const transcript = node(setup, "msg_panel_11")
setup.mockInput.pressKey("F6")
await panelReady(setup)
const panel = node(setup, "session-panel")
const patches = scroll(setup)
expect(panel.width).toBe(width)
setup.mockInput.pressKey("f")
await setup.flush()
expect(panel.width).toBe(width)
expect(prompt.plainText).toBe("")
setup.resize(160, 36)
await setup.flush()
const grownWidth = panel.width
setup.mockInput.pressKey("f")
await setup.waitFor(() => node(setup, "session-panel").width !== grownWidth)
expect(node(setup, "session-panel")).toBe(panel)
setup.mockInput.pressKey("f")
await setup.waitFor(() => panel.width === grownWidth)
if (panel.width === setup.renderer.width) {
setup.mockInput.pressKey("f")
await setup.waitFor(() => panel.width < setup.renderer.width)
}
expect(panel.x).toBeGreaterThan(0)
setup.resize(80, 36)
await setup.waitFor(() => panel.width === 80)
expect(scroll(setup)).toBe(patches)
expect(node(setup, "msg_panel_11")).toBe(transcript)
expect(prompt.isDestroyed).toBe(false)
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
expect(setup.requests.base).toHaveLength(1)
expect(setup.requests.diff).toHaveLength(1)
})
test("split eligibility uses the columns remaining beside vertical session tabs", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path, {
width: 122,
tabs: { enabled: true, layout: "vertical" },
})
await ready(setup)
await setup.waitFor(() => editor(setup).x >= 42)
setup.mockInput.pressKey("F6")
await panelReady(setup)
const panel = node(setup, "session-panel")
expect(panel.x).toBe(0)
expect(panel.width).toBe(122)
setup.mockInput.pressKey("f")
await setup.flush()
expect(panel.x).toBe(0)
expect(panel.width).toBe(122)
setup.resize(180, 36)
await setup.flush()
const grownWidth = panel.width
setup.mockInput.pressKey("f")
await setup.waitFor(() => node(setup, "session-panel").width !== grownWidth)
expect(node(setup, "session-panel")).toBe(panel)
expect(setup.requests.base).toHaveLength(1)
expect(setup.requests.diff).toHaveLength(1)
})
test("panel focus, scrolling, and fullscreen use customized keys and footer hints", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path, {
width: 240,
keybinds: {
leader: "ctrl+space",
"pane.focus.left": "<leader>h",
"pane.focus.right": "<leader>l",
"diff.toggle_fullscreen": "f7",
"diff.down": "x",
"diff.up": "z",
},
})
await ready(setup)
const prompt = editor(setup)
setup.mockInput.pressKey("F6")
await panelReady(setup)
const panel = node(setup, "session-panel")
const patches = scroll(setup)
const splitWidth = panel.width
expect(text(setup, "diff-footer")).toContain("ctrl+space h")
expect(text(setup, "diff-footer")).toContain("f7")
expect(text(setup, "diff-footer")).not.toContain("ctrl+x")
setup.mockInput.pressKey("f")
setup.mockInput.pressKey("j")
focus(setup, "left")
await setup.flush()
expect(panel.width).toBe(splitWidth)
expect(patches.scrollTop).toBe(0)
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
setup.mockInput.pressKey("x")
await setup.waitForFrame(() => patches.scrollTop > 0)
setup.mockInput.pressKey("F7")
await setup.waitFor(() => panel.width === setup.renderer.width)
setup.mockInput.pressKey("F7")
await setup.waitFor(() => panel.width === splitWidth)
setup.mockInput.pressKey(" ", { ctrl: true })
setup.mockInput.pressKey("h")
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
await setup.waitForFrame(() => text(setup, "diff-footer").includes("ctrl+space l"))
setup.mockInput.pressKey(" ", { ctrl: true })
setup.mockInput.pressKey("l")
await setup.waitFor(() => setup.renderer.currentFocusedRenderable === panel)
expect(prompt.plainText).toBe("")
})
test.each(["none", false] as const)(
"disabled panel keys (%s) have no behavioral or footer fallbacks",
async (binding) => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path, {
width: 240,
keybinds: {
"pane.focus.left": binding,
"pane.focus.right": binding,
"diff.toggle_fullscreen": binding,
"diff.down": binding,
"diff.up": binding,
"diff.next_file": binding,
"diff.previous_file": binding,
"diff.next_hunk": binding,
"diff.previous_hunk": binding,
"diff.mark_reviewed": binding,
"diff.close": binding,
"diff.help": binding,
},
})
await ready(setup)
const prompt = editor(setup)
setup.mockInput.pressKey("F6")
await panelReady(setup)
const panel = node(setup, "session-panel")
const splitWidth = panel.width
const patches = scroll(setup)
await setup.mockInput.typeText("fjknpm?[]q")
focus(setup, "left")
await setup.flush()
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
expect(panel.width).toBe(splitWidth)
expect(patches.scrollTop).toBe(0)
expect(text(setup, "diff-review-count")).toBe("0/4")
expect(prompt.plainText).toBe("")
expect(text(setup, "diff-footer")).not.toMatch(/ctrl\+x|full screen|j\/k|n\/p|hunks|q close|\? see all/)
await setup.mockMouse.click(prompt.x + 1, prompt.y)
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
await setup.flush()
expect(text(setup, "diff-footer")).not.toContain("ctrl+x")
focus(setup, "right")
await setup.flush()
expect(setup.renderer.currentFocusedEditor).toBe(prompt)
},
)
test("Enter in the diff panel cannot approve a pending permission in either presentation", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path, { permission: true })
await setup.waitForFrame((frame) => frame.includes("Permission required"))
const permissionNode = node(setup, "session.permission")
setup.mockInput.pressKey("F6")
await panelReady(setup)
const panel = node(setup, "session-panel")
const splitWidth = panel.width
setup.mockInput.pressEnter()
await setup.flush()
expect(setup.requests.permission).toHaveLength(0)
setup.mockInput.pressKey("f")
await setup.waitFor(() => panel.width === setup.renderer.width)
setup.mockInput.pressEnter()
await setup.flush()
expect(setup.requests.permission).toHaveLength(0)
expect(node(setup, "session.permission")).toBe(permissionNode)
setup.mockInput.pressKey("f")
await setup.waitFor(() => panel.width === splitWidth)
focus(setup, "left")
await setup.waitFor(() => setup.renderer.currentFocusedRenderable !== panel)
setup.mockInput.pressEnter()
await setup.waitFor(() => setup.requests.permission.length === 1)
expect(setup.requests.permission).toEqual([{ reply: "once" }])
})
test.each([true, false])("a form cannot capture diff keys (form already pending: %s)", async (pending) => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path, { form: pending })
await setup.waitForFrame((frame) => frame.includes(pending ? setup.form.title : "Transcript item 11"))
setup.mockInput.pressKey("F6")
await panelReady(setup)
if (!pending) {
setup.events.emit({
id: "evt_panel_form",
created: 20,
type: "form.created",
location: { directory },
data: { form: setup.form },
})
await setup.waitForFrame((frame) => frame.includes(setup.form.title))
}
const panel = node(setup, "session-panel")
const patches = scroll(setup)
const splitWidth = panel.width
setup.mockInput.pressKey("j")
await setup.waitFor(() => patches.scrollTop > 0)
setup.mockInput.pressKey("m")
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
setup.mockInput.pressEnter()
await setup.flush()
expect(setup.requests.form).toHaveLength(0)
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
setup.mockInput.pressKey("f")
await setup.waitFor(() => panel.width === setup.renderer.width)
setup.mockInput.pressKey("f")
await setup.waitFor(() => panel.width === splitWidth)
expect(setup.requests.form).toHaveLength(0)
focus(setup, "left")
await setup.waitFor(() => setup.renderer.currentFocusedRenderable !== panel)
setup.mockInput.pressKey("x")
await setup.waitFor(() => setup.renderer.currentFocusedEditor?.plainText === "answerx")
setup.mockInput.pressEnter()
await setup.waitFor(() => setup.requests.form.length === 1)
expect(setup.requests.form).toEqual([{ answer: { target: "answerx" } }])
})
test("disabling the registered diff plugin closes its open panel and removes its bindings", async () => {
await using state = await tmpdir()
await using setup = await createSessionFixture(state.path)
await ready(setup)
const prompt = editor(setup)
const transcript = node(setup, "msg_panel_11")
setup.mockInput.pressKey("F6")
await panelReady(setup)
const patches = scroll(setup)
focus(setup, "left")
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
await setup.mockInput.typeText("/plugins")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("show internal"))
setup.mockInput.pressKey("a", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("opencode.diffs"))
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof InputRenderable)
await setup.mockInput.typeText("opencode.diffs")
await setup.waitFor(() => setup.renderer.currentFocusedEditor?.plainText === "opencode.diffs")
await setup.flush()
setup.mockInput.pressEnter()
await setup.waitFor(() => !setup.renderer.root.findDescendantById("session-panel"))
await setup.flush()
if (setup.captureCharFrame().includes("Plugins")) setup.mockInput.pressEscape()
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
expect(patches.isDestroyed).toBe(true)
expect(node(setup, "msg_panel_11")).toBe(transcript)
setup.mockInput.pressKey("F6")
await setup.flush()
expect(setup.renderer.root.findDescendantById("session-panel")).toBeUndefined()
await setup.mockInput.typeText("fjm")
expect(prompt.plainText).toBe("fjm")
expect(setup.requests.base).toHaveLength(1)
expect(setup.requests.diff).toHaveLength(1)
})
const files = Array.from({ length: 4 }, (_, index) => ({
file: `src/file0${index}.txt`,
status: "modified",
additions: 1,
deletions: 1,
patch: [
`--- a/src/file0${index}.txt`,
`+++ b/src/file0${index}.txt`,
"@@ -1,30 +1,30 @@",
`-before ${index}`,
`+after ${index}`,
...Array.from({ length: 29 }, (_, line) => ` context ${index}-${line}`),
"",
].join("\n"),
})) satisfies FileDiffInfo[]
async function createSessionFixture(
state: string,
input: {
width?: number
tabs?: Config.Info["tabs"]
diffs?: Config.Info["diffs"]
keybinds?: Config.Info["keybinds"]
permission?: boolean
form?: boolean
} = {},
) {
// Drafts survive app disposal in memory, so each production app needs its own session identity.
const session = {
id: `ses_panel_${crypto.randomUUID()}`,
title: "Panel fixture",
projectID: "proj_panel",
location: { directory },
agent: "build",
model: { providerID: "fixture", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 12 },
} satisfies SessionInfo
const permission = {
id: `per_panel_${crypto.randomUUID()}`,
sessionID: session.id,
action: "shell",
resources: ["echo permission-fixture"],
} satisfies PermissionRequest
const form = {
id: `frm_panel_${crypto.randomUUID()}`,
sessionID: session.id,
title: "Panel form fixture",
fields: [
{
key: "target",
type: "string",
title: "Target",
options: [{ value: "staging", label: "Staging" }],
custom: true,
default: "answer",
},
],
} satisfies FormInfo
const location = { directory, project: { id: session.projectID, directory, canonical: directory } }
const requests = {
base: [] as URL[],
diff: [] as URL[],
prompt: [] as unknown[],
permission: [] as unknown[],
form: [] as unknown[],
}
const setup = await createAppFixture({
width: input.width ?? 160,
height: 36,
state,
args: { sessionID: session.id },
config: {
animations: false,
tabs: input.tabs ?? { enabled: false },
session: { sidebar: "hide", terminal: false },
// Keep patch geometry stable while testing host presentation changes, not split/unified diff layout.
diffs: { tree: false, view: "unified", ...input.diffs },
keybinds: { "diff.open": "f6", ...input.keybinds },
},
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "fixture", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "fixture", name: "Fixture" }] })
if (url.pathname === "/api/vcs") return json({ location, data: { branch: { current: "panel", default: "v2" } } })
if (url.pathname === "/api/vcs/base") {
requests.base.push(url)
return json({ location, data: { name: "v2", ref: "refs/heads/v2", source: "default" } })
}
if (url.pathname === "/api/vcs/diff") {
requests.diff.push(url)
return json({ location, data: files })
}
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`)
return json({
data: Array.from({ length: 12 }, (_, index) => ({
id: `msg_panel_${String(index).padStart(2, "0")}`,
type: "user",
text: `Transcript item ${index}`,
time: { created: index + 1 },
})).toReversed(),
cursor: {},
})
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
if (url.pathname === `/api/session/${session.id}/permission`)
return json({ data: input.permission ? [permission] : [] })
if (url.pathname === `/api/session/${session.id}/form`) return json({ data: input.form ? [form] : [] })
if (url.pathname === `/api/session/${session.id}/prompt`) {
requests.prompt.push(await request.json())
return json({ data: {} })
}
if (url.pathname === `/api/session/${session.id}/permission/${permission.id}/reply`) {
requests.permission.push(await request.json())
return new Response(null, { status: 204 })
}
if (url.pathname === `/api/session/${session.id}/form/${form.id}/reply`) {
requests.form.push(await request.json())
return new Response(null, { status: 204 })
}
return undefined
},
})
return { ...setup, requests, form }
}
type AppFixture = Awaited<ReturnType<typeof createAppFixture>>
async function ready(setup: AppFixture) {
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Transcript item 11") && frame.includes("Build · Model Fixture"), {
maxPasses: 120,
})
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
}
async function panelReady(setup: AppFixture) {
await setup.waitForFrame(
(frame) => frame.includes("file00.txt") && setup.renderer.currentFocusedRenderable?.id === "session-panel",
{ maxPasses: 120 },
)
await setup.flush()
}
function focus(setup: AppFixture, direction: "left" | "right") {
setup.mockInput.pressKey("x", { ctrl: true })
setup.mockInput.pressArrow(direction)
}
function node(setup: AppFixture, id: string) {
const result = setup.renderer.root.findDescendantById(id)
if (!result) throw new Error(`Missing renderable: ${id}\n${setup.captureCharFrame()}`)
return result
}
function scroll(setup: AppFixture) {
const result = node(setup, "diff-patches")
if (!(result instanceof ScrollBoxRenderable)) throw new Error("Missing diff scrollbox")
return result
}
function editor(setup: AppFixture) {
const result = setup.renderer.currentFocusedEditor
if (!(result instanceof TextareaRenderable)) throw new Error("Missing focused session prompt")
return result
}
function text(setup: AppFixture, id: string) {
const item = setup.renderer.root.findDescendantById(id)
if (!item) return ""
return setup
.captureCharFrame()
.split("\n")
.slice(item.y, item.y + item.height)
.map((line) => line.slice(item.x, item.x + item.width).trim())
.join("\n")
.trim()
}
function descendants(root: Renderable): Renderable[] {
return root.getChildren().flatMap((item) => [item, ...descendants(item)])
}
@@ -10,7 +10,6 @@ export interface DialogProps extends ParentProps {
containerClass?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
onCloseAutoFocus?: ComponentProps<typeof Content>["onCloseAutoFocus"]
}
export interface DialogHeaderProps extends ParentProps {
@@ -94,7 +93,6 @@ export function Dialog(props: DialogProps) {
>
<div data-slot="dialog-container" class={local.containerClass}>
<Content
onCloseAutoFocus={props.onCloseAutoFocus}
data-slot="dialog-content"
classList={{
...local.classList,
+56 -209
View File
@@ -11997,50 +11997,18 @@
"summary": "List references"
}
},
"/api/worktree": {
"/api/worktree/{projectID}": {
"get": {
"tags": ["worktree"],
"operationId": "v2.worktree.list",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12056,18 +12024,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -12083,7 +12044,7 @@
}
}
},
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
"description": "List known local worktrees for a project.",
"summary": "List worktrees"
},
"post": {
@@ -12091,44 +12052,12 @@
"operationId": "v2.worktree.create",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12171,13 +12100,32 @@
}
}
},
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
"description": "Create a worktree for a project and run its configured setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Worktree.CreateInput"
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["strategy", "directory"],
"additionalProperties": false
}
}
},
@@ -12189,44 +12137,12 @@
"operationId": "v2.worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12262,13 +12178,23 @@
}
}
},
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
"description": "Remove a managed worktree from a project.",
"summary": "Remove worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Worktree.RemoveInput"
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
}
}
},
@@ -12276,50 +12202,18 @@
}
}
},
"/api/worktree/refresh": {
"/api/worktree/{projectID}/refresh": {
"post": {
"tags": ["worktree"],
"operationId": "v2.worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12355,7 +12249,7 @@
}
}
},
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
"description": "Reconcile stored worktrees with the project repositories.",
"summary": "Refresh worktrees"
}
},
@@ -14252,9 +14146,6 @@
]
}
},
"worktree": {
"$ref": "#/components/schemas/Config.Worktree"
},
"warming": {
"anyOf": [
{
@@ -14572,16 +14463,6 @@
},
"additionalProperties": false
},
"Config.Worktree": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"ConfigWebSearch.InfoEncoded": {
"type": "object",
"properties": {
@@ -19695,27 +19576,6 @@
"additionalProperties": false,
"description": "Reports whether this request destroyed an existing workspace."
},
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
},
"Worktree.Directory": {
"type": "object",
"properties": {
@@ -19745,19 +19605,6 @@
"$ref": "#/components/schemas/Worktree.Directory"
}
},
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
"type": "object",
"properties": {
@@ -19891,7 +19738,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project worktree management routes."
},
{
"name": "workspace",
+56 -209
View File
@@ -11997,50 +11997,18 @@
"summary": "List references"
}
},
"/api/worktree": {
"/api/worktree/{projectID}": {
"get": {
"tags": ["worktree"],
"operationId": "v2.worktree.list",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12056,18 +12024,11 @@
}
},
"400": {
"description": "WorktreeError | InvalidRequestError",
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/WorktreeErrorEncoded"
},
{
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
]
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
@@ -12083,7 +12044,7 @@
}
}
},
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
"description": "List known local worktrees for a project.",
"summary": "List worktrees"
},
"post": {
@@ -12091,44 +12052,12 @@
"operationId": "v2.worktree.create",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12171,13 +12100,32 @@
}
}
},
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
"description": "Create a worktree for a project and run its configured setup script.",
"summary": "Create worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Worktree.CreateInput"
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": ["strategy", "directory"],
"additionalProperties": false
}
}
},
@@ -12189,44 +12137,12 @@
"operationId": "v2.worktree.remove",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12262,13 +12178,23 @@
}
}
},
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
"description": "Remove a managed worktree from a project.",
"summary": "Remove worktree",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Worktree.RemoveInput"
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
}
}
},
@@ -12276,50 +12202,18 @@
}
}
},
"/api/worktree/refresh": {
"/api/worktree/{projectID}/refresh": {
"post": {
"tags": ["worktree"],
"operationId": "v2.worktree.refresh",
"parameters": [
{
"name": "location",
"in": "query",
"name": "projectID",
"in": "path",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
"type": "string"
},
"required": false,
"style": "deepObject",
"explode": true
"required": true
}
],
"security": [],
@@ -12355,7 +12249,7 @@
}
}
},
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
"description": "Reconcile stored worktrees with the project repositories.",
"summary": "Refresh worktrees"
}
},
@@ -14252,9 +14146,6 @@
]
}
},
"worktree": {
"$ref": "#/components/schemas/Config.Worktree"
},
"warming": {
"anyOf": [
{
@@ -14572,16 +14463,6 @@
},
"additionalProperties": false
},
"Config.Worktree": {
"type": "object",
"properties": {
"directory": {
"type": "string"
}
},
"required": ["directory"],
"additionalProperties": false
},
"ConfigWebSearch.InfoEncoded": {
"type": "object",
"properties": {
@@ -19695,27 +19576,6 @@
"additionalProperties": false,
"description": "Reports whether this request destroyed an existing workspace."
},
"Worktree.CreateInput": {
"type": "object",
"properties": {
"strategy": {
"type": "string"
},
"from": {
"type": "string"
},
"branch": {
"type": "string"
},
"directory": {
"type": "string"
},
"name": {
"type": "string"
}
},
"additionalProperties": false
},
"Worktree.Directory": {
"type": "object",
"properties": {
@@ -19745,19 +19605,6 @@
"$ref": "#/components/schemas/Worktree.Directory"
}
},
"Worktree.RemoveInput": {
"type": "object",
"properties": {
"directory": {
"type": "string"
},
"force": {
"type": "boolean"
}
},
"required": ["directory", "force"],
"additionalProperties": false
},
"WorktreeErrorEncoded": {
"type": "object",
"properties": {
@@ -19891,7 +19738,7 @@
},
{
"name": "worktree",
"description": "Location-scoped worktree management routes."
"description": "Project worktree management routes."
},
{
"name": "workspace",
@@ -950,112 +950,6 @@ interface VcsEditor {
}
```
### Worktrees
Register a local worktree strategy using the normal plugin lifecycle. The implementation module in this example owns
option validation and the backend's `create`, `remove`, and `list` operations.
```ts title="plugins/worktrees/index.ts"
import { Plugin } from "@opencode-ai/plugin"
import { makeStrategy } from "./strategy"
export default Plugin.define({
id: "company.worktrees",
async setup(ctx) {
const strategy = makeStrategy(ctx.options)
await ctx.worktree.transform((editor) => {
editor.add(strategy)
})
},
})
```
Load the plugin through `plugins` and configure the common destination separately:
```jsonc title="opencode.jsonc"
{
"worktree": { "directory": "../worktrees" },
"plugins": [{ "package": "./plugins/worktrees", "options": {} }],
}
```
- Adding a strategy selects it automatically. The last active registration wins; no strategy-selection config is needed.
- Disposing its registration or unloading the plugin restores the previous implementation, ultimately the bundled Git strategy.
- `reload()` replays transforms after captured inputs change; it does not rerun plugin setup.
- Creation failures do not retry through Git. Existing worktrees retain their recorded owner even when the default changes.
- Strategies manage local directories. Remote workspace provisioning is not part of this interface.
#### Operations
The plugin context exposes the same worktree operations as the client. Every operation uses the plugin's current
location unless overridden. `list` discovers worktrees through that location's strategies and returns the full inventory
for the resolved project, including known worktrees from other checkouts of the same project.
```ts
const created = await ctx.worktree.create({ name: "task" })
const inventory = await ctx.worktree.list()
await ctx.worktree.refresh()
await ctx.worktree.remove({ directory: created.directory, force: false })
```
Create accepts optional explicit `strategy`, destination `directory`, source `from`, and starting `branch` overrides.
All worktree operations derive the project from their location; none takes a `projectID`.
The source defaults to the location's checkout, and a `from` override must belong to that project.
A supplied starting ref must be supported by the selected strategy;
Rift's native snapshot operation, for example, has no ref-selection option.
Calls targeting another location wait for its plugins to activate. Calls in the current plugin's location during setup
see registrations made so far and do not wait for their own activation; prefer lifecycle actions after setup completes.
Configuration is derived from the operation's location; no configuration directory is stored in the database.
To remove a worktree through a checkout-local plugin, select a location where that plugin is configured.
The worktree's destination can be elsewhere:
```ts
await ctx.worktree.remove({
directory: "/worktrees/task",
location: { directory: "/repos/app" },
force: false,
})
```
Missing owners fail removal rather than falling back to Git. A strategy can throw `new Worktree.OperationError({
message: "Uncommitted changes", forceRequired: true })`, importing `Worktree` from `@opencode-ai/plugin`, to request
force confirmation without depending on Core or Git errors.
#### Reference
Implementations receive the final destination after naming and collision handling. Return that directory from `create`;
`list` must report only directories the strategy owns, plus any repository roots. Core owns inventory and startup commands.
```ts
interface WorktreeDefinition {
readonly id: string
create(
input: { sourceDirectory: string; directory: string; branch?: string },
context: { signal: AbortSignal },
): Promise<{ directory: string }>
remove(input: { directory: string; force: boolean }, context: { signal: AbortSignal }): Promise<void>
list(
sourceDirectory: string,
context: { signal: AbortSignal },
): Promise<readonly { directory: string; type: "root" | "worktree" }[]>
}
interface WorktreeEditor {
add(definition: WorktreeDefinition): void
}
interface WorktreeDomain extends WorktreeApi {
transform(callback: (editor: WorktreeEditor) => void): Promise<Registration>
reload(): Promise<void>
}
```
Promise callbacks must cooperate with `signal` cancellation. Effect callbacks return Effects and use Effect interruption
instead. Registration and defaults are location-scoped; core configuration adapters feed the directory setting into
Worktree's state without giving the Worktree service a Config dependency.
### Websearch
List websearch providers or run a query through the selected provider.
+1 -20
View File
@@ -131,7 +131,7 @@ agents.
Control update checks from the global config. Set `update` to `"disable"` to
skip them, `"notify"` to show available updates before installing them, or
`"auto"` to install updates automatically. When omitted, `update` defaults to `"notify"`.
`"auto"` to install updates automatically. When omitted, `update` defaults to `"auto"`.
Automatic installation does not restart a running server. Restart it manually to activate the installed update.
Project-level values are ignored.
@@ -419,25 +419,6 @@ context.
See the [references guide](/references) for shorthand, visibility, and path resolution.
### Worktrees
Set the parent directory for new local worktrees. OpenCode appends the requested or generated worktree name.
```jsonc
{
"worktree": {
"directory": "../worktrees",
},
}
```
Relative paths resolve against the config file that declares them; `~/` resolves against the user's home directory.
Without this setting, creation uses the server's data directory under `worktree/<first-six-project-ID-characters>`.
Configuration applies to the caller's location, not every clone sharing a project ID. Changing it does not move existing worktrees.
Git is the built-in default. A [plugin](/build/plugins#worktrees) that registers a strategy automatically becomes the
default for its location. Strategy-specific options belong to that plugin, not the `worktree` config object.
### Plugins
Load plugins from packages or local plugin directories. Use the object form when a plugin