feat(debug): show ghost breakpoint and tooltip on gutter hover (#9150)

* feat(debug): show ghost breakpoint and tooltip on gutter hover

* fix(debug): show ghost breakpoint only on glyph margin to match click handler

* revert(debug): show ghost across entire gutter, not only glyph margin

* refactor(debug): use MouseTargetType enum, short-circuit hover decoration
This commit is contained in:
Guilhem
2026-05-20 12:57:29 +00:00
committed by GitHub
parent b0ed27096d
commit 271f0cbd08
2 changed files with 158 additions and 8 deletions
@@ -591,6 +591,10 @@
let debugBreakpoints = new SvelteSet<number>()
let breakpointDecorations: string[] = $state([])
let currentLineDecoration: string[] = $state([])
let hoverBreakpointDecoration: string[] = $state([])
// Line currently showing the ghost breakpoint, used to short-circuit redundant
// deltaDecorations calls on every mousemove event.
let hoverBreakpointLine: number | null = null
// Get the DAP server URL based on language
const dapServerUrl = $derived(getDebugServerUrl((lang || 'python3') as DebugLanguage))
const debugFilePath = $derived(`/tmp/script${getDebugFileExtension(lang || '')}`)
@@ -623,6 +627,13 @@
stickiness: 1
}
// Ghost breakpoint shown while hovering the gutter on a line without a breakpoint
const hoverBreakpointDecorationType: meditor.IModelDecorationOptions = {
glyphMarginClassName: 'debug-breakpoint-glyph-hover',
glyphMarginHoverMessage: { value: 'Click to add a breakpoint' },
stickiness: 1
}
const currentLineDecorationType = {
isWholeLine: true,
className: 'debug-current-line',
@@ -854,6 +865,35 @@
debugBreakpoints.add(line)
}
updateBreakpointDecorations()
clearHoverBreakpointDecoration()
}
function updateHoverBreakpointDecoration(line: number): void {
if (hoverBreakpointLine === line) return
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
const decorations = [
{
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: hoverBreakpointDecorationType
}
]
const oldDecorations = untrack(() => hoverBreakpointDecoration)
hoverBreakpointDecoration = monacoEditor.deltaDecorations(oldDecorations, decorations)
hoverBreakpointLine = line
}
function clearHoverBreakpointDecoration(): void {
if (hoverBreakpointLine === null) return
const monacoEditor = editor?.getEditor?.()
if (!monacoEditor) return
const oldDecorations = untrack(() => hoverBreakpointDecoration)
if (oldDecorations.length > 0) {
hoverBreakpointDecoration = monacoEditor.deltaDecorations(oldDecorations, [])
}
hoverBreakpointLine = null
}
function updateBreakpointDecorations(): void {
@@ -1083,8 +1123,7 @@
// Add click handler for glyph margin (breakpoint toggle)
const mouseDownDisposable = monacoEditor.onMouseDown((e) => {
// MouseTargetType.GUTTER_GLYPH_MARGIN = 2
if (e.target.type === 2) {
if (e.target.type === meditor.MouseTargetType.GUTTER_GLYPH_MARGIN) {
const line = e.target.position?.lineNumber
if (line) {
toggleBreakpoint(line)
@@ -1092,6 +1131,27 @@
}
})
// Show a ghost breakpoint while hovering anywhere in the gutter on an empty line.
// Hover area is intentionally wider than the click target — clicks still only
// toggle when landing on the glyph margin itself, but the ghost helps users find it.
const mouseMoveDisposable = monacoEditor.onMouseMove((e) => {
const t = e.target.type
const isGutter =
t === meditor.MouseTargetType.GUTTER_GLYPH_MARGIN ||
t === meditor.MouseTargetType.GUTTER_LINE_NUMBERS ||
t === meditor.MouseTargetType.GUTTER_LINE_DECORATIONS
const line = e.target.position?.lineNumber
if (isGutter && line && !debugBreakpoints.has(line)) {
updateHoverBreakpointDecoration(line)
} else {
clearHoverBreakpointDecoration()
}
})
const mouseLeaveDisposable = monacoEditor.onMouseLeave(() => {
clearHoverBreakpointDecoration()
})
// Add F9 keyboard shortcut for toggling breakpoint at cursor
monacoEditor.addCommand(120, () => {
// KeyCode.F9 = 120
@@ -1124,6 +1184,9 @@
return () => {
mouseDownDisposable.dispose()
mouseMoveDisposable.dispose()
mouseLeaveDisposable.dispose()
clearHoverBreakpointDecoration()
// Disable glyph margin when exiting debug mode
monacoEditor.updateOptions({ glyphMargin: false })
}
@@ -2163,6 +2226,18 @@
margin-top: 4px;
}
/* Ghost breakpoint shown on gutter hover before the user clicks */
.debug-breakpoint-glyph-hover {
background-color: #e51400;
opacity: 0.35;
border-radius: 50%;
width: 10px !important;
height: 10px !important;
margin-left: 5px;
margin-top: 4px;
cursor: pointer;
}
/* Current execution line - yellow background */
.debug-current-line {
background-color: rgba(255, 238, 0, 0.2);
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
import type { editor as meditor, IDisposable } from 'monaco-editor'
import { editor as meditor, type IDisposable } from 'monaco-editor'
import { debugState, getDAPClient, resetDAPClient, type DAPClient } from './dapClient'
import DebugToolbar from './DebugToolbar.svelte'
import DebugPanel from './DebugPanel.svelte'
@@ -27,15 +27,26 @@
let client: DAPClient | null = $state(null)
let breakpointDecorations: string[] = $state([])
let currentLineDecoration: string[] = $state([])
let hoverBreakpointDecoration: string[] = $state([])
// Line currently showing the ghost breakpoint, used to short-circuit redundant
// deltaDecorations calls on every mousemove event.
let hoverBreakpointLine: number | null = null
let disposables: IDisposable[] = []
// Breakpoint glyph margin decoration
const breakpointDecorationType: meditor.IModelDecorationOptions = {
glyphMarginClassName: 'debug-breakpoint-glyph',
glyphMarginHoverMessage: { value: 'Breakpoint' },
glyphMarginHoverMessage: { value: 'Breakpoint (click to remove)' },
stickiness: 1 // NeverGrowsWhenTypingAtEdges
}
// Ghost breakpoint shown while hovering the gutter on an empty line
const hoverBreakpointDecorationType: meditor.IModelDecorationOptions = {
glyphMarginClassName: 'debug-breakpoint-glyph-hover',
glyphMarginHoverMessage: { value: 'Click to add a breakpoint' },
stickiness: 1
}
// Current line decoration (yellow background when stopped)
const currentLineDecorationType: meditor.IModelDecorationOptions = {
isWholeLine: true,
@@ -94,8 +105,7 @@
// Add click handler for glyph margin (breakpoint toggle)
const mouseDownDisposable = editor.onMouseDown((e) => {
if (e.target.type === 2) {
// MouseTargetType.GUTTER_GLYPH_MARGIN
if (e.target.type === meditor.MouseTargetType.GUTTER_GLYPH_MARGIN) {
const line = e.target.position?.lineNumber
if (line) {
toggleBreakpoint(line)
@@ -104,6 +114,29 @@
})
disposables.push(mouseDownDisposable)
// Show a ghost breakpoint while hovering anywhere in the gutter on an empty line.
// Hover area is intentionally wider than the click target — clicks still only
// toggle when landing on the glyph margin itself.
const mouseMoveDisposable = editor.onMouseMove((e) => {
const t = e.target.type
const isGutter =
t === meditor.MouseTargetType.GUTTER_GLYPH_MARGIN ||
t === meditor.MouseTargetType.GUTTER_LINE_NUMBERS ||
t === meditor.MouseTargetType.GUTTER_LINE_DECORATIONS
const line = e.target.position?.lineNumber
if (isGutter && line && !breakpoints.has(line)) {
updateHoverBreakpointDecoration(line)
} else {
clearHoverBreakpointDecoration()
}
})
disposables.push(mouseMoveDisposable)
const mouseLeaveDisposable = editor.onMouseLeave(() => {
clearHoverBreakpointDecoration()
})
disposables.push(mouseLeaveDisposable)
// Add keyboard shortcut F9 for toggling breakpoint
editor.addCommand(
120, // KeyCode.F9
@@ -128,6 +161,7 @@
onDestroy(() => {
disposables.forEach((d) => d.dispose())
disposables = []
clearHoverBreakpointDecoration()
})
function toggleBreakpoint(line: number): void {
@@ -138,9 +172,34 @@
}
// SvelteSet is reactive, no need to reassign
updateBreakpointDecorations()
// A real breakpoint now lives here (or just got cleared) — drop the ghost
clearHoverBreakpointDecoration()
syncBreakpointsWithServer()
}
function updateHoverBreakpointDecoration(line: number): void {
if (hoverBreakpointLine === line) return
if (!editor) return
const decorations: meditor.IModelDeltaDecoration[] = [
{
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: hoverBreakpointDecorationType
}
]
hoverBreakpointDecoration = editor.deltaDecorations(hoverBreakpointDecoration, decorations)
hoverBreakpointLine = line
}
function clearHoverBreakpointDecoration(): void {
if (hoverBreakpointLine === null) return
if (editor && hoverBreakpointDecoration.length > 0) {
hoverBreakpointDecoration = editor.deltaDecorations(hoverBreakpointDecoration, [])
}
hoverBreakpointLine = null
}
function updateBreakpointDecorations(): void {
if (!editor) return
@@ -274,7 +333,10 @@
}
}
async function signDebugRequest(codeToSign: string, lang: string): Promise<{
async function signDebugRequest(
codeToSign: string,
lang: string
): Promise<{
token: string
code: string
}> {
@@ -292,7 +354,9 @@
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(
'Debug signing is not configured on the server. Please contact your administrator.'
)
}
throw new Error(errorText || 'Failed to authorize debug session')
}
@@ -447,6 +511,17 @@
margin-top: 4px;
}
:global(.debug-breakpoint-glyph-hover) {
background-color: #e51400;
opacity: 0.35;
border-radius: 50%;
width: 10px !important;
height: 10px !important;
margin-left: 5px;
margin-top: 4px;
cursor: pointer;
}
:global(.debug-current-line) {
background-color: rgba(255, 238, 0, 0.2);
}