Skip to content

Commit 805d8a8

Browse files
committed
testing: finish implementation of call stack view
1 parent 4e928f7 commit 805d8a8

File tree

11 files changed

+340
-71
lines changed

11 files changed

+340
-71
lines changed

.vscode/extensions/vscode-selfhost-test-provider/src/testOutputScanner.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -657,4 +657,4 @@ function findLastIndex<T>(arr: T[], predicate: (value: T) => boolean) {
657657
}
658658

659659
return -1;
660-
}
660+
}

src/vs/platform/actions/common/actions.ts

+1
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export class MenuId {
143143
static readonly TestMessageContent = new MenuId('TestMessageContent');
144144
static readonly TestPeekElement = new MenuId('TestPeekElement');
145145
static readonly TestPeekTitle = new MenuId('TestPeekTitle');
146+
static readonly TestCallStackContext = new MenuId('TestCallStackContext');
146147
static readonly TouchBarContext = new MenuId('TouchBarContext');
147148
static readonly TitleBarContext = new MenuId('TitleBarContext');
148149
static readonly TitleBarTitleContext = new MenuId('TitleBarTitleContext');

src/vs/workbench/contrib/testing/browser/media/testing.css

+42
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,48 @@
313313
}
314314
}
315315

316+
.test-output-call-stack {
317+
height: 100%;
318+
319+
.monaco-list-row {
320+
padding: 0 3px 0 8px;
321+
display: flex;
322+
gap: 3px;
323+
324+
.location, .label {
325+
white-space: nowrap;
326+
}
327+
328+
.label {
329+
flex-grow: 1;
330+
}
331+
332+
.location {
333+
/*Use direction so the source shows elipses on the left*/
334+
direction: rtl;
335+
overflow: hidden;
336+
text-overflow: ellipsis;
337+
font-size: 0.9em;
338+
}
339+
340+
&.no-source .label {
341+
opacity: 0.7;
342+
}
343+
344+
&:hover {
345+
.label {
346+
overflow: hidden;
347+
text-overflow: ellipsis;
348+
}
349+
350+
.location {
351+
overflow: visible;
352+
text-overflow: unset;
353+
}
354+
}
355+
}
356+
}
357+
316358
/** -- filter */
317359
.monaco-action-bar.testing-filter-action-bar {
318360
flex-shrink: 0;

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

+92-15
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,36 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import * as dom from 'vs/base/browser/dom';
67
import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
7-
import { Disposable } from 'vs/base/common/lifecycle';
8+
import { Emitter } from 'vs/base/common/event';
9+
import { Disposable, IDisposable } from 'vs/base/common/lifecycle';
10+
import { Range } from 'vs/editor/common/core/range';
811
import { localize } from 'vs/nls';
12+
import { MenuId } from 'vs/platform/actions/common/actions';
13+
import { IContextMenuService } from 'vs/platform/contextview/browser/contextView';
914
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
1015
import { ILabelService } from 'vs/platform/label/common/label';
1116
import { WorkbenchList } from 'vs/platform/list/browser/listService';
12-
import { ITestMessageStackTrace } from 'vs/workbench/contrib/testing/common/testTypes';
17+
import { ITestMessageStackFrame } from 'vs/workbench/contrib/testing/common/testTypes';
18+
import { IEditorService, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
1319

1420
const stackItemDelegate: IListVirtualDelegate<void> = {
1521
getHeight: () => 22,
1622
getTemplateId: () => 's',
1723
};
1824

1925
export class TestResultStackWidget extends Disposable {
20-
private readonly list: WorkbenchList<ITestMessageStackTrace>;
26+
private readonly list: WorkbenchList<ITestMessageStackFrame>;
27+
private readonly changeStackFrameEmitter = this._register(new Emitter<ITestMessageStackFrame>());
28+
29+
public readonly onDidChangeStackFrame = this.changeStackFrameEmitter.event;
2130

2231
constructor(
23-
container: HTMLElement,
32+
private readonly container: HTMLElement,
2433
@IInstantiationService instantiationService: IInstantiationService,
2534
@ILabelService labelService: ILabelService,
35+
@IContextMenuService contextMenuService: IContextMenuService
2636
) {
2737
super();
2838

@@ -33,43 +43,110 @@ export class TestResultStackWidget extends Disposable {
3343
stackItemDelegate,
3444
[instantiationService.createInstance(StackRenderer)],
3545
{
46+
multipleSelectionSupport: false,
3647
accessibilityProvider: {
3748
getWidgetAriaLabel: () => localize('testStackTrace', 'Test stack trace'),
38-
getAriaLabel: (e: ITestMessageStackTrace) => e.position && e.uri ? localize({
49+
getAriaLabel: (e: ITestMessageStackFrame) => e.position && e.uri ? localize({
3950
comment: ['{0} is an extension-defined label, then line number and filename'],
4051
key: 'stackTraceLabel',
41-
}, '{0}, line {1} in {2}', e.label, e.position.lineNumber, labelService.getUriLabel(e.uri)) : e.label,
52+
}, '{0}, line {1} in {2}', e.label, e.position.lineNumber, labelService.getUriLabel(e.uri, { relative: true })) : e.label,
4253
}
4354
}
44-
) as WorkbenchList<ITestMessageStackTrace>);
55+
) as WorkbenchList<ITestMessageStackFrame>);
56+
57+
this._register(this.list.onDidChangeSelection(e => {
58+
if (e.elements.length) {
59+
this.changeStackFrameEmitter.fire(e.elements[0]);
60+
}
61+
}));
62+
63+
this._register(dom.addDisposableListener(container, dom.EventType.CONTEXT_MENU, e => {
64+
contextMenuService.showContextMenu({
65+
getAnchor: () => ({ x: e.x, y: e.y }),
66+
menuId: MenuId.TestCallStackContext
67+
});
68+
}));
4569
}
4670

47-
public update(stack: ITestMessageStackTrace[]) {
71+
public update(stack: ITestMessageStackFrame[], selection?: ITestMessageStackFrame) {
4872
this.list.splice(0, this.list.length, stack);
73+
this.list.layout();
74+
75+
const i = selection && stack.indexOf(selection);
76+
if (i && i !== -1) {
77+
this.list.setSelection([i]);
78+
this.list.setFocus([i]);
79+
// selection is triggered actioning on the call stack from a different
80+
// editor, ensure the stack item is still focused in this editor
81+
this.list.domFocus();
82+
}
4983
}
5084

5185
public layout(height?: number, width?: number) {
52-
this.list.layout(height, width);
86+
this.list.layout(height ?? this.container.clientHeight, width);
5387
}
5488
}
5589

5690
interface ITemplateData {
5791
container: HTMLElement;
92+
label: HTMLElement;
93+
location: HTMLElement;
94+
current?: ITestMessageStackFrame;
95+
disposable: IDisposable;
5896
}
5997

60-
class StackRenderer implements IListRenderer<ITestMessageStackTrace, ITemplateData> {
98+
class StackRenderer implements IListRenderer<ITestMessageStackFrame, ITemplateData> {
6199
public readonly templateId = 's';
62100

101+
constructor(
102+
@ILabelService private readonly labelService: ILabelService,
103+
@IEditorService private readonly openerService: IEditorService,
104+
) { }
105+
63106
renderTemplate(container: HTMLElement): ITemplateData {
64-
return { container };
107+
const label = dom.$('.label');
108+
const location = dom.$('.location');
109+
container.appendChild(label);
110+
container.appendChild(location);
111+
const data: ITemplateData = {
112+
container,
113+
label,
114+
location,
115+
disposable: dom.addDisposableListener(container, dom.EventType.CLICK, e => {
116+
if (e.ctrlKey || e.metaKey) {
117+
if (data.current?.uri) {
118+
this.openerService.openEditor({
119+
resource: data.current.uri,
120+
options: {
121+
selection: data.current.position ? Range.fromPositions(data.current.position) : undefined,
122+
}
123+
}, SIDE_GROUP);
124+
e.preventDefault();
125+
e.stopPropagation();
126+
}
127+
}
128+
}),
129+
};
130+
131+
return data;
65132
}
66133

67-
renderElement(element: ITestMessageStackTrace, index: number, templateData: ITemplateData, height: number | undefined): void {
68-
templateData.container.innerText = element.label;
134+
renderElement(element: ITestMessageStackFrame, index: number, templateData: ITemplateData, height: number | undefined): void {
135+
templateData.label.innerText = element.label;
136+
templateData.current = element;
137+
templateData.container.classList.toggle('no-source', !element.uri);
138+
139+
if (element.uri) {
140+
templateData.location.innerText = this.labelService.getUriBasenameLabel(element.uri);
141+
templateData.location.title = this.labelService.getUriLabel(element.uri, { relative: true });
142+
if (element.position) {
143+
templateData.location.innerText += `:${element.position.lineNumber}:${element.position.column}`;
144+
}
145+
}
69146
}
70147

71-
disposeTemplate(_templateData: ITemplateData): void {
72-
// no-op
148+
disposeTemplate(templateData: ITemplateData): void {
149+
templateData.disposable.dispose();
73150
}
74151
}
75152

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

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const commonEditorOptions: IEditorOptions = {
7474
scrollBeyondLastLine: false,
7575
links: true,
7676
lineNumbers: 'off',
77+
glyphMargin: false,
7778
scrollbar: {
7879
verticalScrollbarSize: 14,
7980
horizontal: 'auto',

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

+42-7
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,28 @@ import { OutputPeekTree } from 'vs/workbench/contrib/testing/browser/testResults
2929
import { IObservableValue } from 'vs/workbench/contrib/testing/common/observableValue';
3030
import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult';
3131
import { ITestFollowup, ITestService } from 'vs/workbench/contrib/testing/common/testService';
32+
import { ITestMessageStackFrame } from 'vs/workbench/contrib/testing/common/testTypes';
3233
import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys';
34+
import { ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener';
3335

3436
const enum SubView {
3537
CallStack = 0,
3638
Diff = 1,
3739
History = 2,
3840
}
3941

42+
/** UI state that can be saved/restored, used to give a nice experience when switching stack frames */
43+
export interface ITestResultsViewContentUiState {
44+
splitViewWidths: number[];
45+
}
46+
4047
export class TestResultsViewContent extends Disposable {
4148
private static lastSplitWidth?: number;
4249

4350
private readonly didReveal = this._register(new Emitter<{ subject: InspectSubject; preserveFocus: boolean }>());
4451
private readonly currentSubjectStore = this._register(new DisposableStore());
4552
private readonly onCloseEmitter = this._register(new Relay<void>());
53+
private readonly onDidChangeStackFrameEmitter = this._register(new Relay<ITestMessageStackFrame>());
4654
private followupWidget!: FollowupActionWidget;
4755
private messageContextKeyService!: IContextKeyService;
4856
private contextKeyTestMessage!: IContextKey<string>;
@@ -62,6 +70,16 @@ export class TestResultsViewContent extends Disposable {
6270
public onDidRequestReveal!: Event<InspectSubject>;
6371

6472
public readonly onClose = this.onCloseEmitter.event;
73+
public readonly onDidChangeStackFrame = this.onDidChangeStackFrameEmitter.event;
74+
75+
public get uiState(): ITestResultsViewContentUiState {
76+
return {
77+
splitViewWidths: Array.from(
78+
{ length: this.splitView.length },
79+
(_, i) => this.splitView.getViewSize(i)
80+
),
81+
};
82+
}
6583

6684
constructor(
6785
private readonly editor: ICodeEditor | undefined,
@@ -73,6 +91,7 @@ export class TestResultsViewContent extends Disposable {
7391
@IInstantiationService private readonly instantiationService: IInstantiationService,
7492
@ITextModelService protected readonly modelService: ITextModelService,
7593
@IContextKeyService private readonly contextKeyService: IContextKeyService,
94+
@ITestingPeekOpener private readonly peekOpener: ITestingPeekOpener,
7695
) {
7796
super();
7897
}
@@ -95,6 +114,12 @@ export class TestResultsViewContent extends Disposable {
95114
this._register(this.instantiationService.createInstance(PlainTextMessagePeek, this.editor, messageContainer)),
96115
];
97116

117+
this._register(this.peekOpener.callStackVisible.onDidChange(() => {
118+
if (this.current) {
119+
this.updateVisiblityOfStackView(this.current);
120+
}
121+
}));
122+
98123
this.messageContextKeyService = this._register(this.contextKeyService.createScoped(containerElement));
99124
this.contextKeyTestMessage = TestingContextKeys.testMessageContext.bindTo(this.messageContextKeyService);
100125
this.contextKeyResultOutdated = TestingContextKeys.testResultOutdated.bindTo(this.messageContextKeyService);
@@ -151,7 +176,12 @@ export class TestResultsViewContent extends Disposable {
151176
* Shows a message in-place without showing or changing the peek location.
152177
* This is mostly used if peeking a message without a location.
153178
*/
154-
public reveal(opts: { subject: InspectSubject; preserveFocus: boolean }) {
179+
public reveal(opts: {
180+
subject: InspectSubject;
181+
preserveFocus: boolean;
182+
frame?: ITestMessageStackFrame;
183+
uiState?: ITestResultsViewContentUiState;
184+
}) {
155185
this.didReveal.fire(opts);
156186

157187
if (this.current && equalsSubject(this.current, opts.subject)) {
@@ -163,14 +193,17 @@ export class TestResultsViewContent extends Disposable {
163193
await Promise.all(this.contentProviders.map(p => p.update(opts.subject)));
164194
this.followupWidget.show(opts.subject);
165195
this.currentSubjectStore.clear();
166-
// todo@connor4312: disabled for next Insiders, finish implementing this!
167-
if (Date.now() < 0) { this.updateVisiblityOfStackView(opts.subject); }
196+
this.updateVisiblityOfStackView(opts.subject, opts.frame);
168197
this.populateFloatingClick(opts.subject);
198+
199+
if (opts.uiState) {
200+
opts.uiState.splitViewWidths.forEach((width, i) => this.splitView.resizeView(i, width));
201+
}
169202
});
170203
}
171204

172-
private updateVisiblityOfStackView(subject: InspectSubject) {
173-
const stack = subject instanceof MessageSubject && subject.stack;
205+
private updateVisiblityOfStackView(subject: InspectSubject, frame?: ITestMessageStackFrame) {
206+
const stack = this.peekOpener.callStackVisible.value && subject instanceof MessageSubject && subject.stack;
174207

175208
if (stack) {
176209
if (!this.callStackWidget.value) {
@@ -182,12 +215,14 @@ export class TestResultsViewContent extends Disposable {
182215
maximumSize: Number.MAX_VALUE,
183216
layout: width => widget.layout(undefined, width),
184217
}, 150, 0);
218+
this.onDidChangeStackFrameEmitter.input = widget.onDidChangeStackFrame;
185219
}
186220

187-
this.callStackWidget.value.update(stack);
221+
this.callStackWidget.value.update(stack, frame);
188222
} else if (this.callStackWidget.value) {
189223
this.splitView.removeView(0);
190-
this.callStackWidget.dispose();
224+
this.onDidChangeStackFrameEmitter.input = Event.None;
225+
this.callStackWidget.clear();
191226
}
192227
}
193228

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

+2-1
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, GoToNextMessageAction, GoToPreviousMessageAction, OpenMessageInEditorAction, TestResultsView, TestingOutputPeekController, TestingPeekOpener, ToggleCallStackAction, 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(ToggleCallStackAction);
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)