diff --git a/packages/vscode-extension/src/common/Project.ts b/packages/vscode-extension/src/common/Project.ts index 1d81e64d9..5d8a9560e 100644 --- a/packages/vscode-extension/src/common/Project.ts +++ b/packages/vscode-extension/src/common/Project.ts @@ -16,8 +16,14 @@ export type DeviceSettings = { showTouches: boolean; }; +export type ToolState = { + enabled: boolean; + panelAvailable: boolean; + label: string; +}; + export type ToolsState = { - [key: string]: { enabled: boolean; panelAvailable: boolean; label: string }; + [key: string]: ToolState; }; export type ProjectState = { @@ -125,6 +131,7 @@ export interface ProjectEventMap { needsNativeRebuild: void; replayDataCreated: MultimediaData; isRecording: boolean; + isProfilingCPU: boolean; } export interface ProjectEventListener { @@ -175,6 +182,9 @@ export interface ProjectInterface { captureReplay(): void; captureScreenshot(): void; + startProfilingCPU(): void; + stopProfilingCPU(): void; + dispatchTouches(touches: Array, type: "Up" | "Move" | "Down"): void; dispatchKeyPress(keyCode: number, direction: "Up" | "Down"): void; dispatchWheel(point: TouchPoint, deltaX: number, deltaY: number): void; diff --git a/packages/vscode-extension/src/debugging/CDPSession.ts b/packages/vscode-extension/src/debugging/CDPSession.ts index 72813b0d2..0f80a07a9 100644 --- a/packages/vscode-extension/src/debugging/CDPSession.ts +++ b/packages/vscode-extension/src/debugging/CDPSession.ts @@ -15,6 +15,7 @@ import { BreakpointsController } from "./BreakpointsController"; import { VariableStore } from "./variableStore"; import { CDPDebuggerScope, CDPRemoteObject } from "./cdp"; import { typeToCategory } from "./DebugAdapter"; +import { annotateLocations } from "./cpuProfiler"; type ResolveType = (result: T) => void; type RejectType = (error: unknown) => void; @@ -489,4 +490,14 @@ export class CDPSession { } //#endregion + + public async startProfiling() { + await this.sendCDPMessage("Profiler.start", {}); + } + + public async stopProfiling() { + const result = await this.sendCDPMessage("Profiler.stop", {}); + const annotatedProfile = annotateLocations(result.profile, this.sourceMapRegistry); + return annotatedProfile; + } } diff --git a/packages/vscode-extension/src/debugging/DebugAdapter.ts b/packages/vscode-extension/src/debugging/DebugAdapter.ts index 7ab90cb31..df0a31e9e 100644 --- a/packages/vscode-extension/src/debugging/DebugAdapter.ts +++ b/packages/vscode-extension/src/debugging/DebugAdapter.ts @@ -1,3 +1,6 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; import { DebugConfiguration } from "vscode"; import { DebugSession, @@ -369,12 +372,12 @@ export class DebugAdapter extends DebugSession implements CDPSessionDelegate { this.sendResponse(response); } - protected customRequest( + protected async customRequest( command: string, response: DebugProtocol.Response, args: any, request?: DebugProtocol.Request | undefined - ): void { + ) { switch (command) { case "RNIDE_connect_cdp_debugger": this.connectJSDebugger(args); @@ -382,6 +385,21 @@ export class DebugAdapter extends DebugSession implements CDPSessionDelegate { case "RNIDE_log_message": this.logCustomMessage(args.message, args.type, args.source); break; + case "RNIDE_startProfiling": + if (this.cdpSession) { + await this.cdpSession.startProfiling(); + this.sendEvent(new Event("RNIDE_profilingCPUStarted")); + } + break; + case "RNIDE_stopProfiling": + if (this.cdpSession) { + const profile = await this.cdpSession.stopProfiling(); + const fileName = `profile-${Date.now()}.cpuprofile`; + const filePath = path.join(os.tmpdir(), fileName); + await fs.promises.writeFile(filePath, JSON.stringify(profile)); + this.sendEvent(new Event("RNIDE_profilingCPUStopped", { filePath })); + } + break; default: Logger.debug(`Custom req ${command} ${args}`); } diff --git a/packages/vscode-extension/src/debugging/DebugSession.ts b/packages/vscode-extension/src/debugging/DebugSession.ts index 5239c18cb..902f95f1b 100644 --- a/packages/vscode-extension/src/debugging/DebugSession.ts +++ b/packages/vscode-extension/src/debugging/DebugSession.ts @@ -11,6 +11,8 @@ export type DebugSessionDelegate = { onConsoleLog(event: DebugSessionCustomEvent): void; onDebuggerPaused(event: DebugSessionCustomEvent): void; onDebuggerResumed(event: DebugSessionCustomEvent): void; + onProfilingCPUStarted(event: DebugSessionCustomEvent): void; + onProfilingCPUStopped(event: DebugSessionCustomEvent): void; }; export type DebugSource = { filename?: string; line1based?: number; column0based?: number }; @@ -32,6 +34,12 @@ export class DebugSession implements Disposable { case "RNIDE_continued": this.delegate.onDebuggerResumed(event); break; + case "RNIDE_profilingCPUStarted": + this.delegate.onProfilingCPUStarted(event); + break; + case "RNIDE_profilingCPUStopped": + this.delegate.onProfilingCPUStopped(event); + break; default: // ignore other events break; @@ -85,9 +93,9 @@ export class DebugSession implements Disposable { this.vscSession && (await debug.stopDebugging(this.vscSession)); } - /** + /** This method is async to allow for awaiting it during restarts, please keep in mind tho that - build in vscode dispose system ignores async keyword and works synchronously. + build in vscode dispose system ignores async keyword and works synchronously. */ public async dispose() { this.vscSession && (await debug.stopDebugging(this.vscSession)); @@ -135,6 +143,14 @@ export class DebugSession implements Disposable { this.session.customRequest("next"); } + public async startProfilingCPU() { + await this.session.customRequest("RNIDE_startProfiling"); + } + + public async stopProfilingCPU() { + await this.session.customRequest("RNIDE_stopProfiling"); + } + private async connectCDPDebugger(cdpConfiguration: CDPConfiguration) { await this.session.customRequest("RNIDE_connect_cdp_debugger", cdpConfiguration); } diff --git a/packages/vscode-extension/src/debugging/cdp.ts b/packages/vscode-extension/src/debugging/cdp.ts index cd67b3764..eddae3b68 100644 --- a/packages/vscode-extension/src/debugging/cdp.ts +++ b/packages/vscode-extension/src/debugging/cdp.ts @@ -31,6 +31,36 @@ export type CDPDebuggerScope = { object: CDPRemoteObject & { type: "object" }; }; +export type CDPProfile = { + nodes: CDPProfileNode[]; + startTime: number; + endTime: number; + samples?: number[]; + timeDeltas?: number[]; +}; + +export type CDPProfileNode = { + id: number; + callFrame: CDPCallFrame; + hitCount?: number; + children?: number[]; + deoptReason?: string; + positionTicks?: CDPPositionTick[]; +}; + +export type CDPPositionTick = { + line: number; // 1-based according to the spec, WTF! + ticks: number; +}; + +export type CDPCallFrame = { + functionName: string; + scriptId: string; + url: string; + lineNumber: number; // 0-based + columnNumber: number; // 0-based +}; + export function inferDAPVariableValueForCDPRemoteObject(cdpValue: CDPRemoteObject): string { switch (cdpValue.type) { case "undefined": diff --git a/packages/vscode-extension/src/debugging/cpuProfiler.ts b/packages/vscode-extension/src/debugging/cpuProfiler.ts new file mode 100644 index 000000000..3c4e5dc9c --- /dev/null +++ b/packages/vscode-extension/src/debugging/cpuProfiler.ts @@ -0,0 +1,84 @@ +import { Source } from "@vscode/debugadapter"; +import { CDPCallFrame, CDPProfile } from "./cdp"; +import { SourceMapsRegistry } from "./SourceMapsRegistry"; + +type FrameKey = string; + +type DAPAnnotationLocation = { + callFrame: CDPCallFrame; + locations: DAPSourceLocation[]; +}; + +type DAPSourceLocation = { + lineNumber: number; + columnNumber: number; + source: Source; + relativePath?: string; +}; + +function callFrameKey(callFrame: CDPCallFrame): FrameKey { + return [callFrame.scriptId, callFrame.lineNumber, callFrame.columnNumber].join(":") as FrameKey; +} + +/** + * Expands the providing CPU Profile data with VSCode specific fields that + * are used by the VSCode CPU Profile Visualizer extension. The additional fields + * are used to provide the original source location for each node in the profile. + */ +export function annotateLocations(profile: CDPProfile, sourceMapsRegistry: SourceMapsRegistry) { + let locationIdCounter = 0; + const locationIds = new Map(); + const locations: DAPAnnotationLocation[] = []; + + const nodes = profile.nodes.map((node) => { + const key = callFrameKey(node.callFrame); + let locationId = locationIds.get(key); + + if (!locationId) { + locationId = locationIdCounter++; + locationIds.set(key, locationId); + + const origPosition = sourceMapsRegistry.findOriginalPosition( + // Apparently, hermes seems to report a different scriptId here – for a fresh app that just started, + // we get scriptId "2" for the main bundle, but in profile node's callFrame, "0" is reported. + // for this reasone, we rely on the script URL rather than scriptIde here, but we should revisit this + // to investigate if this is a bug in hermes or something that can be addressed. + node.callFrame.url, + node.callFrame.lineNumber + 1, + node.callFrame.columnNumber + ); + + if (origPosition.sourceURL !== "__source__") { + // TODO: we should find a better way to communicate that the source location cannot be resolved + locations.push({ + callFrame: node.callFrame, + locations: [ + { + lineNumber: origPosition.lineNumber1Based - 1, + columnNumber: origPosition.columnNumber0Based, + source: new Source(origPosition.sourceURL, origPosition.sourceURL), + }, + ], + }); + } else { + locations.push({ + callFrame: node.callFrame, + locations: [], + }); + } + } + return { + ...node, + locationId, // VScode specific field: https://github.com/microsoft/vscode-js-profile-visualizer/blob/main/packages/vscode-js-profile-core/src/cpu/types.ts#L12 + }; + }); + + return { + ...profile, + nodes, + $vscode: { + // VSCode especific fields: https://github.com/microsoft/vscode-js-profile-visualizer/blob/main/packages/vscode-js-profile-core/src/cpu/types.ts#L20 + locations: locations, + }, + }; +} diff --git a/packages/vscode-extension/src/project/deviceSession.ts b/packages/vscode-extension/src/project/deviceSession.ts index 7741940bf..8ffba44bd 100644 --- a/packages/vscode-extension/src/project/deviceSession.ts +++ b/packages/vscode-extension/src/project/deviceSession.ts @@ -331,6 +331,22 @@ export class DeviceSession implements Disposable { return this.device.captureScreenshot(); } + public async startProfilingCPU() { + if (this.debugSession) { + await this.debugSession.startProfilingCPU(); + } else { + throw new Error("Debug session not started"); + } + } + + public async stopProfilingCPU() { + if (this.debugSession) { + await this.debugSession.stopProfilingCPU(); + } else { + throw new Error("Debug session not started"); + } + } + public sendTouches(touches: Array, type: "Up" | "Move" | "Down") { this.device.sendTouches(touches, type); } diff --git a/packages/vscode-extension/src/project/project.ts b/packages/vscode-extension/src/project/project.ts index 32f52501d..24f94aeb9 100644 --- a/packages/vscode-extension/src/project/project.ts +++ b/packages/vscode-extension/src/project/project.ts @@ -1,5 +1,7 @@ import { EventEmitter } from "stream"; import os from "os"; +import path from "path"; +import fs from "fs"; import { env, Disposable, @@ -7,6 +9,8 @@ import { workspace, window, DebugSessionCustomEvent, + Uri, + extensions, ConfigurationChangeEvent, } from "vscode"; import _ from "lodash"; @@ -277,6 +281,75 @@ export class Project return this.deviceSession.captureAndStopRecording(); } + async startProfilingCPU() { + if (this.deviceSession) { + await this.deviceSession.startProfilingCPU(); + } else { + throw new Error("No device session available"); + } + } + + async stopProfilingCPU() { + if (this.deviceSession) { + await this.deviceSession.stopProfilingCPU(); + } else { + throw new Error("No device session available"); + } + } + + onProfilingCPUStarted(event: DebugSessionCustomEvent): void { + this.eventEmitter.emit("isProfilingCPU", true); + } + + async onProfilingCPUStopped(event: DebugSessionCustomEvent) { + this.eventEmitter.emit("isProfilingCPU", false); + + // Handle the profile file if a file path is provided + if (event.body && event.body.filePath) { + const tempFilePath = event.body.filePath; + + // Show save dialog to save the profile file to the workspace folder: + let defaultUri = Uri.file(tempFilePath); + const workspaceFolder = workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + defaultUri = Uri.file(path.join(workspaceFolder.uri.fsPath, path.basename(tempFilePath))); + } + + const saveDialog = await window.showSaveDialog({ + defaultUri, + filters: { + "CPU Profile": ["cpuprofile"], + }, + }); + + if (saveDialog) { + await fs.promises.copyFile(tempFilePath, saveDialog.fsPath); + commands.executeCommand("vscode.open", Uri.file(saveDialog.fsPath)); + + // verify whether flame chart visualizer extension is installed + // flame chart visualizer is not necessary to open the cpuprofile file, but when it is installed, + // the user can use the flame button from cpuprofile view to visualize it differently + const flameChartExtension = extensions.getExtension("ms-vscode.vscode-js-profile-flame"); + if (!flameChartExtension) { + const GO_TO_EXTENSION_BUTTON = "Go to Extension"; + window + .showInformationMessage( + "Flame Chart Visualizer extension is not installed. It is recommended to install it for better profiling insights.", + GO_TO_EXTENSION_BUTTON + ) + .then((action) => { + if (action === GO_TO_EXTENSION_BUTTON) { + commands.executeCommand( + "workbench.extensions.search", + "ms-vscode.vscode-js-profile-flame" + ); + } + }); + } + } + } + } + async captureAndStopRecording() { const recording = await this.stopRecording(); await this.utils.saveMultimedia(recording); diff --git a/packages/vscode-extension/src/webview/components/ToolsDropdown.tsx b/packages/vscode-extension/src/webview/components/ToolsDropdown.tsx index 09da27f40..1e8601f03 100644 --- a/packages/vscode-extension/src/webview/components/ToolsDropdown.tsx +++ b/packages/vscode-extension/src/webview/components/ToolsDropdown.tsx @@ -9,6 +9,8 @@ import "./ToolsDropdown.css"; import { useProject } from "../providers/ProjectProvider"; import IconButton from "./shared/IconButton"; import { DropdownMenuRoot } from "./DropdownMenuRoot"; +import Label from "./shared/Label"; +import { ProjectInterface, ToolState } from "../../common/Project"; interface DevToolCheckboxProps { label: string; @@ -44,26 +46,36 @@ function DevToolCheckbox({ ); } +function ToolsList({ + project, + tools, +}: { + project: ProjectInterface; + tools: [string, ToolState][]; +}) { + return tools.map(([key, tool]) => ( + { + await project.updateToolEnabledState(key, checked); + if (checked) { + project.openTool(key); + } + }} + onSelect={() => project.openTool(key)} + /> + )); +} + function ToolsDropdown({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) { - const { project, toolsState } = useProject(); + const { project, toolsState, isProfilingCPU } = useProject(); - const toolEntries = Object.entries(toolsState).map(([key, tool]) => { - return ( - { - await project.updateToolEnabledState(key, checked); - if (checked) { - project.openTool(key); - } - }} - onSelect={() => project.openTool(key)} - /> - ); - }); + const allTools = Object.entries(toolsState); + const panelTools = allTools.filter(([key, tool]) => tool.panelAvailable); + const nonPanelTools = allTools.filter(([key, tool]) => !tool.panelAvailable); return ( @@ -75,13 +87,18 @@ function ToolsDropdown({ children, disabled }: { children: React.ReactNode; disa className="dropdown-menu-content device-settings-content" onCloseAutoFocus={(e) => e.preventDefault()}>

Tools

- {toolEntries} - {toolEntries.length === 0 && ( -
- Your app doesn't have any supported dev tools configured.  - Learn more -
- )} + + + isProfilingCPU ? project.stopProfilingCPU() : project.startProfilingCPU() + }> + + {isProfilingCPU ? "Stop JS CPU Profiler" : "Start JS CPU Profiler"} + + + +
diff --git a/packages/vscode-extension/src/webview/components/UrlSelect.css b/packages/vscode-extension/src/webview/components/UrlSelect.css index 416b280ed..b2609303c 100644 --- a/packages/vscode-extension/src/webview/components/UrlSelect.css +++ b/packages/vscode-extension/src/webview/components/UrlSelect.css @@ -1,6 +1,5 @@ :root { - --url-select-min-width: 130px; - --url-select-max-width: 300px; + --url-select-max-width: 200px; } .url-select-trigger { @@ -19,7 +18,8 @@ color: var(--swm-url-select); background-color: var(--swm-url-select-background); user-select: none; - min-width: var(--url-select-min-width); + max-width: var(--url-select-max-width); + flex: 1; } .url-select-trigger:hover { background-color: var(--swm-url-select-hover-background); diff --git a/packages/vscode-extension/src/webview/providers/ProjectProvider.tsx b/packages/vscode-extension/src/webview/providers/ProjectProvider.tsx index 8b5311f34..0d7f49070 100644 --- a/packages/vscode-extension/src/webview/providers/ProjectProvider.tsx +++ b/packages/vscode-extension/src/webview/providers/ProjectProvider.tsx @@ -28,6 +28,7 @@ interface ProjectContextProps { replayData: MultimediaData | undefined; setReplayData: Dispatch>; isRecording: boolean; + isProfilingCPU: boolean; } const defaultProjectState: ProjectState = { @@ -61,6 +62,7 @@ const ProjectContext = createContext({ replayData: undefined, setReplayData: () => {}, isRecording: false, + isProfilingCPU: false, }); export default function ProjectProvider({ children }: PropsWithChildren) { @@ -69,6 +71,7 @@ export default function ProjectProvider({ children }: PropsWithChildren) { const [toolsState, setToolsState] = useState({}); const [hasActiveLicense, setHasActiveLicense] = useState(true); const [isRecording, setIsRecording] = useState(false); + const [isProfilingCPU, setIsProfilingCPU] = useState(false); const [replayData, setReplayData] = useState(undefined); useEffect(() => { @@ -87,6 +90,8 @@ export default function ProjectProvider({ children }: PropsWithChildren) { project.addListener("isRecording", setIsRecording); project.addListener("replayDataCreated", setReplayData); + project.addListener("isProfilingCPU", setIsProfilingCPU); + return () => { project.removeListener("projectStateChanged", setProjectState); project.removeListener("deviceSettingsChanged", setDeviceSettings); @@ -105,6 +110,7 @@ export default function ProjectProvider({ children }: PropsWithChildren) { replayData, setReplayData, isRecording, + isProfilingCPU, }; }, [ projectState, @@ -115,6 +121,7 @@ export default function ProjectProvider({ children }: PropsWithChildren) { replayData, setReplayData, isRecording, + isProfilingCPU, ]); return {children}; diff --git a/packages/vscode-extension/src/webview/styles/theme.css b/packages/vscode-extension/src/webview/styles/theme.css index 542afcdd7..7c4bdb516 100644 --- a/packages/vscode-extension/src/webview/styles/theme.css +++ b/packages/vscode-extension/src/webview/styles/theme.css @@ -570,7 +570,8 @@ body[data-use-code-theme="true"] { --swm-button-counter-background: var(--vscode-activityBarBadge-background); --swm-button-counter-border: var(--vscode-contrastBorder); - --swm-button-recording-on-background: var(--navy-light-60); + --swm-button-recording-on: var(--vscode-activityBarBadge-foreground); + --swm-button-recording-on-background: var(--vscode-activityBarBadge-background); --swm-button-replay-hover: var(--navy-light-transparent); /* Tooltip */ diff --git a/packages/vscode-extension/src/webview/views/PreviewView.css b/packages/vscode-extension/src/webview/views/PreviewView.css index b3d513bc5..205614caa 100644 --- a/packages/vscode-extension/src/webview/views/PreviewView.css +++ b/packages/vscode-extension/src/webview/views/PreviewView.css @@ -1,12 +1,22 @@ .button-group-top, .button-group-bottom { display: flex; - align-items: center; + justify-content: space-between; width: 100%; margin: 8px; gap: 4px; } +.button-group-top-left, +.button-group-top-right { + display: flex; + flex-direction: row; +} + +.button-group-top-left { + flex: 1; +} + .icons-rewind:before { width: 0px; margin: -4px; @@ -63,20 +73,41 @@ .button-recording-on, .button-recording-on:hover { background-color: var(--swm-button-recording-on-background); + color: var(--swm-button-recording-on); + border: 1px solid var(--swm-button-counter-border); border-radius: 10px; width: auto; - padding: 0 5px 0 8px; + padding: 0 8px; } .recording-rec-indicator { display: flex; align-items: center; - color: white; font-size: 120%; - filter: blur(0.4px); font-family: "Courier new", fixed; } +.icon-red { + color: red; + font-size: 24px; + scale: 2.8; +} + +@keyframes pulse { + 0% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(0.85); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + .recording-rec-dot { width: 13px; height: 13px; @@ -84,6 +115,7 @@ align-self: center; border-radius: 50%; margin-right: 6px; + animation: pulse 1.5s ease-in-out infinite; } @media (max-width: 385px) { diff --git a/packages/vscode-extension/src/webview/views/PreviewView.tsx b/packages/vscode-extension/src/webview/views/PreviewView.tsx index fa6aba21f..88a7d5bb9 100644 --- a/packages/vscode-extension/src/webview/views/PreviewView.tsx +++ b/packages/vscode-extension/src/webview/views/PreviewView.tsx @@ -42,6 +42,7 @@ function PreviewView() { hasActiveLicense, replayData, isRecording, + isProfilingCPU, setReplayData, } = useProject(); const { showDismissableError } = useUtils(); @@ -136,6 +137,10 @@ function PreviewView() { } } + function stopProfilingCPU() { + project.stopProfilingCPU(); + } + async function handleReplay() { try { await project.captureReplay(); @@ -166,64 +171,81 @@ function PreviewView() { return (
- -
- - - +
+ +
+
+ + {isProfilingCPU && ( + <> +
+ Profiling CPU + + )} - - - {isRecording ? ( -
-
- {recordingTimeFormat} -
- ) : ( - + + + + + + + {isRecording ? ( +
+
+ {recordingTimeFormat} +
+ ) : ( + + )} + + {showReplayButton && ( + + + )} - - {showReplayButton && ( - + - )} - - - - { - setLogCounter(0); - project.focusDebugConsole(); - }} - tooltip={{ - label: "Open logs panel", - }} - disabled={hasNoDevices}> - - - - - + { + setLogCounter(0); + project.focusDebugConsole(); + }} + tooltip={{ + label: "Open logs panel", + }} + disabled={hasNoDevices}> + - + + + + + +
{selectedDevice && initialized ? (