From 51557c0947be7cdad71fe7ffd8991cc0d6095300 Mon Sep 17 00:00:00 2001 From: Akos Kitta Date: Thu, 28 May 2020 16:45:13 +0200 Subject: [PATCH] GH-7909: Inline variable values in editor. Closes #7909. Signed-off-by: Akos Kitta --- .../browser/console/debug-console-items.tsx | 14 + .../src/browser/debug-frontend-module.ts | 4 + .../debug/src/browser/debug-preferences.ts | 6 + .../src/browser/editor/debug-editor-model.ts | 25 +- .../editor/debug-inline-value-decorator.ts | 246 ++++++++++++++++++ .../src/browser/model/debug-stack-frame.tsx | 24 +- packages/monaco/src/browser/monaco-loader.ts | 3 + packages/monaco/src/typings/monaco/index.d.ts | 35 +++ 8 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 packages/debug/src/browser/editor/debug-inline-value-decorator.ts diff --git a/packages/debug/src/browser/console/debug-console-items.tsx b/packages/debug/src/browser/console/debug-console-items.tsx index 77c16c8ba108f..1bcba7f904aae 100644 --- a/packages/debug/src/browser/console/debug-console-items.tsx +++ b/packages/debug/src/browser/console/debug-console-items.tsx @@ -359,4 +359,18 @@ export class DebugScope extends ExpressionContainer { return this.raw.name; } + get expensive(): boolean { + return this.raw.expensive; + } + + get range(): monaco.Range | undefined { + const { source, line, column, endLine, endColumn } = this.raw; + if (source && line !== undefined && column !== undefined && endLine !== undefined && endColumn !== undefined) { + // TODO: check if 0 or 1 based. + // TODO: check if we have to do `endLine || line` and `endColumn || column` + return new monaco.Range(line, column, endLine, endColumn); + } + return undefined; + } + } diff --git a/packages/debug/src/browser/debug-frontend-module.ts b/packages/debug/src/browser/debug-frontend-module.ts index 12fd7a4df413c..ae04a187085f9 100644 --- a/packages/debug/src/browser/debug-frontend-module.ts +++ b/packages/debug/src/browser/debug-frontend-module.ts @@ -55,6 +55,7 @@ import { ColorContribution } from '@theia/core/lib/browser/color-application-con import { DebugWatchManager } from './debug-watch-manager'; import { MonacoEditorService } from '@theia/monaco/lib/browser/monaco-editor-service'; import { DebugBreakpointWidget } from './editor/debug-breakpoint-widget'; +import { DebugInlineValueDecorator } from './editor/debug-inline-value-decorator'; export default new ContainerModule((bind: interfaces.Bind) => { bind(DebugCallStackItemTypeKey).toDynamicValue(({ container }) => @@ -86,6 +87,9 @@ export default new ContainerModule((bind: interfaces.Bind) => { bind(DebugSchemaUpdater).toSelf().inSingletonScope(); bind(DebugConfigurationManager).toSelf().inSingletonScope(); + bind(DebugInlineValueDecorator).toSelf().inSingletonScope(); + bind(FrontendApplicationContribution).toService(DebugInlineValueDecorator); + bind(DebugService).toDynamicValue(context => WebSocketConnectionProvider.createProxy(context.container, DebugPath)).inSingletonScope(); bind(DebugResourceResolver).toSelf().inSingletonScope(); bind(ResourceResolver).toService(DebugResourceResolver); diff --git a/packages/debug/src/browser/debug-preferences.ts b/packages/debug/src/browser/debug-preferences.ts index 13c7ccf712ad4..5c25ac33dbf8c 100644 --- a/packages/debug/src/browser/debug-preferences.ts +++ b/packages/debug/src/browser/debug-preferences.ts @@ -39,6 +39,11 @@ export const debugPreferencesSchema: PreferenceSchema = { enum: ['neverOpen', 'openOnSessionStart', 'openOnFirstSessionStart'], default: 'openOnFirstSessionStart', description: 'Controls when the internal debug console should open.' + }, + 'debug.inlineValues': { + type: 'boolean', + default: false, + description: 'Show variable values inline in editor while debugging.' } } }; @@ -48,6 +53,7 @@ export class DebugConfiguration { 'debug.debugViewLocation': 'default' | 'left' | 'right' | 'bottom'; 'debug.openDebug': 'neverOpen' | 'openOnSessionStart' | 'openOnFirstSessionStart' | 'openOnDebugBreak'; 'debug.internalConsoleOptions': 'neverOpen' | 'openOnSessionStart' | 'openOnFirstSessionStart'; + 'debug.inlineValues': boolean; } export const DebugPreferences = Symbol('DebugPreferences'); diff --git a/packages/debug/src/browser/editor/debug-editor-model.ts b/packages/debug/src/browser/editor/debug-editor-model.ts index 33a58dc04be8d..c87cf01a447f2 100644 --- a/packages/debug/src/browser/editor/debug-editor-model.ts +++ b/packages/debug/src/browser/editor/debug-editor-model.ts @@ -28,6 +28,7 @@ import { DebugHoverWidget, createDebugHoverWidgetContainer } from './debug-hover 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; @@ -83,6 +84,9 @@ export class DebugEditorModel implements Disposable { @inject(DebugExceptionWidget) readonly exceptionWidget: DebugExceptionWidget; + @inject(DebugInlineValueDecorator) + readonly inlineDecoratorService: DebugInlineValueDecorator; + @postConstruct() protected init(): void { this.uri = new URI(this.editor.getControl().getModel()!.uri.toString()); @@ -105,14 +109,29 @@ export class DebugEditorModel implements Disposable { this.toDispose.dispose(); } - protected readonly renderFrames = debounce(() => { + protected readonly renderFrames = debounce(async () => { if (this.toDispose.disposed) { return; } this.toggleExceptionWidget(); - const decorations = this.createFrameDecorations(); - this.frameDecorations = this.deltaDecorations(this.frameDecorations, decorations); + const [newFrameDecorations, inlineValueDecorations] = await Promise.all([ + this.createFrameDecorations(), + this.createInlineValueDecorations() + ]); + const codeEditor = this.editor.getControl(); + codeEditor.removeDecorations(INLINE_VALUE_DECORATION_KEY); + codeEditor.setDecorations(INLINE_VALUE_DECORATION_KEY, inlineValueDecorations); + this.frameDecorations = this.deltaDecorations(this.frameDecorations, newFrameDecorations); }, 100); + + protected async createInlineValueDecorations(): Promise { + const { currentFrame } = this.sessions; + if (!currentFrame || !currentFrame.source || currentFrame.source.uri.toString() !== this.uri.toString()) { + return []; + } + return this.inlineDecoratorService.calculateDecorations(this, currentFrame); + } + protected createFrameDecorations(): monaco.editor.IModelDeltaDecoration[] { const decorations: monaco.editor.IModelDeltaDecoration[] = []; const { currentFrame, topFrame } = this.sessions; diff --git a/packages/debug/src/browser/editor/debug-inline-value-decorator.ts b/packages/debug/src/browser/editor/debug-inline-value-decorator.ts new file mode 100644 index 0000000000000..49ee9cd7ae304 --- /dev/null +++ b/packages/debug/src/browser/editor/debug-inline-value-decorator.ts @@ -0,0 +1,246 @@ +/******************************************************************************** + * Copyright (C) 2020 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 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// Based on https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts + +import { inject, injectable } from 'inversify'; +import { FrontendApplicationContribution } from '@theia/core/lib/browser/frontend-application'; +import { MonacoEditorService } from '@theia/monaco/lib/browser/monaco-editor-service'; +import { ExpressionContainer, DebugVariable } from '../console/debug-console-items'; +import { DebugPreferences } from '../debug-preferences'; +import { DebugEditorModel } from './debug-editor-model'; +import { DebugStackFrame } from '../model/debug-stack-frame'; + +// https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts#L40-L43 +export const INLINE_VALUE_DECORATION_KEY = 'inlinevaluedecoration'; +const MAX_NUM_INLINE_VALUES = 100; // JS Global scope can have 700+ entries. We want to limit ourselves for perf reasons +const MAX_INLINE_DECORATOR_LENGTH = 150; // Max string length of each inline decorator when debugging. If exceeded ... is added +const MAX_TOKENIZATION_LINE_LEN = 500; // If line is too long, then inline values for the line are skipped +const { DEFAULT_WORD_REGEXP } = monaco.wordHelper; + +/** + * MAX SMI (SMall Integer) as defined in v8. + * one bit is lost for boxing/unboxing flag. + * one bit is lost for sign flag. + * See https://thibaultlaurens.github.io/javascript/2013/04/29/how-the-v8-engine-works/#tagged-values + */ +// https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/base/common/uint.ts#L7-L13 +const MAX_SAFE_SMALL_INTEGER = 1 << 30; + +// https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/editor/common/modes.ts#L88-L97 +const enum StandardTokenType { + Other = 0, + Comment = 1, + String = 2, + RegEx = 4 +}; + +@injectable() +export class DebugInlineValueDecorator implements FrontendApplicationContribution { + + @inject(MonacoEditorService) + protected readonly editorService: MonacoEditorService; + + @inject(DebugPreferences) + protected readonly preferences: DebugPreferences; + + protected enabled = false; + protected wordToLineNumbersMap: Map | undefined = new Map(); // TODO: can we get rid of this field? + + onStart(): void { + this.editorService.registerDecorationType(INLINE_VALUE_DECORATION_KEY, {}); + this.enabled = !!this.preferences['debug.inlineValues']; + this.preferences.onPreferenceChanged(({ preferenceName, newValue }) => { + if (preferenceName === 'debug.inlineValues' && !!newValue !== this.enabled) { + this.enabled = !!newValue; + } + }); + } + + async calculateDecorations(debugEditorModel: DebugEditorModel, stackFrame: DebugStackFrame | undefined): Promise { + this.wordToLineNumbersMap = undefined; + const model = debugEditorModel.editor.getControl().getModel() || undefined; + return this.updateInlineValueDecorations(model, stackFrame); + } + + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts#L382-L408 + protected async updateInlineValueDecorations( + model: monaco.editor.ITextModel | undefined, + stackFrame: DebugStackFrame | undefined): Promise { + + if (!this.enabled || !model || !stackFrame || !stackFrame.source || model.uri.toString() !== stackFrame.source.uri.toString()) { + return []; + } + + // XXX: Here is a difference between the VS Code's `IStackFrame` and the `DebugProtocol.StackFrame`. + // In DAP, `source` is optional, hence `range` is optional too. + const { range: stackFrameRange } = stackFrame; + if (!stackFrameRange) { + return []; + } + + const scopes = await stackFrame.getMostSpecificScopes(stackFrameRange); + // Get all top level children in the scope chain + const decorationsPerScope = await Promise.all(scopes.map(async scope => { + const children = Array.from(await scope.getElements()); + let range = new monaco.Range(0, 0, stackFrameRange.startLineNumber, stackFrameRange.startColumn); + if (scope.range) { + range = range.setStartPosition(scope.range.startLineNumber, scope.range.startColumn); + } + + return this.createInlineValueDecorationsInsideRange(children, range, model); + })); + + return decorationsPerScope.reduce((previous, current) => previous.concat(current), []); + } + + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts#L410-L452 + private createInlineValueDecorationsInsideRange( + expressions: ReadonlyArray, + range: monaco.Range, + model: monaco.editor.ITextModel): monaco.editor.IDecorationOptions[] { + + const nameValueMap = new Map(); + for (const expr of expressions) { + if (expr instanceof DebugVariable) { // XXX: VS Code uses `IExpression` that has `name` and `value`. + nameValueMap.set(expr.name, expr.value); + } + // Limit the size of map. Too large can have a perf impact + if (nameValueMap.size >= MAX_NUM_INLINE_VALUES) { + break; + } + } + + const lineToNamesMap: Map = new Map(); + const wordToPositionsMap = this.getWordToPositionsMap(model); + + // Compute unique set of names on each line + nameValueMap.forEach((_, name) => { + const positions = wordToPositionsMap.get(name); + if (positions) { + for (const position of positions) { + if (range.containsPosition(position)) { + if (!lineToNamesMap.has(position.lineNumber)) { + lineToNamesMap.set(position.lineNumber, []); + } + + if (lineToNamesMap.get(position.lineNumber)!.indexOf(name) === -1) { + lineToNamesMap.get(position.lineNumber)!.push(name); + } + } + } + } + }); + + const decorations: monaco.editor.IDecorationOptions[] = []; + // Compute decorators for each line + lineToNamesMap.forEach((names, line) => { + const contentText = names.sort((first, second) => { + const content = model.getLineContent(line); + return content.indexOf(first) - content.indexOf(second); + }).map(name => `${name} = ${nameValueMap.get(name)}`).join(', '); + decorations.push(this.createInlineValueDecoration(line, contentText)); + }); + + return decorations; + } + + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts#L454-L485 + private createInlineValueDecoration(lineNumber: number, contentText: string): monaco.editor.IDecorationOptions { + // If decoratorText is too long, trim and add ellipses. This could happen for minified files with everything on a single line + if (contentText.length > MAX_INLINE_DECORATOR_LENGTH) { + contentText = contentText.substr(0, MAX_INLINE_DECORATOR_LENGTH) + '...'; + } + + return { + color: undefined, // XXX: check inconsistency between APIs. `color` seems to be mandatory from `monaco-editor-core`. + range: { + startLineNumber: lineNumber, + endLineNumber: lineNumber, + startColumn: MAX_SAFE_SMALL_INTEGER, + endColumn: MAX_SAFE_SMALL_INTEGER + }, + renderOptions: { + after: { + contentText, + backgroundColor: 'rgba(255, 200, 0, 0.2)', + margin: '10px' + }, + dark: { + after: { + color: 'rgba(255, 255, 255, 0.5)', + } + }, + light: { + after: { + color: 'rgba(0, 0, 0, 0.5)', + } + } + } + }; + } + + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts#L487-L531 + private getWordToPositionsMap(model: monaco.editor.ITextModel): Map { + if (!this.wordToLineNumbersMap) { + this.wordToLineNumbersMap = new Map(); + if (!model) { + return this.wordToLineNumbersMap; + } + + // For every word in every line, map its ranges for fast lookup + for (let lineNumber = 1, len = model.getLineCount(); lineNumber <= len; ++lineNumber) { + const lineContent = model.getLineContent(lineNumber); + + // If line is too long then skip the line + if (lineContent.length > MAX_TOKENIZATION_LINE_LEN) { + continue; + } + + model.forceTokenization(lineNumber); + const lineTokens = model.getLineTokens(lineNumber); + for (let tokenIndex = 0, tokenCount = lineTokens.getCount(); tokenIndex < tokenCount; tokenIndex++) { + const tokenStartOffset = lineTokens.getStartOffset(tokenIndex); + const tokenEndOffset = lineTokens.getEndOffset(tokenIndex); + const tokenType = lineTokens.getStandardTokenType(tokenIndex); + const tokenStr = lineContent.substring(tokenStartOffset, tokenEndOffset); + + // Token is a word and not a comment + if (tokenType === StandardTokenType.Other) { + DEFAULT_WORD_REGEXP.lastIndex = 0; // We assume tokens will usually map 1:1 to words if they match + const wordMatch = DEFAULT_WORD_REGEXP.exec(tokenStr); + + if (wordMatch) { + const word = wordMatch[0]; + if (!this.wordToLineNumbersMap.has(word)) { + this.wordToLineNumbersMap.set(word, []); + } + + this.wordToLineNumbersMap.get(word)!.push(new monaco.Position(lineNumber, tokenStartOffset)); + } + } + } + } + } + + return this.wordToLineNumbersMap; + } + +} diff --git a/packages/debug/src/browser/model/debug-stack-frame.tsx b/packages/debug/src/browser/model/debug-stack-frame.tsx index 5a9611bda3174..31c5ab406bfa7 100644 --- a/packages/debug/src/browser/model/debug-stack-frame.tsx +++ b/packages/debug/src/browser/model/debug-stack-frame.tsx @@ -87,7 +87,7 @@ export class DebugStackFrame extends DebugStackFrameData implements TreeElement let response; try { response = await this.session.sendRequest('scopes', this.toArgs()); - } catch (e) { + } catch { // no-op: ignore debug adapter errors } if (!response) { @@ -96,6 +96,20 @@ export class DebugStackFrame extends DebugStackFrameData implements TreeElement return response.body.scopes.map(raw => new DebugScope(raw, () => this.session)); } + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/workbench/contrib/debug/common/debugModel.ts#L324-L335 + async getMostSpecificScopes(range: monaco.IRange): Promise { + const scopes = await this.getScopes(); + const nonExpensiveScopes = scopes.filter(s => !s.expensive); + const haveRangeInfo = nonExpensiveScopes.some(s => !!s.range); + if (!haveRangeInfo) { + return nonExpensiveScopes; + } + + const scopesContainingRange = nonExpensiveScopes.filter(scope => scope.range && monaco.Range.containsRange(scope.range, range)) + .sort((first, second) => (first.range!.endLineNumber - first.range!.startLineNumber) - (second.range!.endLineNumber - second.range!.startLineNumber)); + return scopesContainingRange.length ? scopesContainingRange : nonExpensiveScopes; + } + protected toArgs(arg?: T): { frameId: number } & T { return Object.assign({}, arg, { frameId: this.raw.id @@ -130,4 +144,12 @@ export class DebugStackFrame extends DebugStackFrameData implements TreeElement ; } + get range(): monaco.IRange | undefined { + const { source, line: startLine, column: startColumn, endLine, endColumn } = this.raw; + if (source) { + return new monaco.Range(startLine, startColumn, endLine || startLine, endColumn || startColumn); + } + return undefined; + } + } diff --git a/packages/monaco/src/browser/monaco-loader.ts b/packages/monaco/src/browser/monaco-loader.ts index 6f1c304e2d374..d8e2986b2cee8 100644 --- a/packages/monaco/src/browser/monaco-loader.ts +++ b/packages/monaco/src/browser/monaco-loader.ts @@ -77,6 +77,7 @@ export function loadMonaco(vsRequire: any): Promise { 'vs/platform/markers/common/markerService', 'vs/platform/contextkey/common/contextkey', 'vs/platform/contextkey/browser/contextKeyService', + 'vs/editor/common/model/wordHelper', 'vs/base/common/errors' ], (css: any, html: any, commands: any, actions: any, keybindingsRegistry: any, keybindingResolver: any, resolvedKeybinding: any, keybindingLabels: any, @@ -89,6 +90,7 @@ export function loadMonaco(vsRequire: any): Promise { codeEditorService: any, codeEditorServiceImpl: any, openerService: any, markerService: any, contextKey: any, contextKeyService: any, + wordHelper: any, error: any) => { const global: any = self; global.monaco.commands = commands; @@ -109,6 +111,7 @@ export function loadMonaco(vsRequire: any): Promise { global.monaco.contextkey = contextKey; global.monaco.contextKeyService = contextKeyService; global.monaco.mime = mime; + global.monaco.wordHelper = wordHelper; global.monaco.error = error; resolve(); }); diff --git a/packages/monaco/src/typings/monaco/index.d.ts b/packages/monaco/src/typings/monaco/index.d.ts index f9c3898de4fa0..a9e0b94511580 100644 --- a/packages/monaco/src/typings/monaco/index.d.ts +++ b/packages/monaco/src/typings/monaco/index.d.ts @@ -28,6 +28,18 @@ declare module monaco.editor { export interface ICodeEditor { protected readonly _instantiationService: monaco.instantiation.IInstantiationService; + + /** + * @internal + */ + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/editor/browser/editorBrowser.ts#L644-L647 + setDecorations(decorationTypeKey: string, ranges: editorCommon.IDecorationOptions[]): void; + + /** + * @internal + */ + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/editor/browser/editorBrowser.ts#L654-L657 + removeDecorations(decorationTypeKey: string): void; } export interface IBulkEditResult { @@ -331,6 +343,24 @@ declare module monaco.editor { before?: IContentDecorationRenderOptions; after?: IContentDecorationRenderOptions; } + + export interface ITextModel { + /** + * Get the tokens for the line `lineNumber`. + * The tokens might be inaccurate. Use `forceTokenization` to ensure accurate tokens. + * @internal + */ + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/editor/common/model.ts#L826-L831 + getLineTokens(lineNumber: number): LineTokens; + + /** + * Force tokenization information for `lineNumber` to be accurate. + * @internal + */ + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/editor/common/model.ts#L806-L810 + forceTokenization(lineNumber: number): void; + } + } declare module monaco.commands { @@ -1319,6 +1349,11 @@ declare module monaco.error { export function onUnexpectedError(e: any): undefined; } +declare module monaco.wordHelper { + // https://github.com/theia-ide/vscode/blob/standalone/0.19.x/src/vs/editor/common/model/wordHelper.ts#L30 + export const DEFAULT_WORD_REGEXP: RegExp; +} + /** * overloading languages register functions to accept LanguageSelector, * check that all register functions passing a selector to registries: