11import { useState , useRef , useEffect , useCallback } from "react" ;
22import { useBrowserStore , type BrowserTab } from "~/stores/browserStore" ;
33import { isPortBlocked } from "~/lib/constants" ;
4+ import { getSocket } from "~/lib/socket" ;
45
5- interface ConsoleEntry {
6- method : string ;
7- args : string [ ] ;
8- timestamp : number ;
6+ // Cache tunnel URLs across renders
7+ const tunnelCache = new Map < number , string > ( ) ;
8+
9+ type TunnelState =
10+ | { status : "idle" }
11+ | { status : "connecting" }
12+ | { status : "ready" ; url : string }
13+ | { status : "error" ; message : string } ;
14+
15+ function usePortTunnel ( port : string | undefined ) : {
16+ state : TunnelState ;
17+ retry : ( ) => void ;
18+ } {
19+ const [ state , setState ] = useState < TunnelState > ( { status : "idle" } ) ;
20+ const [ retryCount , setRetryCount ] = useState ( 0 ) ;
21+
22+ const retry = useCallback ( ( ) => {
23+ if ( ! port ) return ;
24+ tunnelCache . delete ( parseInt ( port , 10 ) ) ;
25+ setState ( { status : "connecting" } ) ;
26+ setRetryCount ( ( c ) => c + 1 ) ;
27+ } , [ port ] ) ;
28+
29+ useEffect ( ( ) => {
30+ if ( ! port ) {
31+ setState ( { status : "idle" } ) ;
32+ return ;
33+ }
34+
35+ const portNum = parseInt ( port , 10 ) ;
36+ const cached = tunnelCache . get ( portNum ) ;
37+ if ( cached ) {
38+ setState ( { status : "ready" , url : cached } ) ;
39+ return ;
40+ }
41+
42+ setState ( { status : "connecting" } ) ;
43+ const socket = getSocket ( ) ;
44+
45+ const onReady = ( data : { port : number ; url : string } ) => {
46+ if ( data . port === portNum ) {
47+ tunnelCache . set ( portNum , data . url ) ;
48+ setState ( { status : "ready" , url : data . url } ) ;
49+ }
50+ } ;
51+ const onError = ( data : { port : number ; error : string } ) => {
52+ if ( data . port === portNum ) {
53+ setState ( { status : "error" , message : data . error } ) ;
54+ }
55+ } ;
56+ const onDied = ( data : { port : number } ) => {
57+ if ( data . port === portNum ) {
58+ tunnelCache . delete ( portNum ) ;
59+ setState ( { status : "error" , message : "Tunnel disconnected" } ) ;
60+ }
61+ } ;
62+
63+ socket . on ( "port_tunnel_ready" , onReady ) ;
64+ socket . on ( "port_tunnel_error" , onError ) ;
65+ socket . on ( "port_tunnel_died" , onDied ) ;
66+ socket . emit ( "request_port_tunnel" , { port : portNum } ) ;
67+
68+ return ( ) => {
69+ socket . off ( "port_tunnel_ready" , onReady ) ;
70+ socket . off ( "port_tunnel_error" , onError ) ;
71+ socket . off ( "port_tunnel_died" , onDied ) ;
72+ } ;
73+ } , [ port , retryCount ] ) ;
74+
75+ return { state, retry } ;
976}
1077
1178function TabInput ( { tab } : { tab : BrowserTab } ) {
@@ -60,78 +127,81 @@ function TabInput({ tab }: { tab: BrowserTab }) {
60127 ) ;
61128}
62129
63- const METHOD_COLORS : Record < string , string > = {
64- log : "text-gray-300" ,
65- info : "text-blue-400" ,
66- warn : "text-yellow-400" ,
67- error : "text-red-400" ,
68- debug : "text-gray-500" ,
69- } ;
70-
71- function ConsolePanel ( { entries, onClear } : { entries : ConsoleEntry [ ] ; onClear : ( ) => void } ) {
72- const bottomRef = useRef < HTMLDivElement > ( null ) ;
130+ function PortTunnelView ( { tab } : { tab : BrowserTab } ) {
131+ const { state, retry } = usePortTunnel ( tab . port ) ;
132+ const [ copied , setCopied ] = useState ( false ) ;
73133
74- useEffect ( ( ) => {
75- bottomRef . current ?. scrollIntoView ( { behavior : "smooth" } ) ;
76- } , [ entries . length ] ) ;
134+ const handleCopy = ( url : string ) => {
135+ navigator . clipboard . writeText ( url ) ;
136+ setCopied ( true ) ;
137+ setTimeout ( ( ) => setCopied ( false ) , 2000 ) ;
138+ } ;
77139
78140 return (
79- < div className = "flex flex-col h-full border-t border-gray-700 bg-[#0d0d1a]" >
80- < div className = "flex items-center justify-between px-3 py-1 bg-[#16162a] border-b border-gray-700 shrink-0" >
81- < span className = "text-xs text-gray-400 font-medium" > Console</ span >
82- < div className = "flex items-center gap-2" >
83- < span className = "text-[10px] text-gray-500" > { entries . length } messages</ span >
141+ < div className = "flex flex-col items-center justify-center h-full bg-[#0d0d1a] gap-4" >
142+ { state . status === "connecting" && (
143+ < >
144+ < svg className = "w-10 h-10 animate-spin text-blue-500" fill = "none" viewBox = "0 0 24 24" >
145+ < circle className = "opacity-25" cx = "12" cy = "12" r = "10" stroke = "currentColor" strokeWidth = "4" />
146+ < path className = "opacity-75" fill = "currentColor" d = "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
147+ </ svg >
148+ < span className = "text-sm text-gray-400" > Starting tunnel for localhost:{ tab . port } ...</ span >
149+ < span className = "text-xs text-gray-600" > This may take a few seconds</ span >
150+ </ >
151+ ) }
152+
153+ { state . status === "ready" && (
154+ < >
155+ < svg className = "w-10 h-10 text-green-500" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" strokeWidth = { 1.5 } >
156+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.06a4.5 4.5 0 00-6.364-6.364L4.5 8.743" />
157+ </ svg >
158+ < span className = "text-sm text-gray-300" > localhost:{ tab . port } </ span >
159+ < a
160+ href = { state . url }
161+ target = "_blank"
162+ rel = "noopener noreferrer"
163+ className = "px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
164+ >
165+ < svg className = "w-4 h-4" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" strokeWidth = { 2 } >
166+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-4.5-6H18m0 0v4.5m0-4.5L10.5 13.5" />
167+ </ svg >
168+ Open in new tab
169+ </ a >
84170 < button
85- onClick = { onClear }
86- className = "text-[10px] text-gray-500 hover:text-white transition-colors"
171+ onClick = { ( ) => handleCopy ( state . url ) }
172+ className = "text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
173+ title = "Copy URL"
87174 >
88- Clear
175+ < svg className = "w-3 h-3" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" strokeWidth = { 2 } >
176+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
177+ </ svg >
178+ { copied ? "Copied!" : state . url }
89179 </ button >
90- </ div >
91- </ div >
92- < div className = "flex-1 overflow-y-auto p-2 font-mono text-xs space-y-0.5" >
93- { entries . length === 0 ? (
94- < span className = "text-gray-600" > No console output yet</ span >
95- ) : (
96- entries . map ( ( entry , i ) => (
97- < div key = { i } className = { `flex gap-2 ${ METHOD_COLORS [ entry . method ] || "text-gray-300" } ` } >
98- < span className = "text-gray-600 shrink-0 w-12 text-right" >
99- { new Date ( entry . timestamp ) . toLocaleTimeString ( [ ] , { hour12 : false , hour : "2-digit" , minute : "2-digit" , second : "2-digit" } ) }
100- </ span >
101- < span className = "shrink-0 w-10 text-right opacity-60" > { entry . method } </ span >
102- < span className = "break-all whitespace-pre-wrap" > { entry . args . join ( " " ) } </ span >
103- </ div >
104- ) )
105- ) }
106- < div ref = { bottomRef } />
107- </ div >
180+ </ >
181+ ) }
182+
183+ { state . status === "error" && (
184+ < >
185+ < svg className = "w-10 h-10 text-red-500" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" strokeWidth = { 1.5 } >
186+ < path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
187+ </ svg >
188+ < span className = "text-sm text-red-400" > { state . message } </ span >
189+ < button
190+ onClick = { retry }
191+ className = "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-medium transition-colors"
192+ >
193+ Retry
194+ </ button >
195+ </ >
196+ ) }
108197 </ div >
109198 ) ;
110199}
111200
112201export default function BrowserPage ( ) {
113- const { tabs, activeTabId, addTab, removeTab, setActiveTab, refreshTab } = useBrowserStore ( ) ;
202+ const { tabs, activeTabId, addTab, removeTab, setActiveTab } = useBrowserStore ( ) ;
114203 const activeTab = tabs . find ( ( t ) => t . id === activeTabId ) ;
115204
116- const [ showConsole , setShowConsole ] = useState ( false ) ;
117- const [ consoleEntries , setConsoleEntries ] = useState < ConsoleEntry [ ] > ( [ ] ) ;
118-
119- // Listen for postMessage from proxied iframes
120- useEffect ( ( ) => {
121- const handler = ( e : MessageEvent ) => {
122- if ( e . data ?. type === "proxy-console" ) {
123- setConsoleEntries ( ( prev ) => [
124- ...prev ,
125- { method : e . data . method , args : e . data . args , timestamp : e . data . timestamp } ,
126- ] ) ;
127- }
128- } ;
129- window . addEventListener ( "message" , handler ) ;
130- return ( ) => window . removeEventListener ( "message" , handler ) ;
131- } , [ ] ) ;
132-
133- const clearConsole = useCallback ( ( ) => setConsoleEntries ( [ ] ) , [ ] ) ;
134-
135205 return (
136206 < div className = "flex flex-col h-full min-h-0" >
137207 { /* Tab bar */ }
@@ -152,28 +222,6 @@ export default function BrowserPage() {
152222 < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
153223 </ svg >
154224 < span > { tab . port ? `:${ tab . port } ` : "new" } </ span >
155- { isActive && tab . port && (
156- < >
157- < button
158- onClick = { ( e ) => { e . stopPropagation ( ) ; refreshTab ( tab . id ) ; } }
159- className = "p-0.5 rounded hover:bg-gray-600 text-gray-500 hover:text-gray-200 transition-colors"
160- title = "Refresh"
161- >
162- < svg className = "w-3 h-3" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" strokeWidth = { 2 } >
163- < path strokeLinecap = "round" strokeLinejoin = "round" d = "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
164- </ svg >
165- </ button >
166- < button
167- onClick = { ( e ) => { e . stopPropagation ( ) ; setShowConsole ( ! showConsole ) ; } }
168- className = { `p-0.5 rounded hover:bg-gray-600 transition-colors ${ showConsole ? "text-blue-400" : "text-gray-500 hover:text-gray-200" } ` }
169- title = "Toggle console"
170- >
171- < svg className = "w-3 h-3" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" strokeWidth = { 2 } >
172- < path strokeLinecap = "round" strokeLinejoin = "round" d = "M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
173- </ svg >
174- </ button >
175- </ >
176- ) }
177225 < button
178226 onClick = { ( e ) => { e . stopPropagation ( ) ; removeTab ( tab . id ) ; } }
179227 className = "p-0.5 rounded hover:bg-gray-600 text-gray-500 hover:text-gray-200 transition-colors"
@@ -198,40 +246,18 @@ export default function BrowserPage() {
198246 </ div >
199247
200248 { /* Content */ }
201- < div className = { ` flex-1 relative min-h-0 ${ showConsole ? "flex flex-col" : "" } ` } >
249+ < div className = " flex-1 min-h-0" >
202250 { ! activeTab ? (
203251 < div className = "flex flex-col items-center justify-center h-full bg-[#0d0d1a] text-gray-500 gap-3" >
204252 < svg className = "w-12 h-12 text-gray-600" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
205253 < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 1.5 } d = "M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
206254 </ svg >
207- < span className = "text-sm" > Click + to preview a localhost port</ span >
255+ < span className = "text-sm" > Click + to tunnel a localhost port</ span >
208256 </ div >
209257 ) : ! activeTab . port ? (
210258 < TabInput tab = { activeTab } />
211259 ) : (
212- < >
213- < div className = { showConsole ? "flex-1 relative min-h-0" : "absolute inset-0" } >
214- { tabs . filter ( ( t ) => t . port ) . map ( ( tab ) => (
215- < div
216- key = { tab . id }
217- className = "absolute inset-0"
218- style = { { visibility : tab . id === activeTabId ? "visible" : "hidden" } }
219- >
220- < iframe
221- key = { `${ tab . port } -${ tab . refreshKey } ` }
222- src = { `/proxy/${ tab . port } /` }
223- className = "w-full h-full border-0 bg-[#0d0d1a]"
224- title = { `localhost:${ tab . port } ` }
225- />
226- </ div >
227- ) ) }
228- </ div >
229- { showConsole && (
230- < div className = "h-[40%] shrink-0" >
231- < ConsolePanel entries = { consoleEntries } onClear = { clearConsole } />
232- </ div >
233- ) }
234- </ >
260+ < PortTunnelView tab = { activeTab } />
235261 ) }
236262 </ div >
237263 </ div >
0 commit comments