mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(gui-extensions): fix stale details, browser cleanup, update checks and panel state (#53075)
This commit is contained in:
committed by
GitHub
Unverified
parent
077444b94b
commit
715de7af15
@@ -92,6 +92,7 @@ story("comments on a picked element over a still of the page", async ({ page })
|
||||
await picker.click()
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("status")).toHaveText(
|
||||
"Click an element in the page to comment on it. Press Escape to cancel.",
|
||||
)
|
||||
@@ -100,6 +101,7 @@ story("comments on a picked element over a still of the page", async ({ page })
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "false")
|
||||
const editor = root.locator('[data-slot="browser-comment-editor"] textarea')
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(root.getByText("Session getter reads: 0", { exact: true })).toBeVisible()
|
||||
await expect(root.locator('[data-slot="browser-comment-editor"]')).toContainText("button.primary")
|
||||
// The spotlight frames the picked element in surface pixels.
|
||||
await expect(root.locator('[data-slot="browser-comment-spotlight"]')).toHaveCSS("left", "48px")
|
||||
@@ -147,6 +149,44 @@ story("cancels the picker and a comment with Escape", async ({ page }) => {
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("cleans up the originating session's picker and comment after a session switch", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
|
||||
await picker.click()
|
||||
await expect(root.getByText("Picker Alpha: on", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByText("Picker Alpha: off", { exact: true })).toBeVisible()
|
||||
await expect(picker).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await picker.click()
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
await expect(root.locator('[data-slot="browser-comment-editor"] textarea')).toBeFocused()
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.locator('[data-component="browser-comment"]')).toHaveCount(0)
|
||||
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Highlight owners: Alpha", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("ends the native picker and highlight when the pane unmounts", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const picker = root.getByRole("button", { name: "Select an element to comment on", exact: true })
|
||||
await picker.click()
|
||||
await expect(root.getByText("Picker: on", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
|
||||
await expect(root.locator("#browser-panel")).toHaveCount(0)
|
||||
await expect(root.getByText("Picker: off", { exact: true })).toBeVisible()
|
||||
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await picker.click()
|
||||
await root.getByRole("button", { name: "Pick element", exact: true }).click()
|
||||
await expect(root.locator('[data-slot="browser-comment-editor"] textarea')).toBeFocused()
|
||||
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
|
||||
await expect(root.locator("#browser-panel")).toHaveCount(0)
|
||||
await expect(root.getByText("Highlights: clear", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("keeps a comment draft but drops its ref when the page navigates", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Select an element to comment on", exact: true }).click()
|
||||
@@ -231,17 +271,22 @@ story("keeps the submitted URL visible until the browser reports navigation", as
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
|
||||
story("restores the current URL each time the same submitted navigation is blocked", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://blocked.example/")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("https://blocked.example/")
|
||||
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
|
||||
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
|
||||
for (const submission of [1, 2]) {
|
||||
await story.step(`blocked submission ${submission}`, async () => {
|
||||
await address.fill("https://blocked.example/")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("https://blocked.example/")
|
||||
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
|
||||
await expect(root.getByRole("alert")).toHaveText("Request failed")
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { SERVER, project, provider, session, workspaceKey } from "../utils/app"
|
||||
import { SERVER, holdRoute, project, provider, session, workspaceKey } from "../utils/app"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fileDiff, fileNode, openSession } from "../utils/workspace"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -33,6 +33,85 @@ for (const view of ["desktop", "mobile"] as const) {
|
||||
})
|
||||
}
|
||||
|
||||
test("Git initialization belongs to its session and refreshes after a session switch", async ({ page }) => {
|
||||
const requests: { directory: string; provider?: string }[] = []
|
||||
|
||||
const workspace = await openSession(page, {
|
||||
name: "ReviewInitSwitch",
|
||||
project: { vcs: undefined },
|
||||
onVcsInit: (input) => requests.push(input),
|
||||
sessions: [
|
||||
{ id: "ses_init_a", title: "Initialize Alpha" },
|
||||
{ id: "ses_init_b", title: "Initialize Beta" },
|
||||
],
|
||||
seed: { panes: { ses_init_a: { review: true }, ses_init_b: { review: true } } },
|
||||
})
|
||||
|
||||
// The successful init finishes while another session is routed.
|
||||
const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" })
|
||||
const panel = page.locator("#review-panel")
|
||||
const init = panel.getByRole("button", { name: "Create Git repository", exact: true })
|
||||
const pending = panel.getByRole("button", { name: "Creating Git repository…", exact: true })
|
||||
await expect(init).toBeEnabled()
|
||||
await init.click()
|
||||
await request.arrived
|
||||
await expect(pending).toBeDisabled()
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click()
|
||||
await expectSessionTitle(page, "Initialize Beta")
|
||||
await expect(init).toBeEnabled()
|
||||
await expect(pending).toHaveCount(0)
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click()
|
||||
await expectSessionTitle(page, "Initialize Alpha")
|
||||
await expect(pending).toBeDisabled()
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click()
|
||||
await expectSessionTitle(page, "Initialize Beta")
|
||||
await expect(init).toBeEnabled()
|
||||
|
||||
const response = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/vcs/init")
|
||||
// The worktree event can refresh project-wide UI by itself; the completed action must also reload Alpha's cache.
|
||||
const refreshed = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/session/ses_init_a")
|
||||
request.release()
|
||||
expect((await response).status()).toBe(204)
|
||||
expect((await refreshed).status()).toBe(200)
|
||||
expect(requests).toEqual([{ directory: workspace.directory, provider: "git" }])
|
||||
await expect(panel.getByRole("button", { name: "Git changes", exact: true })).toBeVisible()
|
||||
await expect(init).toHaveCount(0)
|
||||
await expect(page.getByText("Request failed", { exact: true })).toHaveCount(0)
|
||||
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click()
|
||||
await expectSessionTitle(page, "Initialize Alpha")
|
||||
await expect(panel.getByRole("button", { name: "Git changes", exact: true })).toBeVisible()
|
||||
await expect(pending).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a rejected Git init releases its session's button without showing an error in another session", async ({ page }) => {
|
||||
await openSession(page, {
|
||||
name: "ReviewInitFailure",
|
||||
project: { vcs: undefined },
|
||||
sessions: [
|
||||
{ id: "ses_init_failure_a", title: "Initialize Alpha" },
|
||||
{ id: "ses_init_failure_b", title: "Initialize Beta" },
|
||||
],
|
||||
seed: { panes: { ses_init_failure_a: { review: true }, ses_init_failure_b: { review: true } } },
|
||||
})
|
||||
const request = await holdRoute(page, (url) => url.pathname === "/api/vcs/init", { method: "POST" })
|
||||
const init = page.locator("#review-panel").getByRole("button", { name: "Create Git repository", exact: true })
|
||||
await init.click()
|
||||
await request.arrived
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Beta" }).click()
|
||||
await expectSessionTitle(page, "Initialize Beta")
|
||||
await expect(init).toBeEnabled()
|
||||
const response = page.waitForResponse((item) => new URL(item.url()).pathname === "/api/vcs/init")
|
||||
request.release()
|
||||
expect((await response).status()).toBe(501)
|
||||
await expect(page.getByText("Request failed", { exact: true })).toHaveCount(0)
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: "Initialize Alpha" }).click()
|
||||
await expectSessionTitle(page, "Initialize Alpha")
|
||||
await expect(init).toBeEnabled()
|
||||
})
|
||||
|
||||
test("open file tab browses, searches, and tracks missing files", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
|
||||
@@ -48,7 +48,9 @@ test("routes typing to the composer unless the open terminal is focused", async
|
||||
await expect.poll(() => pty.sockets.length).toBe(1)
|
||||
pty.send("ready")
|
||||
await expect(terminal).toHaveCSS("opacity", "1")
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
})
|
||||
await page.keyboard.type("a")
|
||||
await expect(editor).toBeFocused()
|
||||
await expect(editor).toHaveText("a")
|
||||
@@ -64,6 +66,7 @@ for (const mount of ["cached", "explicit"] as const) {
|
||||
await route.continue()
|
||||
})
|
||||
const cached = { id: "pty_cached", title: "Terminal 1" }
|
||||
|
||||
const { pty, sessions } = await mockWorkspace(page, {
|
||||
...workspace,
|
||||
pty: mount === "cached" ? { initial: [cached] } : {},
|
||||
@@ -76,14 +79,17 @@ for (const mount of ["cached", "explicit"] as const) {
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(sessions[0]!.id), { waitUntil: "commit" })
|
||||
await expectSessionTitle(page, "TerminalInput")
|
||||
const editor = page.locator('[data-component="composer-editor"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
|
||||
if (mount === "explicit") {
|
||||
await expect(editor).toBeEditable()
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
}
|
||||
|
||||
await expect(terminal).toBeVisible()
|
||||
await ghostty.promise
|
||||
await editor.click()
|
||||
@@ -95,6 +101,7 @@ for (const mount of ["cached", "explicit"] as const) {
|
||||
pty.send("ready")
|
||||
await expect(terminal).toHaveCSS("opacity", "1")
|
||||
await expect(editor).toBeFocused()
|
||||
|
||||
if (mount === "cached") expect(pty.created).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -113,8 +120,40 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
test("closing the last restored terminal does not create a hidden replacement", async ({ page }) => {
|
||||
const cached = { id: "pty_cached", title: "Terminal 1" }
|
||||
|
||||
const { pty } = await openSession(page, {
|
||||
...workspace,
|
||||
pty: { initial: [cached] },
|
||||
seed: {
|
||||
panes: { ses_terminalinput: { terminal: true, terminalHeight: 320 } },
|
||||
storage: { [legacyTerminalKey()]: { active: cached.id, all: [{ ...cached, titleNumber: 1 }] } },
|
||||
},
|
||||
})
|
||||
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => pty.sockets.length).toBe(1)
|
||||
expect(pty.created).toEqual([])
|
||||
|
||||
await page.locator("#terminal-panel").getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect.poll(() => pty.removed).toEqual([cached.id])
|
||||
expect(pty.created).toEqual([])
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => pty.created.map((item) => item.id)).toEqual(["pty_1"])
|
||||
await expect(page.locator("#terminal-panel").getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
})
|
||||
|
||||
function legacyTerminalKey() {
|
||||
const dir = base64Encode("C:/OpenCode/TerminalInput")
|
||||
const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import pkg from "../../package.json" with { type: "json" }
|
||||
import type { SeedInput } from "../utils/app"
|
||||
import { SERVER, type SeedInput } from "../utils/app"
|
||||
import { openHeldStart } from "../utils/direction"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
import { mockWorkspace } from "../utils/workspace"
|
||||
@@ -111,7 +111,23 @@ test("Don't show these in the future turns release notes off and closes What's N
|
||||
await expect.poll(() => stored(page, RELEASE_NOTES)).toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
test("What's New opened after the attachment mounts waits for the routes, then keeps focus over restored Settings", async ({ page }) => {
|
||||
test("a rejected update check shows one failure title without repeating it as a description", async ({ page }) => {
|
||||
await mockWorkspace(page, { name: "UpdateCheck", sessions: [] })
|
||||
await page.goto(
|
||||
`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "stopped", updater: "" })}`,
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const check = settings.getByRole("button", { name: "Check now", exact: true })
|
||||
await expect(check).toBeEnabled()
|
||||
await check.click()
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Request failed", { exact: true })).toHaveCount(1)
|
||||
await expect(page.getByText("You're up to date", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("What's New opened after the attachment mounts waits for the routes, then keeps focus over restored Settings", async ({
|
||||
page,
|
||||
}) => {
|
||||
const held = Promise.withResolvers<void>()
|
||||
await serveChangelog(page, changelog, held.promise)
|
||||
await mockWorkspace(page, { name: "Whats New", sessions: [], seed: upgraded })
|
||||
|
||||
@@ -29,6 +29,8 @@ export function mount(input: {
|
||||
ssh?: string | null
|
||||
storage?: string | null
|
||||
hold?: string | null
|
||||
/** Enables the updater Ipc; check and install reject with this message, which may be empty. */
|
||||
updater?: string | null
|
||||
}) {
|
||||
const root = document.getElementById("root")
|
||||
|
||||
@@ -174,6 +176,7 @@ export function mount(input: {
|
||||
const bridge: Bridge = {
|
||||
packaged: false,
|
||||
async call(request) {
|
||||
if (request.ipc === "updater" && input.updater != null) throw new Error(input.updater)
|
||||
const method = methods.get(request.ipc)?.[request.method]
|
||||
|
||||
if (!method) throw new Error("Unexpected fixture action")
|
||||
@@ -184,6 +187,8 @@ export function mount(input: {
|
||||
return result ?? null
|
||||
},
|
||||
async subscribe(ipc) {
|
||||
if (ipc === "updater" && input.updater != null) return { available: true, state: { status: "idle" } }
|
||||
|
||||
if (ipc === "wsl" || ipc === "ssh") return { available: true, state: snapshot(ipc) }
|
||||
|
||||
return { available: false }
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
ssh: query.get("ssh"),
|
||||
storage: query.get("storage"),
|
||||
hold: query.get("hold"),
|
||||
updater: query.get("updater"),
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -288,7 +288,11 @@ export function createModel(ctx: SetupContext<typeof definition>) {
|
||||
|
||||
const command = (session: Session, action: Browser.Action) => {
|
||||
const id = session.key
|
||||
setState("errors", id, undefined)
|
||||
batch(() => {
|
||||
setState("errors", id, undefined)
|
||||
|
||||
if (state.attachments[id]) setState("attachments", id, "error", undefined)
|
||||
})
|
||||
|
||||
// An unreachable pane is suspended, not a failed request.
|
||||
const failed = (cause: unknown) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Portal, render } from "solid-js/web"
|
||||
import type { Bridge, BridgeLayout } from "../sdk/bridge"
|
||||
import {
|
||||
ExtensionContext,
|
||||
Live,
|
||||
PanelContext,
|
||||
type Appearance,
|
||||
type Build,
|
||||
@@ -36,12 +37,13 @@ import {
|
||||
type Servers,
|
||||
type SessionRef,
|
||||
type SessionScreen,
|
||||
type SetupContext,
|
||||
type Storage,
|
||||
type Workspaces,
|
||||
} from "../sdk"
|
||||
import type { InspectEvent } from "./connection"
|
||||
import browserEn from "./i18n/en"
|
||||
import type { Model } from "./model"
|
||||
import type definition from "./index"
|
||||
import { createModel } from "./model"
|
||||
import SessionBrowserPane from "./panel"
|
||||
import { BrowserPane, type PaneEvent } from "./ipc"
|
||||
|
||||
@@ -69,7 +71,7 @@ type PaneFixtureState = {
|
||||
captures: number
|
||||
holdCapture: boolean
|
||||
picker: Record<string, boolean | undefined>
|
||||
highlights: string[]
|
||||
highlights: { sessionKey: string; ref?: Browser.Ref }[]
|
||||
comments: ComposerNote[]
|
||||
}
|
||||
|
||||
@@ -107,9 +109,14 @@ export function mountBrowserPane(input: PaneHost) {
|
||||
|
||||
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
|
||||
const held: (() => void)[] = []
|
||||
// Picker events reach the routed session's listeners only, as the model keys them by session.
|
||||
const inspectors = new Map<string, Set<(event: InspectEvent) => void>>()
|
||||
const emitInspect = (event: InspectEvent) => inspectors.get(store.session)?.forEach((listener) => listener(event))
|
||||
const bindings = new Map<string, string>()
|
||||
const listeners = new Set<(value: { binding: string; event: PaneEvent }) => void>()
|
||||
|
||||
const emit = (key: string, event: PaneEvent) => {
|
||||
const binding = bindings.get(key)
|
||||
|
||||
if (binding) listeners.forEach((listener) => listener({ binding, event }))
|
||||
}
|
||||
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
@@ -162,56 +169,78 @@ export function mountBrowserPane(input: PaneHost) {
|
||||
open: () => [],
|
||||
}
|
||||
|
||||
// One object per session, as the host gives each routed session its own.
|
||||
const server = { id: "browser-test", compatible: true, data: { on: () => () => undefined } }
|
||||
const [reads, setReads] = createStore({ directory: 0 })
|
||||
|
||||
// Frozen session refs, as the real host supplies. Only the key belongs in the pane's transient state.
|
||||
const views = new Map(
|
||||
["Alpha", "Beta", "Empty"].map((key) => {
|
||||
// SAFETY: the pane reads only `key` of its session.
|
||||
const value = Object.freeze({
|
||||
key,
|
||||
id: key,
|
||||
server,
|
||||
pending: false,
|
||||
location: { directory: "/repo" },
|
||||
get directory() {
|
||||
setReads("directory", (count) => count + 1)
|
||||
|
||||
return "/repo"
|
||||
},
|
||||
})
|
||||
|
||||
// SAFETY: the pane reads only `key`; the real model reads these listed fields and `server.data.on`.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
return [key, { key } as unknown as MountedSession] as const
|
||||
return [key, value as unknown as MountedSession] as const
|
||||
}),
|
||||
)
|
||||
|
||||
const session = () => views.get(store.session) ?? views.get("Alpha")
|
||||
|
||||
// The session screen: one object that follows the route, with the composer the pane attaches comments to.
|
||||
// SAFETY: the pane reads only file.search and composer.attach; the model's workspace-link paths are not invoked.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
const screen = {
|
||||
get session() {
|
||||
return session()
|
||||
},
|
||||
file: { search: async () => [] },
|
||||
composer: { attach: (note: ComposerNote) => setStore("comments", (items) => [...items, note]) },
|
||||
} as unknown as SessionScreen
|
||||
|
||||
const report = (key = store.session) => {
|
||||
const tab = tabs.find((item) => item.title === key)
|
||||
|
||||
if (!tab) return
|
||||
emit(key, {
|
||||
type: "state",
|
||||
state: {
|
||||
tabs: [
|
||||
{
|
||||
...tab,
|
||||
url: store.url ?? tab.url,
|
||||
loading: store.loading,
|
||||
generation: store.generation,
|
||||
loadError: store.loadErrors[key],
|
||||
},
|
||||
],
|
||||
focusedTabID: tab.id,
|
||||
},
|
||||
error: store.error,
|
||||
})
|
||||
}
|
||||
|
||||
const fake = { ...base, screen: { current: () => screen } }
|
||||
// Address rejection counters and cleanup run through the real model over this main-process Ipc boundary.
|
||||
const pane: PaneClient = {
|
||||
register: async (value) => {
|
||||
bindings.set(value.session, value.binding)
|
||||
queueMicrotask(() => {
|
||||
report(value.session)
|
||||
const tab = tabs.find((item) => item.title === value.session)
|
||||
|
||||
// SAFETY: the pane reads only `id`, `t`, `keybinds`, `desktop`, `embeds`, and the screen's `file.search` and
|
||||
// `composer.attach`, of its extension's context.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
const extension = fake as unknown as Context
|
||||
|
||||
const fakeModel: Pick<
|
||||
Model,
|
||||
"tab" | "suspended" | "embed" | "error" | "mount" | "load" | "command" | "inspect" | "highlight" | "onInspect"
|
||||
> = {
|
||||
tab: (_session, id) => {
|
||||
const tab = current()
|
||||
|
||||
if (tab.id !== id) return
|
||||
|
||||
return {
|
||||
...tab,
|
||||
url: store.url ?? tab.url,
|
||||
loading: store.loading,
|
||||
generation: store.generation,
|
||||
loadError: store.loadErrors[store.session],
|
||||
}
|
||||
if (tab) emit(value.session, { type: "embed", tabID: tab.id, embed: `embed-${tab.title}` })
|
||||
})
|
||||
},
|
||||
suspended: () => false,
|
||||
embed: (_session, id) => `embed-${tabs.find((tab) => tab.id === id)?.title}`,
|
||||
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
|
||||
mount: () => () => [],
|
||||
load: () => undefined,
|
||||
command: (_session, command) => {
|
||||
load: async () => undefined,
|
||||
command: async (value) => {
|
||||
const command = value.command
|
||||
|
||||
setStore("error", undefined)
|
||||
|
||||
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
|
||||
@@ -227,28 +256,52 @@ export function mountBrowserPane(input: PaneHost) {
|
||||
}
|
||||
|
||||
if (command.type === "stop") setStore("loading", false)
|
||||
report()
|
||||
},
|
||||
// The desktop confirms each picker change, as the page does once inspect mode is armed.
|
||||
inspect: (_session, tabID, enabled) => {
|
||||
setStore("picker", store.session, enabled)
|
||||
emitInspect({ type: "inspect", tabID, active: enabled })
|
||||
inspect: async (value) => {
|
||||
const key = Array.from(bindings).find((item) => item[1] === value.binding)?.[0]
|
||||
|
||||
if (!key) throw new Error("Unknown picker binding")
|
||||
setStore("picker", key, value.enabled)
|
||||
emit(key, { type: "inspect", tabID: value.tabID, active: value.enabled })
|
||||
},
|
||||
highlight: (_session, _tabID, ref) => setStore("highlights", (items) => [...items, ref ?? "clear"]),
|
||||
onInspect: (session, listener) => {
|
||||
const set = inspectors.get(session.key) ?? new Set()
|
||||
highlight: async (value) => {
|
||||
const key = Array.from(bindings).find((item) => item[1] === value.binding)?.[0]
|
||||
|
||||
set.add(listener)
|
||||
inspectors.set(session.key, set)
|
||||
if (!key) throw new Error("Unknown highlight binding")
|
||||
setStore("highlights", (items) => [...items, { sessionKey: key, ref: value.ref }])
|
||||
},
|
||||
close: async () => undefined,
|
||||
state: () => undefined,
|
||||
on: (_name, listener) => {
|
||||
// SAFETY: the pane's Ipc has one event, whose payload is exactly this binding and PaneEvent.
|
||||
const added = listener as (value: { binding: string; event: PaneEvent }) => void
|
||||
listeners.add(added)
|
||||
|
||||
return () => set.delete(listener)
|
||||
return () => void listeners.delete(added)
|
||||
},
|
||||
}
|
||||
|
||||
// SAFETY: these are every member of its model the pane reads (`props.model.*` in `./panel`).
|
||||
const model = fakeModel as Model
|
||||
const live = Live.accessor(() => ({ status: "active", generation: 1, value: pane }))
|
||||
|
||||
const pick = () =>
|
||||
emitInspect({
|
||||
const fake = {
|
||||
...base,
|
||||
uses: { pane: { load: () => live } },
|
||||
sessions: { current: session, list: () => Array.from(views.values()).filter((view) => view.key !== "Empty") },
|
||||
screen: { current: () => screen },
|
||||
layout: { narrow: () => false, stored: () => [], state: () => "visible", open() {}, close() {} },
|
||||
links: { open() {} },
|
||||
}
|
||||
|
||||
// SAFETY: the real model and pane use only the host boundaries implemented above in this fixture's flows.
|
||||
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
|
||||
const extension = fake as unknown as Context & SetupContext<typeof definition>
|
||||
const model = createModel(extension)
|
||||
|
||||
const pick = () => {
|
||||
setStore("picker", store.session, false)
|
||||
emit(store.session, {
|
||||
type: "inspect",
|
||||
tabID: current().id,
|
||||
active: false,
|
||||
@@ -261,6 +314,7 @@ export function mountBrowserPane(input: PaneHost) {
|
||||
rect: { x: 48, y: 40, width: 160, height: 36 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ExtensionContext.Provider value={extension}>
|
||||
@@ -271,33 +325,61 @@ export function mountBrowserPane(input: PaneHost) {
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
setStore({ url: "about:blank", loading: false })
|
||||
report()
|
||||
}}
|
||||
>
|
||||
Blank page
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStore({ url: "about:blank", loading: true })
|
||||
report()
|
||||
}}
|
||||
>
|
||||
Loading page
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStore({
|
||||
loading: true,
|
||||
generation: store.generation + 1,
|
||||
loadErrors: { [store.session]: undefined },
|
||||
})
|
||||
}
|
||||
report()
|
||||
}}
|
||||
>
|
||||
Load current page
|
||||
</button>
|
||||
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")
|
||||
report()
|
||||
}}
|
||||
>
|
||||
Failed page
|
||||
</button>
|
||||
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
|
||||
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })
|
||||
report()
|
||||
}}
|
||||
>
|
||||
Block navigation
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
onClick={() => {
|
||||
setStore({
|
||||
url: store.pendingURL,
|
||||
pendingURL: undefined,
|
||||
loading: false,
|
||||
generation: store.generation + 1,
|
||||
})
|
||||
}
|
||||
report()
|
||||
}}
|
||||
>
|
||||
Complete navigation
|
||||
</button>
|
||||
@@ -308,11 +390,16 @@ export function mountBrowserPane(input: PaneHost) {
|
||||
<button onClick={pick}>Pick element</button>
|
||||
</nav>
|
||||
<p>Captures: {store.captures}</p>
|
||||
<p>Session getter reads: {reads.directory}</p>
|
||||
<p>Picker: {store.picker[store.session] ? "on" : "off"}</p>
|
||||
<p>Highlights: {store.highlights.join(",")}</p>
|
||||
<For each={tabs}>
|
||||
{(tab) => <p>Picker {tab.title}: {store.picker[tab.title] ? "on" : "off"}</p>}
|
||||
</For>
|
||||
<p>Highlights: {store.highlights.map((item) => item.ref ?? "clear").join(",")}</p>
|
||||
<p>Highlight owners: {store.highlights.map((item) => item.sessionKey).join(",")}</p>
|
||||
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted && session()}>
|
||||
{(view) => <SessionBrowserPane tab={() => current()} session={view()} model={model} />}
|
||||
{(view) => <SessionBrowserPane tab={() => current()} session={view()} screen={screen} model={model} />}
|
||||
</Show>
|
||||
</div>
|
||||
<ul data-testid="fixture-comments">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMemo, For, on, onCleanup, Show, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession } from "../sdk"
|
||||
import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession, type SessionScreen } from "../sdk"
|
||||
import { commentNote } from "./comment"
|
||||
import type { Model } from "./model"
|
||||
import type { PaneElement } from "./ipc"
|
||||
@@ -24,10 +24,11 @@ type PaneState = {
|
||||
/** The movement count at a submit; the next reported movement ends the submitted navigation. */
|
||||
navigating: number | undefined
|
||||
/** The tab whose element picker is on. */
|
||||
picking: Browser.TabID | undefined
|
||||
picking: { sessionKey: string; tabID: Browser.TabID } | undefined
|
||||
/** A picked element awaiting its comment. The page stays frozen as a still until it closes. */
|
||||
comment:
|
||||
| {
|
||||
sessionKey: string
|
||||
tabID: Browser.TabID
|
||||
url: string
|
||||
/** The tab's navigation count at the pick; the element's ref dies when it changes. */
|
||||
@@ -40,7 +41,12 @@ type PaneState = {
|
||||
editorHeight: number
|
||||
}
|
||||
|
||||
export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; session: MountedSession; model: Model }) {
|
||||
export default function SessionBrowserPane(props: {
|
||||
tab: Accessor<PanelTab>
|
||||
session: MountedSession
|
||||
screen: SessionScreen
|
||||
model: Model
|
||||
}) {
|
||||
const extension = useExtension()
|
||||
const keybinds = extension.keybinds
|
||||
const desktop = extension.desktop
|
||||
@@ -122,20 +128,20 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
}
|
||||
|
||||
const inspectable = () => !!address() && !failed() && !suspended()
|
||||
const picking = () => !!state() && store.picking === state()?.id
|
||||
const picking = () => store.picking?.sessionKey === props.session.key && store.picking?.tabID === state()?.id
|
||||
// A comment on a picked element freezes the page so its editor can float above it.
|
||||
const commenting = () => !!store.comment && store.comment.tabID === state()?.id
|
||||
const commenting = () => store.comment?.sessionKey === props.session.key && store.comment?.tabID === state()?.id
|
||||
|
||||
const setPicking = (tabID: Browser.TabID, enabled: boolean) => {
|
||||
props.model.inspect(props.session, tabID, enabled)
|
||||
setStore("picking", enabled ? tabID : undefined)
|
||||
const setPicking = (current: NonNullable<PaneState["picking"]>, enabled: boolean) => {
|
||||
props.model.inspect({ key: current.sessionKey }, current.tabID, enabled)
|
||||
setStore("picking", enabled ? current : undefined)
|
||||
}
|
||||
|
||||
const closeComment = () => {
|
||||
const current = store.comment
|
||||
|
||||
if (!current) return
|
||||
props.model.highlight(props.session, current.tabID)
|
||||
props.model.highlight({ key: current.sessionKey }, current.tabID)
|
||||
setStore("comment", undefined)
|
||||
}
|
||||
|
||||
@@ -145,7 +151,7 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
if (!tab || !inspectable()) return
|
||||
|
||||
if (store.comment) closeComment()
|
||||
setPicking(tab.id, !picking())
|
||||
setPicking({ sessionKey: props.session.key, tabID: tab.id }, !picking())
|
||||
}
|
||||
|
||||
const submitComment = (value: string) => {
|
||||
@@ -153,10 +159,13 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
|
||||
if (!current) return
|
||||
const tab = state()
|
||||
|
||||
// The draft outlives a reload or agent navigation, but the ref no longer names anything.
|
||||
const live = tab?.id === current.tabID && tab.generation === current.generation
|
||||
// The screen's composer, read when the user submits: it serves the session the pane shows.
|
||||
extension.screen.current()?.composer.attach(
|
||||
const live =
|
||||
props.session.key === current.sessionKey && tab?.id === current.tabID && tab.generation === current.generation
|
||||
|
||||
// The owning screen's composer serves the session the pane shows.
|
||||
props.screen.composer.attach(
|
||||
commentNote({
|
||||
origin: extension.id,
|
||||
tabID: current.tabID,
|
||||
@@ -230,12 +239,13 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
onCleanup(
|
||||
props.model.onInspect(session, (event) => {
|
||||
if (event.active) {
|
||||
setStore("picking", event.tabID)
|
||||
setStore("picking", { sessionKey: session.key, tabID: event.tabID })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (store.picking === event.tabID) setStore("picking", undefined)
|
||||
if (store.picking?.sessionKey === session.key && store.picking.tabID === event.tabID)
|
||||
setStore("picking", undefined)
|
||||
|
||||
if (!event.element) return
|
||||
const tab = state()
|
||||
@@ -247,6 +257,7 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
}
|
||||
|
||||
setStore("comment", {
|
||||
sessionKey: session.key,
|
||||
tabID: tab.id,
|
||||
url: tab.url,
|
||||
generation: tab.generation,
|
||||
@@ -260,20 +271,31 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
// A picker or comment belongs to the page on screen: the page's picker stops when its tab is switched away or the
|
||||
// pane hides, and a comment closes with its tab.
|
||||
const endPicker = () => {
|
||||
const tabID = store.picking
|
||||
const current = store.picking
|
||||
|
||||
if (tabID && (tabID !== state()?.id || !visible())) setPicking(tabID, false)
|
||||
if (current && (current.sessionKey !== props.session.key || current.tabID !== state()?.id || !visible()))
|
||||
setPicking(current, false)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (store.picking) setPicking(store.picking, false)
|
||||
closeComment()
|
||||
})
|
||||
|
||||
createKeyed(visible, endPicker, { otherwise: endPicker })
|
||||
createKeyed(
|
||||
() => state()?.id,
|
||||
(id) => {
|
||||
() => {
|
||||
const tab = state()
|
||||
|
||||
return tab ? { session: props.session.key, tabID: tab.id } : undefined
|
||||
},
|
||||
(current) => {
|
||||
endPicker()
|
||||
|
||||
if (store.comment?.tabID !== id) closeComment()
|
||||
if (store.comment?.sessionKey !== current.session || store.comment.tabID !== current.tabID) closeComment()
|
||||
},
|
||||
{
|
||||
equals: (previous, next) => previous.session === next.session && previous.tabID === next.tabID,
|
||||
otherwise: () => {
|
||||
endPicker()
|
||||
closeComment()
|
||||
@@ -384,7 +406,7 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
data-action="browser-inspect"
|
||||
disabled={!inspectable()}
|
||||
state={picking() ? "pressed" : undefined}
|
||||
classList={{ "!text-v2-icon-icon-accent": picking() || !!store.comment }}
|
||||
classList={{ "!text-v2-icon-icon-accent": picking() || commenting() }}
|
||||
aria-pressed={picking()}
|
||||
aria-label={extension.t("inspect")}
|
||||
onClick={toggleInspect}
|
||||
@@ -530,7 +552,7 @@ export default function SessionBrowserPane(props: { tab: Accessor<PanelTab>; ses
|
||||
onSubmit={submitComment}
|
||||
mention={{
|
||||
items: (query) =>
|
||||
extension.screen.current()?.file.search(query, { kind: "any" }) ?? Promise.resolve([]),
|
||||
props.screen.file.search(query, { kind: "any" }),
|
||||
}}
|
||||
selection={
|
||||
<span class="flex min-w-0 items-center gap-1" dir="ltr">
|
||||
|
||||
@@ -170,7 +170,7 @@ const setup: Setup<typeof definition> = (ctx) => {
|
||||
{(value) => (
|
||||
<Show when={!value().pending(props.session)}>
|
||||
<Suspense>
|
||||
<SessionBrowserPane tab={() => props.tab} session={props.session} model={value()} />
|
||||
<SessionBrowserPane tab={() => props.tab} session={props.session} screen={props.screen} model={value()} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ export type DetailsPanelProps = {
|
||||
export default function SessionDetailsPanel(props: DetailsPanelProps) {
|
||||
const ctx = useExtension()
|
||||
const locale = ctx.locale
|
||||
const data = props.session.server.data
|
||||
const data = () => props.session.server.data
|
||||
|
||||
const placement = createMemo(() =>
|
||||
props.mobile ? "top-end" : locale.direction() === "rtl" ? "right-start" : "left-start",
|
||||
@@ -48,8 +48,8 @@ export default function SessionDetailsPanel(props: DetailsPanelProps) {
|
||||
return getFilename(workspace ?? props.session.directory)
|
||||
}
|
||||
|
||||
const branch = () => data.location.vcs.info({ directory: props.session.directory })?.branch.current
|
||||
const baseBranch = () => data.location.vcs.info({ directory: props.project.worktree })?.branch.current
|
||||
const branch = () => data().location.vcs.info({ directory: props.session.directory })?.branch.current
|
||||
const baseBranch = () => data().location.vcs.info({ directory: props.project.worktree })?.branch.current
|
||||
|
||||
return (
|
||||
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
|
||||
|
||||
@@ -112,35 +112,35 @@ function ServiceMenu(props: ServiceMenuProps) {
|
||||
|
||||
function LspMenu(props: ServiceMenuProps) {
|
||||
const ctx = useExtension()
|
||||
const data = props.session.server.data
|
||||
const data = () => props.session.server.data
|
||||
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
(directory) => {
|
||||
data.location.config.invalidate({ directory })
|
||||
() => props.shown && data(),
|
||||
(current) => {
|
||||
const directory = props.directory
|
||||
|
||||
return data.location.config.sync({ directory })
|
||||
current.location.config.invalidate({ directory })
|
||||
|
||||
return current.location.config.sync({ directory })
|
||||
},
|
||||
)
|
||||
|
||||
const names = createMemo(() => configuredLsps(data.location.config.list({ directory: props.directory }) ?? []))
|
||||
const names = createMemo(() => configuredLsps(data().location.config.list({ directory: props.directory }) ?? []))
|
||||
|
||||
createKeyed(
|
||||
() => props.directory,
|
||||
(directory) =>
|
||||
onCleanup(
|
||||
data.on("config.updated", (event) => {
|
||||
if (event.location?.directory !== directory) return
|
||||
void refetch()
|
||||
}),
|
||||
),
|
||||
createKeyed(data, (current) =>
|
||||
onCleanup(
|
||||
current.on("config.updated", (event) => {
|
||||
if (event.location?.directory !== props.directory) return
|
||||
void refetch()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={data.location.config.list({ directory: props.directory }) !== undefined}
|
||||
ready={data().location.config.list({ directory: props.directory }) !== undefined}
|
||||
empty={names().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
@@ -172,11 +172,12 @@ function LspMenu(props: ServiceMenuProps) {
|
||||
function McpMenu(props: ServiceMenuProps) {
|
||||
const ctx = useExtension()
|
||||
const system = ctx.system
|
||||
const data = props.session.server.data
|
||||
const data = () => props.session.server.data
|
||||
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (input: { name: string; enabled: boolean }) => {
|
||||
const client = props.session.server.client
|
||||
const source = data()
|
||||
const ref = { directory: props.directory }
|
||||
const server = (await client.mcp.list({ location: ref })).data.find((item) => item.name === input.name)
|
||||
|
||||
@@ -190,9 +191,9 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
await client.mcp.connect({ server: input.name, location: ref })
|
||||
}
|
||||
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
await data.location.mcp.server.sync(ref)
|
||||
const current = data.location.mcp.server.list(ref)?.find((item) => item.name === input.name)
|
||||
source.location.mcp.server.invalidate(ref)
|
||||
await source.location.mcp.server.sync(ref)
|
||||
const current = source.location.mcp.server.list(ref)?.find((item) => item.name === input.name)
|
||||
|
||||
if (input.enabled && current?.status.status === "needs_auth" && current.integrationID) {
|
||||
const integration = await client.integration.get({ integrationID: current.integrationID, location: ref })
|
||||
@@ -209,8 +210,8 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
system.openExternal(attempt.data.url)
|
||||
}
|
||||
|
||||
data.location.mcp.resource.invalidate(ref)
|
||||
await data.location.mcp.resource.sync(ref)
|
||||
source.location.mcp.resource.invalidate(ref)
|
||||
await source.location.mcp.resource.sync(ref)
|
||||
// A successful HTTP response can still leave the MCP connection in a failed state.
|
||||
const status = current?.status
|
||||
|
||||
@@ -225,15 +226,17 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
}))
|
||||
|
||||
const [load, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
async (directory) => {
|
||||
data.location.mcp.server.invalidate({ directory })
|
||||
await data.location.mcp.server.sync({ directory })
|
||||
() => props.shown && data(),
|
||||
async (current) => {
|
||||
const directory = props.directory
|
||||
|
||||
current.location.mcp.server.invalidate({ directory })
|
||||
await current.location.mcp.server.sync({ directory })
|
||||
},
|
||||
)
|
||||
|
||||
const servers = createMemo(() =>
|
||||
(data.location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
|
||||
(data().location.mcp.server.list({ directory: props.directory }) ?? []).toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
)
|
||||
@@ -242,7 +245,7 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
<ServicePopover
|
||||
{...props}
|
||||
loading={load.loading}
|
||||
ready={data.location.mcp.server.list({ directory: props.directory }) !== undefined}
|
||||
ready={data().location.mcp.server.list({ directory: props.directory }) !== undefined}
|
||||
empty={servers().length === 0}
|
||||
error={load.error}
|
||||
retry={refetch}
|
||||
@@ -334,11 +337,13 @@ function McpMenu(props: ServiceMenuProps) {
|
||||
|
||||
function ServiceCatalog(props: ServiceMenuProps) {
|
||||
const ctx = useExtension()
|
||||
const data = props.session.server.data
|
||||
const data = () => props.session.server.data
|
||||
|
||||
const [items, { refetch }] = createResource(
|
||||
() => props.shown && props.directory,
|
||||
async (directory) => {
|
||||
() => props.shown && data(),
|
||||
async (current) => {
|
||||
const directory = props.directory
|
||||
|
||||
if (props.service.type === "plugins") {
|
||||
const result = await props.session.server.client.plugin.list({ location: { directory } })
|
||||
|
||||
@@ -351,8 +356,8 @@ function ServiceCatalog(props: ServiceMenuProps) {
|
||||
}))
|
||||
}
|
||||
|
||||
data.location.skill.invalidate({ directory })
|
||||
await data.location.skill.sync({ directory })
|
||||
current.location.skill.invalidate({ directory })
|
||||
await current.location.skill.sync({ directory })
|
||||
|
||||
return undefined
|
||||
},
|
||||
@@ -366,7 +371,7 @@ function ServiceCatalog(props: ServiceMenuProps) {
|
||||
? loaded()
|
||||
? (items.latest ?? [])
|
||||
: []
|
||||
: (data.location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({
|
||||
: (data().location.skill.list({ directory: props.directory }) ?? []).map((skill) => ({
|
||||
name: skill.name,
|
||||
status: "active",
|
||||
error: undefined,
|
||||
@@ -375,15 +380,13 @@ function ServiceCatalog(props: ServiceMenuProps) {
|
||||
return entries.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
createKeyed(
|
||||
() => props.directory,
|
||||
(directory) =>
|
||||
onCleanup(
|
||||
data.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", (event) => {
|
||||
if (event.location?.directory !== directory) return
|
||||
void refetch()
|
||||
}),
|
||||
),
|
||||
createKeyed(data, (current) =>
|
||||
onCleanup(
|
||||
current.on(props.service.type === "plugins" ? "plugin.updated" : "skill.updated", (event) => {
|
||||
if (event.location?.directory !== props.directory) return
|
||||
void refetch()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -393,7 +396,7 @@ function ServiceCatalog(props: ServiceMenuProps) {
|
||||
ready={
|
||||
props.service.type === "plugins"
|
||||
? loaded()
|
||||
: data.location.skill.list({ directory: props.directory }) !== undefined
|
||||
: data().location.skill.list({ directory: props.directory }) !== undefined
|
||||
}
|
||||
empty={list().length === 0}
|
||||
error={items.error}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { showToast } from "@opencode/ui/toast"
|
||||
import { comparablePath, containsDirectory, getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For, onCleanup, Show, type ComponentProps, type JSX } from "solid-js"
|
||||
import { useExtension, type Project, type MountedSession } from "../sdk"
|
||||
import { createKeyed, useExtension, type Project, type MountedSession } from "../sdk"
|
||||
import { workspaceDirectories } from "./paths"
|
||||
|
||||
export function SessionWorkspaceMenu(props: {
|
||||
@@ -16,14 +16,14 @@ export function SessionWorkspaceMenu(props: {
|
||||
children: JSX.Element
|
||||
}) {
|
||||
const ctx = useExtension()
|
||||
const data = props.session.server.data
|
||||
const data = () => props.session.server.data
|
||||
|
||||
const [store, setStore] = createStore<{ selected: string | undefined; directories: string[] }>({
|
||||
selected: undefined,
|
||||
directories: workspaceDirectories(props.project),
|
||||
})
|
||||
|
||||
const blocked = () => data.session.status(props.session.id) === "running"
|
||||
const blocked = () => data().session.status(props.session.id) === "running"
|
||||
const currentWorkspace = () => store.directories.find((workspace) => containsDirectory(workspace, props.directory))
|
||||
|
||||
const workspaces = () =>
|
||||
@@ -37,14 +37,16 @@ export function SessionWorkspaceMenu(props: {
|
||||
items.flatMap((item) => (sameDirectory(props.project.worktree, item.directory) ? [] : [item.directory])),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
data.on("worktree.updated", (event) => {
|
||||
if (event.data.projectID !== props.project.id) return
|
||||
void props.session.server.client.worktree
|
||||
.list({ projectID: props.project.id })
|
||||
.then(update)
|
||||
.catch(() => undefined)
|
||||
}),
|
||||
createKeyed(data, (current) =>
|
||||
onCleanup(
|
||||
current.on("worktree.updated", (event) => {
|
||||
if (event.data.projectID !== props.project.id) return
|
||||
void props.session.server.client.worktree
|
||||
.list({ projectID: props.project.id })
|
||||
.then(update)
|
||||
.catch(() => undefined)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
@@ -59,11 +61,11 @@ export function SessionWorkspaceMenu(props: {
|
||||
|
||||
const createWorktree = async (client: MountedSession["server"]["client"], directory: string) => {
|
||||
const project =
|
||||
data.location.info({ directory })?.project ?? (await client.location.get({ location: { directory } })).project
|
||||
data().location.info({ directory })?.project ?? (await client.location.get({ location: { directory } })).project
|
||||
|
||||
const created = await client.worktree.create({ projectID: project.id, from: project.canonical })
|
||||
// Populate the client cache before the destination session mounts.
|
||||
await data.location.syncInfo({ directory: created.directory })
|
||||
await data().location.syncInfo({ directory: created.directory })
|
||||
|
||||
return created.directory
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@ export function createReviewModel(input: {
|
||||
const directory = () => screen.file.root
|
||||
|
||||
// The filter is transient by design: a persisted filter would silently hide files after a reload.
|
||||
const [state, setState] = createStore({ filter: "", initializingGit: false })
|
||||
const [state, setState] = createStore<{ filter: string; initializingGit: Record<string, true | undefined> }>({
|
||||
filter: "",
|
||||
initializingGit: {},
|
||||
})
|
||||
|
||||
// The review panel's scroller and the file it scrolls to belong to the session the view shows.
|
||||
const [scroll, setScroll] = createVisitState<HTMLDivElement | undefined>(undefined)
|
||||
@@ -259,12 +262,14 @@ export function createReviewModel(input: {
|
||||
lifetime.disposed = true
|
||||
})
|
||||
|
||||
// Initializes Git for the routed session. The request keeps that session's object, so its server and location stay
|
||||
// its own; the outcome applies only while the screen still routes it.
|
||||
const initializeGit = () => {
|
||||
if (state.initializingGit) return
|
||||
const alive = () => !lifetime.disposed && !ctx.signal.aborted
|
||||
|
||||
// Initializes Git for the routed session. The request keeps that session's object, so its server and location stay
|
||||
// its own. Cache refresh follows the request's session; only error feedback depends on which session is routed.
|
||||
const initializeGit = () => {
|
||||
const session = view()
|
||||
|
||||
if (state.initializingGit[session.key]) return
|
||||
const location = session.location
|
||||
|
||||
if (!location || !session.server.connected) {
|
||||
@@ -273,13 +278,13 @@ export function createReviewModel(input: {
|
||||
return
|
||||
}
|
||||
|
||||
const current = () => !lifetime.disposed && !ctx.signal.aborted && view().key === session.key
|
||||
const current = () => alive() && view().key === session.key
|
||||
|
||||
setState("initializingGit", true)
|
||||
setState("initializingGit", session.key, true)
|
||||
void session.server.client.vcs
|
||||
.init({ location, provider: "git" })
|
||||
.then(async () => {
|
||||
if (!current()) return
|
||||
if (!alive()) return
|
||||
|
||||
const data = session.server.data
|
||||
|
||||
@@ -288,7 +293,11 @@ export function createReviewModel(input: {
|
||||
data.location.invalidate(location)
|
||||
data.location.vcs.invalidate(location)
|
||||
await data.project.sync()
|
||||
|
||||
if (!alive()) return
|
||||
await data.session.sync(session.id)
|
||||
|
||||
if (!alive()) return
|
||||
await Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)])
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -301,7 +310,7 @@ export function createReviewModel(input: {
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (current()) setState("initializingGit", false)
|
||||
if (alive()) setState("initializingGit", session.key, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -574,7 +583,7 @@ export function createReviewModel(input: {
|
||||
focusFile,
|
||||
hasChanges,
|
||||
initializeGit,
|
||||
initializingGit: () => state.initializingGit,
|
||||
initializingGit: () => !!state.initializingGit[view().key],
|
||||
loadDiff,
|
||||
mode,
|
||||
noGit: createMemo(() => {
|
||||
|
||||
@@ -60,21 +60,22 @@ export default function TerminalPanel(props: { model: TerminalModel; session: Mo
|
||||
})
|
||||
})
|
||||
|
||||
// While the dock shows, an empty workspace gets its first terminal, once per workspace in a row.
|
||||
// While the dock shows, a workspace first observed empty gets its first terminal, once per workspace in a row.
|
||||
createKeyed(opened, () => {
|
||||
const created = { workspace: "" }
|
||||
const seen = { workspace: "" }
|
||||
|
||||
createKeyed(
|
||||
() => {
|
||||
const workspace = terminal()
|
||||
|
||||
return workspace.ready() && workspace.all().length === 0 ? workspace : undefined
|
||||
return workspace.ready() ? workspace : undefined
|
||||
},
|
||||
(workspace) => {
|
||||
if (created.workspace === workspace.key) return
|
||||
if (seen.workspace === workspace.key) return
|
||||
|
||||
workspace.new()
|
||||
created.workspace = workspace.key
|
||||
seen.workspace = workspace.key
|
||||
|
||||
if (workspace.all().length === 0) workspace.new()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,13 +12,7 @@ type Context = SetupContext<typeof definition>
|
||||
|
||||
/** Restarts into the staged update. A beta build moving to stable confirms the installer download first. */
|
||||
export function install(ctx: Context, client: Client) {
|
||||
const download = () =>
|
||||
client.install().catch((cause: unknown) => {
|
||||
showToast({
|
||||
title: ctx.t("common.requestFailed"),
|
||||
description: cause instanceof Error && cause.message ? cause.message : ctx.t("common.requestFailed"),
|
||||
})
|
||||
})
|
||||
const download = () => client.install().catch((cause: unknown) => requestFailed(ctx, cause))
|
||||
|
||||
const state = client.state()
|
||||
|
||||
@@ -35,7 +29,11 @@ export function install(ctx: Context, client: Client) {
|
||||
}
|
||||
|
||||
export async function check(ctx: Context, client: Client) {
|
||||
const state = await client.check()
|
||||
const state = await client
|
||||
.check({ signal: ctx.signal })
|
||||
.catch((cause: unknown) => requestFailed(ctx, cause))
|
||||
|
||||
if (!state || ctx.signal.aborted) return
|
||||
|
||||
if (state.status === "download-required") {
|
||||
install(ctx, client)
|
||||
@@ -57,6 +55,15 @@ export async function check(ctx: Context, client: Client) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestFailed(ctx: Context, cause: unknown) {
|
||||
if (ctx.signal.aborted) return
|
||||
|
||||
showToast({
|
||||
title: ctx.t("common.requestFailed"),
|
||||
description: cause instanceof Error && cause.message ? cause.message : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function DialogStableDownload(props: {
|
||||
ctx: Context
|
||||
dialog: DialogHandle
|
||||
|
||||
Reference in New Issue
Block a user