debug: allow editing visualizers in debug tree (#205163)

* debug: allow editing visualizers in debug tree

* fix visibility lint
This commit is contained in:
Connor Peet 2024-02-13 16:18:29 -08:00 committed by GitHub
parent 702a1ffd58
commit 7f359bb293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 135 additions and 120 deletions

View file

@ -10,17 +10,18 @@ import { HighlightedLabel, IHighlight } from 'vs/base/browser/ui/highlightedlabe
import { IInputValidationOptions, InputBox } from 'vs/base/browser/ui/inputbox/inputBox';
import { IAsyncDataSource, ITreeNode, ITreeRenderer } from 'vs/base/browser/ui/tree/tree';
import { Codicon } from 'vs/base/common/codicons';
import { ThemeIcon } from 'vs/base/common/themables';
import { createMatches, FuzzyScore } from 'vs/base/common/filters';
import { FuzzyScore, createMatches } from 'vs/base/common/filters';
import { createSingleCallFunction } from 'vs/base/common/functional';
import { KeyCode } from 'vs/base/common/keyCodes';
import { DisposableStore, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { DisposableStore, IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle';
import { ThemeIcon } from 'vs/base/common/themables';
import { localize } from 'vs/nls';
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
import { defaultInputBoxStyles } from 'vs/platform/theme/browser/defaultStyles';
import { LinkDetector } from 'vs/workbench/contrib/debug/browser/linkDetector';
import { IDebugService, IExpression, IExpressionValue } from 'vs/workbench/contrib/debug/common/debug';
import { Expression, ExpressionContainer, Variable } from 'vs/workbench/contrib/debug/common/debugModel';
import { IDebugVisualizerService } from 'vs/workbench/contrib/debug/common/debugVisualizers';
import { ReplEvaluationResult } from 'vs/workbench/contrib/debug/common/replModel';
const MAX_VALUE_RENDER_LENGTH_IN_VIEWLET = 1024;
@ -146,24 +147,31 @@ export interface IExpressionTemplateData {
}
export abstract class AbstractExpressionDataSource<Input, Element extends IExpression> implements IAsyncDataSource<Input, Element> {
constructor(@IDebugService protected debugService: IDebugService) { }
constructor(
@IDebugService protected debugService: IDebugService,
@IDebugVisualizerService protected debugVisualizer: IDebugVisualizerService,
) { }
public abstract hasChildren(element: Input | Element): boolean;
public getChildren(element: Input | Element): Promise<Element[]> {
public async getChildren(element: Input | Element): Promise<Element[]> {
const vm = this.debugService.getViewModel();
return this.doGetChildren(element).then(r => {
let dup: Element[] | undefined;
for (let i = 0; i < r.length; i++) {
const visualized = vm.getVisualizedExpression(r[i] as IExpression);
if (visualized) {
dup ||= r.slice();
dup[i] = visualized as Element;
const children = await this.doGetChildren(element);
return Promise.all(children.map(async r => {
const vizOrTree = vm.getVisualizedExpression(r as IExpression);
if (typeof vizOrTree === 'string') {
const viz = await this.debugVisualizer.getVisualizedNodeFor(vizOrTree, r);
if (viz) {
vm.setVisualizedExpression(r, viz);
return viz as IExpression as Element;
}
} else if (vizOrTree) {
return vizOrTree as Element;
}
return dup || r;
});
return r;
}));
}
protected abstract doGetChildren(element: Input | Element): Promise<Element[]>;

View file

@ -127,7 +127,7 @@ export class DebugHoverWidget implements IContentWidget {
this.treeContainer.setAttribute('role', 'tree');
const tip = dom.append(this.complexValueContainer, $('.tip'));
tip.textContent = nls.localize({ key: 'quickTip', comment: ['"switch to editor language hover" means to show the programming language hover widget instead of the debug hover'] }, 'Hold {0} key to switch to editor language hover', isMacintosh ? 'Option' : 'Alt');
const dataSource = new DebugHoverDataSource(this.debugService);
const dataSource = this.instantiationService.createInstance(DebugHoverDataSource);
const linkeDetector = this.instantiationService.createInstance(LinkDetector);
this.tree = <WorkbenchAsyncDataTree<IExpression, IExpression, any>>this.instantiationService.createInstance(WorkbenchAsyncDataTree, 'DebugHover', this.treeContainer, new DebugHoverDelegate(), [
this.instantiationService.createInstance(VariablesRenderer, linkeDetector),
@ -414,7 +414,7 @@ class DebugHoverDataSource extends AbstractExpressionDataSource<IExpression, IEx
return element.hasChildren;
}
public override doGetChildren(element: IExpression): Promise<IExpression[]> {
protected override doGetChildren(element: IExpression): Promise<IExpression[]> {
return element.getChildren();
}
}

View file

@ -126,7 +126,7 @@ export class VariablesView extends ViewPane {
new ScopesRenderer(),
new ScopeErrorRenderer(),
],
new VariablesDataSource(this.debugService), {
this.instantiationService.createInstance(VariablesDataSource), {
accessibilityProvider: new VariablesAccessibilityProvider(),
identityProvider: { getId: (element: IExpression | IScope) => element.getId() },
keyboardNavigationLabelProvider: { getKeyboardNavigationLabel: (e: IExpression | IScope) => e.name },
@ -171,7 +171,7 @@ export class VariablesView extends ViewPane {
let horizontalScrolling: boolean | undefined;
this._register(this.debugService.getViewModel().onDidSelectExpression(e => {
const variable = e?.expression;
if (variable instanceof Variable && !e?.settingWatch) {
if (variable && this.tree.hasNode(variable)) {
horizontalScrolling = this.tree.options.horizontalScrolling;
if (horizontalScrolling) {
this.tree.updateOptions({ horizontalScrolling: false });
@ -210,12 +210,24 @@ export class VariablesView extends ViewPane {
}
private onMouseDblClick(e: ITreeMouseEvent<IExpression | IScope>): void {
const session = this.debugService.getViewModel().focusedSession;
if (session && e.element instanceof Variable && session.capabilities.supportsSetVariable && !e.element.presentationHint?.attributes?.includes('readOnly') && !e.element.presentationHint?.lazy) {
if (this.canSetExpressionValue(e.element)) {
this.debugService.getViewModel().setSelectedExpression(e.element, false);
}
}
private canSetExpressionValue(e: IExpression | IScope | null): e is IExpression {
const session = this.debugService.getViewModel().focusedSession;
if (!session) {
return false;
}
if (e instanceof VisualizedExpression) {
return !!e.treeItem.canEdit;
}
return e instanceof Variable && !e.presentationHint?.attributes?.includes('readOnly') && !e.presentationHint?.lazy;
}
private async onContextMenu(e: ITreeContextMenuEvent<IExpression | IScope>): Promise<void> {
const variable = e.element;
if (!(variable instanceof Variable) || !variable.value) {
@ -415,7 +427,7 @@ export class VisualizedVariableRenderer extends AbstractExpressionsRenderer {
*/
public static rendererOnVisualizationRange(model: IViewModel, tree: AsyncDataTree<any, any, any>): IDisposable {
return model.onDidChangeVisualization(({ original }) => {
if (!tree.hasElement(original)) {
if (!tree.hasNode(original)) {
return;
}
@ -461,24 +473,21 @@ export class VisualizedVariableRenderer extends AbstractExpressionsRenderer {
}
protected override getInputBoxOptions(expression: IExpression): IInputBoxOptions | undefined {
const variable = <Variable>expression;
const viz = <VisualizedExpression>expression;
return {
initialValue: expression.value,
ariaLabel: localize('variableValueAriaLabel', "Type new variable value"),
validationOptions: {
validation: () => variable.errorMessage ? ({ content: variable.errorMessage }) : null
validation: () => viz.errorMessage ? ({ content: viz.errorMessage }) : null
},
onFinish: (value: string, success: boolean) => {
variable.errorMessage = undefined;
const focusedStackFrame = this.debugService.getViewModel().focusedStackFrame;
if (success && variable.value !== value && focusedStackFrame) {
variable.setVariable(value, focusedStackFrame)
// Need to force watch expressions and variables to update since a variable change can have an effect on both
.then(() => {
// Do not refresh scopes due to a node limitation #15520
forgetScopes = false;
this.debugService.getViewModel().updateViews();
});
viz.errorMessage = undefined;
if (success) {
viz.edit(value).then(() => {
// Do not refresh scopes due to a node limitation #15520
forgetScopes = false;
this.debugService.getViewModel().updateViews();
});
}
}
};
@ -494,7 +503,10 @@ export class VisualizedVariableRenderer extends AbstractExpressionsRenderer {
createAndFillInContextMenuActions(menu, { arg: context, shouldForwardArgs: false }, { primary, secondary: [] }, 'inline');
if (viz.original) {
primary.push(new Action('debugViz', localize('removeVisualizer', 'Remove Visualizer'), ThemeIcon.asClassName(Codicon.close), undefined, () => this.debugService.getViewModel().setVisualizedExpression(viz.original!, undefined)));
const action = new Action('debugViz', localize('removeVisualizer', 'Remove Visualizer'), ThemeIcon.asClassName(Codicon.eye), true, () => this.debugService.getViewModel().setVisualizedExpression(viz.original!, undefined));
action.checked = true;
primary.push(action);
actionBar.domNode.style.display = 'initial';
}
actionBar.clear();
actionBar.context = context;
@ -601,7 +613,7 @@ export class VariablesRenderer extends AbstractExpressionsRenderer {
if (resolved.type === DebugVisualizationType.Command) {
viz.execute();
} else {
const replacement = await this.visualization.setVisualizedNodeFor(resolved.id, expression);
const replacement = await this.visualization.getVisualizedNodeFor(resolved.id, expression);
if (replacement) {
this.debugService.getViewModel().setVisualizedExpression(expression, replacement);
}

View file

@ -93,7 +93,7 @@ export class WatchExpressionsView extends ViewPane {
this.instantiationService.createInstance(VariablesRenderer, linkDetector),
this.instantiationService.createInstance(VisualizedVariableRenderer, linkDetector),
],
new WatchExpressionsDataSource(this.debugService), {
this.instantiationService.createInstance(WatchExpressionsDataSource), {
accessibilityProvider: new WatchExpressionsAccessibilityProvider(),
identityProvider: { getId: (element: IExpression) => element.getId() },
keyboardNavigationLabelProvider: {
@ -157,7 +157,7 @@ export class WatchExpressionsView extends ViewPane {
let horizontalScrolling: boolean | undefined;
this._register(this.debugService.getViewModel().onDidSelectExpression(e => {
const expression = e?.expression;
if (expression instanceof Expression || (expression instanceof Variable && e?.settingWatch)) {
if (expression && this.tree.hasElement(expression)) {
horizontalScrolling = this.tree.options.horizontalScrolling;
if (horizontalScrolling) {
this.tree.updateOptions({ horizontalScrolling: false });
@ -204,7 +204,7 @@ export class WatchExpressionsView extends ViewPane {
const element = e.element;
// double click on primitive value: open input box to be able to select and copy value.
const selectedExpression = this.debugService.getViewModel().getSelectedExpression();
if (element instanceof Expression && element !== selectedExpression?.expression) {
if ((element instanceof Expression && element !== selectedExpression?.expression) || (element instanceof VisualizedExpression && element.treeItem.canEdit)) {
this.debugService.getViewModel().setSelectedExpression(element, false);
} else if (!element) {
// Double click in watch panel triggers to add a new watch expression
@ -259,7 +259,7 @@ class WatchExpressionsDataSource extends AbstractExpressionDataSource<IDebugServ
return isDebugService(element) || element.hasChildren;
}
public override doGetChildren(element: IDebugService | IExpression): Promise<Array<IExpression>> {
protected override doGetChildren(element: IDebugService | IExpression): Promise<Array<IExpression>> {
if (isDebugService(element)) {
const debugService = element as IDebugService;
const watchExpressions = debugService.getModel().getWatchExpressions();

View file

@ -634,8 +634,9 @@ export interface IViewModel extends ITreeElement {
*/
readonly focusedStackFrame: IStackFrame | undefined;
setVisualizedExpression(original: IExpression, visualized: IExpression | undefined): void;
getVisualizedExpression(expression: IExpression): IExpression | undefined;
setVisualizedExpression(original: IExpression, visualized: IExpression & { treeId: string } | undefined): void;
/** Returns the visualized expression if loaded, or a tree it should be visualized with, or undefined */
getVisualizedExpression(expression: IExpression): IExpression | string | undefined;
getSelectedExpression(): { expression: IExpression; settingWatch: boolean } | undefined;
setSelectedExpression(expression: IExpression | undefined, settingWatch: boolean): void;
updateViews(): void;
@ -1265,7 +1266,7 @@ export interface IReplOptions {
export interface IDebugVisualizationContext {
variable: DebugProtocol.Variable;
containerId?: string;
containerId?: number;
frameId?: number;
threadId: number;
sessionId: string;

View file

@ -246,9 +246,7 @@ function handleSetResponse(expression: ExpressionContainer, response: DebugProto
}
export class VisualizedExpression implements IExpression {
public readonly name: string;
public readonly hasChildren: boolean;
public readonly value: string;
public errorMessage?: string;
private readonly id = generateUuid();
evaluateLazy(): Promise<void> {
@ -262,15 +260,34 @@ export class VisualizedExpression implements IExpression {
return this.id;
}
get name() {
return this.treeItem.label;
}
get value() {
return this.treeItem.description || '';
}
get hasChildren() {
return this.treeItem.collapsibleState !== DebugTreeItemCollapsibleState.None;
}
constructor(
private readonly visualizer: IDebugVisualizerService,
private readonly treeId: string,
public readonly treeId: string,
public readonly treeItem: IDebugVisualizationTreeItem,
public readonly original?: Variable,
) {
this.name = treeItem.label;
this.hasChildren = treeItem.collapsibleState !== DebugTreeItemCollapsibleState.None;
this.value = treeItem.description || '';
) { }
/** Edits the value, sets the {@link errorMessage} and returns false if unsuccessful */
public async edit(newValue: string) {
try {
await this.visualizer.editTreeItem(this.treeId, this.treeItem, newValue);
return true;
} catch (e) {
this.errorMessage = e.message;
return false;
}
}
}

View file

@ -24,6 +24,7 @@ export class ViewModel implements IViewModel {
private readonly _onWillUpdateViews = new Emitter<void>();
private readonly _onDidChangeVisualization = new Emitter<{ original: IExpression; replacement: IExpression }>();
private readonly visualized = new WeakMap<IExpression, IExpression>();
private readonly preferredVisualizers = new Map</** cache key */ string, /* tree ID */ string>();
private expressionSelectedContextKey!: IContextKey<boolean>;
private loadedScriptsSupportedContextKey!: IContextKey<boolean>;
private stepBackSupportedContextKey!: IContextKey<boolean>;
@ -165,23 +166,33 @@ export class ViewModel implements IViewModel {
this.multiSessionDebug.set(isMultiSessionView);
}
setVisualizedExpression(original: IExpression, visualized: IExpression | undefined): void {
setVisualizedExpression(original: IExpression, visualized: IExpression & { treeId: string } | undefined): void {
const current = this.visualized.get(original) || original;
const key = this.getPreferredVisualizedKey(original);
if (visualized) {
this.visualized.set(original, visualized);
this.preferredVisualizers.set(key, visualized.treeId);
} else {
this.visualized.delete(original);
this.preferredVisualizers.delete(key);
}
this._onDidChangeVisualization.fire({ original: current, replacement: visualized || original });
}
getVisualizedExpression(expression: IExpression): IExpression | undefined {
return this.visualized.get(expression);
getVisualizedExpression(expression: IExpression): IExpression | string | undefined {
return this.visualized.get(expression) || this.preferredVisualizers.get(this.getPreferredVisualizedKey(expression));
}
async evaluateLazyExpression(expression: IExpressionContainer): Promise<void> {
await expression.evaluateLazy();
this._onDidEvaluateLazyExpression.fire(expression);
}
private getPreferredVisualizedKey(expr: IExpression) {
return JSON.stringify([
expr.name,
expr.type,
!!expr.memoryReference,
].join('\0'));
}
}

View file

@ -79,18 +79,17 @@ export interface IDebugVisualizerService {
/**
* Sets that a certa tree should be used for the visualized node
*/
setVisualizedNodeFor(treeId: string, expr: IExpression): Promise<IExpression | undefined>;
/**
* Gets a visualized node for the given expression if the user has preferred
* to visualize it that way.
*/
getVisualizedNodeFor(expr: IExpression): Promise<IExpression | undefined>;
getVisualizedNodeFor(treeId: string, expr: IExpression): Promise<VisualizedExpression | undefined>;
/**
* Gets children for a visualized tree node.
*/
getVisualizedChildren(treeId: string, treeElementId: number): Promise<IExpression[]>;
/**
* Gets children for a visualized tree node.
*/
editTreeItem(treeId: string, item: IDebugVisualizationTreeItem, newValue: string): Promise<void>;
}
const emptyRef: IReference<DebugVisualizer[]> = { object: [], dispose: () => { } };
@ -101,7 +100,6 @@ export class DebugVisualizerService implements IDebugVisualizerService {
private readonly handles = new Map</* extId + \0 + vizId */ string, VisualizerHandle>();
private readonly trees = new Map</* extId + \0 + treeId */ string, VisualizerTreeHandle>();
private readonly didActivate = new Map<string, Promise<void>>();
private readonly preferredTrees = new Map<string, /* key of trees */ string>();
private registrations: { expr: ContextKeyExpression; id: string; extensionId: ExtensionIdentifier }[] = [];
constructor(
@ -126,29 +124,7 @@ export class DebugVisualizerService implements IDebugVisualizerService {
return emptyRef;
}
const context: IDebugVisualizationContext = {
sessionId: variable.getSession()?.getId() || '',
containerId: variable.parent.getId(),
threadId,
variable: {
name: variable.name,
value: variable.value,
type: variable.type,
evaluateName: variable.evaluateName,
variablesReference: variable.reference || 0,
indexedVariables: variable.indexedVariables,
memoryReference: variable.memoryReference,
namedVariables: variable.namedVariables,
presentationHint: variable.presentationHint,
}
};
for (let p: IExpressionContainer = variable; p instanceof Variable; p = p.parent) {
if (p.parent instanceof Scope) {
context.frameId = p.parent.stackFrame.frameId;
}
}
const context = this.getVariableContext(threadId, variable);
const overlay = getContextForVariable(this.contextKeyService, variable, [
[CONTEXT_VARIABLE_NAME.key, variable.name],
[CONTEXT_VARIABLE_VALUE.key, variable.value],
@ -205,22 +181,7 @@ export class DebugVisualizerService implements IDebugVisualizerService {
}
/** @inheritdoc */
public async setVisualizedNodeFor(treeId: string, expr: IExpression): Promise<IExpression | undefined> {
return this.getOrSetNodeFor(expr, treeId);
}
/** @inheritdoc */
public async getVisualizedNodeFor(expr: IExpression): Promise<IExpression | undefined> {
return this.getOrSetNodeFor(expr);
}
/** @inheritdoc */
public async getVisualizedChildren(treeId: string, treeElementId: number): Promise<IExpression[]> {
const children = await this.trees.get(treeId)?.getChildren(treeElementId) || [];
return children.map(c => new VisualizedExpression(this, treeId, c, undefined));
}
private async getOrSetNodeFor(expr: IExpression, useTreeKey?: string): Promise<IExpression | undefined> {
public async getVisualizedNodeFor(treeId: string, expr: IExpression): Promise<VisualizedExpression | undefined> {
if (!(expr instanceof Variable)) {
return;
}
@ -230,37 +191,42 @@ export class DebugVisualizerService implements IDebugVisualizerService {
return;
}
const exprPreferKey = useTreeKey || this.getPreferredTreeKey(expr);
const tree = exprPreferKey && this.trees.get(exprPreferKey);
const tree = this.trees.get(treeId);
if (!tree) {
return;
}
const treeItem = await tree.getTreeItem(this.getVariableContext(threadId, expr));
if (!treeItem) {
try {
const treeItem = await tree.getTreeItem(this.getVariableContext(threadId, expr));
if (!treeItem) {
return;
}
return new VisualizedExpression(this, treeId, treeItem, expr);
} catch (e) {
this.logService.warn('Failed to get visualized node', e);
return;
}
if (useTreeKey) {
this.preferredTrees.set(exprPreferKey, exprPreferKey);
}
return new VisualizedExpression(this, exprPreferKey, treeItem, expr);
}
private getPreferredTreeKey(expr: Variable) {
return JSON.stringify([
expr.name,
expr.value,
expr.type,
!!expr.memoryReference,
].join('\0'));
/** @inheritdoc */
public async getVisualizedChildren(treeId: string, treeElementId: number): Promise<IExpression[]> {
const children = await this.trees.get(treeId)?.getChildren(treeElementId) || [];
return children.map(c => new VisualizedExpression(this, treeId, c, undefined));
}
/** @inheritdoc */
public async editTreeItem(treeId: string, treeItem: IDebugVisualizationTreeItem, newValue: string): Promise<void> {
const newItem = await this.trees.get(treeId)?.editItem?.(treeItem.id, newValue);
if (newItem) {
Object.assign(treeItem, newItem); // replace in-place so rerenders work
}
}
private getVariableContext(threadId: number, variable: Variable) {
const context: IDebugVisualizationContext = {
sessionId: variable.getSession()?.getId() || '',
containerId: variable.parent.getId(),
containerId: (variable.parent instanceof Variable ? variable.reference : undefined),
threadId,
variable: {
name: variable.name,

View file

@ -150,7 +150,7 @@ declare module 'vscode' {
* that came from user evaluations in the Debug Console.
* @see https://microsoft.github.io/debug-adapter-protocol/specification#Types_Variable
*/
containerId?: string;
containerId?: number;
/**
* The ID of the Debug Adapter Protocol StackFrame in which the variable was found,
* for variables that came from scopes in a stack frame.