mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-28 11:24:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebed189984 |
@@ -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
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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() {} },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"]) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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." }))
|
||||
|
||||
@@ -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)",
|
||||
}),
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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:"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} /> })
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user