diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 245be7d05ac..29740a8f947 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -9,6 +9,7 @@ import type { FormField, FormFields, FormValue, + LocationRef, } from "@opencode-ai/client" import open from "open" import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" @@ -16,6 +17,7 @@ import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" +import { useLocation } from "../context/location" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { DialogPrompt } from "../ui/dialog-prompt" @@ -74,10 +76,12 @@ export function DialogIntegration( props: { onConnected?: OnIntegrationConnected; integrationID?: string; autoConnect?: boolean } = {}, ) { const data = useData() + const currentLocation = useLocation() const dialog = useDialog() const theme = useTheme("elevated") + const location = currentLocation.ref ?? data.location.default() const integrations = createMemo(() => - integrationOptions(data.location.integration.list() ?? []).filter( + integrationOptions(data.location.integration.list(location) ?? []).filter( (integration) => props.integrationID === undefined || integration.id === props.integrationID, ), ) @@ -88,14 +92,14 @@ export function DialogIntegration( if (!integration) return const methods = connectMethods(integration) if (credentialConnections(integration).length) { - manageConnections(integration, methods, dialog, props.onConnected) + manageConnections(integration, methods, location, dialog, props.onConnected) return } - selectMethod(integration, methods, dialog, props.onConnected) + selectMethod(integration, methods, location, dialog, props.onConnected) }) const options = createMemo(() => { - const providers = data.location.websearch.list() ?? [] + const providers = data.location.websearch.list(location) ?? [] const providersByID = new Map(providers.map((provider) => [provider.id, provider])) return integrations().map((integration) => { const methods = connectMethods(integration) @@ -117,8 +121,8 @@ export function DialogIntegration( ? () => : undefined, onSelect: () => { - if (credentials.length) return manageConnections(integration, methods, dialog, props.onConnected) - return selectMethod(integration, methods, dialog, props.onConnected) + if (credentials.length) return manageConnections(integration, methods, location, dialog, props.onConnected) + return selectMethod(integration, methods, location, dialog, props.onConnected) }, } }) @@ -145,6 +149,7 @@ export function DialogIntegration( function manageConnections( integration: IntegrationInfo, methods: ConnectMethod[], + location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { @@ -156,7 +161,9 @@ function manageConnections( const shortcuts = Keymap.useShortcuts() const [deleting, setDeleting] = createSignal() const [selected, setSelected] = createSignal(methods.length ? "add" : credentialConnections(integration)[0]?.id) - const current = createMemo(() => data.location.integration.list()?.find((item) => item.id === integration.id)) + const current = createMemo(() => + data.location.integration.list(location)?.find((item) => item.id === integration.id), + ) return ( selectMethod(current() ?? integration, methods, dialog, onConnected), + onSelect: () => selectMethod(current() ?? integration, methods, location, dialog, onConnected), }, ] : []), @@ -193,7 +200,7 @@ function manageConnections( onSelect: () => { if (credentialConnections(current() ?? integration)[0]?.id === connection.id) return void client.api.credential - .activate({ credentialID: connection.id, location: location(data) }) + .activate({ credentialID: connection.id, location: locationQuery(location) }) .catch(toast.error) }, } @@ -217,8 +224,8 @@ function manageConnections( const label = value.trim() if (!label) return void client.api.credential - .update({ credentialID: option.value, label, location: location(data) }) - .then(() => manageConnections(integration, methods, dialog, onConnected)) + .update({ credentialID: option.value, label, location: locationQuery(location) }) + .then(() => manageConnections(integration, methods, location, dialog, onConnected)) .catch(toast.error) }} /> @@ -234,7 +241,7 @@ function manageConnections( if (deleting() !== option.value) return setDeleting(option.value) const final = credentialConnections(current() ?? integration).length === 1 void client.api.credential - .remove({ credentialID: option.value, location: location(data) }) + .remove({ credentialID: option.value, location: locationQuery(location) }) .then(() => { setDeleting(undefined) if (!final) return @@ -256,17 +263,18 @@ function manageConnections( function selectMethod( integration: IntegrationInfo, methods: ConnectMethod[], + location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { - if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected) + if (methods.length === 1) return openMethod(integration, methods[0], location, dialog, onConnected) dialog.replace(() => ( ({ title: method.type === "key" ? (method.label ?? "API key") : method.label, value: method.type === "key" ? "key" : method.id, - onSelect: () => openMethod(integration, method, dialog, onConnected), + onSelect: () => openMethod(integration, method, location, dialog, onConnected), }))} /> )) @@ -275,23 +283,27 @@ function selectMethod( function openMethod( integration: IntegrationInfo, method: ConnectMethod, + location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { if (method.type === "key") { - void beginKey(integration, method, dialog, onConnected) + void beginKey(integration, method, location, dialog, onConnected) return } if (method.type === "command") { - dialog.replace(() => ) + dialog.replace(() => ( + + )) return } - void beginOAuth(integration, method, dialog, onConnected) + void beginOAuth(integration, method, location, dialog, onConnected) } async function beginKey( integration: IntegrationInfo, method: Extract, + location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { @@ -300,16 +312,16 @@ async function beginKey( : undefined if (answer === null) return dialog.replace(() => ( - + )) } function CommandStarting(props: { integration: IntegrationInfo method: Extract + location: LocationRef onConnected?: OnIntegrationConnected }) { - const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() @@ -321,14 +333,14 @@ function CommandStarting(props: { .connect({ integrationID: props.integration.id, methodID: props.method.id, - location: location(data), + location: locationQuery(props.location), }) .then((result) => { if (closed) { void client.api.integration.command.cancel({ integrationID: props.integration.id, attemptID: result.data.attemptID, - location: location(data), + location: locationQuery(props.location), }) return } @@ -338,6 +350,7 @@ function CommandStarting(props: { integration={props.integration} title={props.method.label} attempt={result.data} + location={props.location} onConnected={props.onConnected} /> )) @@ -359,6 +372,7 @@ function CommandPending(props: { integration: IntegrationInfo title: string attempt: CommandAttempt + location: LocationRef onConnected?: OnIntegrationConnected }) { const data = useData() @@ -374,7 +388,7 @@ function CommandPending(props: { .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, - location: location(data), + location: locationQuery(props.location), }) .then((result) => { const status = result.data @@ -385,7 +399,7 @@ function CommandPending(props: { } settled = true if (status.status === "complete") { - void connected(props.integration, data, dialog, toast, props.onConnected) + void connected(props.integration, props.location, data, dialog, toast, props.onConnected) return } toast.show({ @@ -408,7 +422,7 @@ function CommandPending(props: { void client.api.integration.command.cancel({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, - location: location(data), + location: locationQuery(props.location), }) }) @@ -449,6 +463,7 @@ function CommandView(props: { title: string; output: string; message: string }) function KeyMethod(props: { integration: IntegrationInfo method: Extract + location: LocationRef answer?: FormAnswer onConnected?: OnIntegrationConnected }) { @@ -468,11 +483,11 @@ function KeyMethod(props: { void client.api.integration.connect .key({ integrationID: props.integration.id, - location: location(data), + location: locationQuery(props.location), key, ...(props.answer ? { answer: props.answer } : {}), }) - .then(() => connected(props.integration, data, dialog, toast, props.onConnected)) + .then(() => connected(props.integration, props.location, data, dialog, toast, props.onConnected)) .catch((cause) => setError(message(cause))) }} description={() => ( @@ -485,23 +500,30 @@ function KeyMethod(props: { async function beginOAuth( integration: IntegrationInfo, method: IntegrationOAuthMethod, + location: LocationRef, dialog: ReturnType, onConnected?: OnIntegrationConnected, ) { const answer = method.form ? await formAnswer(dialog, method.label, method.form) : undefined if (answer === null) return dialog.replace(() => ( - + )) } function OAuthStarting(props: { integration: IntegrationInfo method: IntegrationOAuthMethod + location: LocationRef answer?: FormAnswer onConnected?: OnIntegrationConnected }) { - const data = useData() const dialog = useDialog() const client = useClient() const toast = useToast() @@ -510,7 +532,7 @@ function OAuthStarting(props: { void client.api.integration.oauth .connect({ integrationID: props.integration.id, - location: location(data), + location: locationQuery(props.location), methodID: props.method.id, ...(props.answer ? { answer: props.answer } : {}), }) @@ -521,6 +543,7 @@ function OAuthStarting(props: { integration={props.integration} title={props.method.label} attempt={result.data} + location={props.location} onConnected={props.onConnected} /> )) @@ -531,6 +554,7 @@ function OAuthStarting(props: { integration={props.integration} title={props.method.label} attempt={result.data} + location={props.location} onConnected={props.onConnected} /> )) @@ -548,6 +572,7 @@ function OAuthAuto(props: { integration: IntegrationInfo title: string attempt: IntegrationAttempt + location: LocationRef onConnected?: OnIntegrationConnected }) { const data = useData() @@ -591,7 +616,11 @@ function OAuthAuto(props: { const poll = () => { void client.api.integration.oauth - .status({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, location: location(data) }) + .status({ + integrationID: props.integration.id, + attemptID: props.attempt.attemptID, + location: locationQuery(props.location), + }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -600,7 +629,7 @@ function OAuthAuto(props: { } settled = true if (status.status === "complete") { - void connected(props.integration, data, dialog, toast, props.onConnected) + void connected(props.integration, props.location, data, dialog, toast, props.onConnected) return } toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" }) @@ -620,7 +649,7 @@ function OAuthAuto(props: { void client.api.integration.oauth.cancel({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, - location: location(data), + location: locationQuery(props.location), }) }) @@ -640,6 +669,7 @@ function OAuthCode(props: { integration: IntegrationInfo title: string attempt: IntegrationAttempt + location: LocationRef onConnected?: OnIntegrationConnected }) { const data = useData() @@ -655,7 +685,7 @@ function OAuthCode(props: { void client.api.integration.oauth.cancel({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, - location: location(data), + location: locationQuery(props.location), }) }) @@ -669,12 +699,12 @@ function OAuthCode(props: { .complete({ integrationID: props.integration.id, attemptID: props.attempt.attemptID, - location: location(data), + location: locationQuery(props.location), code, }) .then(() => { settled = true - return connected(props.integration, data, dialog, toast, props.onConnected) + return connected(props.integration, props.location, data, dialog, toast, props.onConnected) }) .catch((cause) => setError(message(cause))) }} @@ -968,26 +998,31 @@ async function externalAnswer( async function connected( integration: IntegrationInfo, + location: LocationRef, data: ReturnType, dialog: ReturnType, toast: ReturnType, onConnected?: OnIntegrationConnected, ) { - data.location.integration.invalidate() - data.location.model.invalidate() - data.location.provider.invalidate() - await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()]) + data.location.integration.invalidate(location) + data.location.model.invalidate(location) + data.location.provider.invalidate(location) + await Promise.all([ + data.location.integration.sync(location), + data.location.model.sync(location), + data.location.provider.sync(location), + ]) toast.show({ variant: "success", message: `Connected ${integration.name}` }) if (onConnected) { - onConnected(providerID(data, integration.id)) + onConnected(providerID(data, location, integration.id)) return } dialog.clear() } -function providerID(data: ReturnType, integrationID: string) { - const models = data.location.model.list() ?? [] - const matches = (data.location.provider.list() ?? []).filter( +function providerID(data: ReturnType, location: LocationRef, integrationID: string) { + const models = data.location.model.list(location) ?? [] + const matches = (data.location.provider.list(location) ?? []).filter( (provider) => provider.integrationID === integrationID || provider.id === integrationID, ) return ( @@ -997,9 +1032,8 @@ function providerID(data: ReturnType, integrationID: string) { ) } -function location(data: ReturnType) { - const current = data.location.default() - return { directory: current.directory, workspace: current.workspaceID } +function locationQuery(location: LocationRef) { + return { directory: location.directory, workspace: location.workspaceID } } function message(cause: unknown) { diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx index 82306abc854..ba52519e872 100644 --- a/packages/tui/test/cli/tui/dialog-integration.test.tsx +++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx @@ -1,5 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { InputRenderable, TextareaRenderable } from "@opentui/core" +import type { LocationRef } from "@opencode-ai/client" import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { onMount } from "solid-js" @@ -8,7 +9,7 @@ import { ConfigProvider } from "../../../src/config" import { ClientProvider } from "../../../src/context/client" import { DataProvider, useData } from "../../../src/context/data" import { Keymap } from "../../../src/context/keymap" -import { LocationProvider } from "../../../src/context/location" +import { LocationProvider, useLocation } from "../../../src/context/location" import { ThemeProvider } from "../../../src/context/theme" import { DialogProvider, useDialog } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" @@ -203,9 +204,27 @@ test("hides account rename and delete actions while the add account row is selec } }) -async function renderIntegration() { +test("uses the active location for integration data and credential requests", async () => { + const location = { directory: "/remote/project", workspaceID: "workspace_test" } + const fixture = await renderIntegration(location) + + try { + fixture.app.mockInput.pressArrow("down") + fixture.app.mockInput.pressArrow("down") + fixture.app.mockInput.pressEnter() + + await fixture.app.waitFor(() => fixture.requests.length === 1) + expect(fixture.locations).toContainEqual(location) + expect(fixture.locations.at(-1)).toEqual(location) + } finally { + fixture.app.renderer.destroy() + } +}) + +async function renderIntegration(activeLocation?: LocationRef) { const events = createEventStream() const requests: Array<{ method: string; path: string; body?: { label: string } }> = [] + const locations: LocationRef[] = [] const reads = { integration: 0, model: 0, provider: 0 } let accounts = [ { type: "credential" as const, id: "cred_personal", label: "Personal" }, @@ -213,12 +232,19 @@ async function renderIntegration() { ] const calls = createFetch(async (url, request) => { + const directory = + url.searchParams.get("location[directory]") ?? + decodeURIComponent(request.headers.get("x-opencode-directory") ?? process.cwd()) + const workspaceID = + url.searchParams.get("location[workspace]") ?? request.headers.get("x-opencode-workspace") ?? undefined + const requestedLocation = { directory, ...(workspaceID ? { workspaceID } : {}) } const location = { - directory: process.cwd(), - project: { id: "proj_test", directory: process.cwd(), canonical: process.cwd() }, + ...requestedLocation, + project: { id: "proj_test", directory, canonical: directory }, } if (url.pathname === "/api/integration") { + locations.push(requestedLocation) reads.integration++ return json({ location, @@ -244,6 +270,7 @@ async function renderIntegration() { } if (request.method === "POST" && /^\/api\/credential\/[^/]+\/activate$/.test(url.pathname)) { + locations.push(requestedLocation) const id = url.pathname.split("/")[3] const active = accounts.find((account) => account.id === id) if (!active) throw new Error(`unknown credential: ${id}`) @@ -289,9 +316,11 @@ async function renderIntegration() { function Probe() { const data = useData() const dialog = useDialog() + const location = useLocation() onMount(() => { + location.set(activeLocation) void data.location.integration - .sync() + .sync(activeLocation) .then(() => dialog.replace(() => )) }) return null @@ -332,6 +361,7 @@ async function renderIntegration() { app, reads, requests, + locations, get accounts() { return accounts },