/** * 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 let stepsList = []; // Cached steps from DAP let activeContextMenu = null; // Track open context menu let activeModal = null; // Track open modal // 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; } /** * Quote a string for use in command, escaping as needed */ function quoteString(str) { // Escape backslashes and quotes, wrap in quotes return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; } /** * 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: ``, plus: ``, download: ``, check: ``, play: ``, clock: ``, pencil: ``, trash: ``, arrowUp: ``, arrowDown: ``, copy: ``, }; /** * Create control buttons HTML */ function createControlButtonsHTML(compact = false) { const btnClass = compact ? 'btn-sm' : 'btn-sm'; return ` `; } /** * Create the steps panel HTML */ function createStepsPanelHTML() { return `
Steps
Connect to view steps
`; } /** * Create the bottom panel HTML structure */ function createBottomPaneHTML() { return `
Debugger Connecting...
${createControlButtonsHTML(true)}
${createStepsPanelHTML()}
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
${createStepsPanelHTML()}
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'); // Close any open context menu or modal closeContextMenu(); closeModal(); if (debuggerPane) { debuggerPane.remove(); debuggerPane = null; } // Update debug button state const btn = document.querySelector('.dap-debug-btn'); if (btn) btn.classList.remove('selected'); } // ========================================================================== // Step Manipulation UI // ========================================================================== /** * Get status icon for step */ function getStepStatusIcon(status) { switch (status) { case 'completed': return `${Icons.check}`; case 'current': return `${Icons.play}`; case 'pending': default: return `${Icons.clock}`; } } /** * Render step list from data */ function renderStepList(steps) { const container = debuggerPane?.querySelector('.dap-steps-list'); if (!container) return; if (!steps || steps.length === 0) { container.innerHTML = '
No steps available
'; return; } stepsList = steps; const html = steps.map((step) => { const statusIcon = getStepStatusIcon(step.status); const changeBadge = step.change ? `[${step.change}]` : ''; const typeLabel = step.type === 'uses' ? step.typeDetail || step.type : step.type; const isPending = step.status === 'pending'; return `
${statusIcon} ${step.index}. ${escapeHtml(step.name)} ${changeBadge} ${escapeHtml(typeLabel)} ${isPending ? `` : ''}
`; }).join(''); container.innerHTML = html; // Add click handlers for step menu buttons container.querySelectorAll('.dap-step-menu-btn').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const stepItem = btn.closest('.dap-step-item'); const index = parseInt(stepItem.dataset.index); showStepContextMenu(index, e); }); }); } /** * Load steps from DAP server */ async function loadSteps() { try { const response = await sendDapRequest('evaluate', { expression: 'steps list --output json', frameId: currentFrameId, context: 'repl', }); if (response.result) { try { const result = JSON.parse(response.result); if (result.Success && result.Result) { renderStepList(result.Result); enableStepControls(true); return; } } catch (parseErr) { // Response might not be JSON, that's ok } } // Fallback: show empty state renderStepList([]); } catch (error) { console.error('[Content] Failed to load steps:', error); const container = debuggerPane?.querySelector('.dap-steps-list'); if (container) { container.innerHTML = '
Failed to load steps
'; } } } /** * Enable/disable step manipulation controls */ function enableStepControls(enabled) { if (!debuggerPane) return; const addBtn = debuggerPane.querySelector('.dap-add-step-btn'); const exportBtn = debuggerPane.querySelector('.dap-export-btn'); if (addBtn) addBtn.disabled = !enabled; if (exportBtn) exportBtn.disabled = !enabled; } /** * Build REPL command string from action and options */ function buildStepCommand(action, options) { let cmd; switch (action) { case 'step.list': cmd = options.verbose ? 'steps list --verbose' : 'steps list'; break; case 'step.add': cmd = buildAddStepCommand(options); break; case 'step.edit': cmd = buildEditStepCommand(options); break; case 'step.remove': cmd = `steps remove ${options.index}`; break; case 'step.move': cmd = buildMoveStepCommand(options); break; case 'step.export': cmd = buildExportCommand(options); break; default: throw new Error(`Unknown step command: ${action}`); } // Always request JSON output for programmatic use return cmd + ' --output json'; } /** * Build add step command string */ function buildAddStepCommand(options) { let cmd = 'steps add'; if (options.type === 'run') { cmd += ` run ${quoteString(options.script)}`; if (options.shell) cmd += ` --shell ${options.shell}`; if (options.workingDirectory) cmd += ` --working-directory ${quoteString(options.workingDirectory)}`; } else if (options.type === 'uses') { cmd += ` uses ${options.action}`; if (options.with) { for (const [key, value] of Object.entries(options.with)) { cmd += ` --with ${key}=${value}`; } } } if (options.name) cmd += ` --name ${quoteString(options.name)}`; if (options.if) cmd += ` --if ${quoteString(options.if)}`; if (options.env) { for (const [key, value] of Object.entries(options.env)) { cmd += ` --env ${key}=${value}`; } } if (options.continueOnError) cmd += ' --continue-on-error'; if (options.timeout) cmd += ` --timeout ${options.timeout}`; // Position if (options.position) { if (options.position.after !== undefined) cmd += ` --after ${options.position.after}`; else if (options.position.before !== undefined) cmd += ` --before ${options.position.before}`; else if (options.position.at !== undefined) cmd += ` --at ${options.position.at}`; else if (options.position.first) cmd += ' --first'; // --last is default, no need to specify } return cmd; } /** * Build edit step command string */ function buildEditStepCommand(options) { let cmd = `steps edit ${options.index}`; if (options.name) cmd += ` --name ${quoteString(options.name)}`; if (options.script) cmd += ` --script ${quoteString(options.script)}`; if (options.if) cmd += ` --if ${quoteString(options.if)}`; if (options.shell) cmd += ` --shell ${options.shell}`; if (options.workingDirectory) cmd += ` --working-directory ${quoteString(options.workingDirectory)}`; return cmd; } /** * Build move step command string */ function buildMoveStepCommand(options) { let cmd = `steps move ${options.from}`; const pos = options.position; if (pos.after !== undefined) cmd += ` --after ${pos.after}`; else if (pos.before !== undefined) cmd += ` --before ${pos.before}`; else if (pos.at !== undefined) cmd += ` --to ${pos.at}`; else if (pos.first) cmd += ' --first'; else if (pos.last) cmd += ' --last'; return cmd; } /** * Build export command string */ function buildExportCommand(options) { let cmd = 'steps export'; if (options.changesOnly) cmd += ' --changes-only'; if (options.withComments) cmd += ' --with-comments'; return cmd; } /** * Send step command via REPL format */ async function sendStepCommand(action, options = {}) { const expression = buildStepCommand(action, options); try { const response = await sendDapRequest('evaluate', { expression, frameId: currentFrameId, context: 'repl', }); if (response.result) { try { return JSON.parse(response.result); } catch (e) { // Response might be plain text for non-JSON output return { Success: true, Message: response.result }; } } return { Success: false, Error: 'NO_RESPONSE', Message: 'No response from server' }; } catch (error) { return { Success: false, Error: 'REQUEST_FAILED', Message: error.message }; } } /** * Show context menu for a step */ function showStepContextMenu(stepIndex, event) { closeContextMenu(); const step = stepsList.find((s) => s.index === stepIndex); if (!step || step.status !== 'pending') return; const menu = document.createElement('div'); menu.className = 'dap-context-menu'; menu.innerHTML = `
${Icons.pencil} Edit
${Icons.arrowUp} Move Up
${Icons.arrowDown} Move Down
${Icons.trash} Delete
`; // Position menu const rect = event.target.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.top = `${rect.bottom + 4}px`; menu.style.left = `${rect.left}px`; // Add event handlers menu.querySelectorAll('.dap-context-menu-item').forEach((item) => { item.addEventListener('click', async () => { const action = item.dataset.action; closeContextMenu(); await handleStepAction(action, stepIndex); }); }); document.body.appendChild(menu); activeContextMenu = menu; // Close menu on outside click setTimeout(() => { document.addEventListener('click', closeContextMenuOnOutsideClick); }, 0); } /** * Close context menu */ function closeContextMenu() { if (activeContextMenu) { activeContextMenu.remove(); activeContextMenu = null; } document.removeEventListener('click', closeContextMenuOnOutsideClick); } /** * Close context menu on outside click */ function closeContextMenuOnOutsideClick(e) { if (activeContextMenu && !activeContextMenu.contains(e.target)) { closeContextMenu(); } } /** * Handle step action from context menu */ async function handleStepAction(action, stepIndex) { switch (action) { case 'edit': showEditStepDialog(stepIndex); break; case 'moveUp': await moveStep(stepIndex, 'before', stepIndex - 1); break; case 'moveDown': await moveStep(stepIndex, 'after', stepIndex + 1); break; case 'delete': await deleteStep(stepIndex); break; } } /** * Move a step */ async function moveStep(fromIndex, positionType, targetIndex) { const position = {}; position[positionType] = targetIndex; const result = await sendStepCommand('step.move', { from: fromIndex, position }); if (result.Success) { appendOutput(`Step moved successfully`, 'result'); await loadSteps(); } else { appendOutput(`Failed to move step: ${result.Message || result.Error}`, 'error'); } } /** * Delete a step */ async function deleteStep(stepIndex) { const step = stepsList.find((s) => s.index === stepIndex); if (!step) return; // Simple confirmation if (!confirm(`Delete step "${step.name}"?`)) return; const result = await sendStepCommand('step.remove', { index: stepIndex }); if (result.Success) { appendOutput(`Step removed successfully`, 'result'); await loadSteps(); } else { appendOutput(`Failed to remove step: ${result.Message || result.Error}`, 'error'); } } /** * Close modal */ function closeModal() { if (activeModal) { activeModal.remove(); activeModal = null; } } /** * Create modal wrapper */ function createModal(title, content, buttons = []) { closeModal(); const modal = document.createElement('div'); modal.className = 'dap-modal-overlay'; modal.innerHTML = `
${escapeHtml(title)}
${content}
`; // Close on overlay click modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); // Close button modal.querySelector('.dap-modal-close-btn').addEventListener('click', closeModal); document.body.appendChild(modal); activeModal = modal; return modal; } /** * Show Add Step dialog */ function showAddStepDialog() { const content = `
`; const modal = createModal('Add Step', content, [ { label: 'Cancel', action: 'cancel' }, { label: 'Add Step', action: 'add', primary: true }, ]); // Type select handler const typeSelect = modal.querySelector('.dap-step-type-select'); const runFields = modal.querySelectorAll('.dap-run-fields'); const usesFields = modal.querySelectorAll('.dap-uses-fields'); typeSelect.addEventListener('change', () => { const isRun = typeSelect.value === 'run'; runFields.forEach((f) => (f.style.display = isRun ? '' : 'none')); usesFields.forEach((f) => (f.style.display = isRun ? 'none' : '')); }); // Button handlers modal.querySelectorAll('[data-action]').forEach((btn) => { btn.addEventListener('click', async () => { const action = btn.dataset.action; if (action === 'cancel') { closeModal(); return; } if (action === 'add') { await handleAddStep(modal); } }); }); } /** * Handle add step form submission */ async function handleAddStep(modal) { const type = modal.querySelector('.dap-step-type-select').value; const name = modal.querySelector('.dap-name-input').value.trim() || undefined; const positionSelect = modal.querySelector('.dap-position-select').value; let position = {}; if (positionSelect === 'first') { position.first = true; } else if (positionSelect === 'after') { // After current step - find current step index const currentStep = stepsList.find((s) => s.status === 'current'); if (currentStep) { position.after = currentStep.index; } else { position.last = true; } } else { position.last = true; } let result; if (type === 'run') { const script = modal.querySelector('.dap-script-input').value; const shell = modal.querySelector('.dap-shell-select').value || undefined; if (!script.trim()) { alert('Script is required'); return; } result = await sendStepCommand('step.add', { type: 'run', script, name, shell, position, }); } else { const action = modal.querySelector('.dap-action-input').value.trim(); const withText = modal.querySelector('.dap-with-input').value.trim(); if (!action) { alert('Action is required'); return; } // Parse with inputs const withInputs = {}; if (withText) { withText.split('\n').forEach((line) => { const [key, ...valueParts] = line.split('='); if (key && valueParts.length > 0) { withInputs[key.trim()] = valueParts.join('=').trim(); } }); } result = await sendStepCommand('step.add', { type: 'uses', action, name, with: Object.keys(withInputs).length > 0 ? withInputs : undefined, position, }); } if (result.Success) { closeModal(); appendOutput(`Step added at position ${result.Result?.index || 'end'}`, 'result'); await loadSteps(); } else { appendOutput(`Failed to add step: ${result.Message || result.Error}`, 'error'); } } /** * Show Edit Step dialog */ function showEditStepDialog(stepIndex) { const step = stepsList.find((s) => s.index === stepIndex); if (!step) return; const isRun = step.type === 'run'; const content = `
${isRun ? `
` : `
`}
`; const modal = createModal(`Edit Step ${stepIndex}`, content, [ { label: 'Cancel', action: 'cancel' }, { label: 'Save Changes', action: 'save', primary: true }, ]); // Button handlers modal.querySelectorAll('[data-action]').forEach((btn) => { btn.addEventListener('click', async () => { const action = btn.dataset.action; if (action === 'cancel') { closeModal(); return; } if (action === 'save') { await handleEditStep(modal, stepIndex, isRun); } }); }); } /** * Handle edit step form submission */ async function handleEditStep(modal, stepIndex, isRun) { const name = modal.querySelector('.dap-edit-name-input').value.trim() || undefined; const condition = modal.querySelector('.dap-edit-condition-input')?.value.trim() || undefined; const options = { index: stepIndex }; if (name) options.name = name; if (condition) options.if = condition; if (isRun) { const script = modal.querySelector('.dap-edit-script-input')?.value; if (script) options.script = script; } const result = await sendStepCommand('step.edit', options); if (result.Success) { closeModal(); appendOutput(`Step ${stepIndex} updated`, 'result'); await loadSteps(); } else { appendOutput(`Failed to edit step: ${result.Message || result.Error}`, 'error'); } } /** * Show Export modal */ async function showExportModal() { const result = await sendStepCommand('step.export', { changesOnly: false, withComments: true }); if (!result.Success) { appendOutput(`Failed to export: ${result.Message || result.Error}`, 'error'); return; } const yaml = result.Message || result.Result?.yaml || ''; const stats = result.Result || {}; const content = `
Total steps: ${stats.totalSteps || 0} | Added: ${stats.addedCount || 0} | Modified: ${stats.modifiedCount || 0}
${escapeHtml(yaml)}
`; const modal = createModal('Export Steps', content, [ { label: 'Close', action: 'close' }, { label: 'Copy to Clipboard', action: 'copy', primary: true }, ]); // Store YAML for copy modal.dataset.yaml = yaml; // Button handlers modal.querySelectorAll('[data-action]').forEach((btn) => { btn.addEventListener('click', async () => { const action = btn.dataset.action; if (action === 'close') { closeModal(); return; } if (action === 'copy') { try { await navigator.clipboard.writeText(modal.dataset.yaml); btn.textContent = 'Copied!'; setTimeout(() => { btn.innerHTML = `${Icons.copy} Copy to Clipboard`; }, 2000); } catch (err) { appendOutput('Failed to copy to clipboard', 'error'); } } }); }); } /** * 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); } // Add Step button const addStepBtn = pane.querySelector('.dap-add-step-btn'); if (addStepBtn) { addStepBtn.addEventListener('click', showAddStepDialog); } // Export button const exportBtn = pane.querySelector('.dap-export-btn'); if (exportBtn) { exportBtn.addEventListener('click', showExportModal); } } /** * 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, // Use 'repl' context for shell commands (!) and step commands context: (command.startsWith('!') || command.startsWith('steps')) ? '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; } // Also enable step controls when debugger is enabled enableStepControls(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); // Load steps for step manipulation panel await loadSteps(); } } 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); // Load steps for step manipulation panel await loadSteps(); } } 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(); }