Skip to content

Commit 88fd6a2

Browse files
authored
feat(desktop): Terminal Splits (#8767)
1 parent ea8ef37 commit 88fd6a2

File tree

7 files changed

+729
-71
lines changed

7 files changed

+729
-71
lines changed

packages/app/src/components/session/session-sortable-terminal-tab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element
1414
<Tabs.Trigger
1515
value={props.terminal.id}
1616
closeButton={
17-
terminal.all().length > 1 && (
18-
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
17+
terminal.tabs().length > 1 && (
18+
<IconButton icon="close" variant="ghost" onClick={() => terminal.closeTab(props.terminal.tabId)} />
1919
)
2020
}
2121
>
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"
2+
import { Terminal } from "./terminal"
3+
import { useTerminal, type Panel } from "@/context/terminal"
4+
import { IconButton } from "@opencode-ai/ui/icon-button"
5+
6+
export interface TerminalSplitProps {
7+
tabId: string
8+
}
9+
10+
function computeLayout(
11+
panels: Record<string, Panel>,
12+
panelId: string,
13+
bounds: { top: number; left: number; width: number; height: number },
14+
): Map<string, { top: number; left: number; width: number; height: number }> {
15+
const result = new Map<string, { top: number; left: number; width: number; height: number }>()
16+
const panel = panels[panelId]
17+
if (!panel) return result
18+
19+
if (panel.ptyId) {
20+
result.set(panel.ptyId, bounds)
21+
} else if (panel.children && panel.children.length === 2) {
22+
const [leftId, rightId] = panel.children
23+
const sizes = panel.sizes ?? [50, 50]
24+
25+
if (panel.direction === "horizontal") {
26+
const topHeight = (bounds.height * sizes[0]) / 100
27+
const topBounds = { ...bounds, height: topHeight }
28+
const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bounds.height - topHeight }
29+
for (const [k, v] of computeLayout(panels, leftId, topBounds)) result.set(k, v)
30+
for (const [k, v] of computeLayout(panels, rightId, bottomBounds)) result.set(k, v)
31+
} else {
32+
const leftWidth = (bounds.width * sizes[0]) / 100
33+
const leftBounds = { ...bounds, width: leftWidth }
34+
const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: bounds.width - leftWidth }
35+
for (const [k, v] of computeLayout(panels, leftId, leftBounds)) result.set(k, v)
36+
for (const [k, v] of computeLayout(panels, rightId, rightBounds)) result.set(k, v)
37+
}
38+
}
39+
40+
return result
41+
}
42+
43+
function findPanelForPty(panels: Record<string, Panel>, ptyId: string): string | undefined {
44+
for (const [id, panel] of Object.entries(panels)) {
45+
if (panel.ptyId === ptyId) return id
46+
}
47+
}
48+
49+
export function TerminalSplit(props: TerminalSplitProps) {
50+
const terminal = useTerminal()
51+
const pane = createMemo(() => terminal.pane(props.tabId))
52+
const terminals = createMemo(() => terminal.all().filter((t) => t.tabId === props.tabId))
53+
const [containerFocused, setContainerFocused] = createSignal(true)
54+
55+
const layout = createMemo(() => {
56+
const p = pane()
57+
if (!p) {
58+
const single = terminals()[0]
59+
if (!single) return new Map()
60+
return new Map([[single.id, { top: 0, left: 0, width: 100, height: 100 }]])
61+
}
62+
return computeLayout(p.panels, p.root, { top: 0, left: 0, width: 100, height: 100 })
63+
})
64+
65+
const focused = createMemo(() => {
66+
const p = pane()
67+
if (!p) return props.tabId
68+
const focusedPanel = p.panels[p.focused ?? ""]
69+
return focusedPanel?.ptyId ?? props.tabId
70+
})
71+
72+
const handleFocus = (ptyId: string) => {
73+
const p = pane()
74+
if (!p) return
75+
const panelId = findPanelForPty(p.panels, ptyId)
76+
if (panelId) terminal.focus(props.tabId, panelId)
77+
}
78+
79+
const handleClose = (ptyId: string) => {
80+
const pty = terminal.all().find((t) => t.id === ptyId)
81+
if (!pty) return
82+
83+
const p = pane()
84+
if (!p) {
85+
if (pty.tabId === props.tabId) {
86+
terminal.closeTab(props.tabId)
87+
}
88+
return
89+
}
90+
const panelId = findPanelForPty(p.panels, ptyId)
91+
if (panelId) terminal.closeSplit(props.tabId, panelId)
92+
}
93+
94+
return (
95+
<div
96+
class="relative size-full"
97+
data-terminal-split-container
98+
onFocusIn={() => setContainerFocused(true)}
99+
onFocusOut={(e) => {
100+
const related = e.relatedTarget as Node | null
101+
if (!related || !e.currentTarget.contains(related)) {
102+
setContainerFocused(false)
103+
}
104+
}}
105+
>
106+
<For each={terminals()}>
107+
{(pty) => {
108+
const bounds = createMemo(() => layout().get(pty.id) ?? { top: 0, left: 0, width: 100, height: 100 })
109+
const isFocused = createMemo(() => focused() === pty.id)
110+
const hasSplits = createMemo(() => !!pane())
111+
112+
return (
113+
<div
114+
class="absolute flex flex-col min-h-0"
115+
classList={{
116+
"ring-1 ring-inset ring-border-strong-base": containerFocused() && isFocused(),
117+
"border-l border-border-weak-base": bounds().left > 0,
118+
"border-t border-border-weak-base": bounds().top > 0,
119+
}}
120+
style={{
121+
top: `${bounds().top}%`,
122+
left: `${bounds().left}%`,
123+
width: `${bounds().width}%`,
124+
height: `${bounds().height}%`,
125+
}}
126+
onClick={() => handleFocus(pty.id)}
127+
>
128+
<Show when={pane()}>
129+
<div class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity">
130+
<IconButton
131+
icon="close"
132+
variant="ghost"
133+
onClick={(e) => {
134+
e.stopPropagation()
135+
handleClose(pty.id)
136+
}}
137+
/>
138+
</div>
139+
</Show>
140+
<div
141+
class="flex-1 min-h-0"
142+
classList={{ "opacity-50": !containerFocused() || (hasSplits() && !isFocused()) }}
143+
>
144+
<Terminal
145+
pty={pty}
146+
focused={isFocused()}
147+
onCleanup={terminal.update}
148+
onConnectError={() => terminal.clone(pty.id)}
149+
onExit={() => handleClose(pty.id)}
150+
class="size-full"
151+
/>
152+
</div>
153+
</div>
154+
)
155+
}}
156+
</For>
157+
<ResizeHandles tabId={props.tabId} />
158+
</div>
159+
)
160+
}
161+
162+
function ResizeHandles(props: { tabId: string }) {
163+
const terminal = useTerminal()
164+
const pane = createMemo(() => terminal.pane(props.tabId))
165+
166+
const splits = createMemo(() => {
167+
const p = pane()
168+
if (!p) return []
169+
return Object.values(p.panels).filter((panel) => panel.children && panel.children.length === 2)
170+
})
171+
172+
return <For each={splits()}>{(panel) => <ResizeHandle tabId={props.tabId} panelId={panel.id} />}</For>
173+
}
174+
175+
function ResizeHandle(props: { tabId: string; panelId: string }) {
176+
const terminal = useTerminal()
177+
const pane = createMemo(() => terminal.pane(props.tabId))
178+
const panel = createMemo(() => pane()?.panels[props.panelId])
179+
180+
let cleanup: VoidFunction | undefined
181+
182+
onCleanup(() => cleanup?.())
183+
184+
const position = createMemo(() => {
185+
const p = pane()
186+
if (!p) return null
187+
const pan = panel()
188+
if (!pan?.children || pan.children.length !== 2) return null
189+
190+
const bounds = computePanelBounds(p.panels, p.root, props.panelId, {
191+
top: 0,
192+
left: 0,
193+
width: 100,
194+
height: 100,
195+
})
196+
if (!bounds) return null
197+
198+
const sizes = pan.sizes ?? [50, 50]
199+
200+
if (pan.direction === "horizontal") {
201+
return {
202+
horizontal: true,
203+
top: bounds.top + (bounds.height * sizes[0]) / 100,
204+
left: bounds.left,
205+
size: bounds.width,
206+
}
207+
}
208+
return {
209+
horizontal: false,
210+
top: bounds.top,
211+
left: bounds.left + (bounds.width * sizes[0]) / 100,
212+
size: bounds.height,
213+
}
214+
})
215+
216+
const handleMouseDown = (e: MouseEvent) => {
217+
e.preventDefault()
218+
219+
const pos = position()
220+
if (!pos) return
221+
222+
const container = (e.target as HTMLElement).closest("[data-terminal-split-container]") as HTMLElement
223+
if (!container) return
224+
225+
const rect = container.getBoundingClientRect()
226+
const pan = panel()
227+
if (!pan) return
228+
229+
const p = pane()
230+
if (!p) return
231+
const panelBounds = computePanelBounds(p.panels, p.root, props.panelId, {
232+
top: 0,
233+
left: 0,
234+
width: 100,
235+
height: 100,
236+
})
237+
if (!panelBounds) return
238+
239+
const handleMouseMove = (e: MouseEvent) => {
240+
if (pan.direction === "horizontal") {
241+
const totalPx = (rect.height * panelBounds.height) / 100
242+
const topPx = (rect.height * panelBounds.top) / 100
243+
const posPx = e.clientY - rect.top - topPx
244+
const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100))
245+
terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent])
246+
} else {
247+
const totalPx = (rect.width * panelBounds.width) / 100
248+
const leftPx = (rect.width * panelBounds.left) / 100
249+
const posPx = e.clientX - rect.left - leftPx
250+
const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100))
251+
terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent])
252+
}
253+
}
254+
255+
const handleMouseUp = () => {
256+
document.removeEventListener("mousemove", handleMouseMove)
257+
document.removeEventListener("mouseup", handleMouseUp)
258+
cleanup = undefined
259+
}
260+
261+
cleanup = handleMouseUp
262+
document.addEventListener("mousemove", handleMouseMove)
263+
document.addEventListener("mouseup", handleMouseUp)
264+
}
265+
266+
return (
267+
<Show when={position()}>
268+
{(pos) => (
269+
<div
270+
data-component="resize-handle"
271+
data-direction={pos().horizontal ? "vertical" : "horizontal"}
272+
class="absolute"
273+
style={{
274+
top: `${pos().top}%`,
275+
left: `${pos().left}%`,
276+
width: pos().horizontal ? `${pos().size}%` : "8px",
277+
height: pos().horizontal ? "8px" : `${pos().size}%`,
278+
transform: pos().horizontal ? "translateY(-50%)" : "translateX(-50%)",
279+
cursor: pos().horizontal ? "row-resize" : "col-resize",
280+
}}
281+
onMouseDown={handleMouseDown}
282+
/>
283+
)}
284+
</Show>
285+
)
286+
}
287+
288+
function computePanelBounds(
289+
panels: Record<string, Panel>,
290+
currentId: string,
291+
targetId: string,
292+
bounds: { top: number; left: number; width: number; height: number },
293+
): { top: number; left: number; width: number; height: number } | null {
294+
if (currentId === targetId) return bounds
295+
296+
const panel = panels[currentId]
297+
if (!panel?.children || panel.children.length !== 2) return null
298+
299+
const [leftId, rightId] = panel.children
300+
const sizes = panel.sizes ?? [50, 50]
301+
const horizontal = panel.direction === "horizontal"
302+
303+
if (horizontal) {
304+
const topHeight = (bounds.height * sizes[0]) / 100
305+
const bottomHeight = bounds.height - topHeight
306+
const topBounds = { ...bounds, height: topHeight }
307+
const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bottomHeight }
308+
return (
309+
computePanelBounds(panels, leftId, targetId, topBounds) ??
310+
computePanelBounds(panels, rightId, targetId, bottomBounds)
311+
)
312+
}
313+
314+
const leftWidth = (bounds.width * sizes[0]) / 100
315+
const rightWidth = bounds.width - leftWidth
316+
const leftBounds = { ...bounds, width: leftWidth }
317+
const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: rightWidth }
318+
return (
319+
computePanelBounds(panels, leftId, targetId, leftBounds) ??
320+
computePanelBounds(panels, rightId, targetId, rightBounds)
321+
)
322+
}

0 commit comments

Comments
 (0)