diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ccf7012d2..14413dfda 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -156,6 +156,10 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void + let fitFrame: number | undefined + let sizeTimer: ReturnType | undefined + let pendingSize: { cols: number; rows: number } | undefined + let lastSize: { cols: number; rows: number } | undefined let disposed = false const cleanups: VoidFunction[] = [] const start = @@ -209,6 +213,43 @@ export const Terminal = (props: TerminalProps) => { const [terminalColors, setTerminalColors] = createSignal(getTerminalColors()) + const scheduleFit = () => { + if (disposed) return + if (!fitAddon) return + if (fitFrame !== undefined) return + + fitFrame = requestAnimationFrame(() => { + fitFrame = undefined + if (disposed) return + fitAddon.fit() + }) + } + + const scheduleSize = (cols: number, rows: number) => { + if (disposed) return + if (lastSize?.cols === cols && lastSize?.rows === rows) return + + pendingSize = { cols, rows } + + if (!lastSize) { + lastSize = pendingSize + void pushSize(cols, rows) + return + } + + if (sizeTimer !== undefined) return + sizeTimer = setTimeout(() => { + sizeTimer = undefined + const next = pendingSize + if (!next) return + pendingSize = undefined + if (disposed) return + if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return + lastSize = next + void pushSize(next.cols, next.rows) + }, 100) + } + createEffect(() => { const colors = getTerminalColors() setTerminalColors(colors) @@ -220,6 +261,16 @@ export const Terminal = (props: TerminalProps) => { const font = monoFontFamily(settings.appearance.font()) if (!term) return setOptionIfSupported(term, "fontFamily", font) + scheduleFit() + }) + + let zoom = platform.webviewZoom?.() + createEffect(() => { + const next = platform.webviewZoom?.() + if (next === undefined) return + if (next === zoom) return + zoom = next + scheduleFit() }) const focusTerminal = () => { @@ -263,25 +314,6 @@ export const Terminal = (props: TerminalProps) => { const once = { value: false } - const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) - url.searchParams.set("directory", sdk.directory) - url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) - url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - if (window.__OPENCODE__?.serverPassword) { - url.username = "opencode" - url.password = window.__OPENCODE__?.serverPassword - } - const socket = new WebSocket(url) - socket.binaryType = "arraybuffer" - cleanups.push(() => { - if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() - }) - if (disposed) { - cleanup() - return - } - ws = socket - const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restoreSize = restore && @@ -344,39 +376,16 @@ export const Terminal = (props: TerminalProps) => { focusTerminal() - const startResize = () => { - fit.observeResize() - handleResize = () => fit.fit() - window.addEventListener("resize", handleResize) - cleanups.push(() => window.removeEventListener("resize", handleResize)) + if (typeof document !== "undefined" && document.fonts) { + document.fonts.ready.then(scheduleFit) } - if (restore && restoreSize) { - t.write(restore, () => { - fit.fit() - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) - startResize() - }) - } else { - fit.fit() - if (restore) { - t.write(restore, () => { - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) - }) - } - startResize() - } - - const onResize = t.onResize(async (size) => { - if (socket.readyState === WebSocket.OPEN) { - await pushSize(size.cols, size.rows) - } + const onResize = t.onResize((size) => { + scheduleSize(size.cols, size.rows) }) cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { - if (socket.readyState === WebSocket.OPEN) { - socket.send(data) - } + if (ws?.readyState === WebSocket.OPEN) ws.send(data) }) cleanups.push(() => disposeIfDisposable(onData)) const onKey = t.onKey((key) => { @@ -385,17 +394,64 @@ export const Terminal = (props: TerminalProps) => { } }) cleanups.push(() => disposeIfDisposable(onKey)) + + const startResize = () => { + fit.observeResize() + handleResize = scheduleFit + window.addEventListener("resize", handleResize) + cleanups.push(() => window.removeEventListener("resize", handleResize)) + } + + if (restore && restoreSize) { + t.write(restore, () => { + fit.fit() + scheduleSize(t.cols, t.rows) + if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) + startResize() + }) + } else { + fit.fit() + scheduleSize(t.cols, t.rows) + if (restore) { + t.write(restore, () => { + if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) + }) + } + startResize() + } + // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) + const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) + url.searchParams.set("directory", sdk.directory) + url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + if (window.__OPENCODE__?.serverPassword) { + url.username = "opencode" + url.password = window.__OPENCODE__?.serverPassword + } + const socket = new WebSocket(url) + socket.binaryType = "arraybuffer" + ws = socket + cleanups.push(() => { + if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() + }) + if (disposed) { + cleanup() + return + } + const handleOpen = () => { local.onConnect?.() - void pushSize(t.cols, t.rows) + scheduleSize(t.cols, t.rows) } socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) + if (socket.readyState === WebSocket.OPEN) handleOpen() + const decoder = new TextDecoder() const handleMessage = (event: MessageEvent) => { @@ -462,6 +518,8 @@ export const Terminal = (props: TerminalProps) => { onCleanup(() => { disposed = true + if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) + if (sizeTimer !== undefined) clearTimeout(sizeTimer) output?.flush() persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) cleanup() @@ -477,7 +535,7 @@ export const Terminal = (props: TerminalProps) => { classList={{ ...(local.classList ?? {}), "select-text": true, - "size-full px-6 py-3 font-mono": true, + "size-full px-6 py-3 font-mono relative overflow-hidden": true, [local.class ?? ""]: !!local.class, }} {...others}