From 88fd6a294b3ad88d50cb8e1853589ee4e68dc74e Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Fri, 16 Jan 2026 13:51:02 -0300 Subject: feat(desktop): Terminal Splits (#8767) --- packages/app/src/components/terminal-split.tsx | 322 +++++++++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 packages/app/src/components/terminal-split.tsx (limited to 'packages/app/src/components/terminal-split.tsx') diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx new file mode 100644 index 000000000..9a05ff22c --- /dev/null +++ b/packages/app/src/components/terminal-split.tsx @@ -0,0 +1,322 @@ +import { For, Show, createMemo, createSignal, onCleanup } from "solid-js" +import { Terminal } from "./terminal" +import { useTerminal, type Panel } from "@/context/terminal" +import { IconButton } from "@opencode-ai/ui/icon-button" + +export interface TerminalSplitProps { + tabId: string +} + +function computeLayout( + panels: Record, + panelId: string, + bounds: { top: number; left: number; width: number; height: number }, +): Map { + const result = new Map() + const panel = panels[panelId] + if (!panel) return result + + if (panel.ptyId) { + result.set(panel.ptyId, bounds) + } else if (panel.children && panel.children.length === 2) { + const [leftId, rightId] = panel.children + const sizes = panel.sizes ?? [50, 50] + + if (panel.direction === "horizontal") { + const topHeight = (bounds.height * sizes[0]) / 100 + const topBounds = { ...bounds, height: topHeight } + const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bounds.height - topHeight } + for (const [k, v] of computeLayout(panels, leftId, topBounds)) result.set(k, v) + for (const [k, v] of computeLayout(panels, rightId, bottomBounds)) result.set(k, v) + } else { + const leftWidth = (bounds.width * sizes[0]) / 100 + const leftBounds = { ...bounds, width: leftWidth } + const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: bounds.width - leftWidth } + for (const [k, v] of computeLayout(panels, leftId, leftBounds)) result.set(k, v) + for (const [k, v] of computeLayout(panels, rightId, rightBounds)) result.set(k, v) + } + } + + return result +} + +function findPanelForPty(panels: Record, ptyId: string): string | undefined { + for (const [id, panel] of Object.entries(panels)) { + if (panel.ptyId === ptyId) return id + } +} + +export function TerminalSplit(props: TerminalSplitProps) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + const terminals = createMemo(() => terminal.all().filter((t) => t.tabId === props.tabId)) + const [containerFocused, setContainerFocused] = createSignal(true) + + const layout = createMemo(() => { + const p = pane() + if (!p) { + const single = terminals()[0] + if (!single) return new Map() + return new Map([[single.id, { top: 0, left: 0, width: 100, height: 100 }]]) + } + return computeLayout(p.panels, p.root, { top: 0, left: 0, width: 100, height: 100 }) + }) + + const focused = createMemo(() => { + const p = pane() + if (!p) return props.tabId + const focusedPanel = p.panels[p.focused ?? ""] + return focusedPanel?.ptyId ?? props.tabId + }) + + const handleFocus = (ptyId: string) => { + const p = pane() + if (!p) return + const panelId = findPanelForPty(p.panels, ptyId) + if (panelId) terminal.focus(props.tabId, panelId) + } + + const handleClose = (ptyId: string) => { + const pty = terminal.all().find((t) => t.id === ptyId) + if (!pty) return + + const p = pane() + if (!p) { + if (pty.tabId === props.tabId) { + terminal.closeTab(props.tabId) + } + return + } + const panelId = findPanelForPty(p.panels, ptyId) + if (panelId) terminal.closeSplit(props.tabId, panelId) + } + + return ( +
setContainerFocused(true)} + onFocusOut={(e) => { + const related = e.relatedTarget as Node | null + if (!related || !e.currentTarget.contains(related)) { + setContainerFocused(false) + } + }} + > + + {(pty) => { + const bounds = createMemo(() => layout().get(pty.id) ?? { top: 0, left: 0, width: 100, height: 100 }) + const isFocused = createMemo(() => focused() === pty.id) + const hasSplits = createMemo(() => !!pane()) + + return ( +
0, + "border-t border-border-weak-base": bounds().top > 0, + }} + style={{ + top: `${bounds().top}%`, + left: `${bounds().left}%`, + width: `${bounds().width}%`, + height: `${bounds().height}%`, + }} + onClick={() => handleFocus(pty.id)} + > + +
+ { + e.stopPropagation() + handleClose(pty.id) + }} + /> +
+
+
+ terminal.clone(pty.id)} + onExit={() => handleClose(pty.id)} + class="size-full" + /> +
+
+ ) + }} +
+ +
+ ) +} + +function ResizeHandles(props: { tabId: string }) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + + const splits = createMemo(() => { + const p = pane() + if (!p) return [] + return Object.values(p.panels).filter((panel) => panel.children && panel.children.length === 2) + }) + + return {(panel) => } +} + +function ResizeHandle(props: { tabId: string; panelId: string }) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + const panel = createMemo(() => pane()?.panels[props.panelId]) + + let cleanup: VoidFunction | undefined + + onCleanup(() => cleanup?.()) + + const position = createMemo(() => { + const p = pane() + if (!p) return null + const pan = panel() + if (!pan?.children || pan.children.length !== 2) return null + + const bounds = computePanelBounds(p.panels, p.root, props.panelId, { + top: 0, + left: 0, + width: 100, + height: 100, + }) + if (!bounds) return null + + const sizes = pan.sizes ?? [50, 50] + + if (pan.direction === "horizontal") { + return { + horizontal: true, + top: bounds.top + (bounds.height * sizes[0]) / 100, + left: bounds.left, + size: bounds.width, + } + } + return { + horizontal: false, + top: bounds.top, + left: bounds.left + (bounds.width * sizes[0]) / 100, + size: bounds.height, + } + }) + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault() + + const pos = position() + if (!pos) return + + const container = (e.target as HTMLElement).closest("[data-terminal-split-container]") as HTMLElement + if (!container) return + + const rect = container.getBoundingClientRect() + const pan = panel() + if (!pan) return + + const p = pane() + if (!p) return + const panelBounds = computePanelBounds(p.panels, p.root, props.panelId, { + top: 0, + left: 0, + width: 100, + height: 100, + }) + if (!panelBounds) return + + const handleMouseMove = (e: MouseEvent) => { + if (pan.direction === "horizontal") { + const totalPx = (rect.height * panelBounds.height) / 100 + const topPx = (rect.height * panelBounds.top) / 100 + const posPx = e.clientY - rect.top - topPx + const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100)) + terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent]) + } else { + const totalPx = (rect.width * panelBounds.width) / 100 + const leftPx = (rect.width * panelBounds.left) / 100 + const posPx = e.clientX - rect.left - leftPx + const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100)) + terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent]) + } + } + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + cleanup = undefined + } + + cleanup = handleMouseUp + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + } + + return ( + + {(pos) => ( +
+ )} + + ) +} + +function computePanelBounds( + panels: Record, + currentId: string, + targetId: string, + bounds: { top: number; left: number; width: number; height: number }, +): { top: number; left: number; width: number; height: number } | null { + if (currentId === targetId) return bounds + + const panel = panels[currentId] + if (!panel?.children || panel.children.length !== 2) return null + + const [leftId, rightId] = panel.children + const sizes = panel.sizes ?? [50, 50] + const horizontal = panel.direction === "horizontal" + + if (horizontal) { + const topHeight = (bounds.height * sizes[0]) / 100 + const bottomHeight = bounds.height - topHeight + const topBounds = { ...bounds, height: topHeight } + const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bottomHeight } + return ( + computePanelBounds(panels, leftId, targetId, topBounds) ?? + computePanelBounds(panels, rightId, targetId, bottomBounds) + ) + } + + const leftWidth = (bounds.width * sizes[0]) / 100 + const rightWidth = bounds.width - leftWidth + const leftBounds = { ...bounds, width: leftWidth } + const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: rightWidth } + return ( + computePanelBounds(panels, leftId, targetId, leftBounds) ?? + computePanelBounds(panels, rightId, targetId, rightBounds) + ) +} -- cgit v1.2.3