Improve LinkDetector

- recognizes web urls;
- checks that file path exists;
- supports relative to workspace root paths (./foo);
- supports relative to home dir paths (~/foo);
- optional line/column;
- smoke test.
This commit is contained in:
Dmitry Gozman 2019-09-23 14:40:58 -07:00
parent 4e293af126
commit 367a5a0c59
15 changed files with 288 additions and 205 deletions

View file

@ -91,7 +91,8 @@ export function renderExpressionValue(expressionOrValue: IExpressionContainer |
}
if (options.linkDetector) {
container.textContent = '';
container.appendChild(options.linkDetector.handleLinks(value));
const session = (expressionOrValue instanceof ExpressionContainer) ? expressionOrValue.getSession() : undefined;
container.appendChild(options.linkDetector.linkify(value, false, session ? session.root : undefined));
} else {
container.textContent = value;
}

View file

@ -7,12 +7,13 @@ import { LinkDetector } from 'vs/workbench/contrib/debug/browser/linkDetector';
import { RGBA, Color } from 'vs/base/common/color';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { ansiColorIdentifiers } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry';
import { IDebugSession } from 'vs/workbench/contrib/debug/common/debug';
/**
* @param text The content to stylize.
* @returns An {@link HTMLSpanElement} that contains the potentially stylized text.
*/
export function handleANSIOutput(text: string, linkDetector: LinkDetector, themeService: IThemeService): HTMLSpanElement {
export function handleANSIOutput(text: string, linkDetector: LinkDetector, themeService: IThemeService, debugSession: IDebugSession): HTMLSpanElement {
const root: HTMLSpanElement = document.createElement('span');
const textLength: number = text.length;
@ -53,7 +54,7 @@ export function handleANSIOutput(text: string, linkDetector: LinkDetector, theme
if (sequenceFound) {
// Flush buffer with previous styles.
appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, customFgColor, customBgColor);
appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, debugSession, customFgColor, customBgColor);
buffer = '';
@ -99,7 +100,7 @@ export function handleANSIOutput(text: string, linkDetector: LinkDetector, theme
// Flush remaining text buffer if not empty.
if (buffer) {
appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, customFgColor, customBgColor);
appendStylizedStringToContainer(root, buffer, styleNames, linkDetector, debugSession, customFgColor, customBgColor);
}
return root;
@ -267,6 +268,7 @@ export function appendStylizedStringToContainer(
stringContent: string,
cssClasses: string[],
linkDetector: LinkDetector,
debugSession: IDebugSession,
customTextColor?: RGBA,
customBackgroundColor?: RGBA
): void {
@ -274,7 +276,7 @@ export function appendStylizedStringToContainer(
return;
}
const container = linkDetector.handleLinks(stringContent);
const container = linkDetector.linkify(stringContent, true, debugSession.root);
container.className = cssClasses.join(' ');
if (customTextColor) {
@ -326,4 +328,4 @@ export function calcANSI8bitColor(colorNumber: number): RGBA | undefined {
} else {
return;
}
}
}

View file

@ -21,7 +21,7 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IDebugEditorContribution, IDebugService, State, EDITOR_CONTRIBUTION_ID, IStackFrame, IDebugConfiguration, IExpression, IExceptionInfo } from 'vs/workbench/contrib/debug/common/debug';
import { IDebugEditorContribution, IDebugService, State, EDITOR_CONTRIBUTION_ID, IStackFrame, IDebugConfiguration, IExpression, IExceptionInfo, IDebugSession } from 'vs/workbench/contrib/debug/common/debug';
import { ExceptionWidget } from 'vs/workbench/contrib/debug/browser/exceptionWidget';
import { FloatingClickWidget } from 'vs/workbench/browser/parts/editor/editorWidgets';
import { Position } from 'vs/editor/common/core/position';
@ -284,18 +284,18 @@ class DebugEditorContribution implements IDebugEditorContribution {
} else if (sameUri) {
focusedSf.thread.exceptionInfo.then(exceptionInfo => {
if (exceptionInfo && exceptionSf.range.startLineNumber && exceptionSf.range.startColumn) {
this.showExceptionWidget(exceptionInfo, exceptionSf.range.startLineNumber, exceptionSf.range.startColumn);
this.showExceptionWidget(exceptionInfo, this.debugService.getViewModel().focusedSession, exceptionSf.range.startLineNumber, exceptionSf.range.startColumn);
}
});
}
}
private showExceptionWidget(exceptionInfo: IExceptionInfo, lineNumber: number, column: number): void {
private showExceptionWidget(exceptionInfo: IExceptionInfo, debugSession: IDebugSession | undefined, lineNumber: number, column: number): void {
if (this.exceptionWidget) {
this.exceptionWidget.dispose();
}
this.exceptionWidget = this.instantiationService.createInstance(ExceptionWidget, this.editor, exceptionInfo);
this.exceptionWidget = this.instantiationService.createInstance(ExceptionWidget, this.editor, exceptionInfo, debugSession);
this.exceptionWidget.show({ lineNumber, column }, 0);
this.editor.revealLine(lineNumber);
}

View file

@ -980,7 +980,7 @@ export class DebugSession implements IDebugSession {
}
appendToRepl(data: string | IExpression, severity: severity, source?: IReplElementSource): void {
this.repl.appendToRepl(data, severity, source);
this.repl.appendToRepl(this, data, severity, source);
this._onDidChangeREPLElements.fire();
}

View file

@ -8,7 +8,7 @@ import * as nls from 'vs/nls';
import * as dom from 'vs/base/browser/dom';
import { ZoneWidget } from 'vs/editor/contrib/zoneWidget/zoneWidget';
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
import { IExceptionInfo } from 'vs/workbench/contrib/debug/common/debug';
import { IExceptionInfo, IDebugSession } from 'vs/workbench/contrib/debug/common/debug';
import { RunOnceScheduler } from 'vs/base/common/async';
import { IThemeService, ITheme } from 'vs/platform/theme/common/themeService';
import { Color } from 'vs/base/common/color';
@ -27,7 +27,7 @@ export class ExceptionWidget extends ZoneWidget {
private _backgroundColor?: Color;
constructor(editor: ICodeEditor, private exceptionInfo: IExceptionInfo,
constructor(editor: ICodeEditor, private exceptionInfo: IExceptionInfo, private debugSession: IDebugSession | undefined,
@IThemeService themeService: IThemeService,
@IInstantiationService private readonly instantiationService: IInstantiationService
) {
@ -80,7 +80,7 @@ export class ExceptionWidget extends ZoneWidget {
if (this.exceptionInfo.details && this.exceptionInfo.details.stackTrace) {
let stackTrace = $('.stack-trace');
const linkDetector = this.instantiationService.createInstance(LinkDetector);
const linkedStackTrace = linkDetector.handleLinks(this.exceptionInfo.details.stackTrace);
const linkedStackTrace = linkDetector.linkify(this.exceptionInfo.details.stackTrace, true, this.debugSession ? this.debugSession.root : undefined);
stackTrace.appendChild(linkedStackTrace);
dom.append(container, stackTrace);
}

View file

@ -3,160 +3,199 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as strings from 'vs/base/common/strings';
import { isAbsolute } from 'vs/base/common/path';
import { URI as uri } from 'vs/base/common/uri';
import { isMacintosh } from 'vs/base/common/platform';
import { IMouseEvent, StandardMouseEvent } from 'vs/base/browser/mouseEvent';
import * as osPath from 'vs/base/common/path';
import * as platform from 'vs/base/common/platform';
import { URI } from 'vs/base/common/uri';
import * as nls from 'vs/nls';
import { IEnvironmentService } from 'vs/platform/environment/common/environment';
import { IFileService } from 'vs/platform/files/common/files';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IWorkspaceFolder } from 'vs/platform/workspace/common/workspace';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
export class LinkDetector {
private static readonly MAX_LENGTH = 500;
private static FILE_LOCATION_PATTERNS: RegExp[] = [
// group 0: full path with line and column
// group 1: full path without line and column, matched by `*.*` in the end to work only on paths with extensions in the end (s.t. node:10352 would not match)
// group 2: drive letter on windows with trailing backslash or leading slash on mac/linux
// group 3: line number, matched by (:(\d+))
// group 4: column number, matched by ((?::(\d+))?)
// e.g.: at Context.<anonymous> (c:\Users\someone\Desktop\mocha-runner\test\test.js:26:11)
/(?![\(])(?:file:\/\/)?((?:([a-zA-Z]+:)|[^\(\)<>\'\"\[\]:\s]+)(?:[\\/][^\(\)<>\'\"\[\]:]*)?\.[a-zA-Z]+[0-9]*):(\d+)(?::(\d+))?/g
];
const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f';
const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|data:|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug');
const WIN_ABSOLUTE_PATH = /(?:[a-zA-Z]:(?:(?:\\|\/)[\w\.-]*)+)/;
const WIN_RELATIVE_PATH = /(?:(?:\~|\.)(?:(?:\\|\/)[\w\.-]*)+)/;
const WIN_PATH = new RegExp(`(${WIN_ABSOLUTE_PATH.source}|${WIN_RELATIVE_PATH.source})`);
const POSIX_PATH = /((?:\~|\.)?(?:\/[\w\.-]*)+)/;
const LINE_COLUMN = /(?:\:([\d]+))?(?:\:([\d]+))?/;
const PATH_LINK_REGEX = new RegExp(`${platform.isWindows ? WIN_PATH.source : POSIX_PATH.source}${LINE_COLUMN.source}`, 'g');
const MAX_LENGTH = 2000;
type LinkKind = 'web' | 'path' | 'text';
type LinkPart = {
kind: LinkKind;
value: string;
captures: string[];
};
export class LinkDetector {
constructor(
@IEditorService private readonly editorService: IEditorService
@IEditorService private readonly editorService: IEditorService,
@IFileService private readonly fileService: IFileService,
@IOpenerService private readonly openerService: IOpenerService,
@IEnvironmentService private readonly environmentService: IEnvironmentService
) {
// noop
}
/**
* Matches and handles absolute file links in the string provided.
* Returns <span/> element that wraps the processed string, where matched links are replaced by <a/> and unmatched parts are surrounded by <span/> elements.
* Matches and handles web urls, absolute and relative file links in the string provided.
* Returns <span/> element that wraps the processed string, where matched links are replaced by <a/>.
* 'onclick' event is attached to all anchored links that opens them in the editor.
* Each line of the text, even if it contains no links, is wrapped in a <span> and added as a child of the returned <span>.
* When splitLines is true, each line of the text, even if it contains no links, is wrapped in a <span>
* and added as a child of the returned <span>.
*/
handleLinks(text: string): HTMLElement {
linkify(text: string, splitLines?: boolean, workspaceFolder?: IWorkspaceFolder): HTMLElement {
if (splitLines) {
const lines = text.split('\n');
for (let i = 0; i < lines.length - 1; i++) {
lines[i] = lines[i] + '\n';
}
if (!lines[lines.length - 1]) {
// Remove the last element ('') that split added.
lines.pop();
}
const elements = lines.map(line => this.linkify(line, false, workspaceFolder));
if (elements.length === 1) {
// Do not wrap single line with extra span.
return elements[0];
}
const container = document.createElement('span');
elements.forEach(e => container.appendChild(e));
return container;
}
const container = document.createElement('span');
// Handle the text one line at a time
const lines = text.split('\n');
if (strings.endsWith(text, '\n')) {
// Remove the last element ('') that split added
lines.pop();
}
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Re-introduce the newline for every line except the last (unless the last line originally ended with a newline)
if (i < lines.length - 1 || strings.endsWith(text, '\n')) {
line += '\n';
}
// Don't handle links for lines that are too long
if (line.length > LinkDetector.MAX_LENGTH) {
let span = document.createElement('span');
span.textContent = line;
container.appendChild(span);
continue;
}
const lineContainer = document.createElement('span');
for (let pattern of LinkDetector.FILE_LOCATION_PATTERNS) {
// Reset the state of the pattern
pattern = new RegExp(pattern);
let lastMatchIndex = 0;
let match = pattern.exec(line);
while (match !== null) {
let resource: uri | null = isAbsolute(match[1]) ? uri.file(match[1]) : null;
if (!resource) {
match = pattern.exec(line);
continue;
}
const textBeforeLink = line.substring(lastMatchIndex, match.index);
if (textBeforeLink) {
// textBeforeLink may have matches for other patterns, so we run handleLinks on it before adding it.
lineContainer.appendChild(this.handleLinks(textBeforeLink));
}
const link = document.createElement('a');
link.textContent = line.substr(match.index, match[0].length);
link.title = isMacintosh ? nls.localize('fileLinkMac', "Cmd + click to follow link") : nls.localize('fileLink', "Ctrl + click to follow link");
lineContainer.appendChild(link);
const lineNumber = Number(match[3]);
const columnNumber = match[4] ? Number(match[4]) : undefined;
link.onclick = (e) => this.onLinkClick(new StandardMouseEvent(e), resource!, lineNumber, columnNumber);
link.onmousemove = (event) => link.classList.toggle('pointer', isMacintosh ? event.metaKey : event.ctrlKey);
link.onmouseleave = () => link.classList.remove('pointer');
lastMatchIndex = pattern.lastIndex;
const currentMatch = match;
match = pattern.exec(line);
// Append last string part if no more link matches
if (!match) {
const textAfterLink = line.substr(currentMatch.index + currentMatch[0].length);
if (textAfterLink) {
// textAfterLink may have matches for other patterns, so we run handleLinks on it before adding it.
lineContainer.appendChild(this.handleLinks(textAfterLink));
}
}
}
// If we found any matches for this pattern, don't check any more patterns. Other parts of the line will be checked for the other patterns due to the recursion.
if (lineContainer.hasChildNodes()) {
break;
}
}
if (lines.length === 1) {
if (lineContainer.hasChildNodes()) {
// Adding lineContainer to container would introduce an unnecessary surrounding span since there is only one line, so instead we just return lineContainer
return lineContainer;
} else {
container.textContent = line;
}
} else {
if (lineContainer.hasChildNodes()) {
// Add this line to the container
container.appendChild(lineContainer);
} else {
// No links were added, but we still need to surround the unmodified line with a span before adding it
let span = document.createElement('span');
span.textContent = line;
container.appendChild(span);
for (const part of this.detectLinks(text)) {
try {
switch (part.kind) {
case 'text':
container.appendChild(document.createTextNode(part.value));
break;
case 'web':
container.appendChild(this.createWebLink(part.value));
break;
case 'path':
const path = part.captures[0];
const lineNumber = part.captures[1] ? Number(part.captures[1]) : 0;
const columnNumber = part.captures[2] ? Number(part.captures[2]) : 0;
container.appendChild(this.createPathLink(part.value, path, lineNumber, columnNumber, workspaceFolder));
break;
}
} catch (e) {
container.appendChild(document.createTextNode(part.value));
}
}
return container;
}
private onLinkClick(event: IMouseEvent, resource: uri, line: number, column: number = 0): void {
const selection = window.getSelection();
if (!selection || selection.type === 'Range') {
return; // do not navigate when user is selecting
}
if (!(isMacintosh ? event.metaKey : event.ctrlKey)) {
return;
private createWebLink(url: string): Node {
const link = this.createLink(url);
const uri = URI.parse(url);
this.decorateLink(link, () => this.openerService.open(uri));
return link;
}
private createPathLink(text: string, path: string, lineNumber: number, columnNumber: number, workspaceFolder: IWorkspaceFolder | undefined): Node {
if (path[0] === '/' && path[1] === '/') {
// Most likely a url part which did not match, for example ftp://path.
return document.createTextNode(text);
}
event.preventDefault();
this.editorService.openEditor({
resource,
options: {
selection: {
startLineNumber: line,
startColumn: column
}
if (path[0] === '.') {
if (!workspaceFolder) {
return document.createTextNode(text);
}
const uri = workspaceFolder.toResource(path);
const options = { selection: { startLineNumber: lineNumber, startColumn: columnNumber } };
const link = this.createLink(text);
this.decorateLink(link, () => this.editorService.openEditor({ resource: uri, options }));
return link;
}
if (path[0] === '~') {
path = osPath.join(this.environmentService.userHome, path.substring(1));
}
const link = this.createLink(text);
const uri = URI.file(osPath.normalize(path));
this.fileService.resolve(uri).then(stat => {
if (stat.isDirectory) {
return;
}
const options = { selection: { startLineNumber: lineNumber, startColumn: columnNumber } };
this.decorateLink(link, () => this.editorService.openEditor({ resource: uri, options }));
});
return link;
}
private createLink(text: string): HTMLElement {
const link = document.createElement('a');
link.textContent = text;
return link;
}
private decorateLink(link: HTMLElement, onclick: () => void) {
link.classList.add('link');
link.title = platform.isMacintosh ? nls.localize('fileLinkMac', "Cmd + click to follow link") : nls.localize('fileLink', "Ctrl + click to follow link");
link.onmousemove = (event) => link.classList.toggle('pointer', platform.isMacintosh ? event.metaKey : event.ctrlKey);
link.onmouseleave = () => link.classList.remove('pointer');
link.onclick = (event) => {
const selection = window.getSelection();
if (!selection || selection.type === 'Range') {
return; // do not navigate when user is selecting
}
if (!(platform.isMacintosh ? event.metaKey : event.ctrlKey)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
onclick();
};
}
private detectLinks(text: string): LinkPart[] {
if (text.length > MAX_LENGTH) {
return [{ kind: 'text', value: text, captures: [] }];
}
const regexes: RegExp[] = [WEB_LINK_REGEX, PATH_LINK_REGEX];
const kinds: LinkKind[] = ['web', 'path'];
const result: LinkPart[] = [];
const splitOne = (text: string, regexIndex: number) => {
if (regexIndex >= regexes.length) {
result.push({ value: text, kind: 'text', captures: [] });
return;
}
const regex = regexes[regexIndex];
let currentIndex = 0;
let match;
regex.lastIndex = 0;
while ((match = regex.exec(text)) !== null) {
const stringBeforeMatch = text.substring(currentIndex, match.index);
if (stringBeforeMatch) {
splitOne(stringBeforeMatch, regexIndex + 1);
}
const value = match[0];
result.push({
value: value,
kind: kinds[regexIndex],
captures: match.slice(1)
});
currentIndex = match.index + value.length;
}
const stringAfterMatches = text.substring(currentIndex);
if (stringAfterMatches) {
splitOne(stringAfterMatches, regexIndex + 1);
}
};
splitOne(text, 0);
return result;
}
}

View file

@ -167,11 +167,11 @@
/* Links */
.monaco-workbench .monaco-list-row .expression .value a {
.monaco-workbench .monaco-list-row .expression .value a.link {
text-decoration: underline;
}
.monaco-workbench .monaco-list-row .expression .value a.pointer {
.monaco-workbench .monaco-list-row .expression .value a.link.pointer {
cursor: pointer;
}

View file

@ -701,7 +701,7 @@ class ReplSimpleElementsRenderer implements ITreeRenderer<SimpleReplElement, Fuz
dom.clearNode(templateData.value);
// Reset classes to clear ansi decorations since templates are reused
templateData.value.className = 'value';
const result = handleANSIOutput(element.value, this.linkDetector, this.themeService);
const result = handleANSIOutput(element.value, this.linkDetector, this.themeService, element.session);
templateData.value.appendChild(result);
dom.addClass(templateData.value, (element.severity === severity.Warning) ? 'warn' : (element.severity === severity.Error) ? 'error' : (element.severity === severity.Ignore) ? 'ignore' : 'info');

View file

@ -101,6 +101,10 @@ export class ExpressionContainer implements IExpressionContainer {
return this.id;
}
getSession(): IDebugSession | undefined {
return this.session;
}
get value(): string {
return this._value;
}

View file

@ -18,6 +18,7 @@ let topReplElementCounter = 0;
export class SimpleReplElement implements IReplElement {
constructor(
public session: IDebugSession,
private id: string,
public value: string,
public severity: severity,
@ -119,12 +120,12 @@ export class ReplModel {
this.addReplElement(result);
}
appendToRepl(data: string | IExpression, sev: severity, source?: IReplElementSource): void {
appendToRepl(session: IDebugSession, data: string | IExpression, sev: severity, source?: IReplElementSource): void {
const clearAnsiSequence = '\u001b[2J';
if (typeof data === 'string' && data.indexOf(clearAnsiSequence) >= 0) {
// [2J is the ansi escape sequence for clearing the display http://ascii-table.com/ansi-escape-sequences.php
this.removeReplExpressions();
this.appendToRepl(nls.localize('consoleCleared', "Console was cleared"), severity.Ignore);
this.appendToRepl(session, nls.localize('consoleCleared', "Console was cleared"), severity.Ignore);
data = data.substr(data.lastIndexOf(clearAnsiSequence) + clearAnsiSequence.length);
}
@ -133,7 +134,7 @@ export class ReplModel {
if (previousElement instanceof SimpleReplElement && previousElement.severity === sev && !endsWith(previousElement.value, '\n') && !endsWith(previousElement.value, '\r\n')) {
previousElement.value += data;
} else {
const element = new SimpleReplElement(`topReplElement:${topReplElementCounter++}`, data, sev, source);
const element = new SimpleReplElement(session, `topReplElement:${topReplElementCounter++}`, data, sev, source);
this.addReplElement(element);
}
} else {
@ -185,12 +186,12 @@ export class ReplModel {
// flush any existing simple values logged
if (simpleVals.length) {
this.appendToRepl(simpleVals.join(' '), sev, source);
this.appendToRepl(session, simpleVals.join(' '), sev, source);
simpleVals = [];
}
// show object
this.appendToRepl(new RawObjectReplElement(`topReplElement:${topReplElementCounter++}`, (<any>a).prototype, a, undefined, nls.localize('snapshotObj', "Only primitive values are shown for this object.")), sev, source);
this.appendToRepl(session, new RawObjectReplElement(`topReplElement:${topReplElementCounter++}`, (<any>a).prototype, a, undefined, nls.localize('snapshotObj', "Only primitive values are shown for this object.")), sev, source);
}
// string: watch out for % replacement directive
@ -220,7 +221,7 @@ export class ReplModel {
// flush simple values
// always append a new line for output coming from an extension such that separate logs go to separate lines #23695
if (simpleVals.length) {
this.appendToRepl(simpleVals.join(' ') + '\n', sev, source);
this.appendToRepl(session, simpleVals.join(' ') + '\n', sev, source);
}
}

View file

@ -14,9 +14,14 @@ import { Color, RGBA } from 'vs/base/common/color';
import { IThemeService } from 'vs/platform/theme/common/themeService';
import { TestThemeService, TestTheme } from 'vs/platform/theme/test/common/testThemeService';
import { ansiColorMap } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry';
import { DebugModel } from 'vs/workbench/contrib/debug/common/debugModel';
import { DebugSession } from 'vs/workbench/contrib/debug/browser/debugSession';
import { NullOpenerService } from 'vs/platform/opener/common/opener';
suite('Debug - ANSI Handling', () => {
let model: DebugModel;
let session: DebugSession;
let linkDetector: LinkDetector;
let themeService: IThemeService;
@ -24,6 +29,9 @@ suite('Debug - ANSI Handling', () => {
* Instantiate services for use by the functions being tested.
*/
setup(() => {
model = new DebugModel([], [], [], [], [], <any>{ isDirty: (e: any) => false });
session = new DebugSession({ resolved: { name, type: 'node', request: 'launch' }, unresolved: undefined }, undefined!, model, undefined, undefined!, undefined!, undefined!, undefined!, undefined!, undefined!, undefined!, undefined!, undefined!, NullOpenerService);
const instantiationService: TestInstantiationService = <TestInstantiationService>workbenchInstantiationService();
linkDetector = instantiationService.createInstance(LinkDetector);
@ -41,8 +49,8 @@ suite('Debug - ANSI Handling', () => {
assert.equal(0, root.children.length);
appendStylizedStringToContainer(root, 'content1', ['class1', 'class2'], linkDetector);
appendStylizedStringToContainer(root, 'content2', ['class2', 'class3'], linkDetector);
appendStylizedStringToContainer(root, 'content1', ['class1', 'class2'], linkDetector, session);
appendStylizedStringToContainer(root, 'content2', ['class2', 'class3'], linkDetector, session);
assert.equal(2, root.children.length);
@ -72,7 +80,7 @@ suite('Debug - ANSI Handling', () => {
* @returns An {@link HTMLSpanElement} that contains the stylized text.
*/
function getSequenceOutput(sequence: string): HTMLSpanElement {
const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, themeService);
const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, themeService, session);
assert.equal(1, root.children.length);
const child: Node = root.lastChild!;
if (child instanceof HTMLSpanElement) {
@ -313,7 +321,7 @@ suite('Debug - ANSI Handling', () => {
if (elementsExpected === undefined) {
elementsExpected = assertions.length;
}
const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, themeService);
const root: HTMLSpanElement = handleANSIOutput(sequence, linkDetector, themeService, session);
assert.equal(elementsExpected, root.children.length);
for (let i = 0; i < elementsExpected; i++) {
const child: Node = root.children[i];

View file

@ -464,11 +464,12 @@ suite('Debug - Model', () => {
// Repl output
test('repl output', () => {
const session = createMockSession(model);
const repl = new ReplModel();
repl.appendToRepl('first line\n', severity.Error);
repl.appendToRepl('second line ', severity.Error);
repl.appendToRepl('third line ', severity.Error);
repl.appendToRepl('fourth line', severity.Error);
repl.appendToRepl(session, 'first line\n', severity.Error);
repl.appendToRepl(session, 'second line ', severity.Error);
repl.appendToRepl(session, 'third line ', severity.Error);
repl.appendToRepl(session, 'fourth line', severity.Error);
let elements = <SimpleReplElement[]>repl.getReplElements();
assert.equal(elements.length, 2);
@ -477,14 +478,14 @@ suite('Debug - Model', () => {
assert.equal(elements[1].value, 'second line third line fourth line');
assert.equal(elements[1].severity, severity.Error);
repl.appendToRepl('1', severity.Warning);
repl.appendToRepl(session, '1', severity.Warning);
elements = <SimpleReplElement[]>repl.getReplElements();
assert.equal(elements.length, 3);
assert.equal(elements[2].value, '1');
assert.equal(elements[2].severity, severity.Warning);
const keyValueObject = { 'key1': 2, 'key2': 'value' };
repl.appendToRepl(new RawObjectReplElement('fakeid', 'fake', keyValueObject), severity.Info);
repl.appendToRepl(session, new RawObjectReplElement('fakeid', 'fake', keyValueObject), severity.Info);
const element = <RawObjectReplElement>repl.getReplElements()[3];
assert.equal(element.value, 'Object');
assert.deepEqual(element.valueObj, keyValueObject);
@ -492,11 +493,11 @@ suite('Debug - Model', () => {
repl.removeReplExpressions();
assert.equal(repl.getReplElements().length, 0);
repl.appendToRepl('1\n', severity.Info);
repl.appendToRepl('2', severity.Info);
repl.appendToRepl('3\n4', severity.Info);
repl.appendToRepl('5\n', severity.Info);
repl.appendToRepl('6', severity.Info);
repl.appendToRepl(session, '1\n', severity.Info);
repl.appendToRepl(session, '2', severity.Info);
repl.appendToRepl(session, '3\n4', severity.Info);
repl.appendToRepl(session, '5\n', severity.Info);
repl.appendToRepl(session, '6', severity.Info);
elements = <SimpleReplElement[]>repl.getReplElements();
assert.equal(elements.length, 3);
assert.equal(elements[0], '1\n');

View file

@ -8,6 +8,8 @@ import { TestInstantiationService } from 'vs/platform/instantiation/test/common/
import { workbenchInstantiationService } from 'vs/workbench/test/workbenchTestServices';
import { LinkDetector } from 'vs/workbench/contrib/debug/browser/linkDetector';
import { isWindows } from 'vs/base/common/platform';
import { WorkspaceFolder } from 'vs/platform/workspace/common/workspace';
import { URI } from 'vs/base/common/uri';
suite('Debug - Link Detector', () => {
@ -22,19 +24,18 @@ suite('Debug - Link Detector', () => {
});
/**
* Assert that a given Element is an anchor element with an onClick event.
* Assert that a given Element is an anchor element.
*
* @param element The Element to verify.
*/
function assertElementIsLink(element: Element) {
assert(element instanceof HTMLAnchorElement);
assert.notEqual(null, (element as HTMLAnchorElement).onclick);
}
test('noLinks', () => {
const input = 'I am a string';
const expectedOutput = '<span>I am a string</span>';
const output = linkDetector.handleLinks(input);
const output = linkDetector.linkify(input);
assert.equal(0, output.children.length);
assert.equal('SPAN', output.tagName);
@ -44,7 +45,17 @@ suite('Debug - Link Detector', () => {
test('trailingNewline', () => {
const input = 'I am a string\n';
const expectedOutput = '<span>I am a string\n</span>';
const output = linkDetector.handleLinks(input);
const output = linkDetector.linkify(input);
assert.equal(0, output.children.length);
assert.equal('SPAN', output.tagName);
assert.equal(expectedOutput, output.outerHTML);
});
test('trailingNewlineSplit', () => {
const input = 'I am a string\n';
const expectedOutput = '<span>I am a string\n</span>';
const output = linkDetector.linkify(input, true);
assert.equal(0, output.children.length);
assert.equal('SPAN', output.tagName);
@ -52,65 +63,71 @@ suite('Debug - Link Detector', () => {
});
test('singleLineLink', () => {
const input = isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34';
const expectedOutput = /^<span><a title=".*">.*\/foo\/bar.js:12:34<\/a><\/span>$/;
const output = linkDetector.handleLinks(input);
const input = isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34';
const expectedOutput = isWindows ? '<span><a>C:\\foo\\bar.js:12:34<\/a><\/span>' : '<span><a>/Users/foo/bar.js:12:34<\/a><\/span>';
const output = linkDetector.linkify(input);
assert.equal(1, output.children.length);
assert.equal('SPAN', output.tagName);
assert.equal('A', output.firstElementChild!.tagName);
assert(expectedOutput.test(output.outerHTML));
assert.equal(expectedOutput, output.outerHTML);
assertElementIsLink(output.firstElementChild!);
assert.equal(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.firstElementChild!.textContent);
assert.equal(isWindows ? 'C:\\foo\\bar.js:12:34' : '/Users/foo/bar.js:12:34', output.firstElementChild!.textContent);
});
test('relativeLink', () => {
const input = '\./foo/bar.js';
const expectedOutput = '<span>\./foo/bar.js</span>';
const output = linkDetector.handleLinks(input);
const output = linkDetector.linkify(input);
assert.equal(0, output.children.length);
assert.equal('SPAN', output.tagName);
assert.equal(expectedOutput, output.outerHTML);
});
test('relativeLinkWithWorkspace', () => {
const input = '\./foo/bar.js';
const expectedOutput = /^<span><a class="link" title=".*">\.\/foo\/bar\.js<\/a><\/span>$/;
const output = linkDetector.linkify(input, false, new WorkspaceFolder({ uri: URI.file('/path/to/workspace'), name: 'ws', index: 0 }));
assert.equal('SPAN', output.tagName);
assert(expectedOutput.test(output.outerHTML));
});
test('singleLineLinkAndText', function () {
const input = isWindows ? 'The link: C:/foo/bar.js:12:34' : 'The link: /Users/foo/bar.js:12:34';
const expectedOutput = /^<span><span>The link: <\/span><a title=".*">.*\/foo\/bar.js:12:34<\/a><\/span>$/;
const output = linkDetector.handleLinks(input);
const expectedOutput = /^<span>The link: <a>.*\/foo\/bar.js:12:34<\/a><\/span>$/;
const output = linkDetector.linkify(input);
assert.equal(2, output.children.length);
assert.equal(1, output.children.length);
assert.equal('SPAN', output.tagName);
assert.equal('SPAN', output.children[0].tagName);
assert.equal('A', output.children[1].tagName);
assert.equal('A', output.children[0].tagName);
assert(expectedOutput.test(output.outerHTML));
assertElementIsLink(output.children[1]);
assert.equal(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.children[1].textContent);
assertElementIsLink(output.children[0]);
assert.equal(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.children[0].textContent);
});
test('singleLineMultipleLinks', () => {
const input = isWindows ? 'Here is a link C:/foo/bar.js:12:34 and here is another D:/boo/far.js:56:78' :
'Here is a link /Users/foo/bar.js:12:34 and here is another /Users/boo/far.js:56:78';
const expectedOutput = /^<span><span>Here is a link <\/span><a title=".*">.*\/foo\/bar.js:12:34<\/a><span> and here is another <\/span><a title=".*">.*\/boo\/far.js:56:78<\/a><\/span>$/;
const output = linkDetector.handleLinks(input);
const expectedOutput = /^<span>Here is a link <a>.*\/foo\/bar.js:12:34<\/a> and here is another <a>.*\/boo\/far.js:56:78<\/a><\/span>$/;
const output = linkDetector.linkify(input);
assert.equal(4, output.children.length);
assert.equal(2, output.children.length);
assert.equal('SPAN', output.tagName);
assert.equal('SPAN', output.children[0].tagName);
assert.equal('A', output.children[0].tagName);
assert.equal('A', output.children[1].tagName);
assert.equal('SPAN', output.children[2].tagName);
assert.equal('A', output.children[3].tagName);
assert(expectedOutput.test(output.outerHTML));
assertElementIsLink(output.children[0]);
assertElementIsLink(output.children[1]);
assertElementIsLink(output.children[3]);
assert.equal(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.children[1].textContent);
assert.equal(isWindows ? 'D:/boo/far.js:56:78' : '/Users/boo/far.js:56:78', output.children[3].textContent);
assert.equal(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.children[0].textContent);
assert.equal(isWindows ? 'D:/boo/far.js:56:78' : '/Users/boo/far.js:56:78', output.children[1].textContent);
});
test('multilineNoLinks', () => {
const input = 'Line one\nLine two\nLine three';
const expectedOutput = /^<span><span>Line one\n<\/span><span>Line two\n<\/span><span>Line three<\/span><\/span>$/;
const output = linkDetector.handleLinks(input);
const output = linkDetector.linkify(input, true);
assert.equal(3, output.children.length);
assert.equal('SPAN', output.tagName);
@ -123,7 +140,7 @@ suite('Debug - Link Detector', () => {
test('multilineTrailingNewline', () => {
const input = 'I am a string\nAnd I am another\n';
const expectedOutput = '<span><span>I am a string\n<\/span><span>And I am another\n<\/span><\/span>';
const output = linkDetector.handleLinks(input);
const output = linkDetector.linkify(input, true);
assert.equal(2, output.children.length);
assert.equal('SPAN', output.tagName);
@ -135,19 +152,17 @@ suite('Debug - Link Detector', () => {
test('multilineWithLinks', () => {
const input = isWindows ? 'I have a link for you\nHere it is: C:/foo/bar.js:12:34\nCool, huh?' :
'I have a link for you\nHere it is: /Users/foo/bar.js:12:34\nCool, huh?';
const expectedOutput = /^<span><span>I have a link for you\n<\/span><span><span>Here it is: <\/span><a title=".*">.*\/foo\/bar.js:12:34<\/a><span>\n<\/span><\/span><span>Cool, huh\?<\/span><\/span>$/;
const output = linkDetector.handleLinks(input);
const expectedOutput = /^<span><span>I have a link for you\n<\/span><span>Here it is: <a>.*\/foo\/bar.js:12:34<\/a>\n<\/span><span>Cool, huh\?<\/span><\/span>$/;
const output = linkDetector.linkify(input, true);
assert.equal(3, output.children.length);
assert.equal('SPAN', output.tagName);
assert.equal('SPAN', output.children[0].tagName);
assert.equal('SPAN', output.children[1].tagName);
assert.equal('SPAN', output.children[2].tagName);
assert.equal('SPAN', output.children[1].children[0].tagName);
assert.equal('A', output.children[1].children[1].tagName);
assert.equal('SPAN', output.children[1].children[2].tagName);
assert.equal('A', output.children[1].children[0].tagName);
assert(expectedOutput.test(output.outerHTML));
assertElementIsLink(output.children[1].children[1]);
assert.equal(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.children[1].children[1].textContent);
assertElementIsLink(output.children[1].children[0]);
assert.equal(isWindows ? 'C:/foo/bar.js:12:34' : '/Users/foo/bar.js:12:34', output.children[1].children[0].textContent);
});
});

View file

@ -29,6 +29,7 @@ const SPECIFIC_STACK_FRAME = (filename: string) => `${STACK_FRAME} .file[title*=
const VARIABLE = `${VIEWLET} .debug-variables .monaco-list-row .expression`;
const CONSOLE_OUTPUT = `.repl .output.expression .value`;
const CONSOLE_EVALUATION_RESULT = `.repl .evaluation-result.expression .value`;
const CONSOLE_LINK = `.repl .value a.link`;
const REPL_FOCUSED = '.repl-input-wrapper .monaco-editor textarea';
@ -141,6 +142,10 @@ export class Debug extends Viewlet {
await this.code.waitForElements(VARIABLE, false, els => els.length === count || els.length === alternativeCount);
}
async waitForLink(): Promise<void> {
await this.code.waitForElement(CONSOLE_LINK);
}
private async waitForOutput(fn: (output: string[]) => boolean): Promise<string[]> {
const elements = await this.code.waitForElements(CONSOLE_OUTPUT, false, elements => fn(elements.map(e => e.textContent)));
return elements.map(e => e.textContent);

View file

@ -106,6 +106,13 @@ export function setup() {
await app.workbench.debug.waitForReplCommand('2 + 2', r => r === '4');
});
it('debug console link', async function () {
const app = this.app as Application;
await app.workbench.debug.waitForReplCommand('"./app.js:5:1"', r => r.includes('app.js'));
await app.workbench.debug.waitForLink();
});
it('stop debugging', async function () {
const app = this.app as Application;