feat: patched the Theia debug functionality

Patch for:
 - eclipse-theia/theia#11871
 - eclipse-theia/theia#11879
 - eclipse-theia/theia#11880
 - eclipse-theia/theia#11885
 - eclipse-theia/theia#11886
 - eclipse-theia/theia#11916

Closes #1582

Signed-off-by: Akos Kitta <a.kitta@arduino.cc>
This commit is contained in:
Akos Kitta
2022-11-23 18:06:10 +01:00
committed by Akos Kitta
parent 3bc412b42f
commit d0e383853f
15 changed files with 895 additions and 2 deletions

View File

@@ -0,0 +1,29 @@
import * as React from '@theia/core/shared/react';
import { DebugAction as TheiaDebugAction } from '@theia/debug/lib/browser/view/debug-action';
import {
codiconArray,
DISABLED_CLASS,
} from '@theia/core/lib/browser/widgets/widget';
// customized debug action to show the contributed command's label when there is no icon
export class DebugAction extends TheiaDebugAction {
override render(): React.ReactNode {
const { enabled, label, iconClass } = this.props;
const classNames = ['debug-action', ...codiconArray(iconClass, true)];
if (enabled === false) {
classNames.push(DISABLED_CLASS);
}
return (
<span
tabIndex={0}
className={classNames.join(' ')}
title={label}
onClick={this.props.run}
ref={this.setRef}
>
{!iconClass ||
(iconClass.match(/plugin-icon-\d+/) && <div>{label}</div>)}
</span>
);
}
}

View File

@@ -0,0 +1,49 @@
import { injectable } from '@theia/core/shared/inversify';
import { DebugSessionConnection } from '@theia/debug/lib/browser/debug-session-connection';
import { DefaultDebugSessionFactory as TheiaDefaultDebugSessionFactory } from '@theia/debug/lib/browser/debug-session-contribution';
import { DebugConfigurationSessionOptions } from '@theia/debug/lib/browser/debug-session-options';
import {
DebugAdapterPath,
DebugChannel,
ForwardingDebugChannel,
} from '@theia/debug/lib/common/debug-service';
import { DebugSession } from './debug-session';
@injectable()
export class DefaultDebugSessionFactory extends TheiaDefaultDebugSessionFactory {
override get(
sessionId: string,
options: DebugConfigurationSessionOptions,
parentSession?: DebugSession
): DebugSession {
const connection = new DebugSessionConnection(
sessionId,
() =>
new Promise<DebugChannel>((resolve) =>
this.connectionProvider.openChannel(
`${DebugAdapterPath}/${sessionId}`,
(wsChannel) => {
resolve(new ForwardingDebugChannel(wsChannel));
},
{ reconnecting: false }
)
),
this.getTraceOutputChannel()
);
// patched debug session
return new DebugSession(
sessionId,
options,
parentSession,
connection,
this.terminalService,
this.editorManager,
this.breakpoints,
this.labelProvider,
this.messages,
this.fileService,
this.debugContributionProvider,
this.workspaceService
);
}
}

View File

@@ -0,0 +1,120 @@
import type { ContextKey } from '@theia/core/lib/browser/context-key-service';
import { injectable, postConstruct } from '@theia/core/shared/inversify';
import {
DebugSession,
DebugState,
} from '@theia/debug/lib/browser/debug-session';
import { DebugSessionManager as TheiaDebugSessionManager } from '@theia/debug/lib/browser/debug-session-manager';
import type { DebugConfigurationSessionOptions } from '@theia/debug/lib/browser/debug-session-options';
function debugStateLabel(state: DebugState): string {
switch (state) {
case DebugState.Initializing:
return 'initializing';
case DebugState.Stopped:
return 'stopped';
case DebugState.Running:
return 'running';
default:
return 'inactive';
}
}
@injectable()
export class DebugSessionManager extends TheiaDebugSessionManager {
protected debugStateKey: ContextKey<string>;
@postConstruct()
protected override init(): void {
this.debugStateKey = this.contextKeyService.createKey<string>(
'debugState',
debugStateLabel(this.state)
);
super.init();
}
protected override fireDidChange(current: DebugSession | undefined): void {
this.debugTypeKey.set(current?.configuration.type);
this.inDebugModeKey.set(this.inDebugMode);
this.debugStateKey.set(debugStateLabel(this.state));
this.onDidChangeEmitter.fire(current);
}
protected override async doStart(
sessionId: string,
options: DebugConfigurationSessionOptions
): Promise<DebugSession> {
const parentSession =
options.configuration.parentSession &&
this._sessions.get(options.configuration.parentSession.id);
const contrib = this.sessionContributionRegistry.get(
options.configuration.type
);
const sessionFactory = contrib
? contrib.debugSessionFactory()
: this.debugSessionFactory;
const session = sessionFactory.get(sessionId, options, parentSession);
this._sessions.set(sessionId, session);
this.debugTypeKey.set(session.configuration.type);
// this.onDidCreateDebugSessionEmitter.fire(session); // defer the didCreate event after start https://github.com/eclipse-theia/theia/issues/11916
let state = DebugState.Inactive;
session.onDidChange(() => {
if (state !== session.state) {
state = session.state;
if (state === DebugState.Stopped) {
this.onDidStopDebugSessionEmitter.fire(session);
}
}
this.updateCurrentSession(session);
});
session.onDidChangeBreakpoints((uri) =>
this.fireDidChangeBreakpoints({ session, uri })
);
session.on('terminated', async (event) => {
const restart = event.body && event.body.restart;
if (restart) {
// postDebugTask isn't run in case of auto restart as well as preLaunchTask
this.doRestart(session, !!restart);
} else {
await session.disconnect(false, () =>
this.debug.terminateDebugSession(session.id)
);
await this.runTask(
session.options.workspaceFolderUri,
session.configuration.postDebugTask
);
}
});
// eslint-disable-next-line unused-imports/no-unused-vars, @typescript-eslint/no-unused-vars
session.on('exited', async (event) => {
await session.disconnect(false, () =>
this.debug.terminateDebugSession(session.id)
);
});
session.onDispose(() => this.cleanup(session));
session
.start()
.then(() => {
this.onDidCreateDebugSessionEmitter.fire(session); // now fire the didCreate event
this.onDidStartDebugSessionEmitter.fire(session);
})
// eslint-disable-next-line unused-imports/no-unused-vars, @typescript-eslint/no-unused-vars
.catch((e) => {
session.stop(false, () => {
this.debug.terminateDebugSession(session.id);
});
});
session.onDidCustomEvent(({ event, body }) =>
this.onDidReceiveDebugSessionCustomEventEmitter.fire({
event,
body,
session,
})
);
return session;
}
}

View File

@@ -0,0 +1,231 @@
import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { Mutable } from '@theia/core/lib/common/types';
import { URI } from '@theia/core/lib/common/uri';
import { DebugSession as TheiaDebugSession } from '@theia/debug/lib/browser/debug-session';
import { DebugFunctionBreakpoint } from '@theia/debug/lib/browser/model/debug-function-breakpoint';
import { DebugSourceBreakpoint } from '@theia/debug/lib/browser/model/debug-source-breakpoint';
import {
DebugThreadData,
StoppedDetails,
} from '@theia/debug/lib/browser/model/debug-thread';
import { DebugProtocol } from '@vscode/debugprotocol';
import { DebugThread } from './debug-thread';
export class DebugSession extends TheiaDebugSession {
/**
* The `send('initialize')` request resolves later than `on('initialized')` emits the event.
* Hence, the `configure` would use the empty object `capabilities`.
* Using the empty `capabilities` could result in missing exception breakpoint filters, as
* always `capabilities.exceptionBreakpointFilters` is falsy. This deferred promise works
* around this timing issue.
* See: https://github.com/eclipse-theia/theia/issues/11886.
*/
protected didReceiveCapabilities = new Deferred();
protected override async initialize(): Promise<void> {
const clientName = FrontendApplicationConfigProvider.get().applicationName;
try {
const response = await this.connection.sendRequest('initialize', {
clientID: clientName.toLocaleLowerCase().replace(/ /g, '_'),
clientName,
adapterID: this.configuration.type,
locale: 'en-US',
linesStartAt1: true,
columnsStartAt1: true,
pathFormat: 'path',
supportsVariableType: false,
supportsVariablePaging: false,
supportsRunInTerminalRequest: true,
});
this.updateCapabilities(response?.body || {});
this.didReceiveCapabilities.resolve();
} catch (err) {
this.didReceiveCapabilities.reject(err);
throw err;
}
}
protected override async configure(): Promise<void> {
await this.didReceiveCapabilities.promise;
return super.configure();
}
override async stop(isRestart: boolean, callback: () => void): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const _this = this as any;
if (!_this.isStopping) {
_this.isStopping = true;
if (this.configuration.lifecycleManagedByParent && this.parentSession) {
await this.parentSession.stop(isRestart, callback);
} else {
if (this.canTerminate()) {
const terminated = this.waitFor('terminated', 5000);
try {
await this.connection.sendRequest(
'terminate',
{ restart: isRestart },
5000
);
await terminated;
} catch (e) {
console.error('Did not receive terminated event in time', e);
}
} else {
const terminateDebuggee =
this.initialized && this.capabilities.supportTerminateDebuggee;
// Related https://github.com/microsoft/vscode/issues/165138
try {
await this.sendRequest(
'disconnect',
{ restart: isRestart, terminateDebuggee },
2000
);
} catch (err) {
if (
'message' in err &&
typeof err.message === 'string' &&
err.message.test(err.message)
) {
// VS Code ignores errors when sending the `disconnect` request.
// Debug adapter might not send the `disconnected` event as a response.
} else {
throw err;
}
}
}
callback();
}
}
}
protected override async sendFunctionBreakpoints(
affectedUri: URI
): Promise<void> {
const all = this.breakpoints
.getFunctionBreakpoints()
.map(
(origin) =>
new DebugFunctionBreakpoint(origin, this.asDebugBreakpointOptions())
);
const enabled = all.filter((b) => b.enabled);
if (this.capabilities.supportsFunctionBreakpoints) {
try {
const response = await this.sendRequest('setFunctionBreakpoints', {
breakpoints: enabled.map((b) => b.origin.raw),
});
// Apparently, `body` and `breakpoints` can be missing.
// https://github.com/eclipse-theia/theia/issues/11885
// https://github.com/microsoft/vscode/blob/80004351ccf0884b58359f7c8c801c91bb827d83/src/vs/workbench/contrib/debug/browser/debugSession.ts#L448-L449
if (response && response.body) {
response.body.breakpoints.forEach((raw, index) => {
// node debug adapter returns more breakpoints sometimes
if (enabled[index]) {
enabled[index].update({ raw });
}
});
}
} catch (error) {
// could be error or promise rejection of DebugProtocol.SetFunctionBreakpoints
if (error instanceof Error) {
console.error(`Error setting breakpoints: ${error.message}`);
} else {
// handle adapters that send failed DebugProtocol.SetFunctionBreakpoints for invalid breakpoints
const genericMessage =
'Function breakpoint not valid for current debug session';
const message = error.message ? `${error.message}` : genericMessage;
console.warn(
`Could not handle function breakpoints: ${message}, disabling...`
);
enabled.forEach((b) =>
b.update({
raw: {
verified: false,
message,
},
})
);
}
}
}
this.setBreakpoints(affectedUri, all);
}
protected override async sendSourceBreakpoints(
affectedUri: URI,
sourceModified?: boolean
): Promise<void> {
const source = await this.toSource(affectedUri);
const all = this.breakpoints
.findMarkers({ uri: affectedUri })
.map(
({ data }) =>
new DebugSourceBreakpoint(data, this.asDebugBreakpointOptions())
);
const enabled = all.filter((b) => b.enabled);
try {
const breakpoints = enabled.map(({ origin }) => origin.raw);
const response = await this.sendRequest('setBreakpoints', {
source: source.raw,
sourceModified,
breakpoints,
lines: breakpoints.map(({ line }) => line),
});
response.body.breakpoints.forEach((raw, index) => {
// node debug adapter returns more breakpoints sometimes
if (enabled[index]) {
enabled[index].update({ raw });
}
});
} catch (error) {
// could be error or promise rejection of DebugProtocol.SetBreakpointsResponse
if (error instanceof Error) {
console.error(`Error setting breakpoints: ${error.message}`);
} else {
// handle adapters that send failed DebugProtocol.SetBreakpointsResponse for invalid breakpoints
const genericMessage = 'Breakpoint not valid for current debug session';
const message = error.message ? `${error.message}` : genericMessage;
console.warn(
`Could not handle breakpoints for ${affectedUri}: ${message}, disabling...`
);
enabled.forEach((b) =>
b.update({
raw: {
verified: false,
message,
},
})
);
}
}
this.setSourceBreakpoints(affectedUri, all);
}
protected override doUpdateThreads(
threads: DebugProtocol.Thread[],
stoppedDetails?: StoppedDetails
): void {
const existing = this._threads;
this._threads = new Map();
for (const raw of threads) {
const id = raw.id;
const thread = existing.get(id) || new DebugThread(this); // patched debug thread
this._threads.set(id, thread);
const data: Partial<Mutable<DebugThreadData>> = { raw };
if (stoppedDetails) {
if (stoppedDetails.threadId === id) {
data.stoppedDetails = stoppedDetails;
} else if (stoppedDetails.allThreadsStopped) {
data.stoppedDetails = {
// When a debug adapter notifies us that all threads are stopped,
// we do not know why the others are stopped, so we should default
// to something generic.
reason: '',
};
}
}
thread.update(data);
}
this.updateCurrentThread(stoppedDetails);
}
}

View File

@@ -0,0 +1,32 @@
import { WidgetOpenerOptions } from '@theia/core/lib/browser/widget-open-handler';
import { Range } from '@theia/core/shared/vscode-languageserver-types';
import { DebugStackFrame as TheiaDebugStackFrame } from '@theia/debug/lib/browser/model/debug-stack-frame';
import { EditorWidget } from '@theia/editor/lib/browser/editor-widget';
export class DebugStackFrame extends TheiaDebugStackFrame {
override async open(
options: WidgetOpenerOptions = {
mode: 'reveal',
}
): Promise<EditorWidget | undefined> {
if (!this.source) {
return undefined;
}
const { line, column, endLine, endColumn, source } = this.raw;
if (!source) {
return undefined;
}
// create selection based on VS Code
// https://github.com/eclipse-theia/theia/issues/11880
const selection = Range.create(
line,
column,
endLine || line,
endColumn || column
);
this.source.open({
...options,
selection,
});
}
}

View File

@@ -0,0 +1,22 @@
import { DebugStackFrame as TheiaDebugStackFrame } from '@theia/debug/lib/browser/model/debug-stack-frame';
import { DebugThread as TheiaDebugThread } from '@theia/debug/lib/browser/model/debug-thread';
import { DebugProtocol } from '@vscode/debugprotocol';
import { DebugStackFrame } from './debug-stack-frame';
export class DebugThread extends TheiaDebugThread {
protected override doUpdateFrames(
frames: DebugProtocol.StackFrame[]
): TheiaDebugStackFrame[] {
const result = new Set<TheiaDebugStackFrame>();
for (const raw of frames) {
const id = raw.id;
const frame =
this._frames.get(id) || new DebugStackFrame(this, this.session); // patched debug stack frame
this._frames.set(id, frame);
frame.update({ raw });
result.add(frame);
}
this.updateCurrentFrame();
return [...result.values()];
}
}

View File

@@ -0,0 +1,85 @@
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { CommandRegistry } from '@theia/core/lib/common/command';
import {
ActionMenuNode,
CompositeMenuNode,
MenuModelRegistry,
} from '@theia/core/lib/common/menu';
import { nls } from '@theia/core/lib/common/nls';
import { inject, injectable } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { DebugState } from '@theia/debug/lib/browser/debug-session';
import { DebugAction } from './debug-action';
import { DebugToolBar as TheiaDebugToolbar } from '@theia/debug/lib/browser/view/debug-toolbar-widget';
@injectable()
export class DebugToolbar extends TheiaDebugToolbar {
@inject(CommandRegistry) private readonly commandRegistry: CommandRegistry;
@inject(MenuModelRegistry)
private readonly menuModelRegistry: MenuModelRegistry;
@inject(ContextKeyService)
private readonly contextKeyService: ContextKeyService;
protected override render(): React.ReactNode {
const { state } = this.model;
return (
<React.Fragment>
{this.renderContributedCommands()}
{this.renderContinue()}
<DebugAction
enabled={state === DebugState.Stopped}
run={this.stepOver}
label={nls.localizeByDefault('Step Over')}
iconClass="debug-step-over"
ref={this.setStepRef}
/>
<DebugAction
enabled={state === DebugState.Stopped}
run={this.stepIn}
label={nls.localizeByDefault('Step Into')}
iconClass="debug-step-into"
/>
<DebugAction
enabled={state === DebugState.Stopped}
run={this.stepOut}
label={nls.localizeByDefault('Step Out')}
iconClass="debug-step-out"
/>
<DebugAction
enabled={state !== DebugState.Inactive}
run={this.restart}
label={nls.localizeByDefault('Restart')}
iconClass="debug-restart"
/>
{this.renderStart()}
</React.Fragment>
);
}
private renderContributedCommands(): React.ReactNode {
return this.menuModelRegistry
.getMenu(TheiaDebugToolbar.MENU)
.children.filter((node) => node instanceof CompositeMenuNode)
.map((node) => (node as CompositeMenuNode).children)
.reduce((acc, curr) => acc.concat(curr), [])
.filter((node) => node instanceof ActionMenuNode)
.map((node) => this.debugAction(node as ActionMenuNode));
}
private debugAction(node: ActionMenuNode): React.ReactNode {
const { label, command, when, icon: iconClass = '' } = node;
const run = () => this.commandRegistry.executeCommand(command);
const enabled = when ? this.contextKeyService.match(when) : true;
return (
enabled && (
<DebugAction
key={command}
enabled={enabled}
label={label}
iconClass={iconClass}
run={run}
/>
)
);
}
}