import { useCallback, useEffect, useRef, useState } from "react";
interface LogEntry {
id: number;
timestamp: Date;
type: "info" | "success" | "error" | "command";
message: string;
}
interface CommandResult {
id: string;
result?: unknown;
error?: string;
}
type ConnectionStatus = "disconnected" | "connecting" | "connected";
function App() {
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [channelName, setChannelName] = useState("figma");
const [serverUrl, setServerUrl] = useState("ws://localhost:3055");
const [logs, setLogs] = useState<LogEntry[]>([]);
const [commandCount, setCommandCount] = useState(0);
const wsRef = useRef<WebSocket | null>(null);
const pendingCommandsRef = useRef<
Map<string, (result: CommandResult) => void>
>(new Map());
const logIdRef = useRef(0);
const logsEndRef = useRef<HTMLDivElement>(null);
const addLog = useCallback((type: LogEntry["type"], message: string) => {
const entry: LogEntry = {
id: logIdRef.current++,
timestamp: new Date(),
type,
message,
};
setLogs((prev) => [...prev.slice(-99), entry]);
}, []);
// Scroll to bottom when logs change
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
// Listen for messages from plugin code
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const msg = event.data.pluginMessage;
if (!msg) return;
switch (msg.type) {
case "command-result": {
const resolver = pendingCommandsRef.current.get(msg.id);
if (resolver) {
resolver({ id: msg.id, result: msg.result });
pendingCommandsRef.current.delete(msg.id);
}
break;
}
case "command-error": {
const resolver = pendingCommandsRef.current.get(msg.id);
if (resolver) {
resolver({ id: msg.id, error: msg.error });
pendingCommandsRef.current.delete(msg.id);
}
break;
}
case "settings-loaded": {
if (msg.settings?.serverPort) {
setServerUrl(`ws://localhost:${msg.settings.serverPort}`);
}
break;
}
case "auto-connect": {
// Auto-connect when plugin starts
break;
}
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
const executeCommand = useCallback(
(
command: string,
params: Record<string, unknown>,
): Promise<CommandResult> => {
return new Promise((resolve) => {
const id = `cmd_${Date.now()}_${Math.random().toString(36).slice(2)}`;
pendingCommandsRef.current.set(id, resolve);
parent.postMessage(
{
pluginMessage: {
type: "execute-command",
id,
command,
params,
},
},
"*",
);
// Timeout after 30 seconds
setTimeout(() => {
if (pendingCommandsRef.current.has(id)) {
pendingCommandsRef.current.delete(id);
resolve({ id, error: "Command timeout" });
}
}, 30000);
});
},
[],
);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.close();
}
setStatus("connecting");
addLog("info", `Connecting to ${serverUrl}...`);
try {
const ws = new WebSocket(serverUrl);
wsRef.current = ws;
ws.onopen = () => {
addLog("success", "WebSocket connected");
// Join channel
ws.send(JSON.stringify({ type: "join", channel: channelName }));
addLog("info", `Joining channel: ${channelName}`);
setStatus("connected");
};
ws.onmessage = async (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "joined") {
addLog("success", `Joined channel: ${data.channel}`);
} else if (data.type === "system") {
// System messages (join confirmation, etc.)
if (typeof data.message === "string") {
addLog("info", data.message);
}
} else if (
(data.type === "message" || data.type === "broadcast") &&
data.message
) {
const message = data.message;
// Check if this is a Figma command (supports both formats)
// Format 1: { type: "figma_command", command, params, id }
// Format 2: { command, params, id } (from MCP server)
if (message.command) {
setCommandCount((c) => c + 1);
addLog("command", `Received: ${message.command}`);
const result = await executeCommand(
message.command,
message.params || {},
);
// Send result back through WebSocket
ws.send(
JSON.stringify({
type: "message",
channel: channelName,
message: {
id: message.id,
result: result.result,
error: result.error,
},
}),
);
if (result.error) {
addLog("error", `Error: ${result.error}`);
} else {
addLog("success", `Completed: ${message.command}`);
}
}
} else if (data.type === "error") {
addLog("error", `Server error: ${data.message}`);
}
} catch (err) {
addLog("error", `Parse error: ${err}`);
}
};
ws.onerror = () => {
addLog("error", "WebSocket error");
setStatus("disconnected");
};
ws.onclose = () => {
addLog("info", "WebSocket disconnected");
setStatus("disconnected");
};
} catch (err) {
addLog("error", `Connection failed: ${err}`);
setStatus("disconnected");
}
}, [serverUrl, channelName, addLog, executeCommand]);
const disconnect = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
setStatus("disconnected");
addLog("info", "Disconnected");
}, [addLog]);
const clearLogs = useCallback(() => {
setLogs([]);
logIdRef.current = 0;
}, []);
const notify = useCallback((message: string) => {
parent.postMessage({ pluginMessage: { type: "notify", message } }, "*");
}, []);
const getStatusColor = () => {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500";
default:
return "bg-gray-400";
}
};
const getLogTypeColor = (type: LogEntry["type"]) => {
switch (type) {
case "success":
return "text-green-600";
case "error":
return "text-red-600";
case "command":
return "text-blue-600";
default:
return "text-gray-600";
}
};
return (
<div className="flex flex-col h-screen bg-white text-gray-900">
{/* Header */}
<div className="p-3 border-b border-gray-200">
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${getStatusColor()}`} />
<h1 className="text-base font-semibold">Figma MCP</h1>
<span className="text-xs text-gray-500 ml-auto">
{commandCount} commands
</span>
</div>
{/* Connection Settings */}
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<input
type="text"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="ws://localhost:3055"
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={status === "connected"}
/>
</div>
<div className="flex gap-2">
<input
type="text"
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
placeholder="Channel name"
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
disabled={status === "connected"}
/>
{status === "connected" ? (
<button
type="button"
onClick={disconnect}
className="px-3 py-1.5 text-sm bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
>
Disconnect
</button>
) : (
<button
type="button"
onClick={connect}
disabled={status === "connecting"}
className="px-3 py-1.5 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-blue-300 transition-colors"
>
{status === "connecting" ? "Connecting..." : "Connect"}
</button>
)}
</div>
</div>
</div>
{/* Logs */}
<div className="flex-1 overflow-auto p-2 bg-gray-50 font-mono text-xs">
{logs.length === 0 ? (
<div className="text-gray-400 text-center py-8">
No activity yet. Connect to start receiving commands.
</div>
) : (
logs.map((log) => (
<div key={log.id} className="py-0.5">
<span className="text-gray-400">
{log.timestamp.toLocaleTimeString()}
</span>{" "}
<span className={getLogTypeColor(log.type)}>{log.message}</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
{/* Footer */}
<div className="p-2 border-t border-gray-200 flex gap-2">
<button
type="button"
onClick={clearLogs}
className="px-2 py-1 text-xs text-gray-600 border border-gray-300 rounded hover:bg-gray-100 transition-colors"
>
Clear Logs
</button>
<button
type="button"
onClick={() => notify("Figma MCP is ready!")}
className="px-2 py-1 text-xs text-gray-600 border border-gray-300 rounded hover:bg-gray-100 transition-colors"
>
Test Notify
</button>
</div>
</div>
);
}
export default App;