From 036d24286ac36d7c809c5d8e2cdcd42226076da0 Mon Sep 17 00:00:00 2001 From: "Silas J. Matson" Date: Wed, 8 Oct 2025 17:19:54 -0700 Subject: [PATCH] wip: open in editor #34 --- app/components/DetailPanel.tsx | 64 +++++++++++++++++- app/components/StackFrameRow.tsx | 108 +++++++++++++++++++++++++++++++ app/state/sendCommand.ts | 22 +++++++ app/utils/stackFrames.ts | 54 ++++++++++++++++ standalone-server.js | 49 ++++++++++++-- 5 files changed, 290 insertions(+), 7 deletions(-) create mode 100644 app/components/StackFrameRow.tsx create mode 100644 app/state/sendCommand.ts create mode 100644 app/utils/stackFrames.ts diff --git a/app/components/DetailPanel.tsx b/app/components/DetailPanel.tsx index e0cd7db..5875c12 100644 --- a/app/components/DetailPanel.tsx +++ b/app/components/DetailPanel.tsx @@ -18,6 +18,9 @@ import { Tooltip } from "./Tooltip" import IRClipboard from "../native/IRClipboard/NativeIRClipboard" import { $flex } from "../theme/basics" import { formatTime } from "../utils/formatTime" +import { StackFrameRow } from "./StackFrameRow" +import { isErrorStackFrameArray } from "../utils/stackFrames" +import { sendCommand } from "../state/sendCommand" type DetailPanelProps = { selectedItem: TimelineItem | null @@ -263,6 +266,11 @@ function BenchmarkDetailContent({ item }: { item: TimelineItemBenchmark }) { function LogDetailContent({ item }: { item: TimelineItem & { type: typeof CommandType.Log } }) { const { payload } = item + const openInEditor = (file: string, lineNumber: number) => { + if (!file) return + sendCommand("editor.open", { file, lineNumber: String(lineNumber) }) + } + return ( @@ -280,7 +288,30 @@ function LogDetailContent({ item }: { item: TimelineItem & { type: typeof Comman {/* Show stack trace only for error level logs that have stack data */} {payload.level === "error" && "stack" in payload && ( - + {isErrorStackFrameArray(payload.stack) ? ( + + + + Function + + + File + + + Line + + + {payload.stack.map((stackFrame, index) => ( + + ))} + + ) : ( + + )} )} @@ -552,3 +583,34 @@ const $copyButton = themed(({ spacing }) => ({ const $copyButtonText = themed(() => ({ fontSize: 20, })) + +const $stackTableHeader = themed(({ colors, spacing }) => ({ + flexDirection: "row", + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border, + marginBottom: spacing.xs, +})) + +const $stackHeaderFunction = themed(() => ({ + flex: 1, + marginRight: 8, +})) + +const $stackHeaderFile = themed(() => ({ + flex: 1, + marginRight: 8, +})) + +const $stackHeaderLine = themed(() => ({ + width: 60, + alignItems: "flex-end", +})) + +const $stackHeaderText = themed(({ colors, typography }) => ({ + color: colors.neutral, + fontSize: typography.caption, + fontFamily: typography.primary.semiBold, + textTransform: "uppercase", +})) diff --git a/app/components/StackFrameRow.tsx b/app/components/StackFrameRow.tsx new file mode 100644 index 0000000..276653e --- /dev/null +++ b/app/components/StackFrameRow.tsx @@ -0,0 +1,108 @@ +import { useState } from "react" +import { View, Text, Pressable, type ViewStyle, type TextStyle } from "react-native" +import { themed } from "../theme/theme" +import type { ErrorStackFrame } from "../types" +import { isNodeModule, getJustFileName } from "../utils/stackFrames" + +interface StackFrameRowProps { + stackFrame: ErrorStackFrame + onPress: (fileName: string, lineNumber: number) => void +} + +/** + * Renders a single stack frame row in a stack trace. + * Shows function name, file name, and line number. + * Dims node_modules entries and shows hover state. + */ +export function StackFrameRow({ stackFrame, onPress }: StackFrameRowProps) { + const [isHovered, setIsHovered] = useState(false) + const fileName = stackFrame.fileName || "" + const functionName = stackFrame.functionName || "(anonymous function)" + const lineNumber = stackFrame.lineNumber || 0 + const isFromNodeModules = isNodeModule(fileName) + const justFileName = getJustFileName(fileName) + + const handlePress = () => { + if (fileName) { + onPress(fileName, lineNumber) + } + } + + return ( + setIsHovered(true)} + onHoverOut={() => setIsHovered(false)} + > + + + + {functionName} + + + + + {justFileName} + + + + {lineNumber} + + + + ) +} + +const $container = themed(({ spacing }) => ({ + flexDirection: "row", + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + cursor: "pointer", +})) + +const $containerHovered = themed(({ colors }) => ({ + backgroundColor: colors.neutralVery, +})) + +const $containerNodeModules = themed(() => ({ + opacity: 0.4, +})) + +const $functionColumn = themed(() => ({ + flex: 1, + marginRight: 8, +})) + +const $fileColumn = themed(() => ({ + flex: 1, + marginRight: 8, +})) + +const $lineColumn = themed(() => ({ + width: 60, + alignItems: "flex-end", +})) + +const $functionText = themed(({ colors, typography }) => ({ + color: colors.mainText, + fontSize: typography.body, + fontFamily: typography.code.normal, +})) + +const $fileText = themed(({ colors, typography }) => ({ + color: colors.neutral, + fontSize: typography.body, + fontFamily: typography.code.normal, +})) + +const $lineText = themed(({ colors, typography }) => ({ + color: colors.primary, + fontSize: typography.body, + fontFamily: typography.code.normal, +})) diff --git a/app/state/sendCommand.ts b/app/state/sendCommand.ts new file mode 100644 index 0000000..40cdde8 --- /dev/null +++ b/app/state/sendCommand.ts @@ -0,0 +1,22 @@ +import { sendToClient } from "./connectToServer" +import { withGlobal } from "./useGlobal" + +/** + * Sends a command to the connected React Native client. + * This is used to trigger actions in the client app, such as opening files in the editor. + * + * @param type - The command type (e.g., "editor.open") + * @param payload - The command payload + * @param clientId - Optional client ID. If not provided, uses the active client. + */ +export function sendCommand(type: string, payload: any, clientId?: string) { + // If no clientId is provided, use the active client + const activeClientId = clientId || withGlobal("activeClientId", "")[0] + + if (!activeClientId) { + console.warn("No active client to send command to") + return + } + + sendToClient(type, payload, activeClientId) +} diff --git a/app/utils/stackFrames.ts b/app/utils/stackFrames.ts new file mode 100644 index 0000000..553ade8 --- /dev/null +++ b/app/utils/stackFrames.ts @@ -0,0 +1,54 @@ +import type { ErrorStackFrame } from "../types" + +/** + * Type guard to check if stack is an array of ErrorStackFrame objects + */ +export function isErrorStackFrameArray(stack: any): stack is ErrorStackFrame[] { + if (!Array.isArray(stack)) return false + if (stack.length === 0) return false + + const firstItem = stack[0] + return ( + typeof firstItem === "object" && + firstItem !== null && + "fileName" in firstItem && + "lineNumber" in firstItem + ) +} + +/** + * Formats a file name to show just the file name or the last few path segments + */ +export function formatFileName(fileName: string, segmentCount: number = 3): string { + if (!fileName) return "" + + // Remove webpack:// prefix if present + const cleanedFileName = fileName.replace("webpack://", "") + + const segments = cleanedFileName.split("/") + const lastSlashIndex = cleanedFileName.lastIndexOf("/") + + // If it's just a filename with no path, return as-is + if (lastSlashIndex === -1) return cleanedFileName + + // Return the last N segments + const shortPath = segments.slice(-segmentCount).join("/") + return shortPath +} + +/** + * Checks if a file path is from node_modules + */ +export function isNodeModule(fileName: string): boolean { + if (!fileName) return false + return fileName.includes("/node_modules/") +} + +/** + * Gets just the filename from a full path + */ +export function getJustFileName(fileName: string): string { + if (!fileName) return "" + const lastSlashIndex = fileName.lastIndexOf("/") + return lastSlashIndex > -1 ? fileName.substr(lastSlashIndex + 1) : fileName +} diff --git a/standalone-server.js b/standalone-server.js index 803b28b..3fb48ac 100644 --- a/standalone-server.js +++ b/standalone-server.js @@ -9,18 +9,34 @@ const connectedReactotrons = [] const connectedClients = [] +/** + * Safely sends a message to a WebSocket, checking if it's open first + */ +function safeSend(socket, message) { + try { + if (socket.readyState === 1) { + // 1 = OPEN + socket.send(message) + return true + } + } catch (error) { + console.error("Error sending message:", error.message) + } + return false +} + function addReactotronApp(socket) { // Add the Reactotron app to the list of connected Reactotron apps connectedReactotrons.push(socket) // Send a message back to the Reactotron app to let it know it's connected - socket.send(JSON.stringify({ type: "reactotron.connected" })) + safeSend(socket, JSON.stringify({ type: "reactotron.connected" })) console.log("Reactotron app connected: ", socket.id) // Send the updated list of connected clients to all connected Reactotron apps const clients = connectedClients.map((c) => ({ clientId: c.clientId, name: c.name })) connectedReactotrons.forEach((reactotronApp) => { - reactotronApp.send(JSON.stringify({ type: "connectedClients", clients })) + safeSend(reactotronApp, JSON.stringify({ type: "connectedClients", clients })) }) } @@ -36,7 +52,8 @@ function interceptMessage(incoming, socket, server) { const { type, ...actualPayload } = payload server.wss.clients.forEach((wssClient) => { if (wssClient.clientId === payload.clientId) { - wssClient.send( + safeSend( + wssClient, JSON.stringify({ type, payload: actualPayload, @@ -64,6 +81,26 @@ function startReactotronServer(opts = {}) { server.wss.on("connection", (socket, _request) => { // Intercept messages sent to this socket to check for Reactotron apps socket.on("message", (m) => interceptMessage(m, socket, server)) + + // Handle socket errors to prevent crashes + socket.on("error", (error) => { + console.error("WebSocket error:", error.message) + // Remove from connected Reactotrons if present + const index = connectedReactotrons.indexOf(socket) + if (index !== -1) { + connectedReactotrons.splice(index, 1) + console.log("Reactotron app removed due to error") + } + }) + + // Clean up when socket closes + socket.on("close", () => { + const index = connectedReactotrons.indexOf(socket) + if (index !== -1) { + connectedReactotrons.splice(index, 1) + console.log("Reactotron app disconnected") + } + }) }) // The server has started. @@ -82,7 +119,7 @@ function startReactotronServer(opts = {}) { // conn here is a ReactotronConnection object // We will forward this to all connected Reactotron apps. // https://github.com/infinitered/reactotron/blob/bba01082f882307773a01e4f90ccf25ccff76949/apps/reactotron-app/src/renderer/contexts/Standalone/useStandalone.ts#L18 - reactotronApp.send(JSON.stringify({ type: "connectedClients", clients })) + safeSend(reactotronApp, JSON.stringify({ type: "connectedClients", clients })) }) }) @@ -91,7 +128,7 @@ function startReactotronServer(opts = {}) { // send the command to all connected Reactotron apps connectedReactotrons.forEach((reactotronApp) => { console.log("Sending command to Reactotron app: ", cmd.type, reactotronApp.id) - reactotronApp.send(JSON.stringify({ type: "command", cmd })) + safeSend(reactotronApp, JSON.stringify({ type: "command", cmd })) }) }) @@ -104,7 +141,7 @@ function startReactotronServer(opts = {}) { // conn here is a ReactotronConnection object // We will forward this to all connected Reactotron apps. // https://github.com/infinitered/reactotron/blob/bba01082f882307773a01e4f90ccf25ccff76949/apps/reactotron-app/src/renderer/contexts/Standalone/useStandalone.ts#L18 - reactotronApp.send(JSON.stringify({ type: "disconnect", conn })) + safeSend(reactotronApp, JSON.stringify({ type: "disconnect", conn })) }) // Remove the client from the list of connected clients