Canvas: Render logs using XTerm
Use Server Sent Events to stream logs.
Change-Id: I3790a22a39b71409636a81dbe2a2cc8bf4977cb4
diff --git a/apps/canvas/front/src/components/XTerm.tsx b/apps/canvas/front/src/components/XTerm.tsx
new file mode 100644
index 0000000..4f542f1
--- /dev/null
+++ b/apps/canvas/front/src/components/XTerm.tsx
@@ -0,0 +1,98 @@
+import { useEffect, useRef } from "react";
+import { Terminal } from "xterm";
+import { FitAddon } from "xterm-addon-fit";
+import "xterm/css/xterm.css";
+
+interface XTermProps {
+ logs: string;
+}
+
+const scrollbarHack = `
+.xterm-viewport {
+ scrollbar-width: auto;
+ scrollbar-color: #a0a0a0 #f1f5f9;
+}
+.xterm-viewport::-webkit-scrollbar {
+ width: 8px;
+}
+.xterm-viewport::-webkit-scrollbar-track {
+ background: #f1f5f9;
+}
+.xterm-viewport::-webkit-scrollbar-thumb {
+ background-color: #a0a0a0;
+ border-radius: 4px;
+ border: 2px solid #f1f5f9;
+}
+`;
+
+export function XTerm({ logs }: XTermProps) {
+ const termRef = useRef<HTMLDivElement>(null);
+ const termInstance = useRef<Terminal | null>(null);
+ const fitAddon = useRef<FitAddon | null>(null);
+ const prevLogs = useRef<string>("");
+
+ useEffect(() => {
+ if (termRef.current && !termInstance.current) {
+ const term = new Terminal({
+ disableStdin: true,
+ convertEol: true,
+ fontFamily: `'JetBrains Mono', monospace`,
+ fontSize: 12,
+ theme: {
+ background: "#f1f5f9", // bg-muted color
+ foreground: "#020817", // text-foreground color
+ cursor: "#f1f5f9",
+ selectionBackground: "#bfdbfe", // blue-200
+ selectionInactiveBackground: "#e2e8f0", // slate-200
+ },
+ });
+ const addon = new FitAddon();
+ fitAddon.current = addon;
+ term.loadAddon(addon);
+ term.open(termRef.current);
+ addon.fit();
+ termInstance.current = term;
+
+ const resizeObserver = new ResizeObserver(() => {
+ fitAddon.current?.fit();
+ });
+ resizeObserver.observe(termRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ term.dispose();
+ };
+ }
+ }, []);
+
+ useEffect(() => {
+ if (termInstance.current) {
+ if (logs === "") {
+ termInstance.current.clear();
+ prevLogs.current = "";
+ return;
+ }
+
+ const buffer = termInstance.current.buffer.active;
+ const wasAtBottom = buffer.viewportY + termInstance.current.rows >= buffer.length;
+
+ const newLogContent = logs.substring(prevLogs.current.length);
+ prevLogs.current = logs;
+
+ if (newLogContent) {
+ termInstance.current.write(newLogContent, () => {
+ if (wasAtBottom) {
+ termInstance.current?.scrollToBottom();
+ }
+ });
+ }
+ }
+ }, [logs]);
+
+ return (
+ <>
+ <style>{scrollbarHack}</style>
+ <div ref={termRef} className="h-full w-full" />
+ </>
+ );
+}