feat(desktop): Terminal Splits (#8767)
This commit is contained in:
@@ -14,8 +14,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element
|
|||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={props.terminal.id}
|
value={props.terminal.id}
|
||||||
closeButton={
|
closeButton={
|
||||||
terminal.all().length > 1 && (
|
terminal.tabs().length > 1 && (
|
||||||
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
|
<IconButton icon="close" variant="ghost" onClick={() => terminal.closeTab(props.terminal.tabId)} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
322
packages/app/src/components/terminal-split.tsx
Normal file
322
packages/app/src/components/terminal-split.tsx
Normal file
@@ -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<string, Panel>,
|
||||||
|
panelId: string,
|
||||||
|
bounds: { top: number; left: number; width: number; height: number },
|
||||||
|
): Map<string, { top: number; left: number; width: number; height: number }> {
|
||||||
|
const result = new Map<string, { top: number; left: number; width: number; height: number }>()
|
||||||
|
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<string, Panel>, 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 (
|
||||||
|
<div
|
||||||
|
class="relative size-full"
|
||||||
|
data-terminal-split-container
|
||||||
|
onFocusIn={() => setContainerFocused(true)}
|
||||||
|
onFocusOut={(e) => {
|
||||||
|
const related = e.relatedTarget as Node | null
|
||||||
|
if (!related || !e.currentTarget.contains(related)) {
|
||||||
|
setContainerFocused(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<For each={terminals()}>
|
||||||
|
{(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 (
|
||||||
|
<div
|
||||||
|
class="absolute flex flex-col min-h-0"
|
||||||
|
classList={{
|
||||||
|
"ring-1 ring-inset ring-border-strong-base": containerFocused() && isFocused(),
|
||||||
|
"border-l border-border-weak-base": bounds().left > 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)}
|
||||||
|
>
|
||||||
|
<Show when={pane()}>
|
||||||
|
<div class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
<IconButton
|
||||||
|
icon="close"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleClose(pty.id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div
|
||||||
|
class="flex-1 min-h-0"
|
||||||
|
classList={{ "opacity-50": !containerFocused() || (hasSplits() && !isFocused()) }}
|
||||||
|
>
|
||||||
|
<Terminal
|
||||||
|
pty={pty}
|
||||||
|
focused={isFocused()}
|
||||||
|
onCleanup={terminal.update}
|
||||||
|
onConnectError={() => terminal.clone(pty.id)}
|
||||||
|
onExit={() => handleClose(pty.id)}
|
||||||
|
class="size-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<ResizeHandles tabId={props.tabId} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <For each={splits()}>{(panel) => <ResizeHandle tabId={props.tabId} panelId={panel.id} />}</For>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Show when={position()}>
|
||||||
|
{(pos) => (
|
||||||
|
<div
|
||||||
|
data-component="resize-handle"
|
||||||
|
data-direction={pos().horizontal ? "vertical" : "horizontal"}
|
||||||
|
class="absolute"
|
||||||
|
style={{
|
||||||
|
top: `${pos().top}%`,
|
||||||
|
left: `${pos().left}%`,
|
||||||
|
width: pos().horizontal ? `${pos().size}%` : "8px",
|
||||||
|
height: pos().horizontal ? "8px" : `${pos().size}%`,
|
||||||
|
transform: pos().horizontal ? "translateY(-50%)" : "translateX(-50%)",
|
||||||
|
cursor: pos().horizontal ? "row-resize" : "col-resize",
|
||||||
|
}}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePanelBounds(
|
||||||
|
panels: Record<string, Panel>,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
|
|||||||
|
|
||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
|
focused?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
onCleanup?: (pty: LocalPTY) => void
|
onCleanup?: (pty: LocalPTY) => void
|
||||||
onConnectError?: (error: unknown) => void
|
onConnectError?: (error: unknown) => void
|
||||||
|
onExit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalColors = {
|
type TerminalColors = {
|
||||||
@@ -38,7 +40,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "focused", "class", "classList", "onConnectError"])
|
||||||
let ws: WebSocket | undefined
|
let ws: WebSocket | undefined
|
||||||
let term: Term | undefined
|
let term: Term | undefined
|
||||||
let ghostty: Ghostty
|
let ghostty: Ghostty
|
||||||
@@ -49,6 +51,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
let handleTextareaBlur: () => void
|
let handleTextareaBlur: () => void
|
||||||
let reconnect: number | undefined
|
let reconnect: number | undefined
|
||||||
let disposed = false
|
let disposed = false
|
||||||
|
let cleaning = false
|
||||||
|
|
||||||
const getTerminalColors = (): TerminalColors => {
|
const getTerminalColors = (): TerminalColors => {
|
||||||
const mode = theme.mode()
|
const mode = theme.mode()
|
||||||
@@ -88,6 +91,11 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
t.focus()
|
t.focus()
|
||||||
setTimeout(() => t.textarea?.focus(), 0)
|
setTimeout(() => t.textarea?.focus(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (local.focused) focusTerminal()
|
||||||
|
})
|
||||||
|
|
||||||
const handlePointerDown = () => {
|
const handlePointerDown = () => {
|
||||||
const activeElement = document.activeElement
|
const activeElement = document.activeElement
|
||||||
if (activeElement instanceof HTMLElement && activeElement !== container) {
|
if (activeElement instanceof HTMLElement && activeElement !== container) {
|
||||||
@@ -166,6 +174,11 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// allow cmd+d and cmd+shift+d for terminal splitting
|
||||||
|
if (event.metaKey && key === "d") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -231,7 +244,6 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
// console.log("Scroll position:", ydisp)
|
// console.log("Scroll position:", ydisp)
|
||||||
// })
|
// })
|
||||||
socket.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
console.log("WebSocket connected")
|
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: local.pty.id,
|
ptyID: local.pty.id,
|
||||||
@@ -250,7 +262,9 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
props.onConnectError?.(error)
|
props.onConnectError?.(error)
|
||||||
})
|
})
|
||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
console.log("WebSocket disconnected")
|
if (!cleaning) {
|
||||||
|
props.onExit?.()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -274,6 +288,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleaning = true
|
||||||
ws?.close()
|
ws?.close()
|
||||||
t?.dispose()
|
t?.dispose()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,12 +9,31 @@ export type LocalPTY = {
|
|||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
titleNumber: number
|
titleNumber: number
|
||||||
|
tabId: string
|
||||||
rows?: number
|
rows?: number
|
||||||
cols?: number
|
cols?: number
|
||||||
buffer?: string
|
buffer?: string
|
||||||
scrollY?: number
|
scrollY?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SplitDirection = "horizontal" | "vertical"
|
||||||
|
|
||||||
|
export type Panel = {
|
||||||
|
id: string
|
||||||
|
parentId?: string
|
||||||
|
ptyId?: string
|
||||||
|
direction?: SplitDirection
|
||||||
|
children?: [string, string]
|
||||||
|
sizes?: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabPane = {
|
||||||
|
id: string
|
||||||
|
root: string
|
||||||
|
panels: Record<string, Panel>
|
||||||
|
focused?: string
|
||||||
|
}
|
||||||
|
|
||||||
const WORKSPACE_KEY = "__workspace__"
|
const WORKSPACE_KEY = "__workspace__"
|
||||||
const MAX_TERMINAL_SESSIONS = 20
|
const MAX_TERMINAL_SESSIONS = 20
|
||||||
|
|
||||||
@@ -25,6 +44,10 @@ type TerminalCacheEntry = {
|
|||||||
dispose: VoidFunction
|
dispose: VoidFunction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
|
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
|
||||||
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
|
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
|
||||||
|
|
||||||
@@ -33,47 +56,102 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
|
|||||||
createStore<{
|
createStore<{
|
||||||
active?: string
|
active?: string
|
||||||
all: LocalPTY[]
|
all: LocalPTY[]
|
||||||
|
panes: Record<string, TabPane>
|
||||||
}>({
|
}>({
|
||||||
all: [],
|
all: [],
|
||||||
|
panes: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getNextTitleNumber = () => {
|
||||||
|
const existing = new Set(store.all.filter((p) => p.tabId === p.id).map((pty) => pty.titleNumber))
|
||||||
|
let next = 1
|
||||||
|
while (existing.has(next)) next++
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const createPty = async (tabId?: string): Promise<LocalPTY | undefined> => {
|
||||||
|
const tab = tabId ? store.all.find((p) => p.id === tabId) : undefined
|
||||||
|
const num = tab?.titleNumber ?? getNextTitleNumber()
|
||||||
|
const title = tab?.title ?? `Terminal ${num}`
|
||||||
|
const pty = await sdk.client.pty.create({ title }).catch((e) => {
|
||||||
|
console.error("Failed to create terminal", e)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
if (!pty?.data?.id) return undefined
|
||||||
|
return {
|
||||||
|
id: pty.data.id,
|
||||||
|
title,
|
||||||
|
titleNumber: num,
|
||||||
|
tabId: tabId ?? pty.data.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAllPtyIds = (pane: TabPane, panelId: string): string[] => {
|
||||||
|
const panel = pane.panels[panelId]
|
||||||
|
if (!panel) return []
|
||||||
|
if (panel.ptyId) return [panel.ptyId]
|
||||||
|
if (panel.children && panel.children.length === 2) {
|
||||||
|
return [...getAllPtyIds(pane, panel.children[0]), ...getAllPtyIds(pane, panel.children[1])]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFirstLeaf = (pane: TabPane, panelId: string): string | undefined => {
|
||||||
|
const panel = pane.panels[panelId]
|
||||||
|
if (!panel) return undefined
|
||||||
|
if (panel.ptyId) return panelId
|
||||||
|
if (panel.children?.[0]) return getFirstLeaf(pane, panel.children[0])
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrate = (terminals: LocalPTY[]) =>
|
||||||
|
terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id }))
|
||||||
|
|
||||||
|
const tabCache = new Map<string, LocalPTY>()
|
||||||
|
const tabs = createMemo(() => {
|
||||||
|
const migrated = migrate(store.all)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: LocalPTY[] = []
|
||||||
|
for (const p of migrated) {
|
||||||
|
if (!seen.has(p.tabId)) {
|
||||||
|
seen.add(p.tabId)
|
||||||
|
const cached = tabCache.get(p.tabId)
|
||||||
|
if (cached) {
|
||||||
|
cached.title = p.title
|
||||||
|
cached.titleNumber = p.titleNumber
|
||||||
|
result.push(cached)
|
||||||
|
} else {
|
||||||
|
const tab = { ...p, id: p.tabId }
|
||||||
|
tabCache.set(p.tabId, tab)
|
||||||
|
result.push(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of tabCache.keys()) {
|
||||||
|
if (!seen.has(key)) tabCache.delete(key)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
const all = createMemo(() => migrate(store.all))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
all: createMemo(() => Object.values(store.all)),
|
tabs,
|
||||||
active: createMemo(() => store.active),
|
all,
|
||||||
new() {
|
active: () => store.active,
|
||||||
const existingTitleNumbers = new Set(
|
panes: () => store.panes,
|
||||||
store.all.map((pty) => {
|
pane: (tabId: string) => store.panes[tabId],
|
||||||
const match = pty.titleNumber
|
panel: (tabId: string, panelId: string) => store.panes[tabId]?.panels[panelId],
|
||||||
return match
|
focused: (tabId: string) => store.panes[tabId]?.focused,
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
let nextNumber = 1
|
async new() {
|
||||||
while (existingTitleNumbers.has(nextNumber)) {
|
const pty = await createPty()
|
||||||
nextNumber++
|
if (!pty) return
|
||||||
}
|
setStore("all", [...store.all, pty])
|
||||||
|
setStore("active", pty.tabId)
|
||||||
sdk.client.pty
|
|
||||||
.create({ title: `Terminal ${nextNumber}` })
|
|
||||||
.then((pty) => {
|
|
||||||
const id = pty.data?.id
|
|
||||||
if (!id) return
|
|
||||||
setStore("all", [
|
|
||||||
...store.all,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
title: pty.data?.title ?? "Terminal",
|
|
||||||
titleNumber: nextNumber,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setStore("active", id)
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to create terminal", e)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
update(pty: Partial<LocalPTY> & { id: string }) {
|
update(pty: Partial<LocalPTY> & { id: string }) {
|
||||||
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
|
||||||
sdk.client.pty
|
sdk.client.pty
|
||||||
@@ -86,46 +164,82 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
|
|||||||
console.error("Failed to update terminal", e)
|
console.error("Failed to update terminal", e)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async clone(id: string) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
if (!pty) return
|
if (!pty) return
|
||||||
const clone = await sdk.client.pty
|
const clone = await sdk.client.pty.create({ title: pty.title }).catch((e) => {
|
||||||
.create({
|
console.error("Failed to clone terminal", e)
|
||||||
title: pty.title,
|
return undefined
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to clone terminal", e)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
if (!clone?.data) return
|
|
||||||
setStore("all", index, {
|
|
||||||
...pty,
|
|
||||||
...clone.data,
|
|
||||||
})
|
})
|
||||||
if (store.active === pty.id) {
|
if (!clone?.data) return
|
||||||
setStore("active", clone.data.id)
|
setStore("all", index, { ...pty, ...clone.data })
|
||||||
|
if (store.active === pty.tabId) {
|
||||||
|
setStore("active", pty.tabId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
open(id: string) {
|
open(id: string) {
|
||||||
setStore("active", id)
|
setStore("active", id)
|
||||||
},
|
},
|
||||||
|
|
||||||
async close(id: string) {
|
async close(id: string) {
|
||||||
batch(() => {
|
const pty = store.all.find((x) => x.id === id)
|
||||||
setStore(
|
if (!pty) return
|
||||||
"all",
|
|
||||||
store.all.filter((x) => x.id !== id),
|
const pane = store.panes[pty.tabId]
|
||||||
)
|
if (pane) {
|
||||||
if (store.active === id) {
|
const panelId = Object.keys(pane.panels).find((key) => pane.panels[key].ptyId === id)
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
if (panelId) {
|
||||||
const previous = store.all[Math.max(0, index - 1)]
|
await this.closeSplit(pty.tabId, panelId)
|
||||||
setStore("active", previous?.id)
|
return
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (store.active === pty.tabId) {
|
||||||
|
const remaining = store.all.filter((p) => p.tabId === p.id && p.id !== id)
|
||||||
|
setStore("active", remaining[0]?.tabId)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"all",
|
||||||
|
store.all.filter((x) => x.id !== id),
|
||||||
|
)
|
||||||
|
|
||||||
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
|
||||||
console.error("Failed to close terminal", e)
|
console.error("Failed to close terminal", e)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async closeTab(tabId: string) {
|
||||||
|
const pane = store.panes[tabId]
|
||||||
|
const terminalsInTab = store.all.filter((p) => p.tabId === tabId)
|
||||||
|
const ptyIds = pane ? getAllPtyIds(pane, pane.root) : terminalsInTab.map((p) => p.id)
|
||||||
|
|
||||||
|
const remainingTabs = store.all.filter((p) => p.tabId !== tabId)
|
||||||
|
const uniqueTabIds = [...new Set(remainingTabs.map((p) => p.tabId))]
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"all",
|
||||||
|
store.all.filter((x) => !ptyIds.includes(x.id)),
|
||||||
|
)
|
||||||
|
setStore(
|
||||||
|
"panes",
|
||||||
|
produce((panes) => {
|
||||||
|
delete panes[tabId]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (store.active === tabId) {
|
||||||
|
setStore("active", uniqueTabIds[0])
|
||||||
|
}
|
||||||
|
for (const ptyId of ptyIds) {
|
||||||
|
await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => {
|
||||||
|
console.error("Failed to close terminal", e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
move(id: string, to: number) {
|
move(id: string, to: number) {
|
||||||
const index = store.all.findIndex((f) => f.id === id)
|
const index = store.all.findIndex((f) => f.id === id)
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
@@ -136,6 +250,159 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async split(tabId: string, direction: SplitDirection) {
|
||||||
|
const pane = store.panes[tabId]
|
||||||
|
const newPty = await createPty(tabId)
|
||||||
|
if (!newPty) return
|
||||||
|
|
||||||
|
setStore("all", [...store.all, newPty])
|
||||||
|
|
||||||
|
if (!pane) {
|
||||||
|
const rootId = generateId()
|
||||||
|
const leftId = generateId()
|
||||||
|
const rightId = generateId()
|
||||||
|
|
||||||
|
setStore("panes", tabId, {
|
||||||
|
id: tabId,
|
||||||
|
root: rootId,
|
||||||
|
panels: {
|
||||||
|
[rootId]: {
|
||||||
|
id: rootId,
|
||||||
|
direction,
|
||||||
|
children: [leftId, rightId],
|
||||||
|
sizes: [50, 50],
|
||||||
|
},
|
||||||
|
[leftId]: {
|
||||||
|
id: leftId,
|
||||||
|
parentId: rootId,
|
||||||
|
ptyId: tabId,
|
||||||
|
},
|
||||||
|
[rightId]: {
|
||||||
|
id: rightId,
|
||||||
|
parentId: rootId,
|
||||||
|
ptyId: newPty.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
focused: rightId,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const focusedPanelId = pane.focused
|
||||||
|
if (!focusedPanelId) return
|
||||||
|
|
||||||
|
const focusedPanel = pane.panels[focusedPanelId]
|
||||||
|
if (!focusedPanel?.ptyId) return
|
||||||
|
|
||||||
|
const oldPtyId = focusedPanel.ptyId
|
||||||
|
const newSplitId = generateId()
|
||||||
|
const newTerminalId = generateId()
|
||||||
|
|
||||||
|
setStore("panes", tabId, "panels", newSplitId, {
|
||||||
|
id: newSplitId,
|
||||||
|
parentId: focusedPanelId,
|
||||||
|
ptyId: oldPtyId,
|
||||||
|
})
|
||||||
|
setStore("panes", tabId, "panels", newTerminalId, {
|
||||||
|
id: newTerminalId,
|
||||||
|
parentId: focusedPanelId,
|
||||||
|
ptyId: newPty.id,
|
||||||
|
})
|
||||||
|
setStore("panes", tabId, "panels", focusedPanelId, "ptyId", undefined)
|
||||||
|
setStore("panes", tabId, "panels", focusedPanelId, "direction", direction)
|
||||||
|
setStore("panes", tabId, "panels", focusedPanelId, "children", [newSplitId, newTerminalId])
|
||||||
|
setStore("panes", tabId, "panels", focusedPanelId, "sizes", [50, 50])
|
||||||
|
setStore("panes", tabId, "focused", newTerminalId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
focus(tabId: string, panelId: string) {
|
||||||
|
if (store.panes[tabId]) {
|
||||||
|
setStore("panes", tabId, "focused", panelId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async closeSplit(tabId: string, panelId: string) {
|
||||||
|
const pane = store.panes[tabId]
|
||||||
|
if (!pane) return
|
||||||
|
|
||||||
|
const panel = pane.panels[panelId]
|
||||||
|
if (!panel) return
|
||||||
|
|
||||||
|
const ptyId = panel.ptyId
|
||||||
|
if (!ptyId) return
|
||||||
|
|
||||||
|
if (!panel.parentId) {
|
||||||
|
await this.closeTab(tabId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPanel = pane.panels[panel.parentId]
|
||||||
|
if (!parentPanel?.children || parentPanel.children.length !== 2) return
|
||||||
|
|
||||||
|
const siblingId = parentPanel.children[0] === panelId ? parentPanel.children[1] : parentPanel.children[0]
|
||||||
|
const sibling = pane.panels[siblingId]
|
||||||
|
if (!sibling) return
|
||||||
|
|
||||||
|
const newFocused = sibling.ptyId ? panel.parentId! : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!)
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setStore(
|
||||||
|
"panes",
|
||||||
|
tabId,
|
||||||
|
"panels",
|
||||||
|
produce((panels) => {
|
||||||
|
const parent = panels[panel.parentId!]
|
||||||
|
if (!parent) return
|
||||||
|
|
||||||
|
if (sibling.ptyId) {
|
||||||
|
parent.ptyId = sibling.ptyId
|
||||||
|
parent.direction = undefined
|
||||||
|
parent.children = undefined
|
||||||
|
parent.sizes = undefined
|
||||||
|
} else if (sibling.children && sibling.children.length === 2) {
|
||||||
|
parent.ptyId = undefined
|
||||||
|
parent.direction = sibling.direction
|
||||||
|
parent.children = sibling.children
|
||||||
|
parent.sizes = sibling.sizes
|
||||||
|
panels[sibling.children[0]].parentId = panel.parentId!
|
||||||
|
panels[sibling.children[1]].parentId = panel.parentId!
|
||||||
|
}
|
||||||
|
|
||||||
|
delete panels[panelId]
|
||||||
|
delete panels[siblingId]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
setStore("panes", tabId, "focused", newFocused)
|
||||||
|
|
||||||
|
setStore(
|
||||||
|
"all",
|
||||||
|
store.all.filter((x) => x.id !== ptyId),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {})
|
||||||
|
const shouldCleanupPane = remainingPanels.length === 1 && remainingPanels[0]?.ptyId
|
||||||
|
|
||||||
|
if (shouldCleanupPane) {
|
||||||
|
setStore(
|
||||||
|
"panes",
|
||||||
|
produce((panes) => {
|
||||||
|
delete panes[tabId]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => {
|
||||||
|
console.error("Failed to close terminal", e)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
resizeSplit(tabId: string, panelId: string, sizes: [number, number]) {
|
||||||
|
if (store.panes[tabId]?.panels[panelId]) {
|
||||||
|
setStore("panes", tabId, "panels", panelId, "sizes", sizes)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,14 +456,25 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => session().ready(),
|
ready: () => session().ready(),
|
||||||
|
tabs: () => session().tabs(),
|
||||||
all: () => session().all(),
|
all: () => session().all(),
|
||||||
active: () => session().active(),
|
active: () => session().active(),
|
||||||
|
panes: () => session().panes(),
|
||||||
|
pane: (tabId: string) => session().pane(tabId),
|
||||||
|
panel: (tabId: string, panelId: string) => session().panel(tabId, panelId),
|
||||||
|
focused: (tabId: string) => session().focused(tabId),
|
||||||
new: () => session().new(),
|
new: () => session().new(),
|
||||||
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
|
||||||
clone: (id: string) => session().clone(id),
|
clone: (id: string) => session().clone(id),
|
||||||
open: (id: string) => session().open(id),
|
open: (id: string) => session().open(id),
|
||||||
close: (id: string) => session().close(id),
|
close: (id: string) => session().close(id),
|
||||||
|
closeTab: (tabId: string) => session().closeTab(tabId),
|
||||||
move: (id: string, to: number) => session().move(id, to),
|
move: (id: string, to: number) => session().move(id, to),
|
||||||
|
split: (tabId: string, direction: SplitDirection) => session().split(tabId, direction),
|
||||||
|
focus: (tabId: string, panelId: string) => session().focus(tabId, panelId),
|
||||||
|
closeSplit: (tabId: string, panelId: string) => session().closeSplit(tabId, panelId),
|
||||||
|
resizeSplit: (tabId: string, panelId: string, sizes: [number, number]) =>
|
||||||
|
session().resizeSplit(tabId, panelId, sizes),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,3 +9,16 @@
|
|||||||
*[data-tauri-drag-region] {
|
*[data-tauri-drag-region] {
|
||||||
app-region: drag;
|
app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Terminal split resize handles */
|
||||||
|
[data-terminal-split-container] [data-component="resize-handle"] {
|
||||||
|
inset: unset;
|
||||||
|
|
||||||
|
&[data-direction="horizontal"] {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-direction="vertical"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { Terminal } from "@/components/terminal"
|
import { Terminal } from "@/components/terminal"
|
||||||
|
import { TerminalSplit } from "@/components/terminal-split"
|
||||||
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
|
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||||
@@ -170,6 +171,7 @@ export default function Page() {
|
|||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||||
const view = createMemo(() => layout.view(sessionKey()))
|
const view = createMemo(() => layout.view(sessionKey()))
|
||||||
|
const activeTerminal = createMemo(() => terminal.active())
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -380,7 +382,7 @@ export default function Page() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!view().terminal.opened()) return
|
if (!view().terminal.opened()) return
|
||||||
if (!terminal.ready()) return
|
if (!terminal.ready()) return
|
||||||
if (terminal.all().length !== 0) return
|
if (terminal.tabs().length !== 0) return
|
||||||
terminal.new()
|
terminal.new()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -459,6 +461,30 @@ export default function Page() {
|
|||||||
keybind: "ctrl+shift+`",
|
keybind: "ctrl+shift+`",
|
||||||
onSelect: () => terminal.new(),
|
onSelect: () => terminal.new(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "terminal.split.vertical",
|
||||||
|
title: "Split terminal right",
|
||||||
|
description: "Split the current terminal vertically",
|
||||||
|
category: "Terminal",
|
||||||
|
keybind: "mod+d",
|
||||||
|
disabled: !terminal.active(),
|
||||||
|
onSelect: () => {
|
||||||
|
const active = terminal.active()
|
||||||
|
if (active) terminal.split(active, "vertical")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terminal.split.horizontal",
|
||||||
|
title: "Split terminal down",
|
||||||
|
description: "Split the current terminal horizontally",
|
||||||
|
category: "Terminal",
|
||||||
|
keybind: "mod+shift+d",
|
||||||
|
disabled: !terminal.active(),
|
||||||
|
onSelect: () => {
|
||||||
|
const active = terminal.active()
|
||||||
|
if (active) terminal.split(active, "horizontal")
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "steps.toggle",
|
id: "steps.toggle",
|
||||||
title: "Toggle steps",
|
title: "Toggle steps",
|
||||||
@@ -707,7 +733,7 @@ export default function Page() {
|
|||||||
const handleTerminalDragOver = (event: DragEvent) => {
|
const handleTerminalDragOver = (event: DragEvent) => {
|
||||||
const { draggable, droppable } = event
|
const { draggable, droppable } = event
|
||||||
if (draggable && droppable) {
|
if (draggable && droppable) {
|
||||||
const terminals = terminal.all()
|
const terminals = terminal.tabs()
|
||||||
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
|
||||||
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
|
||||||
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
||||||
@@ -1009,7 +1035,7 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!terminal.ready()) return
|
if (!terminal.ready()) return
|
||||||
handoff.terminals = terminal.all().map((t) => t.title)
|
handoff.terminals = terminal.tabs().map((t) => t.title)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -1666,10 +1692,10 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<DragDropSensors />
|
<DragDropSensors />
|
||||||
<ConstrainDragYAxis />
|
<ConstrainDragYAxis />
|
||||||
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
|
<Tabs variant="alt" value={activeTerminal()} onChange={terminal.open}>
|
||||||
<Tabs.List class="h-10">
|
<Tabs.List class="h-10">
|
||||||
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
|
<SortableProvider ids={terminal.tabs().map((t: LocalPTY) => t.id)}>
|
||||||
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
<For each={terminal.tabs()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
|
||||||
</SortableProvider>
|
</SortableProvider>
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -1681,10 +1707,10 @@ export default function Page() {
|
|||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<For each={terminal.all()}>
|
<For each={terminal.tabs()}>
|
||||||
{(pty) => (
|
{(pty) => (
|
||||||
<Tabs.Content value={pty.id}>
|
<Tabs.Content value={pty.id} class="h-[calc(100%-2.5rem)]">
|
||||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
|
<TerminalSplit tabId={pty.id} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -1692,7 +1718,7 @@ export default function Page() {
|
|||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
<Show when={store.activeTerminalDraggable}>
|
<Show when={store.activeTerminalDraggable}>
|
||||||
{(draggedId) => {
|
{(draggedId) => {
|
||||||
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
|
const pty = createMemo(() => terminal.tabs().find((t: LocalPTY) => t.id === draggedId()))
|
||||||
return (
|
return (
|
||||||
<Show when={pty()}>
|
<Show when={pty()}>
|
||||||
{(t) => (
|
{(t) => (
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export namespace Pty {
|
|||||||
ptyProcess.onExit(({ exitCode }) => {
|
ptyProcess.onExit(({ exitCode }) => {
|
||||||
log.info("session exited", { id, exitCode })
|
log.info("session exited", { id, exitCode })
|
||||||
session.info.status = "exited"
|
session.info.status = "exited"
|
||||||
|
for (const ws of session.subscribers) {
|
||||||
|
ws.close()
|
||||||
|
}
|
||||||
|
session.subscribers.clear()
|
||||||
Bus.publish(Event.Exited, { id, exitCode })
|
Bus.publish(Event.Exited, { id, exitCode })
|
||||||
state().delete(id)
|
state().delete(id)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user