mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
463 lines
13 KiB
Svelte
463 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte'
|
|
import { SvelteSet } from 'svelte/reactivity'
|
|
import type { editor as meditor, IDisposable } from 'monaco-editor'
|
|
import { debugState, getDAPClient, resetDAPClient, type DAPClient } from './dapClient'
|
|
import DebugToolbar from './DebugToolbar.svelte'
|
|
import DebugPanel from './DebugPanel.svelte'
|
|
import { getDebugServerUrl, getDebugFileExtension, type DebugLanguage } from './index'
|
|
import { VariableService } from '$lib/gen'
|
|
|
|
interface Props {
|
|
editor: meditor.IStandaloneCodeEditor | null
|
|
code: string
|
|
language?: DebugLanguage
|
|
filePath?: string
|
|
dapServerUrl?: string
|
|
workspace?: string
|
|
}
|
|
|
|
let { editor, code, language = 'bun', filePath, dapServerUrl, workspace }: Props = $props()
|
|
|
|
// Derive the server URL from language if not explicitly provided
|
|
const effectiveServerUrl = $derived(dapServerUrl ?? getDebugServerUrl(language))
|
|
// Derive file path from language if not explicitly provided
|
|
const effectiveFilePath = $derived(filePath ?? `/tmp/script${getDebugFileExtension(language)}`)
|
|
|
|
let client: DAPClient | null = $state(null)
|
|
let breakpointDecorations: string[] = $state([])
|
|
let currentLineDecoration: string[] = $state([])
|
|
let disposables: IDisposable[] = []
|
|
|
|
// Breakpoint glyph margin decoration
|
|
const breakpointDecorationType: meditor.IModelDecorationOptions = {
|
|
glyphMarginClassName: 'debug-breakpoint-glyph',
|
|
glyphMarginHoverMessage: { value: 'Breakpoint' },
|
|
stickiness: 1 // NeverGrowsWhenTypingAtEdges
|
|
}
|
|
|
|
// Current line decoration (yellow background when stopped)
|
|
const currentLineDecorationType: meditor.IModelDecorationOptions = {
|
|
isWholeLine: true,
|
|
className: 'debug-current-line',
|
|
glyphMarginClassName: 'debug-current-line-glyph'
|
|
}
|
|
|
|
// Track breakpoints by line number
|
|
let breakpoints = new SvelteSet<number>()
|
|
|
|
// Track the last used server URL to detect changes
|
|
let lastServerUrl: string | undefined = undefined
|
|
|
|
// React to language/server URL changes - fully exit debug mode and reset client
|
|
$effect(() => {
|
|
const newUrl = effectiveServerUrl
|
|
if (lastServerUrl !== undefined && lastServerUrl !== newUrl) {
|
|
// Server URL changed (language switched), fully exit debug mode
|
|
console.log('[DAP] Language changed, switching from', lastServerUrl, 'to', newUrl)
|
|
|
|
// Terminate and disconnect if we have an active session
|
|
if (client) {
|
|
if (client.isConnected()) {
|
|
// Try to terminate gracefully, then disconnect
|
|
client
|
|
.terminate()
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
client?.disconnect()
|
|
})
|
|
}
|
|
}
|
|
|
|
// Reset the singleton and clear local client reference
|
|
resetDAPClient()
|
|
client = null
|
|
|
|
// Clear current line decoration since we're exiting debug mode
|
|
if (editor) {
|
|
currentLineDecoration = editor.deltaDecorations(currentLineDecoration, [])
|
|
}
|
|
}
|
|
lastServerUrl = newUrl
|
|
})
|
|
|
|
// Export function to refresh breakpoint positions - called by parent when code changes
|
|
export function refreshBreakpoints(): void {
|
|
updateBreakpointPositionsFromDecorations()
|
|
}
|
|
|
|
onMount(() => {
|
|
if (!editor) return
|
|
|
|
client = getDAPClient(effectiveServerUrl)
|
|
lastServerUrl = effectiveServerUrl
|
|
|
|
// Add click handler for glyph margin (breakpoint toggle)
|
|
const mouseDownDisposable = editor.onMouseDown((e) => {
|
|
if (e.target.type === 2) {
|
|
// MouseTargetType.GUTTER_GLYPH_MARGIN
|
|
const line = e.target.position?.lineNumber
|
|
if (line) {
|
|
toggleBreakpoint(line)
|
|
}
|
|
}
|
|
})
|
|
disposables.push(mouseDownDisposable)
|
|
|
|
// Add keyboard shortcut F9 for toggling breakpoint
|
|
editor.addCommand(
|
|
120, // KeyCode.F9
|
|
() => {
|
|
const position = editor?.getPosition()
|
|
if (position) {
|
|
toggleBreakpoint(position.lineNumber)
|
|
}
|
|
}
|
|
)
|
|
|
|
// Update decorations when state changes
|
|
const unsubscribe = debugState.subscribe((state) => {
|
|
updateCurrentLineDecoration(state.currentLine)
|
|
})
|
|
|
|
return () => {
|
|
unsubscribe()
|
|
}
|
|
})
|
|
|
|
onDestroy(() => {
|
|
disposables.forEach((d) => d.dispose())
|
|
disposables = []
|
|
})
|
|
|
|
function toggleBreakpoint(line: number): void {
|
|
if (breakpoints.has(line)) {
|
|
breakpoints.delete(line)
|
|
} else {
|
|
breakpoints.add(line)
|
|
}
|
|
// SvelteSet is reactive, no need to reassign
|
|
updateBreakpointDecorations()
|
|
syncBreakpointsWithServer()
|
|
}
|
|
|
|
function updateBreakpointDecorations(): void {
|
|
if (!editor) return
|
|
|
|
const model = editor.getModel()
|
|
if (!model) return
|
|
|
|
const decorations: meditor.IModelDeltaDecoration[] = Array.from(breakpoints).map((line) => ({
|
|
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
|
|
options: breakpointDecorationType
|
|
}))
|
|
|
|
breakpointDecorations = editor.deltaDecorations(breakpointDecorations, decorations)
|
|
}
|
|
|
|
// Update breakpoint line numbers from decoration positions after code edits
|
|
function updateBreakpointPositionsFromDecorations(): void {
|
|
console.log(
|
|
'[DAP] updateBreakpointPositionsFromDecorations called, decorations:',
|
|
breakpointDecorations.length
|
|
)
|
|
if (!editor || breakpointDecorations.length === 0) return
|
|
|
|
const model = editor.getModel()
|
|
if (!model) return
|
|
|
|
// Get current line numbers from decorations (using plain Set as this is not reactive state)
|
|
const newLines: Set<number> = new Set()
|
|
for (const decorationId of breakpointDecorations) {
|
|
const range = model.getDecorationRange(decorationId)
|
|
if (range) {
|
|
newLines.add(range.startLineNumber)
|
|
}
|
|
}
|
|
|
|
// Check if positions changed
|
|
const oldLines = Array.from(breakpoints).sort((a, b) => a - b)
|
|
const updatedLines = Array.from(newLines).sort((a, b) => a - b)
|
|
|
|
console.log(
|
|
'[DAP] Old breakpoint lines:',
|
|
oldLines,
|
|
'New lines from decorations:',
|
|
updatedLines
|
|
)
|
|
|
|
const positionsChanged =
|
|
oldLines.length !== updatedLines.length ||
|
|
oldLines.some((line, i) => line !== updatedLines[i])
|
|
|
|
if (positionsChanged) {
|
|
console.log('[DAP] Breakpoint positions changed, syncing with server')
|
|
// Update breakpoints set with new positions
|
|
breakpoints.clear()
|
|
for (const line of newLines) {
|
|
breakpoints.add(line)
|
|
}
|
|
// Sync updated positions with server
|
|
syncBreakpointsWithServer()
|
|
}
|
|
}
|
|
|
|
function updateCurrentLineDecoration(line: number | undefined): void {
|
|
if (!editor) return
|
|
|
|
if (!line) {
|
|
currentLineDecoration = editor.deltaDecorations(currentLineDecoration, [])
|
|
return
|
|
}
|
|
|
|
const decorations: meditor.IModelDeltaDecoration[] = [
|
|
{
|
|
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
|
|
options: currentLineDecorationType
|
|
}
|
|
]
|
|
|
|
currentLineDecoration = editor.deltaDecorations(currentLineDecoration, decorations)
|
|
|
|
// Scroll to the current line
|
|
editor.revealLineInCenter(line)
|
|
}
|
|
|
|
async function syncBreakpointsWithServer(): Promise<void> {
|
|
console.log(
|
|
'[DAP] syncBreakpointsWithServer called, connected:',
|
|
client?.isConnected(),
|
|
'breakpoints:',
|
|
Array.from(breakpoints)
|
|
)
|
|
if (!client || !client.isConnected()) {
|
|
console.log('[DAP] Not syncing - client not connected')
|
|
return
|
|
}
|
|
|
|
try {
|
|
console.log(
|
|
'[DAP] Sending setBreakpoints to server:',
|
|
effectiveFilePath,
|
|
Array.from(breakpoints)
|
|
)
|
|
await client.setBreakpoints(effectiveFilePath, Array.from(breakpoints))
|
|
console.log('[DAP] setBreakpoints completed')
|
|
} catch (error) {
|
|
console.error('Failed to sync breakpoints:', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch contextual variables from the backend to pass to the debugger
|
|
*/
|
|
async function fetchContextualVariables(): Promise<Record<string, string>> {
|
|
console.log('[DAP] fetchContextualVariables called, workspace:', workspace)
|
|
if (!workspace) {
|
|
console.log('[DAP] No workspace provided, skipping contextual variables')
|
|
return {}
|
|
}
|
|
|
|
try {
|
|
console.log('[DAP] Fetching contextual variables for workspace:', workspace)
|
|
const variables = await VariableService.listContextualVariables({ workspace })
|
|
console.log('[DAP] Got contextual variables:', variables)
|
|
const envVars: Record<string, string> = {}
|
|
for (const v of variables) {
|
|
envVars[v.name] = v.value
|
|
}
|
|
console.log('[DAP] Parsed env vars:', Object.keys(envVars))
|
|
return envVars
|
|
} catch (error) {
|
|
console.error('Failed to fetch contextual variables:', error)
|
|
return {}
|
|
}
|
|
}
|
|
|
|
async function signDebugRequest(codeToSign: string, lang: string): Promise<{
|
|
token: string
|
|
code: string
|
|
}> {
|
|
if (!workspace) {
|
|
throw new Error('No workspace selected')
|
|
}
|
|
|
|
const response = await fetch(`/api/w/${workspace}/debug/sign`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code: codeToSign, language: lang })
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text()
|
|
// Parse specific error cases for better user messages
|
|
if (errorText.includes('not initialized')) {
|
|
throw new Error('Debug signing is not configured on the server. Please contact your administrator.')
|
|
}
|
|
throw new Error(errorText || 'Failed to authorize debug session')
|
|
}
|
|
|
|
return await response.json()
|
|
}
|
|
|
|
function getDebugErrorMessage(error: unknown): string {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
|
|
// Handle token verification errors from debugger
|
|
if (message.includes('Token verification failed') || message.includes('Debug token required')) {
|
|
if (message.includes('expired')) {
|
|
return 'Debug session expired. Please try again.'
|
|
}
|
|
if (message.includes('Invalid JWT signature')) {
|
|
return 'Debug authorization failed. The signing key may be misconfigured.'
|
|
}
|
|
if (message.includes('Code hash mismatch')) {
|
|
return 'Code was modified after signing. Please try again.'
|
|
}
|
|
if (message.includes('Public key not available')) {
|
|
return 'Debug server cannot verify tokens. Please check WINDMILL_BASE_URL configuration.'
|
|
}
|
|
if (message.includes('Debug token required')) {
|
|
return 'Debug authorization required. The debug session must be signed by the backend.'
|
|
}
|
|
return 'Debug authorization failed. Please try again.'
|
|
}
|
|
|
|
// Handle connection errors
|
|
if (message.includes('WebSocket') || message.includes('connection failed')) {
|
|
return 'Could not connect to debug server. Make sure the DAP server is running.'
|
|
}
|
|
|
|
// Handle signing errors
|
|
if (message.includes('not configured on the server')) {
|
|
return message
|
|
}
|
|
|
|
return message || 'An unexpected error occurred while starting the debugger.'
|
|
}
|
|
|
|
async function startDebugging(): Promise<void> {
|
|
// Always reset and create a fresh client with the current server URL
|
|
// This ensures we connect to the correct endpoint for the current language
|
|
resetDAPClient()
|
|
client = getDAPClient(effectiveServerUrl)
|
|
|
|
try {
|
|
// Fetch contextual variables (WM_WORKSPACE, WM_TOKEN, etc.) from backend
|
|
const env = await fetchContextualVariables()
|
|
console.log('[DAP] Starting debug with env vars:', Object.keys(env), env)
|
|
|
|
// Sign the debug request (creates audit log entry)
|
|
let signedPayload
|
|
try {
|
|
signedPayload = await signDebugRequest(code ?? '', language)
|
|
console.log('[DAP] Got signed payload from backend')
|
|
} catch (signError) {
|
|
console.error('[DAP] Signing failed:', getDebugErrorMessage(signError))
|
|
return
|
|
}
|
|
|
|
await client.connect()
|
|
await client.initialize()
|
|
await client.setBreakpoints(effectiveFilePath, Array.from(breakpoints))
|
|
await client.configurationDone()
|
|
await client.launch({
|
|
code,
|
|
cwd: '/tmp',
|
|
env,
|
|
// JWT token for verification by the debugger
|
|
token: signedPayload.token
|
|
})
|
|
console.log('[DAP] Launch completed with env')
|
|
} catch (error) {
|
|
console.error('[DAP] Failed to start debugging:', getDebugErrorMessage(error))
|
|
}
|
|
}
|
|
|
|
async function stopDebugging(): Promise<void> {
|
|
if (!client) return
|
|
|
|
try {
|
|
await client.terminate()
|
|
client.disconnect()
|
|
} catch (error) {
|
|
console.error('Failed to stop debugging:', error)
|
|
}
|
|
}
|
|
|
|
async function continueExecution(): Promise<void> {
|
|
if (!client) return
|
|
await client.continue_()
|
|
}
|
|
|
|
async function stepOver(): Promise<void> {
|
|
if (!client) return
|
|
await client.stepOver()
|
|
}
|
|
|
|
async function stepIn(): Promise<void> {
|
|
if (!client) return
|
|
await client.stepIn()
|
|
}
|
|
|
|
async function stepOut(): Promise<void> {
|
|
if (!client) return
|
|
await client.stepOut()
|
|
}
|
|
|
|
function clearAllBreakpoints(): void {
|
|
breakpoints.clear()
|
|
updateBreakpointDecorations()
|
|
syncBreakpointsWithServer()
|
|
}
|
|
</script>
|
|
|
|
<div class="flex flex-col h-full">
|
|
<DebugToolbar
|
|
connected={$debugState.connected}
|
|
running={$debugState.running}
|
|
stopped={$debugState.stopped}
|
|
breakpointCount={breakpoints.size}
|
|
onStart={startDebugging}
|
|
onStop={stopDebugging}
|
|
onContinue={continueExecution}
|
|
onStepOver={stepOver}
|
|
onStepIn={stepIn}
|
|
onStepOut={stepOut}
|
|
onClearBreakpoints={clearAllBreakpoints}
|
|
/>
|
|
|
|
{#if $debugState.connected}
|
|
<DebugPanel
|
|
stackFrames={$debugState.stackFrames}
|
|
scopes={$debugState.scopes}
|
|
variables={$debugState.variables}
|
|
{client}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
:global(.debug-breakpoint-glyph) {
|
|
background-color: #e51400;
|
|
border-radius: 50%;
|
|
width: 10px !important;
|
|
height: 10px !important;
|
|
margin-left: 5px;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
:global(.debug-current-line) {
|
|
background-color: rgba(255, 238, 0, 0.2);
|
|
}
|
|
|
|
:global(.debug-current-line-glyph) {
|
|
background-color: #ffcc00;
|
|
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
|
width: 10px !important;
|
|
height: 14px !important;
|
|
margin-left: 5px;
|
|
margin-top: 2px;
|
|
}
|
|
</style>
|