// ***************************************************************************** // Copyright (C) 2018 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import debounce = require('p-debounce'); import { injectable, inject, postConstruct, interfaces, Container } from '@theia/core/shared/inversify'; import * as monaco from '@theia/monaco-editor-core'; import { StandaloneCodeEditor } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneCodeEditor'; import { IDecorationOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/editorCommon'; import URI from '@theia/core/lib/common/uri'; import { Disposable, DisposableCollection, MenuPath, isOSX } from '@theia/core'; import { ContextMenuRenderer } from '@theia/core/lib/browser'; import { BreakpointManager, SourceBreakpointsChangeEvent } from '../breakpoint/breakpoint-manager'; import { DebugSourceBreakpoint } from '../model/debug-source-breakpoint'; import { DebugSessionManager } from '../debug-session-manager'; import { SourceBreakpoint } from '../breakpoint/breakpoint-marker'; import { DebugEditor } from './debug-editor'; import { DebugHoverWidget, createDebugHoverWidgetContainer } from './debug-hover-widget'; import { DebugBreakpointWidget } from './debug-breakpoint-widget'; import { DebugExceptionWidget } from './debug-exception-widget'; import { DebugProtocol } from '@vscode/debugprotocol'; import { DebugInlineValueDecorator, INLINE_VALUE_DECORATION_KEY } from './debug-inline-value-decorator'; export const DebugEditorModelFactory = Symbol('DebugEditorModelFactory'); export type DebugEditorModelFactory = (editor: DebugEditor) => DebugEditorModel; @injectable() export class DebugEditorModel implements Disposable { static createContainer(parent: interfaces.Container, editor: DebugEditor): Container { const child = createDebugHoverWidgetContainer(parent, editor); child.bind(DebugEditorModel).toSelf(); child.bind(DebugBreakpointWidget).toSelf(); child.bind(DebugExceptionWidget).toSelf(); return child; } static createModel(parent: interfaces.Container, editor: DebugEditor): DebugEditorModel { return DebugEditorModel.createContainer(parent, editor).get(DebugEditorModel); } static CONTEXT_MENU: MenuPath = ['debug-editor-context-menu']; protected readonly toDispose = new DisposableCollection(); protected readonly toDisposeOnUpdate = new DisposableCollection(); protected uri: URI; protected breakpointDecorations: string[] = []; protected breakpointRanges = new Map(); protected currentBreakpointDecorations: string[] = []; protected editorDecorations: string[] = []; /** * Set during `render()` to prevent `onDidChangeDecorations` → `updateBreakpoints()` * from reading back the decoration positions we just wrote, which would produce * a spurious or circular `setBreakpoints` call. */ protected ignoreDecorationsChangedEvent = false; /** * Set during `updateBreakpoints()` to prevent the resulting * `onDidChangeBreakpoints` from re-entering `render()`. */ protected ignoreBreakpointsChangeEvent = false; protected toDisposeOnModelChange = new DisposableCollection(); @inject(DebugHoverWidget) readonly hover: DebugHoverWidget; @inject(DebugEditor) readonly editor: DebugEditor; @inject(BreakpointManager) readonly breakpoints: BreakpointManager; @inject(DebugSessionManager) readonly sessions: DebugSessionManager; @inject(ContextMenuRenderer) readonly contextMenu: ContextMenuRenderer; @inject(DebugBreakpointWidget) readonly breakpointWidget: DebugBreakpointWidget; @inject(DebugExceptionWidget) readonly exceptionWidget: DebugExceptionWidget; @inject(DebugInlineValueDecorator) readonly inlineValueDecorator: DebugInlineValueDecorator; @inject(DebugSessionManager) protected readonly sessionManager: DebugSessionManager; @postConstruct() protected init(): void { this.uri = new URI(this.editor.getResourceUri().toString()); this.toDispose.pushAll([ this.hover, this.breakpointWidget, this.exceptionWidget, this.editor.getControl().onMouseDown(event => this.handleMouseDown(event)), this.editor.getControl().onMouseMove(event => this.handleMouseMove(event)), this.editor.getControl().onMouseLeave(event => this.handleMouseLeave(event)), this.editor.getControl().onKeyDown(() => this.hover.hide({ immediate: false })), this.editor.getControl().onDidChangeModelContent(() => this.update()), this.editor.getControl().onDidChangeModel(e => this.updateModel()), this.editor.onDidResize(e => this.breakpointWidget.inputSize = e), this.sessions.onDidChange(() => this.update()), this.toDisposeOnUpdate, Disposable.create(() => this.toDisposeOnModelChange.dispose()), this.breakpoints.onDidChangeBreakpoints(event => { if (!this.ignoreBreakpointsChangeEvent) { this.render(); } this.closeBreakpointIfAffected(event); }), ]); this.updateModel(); } protected updateModel(): void { this.toDisposeOnModelChange.dispose(); this.toDisposeOnModelChange = new DisposableCollection(); const model = this.editor.getControl().getModel(); if (model) { this.toDisposeOnModelChange.push(model.onDidChangeDecorations(() => this.updateBreakpoints())); } this.update(); this.render(); } dispose(): void { this.toDispose.dispose(); } protected readonly update = debounce(async () => { if (this.toDispose.disposed) { return; } this.toDisposeOnUpdate.dispose(); this.toggleExceptionWidget(); await this.updateEditorDecorations(); }, 100); protected async updateEditorDecorations(): Promise { const [newFrameDecorations, inlineValueDecorations] = await Promise.all([ this.createFrameDecorations(), this.createInlineValueDecorations() ]); const codeEditor = this.editor.getControl() as unknown as StandaloneCodeEditor; codeEditor.removeDecorations([INLINE_VALUE_DECORATION_KEY]); codeEditor.setDecorationsByType('Inline debug decorations', INLINE_VALUE_DECORATION_KEY, inlineValueDecorations); this.editorDecorations = this.deltaDecorations(this.editorDecorations, newFrameDecorations); } protected async createInlineValueDecorations(): Promise { if (!this.sessions.isCurrentEditorFrame(this.uri)) { return []; } const { currentFrame } = this.sessions; return this.inlineValueDecorator.calculateDecorations(this, currentFrame); } protected createFrameDecorations(): monaco.editor.IModelDeltaDecoration[] { const { currentFrame, topFrame } = this.sessions; if (!currentFrame || !topFrame) { return []; } if (!currentFrame.thread.stopped) { return []; } const decorations: monaco.editor.IModelDeltaDecoration[] = []; const isTopFrameInEditor = topFrame.source && new URI(topFrame.source.uri.toString()).isEqual(this.uri); const isCurrentFrameInEditor = this.sessionManager.isCurrentEditorFrame(this.uri); if (isTopFrameInEditor) { const columnUntilEOLRange = new monaco.Range(topFrame.raw.line, topFrame.raw.column, topFrame.raw.line, 1 << 30); const range = new monaco.Range(topFrame.raw.line, topFrame.raw.column, topFrame.raw.line, topFrame.raw.column + 1); decorations.push({ options: DebugEditorModel.TOP_STACK_FRAME_MARGIN, range }); decorations.push({ options: DebugEditorModel.TOP_STACK_FRAME_DECORATION, range: columnUntilEOLRange }); const firstNonWhitespaceColumn = this.editor.document.textEditorModel.getLineFirstNonWhitespaceColumn(topFrame.raw.line); if (firstNonWhitespaceColumn !== 0 && topFrame.raw.column > firstNonWhitespaceColumn) { decorations.push({ options: DebugEditorModel.TOP_STACK_FRAME_INLINE_DECORATION, range: columnUntilEOLRange }); } } if (isCurrentFrameInEditor && topFrame !== currentFrame) { const columnUntilEOLRange = new monaco.Range(currentFrame.raw.line, currentFrame.raw.column, currentFrame.raw.line, 1 << 30); const range = new monaco.Range(currentFrame.raw.line, currentFrame.raw.column, currentFrame.raw.line, currentFrame.raw.column + 1); decorations.push({ options: DebugEditorModel.FOCUSED_STACK_FRAME_MARGIN, range }); decorations.push({ options: DebugEditorModel.FOCUSED_STACK_FRAME_DECORATION, range: columnUntilEOLRange }); } return decorations; } protected async toggleExceptionWidget(): Promise { const { currentFrame } = this.sessions; if (!currentFrame) { return; } if (!this.sessions.isCurrentEditorFrame(this.uri)) { this.exceptionWidget.hide(); return; } const info = await currentFrame.thread.getExceptionInfo(); if (!info) { this.exceptionWidget.hide(); return; } this.exceptionWidget.show({ info, lineNumber: currentFrame.raw.line, column: currentFrame.raw.column }); } render(): void { this.ignoreDecorationsChangedEvent = true; try { this.renderBreakpoints(); this.renderCurrentBreakpoints(); } finally { this.ignoreDecorationsChangedEvent = false; } } protected renderBreakpoints(): void { const breakpoints = this.breakpoints.getBreakpoints(this.uri); const decorations = this.createBreakpointDecorations(breakpoints); this.breakpointDecorations = this.deltaDecorations(this.breakpointDecorations, decorations); this.updateBreakpointRanges(breakpoints); } protected createBreakpointDecorations(breakpoints: readonly DebugSourceBreakpoint[]): monaco.editor.IModelDeltaDecoration[] { return breakpoints.map(breakpoint => this.createBreakpointDecoration(breakpoint)); } protected createBreakpointDecoration(breakpoint: DebugSourceBreakpoint): monaco.editor.IModelDeltaDecoration { const lineNumber = breakpoint.line; const column = breakpoint.column || this.editor.getControl().getModel()?.getLineFirstNonWhitespaceColumn(lineNumber) || 1; const range = new monaco.Range(lineNumber, column, lineNumber, column + 1); return { range, options: { stickiness: DebugEditorModel.STICKINESS } }; } protected updateBreakpointRanges(breakpoints: readonly DebugSourceBreakpoint[]): void { this.breakpointRanges.clear(); for (let i = 0; i < this.breakpointDecorations.length; i++) { const decoration = this.breakpointDecorations[i]; const breakpoint = breakpoints[i]; const range = this.editor.getControl().getModel()?.getDecorationRange(decoration); if (range) { this.breakpointRanges.set(decoration, [range, breakpoint]); } } } protected renderCurrentBreakpoints(): void { const decorations = this.createCurrentBreakpointDecorations(); this.currentBreakpointDecorations = this.deltaDecorations(this.currentBreakpointDecorations, decorations); } protected createCurrentBreakpointDecorations(): monaco.editor.IModelDeltaDecoration[] { const breakpoints = this.breakpoints.getBreakpoints(this.uri); // Deduplicate by rendered position: when multiple breakpoints resolve // to the same (line, column) — e.g. via source-map collapsing — only // create one decoration to avoid double dots in the editor. const seen = new Set(); const result: monaco.editor.IModelDeltaDecoration[] = []; for (const bp of breakpoints) { const key = `${bp.line}:${bp.column ?? 0}`; if (seen.has(key)) { continue; } seen.add(key); result.push(this.createCurrentBreakpointDecoration(bp)); } return result; } protected createCurrentBreakpointDecoration(breakpoint: DebugSourceBreakpoint): monaco.editor.IModelDeltaDecoration { const lineNumber = breakpoint.line; const column = breakpoint.column; const range = typeof column === 'number' ? new monaco.Range(lineNumber, column, lineNumber, column + 1) : new monaco.Range(lineNumber, 1, lineNumber, 1); const { className, message } = breakpoint.getDecoration(); const renderInline = typeof column === 'number' && (column > (this.editor.getControl().getModel()?.getLineFirstNonWhitespaceColumn(lineNumber) || 0)); return { range, options: { glyphMarginClassName: className, glyphMarginHoverMessage: message.map(value => ({ value })), stickiness: DebugEditorModel.STICKINESS, beforeContentClassName: renderInline ? `theia-debug-breakpoint-column codicon ${className}` : undefined } }; } protected updateBreakpoints(): void { if (this.areBreakpointsAffected()) { const breakpoints = this.createBreakpoints(); this.ignoreBreakpointsChangeEvent = true; try { this.breakpoints.setBreakpoints(this.uri, breakpoints); } finally { this.ignoreBreakpointsChangeEvent = false; } } } protected areBreakpointsAffected(): boolean { if (this.ignoreDecorationsChangedEvent || !this.editor.getControl().getModel()) { return false; } for (const decoration of this.breakpointDecorations) { const range = this.editor.getControl().getModel()?.getDecorationRange(decoration); const oldRange = this.breakpointRanges.get(decoration)![0]; if (!range || !range.equalsRange(oldRange)) { return true; } } return false; } protected createBreakpoints(): SourceBreakpoint[] { const { uri } = this; const positions = new Set(); const breakpoints: SourceBreakpoint[] = []; for (const decoration of this.breakpointDecorations) { const range = this.editor.getControl().getModel()?.getDecorationRange(decoration); if (range) { const line = range.startLineNumber; const column = range.startColumn; const oldBreakpoint = this.breakpointRanges.get(decoration)?.[1]; if (oldBreakpoint) { const isLineBreakpoint = oldBreakpoint.origin.raw.line !== undefined && oldBreakpoint.origin.raw.column === undefined; const position = isLineBreakpoint ? `${line}` : `${line}:${column}`; if (!positions.has(position)) { const change = isLineBreakpoint ? { line } : { line, column }; const breakpoint = SourceBreakpoint.create(uri, change, oldBreakpoint.origin); breakpoints.push(breakpoint); positions.add(position); } } } } return breakpoints; } get position(): monaco.Position { return this.editor.getControl().getPosition()!; } getBreakpoint(position: monaco.Position = this.position): DebugSourceBreakpoint | undefined { return this.getInlineBreakpoint(position) || this.getLineBreakpoints(position)[0]; } getInlineBreakpoint(position: monaco.Position = this.position): DebugSourceBreakpoint | undefined { return this.breakpoints.getBreakpoints(this.uri).find(candidate => candidate.line === position.lineNumber && candidate.column === position.column); } protected getLineBreakpoints(position: monaco.Position = this.position): DebugSourceBreakpoint[] { return this.breakpoints.getBreakpoints(this.uri).filter(candidate => candidate.line === position.lineNumber); } protected addBreakpoint(raw: DebugProtocol.SourceBreakpoint): void { this.breakpoints.addBreakpoint(SourceBreakpoint.create(this.uri, raw)); } toggleBreakpoint(position: monaco.Position = this.position): void { const { lineNumber } = position; const breakpoints = this.getLineBreakpoints(position); if (breakpoints.length) { for (const breakpoint of breakpoints) { breakpoint.remove(); } } else { this.addBreakpoint({ line: lineNumber }); } } addInlineBreakpoint(): void { const { position: { lineNumber: line, column } } = this; this.addBreakpoint({ line, column }); } acceptBreakpoint(): void { const { position, values } = this.breakpointWidget; if (position && values) { const breakpoint = position.column > 0 ? this.getInlineBreakpoint(position) : this.getLineBreakpoints(position)[0]; if (breakpoint) { this.breakpoints.updateBreakpoint(breakpoint, values); } else { const { lineNumber } = position; const column = position.column > 0 ? position.column : undefined; this.addBreakpoint({ line: lineNumber, column, ...values }); } this.breakpointWidget.hide(); } } protected handleMouseDown(event: monaco.editor.IEditorMouseEvent): void { if (event.target && event.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN) { if (!event.event.rightButton) { this.toggleBreakpoint(event.target.position!); } } this.hintBreakpoint(event); } protected handleMouseMove(event: monaco.editor.IEditorMouseEvent): void { this.showHover(event); this.hintBreakpoint(event); } protected handleMouseLeave(event: monaco.editor.IPartialEditorMouseEvent): void { this.hideHover(event); this.deltaHintDecorations([]); } protected hintDecorations: string[] = []; protected hintBreakpoint(event: monaco.editor.IEditorMouseEvent): void { const hintDecorations = this.createHintDecorations(event); this.deltaHintDecorations(hintDecorations); } protected deltaHintDecorations(hintDecorations: monaco.editor.IModelDeltaDecoration[]): void { this.hintDecorations = this.deltaDecorations(this.hintDecorations, hintDecorations); } protected createHintDecorations(event: monaco.editor.IEditorMouseEvent): monaco.editor.IModelDeltaDecoration[] { if (event.target && event.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN && event.target.position) { const lineNumber = event.target.position.lineNumber; if (this.getLineBreakpoints(event.target.position).length) { return []; } return [{ range: new monaco.Range(lineNumber, 1, lineNumber, 1), options: DebugEditorModel.BREAKPOINT_HINT_DECORATION }]; } return []; } protected closeBreakpointIfAffected({ uri, removed }: SourceBreakpointsChangeEvent): void { if (!uri.isEqual(this.uri)) { return; } const position = this.breakpointWidget.position; if (!position) { return; } for (const breakpoint of removed) { if (breakpoint.line === position.lineNumber) { this.breakpointWidget.hide(); break; } } } protected showHover(mouseEvent: monaco.editor.IEditorMouseEvent): void { const targetType = mouseEvent.target.type; const stopKey = isOSX ? 'metaKey' : 'ctrlKey'; // eslint-disable-next-line @typescript-eslint/no-explicit-any if (targetType === monaco.editor.MouseTargetType.CONTENT_WIDGET && mouseEvent.target.detail === this.hover.getId() && !(mouseEvent.event)[stopKey]) { // mouse moved on top of debug hover widget return; } if (targetType === monaco.editor.MouseTargetType.CONTENT_TEXT) { this.hover.show({ selection: mouseEvent.target.range!, immediate: false }); } else { this.hover.hide({ immediate: false }); } } protected hideHover({ event }: monaco.editor.IPartialEditorMouseEvent): void { const rect = this.hover.getDomNode().getBoundingClientRect(); if (event.posx < rect.left || event.posx > rect.right || event.posy < rect.top || event.posy > rect.bottom) { this.hover.hide({ immediate: false }); } } protected deltaDecorations(oldDecorations: string[], newDecorations: monaco.editor.IModelDeltaDecoration[]): string[] { return this.editor.getControl().deltaDecorations(oldDecorations, newDecorations); } static STICKINESS = monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges; static BREAKPOINT_HINT_DECORATION: monaco.editor.IModelDecorationOptions = { glyphMarginClassName: 'codicon-debug-hint', stickiness: DebugEditorModel.STICKINESS }; static TOP_STACK_FRAME_MARGIN: monaco.editor.IModelDecorationOptions = { glyphMarginClassName: 'codicon-debug-stackframe', stickiness: DebugEditorModel.STICKINESS }; static FOCUSED_STACK_FRAME_MARGIN: monaco.editor.IModelDecorationOptions = { glyphMarginClassName: 'codicon-debug-stackframe-focused', stickiness: DebugEditorModel.STICKINESS }; static TOP_STACK_FRAME_DECORATION: monaco.editor.IModelDecorationOptions = { isWholeLine: true, className: 'theia-debug-top-stack-frame-line', stickiness: DebugEditorModel.STICKINESS }; static TOP_STACK_FRAME_INLINE_DECORATION: monaco.editor.IModelDecorationOptions = { beforeContentClassName: 'theia-debug-top-stack-frame-column' }; static FOCUSED_STACK_FRAME_DECORATION: monaco.editor.IModelDecorationOptions = { isWholeLine: true, className: 'theia-debug-focused-stack-frame-line', stickiness: DebugEditorModel.STICKINESS }; }