/ web / src / components / terminal.rs
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  }