Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to record and preview CPU profile of the app #989

Merged
merged 11 commits into from
Mar 3, 2025
12 changes: 11 additions & 1 deletion packages/vscode-extension/src/common/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -125,6 +131,7 @@ export interface ProjectEventMap {
needsNativeRebuild: void;
replayDataCreated: MultimediaData;
isRecording: boolean;
isProfilingCPU: boolean;
}

export interface ProjectEventListener<T> {
Expand Down Expand Up @@ -175,6 +182,9 @@ export interface ProjectInterface {
captureReplay(): void;
captureScreenshot(): void;

startProfilingCPU(): void;
stopProfilingCPU(): void;

dispatchTouches(touches: Array<TouchPoint>, type: "Up" | "Move" | "Down"): void;
dispatchKeyPress(keyCode: number, direction: "Up" | "Down"): void;
dispatchWheel(point: TouchPoint, deltaX: number, deltaY: number): void;
Expand Down
22 changes: 19 additions & 3 deletions packages/vscode-extension/src/debugging/DebugAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { DebugConfiguration } from "vscode";

Check warning on line 1 in packages/vscode-extension/src/debugging/DebugAdapter.ts

View workflow job for this annotation

GitHub Actions / check

`vscode` import should occur after import of `os`
import fs from "fs";
import path from "path";
import os from "os";
import {
DebugSession,
InitializedEvent,
Expand Down Expand Up @@ -617,12 +620,25 @@
this.sendResponse(response);
}

protected customRequest(
protected async customRequest(
command: string,
response: DebugProtocol.Response,
args: any,
request?: DebugProtocol.Request | undefined
): void {
Logger.debug(`Custom req ${command} ${args}`);
) {
if (command === "startProfiling") {
await this.cdpSession.sendCDPMessage("Profiler.start", {});
this.sendEvent(new Event("RNIDE_profilingCPUStarted"));
this.sendResponse(response);
} else if (command === "stopProfiling") {
const result = await this.cdpSession.sendCDPMessage("Profiler.stop", {});
const fileName = `profile-${Date.now()}.cpuprofile`;
const filePath = path.join(os.tmpdir(), fileName);
await fs.promises.writeFile(filePath, JSON.stringify(result.profile));
this.sendEvent(new Event("RNIDE_profilingCPUStopped", { filePath }));
this.sendResponse(response);
} else {
Logger.debug(`Custom req ${command} ${args}`);
}
}
}
16 changes: 16 additions & 0 deletions packages/vscode-extension/src/debugging/DebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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 class DebugSession implements Disposable {
Expand All @@ -28,6 +30,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;
Expand Down Expand Up @@ -98,6 +106,14 @@ export class DebugSession implements Disposable {
this.session.customRequest("next");
}

public async startProfilingCPU() {
await this.session.customRequest("startProfiling");
}

public async stopProfilingCPU() {
await this.session.customRequest("stopProfiling");
}

private get session() {
if (!this.vscSession) {
throw new Error("Debugger not started");
Expand Down
16 changes: 16 additions & 0 deletions packages/vscode-extension/src/project/deviceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,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<TouchPoint>, type: "Up" | "Move" | "Down") {
this.device.sendTouches(touches, type);
}
Expand Down
73 changes: 73 additions & 0 deletions packages/vscode-extension/src/project/project.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { EventEmitter } from "stream";
import os from "os";
import path from "path";
import fs from "fs";
import {
env,
Disposable,
commands,
workspace,
window,
DebugSessionCustomEvent,
Uri,
extensions,
ConfigurationChangeEvent,
} from "vscode";
import _ from "lodash";
Expand Down Expand Up @@ -278,6 +282,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);
Expand Down
67 changes: 42 additions & 25 deletions packages/vscode-extension/src/webview/components/ToolsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -44,26 +46,36 @@ function DevToolCheckbox({
);
}

function ToolsList({
project,
tools,
}: {
project: ProjectInterface;
tools: [string, ToolState][];
}) {
return tools.map(([key, tool]) => (
<DevToolCheckbox
key={key}
label={tool.label}
checked={tool.enabled}
panelAvailable={tool.panelAvailable}
onCheckedChange={async (checked) => {
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 (
<DevToolCheckbox
key={key}
label={tool.label}
checked={tool.enabled}
panelAvailable={tool.panelAvailable}
onCheckedChange={async (checked) => {
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 (
<DropdownMenuRoot>
Expand All @@ -75,13 +87,18 @@ function ToolsDropdown({ children, disabled }: { children: React.ReactNode; disa
className="dropdown-menu-content device-settings-content"
onCloseAutoFocus={(e) => e.preventDefault()}>
<h4 className="device-settings-heading">Tools</h4>
{toolEntries}
{toolEntries.length === 0 && (
<div className="tools-empty-message">
Your app doesn't have any supported dev tools configured.&nbsp;
<a href="https://ide.swmansion.com/docs/features/dev-tools">Learn more</a>
</div>
)}
<Label>Utilities</Label>
<DropdownMenu.Item
className="dropdown-menu-item"
onSelect={() =>
isProfilingCPU ? project.stopProfilingCPU() : project.startProfilingCPU()
}>
<span className="codicon codicon-chip" />
{isProfilingCPU ? "Stop JS CPU Profiler" : "Start JS CPU Profiler"}
</DropdownMenu.Item>
<ToolsList project={project} tools={nonPanelTools} />
<Label>Tool Panels</Label>
<ToolsList project={project} tools={panelTools} />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenuRoot>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
:root {
--url-select-min-width: 130px;
--url-select-max-width: 300px;
--url-select-max-width: 200px;
}

.url-select-trigger {
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface ProjectContextProps {
replayData: MultimediaData | undefined;
setReplayData: Dispatch<SetStateAction<MultimediaData | undefined>>;
isRecording: boolean;
isProfilingCPU: boolean;
}

const defaultProjectState: ProjectState = {
Expand Down Expand Up @@ -61,6 +62,7 @@ const ProjectContext = createContext<ProjectContextProps>({
replayData: undefined,
setReplayData: () => {},
isRecording: false,
isProfilingCPU: false,
});

export default function ProjectProvider({ children }: PropsWithChildren) {
Expand All @@ -69,6 +71,7 @@ export default function ProjectProvider({ children }: PropsWithChildren) {
const [toolsState, setToolsState] = useState<ToolsState>({});
const [hasActiveLicense, setHasActiveLicense] = useState(true);
const [isRecording, setIsRecording] = useState(false);
const [isProfilingCPU, setIsProfilingCPU] = useState(false);
const [replayData, setReplayData] = useState<MultimediaData | undefined>(undefined);

useEffect(() => {
Expand All @@ -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);
Expand All @@ -105,6 +110,7 @@ export default function ProjectProvider({ children }: PropsWithChildren) {
replayData,
setReplayData,
isRecording,
isProfilingCPU,
};
}, [
projectState,
Expand All @@ -115,6 +121,7 @@ export default function ProjectProvider({ children }: PropsWithChildren) {
replayData,
setReplayData,
isRecording,
isProfilingCPU,
]);

return <ProjectContext.Provider value={contextValue}>{children}</ProjectContext.Provider>;
Expand Down
3 changes: 2 additions & 1 deletion packages/vscode-extension/src/webview/styles/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading