/** * Content Script - Debugger UI * * Injects the debugger pane into GitHub Actions job pages and handles * all UI interactions. Supports two layout modes: sidebar and bottom panel. */ // State let debuggerPane = null; let currentFrameId = 0; let isConnected = false; let replHistory = []; let replHistoryIndex = -1; let currentLayout = 'bottom'; // 'bottom' | 'sidebar' let currentStepElement = null; // Track current step for breakpoint indicator // Layout constants const BOTTOM_PANEL_HEIGHT = 350; const SIDEBAR_WIDTH = 350; // HTML escape helper function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Strip result indicator suffix from step name * e.g., "Run tests [running]" -> "Run tests" */ function stripResultIndicator(name) { return name.replace(/\s*\[(running|success|failure|skipped|cancelled)\]$/i, ''); } /** * Load layout preference from storage */ function loadLayoutPreference() { return new Promise((resolve) => { chrome.storage.local.get(['debuggerLayout'], (data) => { currentLayout = data.debuggerLayout || 'bottom'; resolve(currentLayout); }); }); } /** * Save layout preference to storage */ function saveLayoutPreference(layout) { currentLayout = layout; chrome.storage.local.set({ debuggerLayout: layout }); } /** * Send DAP request to background script */ function sendDapRequest(command, args = {}) { return new Promise((resolve, reject) => { chrome.runtime.sendMessage({ type: 'dap-request', command, args }, (response) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); } else if (response && response.success) { resolve(response.body); } else { reject(new Error(response?.error || 'Unknown error')); } }); }); } /** * Build map of steps from DOM */ function buildStepMap() { const steps = document.querySelectorAll('check-step'); const map = new Map(); steps.forEach((el, idx) => { map.set(idx, { element: el, number: parseInt(el.dataset.number), name: el.dataset.name, conclusion: el.dataset.conclusion, externalId: el.dataset.externalId, }); }); return map; } /** * Find step element by name */ function findStepByName(stepName) { return document.querySelector(`check-step[data-name="${CSS.escape(stepName)}"]`); } /** * Find step element by number */ function findStepByNumber(stepNumber) { return document.querySelector(`check-step[data-number="${stepNumber}"]`); } /** * Get all step elements */ function getAllSteps() { return document.querySelectorAll('check-step'); } /** * SVG Icons */ const Icons = { bug: ` `, close: ` `, layoutBottom: ` `, layoutSidebar: ` `, reverseContinue: ``, stepBack: ``, continue: ``, stepForward: ``, }; /** * Create control buttons HTML */ function createControlButtonsHTML(compact = false) { const btnClass = compact ? 'btn-sm' : 'btn-sm'; return ` `; } /** * Create the bottom panel HTML structure */ function createBottomPaneHTML() { return `
Debugger Connecting...
${createControlButtonsHTML(true)}
Variables
Connect to view variables
Console
Welcome to Actions DAP Debugger
Enter expressions like: \${{ github.ref }}
Or shell commands: !ls -la
`; } /** * Create the sidebar panel HTML structure */ function createSidebarPaneHTML() { return `
Debugger
Variables
Connect to view variables
Console
Welcome to Actions DAP Debugger
Enter expressions like: \${{ github.ref }}
Or shell commands: !ls -la
${createControlButtonsHTML()}
`; } /** * Inject debugger pane into the page */ function injectDebuggerPane(layout = null) { // Remove existing pane if any const existing = document.querySelector('.dap-debugger-pane'); if (existing) existing.remove(); const targetLayout = layout || currentLayout; // Create pane const pane = document.createElement('div'); pane.className = `dap-debugger-pane dap-debugger-${targetLayout}`; if (targetLayout === 'bottom') { pane.innerHTML = createBottomPaneHTML(); } else { pane.innerHTML = createSidebarPaneHTML(); } // Append to body for fixed positioning document.body.appendChild(pane); // Add body padding class for bottom layout to ensure content is scrollable if (targetLayout === 'bottom') { document.body.classList.add('dap-bottom-panel-active'); } else { document.body.classList.remove('dap-bottom-panel-active'); } // Setup event handlers setupPaneEventHandlers(pane); debuggerPane = pane; currentLayout = targetLayout; // Update layout toggle button states updateLayoutToggleButtons(); return pane; } /** * Switch between layouts */ function switchLayout(newLayout) { if (newLayout === currentLayout) return; // Store current state before switching const wasConnected = isConnected; const replOutputContent = debuggerPane?.querySelector('.dap-repl-output')?.innerHTML; const scopeTreeContent = debuggerPane?.querySelector('.dap-scope-tree')?.innerHTML; // Remove old pane if (debuggerPane) { debuggerPane.remove(); debuggerPane = null; } // Save preference and create new pane saveLayoutPreference(newLayout); const pane = injectDebuggerPane(newLayout); // Update body padding class based on new layout if (newLayout === 'bottom') { document.body.classList.add('dap-bottom-panel-active'); } else { document.body.classList.remove('dap-bottom-panel-active'); } // Restore state if (replOutputContent) { const output = pane.querySelector('.dap-repl-output'); if (output) output.innerHTML = replOutputContent; } if (scopeTreeContent) { const scopeTree = pane.querySelector('.dap-scope-tree'); if (scopeTree) scopeTree.innerHTML = scopeTreeContent; } // Re-enable controls if connected if (wasConnected) { enableControls(true); } // Update debug button state const btn = document.querySelector('.dap-debug-btn'); if (btn) btn.classList.add('selected'); // Update layout toggle button states updateLayoutToggleButtons(); } /** * Update layout toggle button active states */ function updateLayoutToggleButtons() { if (!debuggerPane) return; debuggerPane.querySelectorAll('.dap-layout-btn').forEach((btn) => { const layout = btn.dataset.layout; btn.classList.toggle('active', layout === currentLayout); }); } /** * Close the debugger pane */ function closeDebuggerPane() { // Clear breakpoint indicator clearBreakpointIndicator(); // Remove body padding class document.body.classList.remove('dap-bottom-panel-active'); if (debuggerPane) { debuggerPane.remove(); debuggerPane = null; } // Update debug button state const btn = document.querySelector('.dap-debug-btn'); if (btn) btn.classList.remove('selected'); } /** * Setup event handlers for debugger pane */ function setupPaneEventHandlers(pane) { // Control buttons pane.querySelectorAll('[data-action]').forEach((btn) => { btn.addEventListener('click', async () => { const action = btn.dataset.action; enableControls(false); updateStatus('RUNNING'); try { await sendDapRequest(action, { threadId: 1 }); } catch (error) { console.error(`[Content] DAP ${action} failed:`, error); appendOutput(`Error: ${error.message}`, 'error'); enableControls(true); updateStatus('ERROR'); } }); }); // Layout toggle buttons pane.querySelectorAll('.dap-layout-btn').forEach((btn) => { btn.addEventListener('click', () => { const layout = btn.dataset.layout; if (layout && layout !== currentLayout) { switchLayout(layout); } }); }); // Close button const closeBtn = pane.querySelector('.dap-close-btn'); if (closeBtn) { closeBtn.addEventListener('click', closeDebuggerPane); } // REPL input const input = pane.querySelector('.dap-repl-input input'); if (input) { input.addEventListener('keydown', handleReplKeydown); } } /** * Handle REPL input keydown */ async function handleReplKeydown(e) { const input = e.target; if (e.key === 'Enter') { const command = input.value.trim(); if (!command) return; replHistory.push(command); replHistoryIndex = replHistory.length; input.value = ''; // Show command appendOutput(`> ${command}`, 'input'); // Send to DAP try { const response = await sendDapRequest('evaluate', { expression: command, frameId: currentFrameId, context: command.startsWith('!') ? 'repl' : 'watch', }); // Only show result if it's NOT an exit code summary // (shell command output is already streamed via output events) if (response.result && !/^\(exit code: -?\d+\)$/.test(response.result)) { appendOutput(response.result, 'result'); } } catch (error) { appendOutput(error.message, 'error'); } } else if (e.key === 'ArrowUp') { if (replHistoryIndex > 0) { replHistoryIndex--; input.value = replHistory[replHistoryIndex]; } e.preventDefault(); } else if (e.key === 'ArrowDown') { if (replHistoryIndex < replHistory.length - 1) { replHistoryIndex++; input.value = replHistory[replHistoryIndex]; } else { replHistoryIndex = replHistory.length; input.value = ''; } e.preventDefault(); } } /** * Append output to REPL console */ function appendOutput(text, type) { const output = document.querySelector('.dap-repl-output'); if (!output) return; // Handle multi-line output - each line gets its own div const lines = text.split('\n'); lines.forEach((l) => { const div = document.createElement('div'); div.className = `dap-output-${type}`; if (type === 'error') div.classList.add('color-fg-danger'); if (type === 'input') div.classList.add('color-fg-muted'); div.textContent = l; output.appendChild(div); }); output.scrollTop = output.scrollHeight; } /** * Enable/disable control buttons */ function enableControls(enabled) { if (!debuggerPane) return; debuggerPane.querySelectorAll('.dap-control-btn').forEach((btn) => { btn.disabled = !enabled; }); const input = debuggerPane.querySelector('.dap-repl-input input'); if (input) { input.disabled = !enabled; } } /** * Update status display */ function updateStatus(status, extra) { if (!debuggerPane) return; // Update step info text const stepInfo = debuggerPane.querySelector('.dap-step-info'); if (stepInfo && extra) { stepInfo.textContent = extra; } } /** * Update breakpoint indicator - highlights the current step */ function updateBreakpointIndicator(stepElement) { // Clear previous indicator clearBreakpointIndicator(); if (!stepElement) return; // Add indicator class to current step stepElement.classList.add('dap-current-step'); currentStepElement = stepElement; // Scroll step into view if needed stepElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } /** * Clear breakpoint indicator from all steps */ function clearBreakpointIndicator() { if (currentStepElement) { currentStepElement.classList.remove('dap-current-step'); currentStepElement = null; } // Also clear any other steps that might have the class document.querySelectorAll('.dap-current-step').forEach((el) => { el.classList.remove('dap-current-step'); }); } /** * Load scopes for current frame */ async function loadScopes(frameId) { const scopesContainer = document.querySelector('.dap-scope-tree'); if (!scopesContainer) return; scopesContainer.innerHTML = '
Loading...
'; try { console.log('[Content] Loading scopes for frame:', frameId); const response = await sendDapRequest('scopes', { frameId }); console.log('[Content] Scopes response:', response); scopesContainer.innerHTML = ''; if (!response.scopes || response.scopes.length === 0) { scopesContainer.innerHTML = '
No scopes available
'; return; } for (const scope of response.scopes) { console.log('[Content] Creating tree node for scope:', scope.name, 'variablesRef:', scope.variablesReference); // Only mark as expandable if variablesReference > 0 const isExpandable = scope.variablesReference > 0; const node = createTreeNode(scope.name, scope.variablesReference, isExpandable); scopesContainer.appendChild(node); } } catch (error) { console.error('[Content] Failed to load scopes:', error); scopesContainer.innerHTML = `
Error: ${escapeHtml(error.message)}
`; } } /** * Create a tree node for scope/variable display */ function createTreeNode(name, variablesReference, isExpandable, value) { const node = document.createElement('div'); node.className = 'dap-tree-node'; node.dataset.variablesRef = variablesReference; const content = document.createElement('div'); content.className = 'dap-tree-content'; // Expand icon const expandIcon = document.createElement('span'); expandIcon.className = 'dap-expand-icon'; expandIcon.textContent = isExpandable ? '\u25B6' : ' '; // triangleright or space content.appendChild(expandIcon); // Name const nameSpan = document.createElement('span'); nameSpan.className = 'text-bold'; nameSpan.textContent = name; content.appendChild(nameSpan); // Value (if provided) if (value !== undefined) { const valueSpan = document.createElement('span'); valueSpan.className = 'color-fg-muted'; valueSpan.textContent = `: ${value}`; content.appendChild(valueSpan); } node.appendChild(content); if (isExpandable && variablesReference > 0) { content.style.cursor = 'pointer'; content.addEventListener('click', () => toggleTreeNode(node)); } return node; } /** * Toggle tree node expansion */ async function toggleTreeNode(node) { const children = node.querySelector('.dap-tree-children'); const expandIcon = node.querySelector('.dap-expand-icon'); if (children) { // Toggle visibility children.hidden = !children.hidden; expandIcon.textContent = children.hidden ? '\u25B6' : '\u25BC'; // triangleright or triangledown return; } // Fetch children const variablesRef = parseInt(node.dataset.variablesRef); if (!variablesRef) return; expandIcon.textContent = '...'; try { const response = await sendDapRequest('variables', { variablesReference: variablesRef }); const childContainer = document.createElement('div'); childContainer.className = 'dap-tree-children ml-3'; for (const variable of response.variables) { const hasChildren = variable.variablesReference > 0; const childNode = createTreeNode( variable.name, variable.variablesReference, hasChildren, variable.value ); childContainer.appendChild(childNode); } node.appendChild(childContainer); expandIcon.textContent = '\u25BC'; // triangledown } catch (error) { console.error('[Content] Failed to load variables:', error); expandIcon.textContent = '\u25B6'; // triangleright } } /** * Handle stopped event from DAP */ async function handleStoppedEvent(body) { console.log('[Content] Stopped event:', body); isConnected = true; updateStatus('PAUSED', body.reason || 'paused'); enableControls(true); // Get current location try { const stackTrace = await sendDapRequest('stackTrace', { threadId: 1 }); if (stackTrace.stackFrames && stackTrace.stackFrames.length > 0) { const currentFrame = stackTrace.stackFrames[0]; currentFrameId = currentFrame.id; // Strip result indicator from step name for DOM lookup const rawStepName = stripResultIndicator(currentFrame.name); let stepElement = findStepByName(rawStepName); if (!stepElement && currentFrame.line > 0) { // Fallback: use step number from Line property // Add 1 to account for "Set up job" which is always step 1 in GitHub UI but not in DAP stepElement = findStepByNumber(currentFrame.line + 1); } // Update breakpoint indicator if (stepElement) { updateBreakpointIndicator(stepElement); } // Update step info in header const stepInfo = debuggerPane?.querySelector('.dap-step-info'); if (stepInfo) { stepInfo.textContent = `Paused before: ${rawStepName}`; } // Load scopes await loadScopes(currentFrame.id); } } catch (error) { console.error('[Content] Failed to get stack trace:', error); appendOutput(`Error: ${error.message}`, 'error'); } } /** * Handle output event from DAP */ function handleOutputEvent(body) { if (body.output) { const category = body.category === 'stderr' ? 'error' : 'stdout'; appendOutput(body.output.trimEnd(), category); } } /** * Handle terminated event from DAP */ function handleTerminatedEvent() { isConnected = false; updateStatus('TERMINATED', 'Session ended'); enableControls(false); clearBreakpointIndicator(); } /** * Load current debug state (used when page loads while already paused) */ async function loadCurrentDebugState() { if (!debuggerPane) return; try { const stackTrace = await sendDapRequest('stackTrace', { threadId: 1 }); if (stackTrace.stackFrames && stackTrace.stackFrames.length > 0) { const currentFrame = stackTrace.stackFrames[0]; currentFrameId = currentFrame.id; // Strip result indicator from step name for DOM lookup const rawStepName = stripResultIndicator(currentFrame.name); let stepElement = findStepByName(rawStepName); if (!stepElement && currentFrame.line > 0) { // Fallback: use step number from Line property stepElement = findStepByNumber(currentFrame.line + 1); } // Update breakpoint indicator if (stepElement) { updateBreakpointIndicator(stepElement); } // Update step info in header const stepInfo = debuggerPane.querySelector('.dap-step-info'); if (stepInfo) { stepInfo.textContent = `Paused before: ${rawStepName}`; } // Load scopes await loadScopes(currentFrame.id); } } catch (error) { console.error('[Content] Failed to load current debug state:', error); } } /** * Handle status change from background */ function handleStatusChange(status) { console.log('[Content] Status changed:', status); switch (status) { case 'connected': isConnected = true; updateStatus('CONNECTED', 'Waiting for debug event...'); break; case 'paused': isConnected = true; updateStatus('PAUSED'); enableControls(true); loadCurrentDebugState(); break; case 'running': isConnected = true; updateStatus('RUNNING', 'Running...'); enableControls(false); break; case 'disconnected': isConnected = false; updateStatus('DISCONNECTED', 'Disconnected'); enableControls(false); clearBreakpointIndicator(); break; case 'error': isConnected = false; updateStatus('ERROR', 'Connection error'); enableControls(false); clearBreakpointIndicator(); break; } } /** * Listen for messages from background script */ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { console.log('[Content] Received message:', message.type); switch (message.type) { case 'dap-event': const event = message.event; switch (event.event) { case 'stopped': handleStoppedEvent(event.body); break; case 'output': handleOutputEvent(event.body); break; case 'terminated': handleTerminatedEvent(); break; } break; case 'status-changed': handleStatusChange(message.status); break; } }); /** * Inject debug button into GitHub Actions UI header */ function injectDebugButton() { // Try multiple possible container selectors let container = document.querySelector('.js-check-run-search'); // Alternative: look for the header area with search box if (!container) { container = document.querySelector('.PageLayout-content .Box-header'); } if (!container || container.querySelector('.dap-debug-btn-container')) { return; // Already injected or container not found } const buttonContainer = document.createElement('div'); buttonContainer.className = 'ml-2 dap-debug-btn-container'; buttonContainer.innerHTML = ` `; const button = buttonContainer.querySelector('button'); button.addEventListener('click', async () => { let pane = document.querySelector('.dap-debugger-pane'); if (pane) { // Close pane closeDebuggerPane(); } else { // Load preference and create pane await loadLayoutPreference(); pane = injectDebuggerPane(); if (pane) { button.classList.add('selected'); // Check connection status after creating pane chrome.runtime.sendMessage({ type: 'get-status' }, (response) => { if (response && response.status) { handleStatusChange(response.status); } }); } } }); // Insert at the beginning of the container container.insertBefore(buttonContainer, container.firstChild); console.log('[Content] Debug button injected'); } /** * Initialize content script */ async function init() { console.log('[Content] Actions DAP Debugger content script loaded'); // Load layout preference await loadLayoutPreference(); // Check if we're on a job page const steps = getAllSteps(); if (steps.length === 0) { console.log('[Content] No steps found, waiting for DOM...'); // Wait for steps to appear const observer = new MutationObserver((mutations) => { const steps = getAllSteps(); if (steps.length > 0) { observer.disconnect(); console.log('[Content] Steps found, injecting debug button'); injectDebugButton(); } }); observer.observe(document.body, { childList: true, subtree: true }); return; } // Inject debug button in header (user can click to show debugger pane) injectDebugButton(); // Check current connection status chrome.runtime.sendMessage({ type: 'get-status' }, async (response) => { if (response && response.status) { // If already connected/paused, auto-show the debugger pane if (response.status === 'paused' || response.status === 'connected') { const pane = document.querySelector('.dap-debugger-pane'); if (!pane) { injectDebuggerPane(); const btn = document.querySelector('.dap-debug-btn'); if (btn) btn.classList.add('selected'); } handleStatusChange(response.status); // If already paused, load the current debug state if (response.status === 'paused') { await loadCurrentDebugState(); } } } }); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }