Skip to content

Commit b1a68ad

Browse files
feat: use a panel layout for displaying timeline items (#3)
* Remove extra start sever call * Fix with global call * Add clipboard module * Wire up clipboard functionality * Update type to allow for record * Add new timeline item component * Add some opacity * Add a resizable divider component * Use ignite default border * Remove global padding * Add todo * Add Separator component * Add a util hook for selected timeline items * Clean up props * Use new TimelineItem component * Add detail panel and tie it all together * Add clipboard module * Remove redundant comment
1 parent ff38f63 commit b1a68ad

File tree

18 files changed

+874
-168
lines changed

18 files changed

+874
-168
lines changed

app/app.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ const $container = withTheme<ViewStyle>(({ colors }) => ({
6666

6767
const $contentContainer = withTheme<ViewStyle>(({ spacing }) => ({
6868
flex: 1,
69-
padding: spacing.md,
7069
}))
7170

7271
export default App

app/components/DetailPanel.tsx

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import { View, Text, ScrollView, type ViewStyle, type TextStyle } from "react-native"
2+
import { useThemeName, withTheme } from "../theme/theme"
3+
import { TimelineItem } from "../types"
4+
import { TreeView } from "./TreeView"
5+
import ActionButton from "./ActionButton"
6+
import IRClipboard from "../native/IRClipboard/NativeIRClipboard"
7+
8+
type DetailPanelProps = {
9+
selectedItem: TimelineItem | null
10+
onClose?: () => void
11+
}
12+
13+
/**
14+
* DetailPanel displays comprehensive information about a selected timeline item.
15+
* Shows different content based on whether the item is a log entry or network request.
16+
*/
17+
18+
export function DetailPanel({ selectedItem, onClose }: DetailPanelProps) {
19+
const [themeName] = useThemeName()
20+
21+
// Show empty state when no item is selected
22+
if (!selectedItem) {
23+
return (
24+
<View style={$emptyContainer(themeName)}>
25+
<View style={$emptyCard(themeName)}>
26+
<Text style={$emptyIcon(themeName)}>📋</Text>
27+
<Text style={$emptyTitle(themeName)}>No Selection</Text>
28+
<Text style={$emptyText(themeName)}>Select a timeline item to view details</Text>
29+
</View>
30+
</View>
31+
)
32+
}
33+
34+
return (
35+
<View style={$container(themeName)}>
36+
<View style={$header(themeName)}>
37+
<View style={$headerContent}>
38+
<View style={$headerTitleRow}>
39+
<View style={$selectedIndicator(themeName)} />
40+
<Text style={$headerTitle(themeName)}>
41+
{selectedItem.type === "log" ? "Log Details" : "Network Details"}
42+
</Text>
43+
</View>
44+
<View style={$headerInfo(themeName)}>
45+
<Text style={$headerInfoText(themeName)}>
46+
{new Date(selectedItem.date).toLocaleString()}
47+
</Text>
48+
{selectedItem.deltaTime && (
49+
<Text style={$headerInfoText(themeName)}>+{selectedItem.deltaTime}ms</Text>
50+
)}
51+
</View>
52+
</View>
53+
<View style={$headerActions(themeName)}>
54+
<ActionButton
55+
icon={({ size }) => <Text style={{ fontSize: size }}>📋</Text>}
56+
onClick={() => IRClipboard.setString(JSON.stringify(selectedItem.payload))}
57+
/>
58+
{onClose && (
59+
<ActionButton
60+
icon={({ size }) => <Text style={{ fontSize: size }}></Text>}
61+
onClick={onClose}
62+
/>
63+
)}
64+
</View>
65+
</View>
66+
67+
<ScrollView
68+
style={$content}
69+
showsVerticalScrollIndicator={true}
70+
showsHorizontalScrollIndicator={false}
71+
contentContainerStyle={$scrollContent(themeName)}
72+
>
73+
{/* Render appropriate content based on timeline item type */}
74+
{selectedItem.type === "log" ? (
75+
<LogDetailContent item={selectedItem} />
76+
) : (
77+
<NetworkDetailContent item={selectedItem} />
78+
)}
79+
</ScrollView>
80+
</View>
81+
)
82+
}
83+
84+
/**
85+
* Renders detailed content for log timeline items including level, message, stack trace, and metadata.
86+
*/
87+
function LogDetailContent({ item }: { item: TimelineItem & { type: "log" } }) {
88+
const [themeName] = useThemeName()
89+
const { payload } = item
90+
91+
return (
92+
<View style={$detailContent(themeName)}>
93+
<DetailSection title="Log Level">
94+
<Text style={$valueText(themeName)}>{payload.level.toUpperCase()}</Text>
95+
</DetailSection>
96+
97+
<DetailSection title="Message">
98+
{typeof payload.message === "string" ? (
99+
<Text style={$valueText(themeName)}>{payload.message}</Text>
100+
) : (
101+
<TreeView data={payload.message} />
102+
)}
103+
</DetailSection>
104+
105+
{/* Show stack trace only for error level logs that have stack data */}
106+
{payload.level === "error" && "stack" in payload && (
107+
<DetailSection title="Stack Trace">
108+
<TreeView data={payload.stack} />
109+
</DetailSection>
110+
)}
111+
112+
<DetailSection title="Full Payload">
113+
<TreeView data={payload} />
114+
</DetailSection>
115+
116+
<DetailSection title="Metadata">
117+
<TreeView
118+
data={{
119+
id: item.id,
120+
clientId: item.clientId,
121+
connectionId: item.connectionId,
122+
messageId: item.messageId,
123+
important: item.important,
124+
date: item.date,
125+
deltaTime: item.deltaTime,
126+
}}
127+
/>
128+
</DetailSection>
129+
</View>
130+
)
131+
}
132+
133+
/**
134+
* Renders detailed content for network timeline items including request/response data and errors.
135+
*/
136+
function NetworkDetailContent({
137+
item,
138+
}: {
139+
item: TimelineItem & { type: "api.request" | "api.response" }
140+
}) {
141+
const [themeName] = useThemeName()
142+
const { payload } = item
143+
144+
return (
145+
<View style={$detailContent(themeName)}>
146+
{/* Show request data if available */}
147+
{payload.request && (
148+
<>
149+
<DetailSection title="Request">
150+
<TreeView data={payload.request} />
151+
</DetailSection>
152+
</>
153+
)}
154+
155+
{/* Show response data if available */}
156+
{payload.response && (
157+
<>
158+
<DetailSection title="Response">
159+
<TreeView data={payload.response} />
160+
</DetailSection>
161+
</>
162+
)}
163+
164+
{/* Show error information if request failed */}
165+
{payload.error && (
166+
<DetailSection title="Error">
167+
<Text style={$errorText(themeName)}>{payload.error}</Text>
168+
</DetailSection>
169+
)}
170+
171+
<DetailSection title="Full Payload">
172+
<TreeView data={payload} />
173+
</DetailSection>
174+
175+
<DetailSection title="Metadata">
176+
<TreeView
177+
data={{
178+
id: item.id,
179+
clientId: item.clientId,
180+
connectionId: item.connectionId,
181+
messageId: item.messageId,
182+
important: item.important,
183+
date: item.date,
184+
deltaTime: item.deltaTime,
185+
}}
186+
/>
187+
</DetailSection>
188+
</View>
189+
)
190+
}
191+
192+
/**
193+
* A reusable section component with a header and content area for organizing detail information.
194+
*/
195+
function DetailSection({ title, children }: { title: string; children: React.ReactNode }) {
196+
const [themeName] = useThemeName()
197+
198+
return (
199+
<View style={$section(themeName)}>
200+
<View style={$sectionHeader(themeName)}>
201+
<Text style={$sectionTitle(themeName)}>{title}</Text>
202+
</View>
203+
<View style={$sectionContent(themeName)}>{children}</View>
204+
</View>
205+
)
206+
}
207+
208+
const $container = withTheme<ViewStyle>(({ colors }) => ({
209+
flex: 1,
210+
backgroundColor: colors.cardBackground,
211+
borderLeftWidth: 1,
212+
borderLeftColor: colors.border,
213+
}))
214+
215+
const $emptyContainer = withTheme<ViewStyle>(({ colors }) => ({
216+
flex: 1,
217+
justifyContent: "center",
218+
alignItems: "center",
219+
backgroundColor: colors.cardBackground,
220+
borderLeftWidth: 1,
221+
borderLeftColor: colors.border,
222+
}))
223+
224+
const $emptyCard = withTheme<ViewStyle>(({ colors, spacing }) => ({
225+
backgroundColor: colors.background,
226+
borderRadius: 12,
227+
borderWidth: 1,
228+
borderColor: colors.border,
229+
padding: spacing.xl,
230+
alignItems: "center",
231+
maxWidth: 300,
232+
}))
233+
234+
const $emptyIcon = withTheme<TextStyle>(({ spacing }) => ({
235+
fontSize: 48,
236+
marginBottom: spacing.md,
237+
opacity: 0.5,
238+
}))
239+
240+
const $emptyTitle = withTheme<TextStyle>(({ colors, typography }) => ({
241+
color: colors.mainText,
242+
fontSize: typography.subheading,
243+
fontWeight: "600",
244+
marginBottom: 8,
245+
textAlign: "center",
246+
}))
247+
248+
const $emptyText = withTheme<TextStyle>(({ colors, typography }) => ({
249+
color: colors.neutral,
250+
fontSize: typography.body,
251+
textAlign: "center",
252+
lineHeight: typography.body * 1.5,
253+
}))
254+
255+
const $header = withTheme<ViewStyle>(({ colors, spacing }) => ({
256+
flexDirection: "row",
257+
justifyContent: "space-between",
258+
alignItems: "center",
259+
padding: spacing.md,
260+
borderBottomWidth: 1,
261+
borderBottomColor: colors.border,
262+
backgroundColor: colors.background,
263+
}))
264+
265+
const $headerContent: ViewStyle = {
266+
flex: 1,
267+
}
268+
269+
const $headerTitleRow: ViewStyle = {
270+
flexDirection: "row",
271+
alignItems: "center",
272+
}
273+
274+
const $selectedIndicator = withTheme<ViewStyle>(({ colors, spacing }) => ({
275+
width: spacing.xxs,
276+
height: spacing.md,
277+
backgroundColor: colors.primary,
278+
borderRadius: spacing.xxxs,
279+
marginRight: spacing.sm,
280+
}))
281+
282+
const $headerTitle = withTheme<TextStyle>(({ colors, typography, spacing }) => ({
283+
color: colors.mainText,
284+
fontSize: typography.subheading,
285+
fontFamily: typography.primary.semiBold,
286+
marginBottom: spacing.xxs,
287+
}))
288+
289+
const $headerInfo = withTheme<ViewStyle>(({ spacing }) => ({
290+
flexDirection: "row",
291+
gap: spacing.sm,
292+
}))
293+
294+
const $headerInfoText = withTheme<TextStyle>(({ colors, typography }) => ({
295+
color: colors.neutral,
296+
fontSize: typography.caption,
297+
}))
298+
299+
const $headerActions = withTheme<ViewStyle>(({ spacing }) => ({
300+
flexDirection: "row",
301+
gap: spacing.xs,
302+
}))
303+
304+
const $content: ViewStyle = {
305+
flex: 1,
306+
}
307+
308+
const $scrollContent = withTheme<ViewStyle>(({ spacing }) => ({
309+
paddingBottom: spacing.xl, // Extra padding at bottom for better scrolling
310+
}))
311+
312+
const $detailContent = withTheme<ViewStyle>(({ spacing }) => ({
313+
flex: 1,
314+
padding: spacing.md,
315+
}))
316+
317+
const $section = withTheme<ViewStyle>(({ colors, spacing }) => ({
318+
marginBottom: spacing.lg,
319+
backgroundColor: colors.background,
320+
borderRadius: spacing.xs,
321+
borderWidth: 1,
322+
borderColor: colors.border,
323+
overflow: "hidden",
324+
}))
325+
326+
const $sectionHeader = withTheme<ViewStyle>(({ colors, spacing }) => ({
327+
backgroundColor: colors.cardBackground,
328+
paddingVertical: spacing.sm,
329+
paddingHorizontal: spacing.md,
330+
borderBottomWidth: 1,
331+
borderBottomColor: colors.border,
332+
}))
333+
334+
const $sectionTitle = withTheme<TextStyle>(({ colors, typography }) => ({
335+
color: colors.mainText,
336+
fontSize: typography.body,
337+
fontFamily: typography.primary.semiBold,
338+
letterSpacing: 0.5,
339+
}))
340+
341+
const $sectionContent = withTheme<ViewStyle>(({ spacing }) => ({
342+
padding: spacing.md,
343+
backgroundColor: "transparent",
344+
}))
345+
346+
const $valueText = withTheme<TextStyle>(({ colors, typography }) => ({
347+
color: colors.mainText,
348+
fontSize: typography.body,
349+
fontFamily: typography.code.normal,
350+
}))
351+
352+
const $errorText = withTheme<TextStyle>(({ colors, typography }) => ({
353+
color: colors.danger,
354+
fontSize: typography.body,
355+
fontFamily: typography.code.normal,
356+
}))

0 commit comments

Comments
 (0)