web/app/collab/components/collab-workspace.tsxblame
View source
0b4b5821"use client";
0b4b5822
0b4b5823import { useState, useEffect, useCallback, useRef } from "react";
0b4b5824import dynamic from "next/dynamic";
0b4b5825import { collab as collabApi } from "@/lib/api";
0b4b5826import { useAuth } from "@/lib/auth";
0b4b5827import { CollabProvider, useCollab } from "./collab-provider";
0b4b5828import { DiagramDrawer, type Diagram } from "./diagram-drawer";
0b4b5829import { NotesSidebar } from "./notes-sidebar";
0b4b58210import { useCollabNavActions } from "../collab-nav-actions";
0b4b58211
0b4b58212// Dynamically import heavy components (no SSR)
0b4b58213const DiagramViewer = dynamic(
0b4b58214 () => import("./diagram-viewer").then((m) => ({ default: m.DiagramViewer })),
0b4b58215 { ssr: false, loading: () => <div className="flex-1" /> }
0b4b58216);
0b4b58217
0b4b58218import type { CodeEditorHandle } from "./code-editor";
0b4b58219
0b4b58220const CodeEditor = dynamic(
0b4b58221 () => import("./code-editor").then((m) => ({ default: m.CodeEditor })),
0b4b58222 { ssr: false, loading: () => <div style={{ flex: 1 }} /> }
0b4b58223);
0b4b58224
0b4b58225const fmtBtnStyle: React.CSSProperties = {
0b4b58226 padding: "2px 8px",
0b4b58227 fontSize: "10px",
0b4b58228 fontFamily: "'JetBrains Mono', Menlo, monospace",
0b4b58229 background: "none",
0b4b58230 border: "1px solid var(--border)",
0b4b58231 borderRadius: "3px",
0b4b58232 color: "var(--text-muted)",
0b4b58233 cursor: "pointer",
0b4b58234 textTransform: "none",
0b4b58235 letterSpacing: "0",
0b4b58236};
0b4b58237
0b4b58238interface CollabWorkspaceInnerProps {
0b4b58239 owner: string;
0b4b58240 repo: string;
0b4b58241 diagrams: Diagram[];
0b4b58242 sections: string[];
0b4b58243}
0b4b58244
0b4b58245function CollabWorkspaceInner({ owner, repo, diagrams, sections }: CollabWorkspaceInnerProps) {
0b4b58246 const { user: authUser } = useAuth();
0b4b58247 const { emit, connected, users } = useCollab();
0b4b58248 const [activeId, setActiveId] = useState<string | null>(diagrams[0]?.id ?? null);
0b4b58249 const [codeMap, setCodeMap] = useState<Record<string, string>>(() => {
0b4b58250 const map: Record<string, string> = {};
0b4b58251 for (const d of diagrams) map[d.id] = d.code;
0b4b58252 return map;
0b4b58253 });
0b4b58254 const [renderedCode, setRenderedCode] = useState(diagrams[0]?.code ?? "");
0b4b58255 const [codeOpen, setCodeOpen] = useState(true);
0b4b58256 const [codeWidth, setCodeWidth] = useState(520);
0b4b58257 const resizing = useRef(false);
0b4b58258 const editorRef = useRef<CodeEditorHandle>(null);
0b4b58259 const mermaidRef = useRef<any>(null);
0b4b58260 const lintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
0b4b58261 const [validationStatus, setValidationStatus] = useState<
0b4b58262 null | { ok: true } | { ok: false; message: string; line?: number; col?: number }
0b4b58263 >(null);
0b4b58264
0b4b58265 // Load mermaid for validation
0b4b58266 useEffect(() => {
0b4b58267 import("mermaid").then((mod) => {
0b4b58268 const m = mod.default;
0b4b58269 m.initialize({ startOnLoad: false, securityLevel: "loose" });
0b4b58270 mermaidRef.current = m;
0b4b58271 });
0b4b58272 }, []);
0b4b58273
0b4b58274 const activeDiagram = diagrams.find((d) => d.id === activeId) ?? null;
0b4b58275 const currentCode = activeId ? (codeMap[activeId] ?? "") : "";
0b4b58276
0b4b58277 // Notify server of active tab
0b4b58278 useEffect(() => {
0b4b58279 if (activeId) emit("cursor-move", { x: 0, y: 0, activeTab: activeId });
0b4b58280 }, [activeId, emit]);
0b4b58281
0b4b58282 const handleSelectDiagram = useCallback((id: string) => {
0b4b58283 setActiveId(id);
0b4b58284 const code = codeMap[id] ?? diagrams.find((d) => d.id === id)?.code ?? "";
0b4b58285 setRenderedCode(code);
0b4b58286 setValidationStatus(null);
0b4b58287 }, [codeMap, diagrams]);
0b4b58288
0b4b58289 const validateMermaidCode = useCallback(async (code: string): Promise<
0b4b58290 { ok: true } | { ok: false; message: string; line?: number; col?: number }
0b4b58291 > => {
0b4b58292 const m = mermaidRef.current;
0b4b58293 if (!m) return { ok: true };
0b4b58294 try {
0b4b58295 await m.parse(code);
0b4b58296 return { ok: true };
0b4b58297 } catch (e: any) {
0b4b58298 const raw = e.message || String(e);
0b4b58299 let line: number | undefined;
0b4b582100 let col: number | undefined;
0b4b582101
0b4b582102 const lineMatch = raw.match(/line\s*(\d+)/i) || raw.match(/at line (\d+)/i);
0b4b582103 const colMatch = raw.match(/col(?:umn)?\s*(\d+)/i);
0b4b582104 if (lineMatch) line = parseInt(lineMatch[1]);
0b4b582105 if (colMatch) col = parseInt(colMatch[1]);
0b4b582106
0b4b582107 if (!line && e.hash) {
0b4b582108 if (e.hash.line != null) line = e.hash.line + 1;
0b4b582109 if (e.hash.loc) {
0b4b582110 line = line || e.hash.loc.first_line;
0b4b582111 col = col || e.hash.loc.first_column + 1;
0b4b582112 }
0b4b582113 }
0b4b582114
0b4b582115 let msg = "";
0b4b582116 if (line) {
0b4b582117 msg += "Line " + line;
0b4b582118 if (col) msg += ":" + col;
0b4b582119 msg += " \u2014 ";
0b4b582120 }
0b4b582121 const cleaned = raw.replace(/^Error:\s*/i, "").replace(/Syntax error in.*?:\s*/i, "");
0b4b582122 const lines = cleaned.split("\n").filter((l: string) => l.trim());
0b4b582123 msg += lines[0] || cleaned;
0b4b582124
0b4b582125 if (line && code) {
0b4b582126 const srcLines = code.split("\n");
0b4b582127 const srcLine = srcLines[line - 1];
0b4b582128 if (srcLine != null) {
0b4b582129 msg += "\n\n " + line + " \u2502 " + srcLine;
0b4b582130 if (col) {
0b4b582131 msg += "\n " + " ".repeat(String(line).length) + " ".repeat(col) + "\u2191";
0b4b582132 }
0b4b582133 }
0b4b582134 }
0b4b582135
0b4b582136 return { ok: false, message: msg, line, col };
0b4b582137 }
0b4b582138 }, []);
0b4b582139
0b4b582140 const handleCodeChange = useCallback((newCode: string) => {
0b4b582141 if (!activeId) return;
0b4b582142 setCodeMap((prev) => ({ ...prev, [activeId]: newCode }));
0b4b582143 // Debounced lint as-you-type
0b4b582144 if (lintTimerRef.current) clearTimeout(lintTimerRef.current);
0b4b582145 lintTimerRef.current = setTimeout(async () => {
0b4b582146 const result = await validateMermaidCode(newCode);
0b4b582147 setValidationStatus(result);
0b4b582148 }, 500);
0b4b582149 }, [activeId, validateMermaidCode]);
0b4b582150
0b4b582151 const handleRun = useCallback(async () => {
0b4b582152 if (!activeId) return;
0b4b582153 const code = codeMap[activeId] ?? "";
0b4b582154 if (lintTimerRef.current) clearTimeout(lintTimerRef.current);
0b4b582155 const result = await validateMermaidCode(code);
0b4b582156 setValidationStatus(result);
0b4b582157 if (!result.ok) {
0b4b582158 if (result.line) editorRef.current?.jumpToLine(result.line, result.col);
0b4b582159 return;
0b4b582160 }
0b4b582161 setRenderedCode(code);
0b4b582162 emit("diagram-code", { diagramId: activeId, code });
0b4b582163 }, [activeId, codeMap, emit, validateMermaidCode]);
0b4b582164
0b4b582165 // Listen for remote diagram code broadcasts
0b4b582166 const { onDiagramCode } = useCollab();
0b4b582167 useEffect(() => {
0b4b582168 onDiagramCode.current = (data: { diagramId: string; code: string }) => {
0b4b582169 setCodeMap((prev) => ({ ...prev, [data.diagramId]: data.code }));
0b4b582170 if (data.diagramId === activeId) {
0b4b582171 setRenderedCode(data.code);
0b4b582172 }
0b4b582173 };
0b4b582174 return () => { onDiagramCode.current = null; };
0b4b582175 }, [activeId, onDiagramCode]);
0b4b582176
0b4b582177 // Push actions into the nav bar
0b4b582178 const { setActions } = useCollabNavActions();
0b4b582179 const otherUsers = Object.values(users).filter(
0b4b582180 (u) => !authUser || (u.name !== authUser.username && u.name !== authUser.display_name)
0b4b582181 );
0b4b582182 useEffect(() => {
0b4b582183 setActions(
0b4b582184 <>
0b4b582185 {!connected && (
0b4b582186 <span className="text-xs" style={{ color: "var(--text-faint)" }}>
0b4b582187 Connecting...
0b4b582188 </span>
0b4b582189 )}
0b4b582190 {otherUsers.length > 0 && (
0b4b582191 <div className="flex items-center gap-1.5">
0b4b582192 {otherUsers.map((u) => (
0b4b582193 <span
0b4b582194 key={u.id}
0b4b582195 className="collab-avatar flex items-center justify-center text-xs font-medium"
0b4b582196 style={{
0b4b582197 width: "1.5rem",
0b4b582198 height: "1.5rem",
0b4b582199 borderRadius: "9999px",
0b4b582200 backgroundColor: u.color,
0b4b582201 color: "#fff",
0b4b582202 fontSize: "11px",
0b4b582203 position: "relative",
0b4b582204 cursor: "default",
0b4b582205 }}
0b4b582206 data-tooltip={u.name}
0b4b582207 >
0b4b582208 {u.name.charAt(0).toUpperCase()}
0b4b582209 </span>
0b4b582210 ))}
0b4b582211 </div>
0b4b582212 )}
0b4b582213 </>
0b4b582214 );
0b4b582215 return () => setActions(null);
0b4b582216 }, [connected, otherUsers.length, setActions]);
0b4b582217
0b4b582218 // Code pane resize
0b4b582219 const handleResizeStart = useCallback((e: React.MouseEvent) => {
0b4b582220 e.preventDefault();
0b4b582221 resizing.current = true;
0b4b582222 const startX = e.clientX;
0b4b582223 const startWidth = codeWidth;
0b4b582224 const handleMove = (e: MouseEvent) => {
0b4b582225 if (!resizing.current) return;
0b4b582226 setCodeWidth(Math.max(200, Math.min(800, startWidth + (e.clientX - startX))));
0b4b582227 };
0b4b582228 const handleUp = () => {
0b4b582229 resizing.current = false;
0b4b582230 document.removeEventListener("mousemove", handleMove);
0b4b582231 document.removeEventListener("mouseup", handleUp);
0b4b582232 };
0b4b582233 document.addEventListener("mousemove", handleMove);
0b4b582234 document.addEventListener("mouseup", handleUp);
0b4b582235 }, [codeWidth]);
0b4b582236
0b4b582237 return (
0b4b582238 <div style={{ display: "flex", flex: 1, minHeight: 0, height: "100%" }}>
0b4b582239 <DiagramDrawer
0b4b582240 diagrams={diagrams}
0b4b582241 sections={sections}
0b4b582242 activeId={activeId}
0b4b582243 onSelect={handleSelectDiagram}
0b4b582244 />
0b4b582245
0b4b582246 {/* Code pane (collapsible) */}
0b4b582247 <div
0b4b582248 style={{
0b4b582249 width: codeOpen ? codeWidth : 32,
0b4b582250 minWidth: codeOpen ? 200 : 32,
0b4b582251 background: "var(--bg-card)",
0b4b582252 borderRight: "1px solid var(--border)",
0b4b582253 flexShrink: 0,
0b4b582254 overflow: "hidden",
0b4b582255 display: "flex",
0b4b582256 flexDirection: "column",
0b4b582257 position: "relative",
0b4b582258 }}
0b4b582259 >
0b4b582260 <div
0b4b582261 onClick={() => setCodeOpen((v) => !v)}
0b4b582262 style={{
0b4b582263 display: "flex",
0b4b582264 alignItems: "center",
0b4b582265 gap: "6px",
0b4b582266 padding: "8px",
0b4b582267 fontSize: "11px",
0b4b582268 fontWeight: 500,
0b4b582269 color: "var(--text-muted)",
0b4b582270 fontFamily: "'JetBrains Mono', Menlo, monospace",
0b4b582271 cursor: "pointer",
0b4b582272 userSelect: "none",
0b4b582273 borderBottom: codeOpen ? "1px solid var(--border-subtle, var(--divide))" : "none",
0b4b582274 textTransform: "uppercase",
0b4b582275 letterSpacing: "0.5px",
0b4b582276 whiteSpace: "nowrap",
0b4b582277 flexShrink: 0,
0b4b582278 ...(!codeOpen ? { writingMode: "vertical-lr" as const, textOrientation: "mixed" as const, padding: "12px 6px", flex: 1, justifyContent: "start" } : {}),
0b4b582279 }}
0b4b582280 >
0b4b582281 <span style={{ fontSize: "10px", display: "inline-block", transform: codeOpen ? "rotate(90deg)" : "rotate(90deg)", transition: "transform 0.15s", ...(!codeOpen ? { marginBottom: "2px" } : {}) }}>&#9654;</span>
0b4b582282 <span>Definition</span>
0b4b582283 {codeOpen && <span style={{ flex: 1 }} />}
0b4b582284 {codeOpen && validationStatus && (
0b4b582285 <span
0b4b582286 onClick={(e) => e.stopPropagation()}
0b4b582287 style={{
0b4b582288 fontSize: "10px",
0b4b582289 fontFamily: "'JetBrains Mono', Menlo, monospace",
0b4b582290 color: validationStatus.ok ? "var(--accent)" : "#a05050",
0b4b582291 cursor: "default",
0b4b582292 whiteSpace: "nowrap",
0b4b582293 }}
0b4b582294 >
0b4b582295 {validationStatus.ok ? "\u2713 valid" : "\u2717 error"}
0b4b582296 </span>
0b4b582297 )}
0b4b582298 {codeOpen && (
0b4b582299 <>
0b4b582300 <span style={{ display: "flex", gap: 2, alignItems: "center" }} onClick={(e) => e.stopPropagation()}>
0b4b582301 <button
0b4b582302 onClick={() => editorRef.current?.changeFontSize(-1)}
0b4b582303 title="Decrease font size"
0b4b582304 style={fmtBtnStyle}
0b4b582305 >
0b4b582306 A&minus;
0b4b582307 </button>
0b4b582308 <button
0b4b582309 onClick={() => editorRef.current?.changeFontSize(1)}
0b4b582310 title="Increase font size"
0b4b582311 style={fmtBtnStyle}
0b4b582312 >
0b4b582313 A+
0b4b582314 </button>
0b4b582315 </span>
0b4b582316 <button
0b4b582317 onClick={(e) => { e.stopPropagation(); editorRef.current?.format(); }}
0b4b582318 title="Format (Shift+Alt+F)"
0b4b582319 style={fmtBtnStyle}
0b4b582320 >
0b4b582321 Format
0b4b582322 </button>
0b4b582323 <button
0b4b582324 onClick={(e) => { e.stopPropagation(); handleRun(); }}
0b4b582325 style={{
0b4b582326 padding: "2px 10px",
0b4b582327 fontSize: "10px",
0b4b582328 fontFamily: "'JetBrains Mono', Menlo, monospace",
0b4b582329 background: "var(--accent)",
0b4b582330 border: "1px solid var(--accent)",
0b4b582331 borderRadius: "3px",
0b4b582332 color: "var(--accent-text)",
0b4b582333 cursor: "pointer",
0b4b582334 textTransform: "none",
0b4b582335 letterSpacing: "0",
0b4b582336 }}
0b4b582337 >
0b4b582338 &#9654; Run
0b4b582339 </button>
0b4b582340 </>
0b4b582341 )}
0b4b582342 </div>
0b4b582343 {codeOpen && (
0b4b582344 <>
0b4b582345 <CodeEditor ref={editorRef} code={currentCode} onChange={handleCodeChange} onRun={handleRun} />
0b4b582346 {validationStatus && !validationStatus.ok && (
0b4b582347 <div
0b4b582348 style={{
0b4b582349 padding: "6px 10px",
0b4b582350 fontSize: "11px",
0b4b582351 fontFamily: "'JetBrains Mono', Menlo, monospace",
0b4b582352 color: "#a05050",
0b4b582353 background: "#a050500a",
0b4b582354 borderTop: "1px solid #a0505030",
0b4b582355 flexShrink: 0,
0b4b582356 overflowX: "auto",
0b4b582357 whiteSpace: "pre-wrap",
0b4b582358 wordBreak: "break-word",
0b4b582359 maxHeight: "120px",
0b4b582360 overflowY: "auto",
0b4b582361 }}
0b4b582362 >
0b4b582363 {validationStatus.message}
0b4b582364 </div>
0b4b582365 )}
0b4b582366 {/* Resize handle */}
0b4b582367 <div
0b4b582368 onMouseDown={handleResizeStart}
0b4b582369 style={{
0b4b582370 position: "absolute",
0b4b582371 top: 0,
0b4b582372 right: -3,
0b4b582373 width: 6,
0b4b582374 height: "100%",
0b4b582375 cursor: "col-resize",
0b4b582376 zIndex: 10,
0b4b582377 }}
0b4b582378 />
0b4b582379 </>
0b4b582380 )}
0b4b582381 </div>
0b4b582382
0b4b582383 {/* Diagram viewer */}
0b4b582384 <DiagramViewer
0b4b582385 code={renderedCode}
0b4b582386 diagramId={activeId ?? ""}
0b4b582387 />
0b4b582388
0b4b582389 {/* Notes sidebar */}
0b4b582390 {activeDiagram && (
0b4b582391 <NotesSidebar
0b4b582392 diagramId={activeDiagram.id}
0b4b582393 diagramTitle={activeDiagram.title}
0b4b582394 />
0b4b582395 )}
0b4b582396 </div>
0b4b582397 );
0b4b582398}
0b4b582399
0b4b582400interface CollabWorkspaceProps {
0b4b582401 owner: string;
0b4b582402 repo: string;
0b4b582403}
0b4b582404
0b4b582405export function CollabWorkspace({ owner, repo }: CollabWorkspaceProps) {
0b4b582406 const { user, loading: authLoading } = useAuth();
0b4b582407 const [diagrams, setDiagrams] = useState<Diagram[]>([]);
0b4b582408 const [sections, setSections] = useState<string[]>([]);
0b4b582409 const [loading, setLoading] = useState(true);
0b4b582410
0b4b582411 useEffect(() => {
0b4b582412 collabApi
0b4b582413 .listDiagrams(owner, repo)
0b4b582414 .then((data) => {
0b4b582415 // Sections from the API may be objects ({ label }) or strings
0b4b582416 const rawSections: any[] = data.sections ?? [];
0b4b582417 const sectionLabels = rawSections.map((s: any) =>
0b4b582418 typeof s === "string" ? s : s.label ?? ""
0b4b582419 );
0b4b582420 // Diagram section may be a numeric index into sections — resolve to label
0b4b582421 const diagrams = (data.diagrams ?? []).map((d: any) => ({
0b4b582422 ...d,
0b4b582423 section: typeof d.section === "number" ? sectionLabels[d.section] ?? "" : d.section ?? "",
0b4b582424 }));
0b4b582425 setDiagrams(diagrams);
0b4b582426 setSections(sectionLabels);
0b4b582427 })
0b4b582428 .catch(() => {})
0b4b582429 .finally(() => setLoading(false));
0b4b582430 }, [owner, repo]);
0b4b582431
0b4b582432 if (authLoading || loading) {
0b4b582433 return (
0b4b582434 <div className="flex items-center justify-center" style={{ height: "100%" }}>
0b4b582435 <span className="text-sm" style={{ color: "var(--text-faint)" }}>Loading...</span>
0b4b582436 </div>
0b4b582437 );
0b4b582438 }
0b4b582439
0b4b582440 if (diagrams.length === 0) {
0b4b582441 return (
0b4b582442 <div className="flex items-center justify-center" style={{ height: "100%" }}>
0b4b582443 <span className="text-sm" style={{ color: "var(--text-faint)" }}>No diagrams found for this repository.</span>
0b4b582444 </div>
0b4b582445 );
0b4b582446 }
0b4b582447
0b4b582448 if (!user) {
0b4b582449 // Read-only mode — show diagrams without Socket.IO
0b4b582450 return (
0b4b582451 <div style={{ display: "flex", flex: 1, minHeight: 0, height: "100%" }}>
0b4b582452 <ReadOnlyWorkspace diagrams={diagrams} sections={sections} />
0b4b582453 </div>
0b4b582454 );
0b4b582455 }
0b4b582456
0b4b582457 return (
0b4b582458 <CollabProvider owner={owner} repo={repo}>
0b4b582459 <CollabWorkspaceInner owner={owner} repo={repo} diagrams={diagrams} sections={sections} />
0b4b582460 </CollabProvider>
0b4b582461 );
0b4b582462}
0b4b582463
0b4b582464function ReadOnlyWorkspace({ diagrams, sections }: { diagrams: Diagram[]; sections: string[] }) {
0b4b582465 const [activeId, setActiveId] = useState(diagrams[0]?.id ?? null);
0b4b582466 const activeDiagram = diagrams.find((d) => d.id === activeId);
0b4b582467
0b4b582468 return (
0b4b582469 <div style={{ display: "flex", flex: 1, height: "100%" }}>
0b4b582470 {/* Simple drawer without collab features */}
0b4b582471 <div
0b4b582472 style={{
0b4b582473 width: 260,
0b4b582474 borderRight: "1px solid var(--border)",
0b4b582475 display: "flex",
0b4b582476 flexDirection: "column",
0b4b582477 flexShrink: 0,
0b4b582478 overflow: "hidden",
0b4b582479 }}
0b4b582480 >
0b4b582481 <div
0b4b582482 className="px-3 py-2 text-xs font-medium uppercase tracking-wide"
0b4b582483 style={{ color: "var(--text-faint)", borderBottom: "1px solid var(--divide)" }}
0b4b582484 >
0b4b582485 Diagrams
0b4b582486 </div>
0b4b582487 <div style={{ flex: 1, overflowY: "auto" }}>
0b4b582488 {diagrams.map((d) => (
0b4b582489 <button
0b4b582490 key={d.id}
0b4b582491 onClick={() => setActiveId(d.id)}
0b4b582492 className="w-full text-left px-3 py-2 text-sm"
0b4b582493 style={{
0b4b582494 background: d.id === activeId ? "var(--bg-hover)" : "transparent",
0b4b582495 border: "none",
0b4b582496 color: d.id === activeId ? "var(--text-primary)" : "var(--text-muted)",
0b4b582497 fontWeight: d.id === activeId ? 600 : 400,
0b4b582498 cursor: "pointer",
0b4b582499 font: "inherit",
0b4b582500 }}
0b4b582501 >
0b4b582502 {d.title}
0b4b582503 </button>
0b4b582504 ))}
0b4b582505 </div>
0b4b582506 </div>
0b4b582507 <DiagramViewer
0b4b582508 code={activeDiagram?.code ?? ""}
0b4b582509 diagramId={activeId ?? ""}
0b4b582510 />
0b4b582511 </div>
0b4b582512 );
0b4b582513}