/**
* 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 `
`;
}
/**
* Create the bottom panel HTML structure
*/
function createBottomPaneHTML() {
return `
${createStepsPanelHTML()}
Connect to view variables
Welcome to Actions DAP Debugger
Enter expressions like: \${{ github.ref }}
Or shell commands: !ls -la
`;
}
/**
* Create the sidebar panel HTML structure
*/
function createSidebarPaneHTML() {
return `
${createStepsPanelHTML()}
Connect to view variables
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 = `
`;
// 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 = `
`;
// 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}
`;
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();
}