diff --git a/package.json b/package.json index fdc11dd..b5163e5 100644 --- a/package.json +++ b/package.json @@ -54,15 +54,19 @@ }, "commands": [ { - "command": "minecraft-debugger.showMinecraftDiagnostics", - "title": "Minecraft Diagnostics: Show" + "command": "minecraft-debugger.liveDiagnostics", + "title": "Minecraft Diagnostics: Show Live Stats" + }, + { + "command": "minecraft-debugger.replayDiagnostics", + "title": "Minecraft Diagnostics: Open Stats Replay" }, { "command": "minecraft-debugger.minecraftReload", "title": "Minecraft Reload" } ], - "keybindings":[ + "keybindings": [ { "command": "minecraft-debugger.minecraftReload", "key": "ctrl+shift+r", @@ -242,4 +246,4 @@ "typescript": "^5.5.4", "vitest": "^2.1.3" } -} \ No newline at end of file +} diff --git a/src/Session.ts b/src/Session.ts index e484d37..20f2ce4 100644 --- a/src/Session.ts +++ b/src/Session.ts @@ -1,4 +1,3 @@ - // Copyright (C) Microsoft Corporation. All rights reserved. import { createConnection, Server, Socket } from 'net'; @@ -28,10 +27,10 @@ import { import { DebugProtocol } from '@vscode/debugprotocol'; import { EventEmitter } from 'events'; import { LogOutputEvent, LogLevel } from '@vscode/debugadapter/lib/logger'; -import { MessageStreamParser } from './MessageStreamParser'; -import { SourceMaps } from './SourceMaps'; -import { StatMessageModel, StatsProvider2 } from './StatsProvider2'; -import { HomeViewProvider } from './panels/HomeViewProvider'; +import { MessageStreamParser } from './message-stream-parser'; +import { SourceMaps } from './source-maps'; +import { StatMessageModel, StatsProvider } from './stats/stats-provider'; +import { HomeViewProvider } from './panels/home-view-provider'; import * as path from 'path'; import * as fs from 'fs'; import { isUUID } from './Utils'; @@ -144,10 +143,10 @@ export class Session extends DebugSession { // external communication private _homeViewProvider: HomeViewProvider; - private _statsProvider: StatsProvider2; + private _statsProvider: StatsProvider; private _eventEmitter: EventEmitter; - public constructor(homeViewProvider: HomeViewProvider, statsProvider: StatsProvider2, eventEmitter: EventEmitter) { + public constructor(homeViewProvider: HomeViewProvider, statsProvider: StatsProvider, eventEmitter: EventEmitter) { super(); this._homeViewProvider = homeViewProvider; @@ -192,7 +191,7 @@ export class Session extends DebugSession { command: { command: command, dimension_type: 'overworld', - } + }, }); } } @@ -202,7 +201,7 @@ export class Session extends DebugSession { type: 'startProfiler', profiler: { target_module_uuid: this._targetModuleUuid, - } + }, }); } @@ -212,7 +211,7 @@ export class Session extends DebugSession { profiler: { captures_path: capturesBasePath, target_module_uuid: this._targetModuleUuid, - } + }, }); } @@ -231,10 +230,9 @@ export class Session extends DebugSession { this.showNotification(`Failed to write to temp file: ${err.message}`, LogLevel.Error); return; } - commands.executeCommand('vscode.open', Uri.file(captureFullPath)) - .then(undefined, error => { - this.showNotification(`Failed to open CPU profile: ${error.message}`, LogLevel.Error); - }); + commands.executeCommand('vscode.open', Uri.file(captureFullPath)).then(undefined, error => { + this.showNotification(`Failed to open CPU profile: ${error.message}`, LogLevel.Error); + }); // notify home view of new capture this._eventEmitter.emit('new-profiler-capture', profilerCapture.capture_base_path, newCaptureFileName); @@ -1026,14 +1024,17 @@ export class Session extends DebugSession { const reloadOnSourceChangesEnabled = config.get('reloadOnSourceChanges.enabled'); const reloadOnSourceChangesDelay = Math.max(config.get('reloadOnSourceChanges.delay') ?? 0, 0); const reloadOnSourceChangesGlobPattern = config.get('reloadOnSourceChanges.globPattern'); - + // watch all files within the workspace matching custom glob pattern. // only active if Minecraft /reload is enabled let globPattern: RelativePattern | undefined = undefined; if (reloadOnSourceChangesGlobPattern && reloadOnSourceChangesEnabled) { const workspaceFolders = workspace.workspaceFolders; if (workspaceFolders && workspaceFolders.length > 0) { - globPattern = new RelativePattern(workspaceFolders[0].uri.fsPath ?? '', reloadOnSourceChangesGlobPattern); + globPattern = new RelativePattern( + workspaceFolders[0].uri.fsPath ?? '', + reloadOnSourceChangesGlobPattern + ); } } // watch source map files and reload cache if changed. @@ -1064,7 +1065,7 @@ export class Session extends DebugSession { } }, reloadOnSourceChangesDelay); }; - + if (this._sourceFileWatcher) { this._sourceFileWatcher.onDidChange(onSourceChanged); this._sourceFileWatcher.onDidCreate(onSourceChanged); diff --git a/src/Utils.test.ts b/src/Utils.test.ts index 074bf3c..f7de110 100644 --- a/src/Utils.test.ts +++ b/src/Utils.test.ts @@ -1,4 +1,3 @@ - // Copyright (C) Microsoft Corporation. All rights reserved. import * as os from 'os'; @@ -28,7 +27,7 @@ describe('Utils', () => { expect(normalizePathForRemote('C:\\path\\to\\file')).toBe('C:/path/to/file'); expect(normalizePathForRemote('C:/path/to/file')).toBe('C:/path/to/file'); }); - }); + }); describe('isUUID', () => { it('should return true for valid UUIDs', () => { @@ -40,5 +39,5 @@ describe('Utils', () => { expect(isUUID('123e4567-e89b-12d3-a456-42661417400')).toBe(false); // One character short expect(isUUID('123e4567-e89b-12d3-a456-4266141740000')).toBe(false); // One character too long }); - }); + }); }); diff --git a/src/ConfigProvider.ts b/src/config-provider.ts similarity index 100% rename from src/ConfigProvider.ts rename to src/config-provider.ts diff --git a/src/extension.ts b/src/extension.ts index c3b1cc0..cd72c29 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,53 +1,45 @@ - // Copyright (C) Microsoft Corporation. All rights reserved. import * as vscode from 'vscode'; -import { ConfigProvider } from './ConfigProvider'; -import { ServerDebugAdapterFactory } from './ServerDebugAdapterFactory'; -import { HomeViewProvider } from './panels/HomeViewProvider'; -import { MinecraftDiagnosticsPanel } from './panels/MinecraftDiagnostics'; -import { StatsProvider2 } from './StatsProvider2'; +import { ConfigProvider } from './config-provider'; import { EventEmitter } from 'stream'; +import { HomeViewProvider } from './panels/home-view-provider'; +import { MinecraftDiagnosticsPanel } from './panels/minecraft-diagnostics'; +import { ServerDebugAdapterFactory } from './server-debug-adapter-factory'; +import { StatsProvider } from './stats/stats-provider'; +import { ReplayStatsProvider } from './stats/replay-stats-provider'; // called when extension is activated // export function activate(context: vscode.ExtensionContext) { - const statsProvider = new StatsProvider2(); + const liveStatsProvider = new StatsProvider('Live', 'minecraftDiagnosticsLive'); const eventEmitter: EventEmitter = new EventEmitter(); // home view const homeViewProvider = new HomeViewProvider(context.extensionUri, eventEmitter); context.subscriptions.push(vscode.window.registerWebviewViewProvider(HomeViewProvider.viewType, homeViewProvider)); - // register commands - context.subscriptions.push( - vscode.commands.registerCommand('extension.minecraft-js.getPort', _config => { - return vscode.window.showInputBox({ - placeHolder: 'Please enter the port Minecraft is listening on.', - value: '', - }); - }) - ); - // register a configuration provider for the 'minecraft-js' debug type const configProvider = new ConfigProvider(); context.subscriptions.push(vscode.debug.registerDebugConfigurationProvider('minecraft-js', configProvider)); // register a debug adapter descriptor factory for 'minecraft-js', this factory creates the DebugSession - let descriptorFactory = new ServerDebugAdapterFactory(homeViewProvider, statsProvider, eventEmitter); + let descriptorFactory = new ServerDebugAdapterFactory(homeViewProvider, liveStatsProvider, eventEmitter); context.subscriptions.push(vscode.debug.registerDebugAdapterDescriptorFactory('minecraft-js', descriptorFactory)); - if ('dispose' in descriptorFactory) { context.subscriptions.push(descriptorFactory); } - // Create the show diagnostics command - const showDiagnosticsCommand = vscode.commands.registerCommand( - 'minecraft-debugger.showMinecraftDiagnostics', - () => { - MinecraftDiagnosticsPanel.render(context.extensionUri, statsProvider); - } - ); + // + // Command Registrations + // + + const getPortCommand = vscode.commands.registerCommand('extension.minecraft-js.getPort', () => { + return vscode.window.showInputBox({ + placeHolder: 'Please enter the port Minecraft is listening on.', + value: '', + }); + }); const minecraftReloadCommand = vscode.commands.registerCommand('minecraft-debugger.minecraftReload', () => { if (!vscode.debug.activeDebugSession) { @@ -76,8 +68,38 @@ export function activate(context: vscode.ExtensionContext) { } ); - // Add command to the extension context - context.subscriptions.push(showDiagnosticsCommand, minecraftReloadCommand, runMinecraftCommand); + const liveDiagnosticsCommand = vscode.commands.registerCommand('minecraft-debugger.liveDiagnostics', () => { + MinecraftDiagnosticsPanel.render(context.extensionUri, liveStatsProvider); + }); + + const replayDiagnosticsCommand = vscode.commands.registerCommand( + 'minecraft-debugger.replayDiagnostics', + async () => { + const fileUri = await vscode.window.showOpenDialog({ + canSelectMany: false, + openLabel: 'Select diagnostics capture to replay', + filters: { + 'MC Stats files': ['mcstats'], + 'All files': ['*'], + }, + }); + if (!fileUri || fileUri.length === 0) { + vscode.window.showErrorMessage('No file selected.'); + return; + } + const replayStats = new ReplayStatsProvider(fileUri[0].fsPath); + MinecraftDiagnosticsPanel.render(context.extensionUri, replayStats); + } + ); + + // Add commands to the extension context + context.subscriptions.push( + getPortCommand, + minecraftReloadCommand, + runMinecraftCommand, + liveDiagnosticsCommand, + replayDiagnosticsCommand + ); } // called when extension is deactivated diff --git a/src/MessageStreamParser.ts b/src/message-stream-parser.ts similarity index 100% rename from src/MessageStreamParser.ts rename to src/message-stream-parser.ts diff --git a/src/panels/MinecraftDiagnostics.ts b/src/panels/MinecraftDiagnostics.ts deleted file mode 100644 index f1ed010..0000000 --- a/src/panels/MinecraftDiagnostics.ts +++ /dev/null @@ -1,147 +0,0 @@ - -// Copyright (C) Microsoft Corporation. All rights reserved. - -import { Disposable, Webview, WebviewPanel, window, Uri, ViewColumn } from 'vscode'; -import { getUri } from '../utilities/getUri'; -import { getNonce } from '../utilities/getNonce'; -import { StatData, StatsListener, StatsProvider2 } from '../StatsProvider2'; - -export class MinecraftDiagnosticsPanel { - public static currentPanel: MinecraftDiagnosticsPanel | undefined; - private readonly _panel: WebviewPanel; - private _disposables: Disposable[] = []; - - private _statsTracker: StatsProvider2; - private _statsCallback: StatsListener | undefined = undefined; - - private constructor(panel: WebviewPanel, extensionUri: Uri, statsTracker: StatsProvider2) { - this._panel = panel; - this._statsTracker = statsTracker; - - // Set an event listener to listen for when the panel is disposed (i.e. when the user closes - // the panel or when the panel is closed programmatically) - this._panel.onDidDispose(() => this.dispose(), null, this._disposables); - - // Set the HTML content for the webview panel - this._panel.webview.html = this._getWebviewContent(this._panel.webview, extensionUri); - - // Set an event listener to listen for messages passed from the webview context - //this._setWebviewMessageListener(this._panel.webview); - - this._statsCallback = (stat: StatData) => { - if (stat.parent_id !== undefined) { - const message = { - type: 'statistic-updated', - values: stat.values, - id: stat.id, - name: stat.name, - group_name: stat.parent_name, - group: stat.parent_id, - full_id: stat.full_id, - time: stat.tick, - group_full_id: stat.parent_full_id, - }; - - this._panel.webview.postMessage(message); - } - }; - - this._statsTracker.addStatListener(this._statsCallback); - } - - /** - * Renders the current webview panel if it exists otherwise a new webview panel - * will be created and displayed. - * - * @param extensionUri The URI of the directory containing the extension. - */ - public static render(extensionUri: Uri, statsTracker: StatsProvider2) { - if (MinecraftDiagnosticsPanel.currentPanel) { - // If the webview panel already exists reveal it - MinecraftDiagnosticsPanel.currentPanel._panel.reveal(ViewColumn.One); - } else { - // If a webview panel does not already exist create and show a new one - const panel = window.createWebviewPanel( - // Panel view type - 'showMinecraftDiagnostics', - // Panel title - 'Minecraft Diagnostic', - // The editor column the panel should be displayed in - ViewColumn.One, - // Extra panel configurations - { - // Enable JavaScript in the webview - enableScripts: true, - // Restrict the webview to only load resources from the `out` and `webview-ui/build` directories - localResourceRoots: [ - Uri.joinPath(extensionUri, 'out'), - Uri.joinPath(extensionUri, 'webview-ui/build'), - ], - } - ); - - MinecraftDiagnosticsPanel.currentPanel = new MinecraftDiagnosticsPanel(panel, extensionUri, statsTracker); - } - } - - /** - * Cleans up and disposes of webview resources when the webview panel is closed. - */ - public dispose() { - if (this._statsCallback !== undefined) { - this._statsTracker.removeStatListener(this._statsCallback); - this._statsCallback = undefined; - } - - MinecraftDiagnosticsPanel.currentPanel = undefined; - - // Dispose of the current webview panel - this._panel.dispose(); - - // Dispose of all disposables (i.e. commands) for the current webview panel - while (this._disposables.length) { - const disposable = this._disposables.pop(); - if (disposable) { - disposable.dispose(); - } - } - } - - /** - * Defines and returns the HTML that should be rendered within the webview panel. - * - * @remarks This is also the place where references to the React webview build files - * are created and inserted into the webview HTML. - * - * @param webview A reference to the extension webview - * @param extensionUri The URI of the directory containing the extension - * @returns A template string literal containing the HTML that should be - * rendered within the webview panel - */ - private _getWebviewContent(webview: Webview, extensionUri: Uri) { - // The CSS file from the React build output - const stylesUri = getUri(webview, extensionUri, ['webview-ui', 'build', 'assets', 'diagnosticsPanel.css']); - // The JS file from the React build output - const scriptUri = getUri(webview, extensionUri, ['webview-ui', 'build', 'assets', 'diagnosticsPanel.js']); - - const nonce = getNonce(); - - // Tip: Install the es6-string-html VS Code extension to enable code highlighting below - return /*html*/ ` - - - - - - - - Minecraft Diagnostics - - -
- - - - `; - } -} diff --git a/src/panels/HomeViewProvider.ts b/src/panels/home-view-provider.ts similarity index 92% rename from src/panels/HomeViewProvider.ts rename to src/panels/home-view-provider.ts index 20f8ba2..9a84ecc 100644 --- a/src/panels/HomeViewProvider.ts +++ b/src/panels/home-view-provider.ts @@ -1,4 +1,3 @@ - // Copyright (C) Microsoft Corporation. All rights reserved. import * as vscode from 'vscode'; @@ -26,7 +25,7 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { type: 'debugger-status', isConnected: isConnected, supportsCommands: minecraftCapabilities.supportsCommands, - supportsProfiler: minecraftCapabilities.supportsProfiler + supportsProfiler: minecraftCapabilities.supportsProfiler, }); } @@ -57,11 +56,18 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { this._view.webview.onDidReceiveMessage(async message => { switch (message.type) { case 'show-diagnostics': { - vscode.commands.executeCommand('minecraft-debugger.showMinecraftDiagnostics'); + vscode.commands.executeCommand('minecraft-debugger.liveDiagnostics'); + break; + } + case 'open-diagnostics-replay': { + vscode.commands.executeCommand('minecraft-debugger.replayDiagnostics'); break; } case 'show-settings': { - vscode.commands.executeCommand('workbench.action.openSettings', '@ext:mojang-studios.minecraft-debugger'); + vscode.commands.executeCommand( + 'workbench.action.openSettings', + '@ext:mojang-studios.minecraft-debugger' + ); break; } case 'run-minecraft-command': { @@ -108,7 +114,7 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { const uri = await vscode.window.showOpenDialog({ canSelectFiles: false, canSelectFolders: true, - canSelectMany: false + canSelectMany: false, }); if (uri && uri[0]) { this._view?.webview.postMessage({ type: 'captures-base-path-set', capturesBasePath: uri[0].fsPath }); @@ -135,14 +141,14 @@ export class HomeViewProvider implements vscode.WebviewViewProvider { this._view?.webview.postMessage({ type: 'capture-files-refreshed', allCaptureFileNames: allCaptureFileNames, - newCaptureFileName: newCaptureFileName + newCaptureFileName: newCaptureFileName, }); }); } private _deleteProfilerCapture(capturesBasePath: string, fileName: string) { const fullPath = path.join(capturesBasePath, fileName); - fs.unlink(fullPath, (err) => { + fs.unlink(fullPath, err => { if (err) { console.error('Error deleting capture file:', err); return; diff --git a/src/panels/minecraft-diagnostics.ts b/src/panels/minecraft-diagnostics.ts new file mode 100644 index 0000000..1f1af7b --- /dev/null +++ b/src/panels/minecraft-diagnostics.ts @@ -0,0 +1,176 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { Disposable, Webview, WebviewPanel, window, Uri, ViewColumn } from 'vscode'; +import { getUri } from '../utilities/getUri'; +import { getNonce } from '../utilities/getNonce'; +import { StatData, StatsListener, StatsProvider } from '../stats/stats-provider'; + +export class MinecraftDiagnosticsPanel { + private static activeDiagnosticsPanels: MinecraftDiagnosticsPanel[] = []; + + private readonly _panel: WebviewPanel; + private _disposables: Disposable[] = []; + private _statsTracker: StatsProvider; + private _statsCallback: StatsListener | undefined = undefined; + + private constructor(panel: WebviewPanel, extensionUri: Uri, statsTracker: StatsProvider) { + this._panel = panel; + this._statsTracker = statsTracker; + + // Set an event listener to listen for when the panel is disposed (i.e. when the user closes + // the panel or when the panel is closed programmatically) + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + // Set the HTML content for the webview panel + this._panel.webview.html = this._getWebviewContent( + this._panel.webview, + extensionUri, + statsTracker.manualControl() + ); + + // Handle events from the webview panel + this._panel.webview.onDidReceiveMessage(message => { + switch (message.type) { + case 'restart': + this._panel.webview.html = this._getWebviewContent( + this._panel.webview, + extensionUri, + statsTracker.manualControl() + ); + this._statsTracker.stop(); + break; + case 'pause': + this._statsTracker.pause(); + break; + case 'resume': + this._statsTracker.resume(); + break; + case 'slower': + this._statsTracker.slower(); + break; + case 'faster': + this._statsTracker.faster(); + break; + case 'speed': + this._statsTracker.setSpeed(message.speed); + break; + default: + break; + } + }); + + this._statsCallback = { + onStatUpdated: (stat: StatData) => { + if (stat.parent_id !== undefined) { + const message = { + type: 'statistic-updated', + values: stat.values, + id: stat.id, + name: stat.name, + group_name: stat.parent_name, + group: stat.parent_id, + full_id: stat.full_id, + time: stat.tick, + group_full_id: stat.parent_full_id, + }; + this._panel.webview.postMessage(message); + } + }, + onSpeedUpdated: (speed: number) => { + const message = { + type: 'speed-updated', + speed: speed, + }; + this._panel.webview.postMessage(message); + }, + onPauseUpdated: (paused: boolean) => { + const message = { + type: 'pause-updated', + paused: paused, + }; + this._panel.webview.postMessage(message); + }, + }; + + this._statsTracker.addStatListener(this._statsCallback); + } + + public static render(extensionUri: Uri, statsTracker: StatsProvider) { + const statsTrackerId = statsTracker.getUniqueId(); + const existingPanel = MinecraftDiagnosticsPanel.activeDiagnosticsPanels.find( + panel => panel._statsTracker.getUniqueId() === statsTrackerId + ); + if (existingPanel) { + existingPanel._panel.reveal(ViewColumn.One); + } else { + const panel = window.createWebviewPanel( + statsTrackerId, + `Minecraft Diagnostics - [${statsTracker.getName()}]`, + ViewColumn.Active, + { + retainContextWhenHidden: true, + enableScripts: true, + localResourceRoots: [ + Uri.joinPath(extensionUri, 'out'), + Uri.joinPath(extensionUri, 'webview-ui/build'), + ], + } + ); + MinecraftDiagnosticsPanel.activeDiagnosticsPanels.push( + new MinecraftDiagnosticsPanel(panel, extensionUri, statsTracker) + ); + } + } + + public dispose() { + if (this._statsCallback !== undefined) { + this._statsTracker.removeStatListener(this._statsCallback); + this._statsCallback = undefined; + } + + // Remove the current panel from the active panel list + MinecraftDiagnosticsPanel.activeDiagnosticsPanels = MinecraftDiagnosticsPanel.activeDiagnosticsPanels.filter( + panel => panel !== this + ); + + // Dispose of the current webview panel + this._panel.dispose(); + + // Dispose of all disposables (i.e. commands) for the current webview panel + while (this._disposables.length) { + const disposable = this._disposables.pop(); + if (disposable) { + disposable.dispose(); + } + } + } + + private _getWebviewContent(webview: Webview, extensionUri: Uri, showReplayControls: boolean) { + // The CSS file from the React build output + const stylesUri = getUri(webview, extensionUri, ['webview-ui', 'build', 'assets', 'diagnosticsPanel.css']); + // The JS file from the React build output + const scriptUri = getUri(webview, extensionUri, ['webview-ui', 'build', 'assets', 'diagnosticsPanel.js']); + const nonce = getNonce(); + + // Tip: Install the es6-string-html VS Code extension to enable code highlighting below + return ` + + + + + + + + Minecraft Diagnostics + + + +
+ + + + `; + } +} diff --git a/src/ServerDebugAdapterFactory.ts b/src/server-debug-adapter-factory.ts similarity index 88% rename from src/ServerDebugAdapterFactory.ts rename to src/server-debug-adapter-factory.ts index 157948c..f509d81 100644 --- a/src/ServerDebugAdapterFactory.ts +++ b/src/server-debug-adapter-factory.ts @@ -1,22 +1,21 @@ - // Copyright (C) Microsoft Corporation. All rights reserved. import * as Net from 'net'; import * as vscode from 'vscode'; import { EventEmitter } from 'stream'; import { Session } from './Session'; -import { StatsProvider2 } from './StatsProvider2'; -import { HomeViewProvider } from './panels/HomeViewProvider'; +import { StatsProvider } from './stats/stats-provider'; +import { HomeViewProvider } from './panels/home-view-provider'; // Factory for creating a Debug Adapter that runs as a server inside the extension and communicates via a socket. // export class ServerDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory { private server?: Net.Server; private _homeViewProvider: HomeViewProvider; - private _statsProvider: StatsProvider2; + private _statsProvider: StatsProvider; private _eventEmitter: EventEmitter; - constructor(homeViewProvider: HomeViewProvider, statsProvider: StatsProvider2, eventEmitter: EventEmitter) { + constructor(homeViewProvider: HomeViewProvider, statsProvider: StatsProvider, eventEmitter: EventEmitter) { this._homeViewProvider = homeViewProvider; this._statsProvider = statsProvider; this._eventEmitter = eventEmitter; diff --git a/src/SourceMaps.test.ts b/src/source-maps.test.ts similarity index 90% rename from src/SourceMaps.test.ts rename to src/source-maps.test.ts index b03f751..8b31409 100644 --- a/src/SourceMaps.test.ts +++ b/src/source-maps.test.ts @@ -1,14 +1,15 @@ import { describe, it, expect } from 'vitest'; -import { SourceMaps } from './SourceMaps'; +import { SourceMaps } from './source-maps'; import path from 'path'; describe('SourceMaps', () => { + const linesToVerify = [17, 28, 35, 52, 67, 81, 86, 94, 103, 109, 115, 119]; - const linesToVerify = [ - 17, 28, 35, 52, 67, 81, 86, 94, 103, 109, 115, 119 - ]; - - const verifyLines = async (sourceMaps: SourceMaps, originalLocalAbsolutePath: string, generatedRemoteLocalPath: string) => { + const verifyLines = async ( + sourceMaps: SourceMaps, + originalLocalAbsolutePath: string, + generatedRemoteLocalPath: string + ) => { for (const sourceLine of linesToVerify) { const generatedPosition = await sourceMaps.getGeneratedPositionFor({ source: originalLocalAbsolutePath, diff --git a/src/SourceMaps.ts b/src/source-maps.ts similarity index 99% rename from src/SourceMaps.ts rename to src/source-maps.ts index adbb714..9340245 100644 --- a/src/SourceMaps.ts +++ b/src/source-maps.ts @@ -95,7 +95,7 @@ class SourceMapCache { let mapJson; if (this._inlineSourceMap) { const inlineSourceMapRegex = /\/\/# sourceMappingURL=data:application\/json;.*base64,(.*)$/gm; - const mapString = mapFile.toString(); + const mapString = mapFile.toString(); const match = inlineSourceMapRegex.exec(mapString); if (match && match.length > 1) { const base64EncodedMap = match[1]; diff --git a/src/stats/replay-stats-provider.ts b/src/stats/replay-stats-provider.ts new file mode 100644 index 0000000..be98d64 --- /dev/null +++ b/src/stats/replay-stats-provider.ts @@ -0,0 +1,174 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import * as fs from 'fs'; +import * as readline from 'readline'; +import * as path from 'path'; +import { StatMessageModel, StatsProvider, StatsListener } from './stats-provider'; + +export class ReplayStatsProvider extends StatsProvider { + private _replayFilePath: string; + private _replayStreamReader: readline.Interface | null; + private _simTickFreqency: number; + private _simTickPeriod: number; + private _simTickCurrent: number; + private _simTimeoutId: NodeJS.Timeout | null; + private _pendingStats: StatMessageModel[]; + + // resume stream when lines drop below this threshold + private static readonly PENDING_STATS_BUFFER_MIN = 256; + // pause stream when lines exceed this threshold + private static readonly PENDING_STATS_BUFFER_MAX = ReplayStatsProvider.PENDING_STATS_BUFFER_MIN * 2; + + // ticks per second (frequency) + private readonly MILLIS_PER_SECOND = 1000; + private readonly DEFAULT_SPEED = 20; // ticks per second + private readonly MIN_SPEED = 5; + private readonly MAX_SPEED = 160; + + constructor(replayFilePath: string) { + super(path.basename(replayFilePath), replayFilePath); + this._replayFilePath = replayFilePath; + this._replayStreamReader = null; + this._simTickFreqency = this.DEFAULT_SPEED; + this._simTickPeriod = this.MILLIS_PER_SECOND / this._simTickFreqency; // ms per tick + this._simTickCurrent = 0; + this._simTimeoutId = null; + this._pendingStats = []; + } + + public override start() { + this.stop(); + + const fileStream = fs.createReadStream(this._replayFilePath); + this._replayStreamReader = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + this._replayStreamReader.on('line', line => this._onReadNextStatMessage(line)); + this._replayStreamReader.on('close', () => this._onCloseStream()); + + // begin simulation + this._simTimeoutId = setTimeout(() => this._updateSim(), this._simTickPeriod); + this._fireSpeedChanged(); + this._firePauseChanged(); + } + + public override stop() { + if (this._simTimeoutId) { + clearTimeout(this._simTimeoutId); + } + this._replayStreamReader?.close(); + this._simTickFreqency = this.DEFAULT_SPEED; + this._simTickPeriod = this.MILLIS_PER_SECOND / this._simTickFreqency; + this._simTickCurrent = 0; + this._simTimeoutId = null; + this._pendingStats = []; + this._firePauseChanged(); + } + + public override pause() { + if (this._simTimeoutId) { + clearTimeout(this._simTimeoutId); + this._simTimeoutId = null; + } + this._firePauseChanged(); + } + + public override resume() { + if (this._simTickCurrent === 0) { + this.start(); + } else { + this._simTimeoutId = setTimeout(() => this._updateSim(), this._simTickPeriod); + } + this._firePauseChanged(); + } + + public override faster() { + this._simTickFreqency *= 2; + if (this._simTickFreqency > this.MAX_SPEED) { + this._simTickFreqency = this.MAX_SPEED; + } + this._simTickPeriod = this.MILLIS_PER_SECOND / this._simTickFreqency; + this._fireSpeedChanged(); + } + + public override slower() { + this._simTickFreqency /= 2; + if (this._simTickFreqency < this.MIN_SPEED) { + this._simTickFreqency = this.MIN_SPEED; + } + this._simTickPeriod = this.MILLIS_PER_SECOND / this._simTickFreqency; + this._fireSpeedChanged(); + } + + public override setSpeed(speed: string) { + this._simTickFreqency = parseInt(speed); + if (this._simTickFreqency < this.MIN_SPEED) { + this._simTickFreqency = this.MIN_SPEED; + } else if (this._simTickFreqency > this.MAX_SPEED) { + this._simTickFreqency = this.MAX_SPEED; + } + this._simTickPeriod = this.MILLIS_PER_SECOND / this._simTickFreqency; + this._fireSpeedChanged(); + } + + public override manualControl(): boolean { + return true; + } + + private _updateSim() { + const nextStatsMessage = this._pendingStats[0]; + if (nextStatsMessage) { + if (nextStatsMessage.tick > this._simTickCurrent) { + // not ready to process this message, wait for the next tick + } else if (nextStatsMessage.tick < this._simTickCurrent) { + // reset sim? close? + } else if (nextStatsMessage.tick === this._simTickCurrent) { + // process and remove the message, then increment sim tick + this.setStats(nextStatsMessage); + this._pendingStats.shift(); + this._simTickCurrent++; + } + } + // resume stream if we're running low on data + if (this._pendingStats.length < ReplayStatsProvider.PENDING_STATS_BUFFER_MIN) { + this._replayStreamReader?.resume(); + } + // schedule next update as long as we have pending data to process or there's still a stream to read + if (this._replayStreamReader || this._pendingStats.length > 0) { + this._simTimeoutId = setTimeout(() => this._updateSim(), this._simTickPeriod); + } + } + + private _onReadNextStatMessage(line: string) { + const statsMessageJson = JSON.parse(line); + // seed sim tick with first message + if (this._simTickCurrent === 0) { + this._simTickCurrent = statsMessageJson.tick; + } + // add stats messages to queue + this._pendingStats.push(statsMessageJson as StatMessageModel); + // pause stream reader if we've got enough data for now + if (this._pendingStats.length > ReplayStatsProvider.PENDING_STATS_BUFFER_MAX) { + this._replayStreamReader?.pause(); + } + } + + private _onCloseStream() { + this.stop(); + } + + private _fireSpeedChanged() { + this._statListeners.forEach((listener: StatsListener) => { + listener.onSpeedUpdated(this._simTickFreqency); + }); + } + + private _firePauseChanged() { + this._statListeners.forEach((listener: StatsListener) => { + // paused if no timeout id + listener.onPauseUpdated(this._simTimeoutId == null); + }); + } +} diff --git a/src/StatsProvider2.ts b/src/stats/stats-provider.ts similarity index 67% rename from src/StatsProvider2.ts rename to src/stats/stats-provider.ts index e2ca47c..11c4d5f 100644 --- a/src/StatsProvider2.ts +++ b/src/stats/stats-provider.ts @@ -19,13 +19,51 @@ export interface StatDataModel { export interface StatMessageModel { tick: number; + type: string; stats: StatDataModel[]; } -export type StatsListener = (stat: StatData) => void; +export interface StatsListener { + onStatUpdated: (stat: StatData) => void; + onSpeedUpdated: (speed: number) => void; + onPauseUpdated: (paused: boolean) => void; +} + +export class StatsProvider { + private _name: string; + private _uniqueId: string; + protected _statListeners: StatsListener[]; + + constructor(name: string, id: string) { + this._name = name; + this._uniqueId = id; + this._statListeners = []; + } -export class StatsProvider2 { - private _statListeners: StatsListener[] = []; + public getName(): string { + return this._name; + } + + public getUniqueId(): string { + return this._uniqueId; + } + + public setStats(stats: StatMessageModel) { + for (const stat of stats.stats) { + this._fireStatUpdated(stat, stats.tick); + } + } + + public start() {} + public stop() {} + public pause() {} + public resume() {} + public faster() {} + public slower() {} + public setSpeed(speed: string) {} + public manualControl(): boolean { + return false; + } public addStatListener(listener: StatsListener) { this._statListeners.push(listener); @@ -49,7 +87,7 @@ export class StatsProvider2 { values: stat.values ?? [], tick: tick, }; - listener(statData); + listener.onStatUpdated(statData); if (stat.children) { stat.children.forEach((child: StatDataModel) => { @@ -58,10 +96,4 @@ export class StatsProvider2 { } }); } - - public setStats(stats: StatMessageModel) { - for (const stat of stats.stats) { - this._fireStatUpdated(stat, stats.tick); - } - } } diff --git a/webview-ui/src/diagnostics_panel/App.css b/webview-ui/src/diagnostics_panel/App.css index 6815940..a82f029 100644 --- a/webview-ui/src/diagnostics_panel/App.css +++ b/webview-ui/src/diagnostics_panel/App.css @@ -98,3 +98,50 @@ main { .difference-chart-figure tspan{ fill: var(--vscode-editor-background); } + +/* + * Replay Controls Section + */ + +.replay-controls-section { + display: flex; + flex-direction: row; + border: 1px solid var(--vscode-editorGroup-border); + border-radius: 5px; + padding: 5px; + margin-top: 5px; + margin-left: 0px; + align-items: stretch; +} + +/* for the play/pause/stop buttons */ +.replay-play-sub-section { + height: 30px; + display: flex; + flex-direction: row; + margin-left: 0px; +} + +/* for the speed control */ +.replay-speed-sub-section { + height: 30px; + display: flex; + flex-direction: row; + margin-left: 20px; + align-items: center; +} + +.replay-sim-speed-input { + height: 80%; + width: 50px; + margin-left: 5px; + text-align: center; + font-size: 16px; +} + +.replay-button { + height: 100%; + margin-left: 5px; + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} diff --git a/webview-ui/src/diagnostics_panel/App.tsx b/webview-ui/src/diagnostics_panel/App.tsx index caebe04..294c3ac 100644 --- a/webview-ui/src/diagnostics_panel/App.tsx +++ b/webview-ui/src/diagnostics_panel/App.tsx @@ -1,7 +1,6 @@ // Copyright (C) Microsoft Corporation. All rights reserved. import { VSCodePanelTab, VSCodePanelView, VSCodePanels } from '@vscode/webview-ui-toolkit/react'; -import './App.css'; import { StatGroupSelectionBox } from './controls/StatGroupSelectionBox'; import { useCallback, useEffect, useState } from 'react'; import { StatisticType, YAxisStyle, YAxisType, createStatResolver } from './StatisticResolver'; @@ -9,15 +8,19 @@ import MinecraftStatisticLineChart from './controls/MinecraftStatisticLineChart' import MinecraftStatisticStackedLineChart from './controls/MinecraftStatisticStackedLineChart'; import MinecraftStatisticStackedBarChart from './controls/MinecraftStatisticStackedBarChart'; import { MultipleStatisticProvider, SimpleStatisticProvider, StatisticUpdatedMessage } from './StatisticProvider'; - +import ReplayControls from './controls/ReplayControls'; import * as statPrefabs from './StatisticPrefabs'; +import { Icons } from './Icons'; +import './App.css'; -const vscode = acquireVsCodeApi(); - -interface TabState { - tabId: string; +declare global { + interface Window { + initialParams: any; + } } +const vscode = acquireVsCodeApi(); + interface VSCodePanelsChangeEvent extends Event { target: EventTarget & { activeid: string }; } @@ -25,48 +28,48 @@ interface VSCodePanelsChangeEvent extends Event { // Filter out events with a value of zero that haven't been previously subscribed to function constructSubscribedSignalFilter() { const nonFilteredValues: string[] = []; - const func = (event: StatisticUpdatedMessage) => { if (event.values.length === 1 && event.values[0] === 0 && !nonFilteredValues.includes(event.id)) { return false; } - nonFilteredValues.push(event.id); return true; }; - return func; } +const onRestart = () => { + vscode.postMessage({ type: 'restart' }); +}; + +const onSlower = () => { + vscode.postMessage({ type: 'slower' }); +}; + +const onFaster = () => { + vscode.postMessage({ type: 'faster' }); +}; + +const onPause = () => { + vscode.postMessage({ type: 'pause' }); +}; + +const onResume = () => { + vscode.postMessage({ type: 'resume' }); +}; + function App() { - // State const [selectedPlugin, setSelectedPlugin] = useState(''); const [selectedClient, setSelectedClient] = useState(''); const [currentTab, setCurrentTab] = useState(); - - // Load initial state from vscode - useEffect(() => { - const tabState = vscode.getState() as TabState; - if (tabState && tabState.tabId) { - setCurrentTab(tabState.tabId); - } - }, []); - - // Save current tab state whenever it changes - useEffect(() => { - if (currentTab) { - const tabState: TabState = { tabId: currentTab }; - vscode.setState(tabState); - } - }, [currentTab]); + const [paused, setPaused] = useState(true); + const [speed, setSpeed] = useState(''); const handlePluginSelection = useCallback((pluginSelectionId: string) => { - console.log(`Selected Plugin: ${pluginSelectionId}`); setSelectedPlugin(() => pluginSelectionId); }, []); const handleClientSelection = useCallback((clientSelectionId: string) => { - console.log(`Selected Client: ${clientSelectionId}`); setSelectedClient(() => clientSelectionId); }, []); @@ -77,8 +80,35 @@ function App() { } }, []); + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === 'speed-updated') { + setSpeed(`${message.speed}hz`); + } else if (message.type === 'pause-updated') { + setPaused(message.paused); + } + }; + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, []); + return (
+ {window.initialParams.showReplayControls && ( + + )} handlePanelChange(event as VSCodePanelsChangeEvent)}> World Memory diff --git a/webview-ui/src/diagnostics_panel/Icons.tsx b/webview-ui/src/diagnostics_panel/Icons.tsx new file mode 100644 index 0000000..c92a42c --- /dev/null +++ b/webview-ui/src/diagnostics_panel/Icons.tsx @@ -0,0 +1,35 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +export const Icons = { + restart: ( + + + + + ), + pause: ( + + + + ), + play: ( + + + + ), + slower: ( + + + + + ), + faster: ( + + + + + ), +}; diff --git a/webview-ui/src/diagnostics_panel/controls/ReplayControls.tsx b/webview-ui/src/diagnostics_panel/controls/ReplayControls.tsx new file mode 100644 index 0000000..e62ce93 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/controls/ReplayControls.tsx @@ -0,0 +1,62 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import React from 'react'; +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; + +interface ReplayControlsProps { + speed: string; + paused: boolean; + onRestart: () => void; + onPause: () => void; + onResume: () => void; + onSlower: () => void; + onFaster: () => void; + svgIcons: { + restart: React.ReactNode; + pause: React.ReactNode; + play: React.ReactNode; + slower: React.ReactNode; + faster: React.ReactNode; + }; +} + +const ReplayControls: React.FC = ({ + speed, + paused, + onRestart, + onPause, + onResume, + onSlower, + onFaster, + svgIcons, +}) => { + return ( +
+
+ + {svgIcons.restart} + + {paused ? ( + + {svgIcons.play} + + ) : ( + + {svgIcons.pause} + + )} +
+
+ + {svgIcons.slower} + + + + {svgIcons.faster} + +
+
+ ); +}; + +export default ReplayControls; diff --git a/webview-ui/src/home_panel/App.tsx b/webview-ui/src/home_panel/App.tsx index 24226c4..f0efc08 100644 --- a/webview-ui/src/home_panel/App.tsx +++ b/webview-ui/src/home_panel/App.tsx @@ -1,8 +1,7 @@ - // Copyright (C) Microsoft Corporation. All rights reserved. import { useEffect, useState } from 'react'; -import CommandSection from './controls/CommandSection' +import CommandSection from './controls/CommandSection'; import { CommandButton, CommandHandlers, getCommandHandlers } from './handlers/CommandHandlers'; import GeneralSection from './controls/GeneralSection'; import ProfilerSection from './controls/ProfilerSection'; @@ -22,6 +21,10 @@ const onShowDiagnosticsPanel = () => { vscode.postMessage({ type: 'show-diagnostics' }); }; +const onOpenDiagnosticsReplay = () => { + vscode.postMessage({ type: 'open-diagnostics-replay' }); +}; + const onShowSettings = () => { vscode.postMessage({ type: 'show-settings' }); }; @@ -35,20 +38,14 @@ const onCaptureBasePathBrowseButtonPressed = () => { }; const App = () => { - const [debuggerConnected, setDebuggerConnected] = useState(false); const [supportsCommands, setSupportsCommands] = useState(false); const [supportsProfiler, setSupportsProfiler] = useState(false); + const { commandButtons, setCommandButtons, onAddCommand, onDeleteCommand, onEditCommand }: CommandHandlers = + getCommandHandlers(); + const { - commandButtons, - setCommandButtons, - onAddCommand, - onDeleteCommand, - onEditCommand - }: CommandHandlers = getCommandHandlers(); - - const { scrollingListRef, capturesBasePath, setCapturesBasePath, @@ -62,8 +59,8 @@ const App = () => { onSelectCaptureItem, onDeleteCaptureItem, onStartProfiler, - onStopProfiler - }: ProfilerHandlers = getProfilerHandlers(vscode); + onStopProfiler, + }: ProfilerHandlers = getProfilerHandlers(vscode); // load state useEffect(() => { @@ -82,7 +79,7 @@ const App = () => { useEffect(() => { vscode.setState({ commandButtons: commandButtons, - capturesBasePath: capturesBasePath + capturesBasePath: capturesBasePath, }); }, [commandButtons, capturesBasePath]); @@ -122,11 +119,10 @@ const App = () => { // Render return (
- + { />
); -} +}; export default App; diff --git a/webview-ui/src/home_panel/controls/GeneralSection.tsx b/webview-ui/src/home_panel/controls/GeneralSection.tsx index 8820500..6ec718e 100644 --- a/webview-ui/src/home_panel/controls/GeneralSection.tsx +++ b/webview-ui/src/home_panel/controls/GeneralSection.tsx @@ -1,4 +1,3 @@ - // Copyright (C) Microsoft Corporation. All rights reserved. import React from 'react'; @@ -6,15 +5,23 @@ import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; interface GeneralSectionProps { onShowDiagnosticsPanel: () => void; + onOpenDiagnosticsReplay: () => void; onShowSettings(): void; } -const GeneralSection: React.FC = ({ onShowDiagnosticsPanel, onShowSettings }) => { +const GeneralSection: React.FC = ({ + onShowDiagnosticsPanel, + onOpenDiagnosticsReplay, + onShowSettings, +}) => { return (

Actions

- Show Diagnostics + Show Live Diagnostics + + + Open Diagnostic Replay Show Settings