mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-11-14 07:42:03 +08:00
fix(app): recover only confirmed missing worktree locations
A location.sync failure can come from transient server errors and /api/location boots the source before it can determine whether the path exists. Probe filesystem NotFound outside the location middleware, ignore stale and failed probes, and show the recovery dock only after a confirmed missing source. Keep session history and draft, and create replacement worktrees from the known project root.
This commit is contained in:
@@ -29,6 +29,14 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
|
||||
if (recover && requests > 1) return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.route("**/api/location/probe?**", (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
json: { _tag: "ServiceUnavailableError", message: "Probe failed" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
const probe = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/location/probe")
|
||||
const failure = page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === endpoint && response.status() === 500,
|
||||
)
|
||||
@@ -41,6 +49,7 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
|
||||
)
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
expect((await probe).status()).toBe(500)
|
||||
await failure
|
||||
await expect(page.getByText("Keep working in this worktree", { exact: true })).toBeVisible()
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
@@ -22,23 +22,28 @@ test("keeps a session in a removed worktree readable and movable", async ({ page
|
||||
})
|
||||
await page.route("**/api/**", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname === "/api/location/probe" && url.searchParams.get("directory") === missing)
|
||||
return route.fulfill({ json: { exists: false }, headers: { "access-control-allow-origin": "*" } })
|
||||
if (url.searchParams.get("location[directory]") !== missing) return route.fallback()
|
||||
if (!["/api/location", "/api/agent", "/api/provider", "/api/model", "/api/model/default"].includes(url.pathname))
|
||||
return route.fallback()
|
||||
return route.fulfill({ status: 500, body: "", headers: { "access-control-allow-origin": "*" } })
|
||||
return route.fulfill({
|
||||
status: 500,
|
||||
json: { _tag: "ServiceUnavailableError", message: "Unable to boot removed location" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" }))
|
||||
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`, { waitUntil: "domcontentloaded" })
|
||||
await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible()
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
await prompt.fill("Keep the draft while moving")
|
||||
await expect(prompt).toHaveText("Keep the draft while moving")
|
||||
await expect(page.getByRole("status")).toContainText("Session location unavailable")
|
||||
await expect(page.getByRole("status")).toContainText(missing)
|
||||
await expect(prompt).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toBeEnabled()
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Session details" }).click()
|
||||
await page.getByRole("button", { name: "Local repository" }).click()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
const create = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
@@ -59,6 +64,193 @@ test("keeps a session in a removed worktree readable and movable", async ({ page
|
||||
data: { sessionID, location: { directory: created }, projectID: fixture.project.id },
|
||||
})
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Keep the draft while moving")
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Claude Opus 4.6")
|
||||
await expect(page.getByText("Saved conversation in removed worktree", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("preserves a draft when a worktree disappears and resumes after choosing a directory", async ({ page }) => {
|
||||
const source = "/projects/draft-worktree"
|
||||
const destination = "/projects/restored"
|
||||
const sessionID = "ses_draft_recovery"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory: source, title: "Draft recovery" }
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
let exists = true
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: destination,
|
||||
project: { ...fixture.project, worktree: destination },
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_draft", type: "user", text: "Saved draft history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route("**/api/location/probe?**", (route) =>
|
||||
route.fulfill({ json: { exists }, headers: { "access-control-allow-origin": "*" } }),
|
||||
)
|
||||
let moves = 0
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
moves++
|
||||
if (moves === 1)
|
||||
return route.fulfill({
|
||||
status: 400,
|
||||
json: { _tag: "InvalidRequestError", message: "Destination is unavailable" },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
return route.fulfill({ status: 204, body: "" })
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("A draft to keep after moving")
|
||||
const connection = await transport.waitForConnection()
|
||||
const missing = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname === "/api/location/probe" && url.searchParams.get("directory") === source && response.ok()
|
||||
})
|
||||
exists = false
|
||||
await transport.close()
|
||||
await transport.waitForConnection({ after: connection.id })
|
||||
await missing
|
||||
await expect(page.getByRole("status")).toContainText("Session location unavailable")
|
||||
await expect(prompt).toHaveCount(0)
|
||||
await expect(page.getByText("Saved draft history", { exact: true })).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Choose directory", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog", { name: "Choose directory", exact: true })
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
const move = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
expect((await move).postDataJSON()).toEqual({ directory: destination })
|
||||
await expect(page.getByText("Failed to move session", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Choose directory", exact: true })).toBeEnabled()
|
||||
await expect(prompt).toHaveCount(0)
|
||||
expect(moves).toBe(1)
|
||||
|
||||
await page.getByRole("button", { name: "Choose directory", exact: true }).click()
|
||||
await expect(dialog.getByRole("combobox")).toBeFocused()
|
||||
await dialog.getByRole("combobox").fill(destination)
|
||||
await dialog.getByRole("combobox").press("Enter")
|
||||
await expect(dialog.locator(".directory-picker-selection")).toHaveText(destination)
|
||||
const secondMove = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Select folder", exact: true }).click()
|
||||
expect((await secondMove).postDataJSON()).toEqual({ directory: destination })
|
||||
expect(moves).toBe(2)
|
||||
session.directory = destination
|
||||
exists = true
|
||||
await transport.send({
|
||||
id: "evt_draft_recovery_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("A draft to keep after moving")
|
||||
await expect(page.getByText("Saved draft history", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("ignores a stale missing result after the session moves", async ({ page }) => {
|
||||
const source = "/projects/old-worktree"
|
||||
const destination = "/projects/new-worktree"
|
||||
const sessionID = "ses_stale_probe"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory: source, title: "Moving session" }
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/location/probe?**", async (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("directory") === source) {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
return route.fulfill({ json: { exists: false }, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
return route.fulfill({ json: { exists: true }, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await requested.promise
|
||||
const prompt = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(prompt).toBeEditable()
|
||||
await prompt.fill("Draft in new worktree")
|
||||
await transport.waitForConnection()
|
||||
const next = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname === "/api/location/probe" && url.searchParams.get("directory") === destination && response.ok()
|
||||
})
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
id: "evt_stale_probe_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await next
|
||||
const old = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname === "/api/location/probe" && url.searchParams.get("directory") === source && response.ok()
|
||||
})
|
||||
release.resolve()
|
||||
await old
|
||||
await expect(prompt).toBeEditable()
|
||||
await expect(prompt).toHaveText("Draft in new worktree")
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("moves a removed-worktree session into an existing worktree", async ({ page }) => {
|
||||
const source = "/projects/deleted-worktree"
|
||||
const destination = "/projects/existing-worktree"
|
||||
const sessionID = "ses_existing_worktree"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory: source, title: "Existing worktree" }
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: { ...fixture.project, sandboxes: [destination] },
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/location/probe?**", (route) =>
|
||||
route.fulfill({
|
||||
json: { exists: new URL(route.request().url()).searchParams.get("directory") !== source },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => route.fulfill({ status: 204, body: "" }))
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
await expect(page.getByRole("status")).toContainText("Session location unavailable")
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
const option = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
|
||||
await expect(option).toBeVisible()
|
||||
const move = page.waitForRequest(
|
||||
(request) => new URL(request.url()).pathname === `/api/session/${sessionID}/move` && request.method() === "POST",
|
||||
)
|
||||
await option.click()
|
||||
expect((await move).postDataJSON()).toEqual({ directory: destination })
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
id: "evt_existing_worktree_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -17,13 +17,13 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
const local = useLocal()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => sdk().catalogDirectory)
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const view = layout.view(input.sessionKey)
|
||||
|
||||
return createMemo<ComposerControls>(() => {
|
||||
return {
|
||||
agents: {
|
||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []),
|
||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
visible: local.agent.visible(),
|
||||
@@ -34,7 +34,7 @@ export function createComposerControls(input: { sessionKey: Accessor<string>; mo
|
||||
paid: providers.paid().length > 0,
|
||||
loading:
|
||||
!(input.model ?? local.model).ready() ||
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().catalogDirectory }) === undefined) ||
|
||||
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
|
||||
!providers.ready(),
|
||||
},
|
||||
session: {
|
||||
@@ -67,7 +67,7 @@ export function createComposerModelSelection(input: {
|
||||
},
|
||||
),
|
||||
)
|
||||
const providers = useProviders(() => sdk().catalogDirectory)
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
const valid = (model: Pick<ModelKey, "providerID" | "modelID">) => {
|
||||
|
||||
@@ -9,13 +9,13 @@ export function useConfiguredModel() {
|
||||
const server = useServerSDK()
|
||||
createEffect(
|
||||
on(
|
||||
() => [location().catalogDirectory, server.connection.status()] as const,
|
||||
() => [location().directory, server.connection.status()] as const,
|
||||
([directory]) => {
|
||||
void data.location.config.sync({ directory }).catch(() => undefined)
|
||||
},
|
||||
),
|
||||
)
|
||||
const documents = () => data.location.config.list({ directory: location().catalogDirectory })
|
||||
const documents = () => data.location.config.list({ directory: location().directory })
|
||||
const model = createMemo(() => {
|
||||
const entry = documents()?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
|
||||
const model = entry?.type === "document" ? entry.info.model : undefined
|
||||
|
||||
@@ -80,14 +80,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const sdk = useWorkspaceLocation()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().catalogDirectory)
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
const configuredModel = useConfiguredModel()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() =>
|
||||
normalizeAgentList(data.location.agent.list({ directory: sdk().catalogDirectory }) ?? []).filter(
|
||||
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
|
||||
(item) => item.mode !== "subagent" && !item.hidden,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createResource, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
|
||||
export type MissingLocation = { sessionID: string; projectID: string; directory: string }
|
||||
|
||||
export function SessionLocationMissing(props: MissingLocation) {
|
||||
const language = useLanguage()
|
||||
const sdk = useServerSDK()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
|
||||
const project = () => data.project.get(props.projectID)
|
||||
const [worktrees] = createResource(
|
||||
() => (state.worktreesOpen ? props.projectID : undefined),
|
||||
async (projectID) => {
|
||||
try {
|
||||
await sdk.api.worktree.refresh({ projectID })
|
||||
return await sdk.api.worktree.list({ projectID })
|
||||
} catch {
|
||||
showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
|
||||
return []
|
||||
}
|
||||
},
|
||||
{ initialValue: [] },
|
||||
)
|
||||
let button: HTMLButtonElement | undefined
|
||||
|
||||
const move = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; directory?: string }) => {
|
||||
const current = project()
|
||||
if (!current) throw new Error(language.t("workspace.move.failed"))
|
||||
const destination =
|
||||
input.directory ??
|
||||
(await createWorktree({
|
||||
api: sdk.api,
|
||||
data,
|
||||
directory: current.canonical,
|
||||
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
|
||||
}))
|
||||
await sdk.api.session.move({ sessionID: input.sessionID, directory: destination })
|
||||
},
|
||||
onError: (error) => {
|
||||
setState("restoreFocus", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.move.failed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.restoreFocus || move.isPending || dialog.active) return
|
||||
setState("restoreFocus", false)
|
||||
button?.focus()
|
||||
})
|
||||
|
||||
const choose = () => {
|
||||
if (move.isPending) return
|
||||
const sessionID = props.sessionID
|
||||
pickDirectory({
|
||||
server: sdk.server,
|
||||
title: language.t("session.location.choose"),
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
if (directory) move.mutate({ sessionID, directory })
|
||||
if (!directory) setState("restoreFocus", true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-location-missing">
|
||||
<DockShell class="flex flex-col gap-2 p-3">
|
||||
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
|
||||
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
|
||||
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
|
||||
<div class="text-text-base">{language.t("session.location.description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray class="flex flex-wrap justify-end gap-2 p-2">
|
||||
<Show when={project()?.vcs === "git"}>
|
||||
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
|
||||
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
|
||||
{language.t("session.location.worktree")}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
|
||||
<Menu.Item onSelect={() => move.mutate({ sessionID: props.sessionID })} disabled={move.isPending}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={worktrees.loading}>
|
||||
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
|
||||
</Show>
|
||||
<For each={worktrees.latest.filter((item) => item.strategy && item.directory !== props.directory)}>
|
||||
{(worktree) => (
|
||||
<Menu.Item
|
||||
title={worktree.directory}
|
||||
onSelect={() => move.mutate({ sessionID: props.sessionID, directory: worktree.directory })}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="truncate">{getFilename(worktree.directory)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
|
||||
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
|
||||
</Button>
|
||||
</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
@@ -28,6 +28,7 @@ import { createSessionComposerController, type SessionComposerController } from
|
||||
import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import { SessionLocationMissing, type MissingLocation } from "./location-missing"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
@@ -216,22 +217,33 @@ export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegi
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
model: SessionComposerController
|
||||
suggestionBoundary: () => HTMLElement | undefined
|
||||
missing?: Accessor<MissingLocation | undefined>
|
||||
}) {
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={props.model.region}
|
||||
controller={{
|
||||
...props.model.region,
|
||||
showComposer: () => props.model.region.showComposer() || !!props.missing?.(),
|
||||
}}
|
||||
composer={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={props.missing?.()}
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(missing) => <SessionLocationMissing {...missing()} />}
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ErrorBoundary, createEffect, createMemo, onCleanup, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { retry } from "@opencode/util/retry"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { DataProvider } from "@opencode/session-ui/context"
|
||||
import { SessionUserMessage } from "@opencode/session-ui/message"
|
||||
@@ -19,7 +18,6 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { TerminalProvider } from "@/session/terminal/context"
|
||||
import { useSettingsCommand } from "@/settings/command"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import { isProjectDirectory } from "@/workspaces/paths"
|
||||
import { useTabs, type PendingSession } from "@/shell/tabs/tabs"
|
||||
import { requireServerKey } from "@/shell/routes/session"
|
||||
import { useSessionModel } from "./model"
|
||||
@@ -30,59 +28,51 @@ import { SessionErrorFallback } from "./route-error"
|
||||
import { createSessionResolution } from "./session-resolution"
|
||||
import { SessionScreen } from "./screen"
|
||||
import { PreparingComposer } from "./preparing-composer"
|
||||
import type { MissingLocation } from "./composer/location-missing"
|
||||
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const data = useData()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const [locationState, setLocationState] = createStore({ unavailableSource: "" })
|
||||
const [locationState, setLocationState] = createStore<{ missing?: MissingLocation }>({})
|
||||
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
|
||||
createEffect(() => {
|
||||
const session = data.session.get(params.id)
|
||||
if (!session) return
|
||||
const source = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
if (
|
||||
!project ||
|
||||
data.location.info({ directory: source }) ||
|
||||
isProjectDirectory({ worktree: project.canonical, sandboxes: project.sandboxes }, source)
|
||||
)
|
||||
return
|
||||
const directory = session?.location.directory
|
||||
const project = session && data.project.get(session.projectID)
|
||||
const connected = server.ctx.sdk.connection.status() === "connected"
|
||||
setLocationState("missing", undefined)
|
||||
if (!session || !directory || !project || !connected || directory === project.canonical) return
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
void retry(() => data.location.syncInfo({ directory: source }), { retryIf: () => !stale }).then(
|
||||
() => {
|
||||
if (!stale) setLocationState("unavailableSource", "")
|
||||
},
|
||||
() => {
|
||||
if (!stale) setLocationState("unavailableSource", source)
|
||||
void server.ctx.sdk.api.location.probe({ directory }).then(
|
||||
(result) => {
|
||||
if (!stale && !result.exists)
|
||||
setLocationState("missing", { sessionID: session.id, projectID: project.id, directory })
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
})
|
||||
const catalogDirectory = createMemo(() => {
|
||||
const missing = createMemo(() => {
|
||||
const session = data.session.get(params.id)
|
||||
if (!session) return
|
||||
const source = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
if (!project || source !== locationState.unavailableSource || data.location.info({ directory: source }))
|
||||
return source
|
||||
if (data.location.provider.list({ directory: source }) && data.location.model.list({ directory: source }))
|
||||
return source
|
||||
// A removed worktree cannot supply a catalog, but its session still belongs to the known project.
|
||||
return project.canonical
|
||||
const state = locationState.missing
|
||||
if (!state || !session || session.id !== state.sessionID || session.location.directory !== state.directory) return
|
||||
if (server.ctx.sdk.connection.status() !== "connected") return
|
||||
return state
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<MarkSessionNotificationsViewed sessionID={() => params.id} />
|
||||
<ModelsProvider directory={catalogDirectory}>
|
||||
<ModelsProvider directory={directory}>
|
||||
<TargetSessionSettingsCommand />
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||
<Show
|
||||
when={tabs.pendingSession(server.key, params.id)}
|
||||
fallback={<ResolvedTargetSessionRoute catalogDirectory={catalogDirectory} />}
|
||||
fallback={<ResolvedTargetSessionRoute missing={missing} />}
|
||||
>
|
||||
{(pending) => <PreparingSession sessionID={params.id} pending={pending()} />}
|
||||
</Show>
|
||||
@@ -153,7 +143,7 @@ function SessionRouteErrorBoundary(props: ParentProps<{ sessionID?: string; serv
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute(props: { catalogDirectory: () => string | undefined }) {
|
||||
function ResolvedTargetSessionRoute(props: { missing: () => MissingLocation | undefined }) {
|
||||
const params = useParams<{ id: string }>()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
@@ -178,9 +168,9 @@ function ResolvedTargetSessionRoute(props: { catalogDirectory: () => string | un
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value} catalogDirectory={props.catalogDirectory}>
|
||||
<SessionUIProvider directory={value()} providerDirectory={props.catalogDirectory()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
<LocationProvider directory={value}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage missing={props.missing} />
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
)}
|
||||
@@ -205,7 +195,7 @@ function SessionStatePanel(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
function TargetSessionPage(props: { missing: () => MissingLocation | undefined }) {
|
||||
return (
|
||||
// These providers select their scoped state reactively and retain bounded caches,
|
||||
// so keep their owners alive while navigating between workspaces on this server.
|
||||
@@ -213,7 +203,7 @@ function TargetSessionPage() {
|
||||
<FileProvider>
|
||||
<ComposerPersistenceProvider>
|
||||
<CommentsProvider>
|
||||
<SessionPage />
|
||||
<SessionPage missing={props.missing} />
|
||||
</CommentsProvider>
|
||||
</ComposerPersistenceProvider>
|
||||
</FileProvider>
|
||||
@@ -221,9 +211,9 @@ function TargetSessionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPage() {
|
||||
function SessionPage(props: { missing: () => MissingLocation | undefined }) {
|
||||
const session = useSessionModel()
|
||||
return <SessionScreen session={session} />
|
||||
return <SessionScreen session={session} missing={props.missing} />
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID: () => string | undefined }) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createEffect,
|
||||
createComputed,
|
||||
on,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
@@ -36,6 +37,7 @@ import { createSessionBrowser } from "./browser/model"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
|
||||
import { createSessionBtw } from "./btw/model"
|
||||
import type { MissingLocation } from "./composer/location-missing"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -47,19 +49,23 @@ const SessionSummaryPanel = lazy(async () => {
|
||||
return { default: SessionSummaryPanel }
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
export function SessionScreen(props: { session: SessionModel; missing?: Accessor<MissingLocation | undefined> }) {
|
||||
// The timeline cache captures its owner when created, so link handling must be provided above it.
|
||||
const browser = createSessionBrowser(props.session)
|
||||
return (
|
||||
<ArtifactOpenerProvider session={props.session} browser={browser}>
|
||||
<ArtifactMarkdownProvider>
|
||||
<SessionScreenContent session={props.session} browser={browser} />
|
||||
<SessionScreenContent session={props.session} browser={browser} missing={props.missing} />
|
||||
</ArtifactMarkdownProvider>
|
||||
</ArtifactOpenerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
|
||||
function SessionScreenContent(props: {
|
||||
session: SessionModel
|
||||
browser: ReturnType<typeof createSessionBrowser>
|
||||
missing?: Accessor<MissingLocation | undefined>
|
||||
}) {
|
||||
const session = props.session
|
||||
const browser = props.browser
|
||||
const server = useServer()
|
||||
@@ -323,7 +329,9 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
|
||||
</div>
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} missing={props.missing} />
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import { readLocalImage } from "@/runtime/server/image"
|
||||
export function SessionUIProvider(
|
||||
props: ParentProps<{
|
||||
directory: string
|
||||
providerDirectory?: string
|
||||
server: ServerConnection.Key
|
||||
}>,
|
||||
) {
|
||||
@@ -40,7 +39,7 @@ export function SessionUIProvider(
|
||||
await data.session.sync(sessionID).catch(() => undefined)
|
||||
navigate(href(sessionID))
|
||||
}
|
||||
const providers = useProviders(() => props.providerDirectory ?? directory())
|
||||
const providers = useProviders(directory)
|
||||
const sessionUIData = createMemo(() => ({
|
||||
provider: providers.ready()
|
||||
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
|
||||
|
||||
@@ -9,16 +9,11 @@ export type { LocationContext } from "@/runtime/server/client"
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
readonly ref: LocationRef
|
||||
readonly current: LocationGetOutput | undefined
|
||||
readonly catalogDirectory: string
|
||||
}
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: {
|
||||
directory: string | Accessor<string>
|
||||
workspaceID?: string | Accessor<string | undefined>
|
||||
catalogDirectory?: Accessor<string | undefined>
|
||||
}) => {
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
@@ -58,7 +53,6 @@ const context = createSimpleContext({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
catalogDirectory: props.catalogDirectory?.() ?? ref().directory,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -60,6 +60,10 @@ export interface ServerApi<E = never> {
|
||||
readonly connect: ServerConnectOperation<E>
|
||||
}
|
||||
|
||||
export type LocationProbeInput = { readonly directory: string }
|
||||
export type LocationProbeOutput = { readonly exists: boolean }
|
||||
export type LocationProbeOperation<E = never> = (input: LocationProbeInput) => Effect.Effect<LocationProbeOutput, E>
|
||||
|
||||
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type LocationGetOutput = Location.PublicInfo
|
||||
export type LocationGetOperation<E = never> = (input?: LocationGetInput) => Effect.Effect<LocationGetOutput, E>
|
||||
@@ -68,6 +72,7 @@ export type LocationReloadOutput = void
|
||||
export type LocationReloadOperation<E = never> = () => Effect.Effect<LocationReloadOutput, E>
|
||||
|
||||
export interface LocationApi<E = never> {
|
||||
readonly probe: LocationProbeOperation<E>
|
||||
readonly get: LocationGetOperation<E>
|
||||
readonly reload: LocationReloadOperation<E>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationProbeInput,
|
||||
LocationProbeOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -301,6 +303,11 @@ const adaptGroupServer = (raw: RawClient["server.server"]) => ({
|
||||
connect: EndpointServerConnect(raw),
|
||||
})
|
||||
|
||||
const EndpointLocationProbe = (raw: RawClient["server.location"]) => (input: LocationProbeInput) =>
|
||||
preserveEffect<LocationProbeOutput>()(
|
||||
raw["location.probe"]({ query: { directory: input["directory"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
|
||||
preserveEffect<LocationGetOutput>()(
|
||||
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -310,6 +317,7 @@ const EndpointLocationReload = (raw: RawClient["server.location"]) => () =>
|
||||
preserveEffect<LocationReloadOutput>()(raw["location.reload"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupLocation = (raw: RawClient["server.location"]) => ({
|
||||
probe: EndpointLocationProbe(raw),
|
||||
get: EndpointLocationGet(raw),
|
||||
reload: EndpointLocationReload(raw),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import type {
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationProbeInput,
|
||||
LocationProbeOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -440,6 +442,18 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
location: {
|
||||
probe: (input: LocationProbeInput, requestOptions?: RequestOptions) =>
|
||||
request<LocationProbeOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/location/probe`,
|
||||
query: { directory: input["directory"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||
request<LocationGetOutput>(
|
||||
{
|
||||
|
||||
@@ -2742,6 +2742,10 @@ export type ServerConnectInput = { readonly code: { readonly code: string }["cod
|
||||
|
||||
export type ServerConnectOutput = PairingSession
|
||||
|
||||
export type LocationProbeInput = { readonly directory: { readonly directory: string }["directory"] }
|
||||
|
||||
export type LocationProbeOutput = { exists: boolean }
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ export const LocationQuery = Schema.Struct({
|
||||
),
|
||||
}).annotate({ identifier: "LocationQuery" })
|
||||
|
||||
const ProbeQuery = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
}).annotate({ identifier: "LocationProbeQuery" })
|
||||
|
||||
export const locationQueryOpenApi = OpenApi.annotations({
|
||||
transform: (operation) => {
|
||||
const parameters = operation.parameters
|
||||
@@ -32,6 +36,19 @@ export const makeLocationGroup = <LocationId extends HttpApiMiddleware.AnyId, Lo
|
||||
locationMiddleware: Context.Key<LocationId, LocationService>,
|
||||
) =>
|
||||
HttpApiGroup.make("server.location")
|
||||
.add(
|
||||
HttpApiEndpoint.get("location.probe", "/api/location/probe", {
|
||||
query: ProbeQuery,
|
||||
success: Schema.Struct({ exists: Schema.Boolean }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "location.probe",
|
||||
summary: "Probe location path",
|
||||
description:
|
||||
"Check if an explicit path exists without loading its location. Only filesystem NotFound returns false; other filesystem failures do not imply absence.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("location.get", "/api/location", {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { ServiceUnavailableError } from "@opencode/protocol/errors"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
@@ -8,7 +9,15 @@ import { Api } from "../api"
|
||||
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
return handlers
|
||||
.handle("location.probe", (ctx) =>
|
||||
fs.stat(ctx.query.directory).pipe(
|
||||
Effect.as({ exists: true }),
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed({ exists: false })),
|
||||
Effect.orDie,
|
||||
),
|
||||
)
|
||||
.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ShellSelect } from "@opencode/core/shell/select"
|
||||
import { Job } from "@opencode/core/job"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { InstructionDiscovery } from "@opencode/core/instruction-discovery"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { LocationActivity } from "@opencode/core/location-activity"
|
||||
@@ -51,6 +52,7 @@ import type { ServerOptions } from "./options"
|
||||
|
||||
const applicationServiceNodes = [
|
||||
Global.node,
|
||||
FSUtil.node,
|
||||
Database.node,
|
||||
Bus.node,
|
||||
EventLogger.node,
|
||||
@@ -168,6 +170,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
PermissionSaved.Service,
|
||||
PluginUpdate.Service,
|
||||
Project.Service,
|
||||
FSUtil.Service,
|
||||
WellKnown.Service,
|
||||
)(context),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
|
||||
it.live("probes a directory without booting its location or masking a missing path", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const existing = path.join(tmp.path, "existing")
|
||||
const missing = path.join(tmp.path, "removed")
|
||||
const file = path.join(tmp.path, "file")
|
||||
const loop = path.join(tmp.path, "loop")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(existing)
|
||||
await fs.writeFile(file, "content")
|
||||
await fs.symlink(loop, loop)
|
||||
})
|
||||
const server = yield* startServer(tmp.path)
|
||||
const probe = (directory?: string) =>
|
||||
Effect.promise(async () => {
|
||||
const url = new URL("/api/location/probe", server.base)
|
||||
if (directory !== undefined) url.searchParams.set("directory", directory)
|
||||
const response = await fetch(url, { headers: server.headers })
|
||||
return { status: response.status, body: await response.json() }
|
||||
})
|
||||
|
||||
expect(yield* probe(existing)).toEqual({ status: 200, body: { exists: true } })
|
||||
expect(yield* probe(missing)).toEqual({ status: 200, body: { exists: false } })
|
||||
expect(yield* probe(file)).toEqual({ status: 200, body: { exists: true } })
|
||||
expect((yield* probe(path.join(file, "child"))).status).toBe(500)
|
||||
expect((yield* probe(loop)).status).toBe(500)
|
||||
expect((yield* probe()).status).toBe(400)
|
||||
const loaded = yield* Effect.promise(async () =>
|
||||
(await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })).json(),
|
||||
)
|
||||
expect(loaded).toEqual([])
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user