diff --git a/src/extension.ts b/src/extension.ts index 888064d..85b0b2a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,8 +5,14 @@ import * as path from 'path'; import * as vscode from 'vscode'; import WebSocket from 'ws'; -// Store active debugger panels by session ID -const debuggerPanels: Map = new Map(); +// Single grid panel that shows all active sessions +let gridPanel: vscode.WebviewPanel | undefined; +let gridPanelReady = false; +const gridPendingMessages: object[] = []; + +// Active sessions shown in the grid, keyed by session ID +const activeSessions: Map = new Map(); + const websocketConnections: Map = new Map(); let processedSessions: Set = new Set(); @@ -348,57 +354,88 @@ function formatPanelTitle(status: string, testFile?: string): string { return `[${status}] ${getTestFileName(testFile)}`; } +function sendToGrid(message: object) { + if (gridPanel && gridPanelReady) { + gridPanel.webview.postMessage(message); + } else { + gridPendingMessages.push(message); + } +} + +function updateGridPanelTitle() { + if (!gridPanel) { return; } + const count = activeSessions.size; + gridPanel.title = count > 1 + ? `TestDriver: Live Preview (${count} tests)` + : 'TestDriver: Live Preview'; +} + function openDebuggerPanel(context: vscode.ExtensionContext, sessionData?: SessionData) { const sessionId = sessionData?.sessionId || `manual-${Date.now()}`; - const existingPanel = debuggerPanels.get(sessionId); - if (existingPanel) { - existingPanel.reveal(vscode.ViewColumn.Active); - if (sessionData) { - updateDebuggerContent(existingPanel, sessionData, context, sessionId); - } - return; - } + if (!gridPanel) { + gridPanelReady = false; + gridPendingMessages.length = 0; + + const panel = vscode.window.createWebviewPanel( + 'testdriverDebugger', + 'TestDriver: Live Preview', + vscode.ViewColumn.Beside, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(path.join(context.extensionPath, 'media')) + ] + } + ); - const initialTitle = sessionData - ? formatPanelTitle('Loading', sessionData.testFile) - : 'TestDriver Live Preview'; - - const panel = vscode.window.createWebviewPanel( - 'testdriverDebugger', - initialTitle, - vscode.ViewColumn.Beside, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.file(path.join(context.extensionPath, 'media')) - ] - } - ); + gridPanel = panel; - debuggerPanels.set(sessionId, panel); + panel.iconPath = { + light: vscode.Uri.file(path.join(context.extensionPath, 'media', 'icon.png')), + dark: vscode.Uri.file(path.join(context.extensionPath, 'media', 'icon.png')) + }; - panel.iconPath = { - light: vscode.Uri.file(path.join(context.extensionPath, 'media', 'icon.png')), - dark: vscode.Uri.file(path.join(context.extensionPath, 'media', 'icon.png')) - }; + panel.webview.onDidReceiveMessage( + (msg) => { + if (msg.type === 'ready') { + gridPanelReady = true; + for (const pending of gridPendingMessages) { + panel.webview.postMessage(pending); + } + gridPendingMessages.length = 0; + } + }, + null, + context.subscriptions + ); - panel.onDidDispose(() => { - debuggerPanels.delete(sessionId); - disconnectWebSocket(sessionId); - processedSessions.delete(sessionId); - }, null, context.subscriptions); + panel.onDidDispose(() => { + gridPanel = undefined; + gridPanelReady = false; + gridPendingMessages.length = 0; + for (const sid of activeSessions.keys()) { + disconnectWebSocket(sid); + processedSessions.delete(sid); + } + activeSessions.clear(); + }, null, context.subscriptions); - if (sessionData) { - updateDebuggerContent(panel, sessionData, context, sessionId); + panel.webview.html = getGridHtml(); } else { - panel.webview.html = getWaitingHtml(); + gridPanel.reveal(vscode.ViewColumn.Active); + } + + if (sessionData) { + activeSessions.set(sessionId, sessionData); + updateGridPanelTitle(); + updateDebuggerContent(sessionId, sessionData); } } -function updateDebuggerContent(panel: vscode.WebviewPanel, sessionData: SessionData, context: vscode.ExtensionContext, sessionId: string) { - connectToWebSocket(sessionData.debuggerUrl, panel, sessionId, sessionData.testFile); +function updateDebuggerContent(sessionId: string, sessionData: SessionData) { + connectToWebSocket(sessionData.debuggerUrl, sessionId, sessionData.testFile); const data = { resolution: sessionData.resolution, @@ -409,8 +446,15 @@ function updateDebuggerContent(panel: vscode.WebviewPanel, sessionData: SessionD }; const encodedData = Buffer.from(JSON.stringify(data)).toString('base64'); - panel.title = formatPanelTitle('Running', sessionData.testFile); - panel.webview.html = getDebuggerHtml(sessionData.debuggerUrl, encodedData); + const sessionUrl = new URL(sessionData.debuggerUrl); + sessionUrl.searchParams.set('data', encodedData); + + sendToGrid({ + type: 'addSession', + sessionId, + url: sessionUrl.toString(), + title: formatPanelTitle('Running', sessionData.testFile) + }); } function extractVncUrl(debuggerUrl: string): string { @@ -429,7 +473,7 @@ function extractVncUrl(debuggerUrl: string): string { // ── WebSocket Connection ──────────────────────────────────────────────────── -function connectToWebSocket(debuggerUrl: string, panel: vscode.WebviewPanel, sessionId: string, testFile?: string) { +function connectToWebSocket(debuggerUrl: string, sessionId: string, testFile?: string) { disconnectWebSocket(sessionId); try { @@ -446,27 +490,35 @@ function connectToWebSocket(debuggerUrl: string, panel: vscode.WebviewPanel, ses ws.on('message', (data: Buffer) => { try { const message = JSON.parse(data.toString()); - panel.webview.postMessage(message); + sendToGrid({ ...message, _gridSessionId: sessionId }); if (message.event) { + let status = ''; switch (message.event) { case 'test:start': - panel.title = formatPanelTitle('Running', testFile); + status = 'Running'; break; case 'test:stop': - panel.title = formatPanelTitle('Stopped', testFile); + status = 'Stopped'; break; case 'test:success': - panel.title = formatPanelTitle('Passed', testFile); + status = 'Passed'; break; case 'test:error': - panel.title = formatPanelTitle('Failed', testFile); + status = 'Failed'; break; case 'error:fatal': case 'error:sdk': - panel.title = formatPanelTitle('Error', testFile); + status = 'Error'; break; } + if (status) { + sendToGrid({ + type: 'updateTitle', + sessionId, + title: formatPanelTitle(status, testFile) + }); + } } } catch (error) { console.error('Error parsing WebSocket message:', error); @@ -474,7 +526,11 @@ function connectToWebSocket(debuggerUrl: string, panel: vscode.WebviewPanel, ses }); ws.on('close', () => { - panel.title = formatPanelTitle('Done', testFile); + sendToGrid({ + type: 'updateTitle', + sessionId, + title: formatPanelTitle('Done', testFile) + }); }); ws.on('error', (error: Error) => { @@ -494,12 +550,15 @@ function disconnectWebSocket(sessionId: string) { } function closeAllDebuggerPanels() { - for (const [sessionId, panel] of debuggerPanels) { - panel.dispose(); + for (const sessionId of activeSessions.keys()) { disconnectWebSocket(sessionId); } - debuggerPanels.clear(); + activeSessions.clear(); processedSessions.clear(); + if (gridPanel) { + gridPanel.dispose(); + gridPanel = undefined; + } } // ── MCP Server Installation ──────────────────────────────────────────────── @@ -635,29 +694,29 @@ async function autoInstallMcp(context: vscode.ExtensionContext) { // ── Webview HTML ──────────────────────────────────────────────────────────── -function getWaitingHtml(): string { +function getGridHtml(): string { return ` + TestDriver Live Preview -
-

Waiting for TestDriver...

-

Run a test with preview: "ide" to see the live execution here.

-

const testdriver = TestDriver(context, { preview: "ide" });

- -`; -} - -function getDebuggerHtml(debuggerUrl: string, encodedData: string): string { - const url = new URL(debuggerUrl); - url.searchParams.set('data', encodedData); - const fullUrl = url.toString(); - - return ` - - - - - - TestDriver Live Preview - - - - -
-

Connection Lost

-

The TestDriver debugger server is no longer running. Start a new test to reconnect.

+
+
+

Waiting for TestDriver...

+

Run a test with preview: "ide" to see the live execution here.

+

const testdriver = TestDriver(context, { preview: "ide" });

+
`;