Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import type { JSX } from "solid-js"
import { createSignal, Show } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tabs } from "@opencode-ai/ui/tabs"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLanguage } from "@/context/language"

export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
const terminal = useTerminal()
const language = useLanguage()
const sortable = createSortable(props.terminal.id)
const [editing, setEditing] = createSignal(false)
const [title, setTitle] = createSignal(props.terminal.title)
const [menuOpen, setMenuOpen] = createSignal(false)
const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 })
const [blurEnabled, setBlurEnabled] = createSignal(false)

const label = () => {
language.locale()
Expand All @@ -19,20 +27,138 @@ export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element
if (props.terminal.title) return props.terminal.title
return language.t("terminal.title")
}

const close = () => {
const count = terminal.all().length
terminal.close(props.terminal.id)
if (count === 1) {
props.onClose?.()
}
}

const focus = () => {
if (editing()) return

if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return

const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
}

const edit = (e?: Event) => {
if (e) {
e.stopPropagation()
e.preventDefault()
}

setBlurEnabled(false)
setTitle(props.terminal.title)
setEditing(true)
setTimeout(() => {
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
if (!input) return
input.focus()
input.select()
setTimeout(() => setBlurEnabled(true), 100)
}, 10)
}

const save = () => {
if (!blurEnabled()) return

const value = title().trim()
if (value && value !== props.terminal.title) {
terminal.update({ id: props.terminal.id, title: value })
}
setEditing(false)
}

const keydown = (e: KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault()
save()
return
}
if (e.key === "Escape") {
e.preventDefault()
setEditing(false)
}
}

const menu = (e: MouseEvent) => {
e.preventDefault()
setMenuPosition({ x: e.clientX, y: e.clientY })
setMenuOpen(true)
}

return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
value={props.terminal.id}
onClick={focus}
onMouseDown={(e) => e.preventDefault()}
onContextMenu={menu}
closeButton={
terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
<IconButton
icon="close"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
close()
}}
/>
}
>
{label()}
<span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}>
{label()}
</span>
</Tabs.Trigger>
<Show when={editing()}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input
id={`terminal-title-input-${props.terminal.id}`}
type="text"
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
onBlur={save}
onKeyDown={keydown}
onMouseDown={(e) => e.stopPropagation()}
class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
/>
</div>
</Show>
<DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{
position: "fixed",
left: `${menuPosition().x}px`,
top: `${menuPosition().y}px`,
}}
>
<DropdownMenu.Item onSelect={edit}>
<Icon name="edit" class="w-4 h-4 mr-2" />
Rename
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={close}>
<Icon name="close" class="w-4 h-4 mr-2" />
Close
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
)
Expand Down
12 changes: 8 additions & 4 deletions packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,6 @@ export const Terminal = (props: TerminalProps) => {
// console.log("Scroll position:", ydisp)
// })
socket.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty
.update({
ptyID: local.pty.id,
Expand All @@ -257,10 +256,14 @@ export const Terminal = (props: TerminalProps) => {
})
socket.addEventListener("error", (error) => {
console.error("WebSocket error:", error)
props.onConnectError?.(error)
local.onConnectError?.(error)
})
socket.addEventListener("close", () => {
console.log("WebSocket disconnected")
socket.addEventListener("close", (event) => {
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) {
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
}
})
})

Expand Down Expand Up @@ -293,6 +296,7 @@ export const Terminal = (props: TerminalProps) => {
ref={container}
data-component="terminal"
data-prevent-autofocus
tabIndex={-1}
style={{ "background-color": terminalColors().background }}
classList={{
...(local.classList ?? {}),
Expand Down
48 changes: 33 additions & 15 deletions packages/app/src/context/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type LocalPTY = {
cols?: number
buffer?: string
scrollY?: number
error?: boolean
}

const WORKSPACE_KEY = "__workspace__"
Expand Down Expand Up @@ -107,22 +108,26 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
},
])
const newTerminal = {
id,
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
}
setStore("all", (all) => {
const newAll = [...all, newTerminal]
return newAll
})
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
const index = store.all.findIndex((x) => x.id === pty.id)
if (index !== -1) {
setStore("all", index, (existing) => ({ ...existing, ...pty }))
}
sdk.client.pty
.update({
ptyID: pty.id,
Expand Down Expand Up @@ -157,18 +162,29 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
open(id: string) {
setStore("active", id)
},
next() {
const index = store.all.findIndex((x) => x.id === store.active)
if (index === -1) return
const nextIndex = (index + 1) % store.all.length
setStore("active", store.all[nextIndex]?.id)
},
previous() {
const index = store.all.findIndex((x) => x.id === store.active)
if (index === -1) return
const prevIndex = index === 0 ? store.all.length - 1 : index - 1
setStore("active", store.all[prevIndex]?.id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
const filtered = store.all.filter((x) => x.id !== id)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
const next = index > 0 ? index - 1 : 0
setStore("active", filtered[next]?.id)
}
setStore("all", filtered)
})

await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
Expand Down Expand Up @@ -244,6 +260,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),
next: () => workspace().next(),
previous: () => workspace().previous(),
}
},
})
Loading