mirror of
https://github.com/actions/runner.git
synced 2026-01-22 20:44:30 +08:00
1788 lines
56 KiB
JavaScript
1788 lines
56 KiB
JavaScript
/**
|
|
* 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: `<svg class="octicon" viewBox="0 0 16 16" width="16" height="16">
|
|
<path fill="currentColor" d="M4.72.22a.75.75 0 0 1 1.06 0l1 1a.75.75 0 0 1-1.06 1.06l-.22-.22-.22.22a.75.75 0 0 1-1.06-1.06l1-1Z"/>
|
|
<path fill="currentColor" d="M11.28.22a.75.75 0 0 0-1.06 0l-1 1a.75.75 0 0 0 1.06 1.06l.22-.22.22.22a.75.75 0 0 0 1.06-1.06l-1-1Z"/>
|
|
<path fill="currentColor" d="M8 4a4 4 0 0 0-4 4v1h1v2.5a2.5 2.5 0 0 0 2.5 2.5h1a2.5 2.5 0 0 0 2.5-2.5V9h1V8a4 4 0 0 0-4-4Z"/>
|
|
<path fill="currentColor" d="M5 9H3.5a.5.5 0 0 0-.5.5v2a.5.5 0 0 0 .5.5H5V9ZM11 9h1.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5H11V9Z"/>
|
|
</svg>`,
|
|
close: `<svg viewBox="0 0 16 16" width="16" height="16">
|
|
<path fill="currentColor" d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>
|
|
</svg>`,
|
|
layoutBottom: `<svg viewBox="0 0 16 16" width="16" height="16">
|
|
<path fill="currentColor" d="M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1Zm0 1.5a.25.25 0 0 0-.25.25v6h13v-6a.25.25 0 0 0-.25-.25H1.75Zm-.25 10.75c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-3h-13v3Z"/>
|
|
</svg>`,
|
|
layoutSidebar: `<svg viewBox="0 0 16 16" width="16" height="16">
|
|
<path fill="currentColor" d="M1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25V2.75C0 1.784.784 1 1.75 1ZM1.5 2.75v10.5c0 .138.112.25.25.25h8.5V2.5h-8.5a.25.25 0 0 0-.25.25Zm10.25 10.75h2.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25h-2.5v11Z"/>
|
|
</svg>`,
|
|
reverseContinue: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 2v12h2V8.5l5 4V8.5l5 4V2.5l-5 4V2.5l-5 4V2z"/></svg>`,
|
|
stepBack: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2 2v12h2V2H2zm3 6 7 5V3L5 8z"/></svg>`,
|
|
continue: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M4 2l10 6-10 6z"/></svg>`,
|
|
stepForward: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M12 2v12h2V2h-2zM2 3l7 5-7 5V3z"/></svg>`,
|
|
plus: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>`,
|
|
download: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Zm-1.97-7.22a.75.75 0 0 0 1.06 1.06L7 2.69v8.56a.75.75 0 0 0 1.5 0V2.69l5.16 5.15a.75.75 0 1 0 1.06-1.06l-6.5-6.5a.75.75 0 0 0-1.06 0l-6.5 6.5Z" transform="rotate(180 8 8)"/></svg>`,
|
|
check: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>`,
|
|
play: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/></svg>`,
|
|
clock: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></svg>`,
|
|
pencil: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z"/></svg>`,
|
|
trash: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.149l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z"/></svg>`,
|
|
arrowUp: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M3.47 7.78a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0l4.25 4.25a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018L9 4.81v7.44a.75.75 0 0 1-1.5 0V4.81L4.53 7.78a.75.75 0 0 1-1.06 0Z"/></svg>`,
|
|
arrowDown: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M13.03 8.22a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.47 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l2.97 2.97V3.75a.75.75 0 0 1 1.5 0v7.44l2.97-2.97a.75.75 0 0 1 1.06 0Z"/></svg>`,
|
|
copy: `<svg viewBox="0 0 16 16" width="16" height="16"><path fill="currentColor" d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25ZM5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></svg>`,
|
|
};
|
|
|
|
/**
|
|
* Create control buttons HTML
|
|
*/
|
|
function createControlButtonsHTML(compact = false) {
|
|
const btnClass = compact ? 'btn-sm' : 'btn-sm';
|
|
return `
|
|
<button class="btn ${btnClass} dap-control-btn" data-action="reverseContinue" title="Reverse Continue (go to first checkpoint)" disabled>
|
|
${Icons.reverseContinue}
|
|
</button>
|
|
<button class="btn ${btnClass} dap-control-btn" data-action="stepBack" title="Step Back" disabled>
|
|
${Icons.stepBack}
|
|
</button>
|
|
<button class="btn ${btnClass} btn-primary dap-control-btn" data-action="continue" title="Continue" disabled>
|
|
${Icons.continue}
|
|
</button>
|
|
<button class="btn ${btnClass} dap-control-btn" data-action="next" title="Step to Next" disabled>
|
|
${Icons.stepForward}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Create the steps panel HTML
|
|
*/
|
|
function createStepsPanelHTML() {
|
|
return `
|
|
<div class="dap-steps-panel">
|
|
<div class="dap-steps-header p-2 text-bold border-bottom d-flex flex-items-center">
|
|
<span>Steps</span>
|
|
<button class="btn btn-sm dap-add-step-btn ml-auto" title="Add Step" disabled>
|
|
${Icons.plus}
|
|
</button>
|
|
</div>
|
|
<div class="dap-steps-list overflow-auto flex-auto">
|
|
<div class="dap-steps-empty p-2 color-fg-muted">Connect to view steps</div>
|
|
</div>
|
|
<div class="dap-steps-footer p-2 border-top">
|
|
<button class="btn btn-sm dap-export-btn" title="Export Changes" disabled>
|
|
${Icons.download}
|
|
<span class="ml-1">Export</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Create the bottom panel HTML structure
|
|
*/
|
|
function createBottomPaneHTML() {
|
|
return `
|
|
<div class="dap-header d-flex flex-items-center p-2 border-bottom">
|
|
<span class="text-bold">Debugger</span>
|
|
<span class="dap-step-info color-fg-muted ml-2">Connecting...</span>
|
|
|
|
<div class="dap-header-right ml-auto d-flex flex-items-center">
|
|
<div class="dap-controls d-flex flex-items-center mr-3">
|
|
${createControlButtonsHTML(true)}
|
|
</div>
|
|
|
|
<div class="dap-layout-toggles d-flex flex-items-center mr-2">
|
|
<button class="btn btn-sm dap-layout-btn" data-layout="sidebar" title="Sidebar layout">
|
|
${Icons.layoutSidebar}
|
|
</button>
|
|
<button class="btn btn-sm dap-layout-btn active" data-layout="bottom" title="Bottom panel layout">
|
|
${Icons.layoutBottom}
|
|
</button>
|
|
</div>
|
|
|
|
<button class="btn btn-sm dap-close-btn" title="Close debugger">
|
|
${Icons.close}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dap-content d-flex">
|
|
<!-- Steps Panel -->
|
|
${createStepsPanelHTML()}
|
|
|
|
<!-- Scopes Panel -->
|
|
<div class="dap-scopes border-right overflow-auto">
|
|
<div class="dap-scope-header p-2 text-bold border-bottom d-flex flex-items-center">
|
|
<span>Variables</span>
|
|
</div>
|
|
<div class="dap-scope-tree p-2">
|
|
<div class="color-fg-muted">Connect to view variables</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- REPL Console -->
|
|
<div class="dap-repl d-flex flex-column">
|
|
<div class="dap-repl-header p-2 text-bold border-bottom">Console</div>
|
|
<div class="dap-repl-output overflow-auto flex-auto p-2 text-mono text-small">
|
|
<div class="color-fg-muted">Welcome to Actions DAP Debugger</div>
|
|
<div class="color-fg-muted">Enter expressions like: \${{ github.ref }}</div>
|
|
<div class="color-fg-muted">Or shell commands: !ls -la</div>
|
|
</div>
|
|
<div class="dap-repl-input border-top p-2">
|
|
<input type="text" class="form-control input-sm text-mono"
|
|
placeholder="Enter expression or !command" disabled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Create the sidebar panel HTML structure
|
|
*/
|
|
function createSidebarPaneHTML() {
|
|
return `
|
|
<div class="dap-header d-flex flex-items-center p-2 border-bottom">
|
|
<span class="text-bold">Debugger</span>
|
|
|
|
<div class="dap-header-right ml-auto d-flex flex-items-center">
|
|
<div class="dap-layout-toggles d-flex flex-items-center mr-2">
|
|
<button class="btn btn-sm dap-layout-btn active" data-layout="sidebar" title="Sidebar layout">
|
|
${Icons.layoutSidebar}
|
|
</button>
|
|
<button class="btn btn-sm dap-layout-btn" data-layout="bottom" title="Bottom panel layout">
|
|
${Icons.layoutBottom}
|
|
</button>
|
|
</div>
|
|
|
|
<button class="btn btn-sm dap-close-btn" title="Close debugger">
|
|
${Icons.close}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Steps Panel -->
|
|
${createStepsPanelHTML()}
|
|
|
|
<!-- Scopes Panel -->
|
|
<div class="dap-scopes overflow-auto border-bottom">
|
|
<div class="dap-scope-header p-2 text-bold border-bottom d-flex flex-items-center">
|
|
<span>Variables</span>
|
|
</div>
|
|
<div class="dap-scope-tree p-2">
|
|
<div class="color-fg-muted">Connect to view variables</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- REPL Console -->
|
|
<div class="dap-repl d-flex flex-column">
|
|
<div class="dap-repl-header p-2 text-bold border-bottom">Console</div>
|
|
<div class="dap-repl-output overflow-auto flex-auto p-2 text-mono text-small">
|
|
<div class="color-fg-muted">Welcome to Actions DAP Debugger</div>
|
|
<div class="color-fg-muted">Enter expressions like: \${{ github.ref }}</div>
|
|
<div class="color-fg-muted">Or shell commands: !ls -la</div>
|
|
</div>
|
|
<div class="dap-repl-input border-top p-2">
|
|
<input type="text" class="form-control input-sm text-mono"
|
|
placeholder="Enter expression or !command" disabled>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Control buttons -->
|
|
<div class="dap-controls d-flex flex-items-center justify-content-center p-2 border-top">
|
|
${createControlButtonsHTML()}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 `<span class="dap-step-status-icon completed">${Icons.check}</span>`;
|
|
case 'current':
|
|
return `<span class="dap-step-status-icon current">${Icons.play}</span>`;
|
|
case 'pending':
|
|
default:
|
|
return `<span class="dap-step-status-icon pending">${Icons.clock}</span>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = '<div class="dap-steps-empty p-2 color-fg-muted">No steps available</div>';
|
|
return;
|
|
}
|
|
|
|
stepsList = steps;
|
|
|
|
const html = steps.map((step) => {
|
|
const statusIcon = getStepStatusIcon(step.status);
|
|
const changeBadge = step.change ? `<span class="dap-step-badge dap-step-badge-${step.change.toLowerCase()}">[${step.change}]</span>` : '';
|
|
const typeLabel = step.type === 'uses' ? step.typeDetail || step.type : step.type;
|
|
const isPending = step.status === 'pending';
|
|
|
|
return `
|
|
<div class="dap-step-item ${step.status}" data-index="${step.index}" ${isPending ? 'data-editable="true"' : ''}>
|
|
${statusIcon}
|
|
<span class="dap-step-index">${step.index}.</span>
|
|
<span class="dap-step-name" title="${escapeHtml(step.name)}">${escapeHtml(step.name)}</span>
|
|
${changeBadge}
|
|
<span class="dap-step-type">${escapeHtml(typeLabel)}</span>
|
|
${isPending ? `<button class="dap-step-menu-btn" title="Step actions">...</button>` : ''}
|
|
</div>
|
|
`;
|
|
}).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 = '<div class="dap-steps-empty p-2 color-fg-muted">Failed to load steps</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = `
|
|
<div class="dap-context-menu-item" data-action="edit">
|
|
${Icons.pencil}
|
|
<span>Edit</span>
|
|
</div>
|
|
<div class="dap-context-menu-item" data-action="moveUp">
|
|
${Icons.arrowUp}
|
|
<span>Move Up</span>
|
|
</div>
|
|
<div class="dap-context-menu-item" data-action="moveDown">
|
|
${Icons.arrowDown}
|
|
<span>Move Down</span>
|
|
</div>
|
|
<div class="dap-context-menu-divider"></div>
|
|
<div class="dap-context-menu-item danger" data-action="delete">
|
|
${Icons.trash}
|
|
<span>Delete</span>
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `
|
|
<div class="dap-modal">
|
|
<div class="dap-modal-header">
|
|
<span class="text-bold">${escapeHtml(title)}</span>
|
|
<button class="btn btn-sm dap-modal-close-btn">${Icons.close}</button>
|
|
</div>
|
|
<div class="dap-modal-content">
|
|
${content}
|
|
</div>
|
|
<div class="dap-modal-footer">
|
|
${buttons.map((btn) => `<button class="btn btn-sm ${btn.primary ? 'btn-primary' : ''}" data-action="${btn.action}">${btn.label}</button>`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// 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 = `
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Step Type</label>
|
|
<select class="form-control dap-step-type-select">
|
|
<option value="run">Run (shell command)</option>
|
|
<option value="uses">Uses (action)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="dap-form-group dap-run-fields">
|
|
<label class="dap-label">Script</label>
|
|
<textarea class="form-control dap-script-input" rows="4" placeholder="echo 'Hello World'"></textarea>
|
|
</div>
|
|
|
|
<div class="dap-form-group dap-run-fields">
|
|
<label class="dap-label">Shell (optional)</label>
|
|
<select class="form-control dap-shell-select">
|
|
<option value="">Default</option>
|
|
<option value="bash">bash</option>
|
|
<option value="sh">sh</option>
|
|
<option value="pwsh">pwsh</option>
|
|
<option value="powershell">powershell</option>
|
|
<option value="cmd">cmd</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="dap-form-group dap-uses-fields" style="display: none;">
|
|
<label class="dap-label">Action</label>
|
|
<input type="text" class="form-control dap-action-input" placeholder="actions/checkout@v4">
|
|
</div>
|
|
|
|
<div class="dap-form-group dap-uses-fields" style="display: none;">
|
|
<label class="dap-label">With (key=value, one per line)</label>
|
|
<textarea class="form-control dap-with-input" rows="3" placeholder="node-version=20 cache=npm"></textarea>
|
|
</div>
|
|
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Name (optional)</label>
|
|
<input type="text" class="form-control dap-name-input" placeholder="Step name">
|
|
</div>
|
|
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Position</label>
|
|
<select class="form-control dap-position-select">
|
|
<option value="last">At end (default)</option>
|
|
<option value="first">At first pending position</option>
|
|
<option value="after">After current step</option>
|
|
</select>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Step Type</label>
|
|
<input type="text" class="form-control" value="${escapeHtml(step.type)}" disabled>
|
|
</div>
|
|
|
|
${isRun ? `
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Script</label>
|
|
<textarea class="form-control dap-edit-script-input" rows="4">${escapeHtml(step.typeDetail || '')}</textarea>
|
|
</div>
|
|
` : `
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Action</label>
|
|
<input type="text" class="form-control" value="${escapeHtml(step.typeDetail || '')}" disabled>
|
|
</div>
|
|
`}
|
|
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Name</label>
|
|
<input type="text" class="form-control dap-edit-name-input" value="${escapeHtml(step.name)}">
|
|
</div>
|
|
|
|
<div class="dap-form-group">
|
|
<label class="dap-label">Condition (optional)</label>
|
|
<input type="text" class="form-control dap-edit-condition-input" placeholder="success()">
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="dap-export-stats mb-2 color-fg-muted">
|
|
Total steps: ${stats.totalSteps || 0} |
|
|
Added: ${stats.addedCount || 0} |
|
|
Modified: ${stats.modifiedCount || 0}
|
|
</div>
|
|
<div class="dap-export-yaml">
|
|
<pre class="dap-yaml-content">${escapeHtml(yaml)}</pre>
|
|
</div>
|
|
`;
|
|
|
|
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 = '<div class="color-fg-muted">Loading...</div>';
|
|
|
|
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 = '<div class="color-fg-muted">No scopes available</div>';
|
|
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 = `<div class="color-fg-danger">Error: ${escapeHtml(error.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = `
|
|
<button type="button" class="btn btn-sm dap-debug-btn" title="Toggle DAP Debugger">
|
|
${Icons.bug}
|
|
<span class="ml-1 dap-debug-btn-text">Debug</span>
|
|
</button>
|
|
`;
|
|
|
|
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();
|
|
}
|