Skip to content

Commit

Permalink
GH-7909: Inline variable values in editor.
Browse files Browse the repository at this point in the history
Closes #7909.

Signed-off-by: Akos Kitta <kittaakos@typefox.io>
  • Loading branch information
Akos Kitta committed May 28, 2020
1 parent 99a7aa3 commit 51557c0
Show file tree
Hide file tree
Showing 8 changed files with 353 additions and 4 deletions.
14 changes: 14 additions & 0 deletions packages/debug/src/browser/console/debug-console-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

}
4 changes: 4 additions & 0 deletions packages/debug/src/browser/debug-frontend-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions packages/debug/src/browser/debug-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
}
}
};
Expand All @@ -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');
Expand Down
25 changes: 22 additions & 3 deletions packages/debug/src/browser/editor/debug-editor-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand All @@ -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<monaco.editor.IDecorationOptions[]> {
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;
Expand Down
246 changes: 246 additions & 0 deletions packages/debug/src/browser/editor/debug-inline-value-decorator.ts
Original file line number Diff line number Diff line change
@@ -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<string, monaco.Position[]> | 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<monaco.editor.IDecorationOptions[]> {
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<monaco.editor.IDecorationOptions[]> {

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<ExpressionContainer>,
range: monaco.Range,
model: monaco.editor.ITextModel): monaco.editor.IDecorationOptions[] {

const nameValueMap = new Map<string, string>();
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<number, string[]> = new Map<number, string[]>();
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<string, monaco.Position[]> {
if (!this.wordToLineNumbersMap) {
this.wordToLineNumbersMap = new Map<string, monaco.Position[]>();
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;
}

}
24 changes: 23 additions & 1 deletion packages/debug/src/browser/model/debug-stack-frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<DebugScope[]> {
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<T extends object>(arg?: T): { frameId: number } & T {
return Object.assign({}, arg, {
frameId: this.raw.id
Expand Down Expand Up @@ -130,4 +144,12 @@ export class DebugStackFrame extends DebugStackFrameData implements TreeElement
</span>;
}

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;
}

}
Loading

0 comments on commit 51557c0

Please sign in to comment.