diff --git a/lana/src/codelenses/ShowAnalysisCodeLens.ts b/lana/src/codelenses/ShowAnalysisCodeLens.ts index f215820f..91d37555 100644 --- a/lana/src/codelenses/ShowAnalysisCodeLens.ts +++ b/lana/src/codelenses/ShowAnalysisCodeLens.ts @@ -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; @@ -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, diff --git a/lana/src/codelenses/__tests__/ShowAnalysisCodeLens.test.ts b/lana/src/codelenses/__tests__/ShowAnalysisCodeLens.test.ts new file mode 100644 index 00000000..93765430 --- /dev/null +++ b/lana/src/codelenses/__tests__/ShowAnalysisCodeLens.test.ts @@ -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); + }); +}); diff --git a/lana/src/display/OpenFileInPackage.ts b/lana/src/display/OpenFileInPackage.ts index 6ef2c762..482ee747 100644 --- a/lana/src/display/OpenFileInPackage.ts +++ b/lana/src/display/OpenFileInPackage.ts @@ -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'; @@ -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}`); diff --git a/lana/src/display/QuickPickWorkspace.ts b/lana/src/display/QuickPickWorkspace.ts index 7a84c270..8fb07054 100644 --- a/lana/src/display/QuickPickWorkspace.ts +++ b/lana/src/display/QuickPickWorkspace.ts @@ -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'); } diff --git a/lana/src/display/__tests__/OpenFileInPackage.test.ts b/lana/src/display/__tests__/OpenFileInPackage.test.ts index 194bee7d..4b4b12b9 100644 --- a/lana/src/display/__tests__/OpenFileInPackage.test.ts +++ b/lana/src/display/__tests__/OpenFileInPackage.test.ts @@ -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'; @@ -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 }); @@ -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); diff --git a/lana/src/display/__tests__/QuickPickWorkspace.test.ts b/lana/src/display/__tests__/QuickPickWorkspace.test.ts new file mode 100644 index 00000000..817f4125 --- /dev/null +++ b/lana/src/display/__tests__/QuickPickWorkspace.test.ts @@ -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(); + }); +}); diff --git a/lana/src/language/ApexLogLanguageDetector.ts b/lana/src/language/ApexLogLanguageDetector.ts index 4a4160e8..44cdce7c 100644 --- a/lana/src/language/ApexLogLanguageDetector.ts +++ b/lana/src/language/ApexLogLanguageDetector.ts @@ -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) { @@ -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); @@ -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); @@ -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; } diff --git a/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts index 2ed47cd8..216d5ad5 100644 --- a/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts +++ b/lana/src/salesforce/codesymbol/__tests__/SfdxProjectReader.test.ts @@ -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'); diff --git a/log-viewer/src/features/app/LogViewer.ts b/log-viewer/src/features/app/LogViewer.ts index 0d0a1fef..7dcb4719 100644 --- a/log-viewer/src/features/app/LogViewer.ts +++ b/log-viewer/src/features/app/LogViewer.ts @@ -126,9 +126,12 @@ export class LogViewer extends LitElement { constructor() { super(); - vscodeMessenger.request('fetchLog').then((msg) => { - this._handleLogFetch(msg); - }); + vscodeMessenger + .request('fetchLog') + .then((msg) => this._handleLogFetch(msg)) + .catch((error: unknown) => { + this.logProblems = [this._logRequestError(error)]; + }); document.addEventListener('show-tab', (e: Event) => { this._showTabEvent(e); @@ -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. diff --git a/log-viewer/src/features/app/__tests__/LogViewer.test.ts b/log-viewer/src/features/app/__tests__/LogViewer.test.ts new file mode 100644 index 00000000..841241a0 --- /dev/null +++ b/log-viewer/src/features/app/__tests__/LogViewer.test.ts @@ -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', + }), + ]); + }); +});