diff --git a/CHANGELOG.md b/CHANGELOG.md index d8fdcc197..fe378dd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog records changes to stable releases since 1.50.2. "TBA" changes he ## Nightly (only) +- feat: improve display of HTML elements in the debugger - feat: add node tool picker completion for launch.json ([#1997](https://github.com/microsoft/vscode-js-debug/issues/1997)) - fix: process attachment with `--inspect=:1234` style ([#2063](https://github.com/microsoft/vscode-js-debug/issues/2063)) diff --git a/src/adapter/clientCapabilities.ts b/src/adapter/clientCapabilities.ts new file mode 100644 index 000000000..e5c65ca48 --- /dev/null +++ b/src/adapter/clientCapabilities.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { injectable } from 'inversify'; +import Dap from '../dap/api'; + +export interface IClientCapabilies { + value?: Dap.InitializeParams; +} + +export const IClientCapabilies = Symbol('IClientCapabilies'); + +@injectable() +export class ClientCapabilities implements IClientCapabilies { + value?: Dap.InitializeParams | undefined; +} diff --git a/src/adapter/debugAdapter.ts b/src/adapter/debugAdapter.ts index 7bf3b9f13..4ac22c602 100644 --- a/src/adapter/debugAdapter.ts +++ b/src/adapter/debugAdapter.ts @@ -24,6 +24,7 @@ import { IShutdownParticipants } from '../ui/shutdownParticipants'; import { IAsyncStackPolicy } from './asyncStackPolicy'; import { BreakpointManager } from './breakpoints'; import { ICdpProxyProvider } from './cdpProxy'; +import { IClientCapabilies } from './clientCapabilities'; import { ICompletions } from './completions'; import { IConsole } from './console'; import { Diagnostics } from './diagnosics'; @@ -250,6 +251,7 @@ export class DebugAdapter implements IDisposable { ): Promise { console.assert(params.linesStartAt1); console.assert(params.columnsStartAt1); + this._services.get(IClientCapabilies).value = params; const capabilities = DebugAdapter.capabilities(true); setTimeout(() => this.dap.initialized({}), 0); setTimeout(() => this._thread?.dapInitialized(), 0); @@ -310,6 +312,7 @@ export class DebugAdapter implements IDisposable { supportsEvaluationOptions: extended ? true : false, supportsDebuggerProperties: extended ? true : false, supportsSetSymbolOptions: extended ? true : false, + supportsANSIStyling: true, // supportsDataBreakpoints: false, // supportsDisassembleRequest: false, }; @@ -515,6 +518,7 @@ export class DebugAdapter implements IDisposable { this._services.get(IExceptionPauseService), this._services.get(SmartStepper), this._services.get(IShutdownParticipants), + this._services.get(IClientCapabilies), ); const profile = this._services.get(IProfileController); diff --git a/src/adapter/messageFormat.ts b/src/adapter/messageFormat.ts index 843dd27c4..238871cd5 100644 --- a/src/adapter/messageFormat.ts +++ b/src/adapter/messageFormat.ts @@ -148,13 +148,13 @@ export function formatCssAsAnsi(style: string): string { if (background) escapedSequence += `\x1b[48;5;${background}m`; break; case 'font-weight': - if (match[2] === 'bold') escapedSequence += '\x1b[1m'; + if (match[2] === 'bold') escapedSequence += AnsiStyles.Bold; break; case 'font-style': - if (match[2] === 'italic') escapedSequence += '\x1b[3m'; + if (match[2] === 'italic') escapedSequence += AnsiStyles.Italic; break; case 'text-decoration': - if (match[2] === 'underline') escapedSequence += '\x1b[4m'; + if (match[2] === 'underline') escapedSequence += AnsiStyles.Underline; break; default: // css not mapped, skip @@ -166,3 +166,31 @@ export function formatCssAsAnsi(style: string): string { return escapedSequence; } + +export const enum AnsiStyles { + Reset = '\x1b[0m', + Bold = '\x1b[1m', + Dim = '\x1b[2m', + Italic = '\x1b[3m', + Underline = '\x1b[4m', + Blink = '\x1b[5m', + Reverse = '\x1b[7m', + Hidden = '\x1b[8m', + Strikethrough = '\x1b[9m', + Black = '\x1b[30m', + Red = '\x1b[31m', + Green = '\x1b[32m', + Yellow = '\x1b[33m', + Blue = '\x1b[34m', + Magenta = '\x1b[35m', + Cyan = '\x1b[36m', + White = '\x1b[37m', + BrightBlack = '\x1b[30;1m', + BrightRed = '\x1b[31;1m', + BrightGreen = '\x1b[32;1m', + BrightYellow = '\x1b[33;1m', + BrightBlue = '\x1b[34;1m', + BrightMagenta = '\x1b[35;1m', + BrightCyan = '\x1b[36;1m', + BrightWhite = '\x1b[37;1m', +} diff --git a/src/adapter/objectPreview/index.ts b/src/adapter/objectPreview/index.ts index e0bb908ba..ba2083436 100644 --- a/src/adapter/objectPreview/index.ts +++ b/src/adapter/objectPreview/index.ts @@ -314,12 +314,12 @@ function appendKeyValue( if (key.length + separator.length > characterBudget) { return stringUtils.trimEnd(key, characterBudget); } - return `${key}${separator}${ + return escapeAnsiInString(`${key}${separator}${ stringUtils.trimMiddle( value, characterBudget - key.length - separator.length, ) - }`; // Keep in sync with characterBudget calculation. + }`); // Keep in sync with characterBudget calculation. } function renderPropertyPreview( @@ -341,6 +341,10 @@ function renderPropertyPreview( return appendKeyValue(name, ': ', prop.value ?? 'unknown', characterBudget); } +function escapeAnsiInString(value: string) { + return value.replaceAll('\x1b', '\\x1b'); +} + function quoteStringValue(value: string) { // Try a quote style that doesn't appear in the string, preferring/falling back to single quotes const quoteStyle = value.includes("'") @@ -368,7 +372,7 @@ function renderValue( quote = false; } const value = stringUtils.trimMiddle(stringValue, quote ? budget - 2 : budget); - return quote ? quoteStringValue(value) : value; + return escapeAnsiInString(quote ? quoteStringValue(value) : value); } if (object.type === 'undefined') { diff --git a/src/adapter/templates/getNodeChildren.ts b/src/adapter/templates/getNodeChildren.ts new file mode 100644 index 000000000..c7d1aa61c --- /dev/null +++ b/src/adapter/templates/getNodeChildren.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { remoteFunction } from '.'; + +/** + * Returns an object containing array property descriptors for the given + * range of array indices. + */ +export const getNodeChildren = remoteFunction(function( + this: Node, + start: number, + count: number, +) { + const result: Record = {}; + const from = start === -1 ? 0 : start; + const to = count === -1 ? this.childNodes.length : start + count; + for (let i = from; i < to && i < this.childNodes.length; ++i) { + const cn = this.childNodes[i]; + if (cn.nodeName === '#comment') { + result[i] = ``; + } else if (cn.nodeName === '#text') { + result[i] = cn.textContent || ''; + } else { + result[i] = cn; + } + } + + return result; +}); diff --git a/src/adapter/threads.ts b/src/adapter/threads.ts index 35ba41fdd..ed6e408a7 100644 --- a/src/adapter/threads.ts +++ b/src/adapter/threads.ts @@ -27,6 +27,7 @@ import { ITarget } from '../targets/targets'; import { IShutdownParticipants } from '../ui/shutdownParticipants'; import { BreakpointManager, EntryBreakpointMode } from './breakpoints'; import { UserDefinedBreakpoint } from './breakpoints/userDefinedBreakpoint'; +import { IClientCapabilies } from './clientCapabilities'; import { ICompletions } from './completions'; import { ExceptionMessage, IConsole, QueryObjectsMessage } from './console'; import { customBreakpoints } from './customBreakpoints'; @@ -221,12 +222,20 @@ export class Thread implements IVariableStoreLocationProvider { private readonly exceptionPause: IExceptionPauseService, private readonly _smartStepper: SmartStepper, private readonly shutdown: IShutdownParticipants, + clientCapabilities: IClientCapabilies, ) { this._dap = new DeferredContainer(dap); this._sourceContainer = sourceContainer; this._cdp = cdp; this.id = Thread._lastThreadId++; - this.replVariables = new VariableStore(renameProvider, this._cdp, dap, launchConfig, this); + this.replVariables = new VariableStore( + renameProvider, + this._cdp, + dap, + launchConfig, + clientCapabilities, + this, + ); sourceContainer.onSourceMappedSteppingChange(() => this.refreshStackTrace()); this._initialize(); } diff --git a/src/adapter/variableStore.ts b/src/adapter/variableStore.ts index 246c43198..9450bc93e 100644 --- a/src/adapter/variableStore.ts +++ b/src/adapter/variableStore.ts @@ -15,7 +15,9 @@ import Dap from '../dap/api'; import { IDapApi } from '../dap/connection'; import * as errors from '../dap/errors'; import { ProtocolError } from '../dap/protocolError'; +import { ClientCapabilities, IClientCapabilies } from './clientCapabilities'; import { IWasmVariable, IWasmVariableEvaluation, WasmScope } from './dwarf/wasmSymbolProvider'; +import { AnsiStyles } from './messageFormat'; import * as objectPreview from './objectPreview'; import { MapPreview, SetPreview } from './objectPreview/betterTypes'; import { PreviewContextType } from './objectPreview/contexts'; @@ -23,6 +25,7 @@ import { StackFrame, StackTrace } from './stackTrace'; import { getSourceSuffix, RemoteException, RemoteObjectId } from './templates'; import { getArrayProperties } from './templates/getArrayProperties'; import { getArraySlots } from './templates/getArraySlots'; +import { getNodeChildren } from './templates/getNodeChildren'; import { getDescriptionSymbols, getStringyProps, @@ -204,6 +207,7 @@ class VariableContext { public readonly locationProvider: IVariableStoreLocationProvider, private readonly currentRef: undefined | (() => IVariable | Scope), private readonly settings: IContextSettings, + public readonly clientCapabilities: IClientCapabilies, ) { this.name = ctx.name; this.presentationHint = ctx.presentationHint; @@ -247,6 +251,7 @@ class VariableContext { this.locationProvider, () => v, this.settings, + this.clientCapabilities, ), ...rest, ) as InstanceType; @@ -272,6 +277,8 @@ class VariableContext { return this.createVariable(FunctionVariable, ctx, object, customStringRepr); } else if (object.subtype === 'map' || object.subtype === 'set') { return this.createVariable(SetOrMapVariable, ctx, object, customStringRepr); + } else if (object.subtype === 'node') { + return this.createVariable(NodeVariable, ctx, object, customStringRepr); } else if (!objectPreview.subtypesWithoutPreview.has(object.subtype)) { return this.createVariable(ObjectVariable, ctx, object, customStringRepr); } @@ -851,6 +858,118 @@ class ObjectVariable extends Variable implements IMemoryReadable { } } +class NodeAttributes extends ObjectVariable { + public readonly id = getVariableId(); + + override get accessor(): string { + return (this.context.parent as NodeVariable).accessor; + } + + public override async toDap( + context: PreviewContextType, + valueFormat?: Dap.ValueFormat, + ): Promise { + return Promise.resolve({ + ...await super.toDap(context, valueFormat), + value: '...', + }); + } +} + +class NodeVariable extends Variable { + public override async toDap( + previewContext: PreviewContextType, + valueFormat?: Dap.ValueFormat, + ): Promise { + const description = await this.description(); + const length = description?.node?.childNodeCount || 0; + return { + ...await super.toDap(previewContext, valueFormat), + value: await this.getValuePreview(previewContext), + variablesReference: this.id, + indexedVariables: length > 100 ? length : undefined, + namedVariables: length > 100 ? 1 : undefined, + }; + } + + public override async getChildren(params?: Dap.VariablesParamsExtended): Promise { + switch (params?.filter) { + case 'indexed': + return this.getNodeChildren(params.start, params.count); + case 'named': + return [this.getAttributesVar()]; + default: + return [this.getAttributesVar(), ...(await this.getNodeChildren())]; + } + } + + private getAttributesVar() { + return this.context.createVariable( + NodeAttributes, + { + name: l10n.t('Node Attributes'), + presentationHint: { visibility: 'internal' }, + sortOrder: Number.MAX_SAFE_INTEGER, + }, + this.remoteObject, + undefined, + ); + } + + private async getNodeChildren(start = -1, count = -1) { + let slotsObject: Cdp.Runtime.RemoteObject; + try { + slotsObject = await getNodeChildren({ + cdp: this.context.cdp, + generatePreview: false, + args: [start, count], + objectId: this.remoteObject.objectId, + }); + } catch (e) { + return []; + } + + const result = await this.context.createObjectPropertyVars(slotsObject); + if (slotsObject.objectId) { + await this.context.cdp.Runtime.releaseObject({ objectId: slotsObject.objectId }); + } + + return result; + } + + private readonly description = once(() => + this.context.cdp.DOM.describeNode({ + objectId: this.remoteObject.objectId, + }) + ); + + private async getValuePreview(_previewContext: PreviewContextType) { + const description = await this.description(); + if (!description?.node) { + return ''; + } + + const { localName, attributes, childNodeCount } = description.node; + const styleCheck = this.context.clientCapabilities.value?.supportsANSIStyling ? true : ''; + let str = (styleCheck && AnsiStyles.Blue) + `<${localName}`; + if (attributes) { + for (let i = 0; i < attributes.length; i += 2) { + const key = attributes[i]; + const value = attributes[i + 1]; + str += ` ${(styleCheck && AnsiStyles.BrightBlue)}${key}${(styleCheck + && AnsiStyles.Dim)}=${(styleCheck && AnsiStyles.Yellow)}${JSON.stringify(value)}`; + } + } + str += (styleCheck && AnsiStyles.Blue) + '>'; + if (childNodeCount) { + str += `${(styleCheck && AnsiStyles.Dim)}...${(styleCheck && AnsiStyles.Blue)}`; + } + str += `${(styleCheck && AnsiStyles.Reset)}`; + + return str; + } +} + class FunctionVariable extends ObjectVariable { private readonly baseChildren = once(() => super.getChildren({ variablesReference: this.id })); @@ -1272,6 +1391,7 @@ export class VariableStore { @inject(ICdpApi) private readonly cdp: Cdp.Api, @inject(IDapApi) private readonly dap: Dap.Api, @inject(AnyLaunchConfiguration) private readonly launchConfig: AnyLaunchConfiguration, + @inject(ClientCapabilities) private readonly clientCapabilities: ClientCapabilities, private readonly locationProvider: IVariableStoreLocationProvider, ) { this.contextSettings = { @@ -1293,6 +1413,7 @@ export class VariableStore { this.cdp, this.dap, this.launchConfig, + this.clientCapabilities, this.locationProvider, ); } @@ -1345,6 +1466,7 @@ export class VariableStore { this.locationProvider, () => scope, this.contextSettings, + this.clientCapabilities, ), scopeRef, extraProperties, @@ -1371,6 +1493,7 @@ export class VariableStore { this.locationProvider, () => scope, this.contextSettings, + this.clientCapabilities, ), kind, variables, @@ -1497,6 +1620,7 @@ export class VariableStore { this.locationProvider, undefined, this.contextSettings, + this.clientCapabilities, ); } } diff --git a/src/common/disposable.ts b/src/common/disposable.ts index ef89a75ad..8be976376 100644 --- a/src/common/disposable.ts +++ b/src/common/disposable.ts @@ -38,7 +38,7 @@ export class RefCounter { public dispose() { if (!this.disposed) { this.disposed = true; - this.value.dispose; + this.value.dispose(); } } } diff --git a/src/dap/api.d.ts b/src/dap/api.d.ts index d35476bc9..8d7557e55 100644 --- a/src/dap/api.d.ts +++ b/src/dap/api.d.ts @@ -2611,6 +2611,11 @@ export namespace Dap { * Client supports the `startDebugging` request. */ supportsStartDebuggingRequest?: boolean; + + /** + * The client will interpret ANSI escape sequences in the display of `OutputEvent.output` and `Variable.value` fields when `Capabilities.supportsANSIStyling` is also enabled. + */ + supportsANSIStyling?: boolean; } export interface InitializeResult { @@ -2820,6 +2825,11 @@ export namespace Dap { * Clients may present the first applicable mode in this array as the 'default' mode in gestures that set breakpoints. */ breakpointModes?: (BreakpointMode)[]; + + /** + * The debug adapter supports ANSI escape sequences in styling of `OutputEvent.output` and `Variable.value` fields. + */ + supportsANSIStyling?: boolean; } export interface InitializedEventParams { @@ -3092,6 +3102,10 @@ export namespace Dap { /** * The output to report. + * + * ANSI escape sequences may be used to inflience text color and styling if `supportsANSIStyling` is present in both the adapter's `Capabilities` and the client's `InitializeRequestArguments`. A client may strip any unrecognized ANSI sequences. + * + * If the `supportsANSIStyling` capabilities are not both true, then the client should display the output literally. */ output: string; @@ -5406,6 +5420,11 @@ export namespace Dap { * Clients may present the first applicable mode in this array as the 'default' mode in gestures that set breakpoints. */ breakpointModes?: (BreakpointMode)[]; + + /** + * The debug adapter supports ANSI escape sequences in styling of `OutputEvent.output` and `Variable.value` fields. + */ + supportsANSIStyling?: boolean; } /** diff --git a/src/ioc.ts b/src/ioc.ts index 1b54bbb01..3d38eefa7 100644 --- a/src/ioc.ts +++ b/src/ioc.ts @@ -27,6 +27,7 @@ import { } from './adapter/breakpoints/conditions'; import { LogPointCompiler } from './adapter/breakpoints/conditions/logPoint'; import { CdpProxyProvider, ICdpProxyProvider } from './adapter/cdpProxy'; +import { ClientCapabilities, IClientCapabilies } from './adapter/clientCapabilities'; import { Completions, ICompletions } from './adapter/completions'; import { IConsole } from './adapter/console'; import { Console } from './adapter/console/console'; @@ -163,6 +164,7 @@ export const createTargetContainer = ( container.bind(ITarget).toConstantValue(target); container.bind(ITargetOrigin).toConstantValue(target.targetOrigin()); container.bind(IResourceProvider).to(StatefulResourceProvider).inSingletonScope(); + container.bind(IClientCapabilies).to(ClientCapabilities).inSingletonScope(); container.bind(ISourceMapFactory).to(SourceMapFactory).inSingletonScope(); container.bind(IBreakpointConditionFactory).to(BreakpointConditionFactory).inSingletonScope(); container.bind(LogPointCompiler).toSelf().inSingletonScope(); diff --git a/src/test/console/console-format-nodes.txt b/src/test/console/console-format-nodes.txt new file mode 100644 index 000000000..8fb54b146 --- /dev/null +++ b/src/test/console/console-format-nodes.txt @@ -0,0 +1,7 @@ +> result: 
...
 + 0: '\n Content\n ' + > 1: ...

 + 0: 'Paragaph' + 2: '\n More content\n ' + > 3:  + 4: '\n ' diff --git a/src/test/console/console-format-popular-types.txt b/src/test/console/console-format-popular-types.txt index 4a5724e54..c457f41cb 100644 --- a/src/test/console/console-format-popular-types.txt +++ b/src/test/console/console-format-popular-types.txt @@ -318,6 +318,14 @@ Evaluating: 'console.log([true])' stdout> (1) [true] stdout> > (1) [true] +Evaluating: 'console.log(node)' +stdout> p#p +stdout> > 

 + +Evaluating: 'console.log([node])' +stdout> (1) [p#p] +stdout> > (1) [p#p] + Evaluating: 'console.log(new Boolean(true))' stdout> Boolean (true) stdout> > Boolean (true) diff --git a/src/test/console/consoleFormatTest.ts b/src/test/console/consoleFormatTest.ts index a8335c08f..ea94f1ea5 100644 --- a/src/test/console/consoleFormatTest.ts +++ b/src/test/console/consoleFormatTest.ts @@ -151,6 +151,7 @@ describe('console format', () => { 'boxedStringWithProps', 'false', 'true', + 'node', 'new Boolean(true)', 'new Set([1, 2, 3, 4])', 'new Set([1, 2, 3, 4, 5, 6, 7, 8])', @@ -461,6 +462,23 @@ describe('console format', () => { p.assertLog(); }); + itIntegrates('nodes', async ({ r }) => { + const p = await r.launchAndLoad(` +
+ Content +

Paragaph

+ More content +
+
+ `); + + await p.logger.evaluateAndLog('document.getElementById("main")', { + depth: 3, + omitProperties: ['Node Attributes', '[[Prototype]]'], + }); + p.assertLog(); + }); + itIntegrates('error traces in source maps', async ({ r }) => { const handle = await r.launchUrlAndLoad('browserify/browserify.html'); await handle.logger.evaluateAndLog(['try { throwError() } catch (e) { console.error(e) }']); diff --git a/src/test/evaluate/evaluate-default.txt b/src/test/evaluate/evaluate-default.txt index 3ed493cf4..1dd05b9dc 100644 --- a/src/test/evaluate/evaluate-default.txt +++ b/src/test/evaluate/evaluate-default.txt @@ -14,6 +14,8 @@ result: 3 : Uncaught ReferenceError: baz is not defined +result: '\x1b[2m' + > result: Uint8Array(3) [1, 2, 3, buffer: ArrayBuffer(3), byteLength: 3, byteOffset: 0, length: 3, Symbol(Symbol.toStringTag): 'Uint8Array'] 0: 1 1: 2 diff --git a/src/test/evaluate/evaluate.ts b/src/test/evaluate/evaluate.ts index 716eaf8e1..4b34feae5 100644 --- a/src/test/evaluate/evaluate.ts +++ b/src/test/evaluate/evaluate.ts @@ -35,6 +35,9 @@ describe('evaluate', () => { await p.logger.evaluateAndLog(`baz();`); p.log(''); + await p.logger.evaluateAndLog(`'\\x1b[2m'`); + p.log(''); + await p.logger.evaluateAndLog(`new Uint8Array([1, 2, 3]);`); p.log(''); diff --git a/src/test/infra/infra-initialize.txt b/src/test/infra/infra-initialize.txt index 1e0049d3e..6720da753 100644 --- a/src/test/infra/infra-initialize.txt +++ b/src/test/infra/infra-initialize.txt @@ -28,6 +28,7 @@ supportTerminateDebuggee : true supportedChecksumAlgorithms : [ ] + supportsANSIStyling : true supportsBreakpointLocationsRequest : true supportsClipboardContext : true supportsCompletionsRequest : true diff --git a/src/test/logger.ts b/src/test/logger.ts index a8effcb60..a5c0fd796 100644 --- a/src/test/logger.ts +++ b/src/test/logger.ts @@ -10,6 +10,7 @@ interface ILogOptions { format?: Dap.ValueFormat; params?: Partial; logInternalInfo?: boolean; + omitProperties?: string[]; } const kOmitProperties = ['[[ArrayBufferData]]']; @@ -93,8 +94,10 @@ export class Logger { this._dap, rootVariable, (variable, depth) => { - if (kOmitProperties.includes(variable.name)) { - return depth < (options.depth ?? 1); + if ( + kOmitProperties.includes(variable.name) || options.omitProperties?.includes(variable.name) + ) { + return false; } const name = variable.name ? `${variable.name}: ` : ''; diff --git a/src/test/test.ts b/src/test/test.ts index e250c43a0..d31cc6abd 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -111,6 +111,7 @@ class Session { columnsStartAt1: true, pathFormat: 'path', supportsVariablePaging: true, + supportsANSIStyling: true, }), this.dap.once('initialized'), ]); diff --git a/src/test/variables/variables-web-tags.txt b/src/test/variables/variables-web-tags.txt index 5d0fd7ebf..d0a6ed6d2 100644 --- a/src/test/variables/variables-web-tags.txt +++ b/src/test/variables/variables-web-tags.txt @@ -1,7 +1,7 @@ > result: HTMLCollection(2) [meta, title, foo: meta] - > 0: meta - > 1: title + > 0:  + > 1: ... > length: (...) - > foo: meta + > foo:  > [[Prototype]]: HTMLCollection > [[Prototype]]: Object