diff --git a/apps/reactotron-app/src/renderer/App.tsx b/apps/reactotron-app/src/renderer/App.tsx index bbab99b60..9697857bd 100644 --- a/apps/reactotron-app/src/renderer/App.tsx +++ b/apps/reactotron-app/src/renderer/App.tsx @@ -15,6 +15,7 @@ import Overlay from "./pages/reactNative/Overlay" import Storybook from "./pages/reactNative/Storybook" import CustomCommands from "./pages/customCommands" import Help from "./pages/help" +import { Network } from "./pages/network" const AppContainer = styled.div` position: absolute; @@ -67,6 +68,9 @@ function App() { {/* Custom Commands */} } /> + {/* Network */} + } /> + {/* Help */} } /> diff --git a/apps/reactotron-app/src/renderer/components/SideBar/Sidebar.tsx b/apps/reactotron-app/src/renderer/components/SideBar/Sidebar.tsx index 2ff34b503..429684f50 100644 --- a/apps/reactotron-app/src/renderer/components/SideBar/Sidebar.tsx +++ b/apps/reactotron-app/src/renderer/components/SideBar/Sidebar.tsx @@ -7,6 +7,7 @@ import { MdWarning, MdOutlineMobileFriendly, MdMobiledataOff, + MdConnectWithoutContact, } from "react-icons/md" import { FaMagic } from "react-icons/fa" import styled from "styled-components" @@ -58,6 +59,7 @@ function SideBar({ isOpen, serverStatus }: { isOpen: boolean; serverStatus: Serv + + props.theme.background}; + color: ${(props) => props.theme.foreground}; + overflow-y: auto; +` + +const Header = styled.div` + padding: 16px; + border-bottom: 1px solid ${(props) => props.theme.border}; + font-size: 18px; + font-weight: bold; + -webkit-app-region: drag; +` + +const Content = styled.div` + padding: 16px; + flex: 1; + overflow-y: auto; +` + +const CommandItem = styled.div` + padding: 12px; + margin-bottom: 8px; + border: 1px solid ${(props) => props.theme.border}; + border-radius: 4px; + background-color: ${(props) => props.theme.chrome}; +` + +const CommandType = styled.div` + font-weight: bold; + margin-bottom: 8px; + color: ${(props) => props.theme.tag}; +` + +const CommandDetails = styled.div` + font-size: 12px; + color: ${(props) => props.theme.foregroundDark}; +` + +function DrawerWindow() { + const [commands, setCommands] = useState([]) + + useEffect(() => { + const handleUpdateCommands = (_event: any, newCommands: Command[]) => { + setCommands(newCommands) + } + + ipcRenderer.on("update-drawer-commands", handleUpdateCommands) + + return () => { + ipcRenderer.removeListener("update-drawer-commands", handleUpdateCommands) + } + }, []) + + return ( + + Timeline Commands + + {commands.length === 0 ? ( + No commands to display + ) : ( + commands.map((command) => ( + + {command.type} + + Message ID: {command.messageId} + Connection ID: {command.connectionId} + Date: {new Date(command.date).toLocaleString()} + {command.clientId && Client ID: {command.clientId}} + + + )) + )} + + + ) +} + +export default DrawerWindow diff --git a/apps/reactotron-app/src/renderer/pages/network/components/NetworkRequestHeader.tsx b/apps/reactotron-app/src/renderer/pages/network/components/NetworkRequestHeader.tsx new file mode 100644 index 000000000..4c243dc59 --- /dev/null +++ b/apps/reactotron-app/src/renderer/pages/network/components/NetworkRequestHeader.tsx @@ -0,0 +1,47 @@ +import React from "react" +import Styles from "../network.styles" + +interface NetworkRequestHeaderProps { + currSelectedType: string + onTabChange: (tab: string) => void + tabContent: Record +} + +const AvailableTabs = [ + "request headers", + "request params", + "request body", + "response headers", + "response body", +] as const + +const { RequestDataHeader, RequestAvailableTabsContainer } = Styles + +export const NetworkRequestHeader: React.FC = ({ + currSelectedType, + onTabChange, + tabContent, +}) => { + return ( + + + {AvailableTabs.map((tab) => { + const hasTab = tabContent[tab] + if (!hasTab) return null + + return ( + onTabChange(tab)} + className={currSelectedType === tab ? "active" : ""} + > + {tab} + + ) + })} + + + ) +} + +export default NetworkRequestHeader diff --git a/apps/reactotron-app/src/renderer/pages/network/components/NetworkRequestsList.tsx b/apps/reactotron-app/src/renderer/pages/network/components/NetworkRequestsList.tsx new file mode 100644 index 000000000..726e19581 --- /dev/null +++ b/apps/reactotron-app/src/renderer/pages/network/components/NetworkRequestsList.tsx @@ -0,0 +1,100 @@ +import React from "react" +import { VirtualizedList } from "./VirtualizedList" +import Styles from "../network.styles" +import { Command, CommandTypeKey } from "reactotron-core-contract" + +interface NetworkRequestsListProps { + filteredCommands: Command[] + containerHeight: number + currentCommandId?: number + onRequestClick: (messageId: number) => void + overscan?: number +} + +const formatTime = (date: Date) => { + const d = new Date(date) + return d.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) +} + +const formatSize = (bytes: number) => { + const kb = bytes / 1024 + if (kb < 1024) { + return `${kb.toFixed(2)} KB` + } + const mb = kb / 1024 + return `${mb.toFixed(2)} MB` +} + +const { + RequestContainer, + RequestItem, + RequestTableHeader, + RequestTableHeaderCell, + RequestTableCell, +} = Styles + +export const NetworkRequestsList: React.FC = ({ + filteredCommands, + containerHeight, + currentCommandId, + onRequestClick, + overscan = 5, +}) => { + + return ( + + + Time + Method + URL + Status + Size + + item.messageId.toString()} + data={filteredCommands} + itemHeight={50} + height={containerHeight - 40} + overscan={overscan} + renderItem={(command) => { + const payloadBuffer = Buffer.from(JSON.stringify(command.payload), "utf8"); + const payloadSize = payloadBuffer.byteLength + + const shortenedUrl = + command.payload?.request?.url?.split("://")[1] || command.payload?.request?.url || "N/A" + + const method = command.payload?.request?.method?.toUpperCase() || "N/A" + const status = command.payload?.response?.status || "N/A" + const time = formatTime(command.date) + + return ( + onRequestClick(command?.messageId)} + className={currentCommandId === command?.messageId ? "active" : ""} + > + {time} + + {method} + + + {shortenedUrl} + + + {status} + + + {formatSize(payloadSize)} + + + ) + }} + /> + + ) +} diff --git a/apps/reactotron-app/src/renderer/pages/network/components/VirtualizedList.tsx b/apps/reactotron-app/src/renderer/pages/network/components/VirtualizedList.tsx new file mode 100644 index 000000000..307545d62 --- /dev/null +++ b/apps/reactotron-app/src/renderer/pages/network/components/VirtualizedList.tsx @@ -0,0 +1,67 @@ +import React, { useMemo, useRef, useState } from "react" + +interface VirtualizedListProps { + data: T[] + renderItem: (item: T) => React.ReactNode + itemHeight: number + height: number + getKey: (item: T) => string + overscan?: number +} + +export const VirtualizedList = ({ + data, + renderItem, + itemHeight, + height, + getKey, + // extra items before and after visible area + overscan = 5, +}: VirtualizedListProps) => { + const itemAmount = height / itemHeight + const totalHeight = data.length * itemHeight + + const [positions, setPositions] = useState({ head: 0, tail: itemAmount }) + const outerWrapperRef = useRef(null) + + const calculateNewIndexes = () => { + const el = outerWrapperRef.current + if (!el) return + + const scrollTop = el.scrollTop + const head = Math.max(0, Math.ceil(scrollTop / itemHeight)) + const tail = Math.min(data.length, head + itemAmount) + + if (head === positions.head && tail === positions.tail) return + setPositions({ head, tail }) + } + + const overscanHead = Math.max(0, positions.head - overscan) + const overscanTail = Math.min(data.length, positions.tail + overscan) + + const visibleItems = useMemo( + () => data.slice(overscanHead, overscanTail), + [data, overscanHead, overscanTail] + ) + + if (!data.length) return null + + return ( + + + + {visibleItems.map((item) => ( + {renderItem(item)} + ))} + + + + ) +} + diff --git a/apps/reactotron-app/src/renderer/pages/network/index.tsx b/apps/reactotron-app/src/renderer/pages/network/index.tsx new file mode 100644 index 000000000..207475cf6 --- /dev/null +++ b/apps/reactotron-app/src/renderer/pages/network/index.tsx @@ -0,0 +1,120 @@ +import React, { useContext, useEffect, useRef, useState } from "react" +import Styles from "./network.styles" +import { ContentView, ReactotronContext, Header, EmptyState } from "reactotron-core-ui" +import { MdNetworkCheck } from "react-icons/md" +import { useDrawerResize } from "./useDrawerResize" +import { NetworkRequestsList } from "./components/NetworkRequestsList" +import { NetworkRequestHeader } from "./components/NetworkRequestHeader" + +const { + Container, + ResizableSectionWrapper, + RequestResponseContainer, + RequestResponseContainerBody, + ResizeHandle, +} = Styles + +export const Network = () => { + const hasUserResizedRef = useRef(false) + const resizeTimeoutRef = useRef() + + const { commands } = useContext(ReactotronContext) + + const [currentCommandId, setCurrentCommandId] = useState() + const [currSelectedType, setCurrSelectedType] = useState("request headers") + const [screenWidth, setScreenWidth] = useState(window.innerWidth) + + const filteredCommands = commands.filter((command) => command.type === "api.response") + + useEffect(() => { + const handleResize = () => { + if (hasUserResizedRef.current) return + if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current) + + resizeTimeoutRef.current = setTimeout(() => { + setScreenWidth(window.innerWidth) + }, 150) + } + + window.addEventListener("resize", handleResize) + + return () => { + window.removeEventListener("resize", handleResize) + if (resizeTimeoutRef.current) { + clearTimeout(resizeTimeoutRef.current) + } + } + }, []) + + const initialLeftPanelWidth = Math.floor((screenWidth * 7) / 12) + const minRightPanelWidth = Math.floor(screenWidth / 12) + + const { containerRef, leftPanelWidth, handleMouseDown } = useDrawerResize({ + initialLeftPanelWidth, + minLeftPanelWidth: 400, + minRightPanelWidth, + resizeHandleWidth: 10, + onUserResize: () => { + hasUserResizedRef.current = true + }, + }) + + useEffect(() => { + if (!currentCommandId && filteredCommands.length > 0) { + setCurrentCommandId(filteredCommands[0].messageId) + } + }, [currentCommandId, filteredCommands]) + + const currentCommand = filteredCommands.find((command) => command.messageId === currentCommandId) + + const tabContent = { + "request headers": currentCommand?.payload?.request?.headers, + "request params": currentCommand?.payload?.request?.params, + "request body": currentCommand?.payload?.request?.data, + "response body": currentCommand?.payload?.response?.body, + "response headers": currentCommand?.payload?.response?.headers, + } + + if (filteredCommands.length === 0) { + return ( + + + + Network requests will appear here once your app starts making API calls. + + + ) + } + + return ( + + + + {containerRef.current && ( + + )} + + + + {currentCommandId && ( + + + + )} + + + + ) +} diff --git a/apps/reactotron-app/src/renderer/pages/network/network.styles.ts b/apps/reactotron-app/src/renderer/pages/network/network.styles.ts new file mode 100644 index 000000000..2f023f74d --- /dev/null +++ b/apps/reactotron-app/src/renderer/pages/network/network.styles.ts @@ -0,0 +1,215 @@ +import styled from "styled-components" + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +` + +const ResizableSectionWrapper = styled.div` + top: 60px; + right: 0; + height: calc(100vh - 60px); + background-color: ${(props) => props.theme.background}; + border-left: 1px solid ${(props) => props.theme.border}; + display: grid; + grid-template-columns: 350px 10px 1fr; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + overflow: hidden; +` + +const RequestContainer = styled.div` + pointer-events: auto; + background-color: ${(props) => props.theme.backgroundSubtleLight}; + border-right: 1px solid ${(props) => props.theme.border}; +` + +const RequestTableHeader = styled.div` + display: flex; + align-items: center; + height: 40px; + background-color: ${(props) => props.theme.backgroundSubtleDark}; + border-bottom: 2px solid ${(props) => props.theme.border}; + padding: 0 12px; + font-weight: 600; + font-size: 12px; + color: ${(props) => props.theme.foregroundDark}; + text-transform: uppercase; + letter-spacing: 0.5px; +` + +const RequestTableHeaderCell = styled.div<{ width?: string }>` + padding: 0 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + ${(props) => props.width && `width: ${props.width};`} + ${(props) => props.width === "flex" && `flex: 1;`} +` + +const RequestItem = styled.div` + cursor: pointer; + pointer-events: auto; + height: 50px; + border-bottom: 1px solid ${(props) => props.theme.border}; + transition: all 0.2s ease; + color: ${(props) => props.theme.foreground}; + font-size: 13px; + position: relative; + display: flex; + align-items: center; + padding: 0 12px; + + &:hover { + background-color: ${(props) => props.theme.backgroundLighter}; + } + + &.active { + background-color: ${(props) => props.theme.backgroundLighter}; + border-left: 3px solid ${(props) => props.theme.tag}; + padding-left: 9px; + font-weight: 500; + } +` + +const RequestTableCell = styled.div<{ method?: string; status?: number; width?: string }>` + padding: 0 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 13px; + ${(props) => props.width && `width: ${props.width};`} + ${(props) => props.width === "flex" && `flex: 1;`} + + ${(props) => + props.method && + ` + color: ${(() => { + const method = props.method?.toUpperCase() + switch (method) { + case "GET": + return "#61affe" + case "POST": + return "#49cc90" + case "PUT": + return "#fca130" + case "PATCH": + return "#50e3c2" + case "DELETE": + return "#f93e3e" + default: + return props.theme.foreground + } + })()}; + font-weight: 600; + `} + + ${(props) => + props.status && + ` + color: ${(() => { + const status = props.status + if (status >= 200 && status < 300) return "#49cc90" + if (status >= 300 && status < 400) return "#61affe" + if (status >= 400 && status < 500) return "#fca130" + if (status >= 500) return "#f93e3e" + return props.theme.foregroundDark + })()}; + font-weight: 600; + `} +` + +const RequestResponseContainer = styled.div` + width: 100%; + pointer-events: auto; + overflow-y: auto; + overflow-x: auto; + display: flex; + flex-direction: column; + height: 100%; + position: relative; +` + +const RequestDataHeader = styled.ul` + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + border-bottom: 1px solid ${(props) => props.theme.border}; + margin: 0; + padding: 0; + pointer-events: auto; + background-color: ${(props) => props.theme.backgroundSubtleLight}; + + li { + list-style: none; + cursor: pointer; + pointer-events: auto; + padding: 12px 16px; + color: ${(props) => props.theme.foregroundDark}; + font-size: small; + font-weight: 500; + text-transform: capitalize; + transition: all 0.2s ease; + border-bottom: 2px solid transparent; + display: flex; + align-items: center; + + &:hover { + color: ${(props) => props.theme.foreground}; + background-color: ${(props) => props.theme.backgroundLighter}; + } + + &.active { + color: ${(props) => props.theme.tag}; + border-bottom-color: ${(props) => props.theme.tag}; + } + } +` + +const RequestAvailableTabsContainer = styled.div` + display: flex; +` + +const RequestResponseContainerBody = styled.div` + padding: 20px; + flex: 1; + height: 0; +` + +const ResizeHandle = styled.div` + width: 5px; + height: 100%; + background-color: ${(props) => props.theme.border}; + cursor: col-resize; + transition: background-color 0.2s ease; + position: absolute; + left: 0; + top: 0; + + &:hover { + background-color: ${(props) => props.theme.tag}; + } + + &:active { + background-color: ${(props) => props.theme.tag}; + } +` + +const Styles = { + Container, + ResizableSectionWrapper, + RequestContainer, + RequestResponseContainer, + RequestItem, + RequestTableHeader, + RequestTableHeaderCell, + RequestTableCell, + RequestDataHeader, + RequestAvailableTabsContainer, + RequestResponseContainerBody, + ResizeHandle, +} + +export default Styles diff --git a/apps/reactotron-app/src/renderer/pages/network/useDrawerResize.ts b/apps/reactotron-app/src/renderer/pages/network/useDrawerResize.ts new file mode 100644 index 000000000..96c72d6f3 --- /dev/null +++ b/apps/reactotron-app/src/renderer/pages/network/useDrawerResize.ts @@ -0,0 +1,70 @@ +import { useEffect, useRef, useState } from "react" + +interface UsePanelResizeOptions { + initialLeftPanelWidth?: number + minLeftPanelWidth?: number + minRightPanelWidth?: number + resizeHandleWidth?: number + onUserResize?: () => void +} + +export const useDrawerResize = (options: UsePanelResizeOptions = {}) => { + const { + initialLeftPanelWidth = 350, + minLeftPanelWidth = 200, + minRightPanelWidth = 200, + resizeHandleWidth = 10, + onUserResize, + } = options + + const [isResizing, setIsResizing] = useState(false) + const [leftPanelWidth, setLeftPanelWidth] = useState(initialLeftPanelWidth) + + const containerRef = useRef(null) + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault() + setIsResizing(true) + } + + useEffect(() => { + setLeftPanelWidth(initialLeftPanelWidth) + }, [initialLeftPanelWidth]) + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing || !containerRef.current) return + + const containerRect = containerRef.current.getBoundingClientRect() + const newWidth = e.clientX - containerRect.left + + const maxWidth = containerRect.width - minRightPanelWidth - resizeHandleWidth + + if (newWidth >= minLeftPanelWidth && newWidth <= maxWidth) { + setLeftPanelWidth(newWidth) + + if (onUserResize) onUserResize() + } + } + + const handleMouseUp = () => { + setIsResizing(false) + } + + if (isResizing) { + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + }, [isResizing, minLeftPanelWidth, minRightPanelWidth, resizeHandleWidth, onUserResize]) + + return { + containerRef, + leftPanelWidth, + handleMouseDown, + } +} diff --git a/lib/reactotron-core-ui/package.json b/lib/reactotron-core-ui/package.json index 9f9e34b75..0fa12e4ac 100644 --- a/lib/reactotron-core-ui/package.json +++ b/lib/reactotron-core-ui/package.json @@ -27,6 +27,7 @@ "build": "bob build", "prebuild:dev": "yarn clean", "build:dev": "bob build", + "build:watch": "bob build --watch", "build-storybook": "build-storybook", "clean": "rimraf ./dist", "lint": "eslint src --ext .ts,.tsx",
Network requests will appear here once your app starts making API calls.