fix(tui): remove shells from their location (#39885)
This commit is contained in:
parent
1d18459cdc
commit
d07d9ae0da
3 changed files with 66 additions and 16 deletions
|
|
@ -45,6 +45,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||||
// server cannot recover their Location when settling them. Preserve the event Location
|
// server cannot recover their Location when settling them. Preserve the event Location
|
||||||
// until MCP elicitations carry session ownership.
|
// until MCP elicitations carry session ownership.
|
||||||
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
|
export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
|
||||||
|
type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
|
||||||
|
|
||||||
type LocationData = {
|
type LocationData = {
|
||||||
info?: LocationGetOutput
|
info?: LocationGetOutput
|
||||||
|
|
@ -61,7 +62,7 @@ type LocationData = {
|
||||||
websearch?: WebSearchProvider[]
|
websearch?: WebSearchProvider[]
|
||||||
// Currently running shell commands for this location, keyed by shell id. Entries are removed
|
// Currently running shell commands for this location, keyed by shell id. Entries are removed
|
||||||
// once the command exits or is deleted, so this only ever holds in-flight shells.
|
// once the command exits or is deleted, so this only ever holds in-flight shells.
|
||||||
shell?: Record<string, ShellInfo>
|
shell?: Record<string, ShellWithLocation>
|
||||||
skill?: SkillInfo[]
|
skill?: SkillInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -851,7 +852,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
case "shell.created":
|
case "shell.created":
|
||||||
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
|
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
|
||||||
...data,
|
...data,
|
||||||
shell: { ...data?.shell, [event.data.info.id]: event.data.info },
|
shell: {
|
||||||
|
...data?.shell,
|
||||||
|
[event.data.info.id]: { ...event.data.info, location: event.location ?? defaultLocation() },
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
break
|
break
|
||||||
case "shell.exited":
|
case "shell.exited":
|
||||||
|
|
@ -1106,7 +1110,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
const key = locationKey(response.location)
|
const key = locationKey(response.location)
|
||||||
setStore("location", key, {
|
setStore("location", key, {
|
||||||
...store.location[key],
|
...store.location[key],
|
||||||
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
|
shell: Object.fromEntries(
|
||||||
|
response.data.map((info) => [
|
||||||
|
info.id,
|
||||||
|
{
|
||||||
|
...info,
|
||||||
|
location: {
|
||||||
|
directory: response.location.directory,
|
||||||
|
workspaceID: response.location.workspaceID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-j
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
|
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
|
||||||
import { useData } from "../../../context/data"
|
import { useData } from "../../../context/data"
|
||||||
import { useLocation } from "../../../context/location"
|
|
||||||
import { useClient } from "../../../context/client"
|
import { useClient } from "../../../context/client"
|
||||||
import { useTheme } from "../../../context/theme"
|
import { useTheme } from "../../../context/theme"
|
||||||
import { Keymap } from "../../../context/keymap"
|
import { Keymap } from "../../../context/keymap"
|
||||||
|
|
@ -10,13 +9,14 @@ import { useComposerTab } from "./index"
|
||||||
|
|
||||||
export function ShellTab(props: { sessionID: string }) {
|
export function ShellTab(props: { sessionID: string }) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
const location = useLocation()
|
|
||||||
const client = useClient()
|
const client = useClient()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const composer = useComposerTab()
|
const composer = useComposerTab()
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
|
|
||||||
const entries = createMemo(() => data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"))
|
const entries = createMemo(() =>
|
||||||
|
data.shell.listBySession(props.sessionID).filter((shell) => shell.status === "running"),
|
||||||
|
)
|
||||||
|
|
||||||
const [store, setStore] = createStore({ selected: 0 })
|
const [store, setStore] = createStore({ selected: 0 })
|
||||||
let scroll: ScrollBoxRenderable | undefined
|
let scroll: ScrollBoxRenderable | undefined
|
||||||
|
|
@ -83,10 +83,9 @@ export function ShellTab(props: { sessionID: string }) {
|
||||||
run() {
|
run() {
|
||||||
const entry = selectedEntry()
|
const entry = selectedEntry()
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
const ref = location.current
|
|
||||||
void client.api.shell.remove({
|
void client.api.shell.remove({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
location: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
|
location: { directory: entry.location.directory, workspace: entry.location.workspaceID },
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import { createEffect, onMount, type ParentProps } from "solid-js"
|
||||||
import { ConfigProvider } from "../../../src/config"
|
import { ConfigProvider } from "../../../src/config"
|
||||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||||
|
import { Keymap } from "../../../src/context/keymap"
|
||||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||||
|
import { RouteProvider } from "../../../src/context/route"
|
||||||
|
import { ThemeProvider } from "../../../src/context/theme"
|
||||||
|
import { Composer } from "../../../src/routes/session/composer"
|
||||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
|
|
@ -1782,12 +1786,19 @@ test("refreshes references after updates", async () => {
|
||||||
test("keeps shell state scoped to location", async () => {
|
test("keeps shell state scoped to location", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const other = "/tmp/opencode/other"
|
const other = "/tmp/opencode/other"
|
||||||
const calls = createFetch((url) => {
|
const workspace = "ws_other"
|
||||||
|
let removed: URL | undefined
|
||||||
|
const calls = createFetch((url, request) => {
|
||||||
|
if (url.pathname === "/api/shell/sh_other" && request.method === "DELETE") {
|
||||||
|
removed = url
|
||||||
|
return new Response(null, { status: 204 })
|
||||||
|
}
|
||||||
if (url.pathname !== "/api/shell") return
|
if (url.pathname !== "/api/shell") return
|
||||||
const requestDirectory = url.searchParams.get("location[directory]")
|
const requestDirectory = url.searchParams.get("location[directory]")
|
||||||
return json({
|
return json({
|
||||||
location: {
|
location: {
|
||||||
directory: requestDirectory ?? directory,
|
directory: requestDirectory ?? directory,
|
||||||
|
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
|
||||||
project: { id: "proj_test", directory: requestDirectory ?? directory },
|
project: { id: "proj_test", directory: requestDirectory ?? directory },
|
||||||
},
|
},
|
||||||
data: [
|
data: [
|
||||||
|
|
@ -1798,7 +1809,7 @@ test("keeps shell state scoped to location", async () => {
|
||||||
cwd: requestDirectory ?? directory,
|
cwd: requestDirectory ?? directory,
|
||||||
shell: "/bin/sh",
|
shell: "/bin/sh",
|
||||||
file: "/tmp/opencode-shell",
|
file: "/tmp/opencode-shell",
|
||||||
metadata: { sessionID: requestDirectory === other ? "ses_other" : "ses_default" },
|
metadata: { sessionID: "ses_shared" },
|
||||||
time: { started: 1 },
|
time: { started: 1 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
@ -1808,7 +1819,15 @@ test("keeps shell state scoped to location", async () => {
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
data = useData()
|
data = useData()
|
||||||
return <box />
|
return (
|
||||||
|
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_shared" }}>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||||
|
<Composer sessionID="ses_shared" open={true} defaultTab="shell" />
|
||||||
|
</ThemeProvider>
|
||||||
|
</Keymap.Provider>
|
||||||
|
</RouteProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(() => (
|
const app = await testRender(() => (
|
||||||
|
|
@ -1822,19 +1841,31 @@ test("keeps shell state scoped to location", async () => {
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
</TestTuiContexts>
|
</TestTuiContexts>
|
||||||
))
|
))
|
||||||
|
app.renderer.start()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
|
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
|
||||||
await data.shell.sync({ directory: other })
|
await data.shell.sync({ directory: other, workspaceID: workspace })
|
||||||
|
|
||||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||||
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
|
expect(data.shell.list({ directory: other, workspaceID: workspace }).map((shell) => shell.id)).toEqual(["sh_other"])
|
||||||
|
expect(data.shell.listBySession("ses_shared").map((shell) => [shell.id, shell.location.directory])).toEqual([
|
||||||
|
["sh_default", directory],
|
||||||
|
["sh_other", other],
|
||||||
|
])
|
||||||
|
|
||||||
|
await app.waitForFrame((frame) => frame.includes("pnpm dev"))
|
||||||
|
app.mockInput.pressArrow("down")
|
||||||
|
app.mockInput.pressKey("d", { ctrl: true })
|
||||||
|
await wait(() => removed !== undefined)
|
||||||
|
expect(removed?.searchParams.get("location[directory]")).toBe(other)
|
||||||
|
expect(removed?.searchParams.get("location[workspace]")).toBe(workspace)
|
||||||
|
|
||||||
events.emit({
|
events.emit({
|
||||||
id: "evt_shell_created",
|
id: "evt_shell_created",
|
||||||
created: 0,
|
created: 0,
|
||||||
type: "shell.created",
|
type: "shell.created",
|
||||||
location: { directory: other },
|
location: { directory: other, workspaceID: workspace },
|
||||||
data: {
|
data: {
|
||||||
info: {
|
info: {
|
||||||
id: "sh_live_other",
|
id: "sh_live_other",
|
||||||
|
|
@ -1843,13 +1874,18 @@ test("keeps shell state scoped to location", async () => {
|
||||||
cwd: other,
|
cwd: other,
|
||||||
shell: "/bin/sh",
|
shell: "/bin/sh",
|
||||||
file: "/tmp/opencode-shell-live",
|
file: "/tmp/opencode-shell-live",
|
||||||
metadata: { sessionID: "ses_other" },
|
metadata: { sessionID: "ses_shared" },
|
||||||
time: { started: 2 },
|
time: { started: 2 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => data.shell.list({ directory: other }).some((shell) => shell.id === "sh_live_other"))
|
await wait(() =>
|
||||||
|
data.shell.list({ directory: other, workspaceID: workspace }).some((shell) => shell.id === "sh_live_other"),
|
||||||
|
)
|
||||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||||
|
expect(data.shell.listBySession("ses_shared").find((shell) => shell.id === "sh_live_other")?.location.directory).toBe(
|
||||||
|
other,
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue