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" />
+		</>
+	);
+}