terminal.rs
1 use dioxus::prelude::*; 2 3 #[component] 4 pub fn Terminal( 5 id: String, 6 ws_url: String, 7 #[props(default = 13)] font_size: u8, 8 #[props(default = "h-[350px]".to_string())] height_class: String, 9 ) -> Element { 10 let mut initialized = use_signal(|| false); 11 let id_clone = id.clone(); 12 let id_for_cleanup = id.clone(); 13 14 use_drop(move || { 15 document::eval(&format!( 16 r#" 17 const el = document.getElementById('{id_for_cleanup}'); 18 if (el) {{ el.__destroyed = true; }} 19 if (el && el.__reconnectTimer) {{ try {{ clearTimeout(el.__reconnectTimer); }} catch(e) {{}} }} 20 if (el && el.__ws) {{ try {{ el.__ws.close(); }} catch(e) {{}} }} 21 if (el && el.__term) {{ try {{ el.__term.dispose(); }} catch(e) {{}} }} 22 "# 23 )); 24 }); 25 26 use_effect(move || { 27 if *initialized.peek() { 28 return; 29 } 30 initialized.set(true); 31 32 let id = id_clone.clone(); 33 let ws_url = ws_url.clone(); 34 let font_size = font_size; 35 36 document::eval(&format!( 37 r#" 38 (function tryInit(attempt) {{ 39 if (attempt > 50) return; 40 if (typeof Terminal === 'undefined' || typeof FitAddon === 'undefined') {{ 41 setTimeout(() => tryInit(attempt + 1), 200); 42 return; 43 }} 44 const container = document.getElementById('{id}'); 45 if (!container) {{ setTimeout(() => tryInit(attempt + 1), 200); return; }} 46 if (container.dataset.initialized) return; 47 container.dataset.initialized = 'true'; 48 container.__destroyed = false; 49 container.__reconnectTimer = null; 50 container.innerHTML = ''; 51 52 const term = new Terminal({{ 53 cursorBlink: true, 54 fontSize: {font_size}, 55 fontFamily: "'JetBrains Mono', 'Fira Code', monospace", 56 theme: window.CERATINA_THEME || {{}} 57 }}); 58 59 const fit = new FitAddon.FitAddon(); 60 term.loadAddon(fit); 61 if (typeof WebglAddon !== 'undefined') {{ 62 try {{ 63 const webgl = new WebglAddon.WebglAddon(); 64 webgl.onContextLoss(() => webgl.dispose()); 65 term.loadAddon(webgl); 66 }} catch(e) {{}} 67 }} 68 if (typeof WebLinksAddon !== 'undefined') {{ 69 term.loadAddon(new WebLinksAddon.WebLinksAddon()); 70 }} 71 72 term.open(container); 73 fit.fit(); 74 container.__term = term; 75 76 const ro = new ResizeObserver(() => {{ try {{ fit.fit(); }} catch(e) {{}} }}); 77 ro.observe(container); 78 79 let ws = null; 80 81 function connect() {{ 82 if (container.__destroyed) return; 83 if (ws && ws.readyState <= 1) return; 84 ws = new WebSocket('{ws_url}'); 85 container.__ws = ws; 86 87 ws.onopen = () => {{ 88 term.write('\x1b[32m[connected]\x1b[0m\r\n'); 89 }}; 90 91 ws.onmessage = (e) => {{ 92 term.write(e.data); 93 }}; 94 95 ws.onclose = () => {{ 96 term.write('\r\n\x1b[31m[disconnected]\x1b[0m\r\n'); 97 if (container.__destroyed) return; 98 container.__reconnectTimer = setTimeout(() => {{ 99 container.__reconnectTimer = null; 100 connect(); 101 }}, 3000); 102 }}; 103 104 ws.onerror = () => {{}}; 105 }} 106 107 term.onData((data) => {{ 108 if (ws && ws.readyState === WebSocket.OPEN) {{ 109 ws.send(data); 110 }} 111 }}); 112 113 connect(); 114 }})(0); 115 "# 116 )); 117 }); 118 119 rsx! { 120 div { 121 id: "{id}", 122 class: "w-full {height_class}", 123 div { class: "p-4 space-y-3 animate-pulse", 124 div { class: "h-4 w-3/4 bg-muted/30 rounded" } 125 div { class: "h-4 w-1/2 bg-muted/30 rounded" } 126 div { class: "h-4 w-2/3 bg-muted/30 rounded" } 127 } 128 } 129 } 130 }