mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 00:16:22 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f636f53c30 | ||
|
|
0da55f1cef | ||
|
|
ac76382211 | ||
|
|
c30285c148 | ||
|
|
b274224af1 | ||
|
|
7a050a19a1 | ||
|
|
16601775f1 | ||
|
|
0991e8b5a5 | ||
|
|
6d791dfe67 | ||
|
|
f268956c75 | ||
|
|
3290a39667 | ||
|
|
51d69b26a0 | ||
|
|
8ff1ef1a62 | ||
|
|
90cd910f52 | ||
|
|
c3342ca812 | ||
|
|
7ca047b2b9 | ||
|
|
b78c2ea7b4 | ||
|
|
0e143437c8 | ||
|
|
bff58fc387 | ||
|
|
a26a978051 | ||
|
|
218a0dde97 |
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"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-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
|
||||
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
|
||||
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
|
||||
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
|
||||
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
|
||||
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
|
||||
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
|
||||
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
// Keepalives and provider notifications carry no response state and may precede response.created.
|
||||
if (!event.type.startsWith("response.")) return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
|
||||
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
it.effect("tolerates keepalive and provider notifications before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
@@ -534,6 +534,7 @@ describe("OpenAI Responses route", () => {
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
|
||||
@@ -490,20 +490,24 @@ async function openDraft(
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === `/api/worktree/${projectID}`) {
|
||||
if (path === "/api/worktree") {
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
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(`**/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(
|
||||
(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/session", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: Record<string, unknown> = route.request().postDataJSON()
|
||||
|
||||
@@ -87,16 +87,24 @@ 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("**/api/worktree/*", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/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.startsWith("/api/worktree/"))
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
new URL(request.url()).pathname === "/api/worktree" &&
|
||||
new URL(request.url()).searchParams.get("location[directory]") === directory &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
@@ -110,16 +118,57 @@ 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("**/api/worktree/*", async (route) => {
|
||||
await refresh.promise
|
||||
await route.fallback()
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/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) => {
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
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,7 +97,9 @@ for (const theme of ["light", "dark"] as const) {
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
@@ -199,10 +201,13 @@ 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(`**/api/worktree/${projectID}`, (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
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": "*" } })
|
||||
},
|
||||
)
|
||||
if (draft)
|
||||
await page.addInitScript(
|
||||
({ root, server }) => {
|
||||
@@ -222,7 +227,9 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
)
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(
|
||||
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
|
||||
@@ -71,27 +71,23 @@ 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/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
|
||||
params: { projectID: Schema.String },
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
|
||||
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,6 +8,7 @@
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
+15
-11
@@ -75,22 +75,26 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
const standalone = isStandalone()
|
||||
root.dataset.standalone = String(standalone)
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: web.currentServerUrl,
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
const server: ServerConnection.Http | undefined = web.currentServerUrl
|
||||
? {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
http: {
|
||||
url: web.currentServerUrl,
|
||||
...auth,
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={web.platform}>
|
||||
<AppBaseProviders locale={locale}>
|
||||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
defaultServer={web.defaultServerUrl ? ServerConnection.Key.make(web.defaultServerUrl) : undefined}
|
||||
canonicalLocalServer={
|
||||
server ? ServerConnection.key(server) : ServerConnection.Key.make("http://localhost:49374")
|
||||
}
|
||||
servers={server ? [server] : []}
|
||||
>
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
</AppInterface>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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,7 +201,11 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<Show
|
||||
when={props.projects.length > 0}
|
||||
fallback={<HomeProjectEmpty {...props} server={props.servers[0]} items={props.recentlyClosed} />}
|
||||
fallback={
|
||||
<Show when={props.servers[0]}>
|
||||
{(server) => <HomeProjectEmpty {...props} server={server()} items={props.recentlyClosed} />}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<HomeProjectList {...props} {...contextMenuProps} server={props.servers[0]} items={props.projects} />
|
||||
<Show when={props.dropdown}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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"
|
||||
@@ -11,6 +13,7 @@ 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)
|
||||
@@ -24,7 +27,7 @@ export function Home() {
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
<Show when={mobile()}>
|
||||
<Show when={mobile() && home.server.list().length > 0}>
|
||||
<div class="relative z-40 -mb-3 shrink-0 px-3 pt-3">
|
||||
<HomeProjects projects={projects} scroll={scroll} dropdown />
|
||||
</div>
|
||||
@@ -46,7 +49,17 @@ export function Home() {
|
||||
<Show when={!mobile()}>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
</Show>
|
||||
<HomeSessions sessions={sessions} search={search} scroll={scroll} />
|
||||
{/* 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>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div class="hidden shrink-0 px-3 py-2 md:block lg:hidden">
|
||||
|
||||
@@ -318,8 +318,10 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
dialog.show(() => <DeleteDialog server={server} session={session} />),
|
||||
},
|
||||
tab: {
|
||||
isOpen: (record: HomeSessionRecord) =>
|
||||
sessionHasOpenTab(tabs.store, home.selection.value().server, record.session),
|
||||
isOpen: (record: HomeSessionRecord) => {
|
||||
const server = home.selection.value().server
|
||||
return !!server && sessionHasOpenTab(tabs.store, server, record.session)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -9,43 +10,47 @@ export function HomeSessions(props: {
|
||||
scroll: HomeScrollController
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
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",
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
"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", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const featured = ["opencode-go", "opencode", "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", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
|
||||
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||
|
||||
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||
|
||||
@@ -655,6 +655,15 @@ 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.includes("opencode.ai")) return "http://localhost:49374"
|
||||
if (location.hostname === "opencode.ai" || location.hostname.endsWith(".opencode.ai")) return undefined
|
||||
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,9 +7,6 @@ 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",
|
||||
@@ -79,26 +76,32 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
test("loads each project's inventory through its own location using the real client", async () => {
|
||||
const calls: string[] = []
|
||||
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 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 result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
|
||||
expect(result.map((project) => project.id)).toEqual(["a", "b"])
|
||||
expect(result.map((project) => project.sandboxes)).toEqual([
|
||||
@@ -109,24 +112,30 @@ 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 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 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 result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
|
||||
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project, api.worktree))
|
||||
|
||||
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({ projectID: project.id })
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.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,6 +250,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
}
|
||||
|
||||
return {
|
||||
get canonicalLocalServer() {
|
||||
return props.canonicalLocalServer
|
||||
},
|
||||
get list() {
|
||||
return allServers()
|
||||
},
|
||||
|
||||
@@ -23,11 +23,14 @@ 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)
|
||||
|
||||
@@ -63,7 +66,7 @@ export const DialogServer: Component<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog fit class="settings-server-dialog">
|
||||
<Dialog fit class="settings-server-dialog" onCloseAutoFocus={props.onCloseAutoFocus}>
|
||||
<DialogHeader hideClose={true}>
|
||||
<DialogTitle>{title()}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -128,7 +131,9 @@ export const DialogServer: Component<{
|
||||
)
|
||||
}
|
||||
|
||||
function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
function createFormController(
|
||||
options: { onSelect?: () => void; onAdd?: (server: ServerConnection.Http) => void } = {},
|
||||
) {
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
@@ -212,7 +217,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
|
||||
}
|
||||
|
||||
reset()
|
||||
add(connection)
|
||||
const added = add(connection)
|
||||
if (added) options.onAdd?.(added)
|
||||
options.onSelect?.()
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -3,11 +3,9 @@ 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({
|
||||
@@ -20,7 +18,6 @@ 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" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div data-component="session-preparing" data-workspace-session 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,8 +40,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
if (!open) return
|
||||
const sdk = serverSDK
|
||||
void sdk.api.worktree
|
||||
.refresh({ projectID: props.project.id })
|
||||
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
|
||||
.list({ location: { directory: props.directory } })
|
||||
.then((items) =>
|
||||
setDirectories(
|
||||
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||
|
||||
@@ -58,6 +58,7 @@ 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 ParentProps, Show } from "solid-js"
|
||||
import { type JSX, 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 }>) {
|
||||
export function SettingsServerScope(props: ParentProps<{ directory?: string; fallback?: JSX.Element }>) {
|
||||
const global = useGlobal()
|
||||
return (
|
||||
<Show when={global.settings.server.selected()} keyed fallback={props.children}>
|
||||
<Show when={global.settings.server.selected()} keyed fallback={props.fallback}>
|
||||
{(server) => (
|
||||
<SettingsServerDataScope server={server} directory={props.directory}>
|
||||
{props.children}
|
||||
|
||||
@@ -206,7 +206,24 @@ export const SettingsScreen: Component = () => {
|
||||
<Tabs.Content value="projects" class="settings-panel">
|
||||
<SettingsProjects />
|
||||
</Tabs.Content>
|
||||
<SettingsServerScope directory={directory()}>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<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({ projectID: project.id })
|
||||
.list({ location: { directory: project.canonical } })
|
||||
.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({
|
||||
projectID: workspace.project.id,
|
||||
location: { directory: workspace.project.worktree },
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
|
||||
@@ -14,6 +14,15 @@ 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: TabStorage.ServerKey,
|
||||
server: Schema.optional(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(ServerConnection.key(servers.list[0])),
|
||||
initialLayout(servers.list[0] ? ServerConnection.key(servers.list[0]) : undefined),
|
||||
)
|
||||
const [ephemeral, setEphemeral] = createStore({
|
||||
reviewPanelSource: "other" as ReviewPanelSource,
|
||||
|
||||
@@ -141,6 +141,8 @@ 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) {
|
||||
@@ -167,6 +169,7 @@ 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,30 +7,27 @@ import { createWorktree } from "./create"
|
||||
describe("worktree creation", () => {
|
||||
test.each(
|
||||
[
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
|
||||
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
|
||||
{
|
||||
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 clone-local main for $name (cached: $cached)", async (input) => {
|
||||
)("uses the server destination and 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({
|
||||
@@ -66,10 +63,9 @@ 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/proj_clone",
|
||||
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
|
||||
)
|
||||
expect(
|
||||
requests
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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">
|
||||
@@ -11,11 +10,10 @@ 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({
|
||||
projectID: project.id,
|
||||
location: { directory: input.directory },
|
||||
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 })
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
const update = yield* updater.run().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -83,11 +84,15 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
monitor: (notify, signal) =>
|
||||
remote: requestedServer !== undefined,
|
||||
subscribe: (notify, signal) =>
|
||||
runPromise(
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
Fiber.join(update).pipe(
|
||||
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
|
||||
),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
yield* Updater.Service.pipe(
|
||||
Effect.flatMap((updater) =>
|
||||
Updater.pollUpdates({
|
||||
check: updater.run().pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (!result) return Effect.void
|
||||
if (result.type === "available") return server.updateAvailable(result.version)
|
||||
return server.updated(result.version)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "auto"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,7 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
return "notify"
|
||||
return policy
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,14 +6,14 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
@@ -23,6 +23,11 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("automatically installs every available release when enabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("skips when updates are disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export interface Interface {
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly run: () => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -43,20 +43,20 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
@@ -78,7 +78,7 @@ const make = Effect.gen(function* () {
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
@@ -168,17 +168,20 @@ const make = Effect.gen(function* () {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
if (download.code !== 0) return download
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
@@ -200,18 +203,19 @@ const make = Effect.gen(function* () {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: OPENCODE_VERSION,
|
||||
current,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
const next = action(current, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
|
||||
return { policy, version }
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
@@ -220,8 +224,10 @@ const make = Effect.gen(function* () {
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
yield* Ref.set(installedVersion, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -229,9 +235,36 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (OPENCODE_LOCAL)
|
||||
return {
|
||||
type: "unavailable" as const,
|
||||
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
|
||||
}
|
||||
const version = yield* latest()
|
||||
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
if (action(current, version, "auto") === "none") {
|
||||
// An earlier check may have installed the update while this client is still running.
|
||||
return action(OPENCODE_VERSION, current, "auto") === "none"
|
||||
? undefined
|
||||
: { type: "installed" as const, version: current }
|
||||
}
|
||||
return { type: "available" as const, version }
|
||||
})
|
||||
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
const run = Effect.fn("cli.updater.run")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (!result) return undefined
|
||||
if (result.policy === "notify") return { type: "available" as const, version: result.version }
|
||||
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
return { type: "installed" as const, version: result.version }
|
||||
},
|
||||
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
|
||||
return Service.of({ run, check, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,7 +12,8 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
|
||||
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("polls after 1 minute and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const checks = yield* Queue.unbounded<void>()
|
||||
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Queue.take(checks)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* Queue.take(checks)
|
||||
}),
|
||||
)
|
||||
@@ -1931,33 +1931,37 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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 projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy?: Worktree.StrategyID | undefined
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly branch?: string | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
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 projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
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 projectID: Project.ID }
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
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"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.list"]({ query: { location: input?.["location"] } }).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"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input?.["location"] },
|
||||
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: Wo
|
||||
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
|
||||
preserveEffect<WorktreeRemoveOutput>()(
|
||||
raw["worktree.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
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"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
|
||||
@@ -20,6 +20,7 @@ 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,28 +1968,30 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
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/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
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],
|
||||
@@ -2001,7 +2003,8 @@ export function make(options: ClientOptions) {
|
||||
request<WorktreeRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||
path: `/api/worktree`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -2009,11 +2012,12 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
|
||||
path: `/api/worktree/refresh`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -430,6 +430,8 @@ 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 }
|
||||
@@ -1892,7 +1894,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify"
|
||||
update?: "disable" | "notify" | "auto"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
@@ -1993,6 +1995,7 @@ 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]: {
|
||||
@@ -6077,45 +6080,51 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly strategy: {
|
||||
readonly strategy: string
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
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"]
|
||||
}
|
||||
@@ -6123,14 +6132,20 @@ export type WorktreeCreateInput = {
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
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 projectID: { readonly projectID: string }["projectID"] }
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("worktree methods use the global project contract", async () => {
|
||||
test("all worktree operations use location-based routes without a project parameter", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
@@ -350,27 +350,25 @@ test("worktree methods use the global project contract", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(await client.worktree.list()).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({ projectID: "proj_test" })
|
||||
await client.worktree.refresh()
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["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"],
|
||||
["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"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({
|
||||
strategy: "git",
|
||||
@@ -380,6 +378,39 @@ test("worktree methods use the global project contract", async () => {
|
||||
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({
|
||||
|
||||
@@ -74,9 +74,7 @@ export function normalize(input: unknown): Result {
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
@@ -211,6 +209,7 @@ 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]) => {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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,6 +25,7 @@ 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"
|
||||
@@ -71,7 +72,8 @@ const nodes = [
|
||||
PluginHooks.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
Worktree.refreshNode,
|
||||
WorktreeRefresh.node,
|
||||
Worktree.node,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
|
||||
@@ -30,6 +30,7 @@ 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"
|
||||
@@ -69,6 +70,7 @@ 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,
|
||||
@@ -88,6 +90,24 @@ 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
|
||||
@@ -462,6 +482,25 @@ 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) =>
|
||||
@@ -510,6 +549,7 @@ export const requirements = LayerNode.group([
|
||||
Tool.node,
|
||||
Vcs.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
|
||||
@@ -27,6 +27,8 @@ 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"
|
||||
@@ -135,6 +137,7 @@ const services = [
|
||||
ToolOutput.Service,
|
||||
Watcher.Service,
|
||||
WellKnown.Service,
|
||||
Worktree.Service,
|
||||
] as const
|
||||
|
||||
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
|
||||
@@ -183,6 +186,7 @@ export const requirements = LayerNode.group([
|
||||
ToolOutput.node,
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
Worktree.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
@@ -232,6 +236,7 @@ const post = [
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
@@ -409,19 +409,17 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
const next = (delivery: Delivery) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const steer = yield* next("steer")
|
||||
const steer = (yield* pendingSteers(db, sessionID))[0]
|
||||
if (steer) return fromRow(steer)
|
||||
if (promotable !== "input") return undefined
|
||||
const queued = yield* next("queue")
|
||||
const queued = yield* db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return queued ? fromRow(queued) : undefined
|
||||
})
|
||||
|
||||
@@ -490,9 +488,10 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
|
||||
})
|
||||
|
||||
/**
|
||||
* Promotes pending input into visible messages and returns the promoted count.
|
||||
* Steers always go first; only the "input" scope may fall through to one queued
|
||||
* input, and it then collects steers that arrived during promotion.
|
||||
* Promotes pending input into visible messages and returns the promoted count,
|
||||
* or undefined when the runner must first handle a pending control.
|
||||
* Steered compaction takes priority over pending prompts, without crossing a move.
|
||||
* Only the "input" scope may fall through to one queued input.
|
||||
*/
|
||||
export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -506,6 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
const steers = yield* pendingSteers(db, sessionID)
|
||||
if (steers.length > 0 || scope === "steer") {
|
||||
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control === 0) return undefined
|
||||
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!queued) return 0
|
||||
if (queued.type === "compaction" || queued.type === "move") return undefined
|
||||
const promoted = yield* publish(db, bus, sessionID, [queued])
|
||||
const arrivedSteers = yield* pendingSteers(db, sessionID)
|
||||
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
@@ -536,4 +537,14 @@ const pendingSteers = (db: DatabaseService, sessionID: SessionSchema.ID) =>
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => {
|
||||
// A move changes the context's Location: never pull compaction across it.
|
||||
// Within that boundary, compact before promoting even earlier steers so
|
||||
// their text stays verbatim after the checkpoint, not inside its summary.
|
||||
const control = rows.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control > 0 && rows[control].type === "compaction") rows.unshift(...rows.splice(control, 1))
|
||||
return rows
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -147,7 +147,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
return yield* restore(
|
||||
const ready = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
@@ -156,6 +156,8 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
// A control admitted during context preparation owns this boundary.
|
||||
if (promoted === undefined) return undefined
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
onlyIfMissing: true,
|
||||
@@ -164,6 +166,7 @@ const layer = Layer.effect(
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
if (ready) return ready
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
: info.autoupdate === "notify"
|
||||
? "notify"
|
||||
: undefined,
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
|
||||
+152
-148
@@ -1,12 +1,13 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, isNull, sql } 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 { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -20,8 +21,10 @@ 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
|
||||
@@ -32,11 +35,6 @@ 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),
|
||||
@@ -46,16 +44,10 @@ 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 = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "worktree"]),
|
||||
}).annotate({ identifier: "Worktree.ListEntry" })
|
||||
export const ListEntry = Worktree.ListEntry
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
|
||||
@@ -78,9 +70,9 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class DuplicateStrategyError extends Schema.TaggedError<DuplicateStrategyError>()(
|
||||
"Worktree.DuplicateStrategyError",
|
||||
{ strategy: StrategyID },
|
||||
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
|
||||
"Worktree.UnsupportedLocationError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
@@ -89,6 +81,8 @@ export type Error =
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| UnsupportedLocationError
|
||||
| Worktree.OperationError
|
||||
| AppProcess.AppProcessError
|
||||
| Git.WorktreeError
|
||||
|
||||
@@ -98,67 +92,78 @@ export interface Strategy {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
}) => 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>
|
||||
}) => Effect.Effect<Info, unknown>
|
||||
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
|
||||
}
|
||||
|
||||
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 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>
|
||||
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>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
readonly refresh: () => 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 db = (yield* Database.Service).db
|
||||
const database = yield* Database.Service
|
||||
const db = database.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 changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
|
||||
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) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const ops = {
|
||||
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
|
||||
list: Effect.fnUntraced(function* () {
|
||||
const rows = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
@@ -168,7 +173,7 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
}),
|
||||
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
|
||||
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
const row = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
@@ -177,24 +182,21 @@ 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: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
.values({
|
||||
project_id: projectID,
|
||||
directory: input.directory,
|
||||
strategy: input.strategy,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [WorktreeTable.project_id, WorktreeTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
|
||||
: isNotNull(WorktreeTable.strategy),
|
||||
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`,
|
||||
})
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
@@ -202,7 +204,7 @@ const layer = Layer.effect(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row !== undefined),
|
||||
),
|
||||
remove: (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) =>
|
||||
remove: (directory: AbsolutePath, tx?: Transaction) =>
|
||||
(tx ?? db)
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
@@ -214,62 +216,57 @@ const layer = Layer.effect(
|
||||
),
|
||||
}
|
||||
|
||||
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 source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
|
||||
const sourceDirectory = input ?? location.project.directory
|
||||
const resolved = yield* canonical(fs, sourceDirectory)
|
||||
if ((yield* ops.find(projectID, resolved)) === undefined)
|
||||
if ((yield* ops.find(resolved)) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
const found = registry.get(id)
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
|
||||
const found = strategies.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
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 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 name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(directory, name))
|
||||
while (yield* fs.existsSafe(worktreeDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
|
||||
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* selected.create({
|
||||
directory: worktreeDirectory,
|
||||
sourceDirectory,
|
||||
branch: input.branch,
|
||||
})
|
||||
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 })
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* ops.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
strategy: selected.id,
|
||||
replace: true,
|
||||
}),
|
||||
)
|
||||
const project = yield* db
|
||||
.select({ commands: ProjectTable.commands })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const command = project?.commands?.start?.trim()
|
||||
@@ -294,70 +291,71 @@ 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(input.projectID, worktreeDirectory)
|
||||
const stored = yield* ops.find(worktreeDirectory)
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: 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 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 refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* ops.list(input.projectID)
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* () {
|
||||
yield* local
|
||||
const stored = yield* ops.list()
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
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 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 removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const changes = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
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)),
|
||||
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)),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register,
|
||||
list: ops.list,
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Worktree.list")(function* () {
|
||||
yield* refresh()
|
||||
return yield* ops.list()
|
||||
}),
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
@@ -365,14 +363,20 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.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)}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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],
|
||||
})
|
||||
@@ -666,14 +666,14 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
test("normalizes the native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "notify" },
|
||||
encoded: { update: "auto" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ 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"
|
||||
@@ -94,6 +95,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Vcs.node,
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
]),
|
||||
[
|
||||
Location.node.replace(tempLocationLayer),
|
||||
|
||||
@@ -145,6 +145,14 @@ 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"),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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)
|
||||
}),
|
||||
)
|
||||
@@ -1925,6 +1925,185 @@ describe("SessionRunnerLLM", () => {
|
||||
).toEqual(["Replacement context"])
|
||||
})
|
||||
|
||||
for (const order of ["before", "between", "after"] as const) {
|
||||
scenario(`prioritizes manual compaction admitted ${order} two steers at the safe boundary`, function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "active"),
|
||||
TestLLM.text("## Objective\n- Active work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const compactID = SessionMessage.ID.create()
|
||||
if (order === "before") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const first = yield* s.admit("STEER_A")
|
||||
if (order === "between") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const second = yield* s.admit("STEER_B")
|
||||
if (order === "after") yield* s.session.compact({ sessionID, id: compactID })
|
||||
expect((yield* s.session.compact({ sessionID })).id).toBe(compactID)
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* s.inbox).toHaveLength(3)
|
||||
expect((yield* s.messages).some((message) => message.type === "compaction")).toBe(false)
|
||||
yield* active.finish
|
||||
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_A")
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_B")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
expect((yield* s.messages).filter((message) => message.id === compactID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed" },
|
||||
])
|
||||
expect((yield* s.context).filter((message) => message.type === "user").map((message) => message.id)).toEqual([
|
||||
first.id,
|
||||
second.id,
|
||||
])
|
||||
// An advisory drain must not redeliver either steer or rerun compaction.
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* runner.drain({ sessionID, force: false })
|
||||
expect(s.requests).toHaveLength(3)
|
||||
})
|
||||
}
|
||||
|
||||
scenario("waits for active tools before prioritizing compaction over pending steers", function* (s) {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
TestLLM.text("## Objective\n- Tool work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const tools = yield* s.blockTools()
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests[1].messages.some((message) => message.role === "tool")).toBe(true)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("rechecks compaction admitted during boundary context preparation", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const preparing = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
s.systemLoadHook = Deferred.succeed(preparing, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("## Objective\n- Earlier work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(preparing)
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
for (const outcome of ["cancelled", "failed"] as const) {
|
||||
scenario(`preserves both earlier steers when prioritized compaction is ${outcome}`, function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Active complete", "active"))
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
if (outcome === "cancelled") yield* s.session.cancelInbox({ sessionID, inboxID: compact.id })
|
||||
if (outcome === "failed") yield* s.llm.push([LLMEvent.providerError({ message: "summary unavailable" })])
|
||||
yield* s.llm.push(TestLLM.text("Steers complete", "steers"))
|
||||
yield* active.finish
|
||||
|
||||
expect(s.requests).toHaveLength(outcome === "cancelled" ? 2 : 3)
|
||||
if (outcome === "failed") {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
})
|
||||
}
|
||||
if (outcome === "cancelled") expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
expect(userTexts(s.requests[s.requests.length - 1]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(
|
||||
(yield* s.context)
|
||||
.filter((message) => message.id === first.id || message.id === second.id)
|
||||
.map((message) => message.id),
|
||||
).toEqual([first.id, second.id])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
scenario("keeps steers durable across interrupted priority compaction and replay", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- Interrupted checkpoint", "summary"))
|
||||
const summary = yield* s.llm.gate
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* summary.started
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
yield* s.session.interrupt(sessionID)
|
||||
yield* s.session.wait(sessionID)
|
||||
yield* summary.release
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({ status: "failed" })
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
|
||||
yield* s.llm.push(TestLLM.text("Recovered steers", "steers"))
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("does not pull compaction across an earlier move", function* (s) {
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.sessionInbox.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* s.admit("STEER_B")
|
||||
yield* s.sessionInbox.admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "steer" })
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("First steer complete", "first"),
|
||||
TestLLM.text("## Objective\n- Source work checkpoint", "summary"),
|
||||
TestLLM.text("Second steer complete", "second"),
|
||||
)
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[0])).toEqual(["STEER_A"])
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).at(-1)).toBe("STEER_B")
|
||||
expect(
|
||||
(yield* recordedEventTypes(sessionID)).filter(
|
||||
(type) => type === "session.moved.1" || type === "session.compaction.started.1",
|
||||
),
|
||||
).toEqual(["session.moved.1", "session.compaction.started.1"])
|
||||
})
|
||||
|
||||
scenario("runs steers before queued compaction and later queued input", function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
|
||||
@@ -114,7 +114,6 @@ 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 { Effect, Fiber, Stream } from "effect"
|
||||
import { Context, Effect, Exit, Fiber, Layer, Queue, Scope, 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,14 +15,64 @@ 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"
|
||||
|
||||
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])),
|
||||
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 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)
|
||||
@@ -30,7 +80,11 @@ function abs(input: string) {
|
||||
|
||||
const gitWorktree = Worktree.StrategyID.make("git")
|
||||
|
||||
function setup() {
|
||||
const setup = Effect.fnUntraced(function* () {
|
||||
return yield* Fixture
|
||||
})
|
||||
|
||||
function makeFixture() {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -107,7 +161,6 @@ 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`),
|
||||
@@ -131,7 +184,6 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-source`),
|
||||
@@ -159,7 +211,6 @@ describe("Worktree", () => {
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -173,13 +224,39 @@ describe("Worktree", () => {
|
||||
)
|
||||
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ 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()
|
||||
@@ -199,7 +276,6 @@ describe("Worktree", () => {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -210,7 +286,7 @@ describe("Worktree", () => {
|
||||
created.directory,
|
||||
created.directory,
|
||||
])
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
yield* worktree.remove({ directory: created.directory, force: true })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -233,9 +309,12 @@ 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: {
|
||||
@@ -245,7 +324,6 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
const created = yield* worktrees.create({
|
||||
projectID: selected.id,
|
||||
strategy: gitWorktree,
|
||||
from: selected.canonical,
|
||||
directory: abs(path.join(root.path, "worktrees")),
|
||||
@@ -276,7 +354,6 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
branch: "feature-base",
|
||||
directory: parent,
|
||||
@@ -300,7 +377,6 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
branch: "--no-checkout",
|
||||
directory: parent,
|
||||
@@ -321,7 +397,6 @@ 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`),
|
||||
@@ -347,7 +422,6 @@ describe("Worktree", () => {
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
const source = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: sourceParent,
|
||||
@@ -360,7 +434,6 @@ describe("Worktree", () => {
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: source.directory,
|
||||
directory: targetParent,
|
||||
@@ -368,8 +441,8 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
|
||||
yield* worktree.remove({ directory: created.directory, force: false })
|
||||
yield* worktree.remove({ directory: source.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -381,7 +454,6 @@ 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,
|
||||
@@ -389,9 +461,7 @@ describe("Worktree", () => {
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
|
||||
const error = yield* worktree
|
||||
.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
.pipe(Effect.flip)
|
||||
const error = yield* worktree.remove({ directory: created.directory, force: false }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Git.WorktreeError)
|
||||
if (error instanceof Git.WorktreeError) {
|
||||
@@ -401,7 +471,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({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
yield* worktree.remove({ directory: created.directory, force: true })
|
||||
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -419,9 +489,7 @@ describe("Worktree", () => {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const error = yield* worktree
|
||||
.remove({ projectID: input.projectID, directory: unavailable, force: false })
|
||||
.pipe(Effect.flip)
|
||||
const error = yield* worktree.remove({ directory: unavailable, force: false }).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
|
||||
@@ -440,7 +508,6 @@ 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,
|
||||
@@ -455,7 +522,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ directory: created.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -476,7 +543,6 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
@@ -492,7 +558,6 @@ 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(
|
||||
@@ -502,7 +567,7 @@ describe("Worktree", () => {
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
yield* worktree.refresh()
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
@@ -541,7 +606,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({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
expect(yield* worktree.refresh()).toEqual({ updated: [discovered], removed: [] })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
@@ -554,7 +619,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({ projectID: input.projectID })).toEqual({
|
||||
expect(yield* worktree.refresh()).toEqual({
|
||||
updated: [],
|
||||
removed: [discovered, existing].toSorted(),
|
||||
})
|
||||
@@ -575,7 +640,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({ projectID: input.projectID })
|
||||
yield* worktree.refresh()
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
@@ -594,7 +659,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
yield* worktree.refresh()
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
@@ -602,9 +667,15 @@ 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({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
expect(yield* worktree.refresh()).toEqual({
|
||||
updated: [],
|
||||
removed: [],
|
||||
})
|
||||
@@ -622,9 +693,251 @@ describe("Worktree", () => {
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
expect(yield* worktree.refresh()).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,3 +16,4 @@ 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,6 +20,7 @@ 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
|
||||
@@ -47,6 +48,7 @@ export interface Context {
|
||||
readonly tool: ToolDomain
|
||||
readonly vcs: VcsDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
readonly worktree: WorktreeDomain
|
||||
}
|
||||
|
||||
export interface Plugin<R = Scope.Scope> {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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,6 +234,7 @@ 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()
|
||||
|
||||
@@ -538,6 +539,27 @@ 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,3 +17,4 @@ 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,6 +20,7 @@ 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
|
||||
@@ -47,6 +48,7 @@ export interface Context {
|
||||
readonly tool: ToolDomain
|
||||
readonly vcs: VcsDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
readonly worktree: WorktreeDomain
|
||||
}
|
||||
|
||||
export type Cleanup = () => Promise<void> | void
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
}
|
||||
@@ -162,6 +162,21 @@ type PromptFooterInput = {
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
export type PanelPresentation = "panel" | "fullscreen"
|
||||
|
||||
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
|
||||
export interface PanelInput {
|
||||
/** Selected content name, set by ui.panel.open. Contributions decide whether to render it. */
|
||||
readonly name: string
|
||||
readonly sessionID: string
|
||||
readonly width: number
|
||||
readonly presentation: PanelPresentation
|
||||
readonly focused: boolean
|
||||
readonly focus: () => void
|
||||
readonly close: () => void
|
||||
readonly toggleFullscreen: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
@@ -180,6 +195,7 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
@@ -450,6 +466,14 @@ export interface UI {
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly panel: {
|
||||
/** Opens the session.panel slot in the current session. */
|
||||
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
|
||||
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
|
||||
close(): void
|
||||
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
|
||||
current(): { readonly name: string; readonly sessionID: string } | undefined
|
||||
}
|
||||
readonly tabs: {
|
||||
/** Returns whether session tabs are enabled for this TUI. */
|
||||
enabled(): boolean
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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"
|
||||
}
|
||||
+219
-57
@@ -11997,18 +11997,50 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}": {
|
||||
"/api/worktree": {
|
||||
"get": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12024,11 +12056,18 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"description": "WorktreeError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/WorktreeErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12044,7 +12083,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List known local worktrees for a project.",
|
||||
"description": "Discover worktrees through the requested location's strategies and return its project's inventory.",
|
||||
"summary": "List worktrees"
|
||||
},
|
||||
"post": {
|
||||
@@ -12052,12 +12091,44 @@
|
||||
"operationId": "v2.worktree.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12100,32 +12171,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a worktree for a project and run its configured setup script.",
|
||||
"description": "Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
|
||||
"summary": "Create worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"strategy": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"branch": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["strategy", "directory"],
|
||||
"additionalProperties": false
|
||||
"$ref": "#/components/schemas/Worktree.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12137,12 +12189,44 @@
|
||||
"operationId": "v2.worktree.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12178,23 +12262,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Remove a managed worktree from a project.",
|
||||
"description": "Remove a managed worktree from the requested location's project using its recorded strategy.",
|
||||
"summary": "Remove worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["directory", "force"],
|
||||
"additionalProperties": false
|
||||
"$ref": "#/components/schemas/Worktree.RemoveInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -12202,18 +12276,50 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/worktree/{projectID}/refresh": {
|
||||
"/api/worktree/refresh": {
|
||||
"post": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.refresh",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -12249,7 +12355,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Reconcile stored worktrees with the project repositories.",
|
||||
"description": "Discover worktrees from the requested location and reconcile the shared project inventory.",
|
||||
"summary": "Refresh worktrees"
|
||||
}
|
||||
},
|
||||
@@ -13904,7 +14010,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -14146,6 +14252,9 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"worktree": {
|
||||
"$ref": "#/components/schemas/Config.Worktree"
|
||||
},
|
||||
"warming": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -14463,6 +14572,16 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Worktree": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ConfigWebSearch.InfoEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18221,6 +18340,12 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18448,6 +18573,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19567,6 +19695,27 @@
|
||||
"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": {
|
||||
@@ -19596,6 +19745,19 @@
|
||||
"$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": {
|
||||
@@ -19729,7 +19891,7 @@
|
||||
},
|
||||
{
|
||||
"name": "worktree",
|
||||
"description": "Project worktree management routes."
|
||||
"description": "Location-scoped worktree management routes."
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
|
||||
@@ -54,6 +54,7 @@ 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>
|
||||
|
||||
@@ -88,7 +89,6 @@ 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)
|
||||
.add(WorktreeGroup.middleware(locationMiddleware))
|
||||
.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, Struct } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const root = "/api/worktree/:projectID"
|
||||
const root = "/api/worktree"
|
||||
|
||||
export class WorktreeError extends Schema.Error<WorktreeError>("WorktreeError")(
|
||||
{
|
||||
@@ -16,61 +16,69 @@ 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, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
success: Worktree.List,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.list",
|
||||
summary: "List worktrees",
|
||||
description: "List known local worktrees for a project.",
|
||||
}),
|
||||
),
|
||||
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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktree.create", root, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: CreatePayload,
|
||||
query: LocationQuery,
|
||||
payload: Worktree.CreateInput,
|
||||
success: Worktree.Info,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.create",
|
||||
summary: "Create worktree",
|
||||
description: "Create a worktree for a project and run its configured setup script.",
|
||||
}),
|
||||
),
|
||||
})
|
||||
.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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktree.remove", root, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: RemovePayload,
|
||||
query: LocationQuery,
|
||||
payload: Worktree.RemoveInput,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.remove",
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a managed worktree from a project.",
|
||||
}),
|
||||
),
|
||||
})
|
||||
.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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.refresh",
|
||||
summary: "Refresh worktrees",
|
||||
description: "Reconcile stored worktrees with the project repositories.",
|
||||
}),
|
||||
),
|
||||
})
|
||||
.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({ title: "worktree", description: "Project worktree management routes." }))
|
||||
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Location-scoped worktree management routes." }))
|
||||
|
||||
@@ -20,6 +20,7 @@ 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({
|
||||
@@ -34,8 +35,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install updates automatically",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
@@ -98,6 +99,9 @@ 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)",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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,7 +2,6 @@ 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"
|
||||
|
||||
@@ -10,17 +9,18 @@ export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Sc
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
strategy: StrategyID,
|
||||
strategy: optional(StrategyID),
|
||||
from: optional(AbsolutePath),
|
||||
branch: optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))),
|
||||
directory: AbsolutePath,
|
||||
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>.",
|
||||
}),
|
||||
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,10 +37,16 @@ export const Directory = Schema.Struct({
|
||||
}).annotate({ identifier: "Worktree.Directory" })
|
||||
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
}).annotate({ identifier: "Worktree.ListInput" })
|
||||
export interface ListInput extends Schema.Schema.Type<typeof ListInput> {}
|
||||
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 List = Schema.Array(Directory).annotate({ identifier: "Worktree.List" })
|
||||
export type List = typeof List.Type
|
||||
|
||||
@@ -10,6 +10,15 @@ 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,7 +184,6 @@ describe("contract hygiene", () => {
|
||||
Model.Variant,
|
||||
Project.Current,
|
||||
Worktree.Directory,
|
||||
Worktree.ListInput,
|
||||
Worktree.List,
|
||||
Project.Icon,
|
||||
Project.Commands,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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,6 +36,8 @@
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
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) =>
|
||||
Effect.gen(function* () {
|
||||
const worktrees = yield* Worktree.Service
|
||||
|
||||
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()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
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
|
||||
const worktrees = yield* Worktree.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* action(worktrees)
|
||||
}).pipe(badRequest)
|
||||
}
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
@@ -35,7 +35,10 @@ function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
|
||||
name: "WorktreeError",
|
||||
data: {
|
||||
message: message(error),
|
||||
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
|
||||
forceRequired:
|
||||
error instanceof Git.WorktreeError || error instanceof Worktree.OperationError
|
||||
? error.forceRequired
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -51,5 +54,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
@@ -114,7 +116,14 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
const bus = Context.get(context, Bus.Service)
|
||||
return {
|
||||
address: bound.http.address,
|
||||
shutdown: shutdown.await,
|
||||
updateAvailable: (version: string) =>
|
||||
bus.publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
|
||||
updated: (version: string) => bus.publish(InstallationEvent.Updated, { version }).pipe(Effect.asVoid),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -30,7 +30,6 @@ 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"
|
||||
@@ -56,7 +55,6 @@ const applicationServiceNodes = [
|
||||
httpClient,
|
||||
Job.node,
|
||||
Project.node,
|
||||
Worktree.node,
|
||||
Session.node,
|
||||
Instance.node,
|
||||
SessionTransfer.node,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
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 })
|
||||
}
|
||||
@@ -101,7 +101,13 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
expect(event.headers.get("content-encoding")).toBeNull()
|
||||
const body = event.body
|
||||
if (!body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
const reader = body.getReader()
|
||||
yield* Effect.promise(() => readUntil(reader, "server.connected"))
|
||||
yield* server.updateAvailable("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
|
||||
yield* server.updated("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.updated"))
|
||||
yield* Effect.promise(() => reader.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -126,3 +132,11 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
if (next.done) throw new Error(`Event stream ended before ${expected}`)
|
||||
if (new TextDecoder().decode(next.value).includes(expected)) return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,57 +6,223 @@ 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 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)
|
||||
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)
|
||||
|
||||
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)
|
||||
}),
|
||||
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,
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const providers = [
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"opencode",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"google",
|
||||
|
||||
@@ -15,8 +15,8 @@ const provider = {
|
||||
}
|
||||
|
||||
const popular = [
|
||||
{ id: "opencode", name: "OpenCode Zen", models: {} },
|
||||
{ id: "opencode-go", name: "OpenCode Go", models: {} },
|
||||
{ id: "opencode", name: "OpenCode Zen", models: {} },
|
||||
{ id: "openai", name: "OpenAI", models: {} },
|
||||
provider,
|
||||
{ id: "google", name: "Google", models: {} },
|
||||
|
||||
+52
-56
@@ -64,7 +64,6 @@ import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { DialogUpdate } from "./component/dialog-update"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
@@ -88,6 +87,7 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { newSessionLocation } from "./config/new-session-location"
|
||||
import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification"
|
||||
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
|
||||
import { localPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { PanelProvider, usePanel } from "./context/panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -154,6 +155,7 @@ const appBindingCommands = [
|
||||
"provider.connect",
|
||||
"opencode.settings",
|
||||
"opencode.status",
|
||||
"opencode.update",
|
||||
"server.pair",
|
||||
"service.restart",
|
||||
"opencode.debug",
|
||||
@@ -185,10 +187,7 @@ export type TuiInput = {
|
||||
}
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
updater?: UpdateSource
|
||||
packages: PackageSource
|
||||
environment?: Readonly<Record<string, string>>
|
||||
terminalHandoff?: () => Promise<
|
||||
@@ -397,22 +396,27 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
<UpdateNotificationProvider
|
||||
updater={input.updater}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
<PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</PanelProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
@@ -462,7 +466,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
|
||||
function App(props: { pair?: DialogPairCredentials }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
@@ -474,10 +478,12 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const panels = usePanel()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const updater = useUpdateNotification()
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const data = useData()
|
||||
@@ -501,40 +507,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
|
||||
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
|
||||
})
|
||||
const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>(
|
||||
"update-notifications",
|
||||
{ initial: { versions: [] } },
|
||||
)
|
||||
const showUpdate = (version: string) => {
|
||||
const updater = props.updater
|
||||
if (!updater || updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
const key = `update:${version}`
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogUpdate
|
||||
dialogKey={key}
|
||||
version={version}
|
||||
install={() => updater.apply(version)}
|
||||
restart={client.restart}
|
||||
/>
|
||||
),
|
||||
undefined,
|
||||
{ key },
|
||||
)
|
||||
dialog.setCentered(true)
|
||||
}
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater.monitor(showUpdate, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update monitor failed", { error })
|
||||
})
|
||||
})
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
@@ -608,9 +580,22 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
|
||||
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const fullscreenPanel = () =>
|
||||
route.data.type === "session" &&
|
||||
panels.current()?.sessionID === route.data.sessionID &&
|
||||
panels.presentation() === "fullscreen"
|
||||
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
|
||||
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
|
||||
|
||||
// Measure the prospective split layout, even while full-screen hides the tabs.
|
||||
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
|
||||
createEffect(() => {
|
||||
const current = panels.current()
|
||||
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
|
||||
panels.close()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
@@ -972,6 +957,17 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(updater.open
|
||||
? [
|
||||
{
|
||||
name: "opencode.update",
|
||||
title: "Update OpenCode",
|
||||
slash: { name: "update" },
|
||||
run: () => updater.open?.("manual"),
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "server.pair",
|
||||
title: "Pair device",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user