mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 23:46:16 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
887f319769 | ||
|
|
24f6cb51c8 | ||
|
|
d2bec894bf | ||
|
|
d7009156a6 | ||
|
|
4cfca7ab68 | ||
|
|
ae7d9f029d | ||
|
|
9acdb0be18 | ||
|
|
b42555cf01 | ||
|
|
cfe9f13963 |
@@ -1046,6 +1046,7 @@
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
|
||||
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
|
||||
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
|
||||
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
|
||||
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
|
||||
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
|
||||
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
|
||||
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"vite@8.2.2": "patches/vite@8.2.2.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
.toEqual({ placement: "grouped", details: "collapsed" })
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await page.getByRole("button", { name: "Reasoning", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await part.getByRole("button").click()
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -138,7 +138,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
test("keeps failed search calls and their error cards outside the collapsed stack", async ({ page }) => {
|
||||
test("keeps failed search calls and their error cards inside the collapsed stack", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_error_glob",
|
||||
@@ -161,9 +161,14 @@ test("keeps failed search calls and their error cards outside the collapsed stac
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
const glob = page.locator('[data-timeline-part-id="prt_error_glob"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
|
||||
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await glob.getByRole("button").click()
|
||||
await expect(glob).toContainText("Invalid tool input")
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
|
||||
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
|
||||
@@ -177,7 +182,8 @@ test("keeps failed search calls and their error cards outside the collapsed stac
|
||||
.evaluate((element) => getComputedStyle(element, "::before").display),
|
||||
)
|
||||
.toBe("none")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
|
||||
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
|
||||
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
|
||||
"Search timed out after 30 seconds",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
||||
test("keeps shell and question failures in their Used group", async ({ page }) => {
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "expanded" } },
|
||||
},
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -26,13 +24,24 @@ test("transitions shell and question through running error outcomes", async ({ p
|
||||
),
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await used.click()
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
partUpdated(
|
||||
toolPart(
|
||||
shellID,
|
||||
"shell",
|
||||
"completed",
|
||||
{ command: "exit 1" },
|
||||
{ output: "Command exited 1", metadata: { exit: 1 } },
|
||||
),
|
||||
),
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -40,17 +49,21 @@ test("transitions shell and question through running error outcomes", async ({ p
|
||||
),
|
||||
)
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await shell.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(shell).toContainText("Command exited 1")
|
||||
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
|
||||
await expect(question).toContainText(/dismissed/i)
|
||||
})
|
||||
|
||||
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
|
||||
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
|
||||
const failed = "prt_grouped_patch_failed"
|
||||
const surviving = "prt_grouped_patch_surviving"
|
||||
const timeline = await setupTimeline(page, {
|
||||
settings: {
|
||||
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
|
||||
},
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
@@ -81,7 +94,9 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
],
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await used.click()
|
||||
const file = group.locator('[data-scope="apply-patch"] button')
|
||||
await expect(file).toBeVisible()
|
||||
await expect(file).toHaveAttribute("aria-expanded", "false")
|
||||
@@ -104,18 +119,14 @@ test("preserves surviving grouped patch state when its first patch fails", async
|
||||
const survivingRow = page.locator("[data-timeline-key]", {
|
||||
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
|
||||
})
|
||||
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
|
||||
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
|
||||
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
|
||||
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
|
||||
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
|
||||
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
|
||||
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const previous = await failedRow.boundingBox()
|
||||
const next = await survivingRow.boundingBox()
|
||||
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
|
||||
})
|
||||
.toBeGreaterThanOrEqual(-0.5)
|
||||
})
|
||||
|
||||
test("groups instruction files loaded by the same read", async ({ page }) => {
|
||||
|
||||
@@ -53,9 +53,8 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
}),
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
fileList: (path) =>
|
||||
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||
findFiles: () => ["NewProject"],
|
||||
// Listings are requested by absolute path and returned relative to the stable Location.
|
||||
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||
@@ -76,12 +75,23 @@ test("creates a session in a new project and selects its model", async ({ page }
|
||||
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||
await expectAppVisible(addProject)
|
||||
await addProject.click()
|
||||
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
|
||||
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
|
||||
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
|
||||
const listing = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
|
||||
})
|
||||
await picker.getByRole("button", { name: "Parent", exact: true }).click()
|
||||
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
|
||||
await expect(directoryItem).toBeVisible()
|
||||
await directoryItem.click()
|
||||
const selectFolder = page.getByRole("button", { name: "Select folder" })
|
||||
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
|
||||
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
|
||||
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
|
||||
await expect(selectFolder).toBeEnabled()
|
||||
await selectFolder.click()
|
||||
await expect(picker).toBeHidden()
|
||||
|
||||
await page.locator('[data-action="home-new-session"]').click()
|
||||
await expectAppVisible(page.locator('[data-component="composer"]'))
|
||||
|
||||
@@ -50,6 +50,7 @@ export function createComposerProjectControls(props: { draftId: string; worktree
|
||||
if (!connection) return
|
||||
pickDirectory({
|
||||
server: connection,
|
||||
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
|
||||
title,
|
||||
onSelect: (result) => {
|
||||
const directory = Array.isArray(result) ? result[0] : result
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
@@ -15,6 +16,7 @@ export function createProviderConnectionController(options: {
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const location = () => {
|
||||
@@ -184,7 +186,14 @@ export function createProviderConnectionController(options: {
|
||||
...(answer ? { answer } : {}),
|
||||
location: location(),
|
||||
})
|
||||
.then((response) => ({ ok: true as const, authorization: response.data }))
|
||||
.then((response) => {
|
||||
if (options.provider() === "opencode" && platform.platform === "desktop") {
|
||||
const url = new URL(response.data.url)
|
||||
url.searchParams.set("client_id", "opencode-desktop")
|
||||
response.data.url = url.href
|
||||
}
|
||||
return { ok: true as const, authorization: response.data }
|
||||
})
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Ghostty, Terminal } from "ghostty-web"
|
||||
|
||||
describe("terminal application mouse reporting", () => {
|
||||
let container: HTMLDivElement
|
||||
let terminal: Terminal
|
||||
let canvas: HTMLCanvasElement
|
||||
let data: string[]
|
||||
|
||||
beforeEach(async () => {
|
||||
container = document.createElement("div")
|
||||
document.body.appendChild(container)
|
||||
terminal = new Terminal({ cols: 80, rows: 24, ghostty: await Ghostty.load() })
|
||||
terminal.open(container)
|
||||
terminal.write("\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1015h\x1b[?1006h")
|
||||
canvas = container.querySelector("canvas")!
|
||||
data = []
|
||||
terminal.onData((value) => data.push(value))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
terminal.dispose()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
const wheel = (deltaX: number, deltaY: number) => {
|
||||
const event = new WheelEvent("wheel", { deltaX, deltaY, bubbles: true })
|
||||
Object.defineProperties(event, { clientX: { value: 40 }, clientY: { value: 30 } })
|
||||
canvas.dispatchEvent(event)
|
||||
}
|
||||
|
||||
test("reports wheel input with its terminal coordinates instead of arrow keys", () => {
|
||||
wheel(0, -100)
|
||||
|
||||
expect(data).toEqual(["\x1b[<64;6;3M"])
|
||||
})
|
||||
|
||||
test("does not discard small trackpad deltas", () => {
|
||||
wheel(0, -1)
|
||||
|
||||
expect(data).toEqual(["\x1b[<64;6;3M"])
|
||||
})
|
||||
|
||||
test("reports horizontal scrolling", () => {
|
||||
wheel(100, 0)
|
||||
|
||||
expect(data).toEqual(["\x1b[<67;6;3M"])
|
||||
})
|
||||
|
||||
test("distinguishes pointer movement from a left-button drag", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual(["\x1b[<35;6;3M"])
|
||||
})
|
||||
|
||||
test("encodes Alt-modified clicks", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousedown", { altKey: true, clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual(["\x1b[<8;6;3M"])
|
||||
})
|
||||
|
||||
test("leaves application clicks available for process selection", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousedown", { clientX: 40, clientY: 30, bubbles: true }))
|
||||
canvas.dispatchEvent(new MouseEvent("mouseup", { clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual(["\x1b[<0;6;3M", "\x1b[<0;6;3m"])
|
||||
})
|
||||
|
||||
test("preserves Shift as the terminal selection override", () => {
|
||||
canvas.dispatchEvent(new MouseEvent("mousedown", { shiftKey: true, clientX: 40, clientY: 30, bubbles: true }))
|
||||
|
||||
expect(data).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -434,7 +434,7 @@ export function Titlebar(props: {
|
||||
}}
|
||||
>
|
||||
<Show when={!mobile() && (!props.verticalTabs || windows())}>
|
||||
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
</Show>
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
@@ -753,18 +753,23 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
|
||||
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
|
||||
const platform = usePlatform()
|
||||
const windows = () => platform.platform === "desktop" && platform.os === "windows"
|
||||
const classes = () => ({
|
||||
"px-2 rounded-sm": windows(),
|
||||
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
|
||||
})
|
||||
const style = () => ({
|
||||
height: props.height,
|
||||
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
|
||||
})
|
||||
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
|
||||
if (channel === "dev" && props.debugTools) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
onClick={props.debugTools.toggle}
|
||||
aria-label="Toggle debug tools"
|
||||
@@ -780,7 +785,8 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div
|
||||
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
|
||||
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
|
||||
classList={classes()}
|
||||
style={style()}
|
||||
>
|
||||
{value()}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { Path } from "@/runtime/server/types"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
absoluteTreePath,
|
||||
activeTreeNavigation,
|
||||
@@ -26,16 +26,19 @@ import {
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
listPickerDirectory,
|
||||
pickerRelativePath,
|
||||
pickerAbsolutePath,
|
||||
} from "./domain"
|
||||
import "./dialog.css"
|
||||
import { Divider } from "@opencode-ai/ui/divider"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
interface DirectoryPickerDialogProps {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
server: ServerConnection.Any
|
||||
location?: LocationRef
|
||||
mode?: "directory" | "file"
|
||||
start?: string
|
||||
}
|
||||
@@ -67,32 +70,24 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
let navigation = 0
|
||||
|
||||
const [fallbackPath] = createResource(
|
||||
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
|
||||
() =>
|
||||
sdk.api.location
|
||||
.get()
|
||||
.then(
|
||||
(location): Path => ({
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location.project.directory,
|
||||
directory: location.directory,
|
||||
home: "",
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined),
|
||||
() => (props.location ? undefined : true),
|
||||
() => sdk.api.location.get().catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||
const home = createMemo(() => sync.data.path.home || "")
|
||||
const location = createMemo(() => {
|
||||
const current = props.location ?? fallbackPath()
|
||||
return current ? { directory: current.directory, workspace: current.workspaceID } : undefined
|
||||
})
|
||||
const start = createMemo(
|
||||
() =>
|
||||
props.start ||
|
||||
sync.data.path.home ||
|
||||
props.location?.directory ||
|
||||
sync.data.path.directory ||
|
||||
fallbackPath()?.home ||
|
||||
fallbackPath()?.directory,
|
||||
)
|
||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
@@ -100,12 +95,14 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const base = pickerRoot(cleaned) || root() || start()
|
||||
const base = location()?.directory
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
|
||||
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: { directory: base },
|
||||
query: pickerFileSearchQuery(base, value, home()),
|
||||
location: location(),
|
||||
query,
|
||||
type: "file",
|
||||
limit: 20,
|
||||
})
|
||||
@@ -113,7 +110,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
.catch(() => [])
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
|
||||
]
|
||||
return {
|
||||
query: value,
|
||||
@@ -132,15 +129,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
return sdk.api.file
|
||||
.list({ location: { directory: absolute } })
|
||||
.then((result) =>
|
||||
result.data.map((entry) => ({
|
||||
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||
type: entry.type,
|
||||
})),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
const current = location()
|
||||
if (!current) return Promise.resolve(undefined)
|
||||
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
|
||||
})
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
@@ -282,7 +273,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const path = start()
|
||||
if (!path || root()) return
|
||||
if (!path || !location() || root()) return
|
||||
void navigate(path)
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
currentPickerSuggestions,
|
||||
createDirectorySearch,
|
||||
createPriorityTaskQueue,
|
||||
listPickerDirectory,
|
||||
displayPickerPath,
|
||||
pickerParent,
|
||||
pickerRoot,
|
||||
@@ -130,44 +131,181 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||
expect(pickerFileSearchQuery("/home/luke", "~/repos/op", "/home/luke")).toBe("repos/op")
|
||||
})
|
||||
|
||||
test("resolves directory autocomplete from the current browser root", async () => {
|
||||
const directories: string[] = []
|
||||
test("resolves directory autocomplete from the browser root without changing location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({ data: [] })
|
||||
find: (input: unknown) => {
|
||||
calls.push(input)
|
||||
return Promise.resolve({ location, data: [{ path: "src/components/", type: "directory" }] })
|
||||
},
|
||||
list: () => Promise.resolve({ data: [] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
let base = "/repo"
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base })
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => base, location: () => location })
|
||||
|
||||
await search("components")
|
||||
expect(await search("components")).toEqual(["/repo/src/components"])
|
||||
base = "/repo/src"
|
||||
await search("components")
|
||||
expect(await search("components")).toEqual(["/repo/src/components"])
|
||||
|
||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||
expect(calls).toEqual([
|
||||
{ location, query: "components", type: "directory", limit: 50 },
|
||||
{ location, query: "src/components", type: "directory", limit: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps indexed directory results for servers that support empty search", async () => {
|
||||
test("lists absolute parents and preloads siblings through a stable workspace", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo/current", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: async (input: { path?: string }) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location,
|
||||
data:
|
||||
input.path === "/repo"
|
||||
? [
|
||||
{ path: "./", type: "directory" },
|
||||
{ path: "../sibling/", type: "directory" },
|
||||
]
|
||||
: [{ path: "../sibling/src/", type: "directory" }],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
|
||||
{ name: "current", absolute: "/repo/current", type: "directory" },
|
||||
{ name: "sibling", absolute: "/repo/sibling", type: "directory" },
|
||||
])
|
||||
expect(await listPickerDirectory(sdk, location, "/repo/sibling")).toEqual([
|
||||
{ name: "src", absolute: "/repo/sibling/src", type: "directory" },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location, path: "/repo" },
|
||||
{ location, path: "/repo/sibling" },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses listings for typed searches outside the current location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const location = { directory: "/repo/current", workspace: "workspace_1" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.reject(new Error("outside searches must not change location")),
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location, data: [{ path: "../sibling/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
|
||||
expect(await search("sib")).toEqual(["/repo/sibling"])
|
||||
expect(calls).toEqual([{ location, path: "/repo" }])
|
||||
})
|
||||
|
||||
test("keeps literal tilde directory names in server listing and search results", async () => {
|
||||
const location = { directory: "/repo" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: async () => ({ location, data: [{ path: "~/", type: "directory" }] }),
|
||||
find: async () => ({ location, data: [{ path: "~/nested/", type: "directory" }] }),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
expect(await listPickerDirectory(sdk, location, "/repo")).toEqual([
|
||||
{ name: "~", absolute: "/repo/~", type: "directory" },
|
||||
])
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/user", base: () => "/repo", location: () => location })
|
||||
expect(await search("nested")).toEqual(["/repo/~/nested"])
|
||||
})
|
||||
|
||||
test("discards stale typed results without changing the request location", async () => {
|
||||
const location = { directory: "/repo" }
|
||||
const pending = Promise.withResolvers<{
|
||||
location: typeof location
|
||||
data: Array<{ path: string; type: "directory" }>
|
||||
}>()
|
||||
const calls: unknown[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: async (input: { query: string }) => {
|
||||
calls.push(input)
|
||||
if (input.query === "old") return pending.promise
|
||||
return { location, data: [{ path: "new/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/repo", location: () => location })
|
||||
const stale = search("old")
|
||||
expect(await search("new")).toEqual(["/repo/new"])
|
||||
pending.resolve({ location, data: [{ path: "old/", type: "directory" }] })
|
||||
expect(await stale).toEqual([])
|
||||
expect(calls).toEqual([
|
||||
{ location, query: "old", type: "directory", limit: 50 },
|
||||
{ location, query: "new", type: "directory", limit: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
test("maps server-native drive and share paths without rebasing the location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
list: async (input: { location: { directory: string }; path: string }) => {
|
||||
calls.push(input)
|
||||
return { location: input.location, data: [{ path: "../sibling/", type: "directory" }] }
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof listPickerDirectory>[0]
|
||||
const drive = { directory: "C:\\Repo\\Current", workspace: "workspace_1" }
|
||||
expect(await listPickerDirectory(sdk, drive, "c:/repo")).toEqual([
|
||||
{ name: "sibling", type: "directory", absolute: "C:/Repo/sibling" },
|
||||
])
|
||||
const share = { directory: "\\\\Server\\Share\\Current", workspace: "workspace_2" }
|
||||
expect(await listPickerDirectory(sdk, share, "//server/share")).toEqual([
|
||||
{ name: "sibling", type: "directory", absolute: "//Server/Share/sibling" },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location: drive, path: "c:/repo" },
|
||||
{ location: share, path: "//server/share" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps indexed directory results for servers that support empty search", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ location, data: [{ path: "projects/", type: "directory" }] }),
|
||||
list: () => Promise.reject(new Error("listing should not run when search returns results")),
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
expect(await search("")).toEqual(["/home/luke/projects"])
|
||||
})
|
||||
|
||||
test("lists the default directory when empty search is unsupported", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const calls: string[] = []
|
||||
const directories = Array.from({ length: 60 }, (_, index) => ({
|
||||
path: `project-${index}/`,
|
||||
@@ -180,13 +318,19 @@ test("lists the default directory when empty search is unsupported", async () =>
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
calls.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
location,
|
||||
data: [...directories, { path: "README.md", type: "file" }],
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
const results = await search("")
|
||||
expect(results).toHaveLength(60)
|
||||
@@ -195,12 +339,14 @@ test("lists the default directory when empty search is unsupported", async () =>
|
||||
})
|
||||
|
||||
test("matches the default directory listing when typed search is unsupported", async () => {
|
||||
const location = { directory: "/home/luke" }
|
||||
const sdk = {
|
||||
api: {
|
||||
file: {
|
||||
find: () => Promise.resolve({ data: [] }),
|
||||
list: () =>
|
||||
Promise.resolve({
|
||||
location,
|
||||
data: [
|
||||
{ path: "Documents/", type: "directory" },
|
||||
{ path: "Downloads/", type: "directory" },
|
||||
@@ -209,12 +355,18 @@ test("matches the default directory listing when typed search is unsupported", a
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
|
||||
const search = createDirectorySearch({
|
||||
sdk,
|
||||
home: () => "/home/luke",
|
||||
base: () => "/home/luke",
|
||||
location: () => location,
|
||||
})
|
||||
|
||||
expect(await search("documents")).toEqual(["/home/luke/Documents"])
|
||||
})
|
||||
|
||||
test("searches from an absolute root without a default base", async () => {
|
||||
const location = { directory: "/" }
|
||||
const directories: string[] = []
|
||||
const sdk = {
|
||||
api: {
|
||||
@@ -222,6 +374,7 @@ test("searches from an absolute root without a default base", async () => {
|
||||
list: (input: { location?: { directory?: string } }) => {
|
||||
directories.push(input.location?.directory ?? "")
|
||||
return Promise.resolve({
|
||||
location,
|
||||
data: [
|
||||
{ path: "Users/", type: "directory" },
|
||||
{ path: "tmp/", type: "directory" },
|
||||
@@ -231,7 +384,7 @@ test("searches from an absolute root without a default base", async () => {
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined, location: () => location })
|
||||
|
||||
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||
expect(directories).toEqual(["/"])
|
||||
|
||||
@@ -76,6 +76,11 @@ export function pickerFileSearchQuery(root: string, input: string, home: string)
|
||||
|
||||
export function pickerAbsoluteInput(input: string, home: string, current: string) {
|
||||
const value = normalizePickerDrive(input).replace(/^~(?=\/|$)/, normalizePickerDrive(home))
|
||||
return pickerAbsolutePath(value, current)
|
||||
}
|
||||
|
||||
export function pickerAbsolutePath(input: string, current: string) {
|
||||
const value = normalizePickerDrive(input)
|
||||
const absolute = pickerRoot(value) ? value : joinPickerPath(current, value)
|
||||
return canonicalPickerPath(absolute)
|
||||
}
|
||||
@@ -321,7 +326,24 @@ export function displayPickerPath(path: string, input: string, home: string) {
|
||||
return pickerTilde(value, home) || value
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string | undefined; home: () => string }) {
|
||||
export async function listPickerDirectory(
|
||||
sdk: ServerSDK,
|
||||
location: { directory: string; workspace?: string },
|
||||
directory: string,
|
||||
) {
|
||||
const result = await sdk.api.file.list({ location, path: directory })
|
||||
return result.data.map((entry) => {
|
||||
const absolute = pickerAbsolutePath(entry.path, result.location.directory)
|
||||
return { name: getFilename(absolute), type: entry.type, absolute }
|
||||
})
|
||||
}
|
||||
|
||||
export function createDirectorySearch(args: {
|
||||
sdk: ServerSDK
|
||||
location: () => { directory: string; workspace?: string } | undefined
|
||||
base: () => string | undefined
|
||||
home: () => string
|
||||
}) {
|
||||
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
|
||||
let current = 0
|
||||
|
||||
@@ -339,21 +361,14 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
}
|
||||
|
||||
const directories = async (directory: string) => {
|
||||
const key = trimPickerPath(directory)
|
||||
const location = args.location()
|
||||
if (!location) return []
|
||||
const key = JSON.stringify([location, trimPickerPath(directory)])
|
||||
const existing = cache.get(key)
|
||||
if (existing) return existing
|
||||
const request = args.sdk.api.file
|
||||
.list({ location: { directory: key } })
|
||||
.then((result) => result.data)
|
||||
const request = listPickerDirectory(args.sdk, location, directory)
|
||||
.catch(() => [])
|
||||
.then((nodes) =>
|
||||
nodes
|
||||
.filter((node) => node.type === "directory")
|
||||
.map((node) => {
|
||||
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||
}),
|
||||
)
|
||||
.then((nodes) => nodes.filter((node) => node.type === "directory"))
|
||||
cache.set(key, request)
|
||||
return request
|
||||
}
|
||||
@@ -369,18 +384,27 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||
const active = () => token === current
|
||||
const value = cleanPickerInput(filter)
|
||||
const input = scoped(value)
|
||||
if (!input) return [] as string[]
|
||||
const location = args.location()
|
||||
if (!input || !location) return [] as string[]
|
||||
const raw = normalizePickerDrive(value)
|
||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||
const query = normalizePickerDrive(input.path)
|
||||
if (!pathInput) {
|
||||
const results = await args.sdk.api.file
|
||||
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||
.then((result) => result.data.map((entry) => entry.path))
|
||||
.catch(() => [])
|
||||
const relative = pickerRelativePath(location.directory, input.directory)
|
||||
const results =
|
||||
relative === undefined
|
||||
? []
|
||||
: await args.sdk.api.file
|
||||
.find({ location, query: joinPickerPath(relative, query), type: "directory", limit: 50 })
|
||||
.then((result) =>
|
||||
result.data
|
||||
.map((entry) => pickerAbsolutePath(entry.path, result.location.directory))
|
||||
.filter((path) => treePathWithin(input.directory, path)),
|
||||
)
|
||||
.catch(() => [])
|
||||
if (!active()) return []
|
||||
if (results.length) {
|
||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||
return results.slice(0, 50)
|
||||
}
|
||||
const fallback = query
|
||||
? await match(input.directory, query, 50)
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("directoryPickerKind", () => {
|
||||
test("uses the native picker only for local desktop projects", () => {
|
||||
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||
expect(directoryPickerKind("desktop", { ...local, variant: "wsl", distro: "Ubuntu" })).toBe("server")
|
||||
expect(directoryPickerKind("web", local)).toBe("server")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { lazy } from "solid-js"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import { directoryPickerKind } from "./policy"
|
||||
|
||||
const DirectoryPickerDialog = lazy(() =>
|
||||
@@ -10,6 +11,7 @@ const DirectoryPickerDialog = lazy(() =>
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
location?: LocationRef
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
|
||||
@@ -1559,7 +1559,7 @@ export interface PermissionApi<E = never> {
|
||||
|
||||
export type FileListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly path?: RelativePath | undefined
|
||||
readonly path?: string | undefined
|
||||
}
|
||||
export type FileListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileSystem.Entry> }
|
||||
export type FileListOperation<E = never> = (input?: FileListInput) => Effect.Effect<FileListOutput, E>
|
||||
|
||||
@@ -119,6 +119,28 @@ test("multiple consumers share one source and receive live native and RPC events
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("a slow consumer does not stall other subscribers", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
const slow = shared.subscribe()[Symbol.asyncIterator]()
|
||||
const fast = shared.subscribe()[Symbol.asyncIterator]()
|
||||
|
||||
const connected = [slow.next(), fast.next()]
|
||||
events.connections[0].push({ type: "server.connected" })
|
||||
await Promise.all(connected)
|
||||
|
||||
events.connections[0].push({ type: "permission.asked", value: 1 })
|
||||
expect(await fast.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
|
||||
events.connections[0].push({ type: "session.updated", value: 2 })
|
||||
expect(await fast.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
|
||||
|
||||
expect(await slow.next()).toEqual({ done: false, value: { type: "permission.asked", value: 1 } })
|
||||
expect(await slow.next()).toEqual({ done: false, value: { type: "session.updated", value: 2 } })
|
||||
await slow.return!()
|
||||
await fast.return!()
|
||||
await events.connections[0].closed
|
||||
})
|
||||
|
||||
test("an idle consumer does not stall events for an active consumer", async () => {
|
||||
const events = source()
|
||||
const shared = SharedEvents.make(events.connect)
|
||||
|
||||
+44
-120
@@ -4,7 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||
import {
|
||||
AgentsDirectory,
|
||||
ClaudeDirectory,
|
||||
@@ -23,6 +23,8 @@ import { Location } from "./location.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { ConfigVariable } from "./config/variable.js"
|
||||
import { ConfigNormalize } from "./config/normalize.js"
|
||||
import { ConfigDiscovery } from "./config/discovery.js"
|
||||
import { ConfigWatch } from "./config/watch.js"
|
||||
import { WellKnown } from "./wellknown.js"
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
@@ -83,15 +85,12 @@ export const layer = (options?: Options) =>
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const fileTargets = new Set<AbsolutePath>()
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
@@ -177,90 +176,21 @@ export const layer = (options?: Options) =>
|
||||
|
||||
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
|
||||
return [
|
||||
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||
...(yield* Effect.forEach(ConfigDiscovery.names, (file) => loadFile(path.join(directory, file))).pipe(
|
||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||
)),
|
||||
new Directory({ type: "directory", path: directory }),
|
||||
]
|
||||
})
|
||||
|
||||
const discover = Effect.fn("Config.discover")(function* () {
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
|
||||
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
|
||||
// Global roots and the walk are compared by canonical path: the same
|
||||
// directory reached under two spellings (a symlinked checkout, macOS
|
||||
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
|
||||
// classify identically or it enters discovery twice.
|
||||
const globalRoots = yield* Effect.forEach(
|
||||
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
|
||||
(item) => fs.resolve(item),
|
||||
)
|
||||
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
|
||||
const discovered =
|
||||
locationIsGlobal || options?.project === false
|
||||
? []
|
||||
: yield* fs
|
||||
.up({
|
||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((items) =>
|
||||
Effect.forEach(items, (item) =>
|
||||
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
|
||||
const globalEnabled = options?.global !== false
|
||||
// A walked path that resolves into a global root is global config
|
||||
// however the walk reached it (home above the project, or a location
|
||||
// beneath the global config dir), so global: false excludes it
|
||||
// uniformly — classified once here, not per consumer below. With
|
||||
// global enabled, the roots themselves and the global config files are
|
||||
// already loaded below, so the walk must not add them a second time.
|
||||
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
|
||||
const visible = discovered
|
||||
.filter(({ resolved }) =>
|
||||
globalEnabled
|
||||
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
|
||||
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
|
||||
)
|
||||
.map(({ item }) => item)
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
|
||||
const agents = [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
|
||||
|
||||
const projectDirectories = visible
|
||||
.filter((item) => path.basename(item) === ".opencode")
|
||||
.toReversed()
|
||||
.map((directory) => AbsolutePath.make(directory))
|
||||
const directPaths = visible
|
||||
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
||||
.toReversed()
|
||||
fileTargets.clear()
|
||||
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
|
||||
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
|
||||
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
|
||||
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
|
||||
)
|
||||
|
||||
const file = options?.file
|
||||
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
|
||||
const explicit = file
|
||||
? yield* loadFile(path.resolve(file)).pipe(
|
||||
const explicit = sources.explicit
|
||||
? yield* loadFile(sources.explicit).pipe(
|
||||
Effect.map((config) => (config ? [config] : [])),
|
||||
Effect.orDie,
|
||||
)
|
||||
@@ -281,15 +211,18 @@ export const layer = (options?: Options) =>
|
||||
|
||||
// Global entries sit below explicit and direct files; project
|
||||
// directories rank above them.
|
||||
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
|
||||
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
|
||||
const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : []
|
||||
const projectSupplementary = yield* Effect.forEach(
|
||||
sources.project.filter((root) => root.present),
|
||||
(root) => loadDirectory(root.path),
|
||||
).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((entries) => entries.flat()),
|
||||
)
|
||||
return [
|
||||
...(yield* loadWellknown().pipe(Effect.orDie)),
|
||||
...claude,
|
||||
...agents,
|
||||
...sources.claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
|
||||
...sources.agents.map((path) => new AgentsDirectory({ type: "agents", path })),
|
||||
...globalSupplementary,
|
||||
...explicit,
|
||||
...direct,
|
||||
@@ -298,44 +231,35 @@ export const layer = (options?: Options) =>
|
||||
]
|
||||
})
|
||||
|
||||
const initial = yield* discover()
|
||||
let configs = initial
|
||||
const initial = yield* ConfigDiscovery.discover(options)
|
||||
let configs = yield* load(initial)
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
// Vendored trees inside config roots (a plugin's node_modules, a nested
|
||||
// .git) produce event blizzards that can never change discovery output.
|
||||
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
|
||||
// Watch-once: roots leave discovery only by deletion, so a stale watch is
|
||||
// inert, bounded, and dies with this layer — and keeping a deleted root's
|
||||
// watch alive is exactly what makes its recreation observable.
|
||||
const watched = new Set<string>()
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const files = [
|
||||
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
|
||||
...fileTargets,
|
||||
]
|
||||
const targets = [
|
||||
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
|
||||
...files
|
||||
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
|
||||
.map((path) => ({ path, type: "file" as const })),
|
||||
]
|
||||
for (const target of targets) {
|
||||
const key = JSON.stringify(target)
|
||||
if (watched.has(key)) continue
|
||||
watched.add(key)
|
||||
const stream = yield* watcher.subscribe(target)
|
||||
yield* stream.pipe(
|
||||
Stream.runForEach((update) => PubSub.publish(updates, update)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const reloads = yield* PubSub.sliding<void>(1)
|
||||
// Readiness rescans recover writes made before a watch attached.
|
||||
const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid)
|
||||
const watched = yield* FiberMap.make<string>()
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) {
|
||||
const plan = ConfigWatch.plan(sources)
|
||||
for (const key of Array.from(watched, ([key]) => key)) {
|
||||
if (!plan.has(key)) yield* FiberMap.remove(watched, key)
|
||||
}
|
||||
for (const [key, target] of plan) {
|
||||
yield* watcher
|
||||
.subscribe(target, requestReload)
|
||||
.pipe(
|
||||
Effect.flatMap(
|
||||
Stream.runForEach((update) => PubSub.publish(updates, update).pipe(Effect.andThen(requestReload))),
|
||||
),
|
||||
FiberMap.run(watched, key, { onlyIfMissing: true, startImmediately: true }),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const reload = Effect.fn("Config.reload")(
|
||||
function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
const sources = yield* ConfigDiscovery.discover(options)
|
||||
const next = yield* load(sources)
|
||||
yield* reconcile(sources)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
@@ -343,12 +267,12 @@ export const layer = (options?: Options) =>
|
||||
(effect) => reloadLock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(updates).pipe(
|
||||
// Subscribe eagerly so synchronous watch readiness isn't dropped.
|
||||
const pendingReloads = yield* PubSub.subscribe(reloads)
|
||||
yield* Stream.fromSubscription(pendingReloads).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach((update) =>
|
||||
reload().pipe(
|
||||
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
|
||||
),
|
||||
Stream.runForEach(() =>
|
||||
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
@@ -389,7 +313,7 @@ export const layer = (options?: Options) =>
|
||||
Effect.forever,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* reconcile(initial)
|
||||
yield* reloadLock.withPermit(reconcile(initial))
|
||||
|
||||
return Service.of({
|
||||
entries: Effect.fnUntraced(function* () {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
export * as ConfigDiscovery from "./discovery.js"
|
||||
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import type { Options } from "../config.js"
|
||||
|
||||
export const names = ["opencode.json", "opencode.jsonc"]
|
||||
|
||||
/** Eligible sources in priority order, including paths that may appear later. */
|
||||
export interface Sources {
|
||||
readonly global?: AbsolutePath
|
||||
readonly explicit?: AbsolutePath
|
||||
readonly direct: readonly AbsolutePath[]
|
||||
readonly project: readonly { readonly path: AbsolutePath; readonly present: boolean }[]
|
||||
readonly claude: readonly AbsolutePath[]
|
||||
readonly agents: readonly AbsolutePath[]
|
||||
}
|
||||
|
||||
export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options?: Options) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
|
||||
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
|
||||
const globalRoots = yield* Effect.forEach([globalDirectory, globalClaudeDirectory, globalAgentsDirectory], (item) =>
|
||||
fs.resolve(item),
|
||||
)
|
||||
const directories =
|
||||
(yield* fs.resolve(location.directory)) === globalRoots[0] || options?.project === false
|
||||
? []
|
||||
: yield* fs.up({ targets: ["."], start: location.directory }).pipe(Effect.orDie)
|
||||
const discovered = yield* Effect.forEach(directories, (directory) =>
|
||||
Effect.gen(function* () {
|
||||
// Resolve the parent too: missing children must honor symlinked global roots.
|
||||
const parent = yield* fs.resolve(directory)
|
||||
const ecosystem = yield* Effect.filter([".claude", ".agents"], (name) => fs.exists(path.join(directory, name)))
|
||||
return yield* Effect.forEach([...ecosystem, ".opencode", ...names.toReversed()], (name) =>
|
||||
fs
|
||||
.resolve(path.join(parent, name))
|
||||
.pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Effect.map((items) => items.flat()),
|
||||
Effect.orDie,
|
||||
)
|
||||
|
||||
const globalEnabled = options?.global !== false
|
||||
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
|
||||
// Global sources must not re-enter through the project walk.
|
||||
const visible = discovered
|
||||
.filter(({ resolved }) =>
|
||||
globalEnabled
|
||||
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
|
||||
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
|
||||
)
|
||||
.map(({ item }) => item)
|
||||
|
||||
return {
|
||||
global: globalEnabled ? globalDirectory : undefined,
|
||||
explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined,
|
||||
direct: visible.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))).toReversed(),
|
||||
project: yield* Effect.forEach(
|
||||
visible.filter((item) => path.basename(item) === ".opencode").toReversed(),
|
||||
(directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))),
|
||||
),
|
||||
claude: [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
|
||||
]),
|
||||
],
|
||||
agents: [
|
||||
...new Set([
|
||||
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
|
||||
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
|
||||
]),
|
||||
],
|
||||
} satisfies Sources
|
||||
})
|
||||
@@ -33,7 +33,7 @@ export const Plugin = define({
|
||||
const changes = yield* PubSub.sliding<string>(1)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
|
||||
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") {
|
||||
const target = path.resolve(directory)
|
||||
const updates = yield* watcher.subscribe({ path: target, type })
|
||||
yield* FiberMap.run(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export * as ConfigWatch from "./watch.js"
|
||||
|
||||
import path from "path"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import type { Watcher } from "../filesystem/watcher.js"
|
||||
import type { ConfigDiscovery } from "./discovery.js"
|
||||
|
||||
export function plan(sources: ConfigDiscovery.Sources) {
|
||||
const directories = [
|
||||
...(sources.global ? [sources.global] : []),
|
||||
...sources.project.filter((root) => root.present).map((root) => root.path),
|
||||
]
|
||||
const files = [
|
||||
...sources.direct,
|
||||
...sources.project.map((root) => root.path),
|
||||
...(sources.explicit ? [sources.explicit] : []),
|
||||
]
|
||||
// Keep a parent watch for each root so deletion/recreation is observable.
|
||||
const parents = Map.groupBy(
|
||||
files.filter((file) => !directories.some((directory) => file !== directory && FSUtil.contains(directory, file))),
|
||||
(file) => path.dirname(file),
|
||||
)
|
||||
return new Map(
|
||||
[
|
||||
...directories.map((path) => ({
|
||||
path,
|
||||
type: "directory" as const,
|
||||
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
|
||||
})),
|
||||
...Array.from(parents, ([parent, files]) => ({
|
||||
path: parent,
|
||||
type: "entries" as const,
|
||||
names: [...new Set(files.map((file) => path.basename(file)))].toSorted(),
|
||||
})),
|
||||
].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]),
|
||||
)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const Content = Schema.Struct({
|
||||
export type Content = typeof Content.Type
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
@@ -93,17 +93,18 @@ const baseLayer = Layer.effect(
|
||||
}
|
||||
}),
|
||||
list: Effect.fn("FileSystem.list")(function* (input = {}) {
|
||||
const target = yield* resolve(input.path)
|
||||
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
|
||||
// Navigation can leave the cwd without activating another Location.
|
||||
const directory = path.resolve(location.directory, input.path ?? ".")
|
||||
const info = yield* fs.stat(directory).pipe(Effect.orDie)
|
||||
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
|
||||
return yield* fs.readDirectoryEntries(target.real).pipe(
|
||||
return yield* fs.readDirectoryEntries(directory).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.flatMap((item) => {
|
||||
if (item.type !== "file" && item.type !== "directory") return []
|
||||
const absolute = path.join(target.absolute, item.name)
|
||||
const relative = path.relative(target.directory, absolute)
|
||||
const absolute = path.join(directory, item.name)
|
||||
const relative = path.relative(location.directory, absolute) || "."
|
||||
return [
|
||||
Entry.make({
|
||||
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
|
||||
|
||||
@@ -34,6 +34,7 @@ export type Update = ParcelWatcher.Event
|
||||
|
||||
export type WatchInput =
|
||||
| { readonly path: string; readonly type: "file" }
|
||||
| { readonly path: string; readonly type: "entries"; readonly names: readonly string[] }
|
||||
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
|
||||
|
||||
export type Subscription = {
|
||||
@@ -42,25 +43,26 @@ export type Subscription = {
|
||||
readonly backend?: string
|
||||
}
|
||||
|
||||
type Target = {
|
||||
readonly target: string
|
||||
readonly ignore: readonly string[]
|
||||
} & (
|
||||
| { readonly type: "entries"; readonly names: readonly string[] }
|
||||
| { readonly type: "file" | "directory"; readonly names?: readonly string[] }
|
||||
)
|
||||
|
||||
export interface NativeInterface {
|
||||
/** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */
|
||||
readonly subscribe: (input: {
|
||||
readonly type: WatchInput["type"]
|
||||
readonly target: string
|
||||
readonly ignore: readonly string[]
|
||||
readonly publish: (update: Update) => void
|
||||
}) => Effect.Effect<Subscription | undefined>
|
||||
readonly subscribe: (
|
||||
input: Target & { readonly publish: (update: Update) => void },
|
||||
) => Effect.Effect<Subscription | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* The OS-level watch implementation behind the Watcher service. The default
|
||||
* layer uses `node:fs.watch` for files and `@parcel/watcher` for directories;
|
||||
* tests provide implementations they can control.
|
||||
*/
|
||||
/** Uses fs.watch for immediate entries and Parcel for recursive directories. */
|
||||
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
|
||||
|
||||
export interface Interface {
|
||||
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
|
||||
/** onReady runs after native acquisition and listener registration, when the stream is consumed. */
|
||||
readonly subscribe: (input: WatchInput, onReady?: Effect.Effect<void>) => Effect.Effect<Stream.Stream<Update>>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
@@ -89,16 +91,13 @@ export const layer = (options?: Options) =>
|
||||
const native = yield* Native
|
||||
|
||||
// Keys compare structurally (effect Equal), so equivalent watches share one entry.
|
||||
type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] }
|
||||
const watchers = yield* RcMap.make({
|
||||
lookup: (key: Key) =>
|
||||
lookup: (key: Target) =>
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* Effect.acquireRelease(PubSub.unbounded<Update>(), (pubsub) => PubSub.shutdown(pubsub))
|
||||
const subscription = yield* Effect.acquireRelease(
|
||||
native.subscribe({
|
||||
type: key.type,
|
||||
target: key.target,
|
||||
ignore: key.ignore,
|
||||
...key,
|
||||
publish: (update) => PubSub.publishUnsafe(pubsub, update),
|
||||
}),
|
||||
(subscription) =>
|
||||
@@ -127,34 +126,31 @@ export const layer = (options?: Options) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const subscribe = (input: WatchInput) => {
|
||||
const subscribe = Effect.fnUntraced(function* (input: WatchInput, onReady: Effect.Effect<void> = Effect.void) {
|
||||
const target = path.resolve(input.path)
|
||||
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.logInfo("watcher subscribe", {
|
||||
path: target,
|
||||
type: input.type,
|
||||
ignores: ignore.length,
|
||||
})
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
|
||||
return Stream.fromPubSub(pubsub)
|
||||
}),
|
||||
)
|
||||
const names = [...new Set(input.type === "entries" ? input.names : [])].toSorted()
|
||||
yield* Effect.logInfo("watcher subscribe", {
|
||||
path: target,
|
||||
type: input.type,
|
||||
ignores: ignore.length,
|
||||
})
|
||||
}
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore, names })
|
||||
const subscription = yield* PubSub.subscribe(pubsub)
|
||||
if (yield* PubSub.isShutdown(pubsub)) return Stream.empty
|
||||
yield* onReady
|
||||
return Stream.fromSubscription(subscription)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ subscribe })
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* Watcher for tests: the real lifecycle over an in-memory Native that records
|
||||
* acquired watches and routes emitted updates the way the OS watches would: a
|
||||
* file watch receives updates for its own path, a directory watch receives
|
||||
* updates for paths inside it that no ignore entry covers.
|
||||
*/
|
||||
/** Real subscription lifecycle with in-memory, path-filtered event delivery. */
|
||||
export const testLayer = Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
const subscriptions: WatchInput[] = []
|
||||
@@ -165,21 +161,22 @@ export const testLayer = Layer.effectContext(
|
||||
subscriptions.push(
|
||||
input.type === "file"
|
||||
? { path: input.target, type: "file" }
|
||||
: input.ignore.length > 0
|
||||
? { path: input.target, type: "directory", ignore: input.ignore }
|
||||
: { path: input.target, type: "directory" },
|
||||
: input.type === "entries"
|
||||
? { path: input.target, type: "entries", names: input.names }
|
||||
: input.ignore.length > 0
|
||||
? { path: input.target, type: "directory", ignore: input.ignore }
|
||||
: { path: input.target, type: "directory" },
|
||||
)
|
||||
// Ignore entries resolve against the target like the parcel wrapper's
|
||||
// literal paths. Glob entries resolve to paths nothing lives under, so
|
||||
// they are inert here rather than compiled the way parcel compiles them.
|
||||
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
|
||||
active.set(
|
||||
input.publish,
|
||||
input.type === "file"
|
||||
? (target) => target === input.target
|
||||
: (target) =>
|
||||
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
|
||||
)
|
||||
active.set(input.publish, (target) => {
|
||||
if (input.type === "file") return target === input.target
|
||||
if (input.type === "entries")
|
||||
return path.dirname(target) === input.target && input.names.includes(path.basename(target))
|
||||
return FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target))
|
||||
})
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
active.delete(input.publish)
|
||||
@@ -208,18 +205,19 @@ export const nativeLayer = Layer.succeed(
|
||||
Native,
|
||||
Native.of({
|
||||
subscribe: (input) => {
|
||||
if (input.type === "file") {
|
||||
if (input.type === "file" || input.type === "entries") {
|
||||
return Effect.sync(() => {
|
||||
const directory = path.dirname(input.target)
|
||||
const directory = input.type === "file" ? path.dirname(input.target) : input.target
|
||||
const names = new Set(input.type === "file" ? [path.basename(input.target)] : input.names)
|
||||
const subscription = watch(directory, { recursive: false }, (_event, file) => {
|
||||
if (file && path.resolve(directory, file.toString()) !== input.target) return
|
||||
input.publish({ path: input.target, type: "update" } satisfies Update)
|
||||
if (file && !names.has(file)) return
|
||||
for (const name of file ? [file] : names) {
|
||||
input.publish({ path: path.join(directory, name), type: "update" })
|
||||
}
|
||||
})
|
||||
if ("on" in subscription && typeof subscription.on === "function") {
|
||||
subscription.on("error", (error: unknown) =>
|
||||
Effect.runFork(Effect.logError("watcher callback failed", { path: input.target, error })),
|
||||
)
|
||||
}
|
||||
subscription.on("error", (error: unknown) =>
|
||||
Effect.runFork(Effect.logError("watcher callback failed", { path: directory, error })),
|
||||
)
|
||||
return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,7 +165,16 @@ describe("Config", () => {
|
||||
const entries = yield* config.entries()
|
||||
expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global])
|
||||
expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"])
|
||||
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
|
||||
expect(
|
||||
(yield* watcher.subscriptions())
|
||||
.filter((subscription) => subscription.type === "directory")
|
||||
.map((subscription) => subscription.path),
|
||||
).toEqual([global])
|
||||
expect(
|
||||
(yield* watcher.subscriptions()).filter((subscription) =>
|
||||
subscription.path.includes(`${path.sep}.opencode${path.sep}`),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
@@ -230,6 +239,8 @@ describe("Config", () => {
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
|
||||
const watcher = yield* Watcher.Test
|
||||
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
|
||||
@@ -243,17 +254,19 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
it.live("reloads file substitutions when their source changes", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const file = path.join(global, "opencode.json")
|
||||
const source = path.join(global, "shell.txt")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
|
||||
await fs.writeFile(source, "first")
|
||||
await fs.writeFile(file, JSON.stringify({ shell: "{file:shell.txt}" }))
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -264,9 +277,8 @@ describe("Config", () => {
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
|
||||
yield* watcher.emit({ type: "update", path: file })
|
||||
yield* Effect.promise(() => fs.writeFile(source, "second"))
|
||||
yield* watcher.emit({ type: "update", path: source })
|
||||
|
||||
expect(yield* Fiber.join(changed)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
|
||||
@@ -276,6 +288,35 @@ describe("Config", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("excludes missing files under symlinked global roots when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const link = path.join(tmp.path, "link")
|
||||
const project = path.join(link, "plugins", "demo")
|
||||
return Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(global, "plugins", "demo"), { recursive: true })
|
||||
await fs.symlink(global, link, process.platform === "win32" ? "junction" : undefined)
|
||||
}).pipe(
|
||||
Effect.andThen(
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Test
|
||||
const subscriptions = yield* watcher.subscriptions()
|
||||
expect(subscriptions.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
subscriptions.filter((item) => inFixture(global, item.path) || inFixture(link, item.path)),
|
||||
).toEqual([])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("exposes filesystem updates under config roots through changes", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
@@ -892,6 +933,7 @@ describe("Config", () => {
|
||||
path: AbsolutePath.make(path.join(tmp.path, "global")),
|
||||
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
|
||||
},
|
||||
{ type: "entries", path: tmp.path, names: [".opencode", "opencode.json", "opencode.jsonc"] },
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
|
||||
}),
|
||||
@@ -1447,8 +1489,9 @@ describe("Config", () => {
|
||||
|
||||
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
|
||||
expect(yield* watcher.subscriptions()).toContainEqual({
|
||||
path: path.join(tmp.path, "opencode.jsonc"),
|
||||
type: "file",
|
||||
path: tmp.path,
|
||||
type: "entries",
|
||||
names: [".opencode", "opencode.json", "opencode.jsonc"],
|
||||
})
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { writeFileSync } from "node:fs"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
@@ -20,12 +22,19 @@ import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
|
||||
@@ -34,6 +43,144 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
it.live("retains readiness signalled synchronously during initial config startup", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => {
|
||||
if (input.type === "entries" && input.target === tmp.path) {
|
||||
// No event is emitted: only synchronous readiness can trigger the reload.
|
||||
writeFileSync(path.join(tmp.path, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } }))
|
||||
}
|
||||
return { unsubscribe: () => Promise.resolve() }
|
||||
}),
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
|
||||
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "docs")))
|
||||
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads the first config written while a new directory watch is starting", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const root = path.join(tmp.path, ".opencode")
|
||||
const parent = yield* Deferred.make<(update: Watcher.Update) => void>()
|
||||
const starting = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.type === "entries" && input.target === tmp.path) {
|
||||
yield* Deferred.succeed(parent, input.publish)
|
||||
}
|
||||
if (input.type === "directory" && input.target === root) {
|
||||
yield* Deferred.succeed(starting, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return { unsubscribe: () => Promise.resolve() }
|
||||
}),
|
||||
})
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
const publish = yield* Deferred.await(parent)
|
||||
yield* Effect.promise(() => fs.mkdir(root))
|
||||
publish({ path: root, type: "create" })
|
||||
yield* Deferred.await(starting).pipe(Effect.timeout("2 seconds"))
|
||||
// No file event: the recursive native watch has not been acquired yet.
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } })),
|
||||
)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
|
||||
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(root, "docs")))
|
||||
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for (const input of [
|
||||
{ file: "opencode.json", empty: false },
|
||||
{ file: "../opencode.jsonc", empty: false },
|
||||
{ file: ".opencode/opencode.json", empty: false },
|
||||
{ file: "../.opencode/opencode.jsonc", empty: true },
|
||||
]) {
|
||||
it.live(`loads references when ${input.file} is first created and keeps watching it`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const target = path.resolve(project, input.file)
|
||||
yield* Effect.promise(() => fs.mkdir(project))
|
||||
return yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
expect(yield* references.list()).toEqual([])
|
||||
|
||||
if (input.empty) {
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(target)))
|
||||
const config = yield* Config.Service
|
||||
yield* waitUntil(
|
||||
config
|
||||
.entries()
|
||||
.pipe(
|
||||
Effect.map((entries) =>
|
||||
entries.some((entry) => entry.type === "directory" && entry.path === path.dirname(target)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
|
||||
})
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))),
|
||||
)
|
||||
expect((yield* references.list())[0]?.path).toBe(
|
||||
AbsolutePath.make(path.join(path.dirname(target), "docs")),
|
||||
)
|
||||
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
|
||||
)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.rm(input.file.includes(".opencode/") ? path.dirname(target) : target, { recursive: true }),
|
||||
)
|
||||
yield* waitUntil(references.list().pipe(Effect.map((items) => items.length === 0)))
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true })
|
||||
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
|
||||
})
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "docs")),
|
||||
)
|
||||
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
|
||||
yield* waitUntil(
|
||||
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
|
||||
)
|
||||
}).pipe(Effect.provide(liveConfig(project)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("preserves reference precedence and insertion order across documents", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -123,6 +270,23 @@ describe("config plugin reloads", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function liveConfig(directory: string, native?: Watcher.NativeInterface) {
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, Reference.node, Global.node, Location.node]), [
|
||||
Config.node.replace(Config.configured({ global: false })),
|
||||
Location.node.replace(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
||||
),
|
||||
Global.node.replace(
|
||||
Global.layerWith({ config: path.join(directory, "global"), home: path.join(directory, "home") }),
|
||||
),
|
||||
Credential.node.replace(emptyCredentialNode),
|
||||
WellKnown.node.replace(emptyWellknownNode),
|
||||
...(native
|
||||
? [Watcher.node.replace(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))]
|
||||
: []),
|
||||
])
|
||||
}
|
||||
|
||||
function config(name: string) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ConfigDiscovery } from "@opencode-ai/core/config/discovery"
|
||||
import { ConfigWatch } from "@opencode-ai/core/config/watch"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
|
||||
const project = path.resolve("watch-plan-project")
|
||||
const root = AbsolutePath.make(path.join(project, ".opencode"))
|
||||
const sources: ConfigDiscovery.Sources = {
|
||||
direct: ["opencode.json", "opencode.jsonc"].map((name) => AbsolutePath.make(path.join(project, name))),
|
||||
project: [{ path: root, present: false }],
|
||||
claude: [AbsolutePath.make(path.join(project, ".claude"))],
|
||||
agents: [AbsolutePath.make(path.join(project, ".agents"))],
|
||||
}
|
||||
|
||||
describe("ConfigWatch.plan", () => {
|
||||
test("groups missing candidates and keeps parent watches when roots appear", () => {
|
||||
const missing = ConfigWatch.plan(sources)
|
||||
expect(Array.from(missing.values())).toEqual([
|
||||
{ path: project, type: "entries", names: [".opencode", "opencode.json", "opencode.jsonc"] },
|
||||
])
|
||||
const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
|
||||
expect(Array.from(present.values())).toEqual([
|
||||
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
|
||||
...missing.values(),
|
||||
])
|
||||
})
|
||||
|
||||
test("adds exact watches for explicit files only when not already covered", () => {
|
||||
expect(ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(ConfigWatch.plan(sources))
|
||||
const present = { ...sources, project: [{ path: root, present: true }] }
|
||||
expect(ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) })).toEqual(
|
||||
ConfigWatch.plan(present),
|
||||
)
|
||||
const directory = path.resolve("watch-plan-external")
|
||||
expect(
|
||||
Array.from(
|
||||
ConfigWatch.plan({ ...sources, explicit: AbsolutePath.make(path.join(directory, "custom.json")) }).values(),
|
||||
),
|
||||
).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] })
|
||||
})
|
||||
})
|
||||
@@ -53,18 +53,121 @@ function countingNative() {
|
||||
}
|
||||
|
||||
describe("Watcher lifecycle", () => {
|
||||
it.effect("signals readiness after acquisition and buffers updates published by the ready callback", () =>
|
||||
Effect.gen(function* () {
|
||||
const publish = yield* Deferred.make<(update: Watcher.Update) => void>()
|
||||
const acquired = yield* Deferred.make<void>()
|
||||
const counts = { ready: 0, closed: 0 }
|
||||
yield* Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consumer = yield* watcher
|
||||
.subscribe(
|
||||
{ path: "/shared", type: "entries", names: ["opencode.json"] },
|
||||
Effect.gen(function* () {
|
||||
counts.ready++
|
||||
const notify = yield* Deferred.await(publish)
|
||||
notify({ path: "/shared/opencode.json", type: "create" })
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.flatMap(Stream.runHead), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(publish)
|
||||
expect(counts.ready).toBe(0)
|
||||
yield* Deferred.succeed(acquired, undefined)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(consumer))).toEqual({
|
||||
path: "/shared/opencode.json",
|
||||
type: "create",
|
||||
})
|
||||
expect(counts).toEqual({ ready: 1, closed: 1 })
|
||||
}).pipe(
|
||||
withNative({
|
||||
subscribe: (input) =>
|
||||
Deferred.succeed(publish, input.publish).pipe(
|
||||
Effect.andThen(Deferred.await(acquired)),
|
||||
Effect.as({
|
||||
unsubscribe: async () => {
|
||||
counts.closed++
|
||||
},
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not signal readiness for an unavailable native watch", () => {
|
||||
const counts = { ready: 0 }
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const stream = yield* watcher.subscribe(
|
||||
{ path: "/unavailable", type: "directory" },
|
||||
Effect.sync(() => {
|
||||
counts.ready++
|
||||
}),
|
||||
)
|
||||
yield* Stream.runDrain(stream)
|
||||
expect(counts.ready).toBe(0)
|
||||
}).pipe(withNative({ subscribe: () => Effect.undefined }))
|
||||
})
|
||||
|
||||
it.live("watches only named immediate entries, including missing directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* Watcher.Native
|
||||
const events: Watcher.Update[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
native.subscribe({
|
||||
type: "entries",
|
||||
target: tmp.path,
|
||||
names: ["opencode.json", ".opencode"],
|
||||
ignore: [],
|
||||
publish: (update) => events.push(update),
|
||||
}),
|
||||
(subscription) => Effect.promise(() => subscription?.unsubscribe() ?? Promise.resolve()),
|
||||
)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "nested"))
|
||||
await fs.writeFile(path.join(tmp.path, "nested", "opencode.json"), "ignored")
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "ignored")
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.json"), "first")
|
||||
})
|
||||
yield* Effect.sync(() => events.length).pipe(
|
||||
Effect.filterOrFail((count) => count > 0),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
expect(events.every((update) => update.path === path.join(tmp.path, "opencode.json"))).toBe(true)
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
|
||||
yield* Effect.sync(() => events.some((update) => update.path === path.join(tmp.path, ".opencode"))).pipe(
|
||||
Effect.filterOrFail(Boolean),
|
||||
Effect.retry(Schedule.spaced("10 millis")),
|
||||
Effect.timeout("1 second"),
|
||||
)
|
||||
}).pipe(Effect.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("interrupting a consumer interrupts a pending acquisition", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const interrupted = yield* Deferred.make<void>()
|
||||
const counts = { ready: 0 }
|
||||
yield* Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consumer = yield* watcher
|
||||
.subscribe({ path: "/pending", type: "directory" })
|
||||
.subscribe(
|
||||
{ path: "/pending", type: "directory" },
|
||||
Effect.sync(() => {
|
||||
counts.ready++
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(consumer)
|
||||
expect(yield* Deferred.isDone(interrupted)).toBe(true)
|
||||
expect(counts.ready).toBe(0)
|
||||
}).pipe(
|
||||
withNative({
|
||||
subscribe: () =>
|
||||
@@ -77,16 +180,16 @@ describe("Watcher lifecycle", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shares one subscription and releases exactly once after the final consumer", () => {
|
||||
it.effect("shares equivalent entry sets and releases exactly once after the final consumer", () => {
|
||||
const { native, counts } = countingNative()
|
||||
return Effect.gen(function* () {
|
||||
const watcher = yield* Watcher.Service
|
||||
const consume = () =>
|
||||
const consume = (names: string[]) =>
|
||||
watcher
|
||||
.subscribe({ path: "/shared", type: "directory" })
|
||||
.subscribe({ path: "/shared", type: "entries", names })
|
||||
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
|
||||
const first = yield* consume()
|
||||
const second = yield* consume()
|
||||
const first = yield* consume(["opencode.json", ".opencode", "opencode.json"])
|
||||
const second = yield* consume([".opencode", "opencode.json"])
|
||||
yield* Effect.yieldNow
|
||||
expect(counts.subscribes).toBe(1)
|
||||
|
||||
@@ -553,17 +656,35 @@ describeNative("LocationWatcher", () => {
|
||||
})
|
||||
|
||||
it.live("publishes .hg/branch events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<string>()
|
||||
const watcher = Layer.effect(
|
||||
Watcher.Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
yield* ready(branch)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
const service = yield* Watcher.Service
|
||||
return Watcher.Service.of({
|
||||
subscribe: (input, onReady) =>
|
||||
service.subscribe(
|
||||
input,
|
||||
Deferred.succeed(started, input.path).pipe(Effect.andThen(onReady ?? Effect.void)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
{ vcs: "hg" },
|
||||
),
|
||||
).pipe(Layer.provide(AppNodeBuilder.build(Watcher.node)))
|
||||
return yield* withTmp(
|
||||
(directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const branch = path.join(directory, ".hg", "branch")
|
||||
// Use the actual acquisition barrier, not a probe write whose event
|
||||
// callback can race the next write in Bun's filesystem watcher.
|
||||
expect(yield* Deferred.await(started)).toBe(branch)
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
|
||||
).toMatchObject({ file: branch })
|
||||
}),
|
||||
{ vcs: "hg", watcher },
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -83,6 +83,34 @@ describe("FileSystem", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("lists parents and siblings with paths relative to the current location", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const current = path.join(directory, "current")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(directory, "sibling")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "sibling", "file.txt"), "outside"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const parent = yield* filesystem.list({ path: RelativePath.make("..") })
|
||||
expect(parent).toHaveLength(2)
|
||||
expect(parent.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: "." + path.sep, type: "directory" },
|
||||
{ path: path.join("..", "sibling") + path.sep, type: "directory" },
|
||||
]),
|
||||
)
|
||||
const sibling = yield* filesystem.list({ path: RelativePath.make("../sibling") })
|
||||
expect(sibling.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make(path.join("..", "sibling", "file.txt")), type: "file" },
|
||||
])
|
||||
const absolute = yield* filesystem.list({ path: path.join(directory, "sibling") })
|
||||
expect(absolute).toEqual(sibling)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("canonicalizes local symlinked directories", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -105,10 +133,37 @@ describe("FileSystem", () => {
|
||||
it.live("rejects lexical escapes", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(directory)),
|
||||
const current = path.join(directory, "current")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "outside.txt"), "outside"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows listing through an external symlink without allowing file reads", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const current = path.join(directory, "current")
|
||||
const outside = path.join(directory, "outside")
|
||||
yield* Effect.promise(() => fs.mkdir(current))
|
||||
yield* Effect.promise(() => fs.mkdir(outside))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(outside, "file.txt"), "outside"))
|
||||
yield* Effect.promise(() => fs.symlink(outside, path.join(current, "link"), "junction"))
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const entries = yield* filesystem.list({ path: RelativePath.make("link") })
|
||||
expect(entries.map((entry) => ({ path: entry.path, type: entry.type }))).toEqual([
|
||||
{ path: RelativePath.make(path.join("link", "file.txt")), type: "file" },
|
||||
])
|
||||
const result = yield* filesystem.read({ path: RelativePath.make("link/file.txt") }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
}).pipe(provide(current))
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { PositiveInt } from "@opencode-ai/schema/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const ListQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
path: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "An absolute path or a path relative to the requested location. Defaults to the location directory.",
|
||||
}),
|
||||
})
|
||||
|
||||
const FindQuery = Schema.Struct({
|
||||
@@ -42,7 +44,8 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.fs.list",
|
||||
summary: "List directory",
|
||||
description: "List direct children of one directory relative to the requested location.",
|
||||
description:
|
||||
"List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const fs = require("node:fs")
|
||||
const readline = require("node:readline")
|
||||
|
||||
fs.appendFileSync(process.argv[2], `${process.pid}\n`)
|
||||
readline
|
||||
.createInterface({ input: process.stdin })
|
||||
.on("line", (line) => {
|
||||
const request = JSON.parse(line)
|
||||
if (request.id === undefined) return
|
||||
const result =
|
||||
request.method === "initialize"
|
||||
? {
|
||||
protocolVersion: request.params.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: "filesystem-test", version: "1" },
|
||||
}
|
||||
: { tools: [] }
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n")
|
||||
})
|
||||
.on("close", () => process.exit(0))
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schedule } from "effect"
|
||||
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { startServer } from "./fixture/server"
|
||||
|
||||
it.live(
|
||||
"browsing parents and siblings reuses the current Location and its MCP process",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const current = path.join(tmp.path, "root", "current")
|
||||
const starts = path.join(tmp.path, "starts")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(current, { recursive: true })
|
||||
await fs.mkdir(path.join(tmp.path, "root", ".git"))
|
||||
await fs.mkdir(path.join(tmp.path, "root", "sibling", "nested"), { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "root", "sibling", "file.txt"), "outside")
|
||||
await fs.writeFile(starts, "")
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "root", "opencode.json"),
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
servers: {
|
||||
filesystem: {
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture", "mcp-starts.cjs"), starts],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
const server = yield* startServer(path.join(tmp.path, "config"))
|
||||
const list = (directory: string) =>
|
||||
Effect.promise(async () => {
|
||||
const url = new URL("/api/fs/list", server.base)
|
||||
url.searchParams.set("location[directory]", current)
|
||||
url.searchParams.set("path", directory)
|
||||
const response = await fetch(url, { headers: server.headers })
|
||||
expect(response.status).toBe(200)
|
||||
const result = await response.json()
|
||||
expect(result.location.directory).toBe(current)
|
||||
return result.data
|
||||
})
|
||||
const loaded = Effect.promise(async () => {
|
||||
const response = await fetch(new URL("/api/debug/location", server.base), { headers: server.headers })
|
||||
expect(response.status).toBe(200)
|
||||
return response.json()
|
||||
})
|
||||
const count = Effect.promise(
|
||||
async () => (await fs.readFile(starts, "utf8")).trim().split("\n").filter(Boolean).length,
|
||||
)
|
||||
|
||||
yield* list(".")
|
||||
expect(yield* loaded).toEqual([{ directory: current }])
|
||||
expect(
|
||||
yield* count.pipe(
|
||||
Effect.repeat({ while: (n) => n === 0, schedule: Schedule.spaced("25 millis") }),
|
||||
Effect.timeout("5 seconds"),
|
||||
),
|
||||
).toBe(1)
|
||||
|
||||
yield* list("..")
|
||||
const sibling = yield* list("../sibling")
|
||||
expect(sibling).toEqual([
|
||||
{ path: path.join("..", "sibling", "nested") + path.sep, type: "directory" },
|
||||
{ path: path.join("..", "sibling", "file.txt"), type: "file" },
|
||||
])
|
||||
expect(yield* list(path.join(tmp.path, "root", "sibling"))).toEqual(sibling)
|
||||
yield* list("../sibling/nested")
|
||||
yield* list("../sibling")
|
||||
expect(yield* loaded).toEqual([{ directory: current }])
|
||||
expect(yield* count).toBe(1)
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
@@ -271,9 +271,7 @@ export namespace Timeline {
|
||||
detail,
|
||||
new Set(
|
||||
messages
|
||||
.filter(
|
||||
(message) => timelineNoticeRequired(message) || (message.type === "shell" && shellFailed(message)),
|
||||
)
|
||||
.filter((message) => message.type === "compaction" && timelineNoticeRequired(message))
|
||||
.map((message) => message.id),
|
||||
),
|
||||
)
|
||||
@@ -321,7 +319,8 @@ export namespace Timeline {
|
||||
const refs = messages.flatMap((message, messageIndex) =>
|
||||
contentEntries(message)
|
||||
.filter(
|
||||
(entry) => isRenderable(entry.content, showReasoning, detail) && !(thinking && entry.content === lastContent),
|
||||
(entry) =>
|
||||
isRenderable(entry.content, showReasoning, detail) && !(thinking && entry.content === lastContent),
|
||||
)
|
||||
.map((entry) => ({ messageID: message.id, messageIndex, partID: entry.id, content: entry.content })),
|
||||
)
|
||||
@@ -662,9 +661,10 @@ function toolGroupType(
|
||||
detail?: TimelineDetail,
|
||||
) {
|
||||
if (detail) {
|
||||
if (currentToolFailed(content) || content.name === "question") return undefined
|
||||
if (content.name === "question" && !currentToolFailed(content)) return undefined
|
||||
const category = timelineCategory(content)!
|
||||
if (detail[category].placement === "grouped") return "context"
|
||||
if (currentToolFailed(content)) return undefined
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
return undefined
|
||||
|
||||
@@ -71,6 +71,8 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
input.projection.rows().forEach((row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
row.group.refs.forEach((ref) => {
|
||||
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
|
||||
if (content?.type !== "tool" || content.name !== "patch" || content.state.status === "error") return
|
||||
const part = `${ref.messageID}:${ref.partID}`
|
||||
const key = patchGroupKeys.get(part)
|
||||
if (key && !owners.has(key)) owners.set(key, part)
|
||||
@@ -273,8 +275,14 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
|
||||
function contentDefaultOpen(item: SessionMessageAssistant["content"][number]) {
|
||||
if (input.timelineDetail) {
|
||||
if (item.type === "tool" && currentToolFailed(item)) return true
|
||||
const category = timelineCategory(item)
|
||||
if (
|
||||
category &&
|
||||
input.timelineDetail()[category].placement === "hidden" &&
|
||||
item.type === "tool" &&
|
||||
currentToolFailed(item)
|
||||
)
|
||||
return true
|
||||
if (category === "shell" || category === "edit" || category === "thinking")
|
||||
return input.timelineDetail()[category].details === "expanded"
|
||||
}
|
||||
@@ -488,8 +496,8 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
const value = message()
|
||||
return (
|
||||
input.timelineDetail().shell.details === "expanded" ||
|
||||
value?.status === "timeout" ||
|
||||
(value?.status === "exited" && value.exit !== undefined && value.exit !== 0)
|
||||
(input.timelineDetail().shell.placement === "hidden" &&
|
||||
(value?.status === "timeout" || (value?.status === "exited" && value.exit !== undefined && value.exit !== 0)))
|
||||
)
|
||||
})
|
||||
return (
|
||||
|
||||
@@ -529,13 +529,11 @@ export function CurrentContextToolGroup(props: {
|
||||
)
|
||||
const label = createMemo(() => {
|
||||
const notices = props.parts.filter((part) => part.type === "notice").length
|
||||
if (!names() && !notices) {
|
||||
const title = i18n.t("ui.messagePart.context.reasoning")
|
||||
return { text: title, title, before: "", after: "" }
|
||||
}
|
||||
const title = [names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
const title =
|
||||
[names(), notices ? i18n.plural("ui.messagePart.context.notice", notices) : undefined]
|
||||
.filter(Boolean)
|
||||
.join(", ") ||
|
||||
i18n.plural("ui.messagePart.context.thought", props.parts.filter((part) => part.type === "reasoning").length)
|
||||
const text = i18n.t("ui.messagePart.tools.used", { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
|
||||
|
||||
@@ -25,13 +25,7 @@ import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { parseFileLineRange, stripFileLineRange } from "../../prompt/parse"
|
||||
import { moveSelection, reconcileSelectionWindow, revealSelectionOffset } from "../../ui/select-controller"
|
||||
import {
|
||||
directoryAutocompleteExactValue,
|
||||
directoryAutocompleteMatches,
|
||||
directoryAutocompleteResultValue,
|
||||
directoryAutocompleteSearch,
|
||||
slashArgumentAutocomplete,
|
||||
} from "../../prompt/directory-completion"
|
||||
import { directoryAutocomplete, slashArgumentAutocomplete } from "../../prompt/directory-completion"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
@@ -343,20 +337,38 @@ export function Autocomplete(props: {
|
||||
if (referenceMatch())
|
||||
return { options: [], failed: false, mode: input.visible, query: input.query, resolved: true }
|
||||
const { lineRange, base } = parseFileLineRange(input.query ?? "")
|
||||
const directorySearch =
|
||||
input.visible === "directory"
|
||||
? directoryAutocompleteSearch(base, input.location?.directory ?? paths.cwd, paths.home)
|
||||
: undefined
|
||||
|
||||
const requestLocation = {
|
||||
directory: directorySearch?.directory ?? input.location?.directory,
|
||||
directory: input.location?.directory,
|
||||
workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
|
||||
}
|
||||
const result = await (
|
||||
input.visible === "directory"
|
||||
? client.api.file.list({ location: requestLocation })
|
||||
: client.api.file.find({ query: base, limit: 20, location: requestLocation })
|
||||
).then(
|
||||
const width = props.anchor().width - 4
|
||||
if (input.visible === "directory") {
|
||||
const result = await directoryAutocomplete(
|
||||
client.api.file,
|
||||
{ ...requestLocation, directory: requestLocation.directory ?? paths.cwd },
|
||||
base,
|
||||
paths.home,
|
||||
).catch(() => undefined)
|
||||
if (!result)
|
||||
return info.value?.mode === input.visible
|
||||
? { ...info.value, failed: true }
|
||||
: { options: [], failed: true, mode: input.visible, query: input.query, resolved: false }
|
||||
return {
|
||||
options: result.map((item) => ({
|
||||
display: Locale.truncateMiddle(item.value, width),
|
||||
value: item.value,
|
||||
isDirectory: true,
|
||||
path: item.value,
|
||||
absolute: item.absolute,
|
||||
onSelect: () => insertDirectory(item.value),
|
||||
})),
|
||||
failed: false,
|
||||
mode: input.visible,
|
||||
query: input.query,
|
||||
resolved: true,
|
||||
}
|
||||
}
|
||||
const result = await client.api.file.find({ query: base, limit: 20, location: requestLocation }).then(
|
||||
(result) => result,
|
||||
() => undefined,
|
||||
)
|
||||
@@ -368,38 +380,8 @@ export function Autocomplete(props: {
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
|
||||
const width = props.anchor().width - 4
|
||||
const exact = directorySearch ? directoryAutocompleteExactValue(base, directorySearch) : undefined
|
||||
if (exact) {
|
||||
options.push({
|
||||
display: Locale.truncateMiddle(exact, width),
|
||||
value: exact,
|
||||
isDirectory: true,
|
||||
path: exact,
|
||||
absolute: result.location.directory,
|
||||
onSelect: () => insertDirectory(exact),
|
||||
})
|
||||
}
|
||||
const entries =
|
||||
input.visible === "directory"
|
||||
? result.data.filter(
|
||||
(item) =>
|
||||
item.type === "directory" && directoryAutocompleteMatches(item.path, directorySearch?.query ?? ""),
|
||||
)
|
||||
: result.data
|
||||
options.push(
|
||||
...entries.map((item): AutocompleteOption => {
|
||||
if (input.visible === "directory") {
|
||||
const directory = directorySearch ? directoryAutocompleteResultValue(item.path, directorySearch) : item.path
|
||||
return {
|
||||
display: Locale.truncateMiddle(directory, width),
|
||||
value: directory,
|
||||
isDirectory: true,
|
||||
path: directory,
|
||||
absolute: path.resolve(result.location.directory, item.path),
|
||||
onSelect: () => insertDirectory(directory),
|
||||
}
|
||||
}
|
||||
...result.data.map((item): AutocompleteOption => {
|
||||
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
|
||||
return {
|
||||
display: Locale.truncateMiddle(filename, width),
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client"
|
||||
import path from "path"
|
||||
import { displaySlice, promptOffsetWidth } from "./display"
|
||||
import { parseSlashHead } from "./parse"
|
||||
|
||||
export async function directoryAutocomplete(
|
||||
file: Pick<OpenCodeClient["file"], "list">,
|
||||
location: { directory: string; workspace?: string },
|
||||
query: string,
|
||||
home: string,
|
||||
) {
|
||||
const search = directoryAutocompleteSearch(query, location.directory, home)
|
||||
const result = await file.list({ location, path: search.directory })
|
||||
const exact = directoryAutocompleteExactValue(query, search)
|
||||
return [
|
||||
...(exact ? [{ value: exact, absolute: search.directory }] : []),
|
||||
...result.data
|
||||
.filter((item) => item.type === "directory")
|
||||
.map((item) => path.resolve(result.location.directory, item.path))
|
||||
.filter((absolute) => directoryAutocompleteMatches(path.basename(absolute), search.query))
|
||||
.map((absolute) => ({
|
||||
value: directoryAutocompleteResultValue(path.basename(absolute) + "/", search),
|
||||
absolute,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
export function slashArgumentAutocomplete(
|
||||
value: string,
|
||||
offset: number,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import type { KeymapCommand } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
directoryAutocomplete,
|
||||
directoryAutocompleteExactValue,
|
||||
directoryAutocompleteMatches,
|
||||
directoryAutocompleteResultValue,
|
||||
@@ -10,6 +11,64 @@ import {
|
||||
slashArgumentAutocomplete,
|
||||
} from "../../src/prompt/directory-completion"
|
||||
|
||||
describe("directoryAutocomplete", () => {
|
||||
test("lists parents and siblings through the current workspace without changing location", async () => {
|
||||
const location = { directory: "/project/current", workspace: "workspace_1" }
|
||||
const calls: unknown[] = []
|
||||
const file = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: location.directory, workspaceID: location.workspace },
|
||||
data: [
|
||||
{ path: "./", type: "directory" },
|
||||
{ path: "../sibling/", type: "directory" },
|
||||
{ path: "../.hidden/", type: "directory" },
|
||||
{ path: "../README.md", type: "file" },
|
||||
],
|
||||
}
|
||||
},
|
||||
} as Parameters<typeof directoryAutocomplete>[0]
|
||||
expect(await directoryAutocomplete(file, location, "..", "/home/user")).toEqual([
|
||||
{ value: "..", absolute: path.resolve("/project") },
|
||||
{ value: "../current/", absolute: path.resolve("/project/current") },
|
||||
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
|
||||
])
|
||||
expect(await directoryAutocomplete(file, location, "../sib", "/home/user")).toEqual([
|
||||
{ value: "../sibling/", absolute: path.resolve("/project/sibling") },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location, path: path.resolve("/project") },
|
||||
{ location, path: path.resolve("/project") },
|
||||
])
|
||||
})
|
||||
|
||||
test("resolves home and nested sibling completions against the current location", async () => {
|
||||
const location = { directory: "/project/current", workspace: "workspace_1" }
|
||||
const calls: unknown[] = []
|
||||
const file = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: location.directory },
|
||||
data: [{ path: "../sibling/src/", type: "directory" }],
|
||||
}
|
||||
},
|
||||
} as Parameters<typeof directoryAutocomplete>[0]
|
||||
expect(await directoryAutocomplete(file, location, "../sibling/s", "/project/sibling")).toEqual([
|
||||
{ value: "../sibling/src/", absolute: path.resolve("/project/sibling/src") },
|
||||
])
|
||||
expect(await directoryAutocomplete(file, location, "~", "/project/sibling")).toEqual([
|
||||
{ value: "~", absolute: "/project/sibling" },
|
||||
{ value: "~/src/", absolute: path.resolve("/project/sibling/src") },
|
||||
])
|
||||
expect(calls).toEqual([
|
||||
{ location, path: path.resolve("/project/sibling") },
|
||||
{ location, path: "/project/sibling" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
const commands = [
|
||||
{
|
||||
id: "session.cd",
|
||||
|
||||
@@ -108,9 +108,10 @@ const source = {
|
||||
"ui.messagePart.context.search.other": "{{count}} searches",
|
||||
"ui.messagePart.context.list.one": "{{count}} list",
|
||||
"ui.messagePart.context.list.other": "{{count}} lists",
|
||||
"ui.messagePart.context.notice.one": "{{count}} notice",
|
||||
"ui.messagePart.context.notice.other": "{{count}} notices",
|
||||
"ui.messagePart.context.reasoning": "Reasoning",
|
||||
"ui.messagePart.context.notice.one": "{{count}} Notice",
|
||||
"ui.messagePart.context.notice.other": "{{count}} Notices",
|
||||
"ui.messagePart.context.thought.one": "{{count}} Thought",
|
||||
"ui.messagePart.context.thought.other": "{{count}} Thoughts",
|
||||
"ui.messagePart.context.match.one": "({{count}} match)",
|
||||
"ui.messagePart.context.match.other": "({{count}} matches)",
|
||||
"ui.messagePart.tools.used": "Used {{tools}}",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
--- a/dist/ghostty-web.js
|
||||
+++ b/dist/ghostty-web.js
|
||||
@@ -1209,7 +1209,7 @@
|
||||
*/
|
||||
getMouseModifiers(A) {
|
||||
let g = 0;
|
||||
- return A.shiftKey && (g |= 4), A.metaKey && (g |= 8), A.ctrlKey && (g |= 16), g;
|
||||
+ return A.shiftKey && (g |= 4), A.altKey && (g |= 8), A.ctrlKey && (g |= 16), g;
|
||||
}
|
||||
/**
|
||||
* Encode mouse event as SGR sequence
|
||||
@@ -1246,7 +1246,7 @@
|
||||
*/
|
||||
handleMouseDown(A) {
|
||||
var C;
|
||||
- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()))
|
||||
+ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey)
|
||||
return;
|
||||
const g = this.pixelToCell(A);
|
||||
if (!g)
|
||||
@@ -1259,7 +1259,7 @@
|
||||
*/
|
||||
handleMouseUp(A) {
|
||||
var C;
|
||||
- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()))
|
||||
+ if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()) || A.shiftKey)
|
||||
return;
|
||||
const g = this.pixelToCell(A);
|
||||
if (!g)
|
||||
@@ -1272,7 +1272,7 @@
|
||||
*/
|
||||
handleMouseMove(A) {
|
||||
var E, D, i;
|
||||
- if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking()))
|
||||
+ if (this.isDisposed || !((E = this.mouseConfig) != null && E.hasMouseTracking()) || A.shiftKey)
|
||||
return;
|
||||
const g = ((D = this.getModeCallback) == null ? void 0 : D.call(this, 1002)) ?? !1, I = ((i = this.getModeCallback) == null ? void 0 : i.call(this, 1003)) ?? !1;
|
||||
if (!g && !I || g && !I && this.mouseButtonsPressed === 0)
|
||||
@@ -1280,21 +1280,21 @@
|
||||
const C = this.pixelToCell(A);
|
||||
if (!C)
|
||||
return;
|
||||
- let Q = 32;
|
||||
- this.mouseButtonsPressed & 1 ? Q += 0 : this.mouseButtonsPressed & 2 ? Q += 1 : this.mouseButtonsPressed & 4 && (Q += 2), this.sendMouseEvent(Q, C.col, C.row, !1, A);
|
||||
+ let Q = 35;
|
||||
+ this.mouseButtonsPressed & 1 ? Q = 32 : this.mouseButtonsPressed & 2 ? Q = 33 : this.mouseButtonsPressed & 4 && (Q = 34), this.sendMouseEvent(Q, C.col, C.row, !1, A);
|
||||
}
|
||||
/**
|
||||
* Handle wheel event (scroll)
|
||||
*/
|
||||
handleWheel(A) {
|
||||
- var C;
|
||||
- if (this.isDisposed || !((C = this.mouseConfig) != null && C.hasMouseTracking()))
|
||||
+ var Q;
|
||||
+ if (this.isDisposed || !((Q = this.mouseConfig) != null && Q.hasMouseTracking()) || A.shiftKey)
|
||||
return;
|
||||
const g = this.pixelToCell(A);
|
||||
- if (!g)
|
||||
+ if (!g || A.deltaX === 0 && A.deltaY === 0)
|
||||
return;
|
||||
- const I = A.deltaY < 0 ? 64 : 65;
|
||||
- this.sendMouseEvent(I, g.col, g.row, !1, A), A.preventDefault();
|
||||
+ const C = Math.abs(A.deltaX) > Math.abs(A.deltaY) ? A.deltaX < 0 ? 66 : 67 : A.deltaY < 0 ? 64 : 65;
|
||||
+ this.sendMouseEvent(C, g.col, g.row, !1, A), A.preventDefault();
|
||||
}
|
||||
/**
|
||||
* Emit paste data with bracketed paste support
|
||||
@@ -2353,7 +2353,7 @@
|
||||
attachEventListeners() {
|
||||
const A = this.renderer.getCanvas();
|
||||
A.addEventListener("mousedown", (g) => {
|
||||
- if (g.button === 0) {
|
||||
+ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey) && g.button === 0) {
|
||||
A.parentElement && A.parentElement.focus();
|
||||
const I = this.pixelToCell(g.offsetX, g.offsetY);
|
||||
this.hasSelection() && this.clearSelection();
|
||||
@@ -2408,35 +2408,37 @@
|
||||
}
|
||||
}
|
||||
}, document.addEventListener("mouseup", this.boundMouseUpHandler), A.addEventListener("click", (g) => {
|
||||
- if (g.detail === 2) {
|
||||
- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row);
|
||||
- if (C) {
|
||||
- const Q = this.viewportRowToAbsolute(I.row);
|
||||
- this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender();
|
||||
- const E = this.getSelection();
|
||||
- E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire());
|
||||
- }
|
||||
- } else if (g.detail >= 3) {
|
||||
- const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength();
|
||||
- let E = null;
|
||||
- if (C < Q)
|
||||
- E = this.wasmTerm.getScrollbackLine(C);
|
||||
- else {
|
||||
- const i = C - Q;
|
||||
- E = this.wasmTerm.getLine(i);
|
||||
- }
|
||||
- let D = -1;
|
||||
- if (E) {
|
||||
- for (let i = E.length - 1; i >= 0; i--)
|
||||
- if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) {
|
||||
- D = i;
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
- if (D >= 0) {
|
||||
- this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender();
|
||||
- const i = this.getSelection();
|
||||
- i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire());
|
||||
+ if (!(this.wasmTerm.hasMouseTracking() && !g.shiftKey)) {
|
||||
+ if (g.detail === 2) {
|
||||
+ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.getWordAtCell(I.col, I.row);
|
||||
+ if (C) {
|
||||
+ const Q = this.viewportRowToAbsolute(I.row);
|
||||
+ this.selectionStart = { col: C.startCol, absoluteRow: Q }, this.selectionEnd = { col: C.endCol, absoluteRow: Q }, this.requestRender();
|
||||
+ const E = this.getSelection();
|
||||
+ E && (this.copyToClipboard(E), this.selectionChangedEmitter.fire());
|
||||
+ }
|
||||
+ } else if (g.detail >= 3) {
|
||||
+ const I = this.pixelToCell(g.offsetX, g.offsetY), C = this.viewportRowToAbsolute(I.row), Q = this.wasmTerm.getScrollbackLength();
|
||||
+ let E = null;
|
||||
+ if (C < Q)
|
||||
+ E = this.wasmTerm.getScrollbackLine(C);
|
||||
+ else {
|
||||
+ const i = C - Q;
|
||||
+ E = this.wasmTerm.getLine(i);
|
||||
+ }
|
||||
+ let D = -1;
|
||||
+ if (E) {
|
||||
+ for (let i = E.length - 1; i >= 0; i--)
|
||||
+ if (E[i] && E[i].codepoint !== 0 && E[i].codepoint !== 32) {
|
||||
+ D = i;
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ if (D >= 0) {
|
||||
+ this.selectionStart = { col: 0, absoluteRow: C }, this.selectionEnd = { col: D, absoluteRow: C }, this.requestRender();
|
||||
+ const i = this.getSelection();
|
||||
+ i && (this.copyToClipboard(i), this.selectionChangedEmitter.fire());
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}), this.boundContextMenuHandler = (g) => {
|
||||
@@ -2676,23 +2678,27 @@
|
||||
const M = await this.linkDetector.getLinkAt(Q, o);
|
||||
M && (M.activate(I), (I.ctrlKey || I.metaKey) && I.preventDefault());
|
||||
}, this.handleWheel = (I) => {
|
||||
- var Q, E, D;
|
||||
- if (I.preventDefault(), I.stopPropagation(), this.customWheelEventHandler && this.customWheelEventHandler(I))
|
||||
+ var Q, E, D, i;
|
||||
+ if (I.preventDefault(), this.customWheelEventHandler && this.customWheelEventHandler(I)) {
|
||||
+ I.stopPropagation();
|
||||
+ return;
|
||||
+ }
|
||||
+ if ((Q = this.wasmTerm) != null && Q.hasMouseTracking() && !I.shiftKey)
|
||||
return;
|
||||
- if (((Q = this.wasmTerm) == null ? void 0 : Q.isAlternateScreen()) ?? !1) {
|
||||
- const i = I.deltaY > 0 ? "down" : "up", o = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5);
|
||||
- for (let s = 0; s < o; s++)
|
||||
- i === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B");
|
||||
+ if (I.stopPropagation(), ((E = this.wasmTerm) == null ? void 0 : E.isAlternateScreen()) ?? !1) {
|
||||
+ const o = I.deltaY > 0 ? "down" : "up", s = Math.min(Math.abs(Math.round(I.deltaY / 33)), 5);
|
||||
+ for (let G = 0; G < s; G++)
|
||||
+ o === "up" ? this.dataEmitter.fire("\x1B[A") : this.dataEmitter.fire("\x1B[B");
|
||||
} else {
|
||||
- let i;
|
||||
+ let o;
|
||||
if (I.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
|
||||
- const o = ((D = (E = this.renderer) == null ? void 0 : E.getMetrics()) == null ? void 0 : D.height) ?? 20;
|
||||
- i = I.deltaY / o;
|
||||
+ const s = ((i = (D = this.renderer) == null ? void 0 : D.getMetrics()) == null ? void 0 : i.height) ?? 20;
|
||||
+ o = I.deltaY / s;
|
||||
} else
|
||||
- I.deltaMode === WheelEvent.DOM_DELTA_LINE ? i = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? i = I.deltaY * this.rows : i = I.deltaY / 33;
|
||||
- if (i !== 0) {
|
||||
- const o = this.viewportY - i;
|
||||
- this.smoothScrollTo(o);
|
||||
+ I.deltaMode === WheelEvent.DOM_DELTA_LINE ? o = I.deltaY : I.deltaMode === WheelEvent.DOM_DELTA_PAGE ? o = I.deltaY * this.rows : o = I.deltaY / 33;
|
||||
+ if (o !== 0) {
|
||||
+ const s = this.viewportY - o;
|
||||
+ this.smoothScrollTo(s);
|
||||
}
|
||||
}
|
||||
}, this.handleMouseDown = (I) => {
|
||||
@@ -3254,7 +3260,7 @@
|
||||
* Clean up components (called on dispose or error)
|
||||
*/
|
||||
cleanupComponents() {
|
||||
- this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0;
|
||||
+ this.selectionManager && (this.selectionManager.dispose(), this.selectionManager = void 0), this.inputHandler && (this.inputHandler.dispose(), this.inputHandler = void 0), this.renderer && (this.renderer.dispose(), this.renderer = void 0), this.canvas && this.canvas.parentNode && (this.canvas.parentNode.removeChild(this.canvas), this.canvas = void 0), this.textarea && this.textarea.parentNode && (this.textarea.parentNode.removeChild(this.textarea), this.textarea = void 0), this.element && (this.element.removeEventListener("wheel", this.handleWheel, { capture: !0 }), this.element.removeEventListener("mousedown", this.handleMouseDown, { capture: !0 }), this.element.removeEventListener("mousemove", this.handleMouseMove), this.element.removeEventListener("mouseleave", this.handleMouseLeave), this.element.removeEventListener("click", this.handleClick), this.element.removeAttribute("contenteditable"), this.element.removeAttribute("role"), this.element.removeAttribute("aria-label"), this.element.removeAttribute("aria-multiline")), this.isOpen && typeof document < "u" && document.removeEventListener("mouseup", this.handleMouseUp), this.scrollbarHideTimeout && (window.clearTimeout(this.scrollbarHideTimeout), this.scrollbarHideTimeout = void 0), this.linkDetector && (this.linkDetector.dispose(), this.linkDetector = void 0), this.wasmTerm && (this.wasmTerm.free(), this.wasmTerm = void 0), this.ghostty = void 0, this.element = void 0, this.textarea = void 0;
|
||||
}
|
||||
/**
|
||||
* Assert terminal is open (throw if not)
|
||||
Reference in New Issue
Block a user