|
|
@@ -67,6 +67,19 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
let handleTextareaFocus: () => void
|
|
|
let handleTextareaBlur: () => void
|
|
|
let disposed = false
|
|
|
+ const cleanups: VoidFunction[] = []
|
|
|
+
|
|
|
+ const cleanup = () => {
|
|
|
+ if (!cleanups.length) return
|
|
|
+ const fns = cleanups.splice(0).reverse()
|
|
|
+ for (const fn of fns) {
|
|
|
+ try {
|
|
|
+ fn()
|
|
|
+ } catch {
|
|
|
+ // ignore
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
const getTerminalColors = (): TerminalColors => {
|
|
|
const mode = theme.mode()
|
|
|
@@ -128,7 +141,7 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
if (disposed) return
|
|
|
|
|
|
const mod = loaded.mod
|
|
|
- ghostty = loaded.ghostty
|
|
|
+ const g = loaded.ghostty
|
|
|
|
|
|
const once = { value: false }
|
|
|
|
|
|
@@ -138,6 +151,13 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
url.password = window.__OPENCODE__?.serverPassword
|
|
|
}
|
|
|
const socket = new WebSocket(url)
|
|
|
+ cleanups.push(() => {
|
|
|
+ if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
|
|
|
+ })
|
|
|
+ if (disposed) {
|
|
|
+ cleanup()
|
|
|
+ return
|
|
|
+ }
|
|
|
ws = socket
|
|
|
|
|
|
const t = new mod.Terminal({
|
|
|
@@ -148,8 +168,14 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
allowTransparency: true,
|
|
|
theme: terminalColors(),
|
|
|
scrollback: 10_000,
|
|
|
- ghostty,
|
|
|
+ ghostty: g,
|
|
|
})
|
|
|
+ cleanups.push(() => t.dispose())
|
|
|
+ if (disposed) {
|
|
|
+ cleanup()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ghostty = g
|
|
|
term = t
|
|
|
|
|
|
const copy = () => {
|
|
|
@@ -201,13 +227,17 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
return false
|
|
|
})
|
|
|
|
|
|
- fitAddon = new mod.FitAddon()
|
|
|
- serializeAddon = new SerializeAddon()
|
|
|
- t.loadAddon(serializeAddon)
|
|
|
- t.loadAddon(fitAddon)
|
|
|
+ const fit = new mod.FitAddon()
|
|
|
+ const serializer = new SerializeAddon()
|
|
|
+ cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
|
+ t.loadAddon(serializer)
|
|
|
+ t.loadAddon(fit)
|
|
|
+ fitAddon = fit
|
|
|
+ serializeAddon = serializer
|
|
|
|
|
|
t.open(container)
|
|
|
container.addEventListener("pointerdown", handlePointerDown)
|
|
|
+ cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
|
|
|
|
|
|
handleTextareaFocus = () => {
|
|
|
t.options.cursorBlink = true
|
|
|
@@ -218,6 +248,8 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
|
|
|
t.textarea?.addEventListener("focus", handleTextareaFocus)
|
|
|
t.textarea?.addEventListener("blur", handleTextareaBlur)
|
|
|
+ cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
|
|
|
+ cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
|
|
|
|
|
|
focusTerminal()
|
|
|
|
|
|
@@ -233,10 +265,11 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- fitAddon.observeResize()
|
|
|
- handleResize = () => fitAddon.fit()
|
|
|
+ fit.observeResize()
|
|
|
+ handleResize = () => fit.fit()
|
|
|
window.addEventListener("resize", handleResize)
|
|
|
- t.onResize(async (size) => {
|
|
|
+ cleanups.push(() => window.removeEventListener("resize", handleResize))
|
|
|
+ const onResize = t.onResize(async (size) => {
|
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
|
await sdk.client.pty
|
|
|
.update({
|
|
|
@@ -249,20 +282,24 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
.catch(() => {})
|
|
|
}
|
|
|
})
|
|
|
- t.onData((data) => {
|
|
|
+ cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
|
+ const onData = t.onData((data) => {
|
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
|
socket.send(data)
|
|
|
}
|
|
|
})
|
|
|
- t.onKey((key) => {
|
|
|
+ cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
|
+ const onKey = t.onKey((key) => {
|
|
|
if (key.key == "Enter") {
|
|
|
props.onSubmit?.()
|
|
|
}
|
|
|
})
|
|
|
+ cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
|
|
|
// t.onScroll((ydisp) => {
|
|
|
// console.log("Scroll position:", ydisp)
|
|
|
// })
|
|
|
- socket.addEventListener("open", () => {
|
|
|
+
|
|
|
+ const handleOpen = () => {
|
|
|
local.onConnect?.()
|
|
|
sdk.client.pty
|
|
|
.update({
|
|
|
@@ -273,18 +310,27 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
},
|
|
|
})
|
|
|
.catch(() => {})
|
|
|
- })
|
|
|
- socket.addEventListener("message", (event) => {
|
|
|
+ }
|
|
|
+ socket.addEventListener("open", handleOpen)
|
|
|
+ cleanups.push(() => socket.removeEventListener("open", handleOpen))
|
|
|
+
|
|
|
+ const handleMessage = (event: MessageEvent) => {
|
|
|
t.write(event.data)
|
|
|
- })
|
|
|
- socket.addEventListener("error", (error) => {
|
|
|
+ }
|
|
|
+ socket.addEventListener("message", handleMessage)
|
|
|
+ cleanups.push(() => socket.removeEventListener("message", handleMessage))
|
|
|
+
|
|
|
+ const handleError = (error: Event) => {
|
|
|
if (disposed) return
|
|
|
if (once.value) return
|
|
|
once.value = true
|
|
|
console.error("WebSocket error:", error)
|
|
|
local.onConnectError?.(error)
|
|
|
- })
|
|
|
- socket.addEventListener("close", (event) => {
|
|
|
+ }
|
|
|
+ socket.addEventListener("error", handleError)
|
|
|
+ cleanups.push(() => socket.removeEventListener("error", handleError))
|
|
|
+
|
|
|
+ const handleClose = (event: CloseEvent) => {
|
|
|
if (disposed) return
|
|
|
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
|
|
// For other codes (network issues, server restart), trigger error handler
|
|
|
@@ -293,7 +339,9 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
once.value = true
|
|
|
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
|
|
|
}
|
|
|
- })
|
|
|
+ }
|
|
|
+ socket.addEventListener("close", handleClose)
|
|
|
+ cleanups.push(() => socket.removeEventListener("close", handleClose))
|
|
|
}
|
|
|
|
|
|
void run().catch((err) => {
|
|
|
@@ -309,13 +357,6 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
|
|
|
onCleanup(() => {
|
|
|
disposed = true
|
|
|
- if (handleResize) {
|
|
|
- window.removeEventListener("resize", handleResize)
|
|
|
- }
|
|
|
- container.removeEventListener("pointerdown", handlePointerDown)
|
|
|
- term?.textarea?.removeEventListener("focus", handleTextareaFocus)
|
|
|
- term?.textarea?.removeEventListener("blur", handleTextareaBlur)
|
|
|
-
|
|
|
const t = term
|
|
|
if (serializeAddon && props.onCleanup && t) {
|
|
|
const buffer = (() => {
|
|
|
@@ -334,8 +375,7 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
})
|
|
|
}
|
|
|
|
|
|
- ws?.close()
|
|
|
- t?.dispose()
|
|
|
+ cleanup()
|
|
|
})
|
|
|
|
|
|
return (
|