diff --git a/.opencode/plans/dap-step-manipulation.md b/.opencode/plans/dap-step-manipulation.md index 1d6f973cf..7188a2e6e 100644 --- a/.opencode/plans/dap-step-manipulation.md +++ b/.opencode/plans/dap-step-manipulation.md @@ -7,15 +7,15 @@ ## Progress Checklist -- [ ] **Chunk 1:** Command Parser & Infrastructure -- [ ] **Chunk 2:** Step Serializer (ActionStep → YAML) -- [ ] **Chunk 3:** Step Factory (Create new steps) -- [ ] **Chunk 4:** Step Manipulator (Queue operations) -- [ ] **Chunk 5:** REPL Commands (!step list, !step add run, !step edit, !step remove, !step move) -- [ ] **Chunk 6:** Action Download Integration (!step add uses) -- [ ] **Chunk 7:** Export Command (!step export) -- [ ] **Chunk 8:** JSON API for Browser Extension -- [ ] **Chunk 9:** Browser Extension UI +- [x] **Chunk 1:** Command Parser & Infrastructure +- [x] **Chunk 2:** Step Serializer (ActionStep → YAML) +- [x] **Chunk 3:** Step Factory (Create new steps) +- [x] **Chunk 4:** Step Manipulator (Queue operations) +- [x] **Chunk 5:** REPL Commands (!step list, !step add run, !step edit, !step remove, !step move) +- [x] **Chunk 6:** Action Download Integration (!step add uses) +- [x] **Chunk 7:** Export Command (!step export) +- [x] **Chunk 8:** JSON API for Browser Extension +- [x] **Chunk 9:** Browser Extension UI ## Overview diff --git a/browser-ext/content/content.css b/browser-ext/content/content.css index ff347ac7e..b693341cd 100644 --- a/browser-ext/content/content.css +++ b/browser-ext/content/content.css @@ -613,3 +613,411 @@ html[data-color-mode="light"] .dap-debug-btn.selected { background-color: #ddf4ff; border-color: #54aeff; } + +/* ========================================================================== + Steps Panel + ========================================================================== */ + +.dap-steps-panel { + display: flex; + flex-direction: column; + border-right: 1px solid var(--borderColor-default, #30363d); + min-width: 200px; + max-width: 280px; + width: 25%; +} + +.dap-debugger-sidebar .dap-steps-panel { + border-right: none; + border-bottom: 1px solid var(--borderColor-default, #30363d); + max-width: none; + width: auto; + max-height: 35%; + min-height: 120px; +} + +.dap-steps-header { + background-color: var(--bgColor-muted, #161b22); + font-size: 12px; + font-weight: 600; + flex-shrink: 0; +} + +.dap-steps-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +.dap-steps-empty { + font-size: 12px; +} + +.dap-steps-footer { + flex-shrink: 0; + background-color: var(--bgColor-muted, #161b22); +} + +.dap-add-step-btn, +.dap-export-btn { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.dap-add-step-btn svg, +.dap-export-btn svg { + width: 14px; + height: 14px; +} + +.dap-add-step-btn:disabled, +.dap-export-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Step Items */ +.dap-step-item { + display: flex; + align-items: center; + padding: 4px 8px; + font-size: 12px; + cursor: default; + gap: 4px; + border-left: 3px solid transparent; +} + +.dap-step-item:hover { + background-color: var(--bgColor-muted, #161b22); +} + +.dap-step-item.completed { + opacity: 0.6; +} + +.dap-step-item.current { + background-color: var(--bgColor-accent-muted, #388bfd26); + border-left-color: var(--fgColor-accent, #58a6ff); +} + +.dap-step-item.pending[data-editable="true"] { + cursor: pointer; +} + +.dap-step-status-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.dap-step-status-icon svg { + width: 12px; + height: 12px; +} + +.dap-step-status-icon.completed { + color: var(--fgColor-success, #3fb950); +} + +.dap-step-status-icon.current { + color: var(--fgColor-accent, #58a6ff); +} + +.dap-step-status-icon.pending { + color: var(--fgColor-muted, #8b949e); +} + +.dap-step-index { + color: var(--fgColor-muted, #8b949e); + font-size: 11px; + min-width: 20px; +} + +.dap-step-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fgColor-default, #e6edf3); +} + +.dap-step-badge { + font-size: 10px; + padding: 1px 4px; + border-radius: 3px; + font-weight: 500; + flex-shrink: 0; +} + +.dap-step-badge-added { + background-color: var(--bgColor-success-muted, #2ea04326); + color: var(--fgColor-success, #3fb950); +} + +.dap-step-badge-modified { + background-color: var(--bgColor-attention-muted, #bb800926); + color: var(--fgColor-attention, #d29922); +} + +.dap-step-type { + color: var(--fgColor-muted, #8b949e); + font-size: 10px; + flex-shrink: 0; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dap-step-menu-btn { + background: none; + border: none; + color: var(--fgColor-muted, #8b949e); + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + font-weight: bold; + letter-spacing: 1px; + opacity: 0; + transition: opacity 0.1s; +} + +.dap-step-item:hover .dap-step-menu-btn { + opacity: 1; +} + +.dap-step-menu-btn:hover { + background-color: var(--bgColor-neutral-muted, #6e768166); + color: var(--fgColor-default, #e6edf3); +} + +/* ========================================================================== + Context Menu + ========================================================================== */ + +.dap-context-menu { + background-color: var(--bgColor-default, #0d1117); + border: 1px solid var(--borderColor-default, #30363d); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 10000; + min-width: 140px; + padding: 4px 0; +} + +.dap-context-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 12px; + color: var(--fgColor-default, #e6edf3); + cursor: pointer; +} + +.dap-context-menu-item:hover { + background-color: var(--bgColor-accent-muted, #388bfd26); +} + +.dap-context-menu-item.danger { + color: var(--fgColor-danger, #f85149); +} + +.dap-context-menu-item.danger:hover { + background-color: var(--bgColor-danger-muted, #da363326); +} + +.dap-context-menu-item svg { + width: 14px; + height: 14px; +} + +.dap-context-menu-divider { + height: 1px; + background-color: var(--borderColor-default, #30363d); + margin: 4px 0; +} + +/* ========================================================================== + Modal + ========================================================================== */ + +.dap-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10001; +} + +.dap-modal { + background-color: var(--bgColor-default, #0d1117); + border: 1px solid var(--borderColor-default, #30363d); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + width: 90%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.dap-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--borderColor-default, #30363d); +} + +.dap-modal-header .text-bold { + font-size: 14px; + color: var(--fgColor-default, #e6edf3); +} + +.dap-modal-close-btn { + background: none !important; + border: none !important; + color: var(--fgColor-muted, #8b949e) !important; + padding: 4px !important; + cursor: pointer; +} + +.dap-modal-close-btn:hover { + color: var(--fgColor-default, #e6edf3) !important; +} + +.dap-modal-close-btn svg { + width: 16px; + height: 16px; +} + +.dap-modal-content { + padding: 16px; + overflow-y: auto; + flex: 1; +} + +.dap-modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--borderColor-default, #30363d); +} + +/* Form elements */ +.dap-form-group { + margin-bottom: 12px; +} + +.dap-form-group:last-child { + margin-bottom: 0; +} + +.dap-label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--fgColor-default, #e6edf3); + margin-bottom: 4px; +} + +.dap-modal .form-control { + width: 100%; + background-color: var(--bgColor-inset, #010409) !important; + border-color: var(--borderColor-default, #30363d) !important; + color: var(--fgColor-default, #e6edf3) !important; + font-size: 12px; + padding: 6px 8px; + border-radius: 6px; +} + +.dap-modal .form-control:focus { + border-color: var(--focus-outlineColor, #1f6feb) !important; + outline: none; + box-shadow: 0 0 0 3px rgba(31, 111, 235, 0.3); +} + +.dap-modal select.form-control { + appearance: auto; +} + +.dap-modal textarea.form-control { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + resize: vertical; +} + +/* Export Modal */ +.dap-export-stats { + font-size: 12px; +} + +.dap-export-yaml { + background-color: var(--bgColor-inset, #010409); + border: 1px solid var(--borderColor-default, #30363d); + border-radius: 6px; + overflow: auto; + max-height: 300px; +} + +.dap-yaml-content { + margin: 0; + padding: 12px; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + color: var(--fgColor-default, #e6edf3); + white-space: pre; +} + +/* ========================================================================== + Light Mode Overrides for New Components + ========================================================================== */ + +[data-color-mode="light"] .dap-context-menu, +html[data-color-mode="light"] .dap-context-menu { + background-color: #ffffff; + border-color: #d0d7de; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} + +[data-color-mode="light"] .dap-context-menu-item, +html[data-color-mode="light"] .dap-context-menu-item { + color: #1f2328; +} + +[data-color-mode="light"] .dap-modal, +html[data-color-mode="light"] .dap-modal { + background-color: #ffffff; + border-color: #d0d7de; +} + +[data-color-mode="light"] .dap-modal-header .text-bold, +html[data-color-mode="light"] .dap-modal-header .text-bold { + color: #1f2328; +} + +[data-color-mode="light"] .dap-step-name, +html[data-color-mode="light"] .dap-step-name { + color: #1f2328; +} + +[data-color-mode="light"] .dap-export-yaml, +html[data-color-mode="light"] .dap-export-yaml { + background-color: #f6f8fa; +} + +[data-color-mode="light"] .dap-yaml-content, +html[data-color-mode="light"] .dap-yaml-content { + color: #1f2328; +} diff --git a/browser-ext/content/content.js b/browser-ext/content/content.js index 76cad32a1..8193fa2ac 100644 --- a/browser-ext/content/content.js +++ b/browser-ext/content/content.js @@ -13,6 +13,9 @@ 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; @@ -132,6 +135,16 @@ const Icons = { stepBack: ``, continue: ``, stepForward: ``, + plus: ``, + download: ``, + check: ``, + play: ``, + clock: ``, + pencil: ``, + trash: ``, + arrowUp: ``, + arrowDown: ``, + copy: ``, }; /** @@ -155,6 +168,31 @@ function createControlButtonsHTML(compact = false) { `; } +/** + * Create the steps panel HTML + */ +function createStepsPanelHTML() { + return ` +
+
+ Steps + +
+
+
Connect to view steps
+
+ +
+ `; +} + /** * Create the bottom panel HTML structure */ @@ -185,6 +223,9 @@ function createBottomPaneHTML() {
+ + ${createStepsPanelHTML()} +
@@ -236,6 +277,9 @@ function createSidebarPaneHTML() {
+ + ${createStepsPanelHTML()} +
@@ -382,6 +426,10 @@ function closeDebuggerPane() { // 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; @@ -392,6 +440,628 @@ function closeDebuggerPane() { 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: JSON.stringify({ cmd: 'step.list', verbose: false }), + 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; +} + +/** + * Send step command via JSON API + */ +async function sendStepCommand(cmd, options = {}) { + const command = { cmd, ...options }; + try { + const response = await sendDapRequest('evaluate', { + expression: JSON.stringify(command), + frameId: currentFrameId, + context: 'repl', + }); + + if (response.result) { + try { + return JSON.parse(response.result); + } catch (e) { + return { Success: false, Error: 'PARSE_ERROR', Message: response.result }; + } + } + return { Success: false, Error: 'NO_RESPONSE', Message: 'No response from server' }; + } catch (error) { + return { Success: false, Error: 'REQUEST_FAILED', Message: error.message }; + } +} + +/** + * Show context menu for a step + */ +function showStepContextMenu(stepIndex, event) { + closeContextMenu(); + + const step = stepsList.find((s) => s.index === stepIndex); + if (!step || step.status !== 'pending') return; + + const menu = document.createElement('div'); + menu.className = 'dap-context-menu'; + menu.innerHTML = ` +
+ ${Icons.pencil} + Edit +
+
+ ${Icons.arrowUp} + Move Up +
+
+ ${Icons.arrowDown} + Move Down +
+
+
+ ${Icons.trash} + Delete +
+ `; + + // Position menu + const rect = event.target.getBoundingClientRect(); + menu.style.position = 'fixed'; + menu.style.top = `${rect.bottom + 4}px`; + menu.style.left = `${rect.left}px`; + + // Add event handlers + menu.querySelectorAll('.dap-context-menu-item').forEach((item) => { + item.addEventListener('click', async () => { + const action = item.dataset.action; + closeContextMenu(); + await handleStepAction(action, stepIndex); + }); + }); + + document.body.appendChild(menu); + activeContextMenu = menu; + + // Close menu on outside click + setTimeout(() => { + document.addEventListener('click', closeContextMenuOnOutsideClick); + }, 0); +} + +/** + * Close context menu + */ +function closeContextMenu() { + if (activeContextMenu) { + activeContextMenu.remove(); + activeContextMenu = null; + } + document.removeEventListener('click', closeContextMenuOnOutsideClick); +} + +/** + * Close context menu on outside click + */ +function closeContextMenuOnOutsideClick(e) { + if (activeContextMenu && !activeContextMenu.contains(e.target)) { + closeContextMenu(); + } +} + +/** + * Handle step action from context menu + */ +async function handleStepAction(action, stepIndex) { + switch (action) { + case 'edit': + showEditStepDialog(stepIndex); + break; + case 'moveUp': + await moveStep(stepIndex, 'before', stepIndex - 1); + break; + case 'moveDown': + await moveStep(stepIndex, 'after', stepIndex + 1); + break; + case 'delete': + await deleteStep(stepIndex); + break; + } +} + +/** + * Move a step + */ +async function moveStep(fromIndex, positionType, targetIndex) { + const position = {}; + position[positionType] = targetIndex; + + const result = await sendStepCommand('step.move', { from: fromIndex, position }); + if (result.Success) { + appendOutput(`Step moved successfully`, 'result'); + await loadSteps(); + } else { + appendOutput(`Failed to move step: ${result.Message || result.Error}`, 'error'); + } +} + +/** + * Delete a step + */ +async function deleteStep(stepIndex) { + const step = stepsList.find((s) => s.index === stepIndex); + if (!step) return; + + // Simple confirmation + if (!confirm(`Delete step "${step.name}"?`)) return; + + const result = await sendStepCommand('step.remove', { index: stepIndex }); + if (result.Success) { + appendOutput(`Step removed successfully`, 'result'); + await loadSteps(); + } else { + appendOutput(`Failed to remove step: ${result.Message || result.Error}`, 'error'); + } +} + +/** + * Close modal + */ +function closeModal() { + if (activeModal) { + activeModal.remove(); + activeModal = null; + } +} + +/** + * Create modal wrapper + */ +function createModal(title, content, buttons = []) { + closeModal(); + + const modal = document.createElement('div'); + modal.className = 'dap-modal-overlay'; + modal.innerHTML = ` +
+
+ ${escapeHtml(title)} + +
+
+ ${content} +
+ +
+ `; + + // Close on overlay click + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + + // Close button + modal.querySelector('.dap-modal-close-btn').addEventListener('click', closeModal); + + document.body.appendChild(modal); + activeModal = modal; + + return modal; +} + +/** + * Show Add Step dialog + */ +function showAddStepDialog() { + const content = ` +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + +
+ + +
+ +
+ + +
+ `; + + const modal = createModal('Add Step', content, [ + { label: 'Cancel', action: 'cancel' }, + { label: 'Add Step', action: 'add', primary: true }, + ]); + + // Type select handler + const typeSelect = modal.querySelector('.dap-step-type-select'); + const runFields = modal.querySelectorAll('.dap-run-fields'); + const usesFields = modal.querySelectorAll('.dap-uses-fields'); + + typeSelect.addEventListener('change', () => { + const isRun = typeSelect.value === 'run'; + runFields.forEach((f) => (f.style.display = isRun ? '' : 'none')); + usesFields.forEach((f) => (f.style.display = isRun ? 'none' : '')); + }); + + // Button handlers + modal.querySelectorAll('[data-action]').forEach((btn) => { + btn.addEventListener('click', async () => { + const action = btn.dataset.action; + if (action === 'cancel') { + closeModal(); + return; + } + + if (action === 'add') { + await handleAddStep(modal); + } + }); + }); +} + +/** + * Handle add step form submission + */ +async function handleAddStep(modal) { + const type = modal.querySelector('.dap-step-type-select').value; + const name = modal.querySelector('.dap-name-input').value.trim() || undefined; + const positionSelect = modal.querySelector('.dap-position-select').value; + + let position = {}; + if (positionSelect === 'first') { + position.first = true; + } else if (positionSelect === 'after') { + // After current step - find current step index + const currentStep = stepsList.find((s) => s.status === 'current'); + if (currentStep) { + position.after = currentStep.index; + } else { + position.last = true; + } + } else { + position.last = true; + } + + let result; + if (type === 'run') { + const script = modal.querySelector('.dap-script-input').value; + const shell = modal.querySelector('.dap-shell-select').value || undefined; + + if (!script.trim()) { + alert('Script is required'); + return; + } + + result = await sendStepCommand('step.add', { + type: 'run', + script, + name, + shell, + position, + }); + } else { + const action = modal.querySelector('.dap-action-input').value.trim(); + const withText = modal.querySelector('.dap-with-input').value.trim(); + + if (!action) { + alert('Action is required'); + return; + } + + // Parse with inputs + const withInputs = {}; + if (withText) { + withText.split('\n').forEach((line) => { + const [key, ...valueParts] = line.split('='); + if (key && valueParts.length > 0) { + withInputs[key.trim()] = valueParts.join('=').trim(); + } + }); + } + + result = await sendStepCommand('step.add', { + type: 'uses', + action, + name, + with: Object.keys(withInputs).length > 0 ? withInputs : undefined, + position, + }); + } + + if (result.Success) { + closeModal(); + appendOutput(`Step added at position ${result.Result?.index || 'end'}`, 'result'); + await loadSteps(); + } else { + appendOutput(`Failed to add step: ${result.Message || result.Error}`, 'error'); + } +} + +/** + * Show Edit Step dialog + */ +function showEditStepDialog(stepIndex) { + const step = stepsList.find((s) => s.index === stepIndex); + if (!step) return; + + const isRun = step.type === 'run'; + + const content = ` +
+ + +
+ + ${isRun ? ` +
+ + +
+ ` : ` +
+ + +
+ `} + +
+ + +
+ +
+ + +
+ `; + + const modal = createModal(`Edit Step ${stepIndex}`, content, [ + { label: 'Cancel', action: 'cancel' }, + { label: 'Save Changes', action: 'save', primary: true }, + ]); + + // Button handlers + modal.querySelectorAll('[data-action]').forEach((btn) => { + btn.addEventListener('click', async () => { + const action = btn.dataset.action; + if (action === 'cancel') { + closeModal(); + return; + } + + if (action === 'save') { + await handleEditStep(modal, stepIndex, isRun); + } + }); + }); +} + +/** + * Handle edit step form submission + */ +async function handleEditStep(modal, stepIndex, isRun) { + const name = modal.querySelector('.dap-edit-name-input').value.trim() || undefined; + const condition = modal.querySelector('.dap-edit-condition-input')?.value.trim() || undefined; + + const options = { index: stepIndex }; + if (name) options.name = name; + if (condition) options.if = condition; + + if (isRun) { + const script = modal.querySelector('.dap-edit-script-input')?.value; + if (script) options.script = script; + } + + const result = await sendStepCommand('step.edit', options); + + if (result.Success) { + closeModal(); + appendOutput(`Step ${stepIndex} updated`, 'result'); + await loadSteps(); + } else { + appendOutput(`Failed to edit step: ${result.Message || result.Error}`, 'error'); + } +} + +/** + * Show Export modal + */ +async function showExportModal() { + const result = await sendStepCommand('step.export', { changesOnly: false, withComments: true }); + + if (!result.Success) { + appendOutput(`Failed to export: ${result.Message || result.Error}`, 'error'); + return; + } + + const yaml = result.Message || result.Result?.yaml || ''; + const stats = result.Result || {}; + + const content = ` +
+ Total steps: ${stats.totalSteps || 0} | + Added: ${stats.addedCount || 0} | + Modified: ${stats.modifiedCount || 0} +
+
+
${escapeHtml(yaml)}
+
+ `; + + const modal = createModal('Export Steps', content, [ + { label: 'Close', action: 'close' }, + { label: 'Copy to Clipboard', action: 'copy', primary: true }, + ]); + + // Store YAML for copy + modal.dataset.yaml = yaml; + + // Button handlers + modal.querySelectorAll('[data-action]').forEach((btn) => { + btn.addEventListener('click', async () => { + const action = btn.dataset.action; + if (action === 'close') { + closeModal(); + return; + } + + if (action === 'copy') { + try { + await navigator.clipboard.writeText(modal.dataset.yaml); + btn.textContent = 'Copied!'; + setTimeout(() => { + btn.innerHTML = `${Icons.copy} Copy to Clipboard`; + }, 2000); + } catch (err) { + appendOutput('Failed to copy to clipboard', 'error'); + } + } + }); + }); +} + /** * Setup event handlers for debugger pane */ @@ -435,6 +1105,18 @@ function setupPaneEventHandlers(pane) { 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); + } } /** @@ -522,6 +1204,9 @@ function enableControls(enabled) { if (input) { input.disabled = !enabled; } + + // Also enable step controls when debugger is enabled + enableStepControls(enabled); } /** @@ -729,6 +1414,9 @@ async function handleStoppedEvent(body) { // 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); @@ -790,6 +1478,9 @@ async function loadCurrentDebugState() { // 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); diff --git a/src/Runner.Worker/Dap/DapDebugSession.cs b/src/Runner.Worker/Dap/DapDebugSession.cs index 4bfe61314..47b30292b 100644 --- a/src/Runner.Worker/Dap/DapDebugSession.cs +++ b/src/Runner.Worker/Dap/DapDebugSession.cs @@ -13,6 +13,7 @@ using GitHub.DistributedTask.WebApi; using GitHub.Runner.Common; using GitHub.Runner.Common.Util; using GitHub.Runner.Sdk; +using GitHub.Runner.Worker.Dap.StepCommands; using Newtonsoft.Json; namespace GitHub.Runner.Worker.Dap @@ -282,9 +283,21 @@ namespace GitHub.Runner.Worker.Dap private readonly List _completedSteps = new List(); private int _nextCompletedFrameId = CompletedFrameIdBase; + // Track completed IStep objects for the step manipulator + private readonly List _completedStepsTracker = new List(); + // Variable provider for converting contexts to DAP variables private DapVariableProvider _variableProvider; + // Step command parser for !step REPL commands + private IStepCommandParser _stepCommandParser; + + // Step command handler for executing step commands + private IStepCommandHandler _stepCommandHandler; + + // Step manipulator for queue operations + private IStepManipulator _stepManipulator; + // Checkpoint storage for step-back (time-travel) debugging private readonly List _checkpoints = new List(); private const int MaxCheckpoints = 50; @@ -319,6 +332,9 @@ namespace GitHub.Runner.Worker.Dap { base.Initialize(hostContext); _variableProvider = new DapVariableProvider(hostContext); + _stepCommandParser = hostContext.GetService(); + _stepCommandHandler = hostContext.GetService(); + _stepManipulator = hostContext.GetService(); Trace.Info("DapDebugSession initialized"); } @@ -661,6 +677,12 @@ namespace GitHub.Runner.Worker.Dap return HandleDebugCommand(expression); } + // Check for !step command (step manipulation commands) + if (_stepCommandParser.IsStepCommand(expression)) + { + return await HandleStepCommandAsync(expression); + } + // Get the current execution context var executionContext = _currentStep?.ExecutionContext ?? _jobContext; if (executionContext == null) @@ -1096,6 +1118,81 @@ namespace GitHub.Runner.Worker.Dap }); } + /// + /// Handles step manipulation commands (!step ...). + /// Parses and executes commands using the StepCommandHandler. + /// + private async Task HandleStepCommandAsync(string expression) + { + Trace.Info($"Handling step command: {expression}"); + + try + { + var command = _stepCommandParser.Parse(expression); + + // Ensure manipulator is initialized with current context + EnsureManipulatorInitialized(); + + // Execute the command + var result = await _stepCommandHandler.HandleAsync(command, _jobContext); + + if (result.Success) + { + // Return appropriate response format based on input type + if (command.WasJsonInput) + { + return CreateSuccessResponse(new EvaluateResponseBody + { + Result = Newtonsoft.Json.JsonConvert.SerializeObject(result), + Type = "json", + VariablesReference = 0 + }); + } + else + { + return CreateSuccessResponse(new EvaluateResponseBody + { + Result = result.Message, + Type = "string", + VariablesReference = 0 + }); + } + } + else + { + return CreateErrorResponse($"[{result.Error}] {result.Message}"); + } + } + catch (StepCommandException ex) + { + Trace.Warning($"Step command error: {ex.ErrorCode} - {ex.Message}"); + + return CreateErrorResponse($"[{ex.ErrorCode}] {ex.Message}"); + } + catch (Exception ex) + { + Trace.Error($"Step command failed: {ex}"); + + return CreateErrorResponse($"Step command failed: {ex.Message}"); + } + } + + /// + /// Ensures the step manipulator is initialized with the current job context. + /// + private void EnsureManipulatorInitialized() + { + if (_jobContext == null) + { + throw new StepCommandException(StepCommandErrors.NoContext, + "No job context available. Wait for the first step to start."); + } + + // The manipulator should already be initialized from OnStepStartingAsync + // but just ensure the handler has the reference + _stepCommandHandler.SetManipulator(_stepManipulator); + } + private Response HandleSetBreakpoints(Request request) { // Stub - breakpoints not implemented in demo @@ -1187,6 +1284,9 @@ namespace GitHub.Runner.Worker.Dap _jobContext = jobContext; _jobCancellationToken = cancellationToken; // Store for REPL commands + // Initialize or update the step manipulator + InitializeStepManipulator(step, isFirstStep); + // Hook up StepsContext debug logging (do this once when we first get jobContext) if (jobContext.Global.StepsContext.OnDebugLog == null) { @@ -1233,6 +1333,28 @@ namespace GitHub.Runner.Worker.Dap await WaitForCommandAsync(cancellationToken); } + /// + /// Initializes or updates the step manipulator with the current state. + /// + private void InitializeStepManipulator(IStep currentStep, bool isFirstStep) + { + if (isFirstStep) + { + // First step - initialize fresh + _stepManipulator.Initialize(_jobContext, 0); + (_stepManipulator as StepManipulator)?.SetCurrentStep(currentStep); + _stepCommandHandler.SetManipulator(_stepManipulator); + _stepManipulator.RecordOriginalState(); + Trace.Info("Step manipulator initialized for debug session"); + } + else + { + // Update current step reference + (_stepManipulator as StepManipulator)?.SetCurrentStep(currentStep); + _stepManipulator.UpdateCurrentIndex(_completedStepsTracker.Count + 1); + } + } + public void OnStepCompleted(IStep step) { if (!IsActive) @@ -1269,6 +1391,10 @@ namespace GitHub.Runner.Worker.Dap FrameId = _nextCompletedFrameId++ }); + // Track IStep for the step manipulator + _completedStepsTracker.Add(step); + _stepManipulator?.AddCompletedStep(step); + // Clear current step reference since it's done // (will be set again when next step starts) } @@ -1604,6 +1730,16 @@ namespace GitHub.Runner.Worker.Dap _completedSteps.RemoveAt(_completedSteps.Count - 1); } + // Also clear the step tracker for manipulator sync + while (_completedStepsTracker.Count > checkpointIndex) + { + _completedStepsTracker.RemoveAt(_completedStepsTracker.Count - 1); + } + + // Reset the step manipulator to match the restored state + // It will be re-initialized when the restored step starts + _stepManipulator?.ClearChanges(); + // Store restored checkpoint for StepsRunner to consume _restoredCheckpoint = checkpoint; diff --git a/src/Runner.Worker/Dap/StepCommands/StepChange.cs b/src/Runner.Worker/Dap/StepCommands/StepChange.cs new file mode 100644 index 000000000..e7f23d123 --- /dev/null +++ b/src/Runner.Worker/Dap/StepCommands/StepChange.cs @@ -0,0 +1,132 @@ +using System; + +namespace GitHub.Runner.Worker.Dap.StepCommands +{ + /// + /// Records a modification made to a step during a debug session. + /// Used for change tracking and export diff generation. + /// + public class StepChange + { + /// + /// The type of change made. + /// + public ChangeType Type { get; set; } + + /// + /// The original 1-based index of the step (before any modifications). + /// For Added steps, this is -1. + /// + public int OriginalIndex { get; set; } = -1; + + /// + /// The current 1-based index of the step (after all modifications). + /// For Removed steps, this is -1. + /// + public int CurrentIndex { get; set; } = -1; + + /// + /// Snapshot of the step before modification (for Modified/Moved/Removed). + /// + public StepInfo OriginalStep { get; set; } + + /// + /// The step after modification (for Added/Modified/Moved). + /// For Removed steps, this is null. + /// + public StepInfo ModifiedStep { get; set; } + + /// + /// Timestamp when the change was made. + /// + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + /// + /// Description of the change for display purposes. + /// + public string Description { get; set; } + + /// + /// Creates a change record for an added step. + /// + public static StepChange Added(StepInfo step, int index) + { + return new StepChange + { + Type = ChangeType.Added, + OriginalIndex = -1, + CurrentIndex = index, + ModifiedStep = step, + Description = $"Added step '{step.Name}' at position {index}" + }; + } + + /// + /// Creates a change record for a modified step. + /// + public static StepChange Modified(StepInfo original, StepInfo modified, string changeDescription = null) + { + return new StepChange + { + Type = ChangeType.Modified, + OriginalIndex = original.Index, + CurrentIndex = modified.Index, + OriginalStep = original, + ModifiedStep = modified, + Description = changeDescription ?? $"Modified step '{original.Name}' at position {original.Index}" + }; + } + + /// + /// Creates a change record for a removed step. + /// + public static StepChange Removed(StepInfo step) + { + return new StepChange + { + Type = ChangeType.Removed, + OriginalIndex = step.Index, + CurrentIndex = -1, + OriginalStep = step, + Description = $"Removed step '{step.Name}' from position {step.Index}" + }; + } + + /// + /// Creates a change record for a moved step. + /// + public static StepChange Moved(StepInfo original, int newIndex) + { + var modified = new StepInfo + { + Index = newIndex, + Name = original.Name, + Type = original.Type, + TypeDetail = original.TypeDetail, + Status = original.Status, + Action = original.Action, + Step = original.Step, + OriginalIndex = original.Index, + Change = ChangeType.Moved + }; + + return new StepChange + { + Type = ChangeType.Moved, + OriginalIndex = original.Index, + CurrentIndex = newIndex, + OriginalStep = original, + ModifiedStep = modified, + Description = $"Moved step '{original.Name}' from position {original.Index} to {newIndex}" + }; + } + + /// + /// Returns a human-readable summary of this change. + /// + public override string ToString() + { + return Description ?? $"{Type} step at index {OriginalIndex} -> {CurrentIndex}"; + } + } +} diff --git a/src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs b/src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs new file mode 100644 index 000000000..bb6c6b44c --- /dev/null +++ b/src/Runner.Worker/Dap/StepCommands/StepCommandHandler.cs @@ -0,0 +1,782 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using GitHub.DistributedTask.ObjectTemplating.Tokens; +using GitHub.DistributedTask.Pipelines; +using GitHub.Runner.Common; +using Newtonsoft.Json; +using DistributedTask = GitHub.DistributedTask; + +namespace GitHub.Runner.Worker.Dap.StepCommands +{ + /// + /// Interface for handling step manipulation commands. + /// Executes parsed commands using the StepManipulator, StepFactory, and StepSerializer. + /// + [ServiceLocator(Default = typeof(StepCommandHandler))] + public interface IStepCommandHandler : IRunnerService + { + /// + /// Handles a parsed step command and returns the result. + /// + /// The parsed command to execute + /// The job execution context + /// Result of the command execution + Task HandleAsync(StepCommand command, IExecutionContext jobContext); + + /// + /// Initializes the handler with required services. + /// Called when the debug session starts. + /// + /// The step manipulator to use + void SetManipulator(IStepManipulator manipulator); + } + + /// + /// Handles step manipulation commands (list, add, edit, remove, move, export). + /// + public sealed class StepCommandHandler : RunnerService, IStepCommandHandler + { + private IStepManipulator _manipulator; + private IStepFactory _factory; + private IStepSerializer _serializer; + + public override void Initialize(IHostContext hostContext) + { + base.Initialize(hostContext); + _factory = hostContext.GetService(); + _serializer = hostContext.GetService(); + } + + /// + public void SetManipulator(IStepManipulator manipulator) + { + _manipulator = manipulator; + } + + /// + public async Task HandleAsync(StepCommand command, IExecutionContext jobContext) + { + try + { + ValidateContext(jobContext); + + var result = command switch + { + ListCommand list => HandleList(list), + AddRunCommand addRun => HandleAddRun(addRun, jobContext), + AddUsesCommand addUses => await HandleAddUsesAsync(addUses, jobContext), + EditCommand edit => HandleEdit(edit), + RemoveCommand remove => HandleRemove(remove), + MoveCommand move => HandleMove(move), + ExportCommand export => HandleExport(export), + _ => throw new StepCommandException(StepCommandErrors.InvalidCommand, + $"Unknown command type: {command.GetType().Name}") + }; + + return result; + } + catch (StepCommandException ex) + { + return ex.ToResult(); + } + catch (Exception ex) + { + Trace.Error($"Step command failed: {ex}"); + return StepCommandResult.Fail(StepCommandErrors.InvalidCommand, ex.Message); + } + } + + #region Command Handlers + + /// + /// Handles the !step list command. + /// + private StepCommandResult HandleList(ListCommand command) + { + var steps = _manipulator.GetAllSteps(); + var output = FormatStepList(steps, command.Verbose); + + return new StepCommandResult + { + Success = true, + Message = output, + Result = new + { + steps = steps.Select(s => new + { + index = s.Index, + name = s.Name, + type = s.Type, + typeDetail = s.TypeDetail, + status = s.Status.ToString().ToLower(), + change = s.Change?.ToString().ToUpper() + }).ToList(), + totalCount = steps.Count, + completedCount = steps.Count(s => s.Status == StepStatus.Completed), + pendingCount = steps.Count(s => s.Status == StepStatus.Pending) + } + }; + } + + /// + /// Formats the step list for REPL display. + /// + private string FormatStepList(IReadOnlyList steps, bool verbose) + { + if (steps.Count == 0) + { + return "No steps in the job."; + } + + var sb = new StringBuilder(); + sb.AppendLine("Steps:"); + + var maxNameLength = steps.Max(s => s.Name?.Length ?? 0); + maxNameLength = Math.Min(maxNameLength, 40); // Cap at 40 chars + + foreach (var step in steps) + { + // Status indicator + var statusIcon = step.Status switch + { + StepStatus.Completed => "\u2713", // checkmark + StepStatus.Current => "\u25B6", // play arrow + StepStatus.Pending => " ", + _ => "?" + }; + + // Change indicator + var changeTag = step.Change.HasValue + ? $" [{step.Change.Value.ToString().ToUpper()}]" + : ""; + + // Truncate name if needed + var displayName = step.Name ?? ""; + if (displayName.Length > maxNameLength) + { + displayName = displayName.Substring(0, maxNameLength - 3) + "..."; + } + + // Build the line + var line = $" {statusIcon} {step.Index,2}. {displayName.PadRight(maxNameLength)}{changeTag,-12} {step.Type,-5} {step.TypeDetail}"; + + if (verbose && step.Action != null) + { + // Add verbose details + sb.AppendLine(line); + if (!string.IsNullOrEmpty(step.Action.Condition) && step.Action.Condition != "success()") + { + sb.AppendLine($" if: {step.Action.Condition}"); + } + } + else + { + sb.AppendLine(line); + } + } + + // Legend + sb.AppendLine(); + sb.Append("Legend: \u2713 = completed, \u25B6 = current/paused"); + if (steps.Any(s => s.Change.HasValue)) + { + sb.Append(", [ADDED] = new, [MODIFIED] = edited, [MOVED] = reordered"); + } + + return sb.ToString(); + } + + /// + /// Handles the !step add run command. + /// + private StepCommandResult HandleAddRun(AddRunCommand command, IExecutionContext jobContext) + { + // Create the ActionStep + var actionStep = _factory.CreateRunStep( + script: command.Script, + name: command.Name, + shell: command.Shell, + workingDirectory: command.WorkingDirectory, + env: command.Env, + condition: command.Condition, + continueOnError: command.ContinueOnError, + timeoutMinutes: command.Timeout); + + // Wrap in IActionRunner + var runner = _factory.WrapInRunner(actionStep, jobContext); + + // Insert into the queue + var index = _manipulator.InsertStep(runner, command.Position); + + var stepInfo = StepInfo.FromStep(runner, index, StepStatus.Pending); + stepInfo.Change = ChangeType.Added; + + Trace.Info($"Added run step '{actionStep.DisplayName}' at position {index}"); + + return new StepCommandResult + { + Success = true, + Message = $"Step added at position {index}: {actionStep.DisplayName}", + Result = new + { + index, + step = new + { + name = stepInfo.Name, + type = stepInfo.Type, + typeDetail = stepInfo.TypeDetail, + status = stepInfo.Status.ToString().ToLower() + } + } + }; + } + + /// + /// Handles the !step add uses command with action download integration. + /// Downloads the action via IActionManager and handles pre/post steps. + /// + private async Task HandleAddUsesAsync(AddUsesCommand command, IExecutionContext jobContext) + { + // Create the ActionStep + var actionStep = _factory.CreateUsesStep( + actionReference: command.Action, + name: command.Name, + with: command.With, + env: command.Env, + condition: command.Condition, + continueOnError: command.ContinueOnError, + timeoutMinutes: command.Timeout); + + // For local actions (starting with ./ or ..) and docker actions, skip download + var isLocalOrDocker = command.Action.StartsWith("./") || + command.Action.StartsWith("../") || + command.Action.StartsWith("docker://", StringComparison.OrdinalIgnoreCase); + + IActionRunner preStepRunner = null; + string downloadMessage = null; + + if (!isLocalOrDocker) + { + // Download the action via IActionManager + try + { + var actionManager = HostContext.GetService(); + + Trace.Info($"Preparing action '{command.Action}' for download..."); + + // PrepareActionsAsync downloads the action and returns info about pre/post steps + var prepareResult = await actionManager.PrepareActionsAsync( + jobContext, + new[] { actionStep } + ); + + // Check if this action has a pre-step + if (prepareResult.PreStepTracker != null && + prepareResult.PreStepTracker.TryGetValue(actionStep.Id, out var preRunner)) + { + preStepRunner = preRunner; + Trace.Info($"Action '{command.Action}' has a pre-step that will be inserted"); + } + + // Note: Post-steps are handled automatically by the job infrastructure + // when the action definition declares a post step. The ActionRunner + // will add it to PostJobSteps during execution. + + downloadMessage = $"Action '{command.Action}' downloaded successfully"; + Trace.Info(downloadMessage); + } + catch (DistributedTask.WebApi.UnresolvableActionDownloadInfoException ex) + { + // Action not found or not accessible + Trace.Error($"Failed to resolve action: {ex.Message}"); + throw new StepCommandException( + StepCommandErrors.ActionDownloadFailed, + $"Failed to resolve action '{command.Action}': {ex.Message}"); + } + catch (DistributedTask.WebApi.FailedToResolveActionDownloadInfoException ex) + { + // Network or other transient error + Trace.Error($"Failed to download action: {ex.Message}"); + throw new StepCommandException( + StepCommandErrors.ActionDownloadFailed, + $"Failed to download action '{command.Action}': {ex.Message}. Try again."); + } + catch (DistributedTask.WebApi.InvalidActionArchiveException ex) + { + // Corrupted archive + Trace.Error($"Invalid action archive: {ex.Message}"); + throw new StepCommandException( + StepCommandErrors.ActionDownloadFailed, + $"Action '{command.Action}' has an invalid archive: {ex.Message}"); + } + catch (Exception ex) when (ex.Message.Contains("action.yml") || + ex.Message.Contains("action.yaml") || + ex.Message.Contains("Dockerfile")) + { + // Action exists but has no valid entry point + Trace.Error($"Invalid action format: {ex.Message}"); + throw new StepCommandException( + StepCommandErrors.ActionDownloadFailed, + $"Action '{command.Action}' is invalid: {ex.Message}"); + } + } + else + { + downloadMessage = isLocalOrDocker && command.Action.StartsWith("docker://") + ? "Docker action - container will be pulled when step executes" + : "Local action - no download needed"; + } + + // Calculate insertion position + // If there's a pre-step, we need to insert it before the main step + var insertPosition = command.Position; + int mainStepIndex; + int? preStepIndex = null; + + if (preStepRunner != null) + { + // Insert pre-step first + preStepIndex = _manipulator.InsertStep(preStepRunner, insertPosition); + + // Then insert main step after the pre-step + mainStepIndex = _manipulator.InsertStep( + _factory.WrapInRunner(actionStep, jobContext), + StepPosition.After(preStepIndex.Value)); + + Trace.Info($"Added pre-step at position {preStepIndex} and main step at position {mainStepIndex}"); + } + else + { + // No pre-step, just insert the main step + var runner = _factory.WrapInRunner(actionStep, jobContext); + mainStepIndex = _manipulator.InsertStep(runner, insertPosition); + } + + var stepInfo = StepInfo.FromStep(_factory.WrapInRunner(actionStep, jobContext), mainStepIndex, StepStatus.Pending); + stepInfo.Change = ChangeType.Added; + + Trace.Info($"Added uses step '{actionStep.DisplayName}' at position {mainStepIndex}"); + + // Build result message + var messageBuilder = new StringBuilder(); + messageBuilder.Append($"Step added at position {mainStepIndex}: {actionStep.DisplayName}"); + + if (preStepIndex.HasValue) + { + messageBuilder.Append($"\n Pre-step added at position {preStepIndex.Value}"); + } + + if (!string.IsNullOrEmpty(downloadMessage)) + { + messageBuilder.Append($"\n ({downloadMessage})"); + } + + // TODO: Before production release, add action restriction checks: + // - Verify action is in organization's allowed list + // - Check verified creator requirements + // - Enforce enterprise policies + // For now, allow all actions in prototype + + return new StepCommandResult + { + Success = true, + Message = messageBuilder.ToString(), + Result = new + { + index = mainStepIndex, + preStepIndex, + hasPreStep = preStepIndex.HasValue, + step = new + { + name = stepInfo.Name, + type = stepInfo.Type, + typeDetail = stepInfo.TypeDetail, + status = stepInfo.Status.ToString().ToLower() + }, + actionDownloaded = !isLocalOrDocker + } + }; + } + + /// + /// Handles the !step edit command. + /// + private StepCommandResult HandleEdit(EditCommand command) + { + // Get the step info for validation + var stepInfo = _manipulator.GetStep(command.Index); + if (stepInfo == null) + { + throw new StepCommandException(StepCommandErrors.InvalidIndex, + $"No step at index {command.Index}."); + } + + if (stepInfo.Status == StepStatus.Completed) + { + throw new StepCommandException(StepCommandErrors.InvalidIndex, + $"Step {command.Index} has already completed and cannot be modified."); + } + + if (stepInfo.Status == StepStatus.Current) + { + throw new StepCommandException(StepCommandErrors.InvalidIndex, + $"Step {command.Index} is currently executing. Use step-back first to modify it."); + } + + if (stepInfo.Action == null) + { + throw new StepCommandException(StepCommandErrors.InvalidIndex, + $"Step {command.Index} is not an action step and cannot be edited."); + } + + // Track what was changed for the message + var changes = new List(); + + // Apply edits + _manipulator.EditStep(command.Index, action => + { + // Name + if (command.Name != null) + { + action.DisplayName = command.Name; + changes.Add("name"); + } + + // Condition + if (command.Condition != null) + { + action.Condition = command.Condition; + changes.Add("if"); + } + + // Script (for run steps) + if (command.Script != null) + { + UpdateScript(action, command.Script); + changes.Add("script"); + } + + // Shell (for run steps) + if (command.Shell != null) + { + UpdateRunInput(action, "shell", command.Shell); + changes.Add("shell"); + } + + // Working directory + if (command.WorkingDirectory != null) + { + UpdateRunInput(action, "workingDirectory", command.WorkingDirectory); + changes.Add("working-directory"); + } + + // With inputs (for uses steps) + if (command.With != null) + { + foreach (var kvp in command.With) + { + UpdateWithInput(action, kvp.Key, kvp.Value); + changes.Add($"with.{kvp.Key}"); + } + } + + // Remove with inputs + if (command.RemoveWith != null) + { + foreach (var key in command.RemoveWith) + { + RemoveInput(action, key); + changes.Add($"remove with.{key}"); + } + } + + // Env vars + if (command.Env != null) + { + foreach (var kvp in command.Env) + { + UpdateEnvVar(action, kvp.Key, kvp.Value); + changes.Add($"env.{kvp.Key}"); + } + } + + // Remove env vars + if (command.RemoveEnv != null) + { + foreach (var key in command.RemoveEnv) + { + RemoveEnvVar(action, key); + changes.Add($"remove env.{key}"); + } + } + + // Continue on error + if (command.ContinueOnError.HasValue) + { + action.ContinueOnError = new BooleanToken(null, null, null, command.ContinueOnError.Value); + changes.Add("continue-on-error"); + } + + // Timeout + if (command.Timeout.HasValue) + { + action.TimeoutInMinutes = new NumberToken(null, null, null, command.Timeout.Value); + changes.Add("timeout-minutes"); + } + }); + + var changesStr = changes.Count > 0 ? string.Join(", ", changes) : "no changes"; + + return new StepCommandResult + { + Success = true, + Message = $"Step {command.Index} updated ({changesStr})", + Result = new + { + index = command.Index, + changes + } + }; + } + + /// + /// Handles the !step remove command. + /// + private StepCommandResult HandleRemove(RemoveCommand command) + { + // Get step info for the message + var stepInfo = _manipulator.GetStep(command.Index); + var stepName = stepInfo?.Name ?? $"step {command.Index}"; + + // Remove the step + _manipulator.RemoveStep(command.Index); + + Trace.Info($"Removed step '{stepName}' from position {command.Index}"); + + return new StepCommandResult + { + Success = true, + Message = $"Step {command.Index} removed: {stepName}", + Result = new + { + index = command.Index, + removedStep = stepName + } + }; + } + + /// + /// Handles the !step move command. + /// + private StepCommandResult HandleMove(MoveCommand command) + { + // Get step info for the message + var stepInfo = _manipulator.GetStep(command.FromIndex); + var stepName = stepInfo?.Name ?? $"step {command.FromIndex}"; + + // Move the step + var newIndex = _manipulator.MoveStep(command.FromIndex, command.Position); + + Trace.Info($"Moved step '{stepName}' from position {command.FromIndex} to {newIndex}"); + + return new StepCommandResult + { + Success = true, + Message = $"Step moved from position {command.FromIndex} to {newIndex}: {stepName}", + Result = new + { + fromIndex = command.FromIndex, + toIndex = newIndex, + stepName + } + }; + } + + /// + /// Handles the !step export command. + /// Generates YAML output for modified steps with optional change comments. + /// + private StepCommandResult HandleExport(ExportCommand command) + { + var steps = _manipulator.GetAllSteps(); + var changes = _manipulator.GetChanges(); + + IEnumerable toExport; + if (command.ChangesOnly) + { + toExport = steps.Where(s => s.Change.HasValue && s.Action != null); + } + else + { + toExport = steps.Where(s => s.Action != null); + } + + var yaml = _serializer.ToYaml(toExport, command.WithComments); + + return new StepCommandResult + { + Success = true, + Message = yaml, + Result = new + { + yaml, + totalSteps = steps.Count, + exportedSteps = toExport.Count(), + addedCount = changes.Count(c => c.Type == ChangeType.Added), + modifiedCount = changes.Count(c => c.Type == ChangeType.Modified), + movedCount = changes.Count(c => c.Type == ChangeType.Moved), + removedCount = changes.Count(c => c.Type == ChangeType.Removed) + } + }; + } + + #endregion + + #region Helper Methods + + /// + /// Validates that we have a valid context and manipulator. + /// + private void ValidateContext(IExecutionContext jobContext) + { + if (_manipulator == null) + { + throw new StepCommandException(StepCommandErrors.NoContext, + "Step manipulator not initialized. Debug session may not be active."); + } + } + + /// + /// Updates the script in a run step's Inputs mapping. + /// + private void UpdateScript(ActionStep action, string script) + { + UpdateRunInput(action, "script", script); + } + + /// + /// Updates a value in a run step's Inputs mapping. + /// + private void UpdateRunInput(ActionStep action, string key, string value) + { + if (action.Reference is not ScriptReference) + { + throw new StepCommandException(StepCommandErrors.InvalidType, + "Cannot update script/shell/working-directory on a non-run step."); + } + + if (action.Inputs == null) + { + action.Inputs = new MappingToken(null, null, null); + } + + UpdateMappingValue(action.Inputs as MappingToken, key, value); + } + + /// + /// Updates a with input for a uses step. + /// + private void UpdateWithInput(ActionStep action, string key, string value) + { + // For uses steps, Inputs contains the "with" values + if (action.Reference is ScriptReference) + { + throw new StepCommandException(StepCommandErrors.InvalidType, + "Cannot update 'with' on a run step. Use --script instead."); + } + + if (action.Inputs == null) + { + action.Inputs = new MappingToken(null, null, null); + } + + UpdateMappingValue(action.Inputs as MappingToken, key, value); + } + + /// + /// Removes an input from the step. + /// + private void RemoveInput(ActionStep action, string key) + { + RemoveMappingValue(action.Inputs as MappingToken, key); + } + + /// + /// Updates an environment variable. + /// + private void UpdateEnvVar(ActionStep action, string key, string value) + { + if (action.Environment == null) + { + action.Environment = new MappingToken(null, null, null); + } + + UpdateMappingValue(action.Environment as MappingToken, key, value); + } + + /// + /// Removes an environment variable. + /// + private void RemoveEnvVar(ActionStep action, string key) + { + RemoveMappingValue(action.Environment as MappingToken, key); + } + + /// + /// Updates or adds a key-value pair in a MappingToken. + /// + private void UpdateMappingValue(MappingToken mapping, string key, string value) + { + if (mapping == null) + { + return; + } + + // Find and update existing key, or add new one + for (int i = 0; i < mapping.Count; i++) + { + var pair = mapping[i]; + if (string.Equals(pair.Key?.ToString(), key, StringComparison.OrdinalIgnoreCase)) + { + // Found it - replace the value + mapping.RemoveAt(i); + mapping.Insert(i, + new StringToken(null, null, null, key), + new StringToken(null, null, null, value)); + return; + } + } + + // Not found - add new entry + mapping.Add( + new StringToken(null, null, null, key), + new StringToken(null, null, null, value)); + } + + /// + /// Removes a key from a MappingToken. + /// + private void RemoveMappingValue(MappingToken mapping, string key) + { + if (mapping == null) + { + return; + } + + for (int i = 0; i < mapping.Count; i++) + { + var pair = mapping[i]; + if (string.Equals(pair.Key?.ToString(), key, StringComparison.OrdinalIgnoreCase)) + { + mapping.RemoveAt(i); + return; + } + } + } + + #endregion + } +} diff --git a/src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs b/src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs new file mode 100644 index 000000000..64bcbd1f2 --- /dev/null +++ b/src/Runner.Worker/Dap/StepCommands/StepCommandParser.cs @@ -0,0 +1,930 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using GitHub.Runner.Common; +using Newtonsoft.Json.Linq; + +namespace GitHub.Runner.Worker.Dap.StepCommands +{ + /// + /// Interface for parsing step commands from REPL strings or JSON. + /// + [ServiceLocator(Default = typeof(StepCommandParser))] + public interface IStepCommandParser : IRunnerService + { + /// + /// Parses a command string (REPL or JSON) into a structured StepCommand. + /// + /// The input string (e.g., "!step list --verbose" or JSON) + /// Parsed StepCommand + /// If parsing fails + StepCommand Parse(string input); + + /// + /// Checks if the input is a step command. + /// + /// The input string to check + /// True if this is a step command (REPL or JSON format) + bool IsStepCommand(string input); + } + + #region Command Classes + + /// + /// Base class for all step commands. + /// + public abstract class StepCommand + { + /// + /// Whether the original input was JSON (affects response format). + /// + public bool WasJsonInput { get; set; } + } + + /// + /// !step list [--verbose] + /// + public class ListCommand : StepCommand + { + public bool Verbose { get; set; } + } + + /// + /// !step add run "script" [options] + /// + public class AddRunCommand : StepCommand + { + public string Script { get; set; } + public string Name { get; set; } + public string Shell { get; set; } + public string WorkingDirectory { get; set; } + public Dictionary Env { get; set; } + public string Condition { get; set; } + public bool ContinueOnError { get; set; } + public int? Timeout { get; set; } + public StepPosition Position { get; set; } = StepPosition.Last(); + } + + /// + /// !step add uses "action@ref" [options] + /// + public class AddUsesCommand : StepCommand + { + public string Action { get; set; } + public string Name { get; set; } + public Dictionary With { get; set; } + public Dictionary Env { get; set; } + public string Condition { get; set; } + public bool ContinueOnError { get; set; } + public int? Timeout { get; set; } + public StepPosition Position { get; set; } = StepPosition.Last(); + } + + /// + /// !step edit [modifications] + /// + public class EditCommand : StepCommand + { + public int Index { get; set; } + public string Name { get; set; } + public string Script { get; set; } + public string Action { get; set; } + public string Shell { get; set; } + public string WorkingDirectory { get; set; } + public string Condition { get; set; } + public Dictionary With { get; set; } + public Dictionary Env { get; set; } + public List RemoveWith { get; set; } + public List RemoveEnv { get; set; } + public bool? ContinueOnError { get; set; } + public int? Timeout { get; set; } + } + + /// + /// !step remove + /// + public class RemoveCommand : StepCommand + { + public int Index { get; set; } + } + + /// + /// !step move [position options] + /// + public class MoveCommand : StepCommand + { + public int FromIndex { get; set; } + public StepPosition Position { get; set; } + } + + /// + /// !step export [--changes-only] [--with-comments] + /// + public class ExportCommand : StepCommand + { + public bool ChangesOnly { get; set; } + public bool WithComments { get; set; } + } + + #endregion + + #region Position Types + + /// + /// Types of position specifications for inserting/moving steps. + /// + public enum PositionType + { + /// Insert at specific index (1-based) + At, + /// Insert after specific index (1-based) + After, + /// Insert before specific index (1-based) + Before, + /// Insert at first pending position + First, + /// Insert at end (default) + Last + } + + /// + /// Represents a position for inserting or moving steps. + /// + public class StepPosition + { + public PositionType Type { get; set; } + public int? Index { get; set; } + + public static StepPosition At(int index) => new StepPosition { Type = PositionType.At, Index = index }; + public static StepPosition After(int index) => new StepPosition { Type = PositionType.After, Index = index }; + public static StepPosition Before(int index) => new StepPosition { Type = PositionType.Before, Index = index }; + public static StepPosition First() => new StepPosition { Type = PositionType.First }; + public static StepPosition Last() => new StepPosition { Type = PositionType.Last }; + + public override string ToString() + { + return Type switch + { + PositionType.At => $"at {Index}", + PositionType.After => $"after {Index}", + PositionType.Before => $"before {Index}", + PositionType.First => "first", + PositionType.Last => "last", + _ => "unknown" + }; + } + } + + #endregion + + /// + /// Parser implementation for step commands (REPL and JSON formats). + /// + public sealed class StepCommandParser : RunnerService, IStepCommandParser + { + // Regex to match quoted strings (handles escaped quotes) + private static readonly Regex QuotedStringRegex = new Regex( + @"""(?:[^""\\]|\\.)*""|'(?:[^'\\]|\\.)*'", + RegexOptions.Compiled); + + public bool IsStepCommand(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + + var trimmed = input.Trim(); + + // REPL command format: !step ... + if (trimmed.StartsWith("!step", StringComparison.OrdinalIgnoreCase)) + return true; + + // JSON format: {"cmd": "step.*", ...} + if (trimmed.StartsWith("{") && trimmed.Contains("\"cmd\"") && trimmed.Contains("\"step.")) + return true; + + return false; + } + + public StepCommand Parse(string input) + { + var trimmed = input?.Trim() ?? ""; + + if (trimmed.StartsWith("{")) + { + return ParseJsonCommand(trimmed); + } + else + { + return ParseReplCommand(trimmed); + } + } + + #region JSON Parsing + + private StepCommand ParseJsonCommand(string json) + { + JObject obj; + try + { + obj = JObject.Parse(json); + } + catch (Exception ex) + { + throw new StepCommandException(StepCommandErrors.ParseError, $"Invalid JSON: {ex.Message}"); + } + + var cmd = obj["cmd"]?.ToString(); + if (string.IsNullOrEmpty(cmd)) + { + throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'cmd' field in JSON"); + } + + StepCommand result = cmd switch + { + "step.list" => ParseJsonListCommand(obj), + "step.add" => ParseJsonAddCommand(obj), + "step.edit" => ParseJsonEditCommand(obj), + "step.remove" => ParseJsonRemoveCommand(obj), + "step.move" => ParseJsonMoveCommand(obj), + "step.export" => ParseJsonExportCommand(obj), + _ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown command: {cmd}") + }; + + result.WasJsonInput = true; + return result; + } + + private ListCommand ParseJsonListCommand(JObject obj) + { + return new ListCommand + { + Verbose = obj["verbose"]?.Value() ?? false + }; + } + + private StepCommand ParseJsonAddCommand(JObject obj) + { + var type = obj["type"]?.ToString()?.ToLower(); + + if (type == "run") + { + var script = obj["script"]?.ToString(); + if (string.IsNullOrEmpty(script)) + { + throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'script' field for run step"); + } + + return new AddRunCommand + { + Script = script, + Name = obj["name"]?.ToString(), + Shell = obj["shell"]?.ToString(), + WorkingDirectory = obj["workingDirectory"]?.ToString(), + Env = ParseJsonDictionary(obj["env"]), + Condition = obj["if"]?.ToString(), + ContinueOnError = obj["continueOnError"]?.Value() ?? false, + Timeout = obj["timeout"]?.Value(), + Position = ParseJsonPosition(obj["position"]) + }; + } + else if (type == "uses") + { + var action = obj["action"]?.ToString(); + if (string.IsNullOrEmpty(action)) + { + throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'action' field for uses step"); + } + + return new AddUsesCommand + { + Action = action, + Name = obj["name"]?.ToString(), + With = ParseJsonDictionary(obj["with"]), + Env = ParseJsonDictionary(obj["env"]), + Condition = obj["if"]?.ToString(), + ContinueOnError = obj["continueOnError"]?.Value() ?? false, + Timeout = obj["timeout"]?.Value(), + Position = ParseJsonPosition(obj["position"]) + }; + } + else + { + throw new StepCommandException(StepCommandErrors.InvalidType, + $"Invalid step type: '{type}'. Must be 'run' or 'uses'."); + } + } + + private EditCommand ParseJsonEditCommand(JObject obj) + { + var index = obj["index"]?.Value(); + if (!index.HasValue) + { + throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for edit command"); + } + + return new EditCommand + { + Index = index.Value, + Name = obj["name"]?.ToString(), + Script = obj["script"]?.ToString(), + Action = obj["action"]?.ToString(), + Shell = obj["shell"]?.ToString(), + WorkingDirectory = obj["workingDirectory"]?.ToString(), + Condition = obj["if"]?.ToString(), + With = ParseJsonDictionary(obj["with"]), + Env = ParseJsonDictionary(obj["env"]), + RemoveWith = ParseJsonStringList(obj["removeWith"]), + RemoveEnv = ParseJsonStringList(obj["removeEnv"]), + ContinueOnError = obj["continueOnError"]?.Value(), + Timeout = obj["timeout"]?.Value() + }; + } + + private RemoveCommand ParseJsonRemoveCommand(JObject obj) + { + var index = obj["index"]?.Value(); + if (!index.HasValue) + { + throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'index' field for remove command"); + } + + return new RemoveCommand { Index = index.Value }; + } + + private MoveCommand ParseJsonMoveCommand(JObject obj) + { + var from = obj["from"]?.Value(); + if (!from.HasValue) + { + throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'from' field for move command"); + } + + var position = ParseJsonPosition(obj["position"]); + if (position.Type == PositionType.Last) + { + // Default 'last' is fine for add, but move needs explicit position + // unless explicitly set + var posObj = obj["position"]; + if (posObj == null) + { + throw new StepCommandException(StepCommandErrors.ParseError, "Missing 'position' field for move command"); + } + } + + return new MoveCommand + { + FromIndex = from.Value, + Position = position + }; + } + + private ExportCommand ParseJsonExportCommand(JObject obj) + { + return new ExportCommand + { + ChangesOnly = obj["changesOnly"]?.Value() ?? false, + WithComments = obj["withComments"]?.Value() ?? false + }; + } + + private StepPosition ParseJsonPosition(JToken token) + { + if (token == null || token.Type == JTokenType.Null) + return StepPosition.Last(); + + if (token.Type == JTokenType.Object) + { + var obj = (JObject)token; + + if (obj["at"] != null) + return StepPosition.At(obj["at"].Value()); + if (obj["after"] != null) + return StepPosition.After(obj["after"].Value()); + if (obj["before"] != null) + return StepPosition.Before(obj["before"].Value()); + if (obj["first"]?.Value() == true) + return StepPosition.First(); + if (obj["last"]?.Value() == true) + return StepPosition.Last(); + } + + return StepPosition.Last(); + } + + private Dictionary ParseJsonDictionary(JToken token) + { + if (token == null || token.Type != JTokenType.Object) + return null; + + var result = new Dictionary(); + foreach (var prop in ((JObject)token).Properties()) + { + result[prop.Name] = prop.Value?.ToString() ?? ""; + } + return result.Count > 0 ? result : null; + } + + private List ParseJsonStringList(JToken token) + { + if (token == null || token.Type != JTokenType.Array) + return null; + + var result = new List(); + foreach (var item in (JArray)token) + { + var str = item?.ToString(); + if (!string.IsNullOrEmpty(str)) + result.Add(str); + } + return result.Count > 0 ? result : null; + } + + #endregion + + #region REPL Parsing + + private StepCommand ParseReplCommand(string input) + { + // Tokenize the input, respecting quoted strings + var tokens = Tokenize(input); + + if (tokens.Count < 2 || !tokens[0].Equals("!step", StringComparison.OrdinalIgnoreCase)) + { + throw new StepCommandException(StepCommandErrors.ParseError, + "Invalid command format. Expected: !step [args...]"); + } + + var subCommand = tokens[1].ToLower(); + + return subCommand switch + { + "list" => ParseReplListCommand(tokens), + "add" => ParseReplAddCommand(tokens), + "edit" => ParseReplEditCommand(tokens), + "remove" => ParseReplRemoveCommand(tokens), + "move" => ParseReplMoveCommand(tokens), + "export" => ParseReplExportCommand(tokens), + _ => throw new StepCommandException(StepCommandErrors.InvalidCommand, $"Unknown sub-command: {subCommand}") + }; + } + + private List Tokenize(string input) + { + var tokens = new List(); + var remaining = input; + + while (!string.IsNullOrEmpty(remaining)) + { + remaining = remaining.TrimStart(); + if (string.IsNullOrEmpty(remaining)) + break; + + // Check for quoted string + var match = QuotedStringRegex.Match(remaining); + if (match.Success && match.Index == 0) + { + // Extract the quoted content (without quotes) + var quoted = match.Value; + var content = quoted.Substring(1, quoted.Length - 2); + // Unescape + content = content.Replace("\\\"", "\"").Replace("\\'", "'").Replace("\\\\", "\\"); + tokens.Add(content); + remaining = remaining.Substring(match.Length); + } + else + { + // Non-quoted token + var spaceIndex = remaining.IndexOfAny(new[] { ' ', '\t' }); + if (spaceIndex == -1) + { + tokens.Add(remaining); + break; + } + tokens.Add(remaining.Substring(0, spaceIndex)); + remaining = remaining.Substring(spaceIndex); + } + } + + return tokens; + } + + private ListCommand ParseReplListCommand(List tokens) + { + var cmd = new ListCommand(); + + for (int i = 2; i < tokens.Count; i++) + { + var token = tokens[i].ToLower(); + if (token == "--verbose" || token == "-v") + { + cmd.Verbose = true; + } + else + { + throw new StepCommandException(StepCommandErrors.InvalidOption, + $"Unknown option for list: {tokens[i]}"); + } + } + + return cmd; + } + + private StepCommand ParseReplAddCommand(List tokens) + { + if (tokens.Count < 3) + { + throw new StepCommandException(StepCommandErrors.ParseError, + "Usage: !step add [options]"); + } + + var type = tokens[2].ToLower(); + + if (type == "run") + { + return ParseReplAddRunCommand(tokens); + } + else if (type == "uses") + { + return ParseReplAddUsesCommand(tokens); + } + else + { + throw new StepCommandException(StepCommandErrors.InvalidType, + $"Invalid step type: '{type}'. Must be 'run' or 'uses'."); + } + } + + private AddRunCommand ParseReplAddRunCommand(List tokens) + { + // !step add run "script" [options] + if (tokens.Count < 4) + { + throw new StepCommandException(StepCommandErrors.ParseError, + "Usage: !step add run \"