Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion app/components/DetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<View style={$detailContent()}>
<DetailSection title="Log Level">
Expand All @@ -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 && (
<DetailSection title="Stack Trace">
<TreeViewWithProvider data={payload.stack} />
{isErrorStackFrameArray(payload.stack) ? (
<View>
<View style={$stackTableHeader()}>
<View style={$stackHeaderFunction()}>
<Text style={$stackHeaderText()}>Function</Text>
</View>
<View style={$stackHeaderFile()}>
<Text style={$stackHeaderText()}>File</Text>
</View>
<View style={$stackHeaderLine()}>
<Text style={$stackHeaderText()}>Line</Text>
</View>
</View>
{payload.stack.map((stackFrame, index) => (
<StackFrameRow
key={`stack-${index}`}
stackFrame={stackFrame}
onPress={openInEditor}
/>
))}
</View>
) : (
<TreeViewWithProvider data={payload.stack} />
)}
</DetailSection>
)}

Expand Down Expand Up @@ -552,3 +583,34 @@ const $copyButton = themed<ViewStyle>(({ spacing }) => ({
const $copyButtonText = themed<TextStyle>(() => ({
fontSize: 20,
}))

const $stackTableHeader = themed<ViewStyle>(({ colors, spacing }) => ({
flexDirection: "row",
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.border,
marginBottom: spacing.xs,
}))

const $stackHeaderFunction = themed<ViewStyle>(() => ({
flex: 1,
marginRight: 8,
}))

const $stackHeaderFile = themed<ViewStyle>(() => ({
flex: 1,
marginRight: 8,
}))

const $stackHeaderLine = themed<ViewStyle>(() => ({
width: 60,
alignItems: "flex-end",
}))

const $stackHeaderText = themed<TextStyle>(({ colors, typography }) => ({
color: colors.neutral,
fontSize: typography.caption,
fontFamily: typography.primary.semiBold,
textTransform: "uppercase",
}))
108 changes: 108 additions & 0 deletions app/components/StackFrameRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Pressable
onPress={handlePress}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
>
<View
style={[
$container(),
isHovered ? $containerHovered() : {},
isFromNodeModules ? $containerNodeModules() : {},
]}
>
<View style={$functionColumn()}>
<Text style={$functionText()} numberOfLines={1}>
{functionName}
</Text>
</View>
<View style={$fileColumn()}>
<Text style={$fileText()} numberOfLines={1}>
{justFileName}
</Text>
</View>
<View style={$lineColumn()}>
<Text style={$lineText()}>{lineNumber}</Text>
</View>
</View>
</Pressable>
)
}

const $container = themed<ViewStyle>(({ spacing }) => ({
flexDirection: "row",
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
cursor: "pointer",
}))

const $containerHovered = themed<ViewStyle>(({ colors }) => ({
backgroundColor: colors.neutralVery,
}))

const $containerNodeModules = themed<ViewStyle>(() => ({
opacity: 0.4,
}))

const $functionColumn = themed<ViewStyle>(() => ({
flex: 1,
marginRight: 8,
}))

const $fileColumn = themed<ViewStyle>(() => ({
flex: 1,
marginRight: 8,
}))

const $lineColumn = themed<ViewStyle>(() => ({
width: 60,
alignItems: "flex-end",
}))

const $functionText = themed<TextStyle>(({ colors, typography }) => ({
color: colors.mainText,
fontSize: typography.body,
fontFamily: typography.code.normal,
}))

const $fileText = themed<TextStyle>(({ colors, typography }) => ({
color: colors.neutral,
fontSize: typography.body,
fontFamily: typography.code.normal,
}))

const $lineText = themed<TextStyle>(({ colors, typography }) => ({
color: colors.primary,
fontSize: typography.body,
fontFamily: typography.code.normal,
}))
22 changes: 22 additions & 0 deletions app/state/sendCommand.ts
Original file line number Diff line number Diff line change
@@ -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)
}
54 changes: 54 additions & 0 deletions app/utils/stackFrames.ts
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 43 additions & 6 deletions standalone-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
})
}

Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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 }))
})
})

Expand All @@ -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 }))
})
})

Expand All @@ -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
Expand Down