From 1256bb3faf7356ebd8aa087face35200a02178eb Mon Sep 17 00:00:00 2001 From: Rogier Muller Date: Wed, 20 May 2026 07:24:54 +0200 Subject: [PATCH] Add selected expression mini trace --- .github/workflows/release.yml | 2 - package.json | 11 ++++ scripts/generate-contributes.ts | 6 ++ src/commands.ts | 2 + src/constants.ts | 1 + src/miniTrace.ts | 98 +++++++++++++++++++++++++++++++++ src/miniTraceReport.ts | 32 +++++++++++ test/index.test.ts | 11 ++++ 8 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 src/miniTrace.ts create mode 100644 src/miniTraceReport.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c55a923..7b9167b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,6 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: lts/-1 - cache: pnpm - name: 📦 Install dependencies run: pnpm install --frozen-lockfile @@ -42,7 +41,6 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: lts/-1 - cache: pnpm - run: npx changelogithub env: diff --git a/package.json b/package.json index ddfb78b..f796655 100644 --- a/package.json +++ b/package.json @@ -279,6 +279,11 @@ "command": "tsperf.tracer.gotoTracePosition", "category": "Tracer" }, + { + "title": "Measure selected expression", + "command": "tsperf.tracer.measureSelection", + "category": "Tracer" + }, { "title": "Open trace viewer", "icon": { @@ -315,6 +320,12 @@ ], "menus": { "commandPalette": [ + { + "title": "Measure selected expression", + "when": "!notebookEditorFocused && (editorLangId == 'typescript' || editorLangId == 'typescriptreact')", + "command": "tsperf.tracer.measureSelection", + "category": "Tracer" + }, { "title": "Send Trace to Trace Viewer", "when": "!notebookEditorFocused && editorLangId == 'json'", diff --git a/scripts/generate-contributes.ts b/scripts/generate-contributes.ts index 59f33f1..b5109e6 100755 --- a/scripts/generate-contributes.ts +++ b/scripts/generate-contributes.ts @@ -32,6 +32,12 @@ const commandRecord: Record = { 'tsperf.tracer.gotoTracePosition': { title: 'Goto position in trace', }, + 'tsperf.tracer.measureSelection': { + title: 'Measure selected expression', + when: { + pallete: '!notebookEditorFocused && (editorLangId == \'typescript\' || editorLangId == \'typescriptreact\')', + }, + }, 'tsperf.tracer.openInBrowser': { title: 'Open trace viewer', icon: { diff --git a/src/commands.ts b/src/commands.ts index 57b522b..dd010f3 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -13,6 +13,7 @@ import { addTraceFile, getWorkspacePath, openTerminal, openTraceDirectoryExterna import { addTraceDiagnostics, clearTaceDiagnostics } from './traceDiagnostics' import { setStatusBarState } from './statusBar' import { afterWatches, projectPath, saveName, state, traceFiles, traceRunning } from './appState' +import { measureSelectedExpression } from './miniTrace' const readdir = promisify(readdirC) @@ -23,6 +24,7 @@ const commandHandlers: Record< 'tsperf.tracer.runTrace': () => (...args: unknown[]) => runTrace(args), 'tsperf.tracer.openInBrowser': (context: vscode.ExtensionContext) => () => prepareWebView(context), 'tsperf.tracer.gotoTracePosition': (context: vscode.ExtensionContext) => () => gotoTracePosition(context), + 'tsperf.tracer.measureSelection': () => () => measureSelectedExpression(), 'tsperf.tracer.sendTrace': () => (event: unknown) => { if (!(Array.isArray(event) && event[0] instanceof vscode.Uri)) return diff --git a/src/constants.ts b/src/constants.ts index fc97b3b..a657de4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -23,6 +23,7 @@ export type ConfigKey = typeof configKeys[number] export const commandIds = [ 'tsperf.tracer.gotoTracePosition', + 'tsperf.tracer.measureSelection', 'tsperf.tracer.openInBrowser', 'tsperf.tracer.runTrace', 'tsperf.tracer.sendTrace', diff --git a/src/miniTrace.ts b/src/miniTrace.ts new file mode 100644 index 0000000..434e4e0 --- /dev/null +++ b/src/miniTrace.ts @@ -0,0 +1,98 @@ +import { performance } from 'node:perf_hooks' +import * as vscode from 'vscode' +import type { server } from 'typescript' +import { getCurrentConfig } from './configuration' +import { log } from './logger' +import { type MiniTraceSample, formatMiniTraceReport } from './miniTraceReport' + +function getTargetRange(editor: vscode.TextEditor) { + if (!editor.selection.isEmpty) + return editor.selection + + return editor.document.getWordRangeAtPosition(editor.selection.active) +} + +function formatTargetLabel(text: string) { + const compact = text.trim().replace(/\s+/g, ' ') + if (!compact) + return 'at cursor' + + if (compact.length <= 48) + return `"${compact}"` + + return `"${compact.slice(0, 45)}..."` +} + +async function timedRequest(name: string, request: () => Thenable) { + const start = performance.now() + const response = await request() + const duration = performance.now() - start + log({ miniTraceRequest: name, duration }) + return { response, duration } +} + +async function measurePosition(fileName: string, line: number, offset: number): Promise { + const [quickInfo, completions] = await Promise.all([ + timedRequest('quickinfo-full', () => vscode.commands.executeCommand( + 'typescript.tsserverRequest', + 'quickinfo-full', + { file: fileName, line, offset } satisfies server.protocol.FileLocationRequestArgs, + )), + timedRequest('completionInfo', () => vscode.commands.executeCommand( + 'typescript.tsserverRequest', + 'completionInfo', + { file: fileName, line, offset } satisfies server.protocol.CompletionsRequestArgs, + )), + ]) + + if (!(quickInfo.response as server.protocol.QuickInfoResponse)?.success) + log('mini trace quickinfo-full request did not succeed') + + if (!(completions.response as server.protocol.CompletionInfoResponse)?.success) + log('mini trace completionInfo request did not succeed') + + return { + quickInfoDuration: quickInfo.duration, + completionsDuration: completions.duration, + } +} + +export async function measureSelectedExpression() { + const editor = vscode.window.activeTextEditor + if (!editor) { + vscode.window.showWarningMessage('Open a TypeScript file to measure an expression') + return + } + + if (editor.document.languageId !== 'typescript' && editor.document.languageId !== 'typescriptreact') { + vscode.window.showWarningMessage('Mini trace measurements only run in TypeScript files') + return + } + + const range = getTargetRange(editor) + if (!range) { + vscode.window.showWarningMessage('Select an expression or place the cursor on an identifier') + return + } + + const { benchmarkIterations, restartTsserverOnIteration } = getCurrentConfig() + const iterations = Math.max(1, benchmarkIterations) + const position = range.start + const label = formatTargetLabel(editor.document.getText(range)) + const samples: MiniTraceSample[] = [] + + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: `Measuring ${label}`, + cancellable: false, + }, async () => { + for (let i = 0; i < iterations; i++) { + if (restartTsserverOnIteration) + await vscode.commands.executeCommand('typescript.restartTsServer') + + samples.push(await measurePosition(editor.document.fileName, position.line, position.character)) + } + }) + + vscode.window.showInformationMessage(formatMiniTraceReport({ label, samples })) +} diff --git a/src/miniTraceReport.ts b/src/miniTraceReport.ts new file mode 100644 index 0000000..f80658f --- /dev/null +++ b/src/miniTraceReport.ts @@ -0,0 +1,32 @@ +export interface MiniTraceSample { + quickInfoDuration: number + completionsDuration: number +} + +export interface MiniTraceReportArgs { + label: string + samples: MiniTraceSample[] +} + +function average(values: number[]) { + const finite = values.filter(Number.isFinite) + if (finite.length === 0) + return Number.NaN + + return finite.reduce((sum, value) => sum + value, 0) / finite.length +} + +function formatMs(value: number) { + if (!Number.isFinite(value)) + return 'n/a' + + return `${Math.round(value)}ms` +} + +export function formatMiniTraceReport({ label, samples }: MiniTraceReportArgs) { + const quickInfoAverage = average(samples.map(sample => sample.quickInfoDuration)) + const completionsAverage = average(samples.map(sample => sample.completionsDuration)) + const runs = samples.length === 1 ? '1 run' : `${samples.length} runs` + + return `Mini trace ${label}: quick info ${formatMs(quickInfoAverage)}, completions ${formatMs(completionsAverage)} (${runs})` +} diff --git a/test/index.test.ts b/test/index.test.ts index 401553c..ee42eb9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,7 +1,18 @@ import { describe, expect, it } from 'vitest' +import { formatMiniTraceReport } from '../src/miniTraceReport' describe('should', () => { it('exported', () => { expect(1).toEqual(1) }) + + it('formats selected expression mini trace measurements', () => { + expect(formatMiniTraceReport({ + label: '"value"', + samples: [ + { quickInfoDuration: 12.2, completionsDuration: 18.9 }, + { quickInfoDuration: 14.7, completionsDuration: 20.1 }, + ], + })).toBe('Mini trace "value": quick info 13ms, completions 20ms (2 runs)') + }) })