1- import { useEffect , useRef , useCallback , useState } from 'react'
2- import { init , Terminal as GhosttyTerminal , FitAddon } from 'ghostty-web'
1+ import { useEffect , useRef , useState } from 'react'
2+ import { Ghostty , Terminal as GhosttyTerminal , FitAddon } from 'ghostty-web'
33import { getTerminalUrl } from '@/lib/api'
44
55interface TerminalProps {
66 workspaceName : string
77 initialCommand ?: string
88}
99
10- let ghosttyInitialized = false
11- let ghosttyInitPromise : Promise < void > | null = null
12-
13- async function ensureGhosttyInit ( ) : Promise < void > {
14- if ( ghosttyInitialized ) return
15- if ( ghosttyInitPromise ) return ghosttyInitPromise
16-
17- ghosttyInitPromise = init ( ) . then ( ( ) => {
18- ghosttyInitialized = true
19- } )
20- return ghosttyInitPromise
21- }
22-
23- export function Terminal ( { workspaceName, initialCommand } : TerminalProps ) {
10+ function TerminalInstance ( { workspaceName, initialCommand } : TerminalProps ) {
2411 const terminalRef = useRef < HTMLDivElement > ( null )
2512 const termRef = useRef < GhosttyTerminal | null > ( null )
13+ const ghosttyRef = useRef < Ghostty | null > ( null )
2614 const fitAddonRef = useRef < FitAddon | null > ( null )
2715 const wsRef = useRef < WebSocket | null > ( null )
2816 const initialCommandSent = useRef ( false )
@@ -31,136 +19,141 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
3119 const [ isInitialized , setIsInitialized ] = useState ( false )
3220 const [ hasReceivedData , setHasReceivedData ] = useState ( false )
3321
34- const connect = useCallback ( async ( ) => {
35- if ( ! terminalRef . current ) return
22+ useEffect ( ( ) => {
23+ let cancelled = false
24+
25+ const connect = async ( ) => {
26+ if ( ! terminalRef . current || cancelled ) return
27+
28+ const ghostty = await Ghostty . load ( )
29+ if ( cancelled ) return
30+
31+ ghosttyRef . current = ghostty
32+ setIsInitialized ( true )
33+
34+ const term = new GhosttyTerminal ( {
35+ ghostty,
36+ cursorBlink : false ,
37+ cursorStyle : 'block' ,
38+ fontSize : 14 ,
39+ fontFamily : 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' ,
40+ scrollback : 10000 ,
41+ theme : {
42+ background : '#0d1117' ,
43+ foreground : '#c9d1d9' ,
44+ cursor : '#58a6ff' ,
45+ cursorAccent : '#0d1117' ,
46+ selectionBackground : '#264f78' ,
47+ selectionForeground : '#ffffff' ,
48+ black : '#484f58' ,
49+ red : '#ff7b72' ,
50+ green : '#3fb950' ,
51+ yellow : '#d29922' ,
52+ blue : '#58a6ff' ,
53+ magenta : '#bc8cff' ,
54+ cyan : '#39c5cf' ,
55+ white : '#b1bac4' ,
56+ brightBlack : '#6e7681' ,
57+ brightRed : '#ffa198' ,
58+ brightGreen : '#56d364' ,
59+ brightYellow : '#e3b341' ,
60+ brightBlue : '#79c0ff' ,
61+ brightMagenta : '#d2a8ff' ,
62+ brightCyan : '#56d4dd' ,
63+ brightWhite : '#f0f6fc' ,
64+ } ,
65+ } )
3666
37- // Dispose any existing terminal and clear DOM
38- if ( termRef . current ) {
39- termRef . current . dispose ( )
40- termRef . current = null
41- }
42- if ( wsRef . current ) {
43- wsRef . current . close ( )
44- wsRef . current = null
45- }
46- terminalRef . current . innerHTML = ''
47-
48- await ensureGhosttyInit ( )
49- setIsInitialized ( true )
50-
51- const term = new GhosttyTerminal ( {
52- cursorBlink : false ,
53- cursorStyle : 'block' ,
54- fontSize : 14 ,
55- fontFamily : 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' ,
56- scrollback : 10000 ,
57- theme : {
58- background : '#0d1117' ,
59- foreground : '#c9d1d9' ,
60- cursor : '#58a6ff' ,
61- cursorAccent : '#0d1117' ,
62- selectionBackground : '#264f78' ,
63- selectionForeground : '#ffffff' ,
64- black : '#484f58' ,
65- red : '#ff7b72' ,
66- green : '#3fb950' ,
67- yellow : '#d29922' ,
68- blue : '#58a6ff' ,
69- magenta : '#bc8cff' ,
70- cyan : '#39c5cf' ,
71- white : '#b1bac4' ,
72- brightBlack : '#6e7681' ,
73- brightRed : '#ffa198' ,
74- brightGreen : '#56d364' ,
75- brightYellow : '#e3b341' ,
76- brightBlue : '#79c0ff' ,
77- brightMagenta : '#d2a8ff' ,
78- brightCyan : '#56d4dd' ,
79- brightWhite : '#f0f6fc' ,
80- } ,
81- } )
82- termRef . current = term
83-
84- const fitAddon = new FitAddon ( )
85- fitAddonRef . current = fitAddon
86- term . loadAddon ( fitAddon )
87-
88- term . open ( terminalRef . current )
89-
90- if ( term . textarea ) {
91- term . textarea . style . opacity = '0'
92- term . textarea . style . position = 'absolute'
93- term . textarea . style . left = '-9999px'
94- term . textarea . style . top = '-9999px'
95- }
67+ if ( cancelled ) {
68+ term . dispose ( )
69+ return
70+ }
9671
97- requestAnimationFrame ( ( ) => {
98- fitAddon . fit ( )
99- } )
72+ termRef . current = term
10073
101- const wsUrl = getTerminalUrl ( workspaceName )
102- const ws = new WebSocket ( wsUrl )
103- wsRef . current = ws
74+ const fitAddon = new FitAddon ( )
75+ fitAddonRef . current = fitAddon
76+ term . loadAddon ( fitAddon )
10477
105- ws . onopen = ( ) => {
106- setIsConnected ( true )
107- const { cols, rows } = term
108- ws . send ( JSON . stringify ( { type : 'resize' , cols, rows } ) )
78+ term . open ( terminalRef . current )
10979
110- if ( initialCommand && ! initialCommandSent . current ) {
111- initialCommandSent . current = true
112- setTimeout ( ( ) => {
113- ws . send ( initialCommand + '\n' )
114- } , 500 )
80+ if ( term . textarea ) {
81+ term . textarea . style . opacity = '0'
82+ term . textarea . style . position = 'absolute'
83+ term . textarea . style . left = '-9999px'
84+ term . textarea . style . top = '-9999px'
11585 }
116- }
11786
118- ws . onmessage = ( event ) => {
119- setHasReceivedData ( true )
120- if ( event . data instanceof Blob ) {
121- event . data . text ( ) . then ( ( text ) => {
122- term . write ( text )
123- } )
124- } else if ( event . data instanceof ArrayBuffer ) {
125- term . write ( new Uint8Array ( event . data ) )
126- } else {
127- term . write ( event . data )
128- }
129- }
87+ requestAnimationFrame ( ( ) => {
88+ if ( ! cancelled ) fitAddon . fit ( )
89+ } )
90+
91+ const wsUrl = getTerminalUrl ( workspaceName )
92+ const ws = new WebSocket ( wsUrl )
93+ wsRef . current = ws
94+
95+ ws . onopen = ( ) => {
96+ if ( cancelled ) return
97+ setIsConnected ( true )
98+ const { cols, rows } = term
99+ ws . send ( JSON . stringify ( { type : 'resize' , cols, rows } ) )
130100
131- ws . onclose = ( event ) => {
132- setIsConnected ( false )
133- term . writeln ( '' )
134- if ( event . code === 1000 ) {
135- term . writeln ( '\x1b[38;5;245mSession ended\x1b[0m' )
136- } else if ( event . code === 404 || event . reason ?. includes ( 'not found' ) ) {
137- term . writeln ( '\x1b[31mWorkspace not found or not running\x1b[0m' )
138- } else {
139- term . writeln ( `\x1b[31mDisconnected (code: ${ event . code } )\x1b[0m` )
101+ if ( initialCommand && ! initialCommandSent . current ) {
102+ initialCommandSent . current = true
103+ setTimeout ( ( ) => {
104+ if ( ! cancelled ) ws . send ( initialCommand + '\n' )
105+ } , 500 )
106+ }
140107 }
141- }
142108
143- ws . onerror = ( ) => {
144- setIsConnected ( false )
145- term . writeln ( '\x1b[31mConnection error - is the workspace running?\x1b[0m' )
146- }
109+ ws . onmessage = ( event ) => {
110+ if ( cancelled ) return
111+ setHasReceivedData ( true )
112+ if ( event . data instanceof Blob ) {
113+ event . data . text ( ) . then ( ( text ) => {
114+ if ( ! cancelled ) term . write ( text )
115+ } )
116+ } else if ( event . data instanceof ArrayBuffer ) {
117+ term . write ( new Uint8Array ( event . data ) )
118+ } else {
119+ term . write ( event . data )
120+ }
121+ }
147122
148- term . onData ( ( data ) => {
149- if ( ws . readyState === WebSocket . OPEN ) {
150- ws . send ( data )
123+ ws . onclose = ( event ) => {
124+ if ( cancelled ) return
125+ setIsConnected ( false )
126+ term . writeln ( '' )
127+ if ( event . code === 1000 ) {
128+ term . writeln ( '\x1b[38;5;245mSession ended\x1b[0m' )
129+ } else if ( event . code === 404 || event . reason ?. includes ( 'not found' ) ) {
130+ term . writeln ( '\x1b[31mWorkspace not found or not running\x1b[0m' )
131+ } else {
132+ term . writeln ( `\x1b[31mDisconnected (code: ${ event . code } )\x1b[0m` )
133+ }
151134 }
152- } )
153135
154- term . onResize ( ( { cols, rows } ) => {
155- if ( ws . readyState === WebSocket . OPEN ) {
156- ws . send ( JSON . stringify ( { type : 'resize' , cols, rows } ) )
136+ ws . onerror = ( ) => {
137+ if ( cancelled ) return
138+ setIsConnected ( false )
139+ term . writeln ( '\x1b[31mConnection error - is the workspace running?\x1b[0m' )
157140 }
158- } )
159141
160- term . focus ( )
161- } , [ workspaceName , initialCommand ] )
142+ term . onData ( ( data ) => {
143+ if ( ws . readyState === WebSocket . OPEN ) {
144+ ws . send ( data )
145+ }
146+ } )
147+
148+ term . onResize ( ( { cols, rows } ) => {
149+ if ( ws . readyState === WebSocket . OPEN ) {
150+ ws . send ( JSON . stringify ( { type : 'resize' , cols, rows } ) )
151+ }
152+ } )
153+
154+ term . focus ( )
155+ }
162156
163- useEffect ( ( ) => {
164157 connect ( )
165158
166159 const handleFit = ( ) => {
@@ -184,21 +177,21 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
184177 window . addEventListener ( 'resize' , debouncedFit )
185178
186179 return ( ) => {
180+ cancelled = true
187181 window . removeEventListener ( 'resize' , debouncedFit )
188- if ( resizeObserverRef . current ) {
189- resizeObserverRef . current . disconnect ( )
190- }
191- if ( wsRef . current ) {
192- wsRef . current . close ( )
193- }
194- if ( termRef . current ) {
195- termRef . current . dispose ( )
196- }
182+ resizeObserverRef . current ?. disconnect ( )
183+ resizeObserverRef . current = null
184+ wsRef . current ?. close ( )
185+ wsRef . current = null
186+ termRef . current ?. dispose ( )
187+ termRef . current = null
188+ fitAddonRef . current = null
189+ ghosttyRef . current = null
197190 }
198- } , [ connect ] )
191+ } , [ workspaceName , initialCommand ] )
199192
200193 return (
201- < div className = "relative h-full w-full bg-[#0d1117] rounded-lg overflow-hidden cursor-default" data-testid = "terminal-container" >
194+ < >
202195 < div
203196 ref = { terminalRef }
204197 className = "absolute inset-0"
@@ -221,6 +214,18 @@ export function Terminal({ workspaceName, initialCommand }: TerminalProps) {
221214 </ span >
222215 </ div >
223216 ) }
217+ </ >
218+ )
219+ }
220+
221+ export function Terminal ( { workspaceName, initialCommand } : TerminalProps ) {
222+ return (
223+ < div className = "relative h-full w-full bg-[#0d1117] rounded-lg overflow-hidden cursor-default" data-testid = "terminal-container" >
224+ < TerminalInstance
225+ key = { workspaceName }
226+ workspaceName = { workspaceName }
227+ initialCommand = { initialCommand }
228+ />
224229 </ div >
225230 )
226231}
0 commit comments