Skip to content
Merged
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: 6 additions & 6 deletions lana/src/codelenses/ShowAnalysisCodeLens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CodeLens, Range, languages, type CodeLensProvider, type TextDocument }

import type { Context } from '../Context.js';
import { ShowLogAnalysis } from '../commands/ShowLogAnalysis.js';
import { isApexLogContent } from '../language/ApexLogLanguageDetector.js';
import { APEX_LOG_URI_SCHEMES, isApexLogContent } from '../language/ApexLogLanguageDetector.js';

class ShowAnalysisCodeLens implements CodeLensProvider {
context: Context;
Expand All @@ -28,11 +28,11 @@ class ShowAnalysisCodeLens implements CodeLensProvider {
}

static apply(context: Context): void {
const docSelector = [
{ scheme: 'file', language: 'apexlog' },
{ scheme: 'file', pattern: '**/*.log' },
{ scheme: 'file', pattern: '**/*.txt' },
];
const docSelector = APEX_LOG_URI_SCHEMES.flatMap((scheme) => [
{ scheme, language: 'apexlog' },
{ scheme, pattern: '**/*.log' },
{ scheme, pattern: '**/*.txt' },
]);

const codeLensProviderDisposable = languages.registerCodeLensProvider(
docSelector,
Expand Down
27 changes: 27 additions & 0 deletions lana/src/codelenses/__tests__/ShowAnalysisCodeLens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import { languages } from 'vscode';

import type { Context } from '../../Context.js';
import { APEX_LOG_URI_SCHEMES } from '../../language/ApexLogLanguageDetector.js';
import { ShowAnalysisCodeLens } from '../ShowAnalysisCodeLens.js';

describe('ShowAnalysisCodeLens.apply', () => {
it('registers log selectors for every supported workspace scheme', () => {
const subscriptions: { dispose(): void }[] = [];
const context = { context: { subscriptions } } as unknown as Context;

ShowAnalysisCodeLens.apply(context);

expect(languages.registerCodeLensProvider).toHaveBeenCalledWith(
APEX_LOG_URI_SCHEMES.flatMap((scheme) => [
{ scheme, language: 'apexlog' },
{ scheme, pattern: '**/*.log' },
{ scheme, pattern: '**/*.txt' },
]),
expect.any(ShowAnalysisCodeLens),
);
expect(subscriptions).toHaveLength(1);
});
});
11 changes: 2 additions & 9 deletions lana/src/display/OpenFileInPackage.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
/*
* Copyright (c) 2020 Certinia Inc. All rights reserved.
*/
import {
Position,
Selection,
ViewColumn,
workspace,
type TextDocumentShowOptions,
type Uri,
} from 'vscode';
import { Position, Selection, ViewColumn, workspace, type TextDocumentShowOptions } from 'vscode';
import { Utils } from 'vscode-uri';

import type { Context } from '../Context.js';
Expand Down Expand Up @@ -51,7 +44,7 @@ export class OpenFileInPackage {
selection: new Selection(pos, pos),
};

context.display.showFile(uri.fsPath, options);
context.display.showFile(uri, options);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
context.display.showErrorMessage(`Unable to open '${symbolName}': ${message}`);
Expand Down
4 changes: 2 additions & 2 deletions lana/src/display/QuickPickWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ export class QuickPickWorkspace {

if (workspaceFolders.length > 1) {
const [workspace] = await QuickPick.pick(
workspaceFolders.map((ws) => new Item(ws.name(), ws.path(), '')),
workspaceFolders.map((ws) => new Item(ws.name(), ws.uri, '')),
new Options('Select a workspace:'),
);

if (workspace) {
const selectedWs = workspaceFolders.find((ws) => ws.path() === workspace.description);
const selectedWs = workspaceFolders.find((ws) => ws.uri === workspace.description);
if (!selectedWs) {
throw new Error('Selected workspace not found');
}
Expand Down
12 changes: 8 additions & 4 deletions lana/src/display/__tests__/OpenFileInPackage.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*
* Copyright (c) 2025 Certinia Inc. All rights reserved.
*/
import { workspace } from 'vscode';
import { Uri, workspace } from 'vscode';
import type { Context } from '../../Context';
import { getMethodLine, parseApex } from '../../salesforce/ApexParser/ApexSymbolLocator';
import { OpenFileInPackage } from '../OpenFileInPackage';
Expand Down Expand Up @@ -79,9 +79,10 @@ describe('OpenFileInPackage.openFileForSymbol', () => {

it('opens the file at the resolved line and character on an exact match', async () => {
const { context, workspaceManager, display } = createContext();
const uri = Uri.parse('vscode-vfs://github/workspace/force-app/MyClass.cls');
workspaceManager.findSymbol.mockResolvedValue({
status: 'found',
uri: { fsPath: '/ws/force-app/MyClass.cls', path: '/ws/force-app/MyClass.cls' },
uri,
});
mockGetMethodLine.mockReturnValue({ line: 12, character: 4, isExactMatch: true });

Expand All @@ -93,8 +94,11 @@ describe('OpenFileInPackage.openFileForSymbol', () => {
);
expect(display.showErrorMessage).not.toHaveBeenCalled();
expect(display.showFile).toHaveBeenCalledTimes(1);
const [path, options] = display.showFile.mock.calls[0];
expect(path).toBe('/ws/force-app/MyClass.cls');
const [openedUri, options] = display.showFile.mock.calls[0];
expect(openedUri).toBe(uri);
expect(openedUri).toEqual(
expect.objectContaining({ scheme: 'vscode-vfs', authority: 'github' }),
);
// line is converted to zero-indexed; character used as-is
expect(options.selection.start).toEqual(expect.objectContaining({ line: 11, character: 4 }));
expect(options.viewColumn).toBe(-1);
Expand Down
40 changes: 40 additions & 0 deletions lana/src/display/__tests__/QuickPickWorkspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*/
import type { Context } from '../../Context.js';
import type { VSWorkspace } from '../../workspace/VSWorkspace.js';
import { QuickPick } from '../QuickPick.js';
import { QuickPickWorkspace } from '../QuickPickWorkspace.js';

function workspace(name: string, uri: string): VSWorkspace {
return {
name: () => name,
uri,
path: jest.fn(() => {
throw new Error('desktop path must not be read');
}),
} as unknown as VSWorkspace;
}

describe('QuickPickWorkspace.pickOrReturn', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('displays and matches multi-root workspaces by URI', async () => {
const local = workspace('Local', 'file:///workspace/local');
const virtual = workspace('Virtual', 'vscode-vfs://github/project/root');
const pick = jest.spyOn(QuickPick, 'pick').mockImplementation(async (items) => {
expect(items.map((item) => item.description)).toEqual([local.uri, virtual.uri]);
return [items[1]!];
});
const context = {
workspaceManager: { workspaceFolders: [local, virtual] },
} as unknown as Context;

await expect(QuickPickWorkspace.pickOrReturn(context)).resolves.toBe(virtual);
expect(pick).toHaveBeenCalledTimes(1);
expect(local.path).not.toHaveBeenCalled();
expect(virtual.path).not.toHaveBeenCalled();
});
});
9 changes: 4 additions & 5 deletions lana/src/language/ApexLogLanguageDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const EXECUTION_STARTED = /^\d{2}:\d{2}:\d{2}\.\d{1,} \(\d+\)\|EXECUTION_STARTED
const USER_INFO = /^\d{2}:\d{2}:\d{2}\.\d{1,} \(\d+\)\|USER_INFO\|/;
const DETECT_EXTENSIONS = new Set(['.log', '.txt']);
const MAX_LINES_TO_CHECK = 100;
export const APEX_LOG_URI_SCHEMES: readonly string[] = ['file', 'vscode-vfs', 'memfs'];

export function isApexLogContent(doc: TextDocument): boolean {
if (doc.lineCount === 0) {
Expand Down Expand Up @@ -71,9 +72,8 @@ function getActiveTabUri(): Uri | undefined {

function updateContextKey(): void {
const editor = window.activeTextEditor;
const supportedSchemes = ['file', 'vscode-vfs', 'memfs'];

if (editor && supportedSchemes.includes(editor.document.uri.scheme)) {
if (editor && APEX_LOG_URI_SCHEMES.includes(editor.document.uri.scheme)) {
const doc = editor.document;
if (hasDetectExtension(doc.uri)) {
const detected = isApexLogContent(doc);
Expand All @@ -86,7 +86,7 @@ function updateContextKey(): void {

// Fallback to tab API for large files where activeTextEditor is undefined
const tabUri = getActiveTabUri();
if (tabUri && supportedSchemes.includes(tabUri.scheme) && hasDetectExtension(tabUri)) {
if (tabUri && APEX_LOG_URI_SCHEMES.includes(tabUri.scheme) && hasDetectExtension(tabUri)) {
// isApexLogFile is async; fire-and-forget is acceptable here for context key update
void isApexLogFile(tabUri).then((detected) => {
commands.executeCommand('setContext', 'lana.isApexLog', detected);
Expand Down Expand Up @@ -128,8 +128,7 @@ export class ApexLogLanguageDetector {
}

function detectAndSetLanguage(doc: TextDocument): void {
const supportedSchemes = ['file', 'vscode-vfs', 'memfs'];
if (doc.languageId === 'apexlog' || !supportedSchemes.includes(doc.uri.scheme)) {
if (doc.languageId === 'apexlog' || !APEX_LOG_URI_SCHEMES.includes(doc.uri.scheme)) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
* Copyright (c) 2025 Certinia Inc. All rights reserved.
*/
import { RelativePattern, Uri, workspace, type WorkspaceFolder } from 'vscode';
import { Utils } from 'vscode-uri';
import { getProjects } from '../SfdxProjectReader';

jest.mock('vscode');
Expand Down
21 changes: 18 additions & 3 deletions log-viewer/src/features/app/LogViewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,12 @@ export class LogViewer extends LitElement {

constructor() {
super();
vscodeMessenger.request<LogDataEvent>('fetchLog').then((msg) => {
this._handleLogFetch(msg);
});
vscodeMessenger
.request<LogDataEvent>('fetchLog')
.then((msg) => this._handleLogFetch(msg))
.catch((error: unknown) => {
this.logProblems = [this._logRequestError(error)];
});

document.addEventListener('show-tab', (e: Event) => {
this._showTabEvent(e);
Expand Down Expand Up @@ -285,6 +288,18 @@ export class LogViewer extends LitElement {
}
}

private _logRequestError(error: unknown): LogIssue {
return {
summary: 'Could not load log',
message: error instanceof Error ? error.message : String(error),
severity: 'error',
label: null,
action: null,
category: null,
timestamp: null,
};
}

/**
* Reads the log, returning the failure as a {@link LogIssue} rather than publishing it —
* the caller owns `logProblems` so it can rebuild the list for each load.
Expand Down
41 changes: 41 additions & 0 deletions log-viewer/src/features/app/__tests__/LogViewer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Certinia Inc. All rights reserved.
*
* @jest-environment jsdom
*/
import { describe, expect, it } from '@jest/globals';

jest.mock('../../../core/messaging/VSCodeExtensionMessenger.js', () => ({
VSCodeExtensionMessenger: {
listen: jest.fn(() => jest.fn()),
},
vscodeMessenger: {
request: jest.fn(() => Promise.reject(new Error('No extension host to answer "fetchLog"'))),
},
}));

jest.mock('../AppHeader.js', () => ({}));
jest.mock('../../../components/LogInspector.js', () => ({}));

import { vscodeMessenger } from '../../../core/messaging/VSCodeExtensionMessenger.js';
import { LogViewer } from '../LogViewer.js';

const requestMock = vscodeMessenger.request as jest.Mock;

describe('LogViewer', () => {
it('surfaces a fetchLog request failure', async () => {
const viewer = new LogViewer();

await Promise.resolve();
await Promise.resolve();

expect(requestMock).toHaveBeenCalledWith('fetchLog');
expect(viewer.logProblems).toEqual([
expect.objectContaining({
summary: 'Could not load log',
message: 'No extension host to answer "fetchLog"',
severity: 'error',
}),
]);
});
});