summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/terminal.tsx
blob: c05ddfbf6351e4b429a703a611f3a2dd0adb6bc7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { usePrefersDark } from "@solid-primitives/media"

export interface TerminalProps extends ComponentProps<"div"> {
  pty: LocalPTY
  onSubmit?: () => void
  onCleanup?: (pty: LocalPTY) => void
  onConnectError?: (error: unknown) => void
}

export const Terminal = (props: TerminalProps) => {
  const sdk = useSDK()
  let container!: HTMLDivElement
  const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
  let ws: WebSocket
  let term: Term
  let ghostty: Ghostty
  let serializeAddon: SerializeAddon
  let fitAddon: FitAddon
  let handleResize: () => void
  const prefersDark = usePrefersDark()

  onMount(async () => {
    ghostty = await Ghostty.load()

    ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
    term = new Term({
      cursorBlink: true,
      fontSize: 14,
      fontFamily: "IBM Plex Mono, monospace",
      allowTransparency: true,
      theme: prefersDark()
        ? {
            background: "#191515",
            foreground: "#d4d4d4",
            cursor: "#d4d4d4",
          }
        : {
            background: "#fcfcfc",
            foreground: "#211e1e",
            cursor: "#211e1e",
          },
      scrollback: 10_000,
      ghostty,
    })
    term.attachCustomKeyEventHandler((event) => {
      // allow for ctrl-` to toggle terminal in parent
      if (event.ctrlKey && event.key.toLowerCase() === "`") {
        event.preventDefault()
        return true
      }
      return false
    })

    fitAddon = new FitAddon()
    serializeAddon = new SerializeAddon()
    term.loadAddon(serializeAddon)
    term.loadAddon(fitAddon)

    term.open(container)

    if (local.pty.buffer) {
      if (local.pty.rows && local.pty.cols) {
        term.resize(local.pty.cols, local.pty.rows)
      }
      term.reset()
      term.write(local.pty.buffer)
      if (local.pty.scrollY) {
        term.scrollToLine(local.pty.scrollY)
      }
      fitAddon.fit()
    }

    container.focus()

    fitAddon.observeResize()
    handleResize = () => fitAddon.fit()
    window.addEventListener("resize", handleResize)
    term.onResize(async (size) => {
      if (ws && ws.readyState === WebSocket.OPEN) {
        await sdk.client.pty.update({
          ptyID: local.pty.id,
          size: {
            cols: size.cols,
            rows: size.rows,
          },
        })
      }
    })
    term.onData((data) => {
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(data)
      }
    })
    term.onKey((key) => {
      if (key.key == "Enter") {
        props.onSubmit?.()
      }
    })
    // term.onScroll((ydisp) => {
    // console.log("Scroll position:", ydisp)
    // })
    ws.addEventListener("open", () => {
      console.log("WebSocket connected")
      sdk.client.pty.update({
        ptyID: local.pty.id,
        size: {
          cols: term.cols,
          rows: term.rows,
        },
      })
    })
    ws.addEventListener("message", (event) => {
      term.write(event.data)
    })
    ws.addEventListener("error", (error) => {
      console.error("WebSocket error:", error)
      props.onConnectError?.(error)
    })
    ws.addEventListener("close", () => {
      console.log("WebSocket disconnected")
    })
  })

  onCleanup(() => {
    if (handleResize) {
      window.removeEventListener("resize", handleResize)
    }
    if (serializeAddon && props.onCleanup) {
      const buffer = serializeAddon.serialize()
      props.onCleanup({
        ...local.pty,
        buffer,
        rows: term.rows,
        cols: term.cols,
        scrollY: term.getViewportY(),
      })
    }
    ws?.close()
    term?.dispose()
  })

  return (
    <div
      ref={container}
      data-component="terminal"
      data-prevent-autofocus
      classList={{
        ...(local.classList ?? {}),
        "size-full px-6 py-3 font-mono": true,
        [local.class ?? ""]: !!local.class,
      }}
      {...others}
    />
  )
}