Spaces:
Running
Running
| // src/Terminal.js | |
| import { useEffect, useRef } from "react"; | |
| import { Terminal } from "xterm"; | |
| import { FitAddon } from "xterm-addon-fit"; | |
| import "xterm/css/xterm.css"; | |
| export default function XTerm({ onData, output, autoFocusWhen }) { | |
| const termRef = useRef(null); | |
| const fitRef = useRef(null); | |
| const containerId = "terminal-container"; | |
| useEffect(() => { | |
| const term = new Terminal({ | |
| cursorBlink: true, | |
| fontSize: 13, | |
| convertEol: true, | |
| theme: { | |
| background: "#1e1e1e", | |
| foreground: "#dcdcdc", | |
| }, | |
| }); | |
| const fit = new FitAddon(); | |
| fitRef.current = fit; | |
| term.loadAddon(fit); | |
| term.open(document.getElementById(containerId)); | |
| fit.fit(); | |
| term.onData((data) => { | |
| // echo input for feedback | |
| // don't echo CR as blank line; xterm handles newline | |
| if (data === "\r") { | |
| // pass the collected line to parent via onData | |
| // xterm doesn't provide line buffer, so parent may rely on onData chunks (expected) | |
| onData("\n"); // send newline signal (caller will interpret) | |
| } else { | |
| // For regular characters, echo them and also forward individual characters | |
| term.write(data); | |
| // forward characters to parent if needed | |
| // It's often better to send full line when Enter pressed; the parent code in App.js | |
| // expects the onData to receive the full line — our App.js uses trimmed lines. | |
| // Here, for simplicity, also forward characters so parent can capture typed content if desired. | |
| } | |
| }); | |
| termRef.current = term; | |
| // Resize observer to keep fit updated | |
| const ro = new ResizeObserver(() => { | |
| try { fit.fit(); } catch {} | |
| }); | |
| ro.observe(document.getElementById(containerId)); | |
| return () => { | |
| ro.disconnect(); | |
| try { term.dispose(); } catch {} | |
| }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| // Write new output into terminal when `output` prop changes. | |
| useEffect(() => { | |
| if (!termRef.current || output == null) return; | |
| try { | |
| // print with newline separation | |
| // ensure not to double newlines if the output already has trailing newline | |
| const text = String(output); | |
| termRef.current.writeln(text.replace(/\r/g, "")); | |
| } catch (e) { | |
| // ignore | |
| } | |
| }, [output]); | |
| // Auto-focus when parent asks | |
| useEffect(() => { | |
| if (!termRef.current) return; | |
| if (autoFocusWhen) { | |
| // focus xterm helper | |
| setTimeout(() => { | |
| const ta = document.querySelector(`#${containerId} .xterm-helper-textarea`); | |
| if (ta) { | |
| try { | |
| ta.focus(); | |
| const len = ta.value?.length || 0; | |
| ta.setSelectionRange(len, len); | |
| } catch {} | |
| } else { | |
| const cont = document.getElementById(containerId); | |
| if (cont) cont.focus(); | |
| } | |
| }, 50); | |
| } | |
| }, [autoFocusWhen]); | |
| return ( | |
| <div | |
| id={containerId} | |
| style={{ | |
| width: "100%", | |
| height: "180px", | |
| background: "#1e1e1e", | |
| borderTop: "1px solid #333", | |
| }} | |
| /> | |
| ); | |
| } | |