refactor(tui): centralize location state

This commit is contained in:
Dax Raad
2026-07-14 23:01:41 -04:00
parent ff3442ce34
commit 94a23b8537
19 changed files with 173 additions and 245 deletions
+41 -43
View File
@@ -42,14 +42,13 @@ import { DialogProvider, useDialog } from "./ui/dialog"
import { DialogIntegration } from "./component/dialog-integration" import { DialogIntegration } from "./component/dialog-integration"
import { ErrorComponent } from "./component/error-component" import { ErrorComponent } from "./component/error-component"
import { PluginRouteMissing } from "./component/plugin-route-missing" import { PluginRouteMissing } from "./component/plugin-route-missing"
import { ProjectProvider, useProject } from "./context/project"
import { EditorContextProvider } from "./context/editor" import { EditorContextProvider } from "./context/editor"
import { useEvent } from "./context/event" import { useEvent } from "./context/event"
import { ClientProvider, useClient } from "./context/client" import { ClientProvider, useClient } from "./context/client"
import { StartupLoading } from "./component/startup-loading" import { StartupLoading } from "./component/startup-loading"
import { Reconnecting } from "./component/reconnecting" import { Reconnecting } from "./component/reconnecting"
import { DataProvider, useData } from "./context/data" import { DataProvider, useData } from "./context/data"
import { LocationProvider } from "./context/location" import { LocationProvider, useLocation } from "./context/location"
import { LocalProvider, useLocal } from "./context/local" import { LocalProvider, useLocal } from "./context/local"
import { PermissionProvider } from "./context/permission" import { PermissionProvider } from "./context/permission"
import { DialogModel } from "./component/dialog-model" import { DialogModel } from "./component/dialog-model"
@@ -327,40 +326,38 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<PluginRuntimeProvider value={pluginRuntime}> <PluginRuntimeProvider value={pluginRuntime}>
<ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}> <ClientProvider api={api} reconnect={reconnect} reload={input.server.reload}>
<PermissionProvider> <PermissionProvider>
<ProjectProvider> <DataProvider>
<DataProvider> <LocationProvider>
<LocationProvider> <ThemeProvider mode={mode}>
<ThemeProvider mode={mode}> <LocalProvider>
<LocalProvider> <PromptStashProvider>
<PromptStashProvider> <DialogProvider>
<DialogProvider> <FrecencyProvider>
<FrecencyProvider> <PromptHistoryProvider>
<PromptHistoryProvider> <PromptRefProvider>
<PromptRefProvider> <EditorContextProvider>
<EditorContextProvider> <PluginProvider packages={input.packages}>
<PluginProvider packages={input.packages}> <App
<App pair={
pair={ input.server.endpoint.auth
input.server.endpoint.auth ? input.server.endpoint.auth
? input.server.endpoint.auth : {
: { username: "opencode",
username: "opencode", password: "",
password: "", }
} }
} />
/> </PluginProvider>
</PluginProvider> </EditorContextProvider>
</EditorContextProvider> </PromptRefProvider>
</PromptRefProvider> </PromptHistoryProvider>
</PromptHistoryProvider> </FrecencyProvider>
</FrecencyProvider> </DialogProvider>
</DialogProvider> </PromptStashProvider>
</PromptStashProvider> </LocalProvider>
</LocalProvider> </ThemeProvider>
</ThemeProvider> </LocationProvider>
</LocationProvider> </DataProvider>
</DataProvider>
</ProjectProvider>
</PermissionProvider> </PermissionProvider>
</ClientProvider> </ClientProvider>
</PluginRuntimeProvider> </PluginRuntimeProvider>
@@ -413,7 +410,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const themeState = useTheme() const themeState = useTheme()
const { theme, mode, setMode, locked, lock, unlock } = themeState const { theme, mode, setMode, locked, lock, unlock } = themeState
const data = useData() const data = useData()
const project = useProject() const location = useLocation()
const exit = useExit() const exit = useExit()
const promptRef = usePromptRef() const promptRef = usePromptRef()
const pluginRuntime = usePluginRuntime() const pluginRuntime = usePluginRuntime()
@@ -988,12 +985,12 @@ function App(props: { pair?: DialogPairCredentials }) {
})) }))
event.on("tui.command.execute", (evt, { workspace }) => { event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
keymap.dispatchCommand(evt.data.command) keymap.dispatchCommand(evt.data.command)
}) })
event.on("tui.toast.show", (evt, { workspace }) => { event.on("tui.toast.show", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
toast.show({ toast.show({
title: evt.data.title, title: evt.data.title,
message: evt.data.message, message: evt.data.message,
@@ -1003,13 +1000,14 @@ function App(props: { pair?: DialogPairCredentials }) {
}) })
event.on("plugin.updated", (_evt, { directory, workspace }) => { event.on("plugin.updated", (_evt, { directory, workspace }) => {
if (directory !== project.instance.directory()) return const current = location.current ?? data.location.default()
if (workspace !== project.workspace.current()) return if (directory !== current.directory) return
if (workspace !== current.workspaceID) return
toast.show({ variant: "success", message: "Plugins reloaded" }) toast.show({ variant: "success", message: "Plugins reloaded" })
}) })
event.on("tui.session.select", (evt, { workspace }) => { event.on("tui.session.select", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
route.navigate({ route.navigate({
type: "session", type: "session",
sessionID: evt.data.sessionID, sessionID: evt.data.sessionID,
@@ -1027,7 +1025,7 @@ function App(props: { pair?: DialogPairCredentials }) {
}) })
event.on("session.error", (evt, { workspace }) => { event.on("session.error", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
const error = evt.data.error const error = evt.data.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error) const message = errorMessage(error)
@@ -14,7 +14,6 @@ import { Locale } from "../util/locale"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
import { isRecord } from "../util/record" import { isRecord } from "../util/record"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { useProject } from "../context/project"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
import type { ProjectDirectoriesOutput } from "@opencode-ai/client" import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
@@ -41,11 +40,11 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const { theme } = useTheme() const { theme } = useTheme()
const sessionData = useData() const sessionData = useData()
const projectContext = useProject()
const route = useRoute() const route = useRoute()
const toast = useToast() const toast = useToast()
const paths = useTuiPaths() const paths = useTuiPaths()
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
const location = createMemo(() => sessionData.location.info())
const [working, setWorking] = createSignal(Boolean(props.initialRemoving)) const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [removing, setRemoving] = createSignal(props.initialRemoving) const [removing, setRemoving] = createSignal(props.initialRemoving)
@@ -63,15 +62,15 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
// swallow it and let the directory list render without a current marker. // swallow it and let the directory list render without a current marker.
// Once the current project is known, a mismatch is a guaranteed miss. // Once the current project is known, a mismatch is a guaranteed miss.
const [loadedProject] = createResource( const [loadedProject] = createResource(
() => (projectContext.project() === undefined ? props.projectID : undefined), () => (location()?.project.id === props.projectID ? undefined : props.projectID),
(projectID) => (projectID) =>
client.api.project client.api.project
.current({ location: { directory: projectContext.instance.directory() || paths.cwd } }) .current({ location: { directory: location()?.directory || paths.cwd } })
.then((project) => (project.id === projectID ? project.directory : undefined)) .then((project) => (project.id === projectID ? project.directory : undefined))
.catch(() => undefined), .catch(() => undefined),
) )
const currentCheckout = createMemo(() => { const currentCheckout = createMemo(() => {
if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree if (location()?.project.id === props.projectID) return location()?.project.directory
return loadedProject() return loadedProject()
}) })
@@ -79,14 +78,14 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
() => (props.initialRemoving ? undefined : props.projectID), () => (props.initialRemoving ? undefined : props.projectID),
async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => { async (projectID, info): Promise<ReadonlyArray<ProjectDirectory> | undefined> => {
try { try {
const location = { directory: projectContext.instance.directory() || paths.cwd } const requestLocation = { directory: location()?.directory || paths.cwd }
await client.api.projectCopy.refresh({ await client.api.projectCopy.refresh({
projectID, projectID,
location, location: requestLocation,
}) })
const directories = await client.api.project.directories({ const directories = await client.api.project.directories({
projectID, projectID,
location, location: requestLocation,
}) })
setLoadError(undefined) setLoadError(undefined)
return directories return directories
@@ -204,7 +203,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
async function removedCurrent(current: boolean) { async function removedCurrent(current: boolean) {
if (!current) return false if (!current) return false
const fallback = projectContext.data.project.mainDir const fallback = directoryData()?.findLast((item) => item.strategy === undefined)?.directory
if (fallback) setReplacementCurrent(fallback) if (fallback) setReplacementCurrent(fallback)
if (route.data.type === "session") { if (route.data.type === "session") {
route.navigate({ type: "home" }) route.navigate({ type: "home" })
@@ -236,7 +235,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const error = await client.api.projectCopy const error = await client.api.projectCopy
.remove({ .remove({
projectID: props.projectID, projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd }, location: { directory: location()?.directory || paths.cwd },
directory: selected.directory, directory: selected.directory,
force: false, force: false,
}) })
@@ -263,7 +262,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
const forcedError = await client.api.projectCopy const forcedError = await client.api.projectCopy
.remove({ .remove({
projectID: props.projectID, projectID: props.projectID,
location: { directory: projectContext.instance.directory() || paths.cwd }, location: { directory: location()?.directory || paths.cwd },
directory: selected.directory, directory: selected.directory,
force: true, force: true,
}) })
@@ -7,7 +7,6 @@ import { useRoute } from "../context/route"
import { useData } from "../context/data" import { useData } from "../context/data"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { useProject } from "../context/project"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useClient } from "../context/client" import { useClient } from "../context/client"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
@@ -21,7 +20,6 @@ export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const data = useData() const data = useData()
const project = useProject()
const { theme } = useTheme() const { theme } = useTheme()
const client = useClient() const client = useClient()
const local = useLocal() const local = useLocal()
@@ -33,15 +31,16 @@ export function DialogSessionList() {
const [searchResults] = createResource(search, async (query) => { const [searchResults] = createResource(search, async (query) => {
if (!query) return if (!query) return
const location = data.location.default()
try { try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({ const response = await client.api.session.list({
project: current.project.id,
search: query, search: query,
limit: 50, limit: 50,
order: "desc", order: "desc",
parentID: null, parentID: null,
directory: location.directory,
workspace: location.workspaceID,
}) })
return { query, sessions: response.data, error: undefined } return { query, sessions: response.data, error: undefined }
} catch (error) { } catch (error) {
@@ -101,7 +100,8 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => { const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory const directory = session.location.directory
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const slot = slotByID.get(session.id) const slot = slotByID.get(session.id)
const deleting = toDelete() === session.id const deleting = toDelete() === session.id
return { return {
+6 -3
View File
@@ -1,14 +1,14 @@
import { createMemo, createResource } from "solid-js" import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog" import { useDialog } from "../ui/dialog"
import { useProject } from "../context/project"
import { useClient } from "../context/client" import { useClient } from "../context/client"
import { useData } from "../context/data"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) { export function DialogTag(props: { onSelect?: (value: string) => void }) {
const client = useClient() const client = useClient()
const dialog = useDialog() const dialog = useDialog()
const project = useProject() const data = useData()
const [store] = createStore({ const [store] = createStore({
filter: "", filter: "",
@@ -22,7 +22,10 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
query: store.filter, query: store.filter,
type: "file", type: "file",
limit: 5, limit: 5,
location: { workspace: project.workspace.current() }, location: {
directory: data.location.default().directory,
workspace: data.location.default().workspaceID,
},
}) })
.catch(() => undefined) .catch(() => undefined)
return result?.data.map((item) => item.path) ?? [] return result?.data.map((item) => item.path) ?? []
@@ -6,7 +6,6 @@ import { firstBy } from "remeda"
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js" import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useEditorContext } from "../../context/editor" import { useEditorContext } from "../../context/editor"
import { useProject } from "../../context/project"
import { useClient } from "../../context/client" import { useClient } from "../../context/client"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
@@ -87,7 +86,6 @@ export function Autocomplete(props: {
const editor = useEditorContext() const editor = useEditorContext()
const client = useClient() const client = useClient()
const data = useData() const data = useData()
const project = useProject()
const slashes = useCommandSlashes() const slashes = useCommandSlashes()
const keymap = Keymap.use() const keymap = Keymap.use()
const { theme } = useTheme() const { theme } = useTheme()
@@ -284,7 +282,7 @@ export function Autocomplete(props: {
}) })
function normalizeMentionPath(filePath: string) { function normalizeMentionPath(filePath: string) {
const baseDir = location()?.directory || project.instance.directory() || paths.cwd const baseDir = location.current?.directory || data.location.info()?.directory || paths.cwd
const absolute = path.resolve(filePath) const absolute = path.resolve(filePath)
const relative = path.relative(baseDir, absolute) const relative = path.relative(baseDir, absolute)
@@ -310,7 +308,7 @@ export function Autocomplete(props: {
} }
const [files] = createResource( const [files] = createResource(
() => ({ query: search(), location: location(), visible: store.visible }), () => ({ query: search(), location: location.current, visible: store.visible }),
async (input) => { async (input) => {
if (!input.visible || input.visible === "/") return { options: [], failed: false } if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return { options: [], failed: false } if (referenceMatch()) return { options: [], failed: false }
@@ -322,7 +320,7 @@ export function Autocomplete(props: {
limit: 20, limit: 20,
location: { location: {
directory: input.location?.directory, directory: input.location?.directory,
workspace: input.location?.workspaceID ?? project.workspace.current(), workspace: input.location?.workspaceID ?? data.location.default().workspaceID,
}, },
}) })
.then( .then(
@@ -365,7 +363,7 @@ export function Autocomplete(props: {
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
const width = props.anchor().width - 4 const width = props.anchor().width - 4
for (const res of data.location.mcp.resource.list(location()) ?? []) { for (const res of data.location.mcp.resource.list(location.current) ?? []) {
options.push({ options.push({
display: Locale.truncateMiddle(res.name, width), display: Locale.truncateMiddle(res.name, width),
// Match the name only; matching the URI caused unrelated fuzzy hits. // Match the name only; matching the URI caused unrelated fuzzy hits.
@@ -433,7 +431,7 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = [...slashes()] const results: AutocompleteOption[] = [...slashes()]
const commandNames = new Set<string>() const commandNames = new Set<string>()
for (const serverCommand of data.location.command.list(location()) ?? []) { for (const serverCommand of data.location.command.list(location.current) ?? []) {
commandNames.add(serverCommand.name) commandNames.add(serverCommand.name)
results.push({ results.push({
display: "/" + serverCommand.name, display: "/" + serverCommand.name,
@@ -449,7 +447,7 @@ export function Autocomplete(props: {
} }
for (const skill of data.location.skill for (const skill of data.location.skill
.list(location()) .list(location.current)
?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) { ?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) {
results.push({ results.push({
display: "/" + skill.id, display: "/" + skill.id,
+10 -10
View File
@@ -23,7 +23,6 @@ import { useClipboard } from "../../context/clipboard"
import { Spinner } from "../spinner" import { Spinner } from "../spinner"
import { useClient } from "../../context/client" import { useClient } from "../../context/client"
import { useRoute } from "../../context/route" import { useRoute } from "../../context/route"
import { useProject } from "../../context/project"
import { useEvent } from "../../context/event" import { useEvent } from "../../context/event"
import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor" import { editorSelectionKey, useEditorContext, type EditorSelection } from "../../context/editor"
import { normalizePromptContent, openEditor } from "../../editor" import { normalizePromptContent, openEditor } from "../../editor"
@@ -151,7 +150,6 @@ export function Prompt(props: PromptProps) {
const client = useClient() const client = useClient()
const editor = useEditorContext() const editor = useEditorContext()
const route = useRoute() const route = useRoute()
const project = useProject()
const data = useData() const data = useData()
const currentLocation = useLocation() const currentLocation = useLocation()
const config = useConfig().data const config = useConfig().data
@@ -165,7 +163,8 @@ export function Prompt(props: PromptProps) {
.filter((id) => id !== props.sessionID && data.session.status(id) === "running").length .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length
}) })
const runningShells = createMemo( const runningShells = createMemo(
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, () =>
data.shell.list(currentLocation.current).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
) )
const history = usePromptHistory() const history = usePromptHistory()
const stash = usePromptStash() const stash = usePromptStash()
@@ -214,7 +213,8 @@ export function Prompt(props: PromptProps) {
const editorContextLabelState = createMemo(() => editor.labelState()) const editorContextLabelState = createMemo(() => editor.labelState())
const [auto, setAuto] = createSignal<AutocompleteRef>() const [auto, setAuto] = createSignal<AutocompleteRef>()
const move = usePromptMove({ const move = usePromptMove({
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(), projectID: () =>
(props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id,
sessionID: () => props.sessionID, sessionID: () => props.sessionID,
}) })
const [cursorVersion, setCursorVersion] = createSignal(0) const [cursorVersion, setCursorVersion] = createSignal(0)
@@ -244,7 +244,7 @@ export function Prompt(props: PromptProps) {
const event = useEvent() const event = useEvent()
event.on("tui.prompt.append", (evt, { workspace }) => { event.on("tui.prompt.append", (evt, { workspace }) => {
if (workspace !== project.workspace.current()) return if (workspace !== (currentLocation.current?.workspaceID ?? data.location.default().workspaceID)) return
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
input.insertText(evt.data.text) input.insertText(evt.data.text)
setTimeout(() => { setTimeout(() => {
@@ -465,8 +465,8 @@ export function Prompt(props: PromptProps) {
renderer, renderer,
value, value,
cwd: cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) || (data.location.info()?.project.directory === "/" ? undefined : data.location.info()?.project.directory) ||
project.instance.directory() || data.location.default().directory ||
paths.cwd, paths.cwd,
}) })
if (!content) return if (!content) return
@@ -502,7 +502,7 @@ export function Prompt(props: PromptProps) {
run: () => { run: () => {
dialog.replace(() => ( dialog.replace(() => (
<DialogSkill <DialogSkill
location={currentLocation()} location={currentLocation.current}
onSelect={(skill) => { onSelect={(skill) => {
input.setText(`/${skill} `) input.setText(`/${skill} `)
setStore("prompt", { setStore("prompt", {
@@ -1016,7 +1016,7 @@ export function Prompt(props: PromptProps) {
setStore("mode", "normal") setStore("mode", "normal")
} else if ( } else if (
inputText.startsWith("/") && inputText.startsWith("/") &&
(data.location.command.list(currentLocation()) ?? []).some( (data.location.command.list(currentLocation.current) ?? []).some(
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1), (command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
) )
) { ) {
@@ -1043,7 +1043,7 @@ export function Prompt(props: PromptProps) {
}) })
} else if ( } else if (
inputText.startsWith("/") && inputText.startsWith("/") &&
(data.location.skill.list(currentLocation()) ?? []).some( (data.location.skill.list(currentLocation.current) ?? []).some(
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1), (skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
) )
) { ) {
+6 -6
View File
@@ -7,7 +7,6 @@ import { useClient } from "../../context/client"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
import { useProject } from "../../context/project"
import { useData } from "../../context/data" import { useData } from "../../context/data"
function moveReminderText(directory: string) { function moveReminderText(directory: string) {
@@ -18,7 +17,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
const dialog = useDialog() const dialog = useDialog()
const client = useClient() const client = useClient()
const toast = useToast() const toast = useToast()
const project = useProject()
const data = useData() const data = useData()
const paths = useTuiPaths() const paths = useTuiPaths()
const [creating, setCreating] = createSignal(false) const [creating, setCreating] = createSignal(false)
@@ -34,7 +32,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
try { try {
const result = await client.api.projectCopy.create({ const result = await client.api.projectCopy.create({
projectID, projectID,
location: { directory: project.instance.directory() || paths.cwd }, location: { directory: data.location.info()?.directory || paths.cwd },
strategy: "git_worktree", strategy: "git_worktree",
directory: path.join(paths.worktree, projectID.slice(0, 6)), directory: path.join(paths.worktree, projectID.slice(0, 6)),
name, name,
@@ -77,8 +75,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
} }
: { : {
type: "directory", type: "directory",
directory: project.instance.directory(), directory: data.location.default().directory,
subdirectory: project.instance.directory() !== project.instance.path().worktree, subdirectory: data.location.default().directory !== data.location.info()?.project.directory,
}) })
} }
onCurrentChange={setDestination} onCurrentChange={setDestination}
@@ -130,8 +128,10 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
if (projectID) return projectID if (projectID) return projectID
const sessionID = input.sessionID() const sessionID = input.sessionID()
if (sessionID) return (await resolveSession(sessionID))?.projectID if (sessionID) return (await resolveSession(sessionID))?.projectID
const current = data.location.info()
if (current) return current.project.id
return client.api.project return client.api.project
.current({ location: { directory: project.instance.directory() || paths.cwd } }) .current({ location: { directory: data.location.default().directory || paths.cwd } })
.then((project) => project.id) .then((project) => project.id)
.catch(() => undefined) .catch(() => undefined)
} }
+19 -7
View File
@@ -9,6 +9,7 @@ import type {
FormInfo, FormInfo,
IntegrationInfo, IntegrationInfo,
LocationRef, LocationRef,
LocationGetOutput,
McpResource, McpResource,
McpServer, McpServer,
ModelInfo, ModelInfo,
@@ -43,6 +44,7 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
export type FormWithLocation = FormInfo & { readonly location?: LocationRef } export type FormWithLocation = FormInfo & { readonly location?: LocationRef }
type LocationData = { type LocationData = {
info?: LocationGetOutput
agent?: AgentInfo[] agent?: AgentInfo[]
command?: CommandInfo[] command?: CommandInfo[]
integration?: IntegrationInfo[] integration?: IntegrationInfo[]
@@ -1058,6 +1060,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}, },
}, },
location: { location: {
info(ref?: LocationRef) {
return store.location[locationKey(ref ?? defaultLocation())]?.info
},
default() { default() {
return defaultLocation() return defaultLocation()
}, },
@@ -1067,7 +1072,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const location = await client.api.location.get({ location: locationQuery(current) }) const location = await client.api.location.get({ location: locationQuery(current) })
const key = locationKey(location) const key = locationKey(location)
if (!store.location[key]) setStore("location", key, {}) if (!store.location[key]) setStore("location", key, {})
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) setStore("location", key, "info", location)
if (!ref) {
setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
}
}) })
const location = ref ?? defaultLocation() const location = ref ?? defaultLocation()
await Promise.all([ await Promise.all([
@@ -1275,12 +1283,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
) )
}) })
.catch(() => undefined) .catch(() => undefined)
void client.api.session void client.api.location
.list({ .get({ location: locationQuery(defaultLocation()) })
limit: 50, .then((location) => {
order: "desc", const key = locationKey(location)
directory: defaultLocation().directory, setStore("location", key, { ...store.location[key], info: location })
workspace: defaultLocation().workspaceID, return client.api.session.list({
project: location.project.id,
limit: 50,
order: "desc",
})
}) })
.then((response) => { .then((response) => {
setStore( setStore(
+3 -3
View File
@@ -1,13 +1,13 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useProject } from "./project" import { useData } from "./data"
import { abbreviateHome } from "../runtime" import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "./runtime" import { useTuiPaths } from "./runtime"
export function useDirectory() { export function useDirectory() {
const project = useProject() const data = useData()
const paths = useTuiPaths() const paths = useTuiPaths()
return createMemo(() => { return createMemo(() => {
const directory = project.instance.path().directory || paths.cwd const directory = data.location.info()?.directory ?? data.location.default().directory ?? paths.cwd
return abbreviateHome(directory, paths.home) return abbreviateHome(directory, paths.home)
}) })
} }
+20 -14
View File
@@ -1,17 +1,18 @@
import type { LocationRef } from "@opencode-ai/client" import type { LocationGetOutput, LocationRef } from "@opencode-ai/client"
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" import { createContext, createMemo, createSignal, onCleanup, useContext, type ParentProps } from "solid-js"
import { useClient } from "./client" import { useClient } from "./client"
import { useData } from "./data" import { useData } from "./data"
const context = createContext<{ const context = createContext<{
current: Accessor<LocationRef | undefined> readonly current: LocationGetOutput | undefined
set: (location?: LocationRef) => void set: (location?: LocationRef) => void
}>() }>()
export function LocationProvider(props: ParentProps) { export function LocationProvider(props: ParentProps) {
const client = useClient() const client = useClient()
const data = useData() const data = useData()
const [current, setCurrent] = createSignal<LocationRef>() const [ref, setRef] = createSignal<LocationRef>()
const current = createMemo(() => data.location.info(ref()))
function sync(location?: LocationRef) { function sync(location?: LocationRef) {
if (!location) return if (!location) return
@@ -24,23 +25,28 @@ export function LocationProvider(props: ParentProps) {
} }
function set(location?: LocationRef) { function set(location?: LocationRef) {
setCurrent(location) setRef(location)
if (client.connection.status() === "connected") sync(location) if (client.connection.status() === "connected") sync(location)
} }
onCleanup(client.event.on("server.connected", () => sync(current()))) onCleanup(client.event.on("server.connected", () => sync(ref())))
return <context.Provider value={{ current, set }}>{props.children}</context.Provider> return (
<context.Provider
value={{
get current() {
return current()
},
set,
}}
>
{props.children}
</context.Provider>
)
} }
export function useLocation() { export function useLocation() {
const value = useContext(context) const value = useContext(context)
if (!value) throw new Error("Location context must be used within a LocationProvider") if (!value) throw new Error("Location context must be used within a LocationProvider")
return value.current return value
}
export function useSetLocation() {
const value = useContext(context)
if (!value) throw new Error("Location context must be used within a LocationProvider")
return value.set
} }
+2 -2
View File
@@ -7,8 +7,8 @@ export function usePathFormatter() {
const paths = useTuiPaths() const paths = useTuiPaths()
const location = useLocation() const location = useLocation()
return { return {
path: () => location()?.directory || paths.cwd, path: () => location.current?.directory || paths.cwd,
format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home), format: (input?: string) => formatPath(input, location.current?.directory || paths.cwd, paths.home),
} }
} }
-76
View File
@@ -1,76 +0,0 @@
import { batch } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const client = useClient()
const defaultPath = {
home: "",
state: "",
config: "",
worktree: "",
directory: process.cwd(),
}
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
worktree: undefined as string | undefined,
mainDir: undefined as string | undefined,
},
instance: {
path: defaultPath,
},
workspace: {
current: undefined as string | undefined,
},
})
async function sync() {
const workspace = store.workspace.current
const location = { workspace }
const current = await client.api.location.get({ location })
const directories = await client.api.project.directories({ projectID: current.project.id, location })
batch(() => {
setStore(
"instance",
"path",
reconcile({ ...defaultPath, worktree: current.project.directory, directory: current.directory }),
)
setStore("project", "id", current.project.id)
setStore("project", "worktree", current.project.directory)
setStore("project", "mainDir", directories.findLast((item) => item.strategy === undefined)?.directory)
})
}
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace.current
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace.current === workspace) return
setStore("workspace", "current", workspace)
},
},
sync,
}
},
})
@@ -1,14 +1,22 @@
import { Plugin } from "@opencode-ai/plugin/v2/tui" import { Plugin } from "@opencode-ai/plugin/v2/tui"
import { createMemo, Show } from "solid-js"
import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
function View() { function View(props: { context: Plugin.Context }) {
const { theme } = useTheme() const { theme } = useTheme()
return <text fg={theme.textMuted}>Sidebar footer unavailable</text> const paths = useTuiPaths()
const directory = createMemo(() =>
props.context.location ? abbreviateHome(props.context.location.directory, paths.home) : undefined,
)
return <Show when={directory()}>{(value) => <FilePath value={value()} maxWidth={38} fg={theme.textMuted} />}</Show>
} }
export default Plugin.define({ export default Plugin.define({
id: "opencode.sidebar-footer", id: "opencode.sidebar-footer",
setup(context) { setup(context) {
context.ui.slot("sidebar.footer", () => <View />) context.ui.slot("sidebar.footer", () => <View context={context} />)
}, },
}) })
+1 -1
View File
@@ -85,7 +85,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const context: Context = { const context: Context = {
options: item.options ?? {}, options: item.options ?? {},
get location() { get location() {
return location() return location.current
}, },
client: client.api, client: client.api,
data, data,
+3 -3
View File
@@ -9,7 +9,7 @@ import { useLocal } from "../context/local"
import { usePluginRuntime } from "../plugin/runtime" import { usePluginRuntime } from "../plugin/runtime"
import { useEditorContext } from "../context/editor" import { useEditorContext } from "../context/editor"
import { useData } from "../context/data" import { useData } from "../context/data"
import { useSetLocation } from "../context/location" import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form" import { FormPrompt } from "./session/form"
import { PluginSlot } from "../plugin/context" import { PluginSlot } from "../plugin/context"
@@ -28,12 +28,12 @@ export function Home() {
const local = useLocal() const local = useLocal()
const editor = useEditorContext() const editor = useEditorContext()
const data = useData() const data = useData()
const setLocation = useSetLocation() const location = useLocation()
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home. // Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? []) const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
let sent = false let sent = false
createEffect(() => setLocation(data.location.default())) createEffect(() => location.set(data.location.default()))
onMount(() => { onMount(() => {
editor.clearSelection() editor.clearSelection()
@@ -18,9 +18,7 @@ export function ShellTab(props: { sessionID: string }) {
const shortcuts = Keymap.useShortcuts() const shortcuts = Keymap.useShortcuts()
const entries = createMemo(() => const entries = createMemo(() =>
data.shell data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
.list()
.filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"),
) )
const [store, setStore] = createStore({ selected: 0 }) const [store, setStore] = createStore({ selected: 0 })
@@ -47,8 +45,7 @@ export function ShellTab(props: { sessionID: string }) {
const cleanup = composer.register({ const cleanup = composer.register({
id: "shell", id: "shell",
label: "Shell", label: "Shell",
hints: () => hints: () => (selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : []),
selectedEntry() ? [{ label: "kill", shortcut: shortcuts.get("composer.shell.kill") ?? "" }] : [],
}) })
onCleanup(cleanup) onCleanup(cleanup)
}) })
@@ -87,7 +84,7 @@ export function ShellTab(props: { sessionID: string }) {
run() { run() {
const entry = selectedEntry() const entry = selectedEntry()
if (!entry) return if (!entry) return
const ref = location() 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: ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined,
@@ -99,11 +96,7 @@ export function ShellTab(props: { sessionID: string }) {
return ( return (
<Show when={composer.active("shell")}> <Show when={composer.active("shell")}>
<scrollbox <scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No shell commands</text>}> <Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No shell commands</text>}>
<For each={entries()}> <For each={entries()}>
{(shell, index) => { {(shell, index) => {
+4 -9
View File
@@ -18,7 +18,6 @@ import { EOL, tmpdir } from "node:os"
import { mkdir, writeFile } from "node:fs/promises" import { mkdir, writeFile } from "node:fs/promises"
import { useRoute, useRouteData } from "../../context/route" import { useRoute, useRouteData } from "../../context/route"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useProject } from "../../context/project"
import { useData } from "../../context/data" import { useData } from "../../context/data"
import { SplitBorder } from "../../ui/border" import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
@@ -71,7 +70,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
import { usePluginRuntime } from "../../plugin/runtime" import { usePluginRuntime } from "../../plugin/runtime"
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
import { usePathFormatter } from "../../context/path-format" import { usePathFormatter } from "../../context/path-format"
import { useSetLocation } from "../../context/location" import { useLocation } from "../../context/location"
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows" import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
import { switchLabel } from "../../util/model" import { switchLabel } from "../../util/model"
@@ -106,7 +105,6 @@ export function Session() {
const route = useRouteData("session") const route = useRouteData("session")
const { navigate } = useRoute() const { navigate } = useRoute()
const data = useData() const data = useData()
const project = useProject()
const paths = useTuiPaths() const paths = useTuiPaths()
const configState = useConfig() const configState = useConfig()
const config = configState.data const config = configState.data
@@ -115,9 +113,9 @@ export function Session() {
const session = createMemo(() => data.session.get(route.sessionID)) const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID) const messages = () => data.session.message.list(route.sessionID)
const location = createMemo(() => session()?.location) const location = createMemo(() => session()?.location)
const setLocation = useSetLocation() const currentLocation = useLocation()
createEffect(() => setLocation(location())) createEffect(() => currentLocation.set(location()))
createEffect(() => { createEffect(() => {
const title = Locale.truncate(session()?.title ?? "", 50) const title = Locale.truncate(session()?.title ?? "", 50)
@@ -211,7 +209,6 @@ export function Session() {
navigate({ type: "home" }) navigate({ type: "home" })
return return
} }
project.workspace.set(info.location.workspaceID)
editor.reconnect(info.location.directory) editor.reconnect(info.location.directory)
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
})().catch((error) => { })().catch((error) => {
@@ -2348,9 +2345,7 @@ function Shell(props: ToolProps) {
.output({ .output({
id, id,
limit: 1024 * 1024, limit: 1024 * 1024,
location: location location: location ? { directory: location.directory, workspace: location.workspaceID } : undefined,
? { directory: location.directory, workspace: location.workspaceID }
: undefined,
}) })
.then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim()))) .then((response) => setBackgroundOutput(stripAnsi(response.data.output.trim())))
.catch(() => undefined) .catch(() => undefined)
+7 -4
View File
@@ -5,10 +5,9 @@ import type { OpenCodeEvent } from "@opencode-ai/client"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { createEffect, onMount, type ParentProps } from "solid-js" import { createEffect, onMount, type ParentProps } from "solid-js"
import { ProjectProvider } from "../../../src/context/project"
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 { LocationProvider, useSetLocation } from "../../../src/context/location" import { LocationProvider, useLocation } from "../../../src/context/location"
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
@@ -44,10 +43,14 @@ function DataProvider(props: ParentProps) {
) )
} }
function ProjectProvider(props: ParentProps) {
return props.children
}
function SyncLocation() { function SyncLocation() {
const data = useData() const data = useData()
const setLocation = useSetLocation() const location = useLocation()
createEffect(() => setLocation(data.location.default())) createEffect(() => location.set(data.location.default()))
return null return null
} }
+14 -25
View File
@@ -4,7 +4,6 @@ import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import type { Service } from "@opencode-ai/client/effect" import type { Service } from "@opencode-ai/client/effect"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import { onMount } from "solid-js" import { onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/context/project"
import { ClientProvider, useClient } from "../../../src/context/client" import { ClientProvider, useClient } from "../../../src/context/client"
import { useEvent } from "../../../src/context/event" import { useEvent } from "../../../src/context/event"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
@@ -61,7 +60,6 @@ async function mount(
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
const seen: OpenCodeEvent[] = [] const seen: OpenCodeEvent[] = []
const workspaces: Array<string | undefined> = [] const workspaces: Array<string | undefined> = []
let project!: ReturnType<typeof useProject>
let client!: ReturnType<typeof useClient> let client!: ReturnType<typeof useClient>
let done!: () => void let done!: () => void
const ready = new Promise<void>((resolve) => { const ready = new Promise<void>((resolve) => {
@@ -71,32 +69,27 @@ async function mount(
const app = await testRender(() => ( const app = await testRender(() => (
<TestTuiContexts log={log}> <TestTuiContexts log={log}>
<ClientProvider api={createApi(calls.fetch)} reconnect={reconnect}> <ClientProvider api={createApi(calls.fetch)} reconnect={reconnect}>
<ProjectProvider> <Probe
<Probe onReady={(ctx) => {
onReady={async (ctx) => { client = ctx.client
project = ctx.project done()
client = ctx.client }}
await project.sync() seen={seen}
done() workspaces={workspaces}
}} />
seen={seen}
workspaces={workspaces}
/>
</ProjectProvider>
</ClientProvider> </ClientProvider>
</TestTuiContexts> </TestTuiContexts>
)) ))
await ready await ready
return { app, events, emit: events.emit, project, client, seen, workspaces } return { app, events, emit: events.emit, client, seen, workspaces }
} }
function Probe(props: { function Probe(props: {
seen: OpenCodeEvent[] seen: OpenCodeEvent[]
workspaces: Array<string | undefined> workspaces: Array<string | undefined>
onReady: (ctx: { project: ReturnType<typeof useProject>; client: ReturnType<typeof useClient> }) => void onReady: (ctx: { client: ReturnType<typeof useClient> }) => void
}) { }) {
const project = useProject()
const client = useClient() const client = useClient()
const event = useEvent() const event = useEvent()
@@ -105,7 +98,7 @@ function Probe(props: {
props.seen.push(evt) props.seen.push(evt)
props.workspaces.push(workspace) props.workspaces.push(workspace)
}) })
props.onReady({ project, client }) props.onReady({ client })
}) })
return <box /> return <box />
@@ -161,10 +154,9 @@ describe("useEvent", () => {
}) })
test("delivers current project events regardless of active workspace", async () => { test("delivers current project events regardless of active workspace", async () => {
const { app, emit, project, seen } = await mount() const { app, emit, seen } = await mount()
try { try {
project.workspace.set("ws_a")
emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" })) emit(event(vcs("ws"), { directory: "/tmp/other", project: projectID, workspace: "ws_b" }))
await wait(() => seen.length === 1) await wait(() => seen.length === 1)
@@ -176,10 +168,9 @@ describe("useEvent", () => {
}) })
test("delivers truly global events even when a workspace is active", async () => { test("delivers truly global events even when a workspace is active", async () => {
const { app, emit, project, seen } = await mount() const { app, emit, seen } = await mount()
try { try {
project.workspace.set("ws_a")
emit(event(update("1.2.3"), { directory: "global" })) emit(event(update("1.2.3"), { directory: "global" }))
await wait(() => seen.length === 1) await wait(() => seen.length === 1)
@@ -256,9 +247,7 @@ describe("useEvent", () => {
return new Response( return new Response(
new ReadableStream({ new ReadableStream({
start(controller) { start(controller) {
controller.enqueue( controller.enqueue(encoder.encode('data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n'))
encoder.encode('data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n'),
)
controller.close() controller.close()
}, },
}), }),