Skip to content

Commit 51e4430

Browse files
authored
testing: call stack tpi refinements (microsoft#226981)
Fixes microsoft#226855 - ctrl+click on words in files takes you to the source Fixes microsoft#226863 - clicking file titles should toggle collapse Fixes microsoft#226857 - add 'collapse all' button in peek Fixes microsoft#226852 - name 'go to source' actions better
1 parent 2e4a043 commit 51e4430

File tree

9 files changed

+188
-37
lines changed

9 files changed

+188
-37
lines changed

src/vs/workbench/contrib/debug/browser/callStackWidget.ts

Lines changed: 101 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,24 @@ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cance
1212
import { Codicon } from 'vs/base/common/codicons';
1313
import { Emitter, Event } from 'vs/base/common/event';
1414
import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle';
15-
import { autorun, autorunWithStore, derived, IObservable, ISettableObservable, observableValue } from 'vs/base/common/observable';
15+
import { autorun, autorunWithStore, derived, IObservable, ISettableObservable, observableValue, transaction } from 'vs/base/common/observable';
1616
import { ThemeIcon } from 'vs/base/common/themables';
1717
import { Constants } from 'vs/base/common/uint';
1818
import { URI } from 'vs/base/common/uri';
1919
import { generateUuid } from 'vs/base/common/uuid';
2020
import 'vs/css!./media/callStackWidget';
2121
import { ICodeEditor } from 'vs/editor/browser/editorBrowser';
22-
import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService';
22+
import { EditorContributionCtor, EditorContributionInstantiation, IEditorContributionDescription } from 'vs/editor/browser/editorExtensions';
2323
import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget';
2424
import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget';
2525
import { IEditorOptions } from 'vs/editor/common/config/editorOptions';
26+
import { Position } from 'vs/editor/common/core/position';
2627
import { Range } from 'vs/editor/common/core/range';
28+
import { IWordAtPosition } from 'vs/editor/common/core/wordHelper';
29+
import { IEditorContribution, IEditorDecorationsCollection } from 'vs/editor/common/editorCommon';
2730
import { Location } from 'vs/editor/common/languages';
2831
import { ITextModelService } from 'vs/editor/common/services/resolverService';
32+
import { ClickLinkGesture, ClickLinkMouseEvent } from 'vs/editor/contrib/gotoSymbol/browser/link/clickLinkGesture';
2933
import { localize, localize2 } from 'vs/nls';
3034
import { createActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem';
3135
import { MenuWorkbenchToolBar } from 'vs/platform/actions/browser/toolbar';
@@ -38,7 +42,7 @@ import { INotificationService } from 'vs/platform/notification/common/notificati
3842
import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles';
3943
import { ResourceLabel } from 'vs/workbench/browser/labels';
4044
import { makeStackFrameColumnDecoration, TOP_STACK_FRAME_DECORATION } from 'vs/workbench/contrib/debug/browser/callStackEditorContribution';
41-
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
45+
import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
4246

4347

4448
export class CallStackFrame {
@@ -97,6 +101,9 @@ class WrappedCustomStackFrame implements IFrameLikeItem {
97101
constructor(public readonly original: CustomStackFrame) { }
98102
}
99103

104+
const isFrameLike = (item: unknown): item is IFrameLikeItem =>
105+
item instanceof WrappedCallStackFrame || item instanceof WrappedCustomStackFrame;
106+
100107
type ListItem = WrappedCallStackFrame | SkippedCallFrames | WrappedCustomStackFrame;
101108

102109
const WIDGET_CLASS_NAME = 'multiCallStackWidget';
@@ -157,6 +164,17 @@ export class CallStackWidget extends Disposable {
157164
this.layoutEmitter.fire();
158165
}
159166

167+
public collapseAll() {
168+
transaction(tx => {
169+
for (let i = 0; i < this.list.length; i++) {
170+
const frame = this.list.element(i);
171+
if (isFrameLike(frame)) {
172+
frame.collapsed.set(true, tx);
173+
}
174+
}
175+
});
176+
}
177+
160178
private async loadFrame(replacing: SkippedCallFrames): Promise<void> {
161179
if (!this.cts) {
162180
return;
@@ -356,9 +374,9 @@ abstract class AbstractFrameRenderer<T extends IAbstractFrameRendererTemplateDat
356374
collapse.element.ariaExpanded = String(!collapsed);
357375
elements.root.classList.toggle('collapsed', collapsed);
358376
}));
359-
elementStore.add(collapse.onDidClick(() => {
360-
item.collapsed.set(!item.collapsed.get(), undefined);
361-
}));
377+
const toggleCollapse = () => item.collapsed.set(!item.collapsed.get(), undefined);
378+
elementStore.add(collapse.onDidClick(toggleCollapse));
379+
elementStore.add(dom.addDisposableListener(elements.title, 'click', toggleCollapse));
362380
}
363381

364382
disposeElement(element: ListItem, index: number, templateData: T, height: number | undefined): void {
@@ -382,26 +400,33 @@ class FrameCodeRenderer extends AbstractFrameRenderer<IStackTemplateData> {
382400
private readonly containingEditor: ICodeEditor | undefined,
383401
private readonly onLayout: Event<void>,
384402
@ITextModelService private readonly modelService: ITextModelService,
385-
@ICodeEditorService private readonly editorService: ICodeEditorService,
386403
@IInstantiationService instantiationService: IInstantiationService,
387404
) {
388405
super(instantiationService);
389406
}
390407

391408
protected override finishRenderTemplate(data: IAbstractFrameRendererTemplateData): IStackTemplateData {
409+
// override default e.g. language contributions, only allow users to click
410+
// on code in the call stack to go to its source location
411+
const contributions: IEditorContributionDescription[] = [{
412+
id: ClickToLocationContribution.ID,
413+
instantiation: EditorContributionInstantiation.BeforeFirstInteraction,
414+
ctor: ClickToLocationContribution as EditorContributionCtor,
415+
}];
416+
392417
const editor = this.containingEditor
393418
? this.instantiationService.createInstance(
394419
EmbeddedCodeEditorWidget,
395420
data.elements.editor,
396421
editorOptions,
397-
{ isSimpleWidget: true },
422+
{ isSimpleWidget: true, contributions },
398423
this.containingEditor,
399424
)
400425
: this.instantiationService.createInstance(
401426
CodeEditorWidget,
402427
data.elements.editor,
403428
editorOptions,
404-
{ isSimpleWidget: true },
429+
{ isSimpleWidget: true, contributions },
405430
);
406431

407432
data.templateStore.add(editor);
@@ -423,20 +448,6 @@ class FrameCodeRenderer extends AbstractFrameRenderer<IStackTemplateData> {
423448
const uri = item.source!;
424449

425450
template.label.element.setFile(uri);
426-
template.elements.title.role = 'link';
427-
elementStore.add(dom.addDisposableListener(template.elements.title, 'click', e => {
428-
this.editorService.openCodeEditor({
429-
resource: uri,
430-
options: {
431-
selection: Range.fromPositions({
432-
column: item.column ?? 1,
433-
lineNumber: item.line ?? 1,
434-
}),
435-
selectionRevealType: TextEditorSelectionRevealType.CenterIfOutsideViewport,
436-
},
437-
}, this.containingEditor || null, e.ctrlKey || e.metaKey);
438-
}));
439-
440451
const cts = new CancellationTokenSource();
441452
elementStore.add(toDisposable(() => cts.dispose(true)));
442453
this.modelService.createModelReference(uri).then(reference => {
@@ -632,6 +643,73 @@ class SkippedRenderer implements IListRenderer<ListItem, ISkippedTemplateData> {
632643
}
633644
}
634645

646+
/** A simple contribution that makes all data in the editor clickable to go to the location */
647+
class ClickToLocationContribution extends Disposable implements IEditorContribution {
648+
public static readonly ID = 'clickToLocation';
649+
private readonly linkDecorations: IEditorDecorationsCollection;
650+
private current: { line: number; word: IWordAtPosition } | undefined;
651+
652+
constructor(
653+
private readonly editor: ICodeEditor,
654+
@IEditorService editorService: IEditorService,
655+
) {
656+
super();
657+
this.linkDecorations = editor.createDecorationsCollection();
658+
this._register(toDisposable(() => this.linkDecorations.clear()));
659+
660+
const clickLinkGesture = this._register(new ClickLinkGesture(editor));
661+
662+
this._register(clickLinkGesture.onMouseMoveOrRelevantKeyDown(([mouseEvent, keyboardEvent]) => {
663+
this.onMove(mouseEvent);
664+
}));
665+
this._register(clickLinkGesture.onExecute((e) => {
666+
const model = this.editor.getModel();
667+
if (!this.current || !model) {
668+
return;
669+
}
670+
671+
editorService.openEditor({
672+
resource: model.uri,
673+
options: {
674+
selection: Range.fromPositions(new Position(this.current.line, this.current.word.startColumn)),
675+
selectionRevealType: TextEditorSelectionRevealType.CenterIfOutsideViewport,
676+
},
677+
}, e.hasSideBySideModifier ? SIDE_GROUP : undefined);
678+
}));
679+
}
680+
681+
private onMove(mouseEvent: ClickLinkMouseEvent) {
682+
if (!mouseEvent.hasTriggerModifier) {
683+
return this.clear();
684+
}
685+
686+
const position = mouseEvent.target.position;
687+
const word = position && this.editor.getModel()?.getWordAtPosition(position);
688+
if (!word) {
689+
return this.clear();
690+
}
691+
692+
const prev = this.current?.word;
693+
if (prev && prev.startColumn === word.startColumn && prev.endColumn === word.endColumn && prev.word === word.word) {
694+
return;
695+
}
696+
697+
this.current = { word, line: position.lineNumber };
698+
this.linkDecorations.set([{
699+
range: new Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn),
700+
options: {
701+
description: 'call-stack-go-to-file-link',
702+
inlineClassName: 'call-stack-go-to-file-link',
703+
},
704+
}]);
705+
}
706+
707+
private clear() {
708+
this.linkDecorations.clear();
709+
this.current = undefined;
710+
}
711+
}
712+
635713
registerAction2(class extends Action2 {
636714
constructor() {
637715
super({

src/vs/workbench/contrib/debug/browser/media/callStackWidget.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,9 @@
5959
line-height: inherit !important;
6060
}
6161
}
62+
63+
.monaco-editor .call-stack-go-to-file-link {
64+
text-decoration: underline;
65+
cursor: pointer;
66+
color: var(--vscode-editorLink-activeForeground) !important;
67+
}

src/vs/workbench/contrib/testing/browser/testResultsView/testMessageStack.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export class TestResultStackWidget extends Disposable {
3030
));
3131
}
3232

33+
public collapseAll() {
34+
this.widget.collapseAll();
35+
}
36+
3337
public update(messageFrame: AnyStackFrame, stack: ITestMessageStackFrame[]) {
3438
this.widget.setFrames([messageFrame, ...stack.map(frame => new CallStackFrame(
3539
frame.label,

src/vs/workbench/contrib/testing/browser/testResultsView/testResultsSubject.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ interface ISubjectCommon {
2222
controllerId: string;
2323
}
2424

25+
export const inspectSubjectHasStack = (subject: InspectSubject | undefined) =>
26+
subject instanceof MessageSubject && !!subject.stack?.length;
27+
2528
export class MessageSubject implements ISubjectCommon {
2629
public readonly test: ITestItem;
2730
public readonly message: ITestMessage;

src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -838,22 +838,21 @@ class TreeActionsProvider {
838838
}
839839

840840
if (element instanceof TestMessageElement) {
841+
id = MenuId.TestMessageContext;
842+
contextKeys.push([TestingContextKeys.testMessageContext.key, element.contextValue]);
843+
841844
primary.push(new Action(
842-
'testing.outputPeek.goToFile',
843-
localize('testing.goToFile', "Go to Source"),
845+
'testing.outputPeek.goToTest',
846+
localize('testing.goToTest', "Go to Test"),
844847
ThemeIcon.asClassName(Codicon.goToFile),
845848
undefined,
846849
() => this.commandService.executeCommand('vscode.revealTest', element.test.item.extId),
847850
));
848-
}
849851

850-
if (element instanceof TestMessageElement) {
851-
id = MenuId.TestMessageContext;
852-
contextKeys.push([TestingContextKeys.testMessageContext.key, element.contextValue]);
853852
if (this.showRevealLocationOnMessages && element.location) {
854853
primary.push(new Action(
855854
'testing.outputPeek.goToError',
856-
localize('testing.goToError', "Go to Source"),
855+
localize('testing.goToError', "Go to Error"),
857856
ThemeIcon.asClassName(Codicon.goToFile),
858857
undefined,
859858
() => this.editorService.openEditor({

src/vs/workbench/contrib/testing/browser/testResultsView/testResultsViewContent.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,13 @@ export class TestResultsViewContent extends Disposable {
322322
});
323323
}
324324

325+
/**
326+
* Collapses all displayed stack frames.
327+
*/
328+
public collapseStack() {
329+
this.callStackWidget.collapseAll();
330+
}
331+
325332
private getCallFrames(subject: InspectSubject) {
326333
if (!(subject instanceof MessageSubject)) {
327334
return undefined;

src/vs/workbench/contrib/testing/browser/testing.contribution.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { testingResultsIcon, testingViewIcon } from 'vs/workbench/contrib/testin
2525
import { TestCoverageView } from 'vs/workbench/contrib/testing/browser/testCoverageView';
2626
import { TestingDecorationService, TestingDecorations } from 'vs/workbench/contrib/testing/browser/testingDecorations';
2727
import { TestingExplorerView } from 'vs/workbench/contrib/testing/browser/testingExplorerView';
28-
import { CloseTestPeek, GoToNextMessageAction, GoToPreviousMessageAction, OpenMessageInEditorAction, TestResultsView, TestingOutputPeekController, TestingPeekOpener, ToggleTestingPeekHistory } from 'vs/workbench/contrib/testing/browser/testingOutputPeek';
28+
import { CloseTestPeek, CollapsePeekStack, GoToNextMessageAction, GoToPreviousMessageAction, OpenMessageInEditorAction, TestResultsView, TestingOutputPeekController, TestingPeekOpener, ToggleTestingPeekHistory } from 'vs/workbench/contrib/testing/browser/testingOutputPeek';
2929
import { TestingProgressTrigger } from 'vs/workbench/contrib/testing/browser/testingProgressUiService';
3030
import { TestingViewPaneContainer } from 'vs/workbench/contrib/testing/browser/testingViewPaneContainer';
3131
import { testingConfiguration } from 'vs/workbench/contrib/testing/common/configuration';
@@ -136,6 +136,7 @@ registerAction2(GoToPreviousMessageAction);
136136
registerAction2(GoToNextMessageAction);
137137
registerAction2(CloseTestPeek);
138138
registerAction2(ToggleTestingPeekHistory);
139+
registerAction2(CollapsePeekStack);
139140

140141
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingContentProvider, LifecyclePhase.Restored);
141142
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingPeekOpener, LifecyclePhase.Eventually);

0 commit comments

Comments
 (0)