Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions __mocks__/vscode.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ class MockTreeItem {
}
}

class MockRange {
start;
end;
constructor(startLine, startCharacter, endLine, endCharacter) {
this.start = { line: startLine, character: startCharacter };
this.end = { line: endLine, character: endCharacter };
}
}

module.exports = {
EventEmitter: jest.fn(() => {
const callbacks = [];
Expand All @@ -60,6 +69,7 @@ module.exports = {
};
}),
Uri: URI,
Range: MockRange,
TreeItem: MockTreeItem,
TreeItemCollapsibleState: MockTreeItemCollapsibleState,
window: {
Expand All @@ -86,6 +96,8 @@ module.exports = {
reveal: jest.fn().mockResolvedValue(undefined),
})),
registerTreeDataProvider: jest.fn(() => ({ dispose: jest.fn() })),
createTextEditorDecorationType: jest.fn(() => ({ dispose: jest.fn() })),
onDidChangeActiveTextEditor: jest.fn(() => ({ dispose: jest.fn() })),
showErrorMessage: jest.fn(),
showInformationMessage: jest.fn(() => Promise.resolve(undefined)),
showWarningMessage: jest.fn(),
Expand Down
3 changes: 3 additions & 0 deletions src/desktop/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { GenericCommands } from '../features/generic-commands';
import { ComponentViewer } from '../views/component-viewer/component-viewer';
import { ComponentViewerTreeDataProvider } from '../views/component-viewer/component-viewer-tree-view';
import { CorePeripherals } from '../views/core-peripherals/core-peripherals';
import { SourceFileHighlighting } from '../features/source-file-highlighting/source-file-highlighting';

const BUILTIN_TOOLS_PATHS = [
'tools/pyocd/pyocd',
Expand Down Expand Up @@ -58,6 +59,7 @@ export const activate = async (context: vscode.ExtensionContext): Promise<void>
corePeripheralsTreeDataProvider = new ComponentViewerTreeDataProvider();
const componentViewer = new ComponentViewer(context, componentViewerTreeDataProvider);
const corePeripherals = new CorePeripherals(context, corePeripheralsTreeDataProvider);
const sourceFileHighlighting = new SourceFileHighlighting(context);

addToolsToPath(context, BUILTIN_TOOLS_PATHS);
// Activate generic commands
Expand All @@ -69,6 +71,7 @@ export const activate = async (context: vscode.ExtensionContext): Promise<void>
cpuStates.activate(gdbtargetDebugTracker);
cpuStatesCommands.activate(context, cpuStates);
cpuStatesStatusBarItem.activate(context, cpuStates);
sourceFileHighlighting.activate();
// Live Watch view
logger.debug('Activating Live Watch Tree Data Provider');
if (!await liveWatchTreeDataProvider.activate(gdbtargetDebugTracker)) {
Expand Down
165 changes: 165 additions & 0 deletions src/features/source-file-highlighting/source-file-highlighting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* Copyright 2026 Arm Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// generated with AI

import * as vscode from 'vscode';
import { SourceFileHighlighting } from './source-file-highlighting';
import { debugSessionFactory, extensionContextFactory } from '../../__test__/vscode.factory';

type ActiveDebugSessionListener = (session: vscode.DebugSession | undefined) => void;
type ActiveTextEditorListener = (editor: vscode.TextEditor | undefined) => void;

function makeDocument(options: {
fileName?: string;
lineCount?: number;
scheme?: string;
} = {}): vscode.TextDocument {
const fileName = options.fileName ?? '/workspace/source/main.c';
const uri = options.scheme === 'file' || options.scheme === undefined
? vscode.Uri.file(fileName)
: { ...vscode.Uri.file(fileName), scheme: options.scheme };
return {
fileName,
uri,
lineCount: options.lineCount ?? 12
} as vscode.TextDocument;
}

function makeEditor(document: vscode.TextDocument = makeDocument()): jest.Mocked<vscode.TextEditor> {
return {
document,
setDecorations: jest.fn()
} as unknown as jest.Mocked<vscode.TextEditor>;
}

async function waitForAsyncCallbacks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}

describe('SourceFileHighlighting', () => {
let activeDebugSessionListener: ActiveDebugSessionListener;
let activeTextEditorListener: ActiveTextEditorListener;
let debugSessionDisposable: vscode.Disposable;
let editorDisposable: vscode.Disposable;

beforeEach(() => {
jest.clearAllMocks();
debugSessionDisposable = { dispose: jest.fn() };
editorDisposable = { dispose: jest.fn() };
(vscode.debug.onDidChangeActiveDebugSession as jest.Mock).mockImplementation((listener: ActiveDebugSessionListener) => {
activeDebugSessionListener = listener;
return debugSessionDisposable;
});
(vscode.window.onDidChangeActiveTextEditor as jest.Mock).mockImplementation((listener: ActiveTextEditorListener) => {
activeTextEditorListener = listener;
return editorDisposable;
});
});

it('registers active debug session and active editor listeners', () => {
const context = extensionContextFactory();
const sourceFileHighlighting = new SourceFileHighlighting(context);

sourceFileHighlighting.activate();

expect(vscode.debug.onDidChangeActiveDebugSession).toHaveBeenCalledWith(expect.any(Function));
expect(vscode.window.onDidChangeActiveTextEditor).toHaveBeenCalledWith(expect.any(Function));
expect(context.subscriptions).toContain(debugSessionDisposable);
});

it('requests breakpoint locations for the active file and highlights unique executable lines', async () => {
const context = extensionContextFactory();
const sourceFileHighlighting = new SourceFileHighlighting(context);
const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' });
(debugSession.customRequest as jest.Mock).mockResolvedValueOnce({
breakpoints: [
{ line: 2 },
{ line: 7 },
{ line: 2 }
]
});
const editor = makeEditor(makeDocument({ fileName: '/workspace/source/main.c', lineCount: 20 }));

sourceFileHighlighting.activate();
activeDebugSessionListener(debugSession);
activeTextEditorListener(editor);
await waitForAsyncCallbacks();

expect(debugSession.customRequest).toHaveBeenCalledWith('breakpointLocations', {
source: { path: '/workspace/source/main.c' },
line: 1,
endLine: 20
});
expect(editor.setDecorations).toHaveBeenCalledTimes(1);
expect(editor.setDecorations).toHaveBeenCalledWith(
(vscode.window.createTextEditorDecorationType as jest.Mock).mock.results[0].value,
[
{ range: new vscode.Range(1, 0, 1, 0) },
{ range: new vscode.Range(6, 0, 6, 0) }
]
);
});

it('does not request breakpoint locations before an active debug session exists', async () => {
const context = extensionContextFactory();
const sourceFileHighlighting = new SourceFileHighlighting(context);
const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' });
const editor = makeEditor();

sourceFileHighlighting.activate();
activeTextEditorListener(editor);
await waitForAsyncCallbacks();

expect(debugSession.customRequest).not.toHaveBeenCalled();
expect(editor.setDecorations).not.toHaveBeenCalled();
});

it('does not request breakpoint locations for non-file editors', async () => {
const context = extensionContextFactory();
const sourceFileHighlighting = new SourceFileHighlighting(context);
const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' });
const editor = makeEditor(makeDocument({ scheme: 'untitled' }));

sourceFileHighlighting.activate();
activeDebugSessionListener(debugSession);
activeTextEditorListener(editor);
await waitForAsyncCallbacks();

expect(debugSession.customRequest).not.toHaveBeenCalled();
expect(editor.setDecorations).not.toHaveBeenCalled();
});

it('does not decorate when the adapter returns no breakpoint locations', async () => {
const context = extensionContextFactory();
const sourceFileHighlighting = new SourceFileHighlighting(context);
const debugSession = debugSessionFactory({ name: 'test-session', type: 'gdbtarget', request: 'launch' });
(debugSession.customRequest as jest.Mock).mockResolvedValueOnce(undefined);
const editor = makeEditor();

sourceFileHighlighting.activate();
activeDebugSessionListener(debugSession);
activeTextEditorListener(editor);
await waitForAsyncCallbacks();

expect(debugSession.customRequest).toHaveBeenCalledWith('breakpointLocations', {
source: { path: '/workspace/source/main.c' },
line: 1,
endLine: 12
});
expect(editor.setDecorations).not.toHaveBeenCalled();
});
});
79 changes: 79 additions & 0 deletions src/features/source-file-highlighting/source-file-highlighting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Copyright 2026 Arm Limited
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as vscode from 'vscode';
import { DebugProtocol } from '@vscode/debugprotocol';

export class SourceFileHighlighting {
private activeDebugSession: vscode.DebugSession | undefined;
private context: vscode.ExtensionContext;
private executableLineDecorator = vscode.window.createTextEditorDecorationType({
// turn it red for testing
backgroundColor: 'rgba(222, 199, 199, 0.3)',
// only highlight the margin of the line to avoid obscuring code
isWholeLine: true,
Comment on lines +24 to +27
});
Comment on lines +23 to +28

constructor(context: vscode.ExtensionContext) {
this.context = context;
}

public activate(): void {
this.registerToTrackerEvents();
vscode.window.onDidChangeActiveTextEditor(editor => {
this.handleOnDidChangeActiveTextEditor(editor);
});
Comment on lines +36 to +38
}

private registerToTrackerEvents(): void {
const onDidChangeActiveDebugSessionDisposable = vscode.debug.onDidChangeActiveDebugSession(session => {
this.activeDebugSession = session;
});
this.context.subscriptions.push(onDidChangeActiveDebugSessionDisposable);
}

private async handleOnDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined): Promise<void> {
if (!editor || !this.activeDebugSession) {
return;
}
const breakpointLocations = await this.getBreakpointLocations(editor);
if (!breakpointLocations) {
Comment on lines +44 to +53
return;
}
const executableLines = new Set(breakpointLocations.breakpoints.map((bp: DebugProtocol.BreakpointLocation) => bp.line));
const decorations: vscode.DecorationOptions[] = Array.from(executableLines).map((exeline: number) => {
const line = exeline - 1; // Convert to 0-based index
return {
range: new vscode.Range(line, 0, line, 0),
};
});
editor.setDecorations(this.executableLineDecorator, decorations);
}

private async getBreakpointLocations(editor: vscode.TextEditor): Promise<DebugProtocol.BreakpointLocationsResponse['body'] | void> {
if(editor.document.uri.scheme !== 'file') {
return;
}
const currentSourceFile = editor.document.fileName;
const args : DebugProtocol.BreakpointLocationsArguments = {
source: { path: currentSourceFile },
line: 1,
endLine: editor.document.lineCount, // Requesting breakpoint locations for the whole file
};
const breakpointLocations = await this.activeDebugSession?.customRequest('breakpointLocations', args);
return breakpointLocations;
}
}
Loading