@buoy-gg/network 7.0.4 → 7.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +2 -1
  2. package/lib/commonjs/index.js +85 -0
  3. package/lib/commonjs/network/components/NetworkEventDetailView.js +201 -40
  4. package/lib/commonjs/network/components/NetworkEventItemCompact.js +52 -5
  5. package/lib/commonjs/network/components/NetworkHeaderLive.js +132 -0
  6. package/lib/commonjs/network/components/NetworkListScreen.js +134 -56
  7. package/lib/commonjs/network/components/NetworkModal.js +154 -36
  8. package/lib/commonjs/network/components/NetworkSavedScreen.js +245 -0
  9. package/lib/commonjs/network/components/NetworkUpgradeBanner.js +76 -0
  10. package/lib/commonjs/network/hooks/useLiveNetworkEvent.js +62 -0
  11. package/lib/commonjs/network/hooks/useNetworkSaved.js +38 -0
  12. package/lib/commonjs/network/index.js +84 -0
  13. package/lib/commonjs/network/sync/networkSyncAdapter.js +85 -7
  14. package/lib/commonjs/network/utils/generateCurl.js +71 -0
  15. package/lib/commonjs/network/utils/networkEventStore.js +10 -1
  16. package/lib/commonjs/network/utils/networkListener.js +54 -1
  17. package/lib/commonjs/network/utils/networkSavedStore.js +575 -0
  18. package/lib/commonjs/network/utils/withPinnedFirst.js +32 -0
  19. package/lib/module/index.js +17 -1
  20. package/lib/module/network/components/NetworkEventDetailView.js +204 -41
  21. package/lib/module/network/components/NetworkEventItemCompact.js +53 -6
  22. package/lib/module/network/components/NetworkHeaderLive.js +132 -2
  23. package/lib/module/network/components/NetworkListScreen.js +136 -58
  24. package/lib/module/network/components/NetworkModal.js +156 -39
  25. package/lib/module/network/components/NetworkSavedScreen.js +241 -0
  26. package/lib/module/network/components/NetworkUpgradeBanner.js +72 -0
  27. package/lib/module/network/hooks/useLiveNetworkEvent.js +58 -0
  28. package/lib/module/network/hooks/useNetworkSaved.js +34 -0
  29. package/lib/module/network/index.js +7 -1
  30. package/lib/module/network/sync/networkSyncAdapter.js +85 -7
  31. package/lib/module/network/utils/generateCurl.js +67 -0
  32. package/lib/module/network/utils/networkEventStore.js +10 -1
  33. package/lib/module/network/utils/networkListener.js +54 -1
  34. package/lib/module/network/utils/networkSavedStore.js +569 -0
  35. package/lib/module/network/utils/withPinnedFirst.js +28 -0
  36. package/lib/typescript/index.d.ts +13 -1
  37. package/lib/typescript/index.d.ts.map +1 -1
  38. package/lib/typescript/network/components/NetworkEventDetailView.d.ts.map +1 -1
  39. package/lib/typescript/network/components/NetworkEventItemCompact.d.ts +12 -0
  40. package/lib/typescript/network/components/NetworkEventItemCompact.d.ts.map +1 -1
  41. package/lib/typescript/network/components/NetworkHeaderLive.d.ts +28 -0
  42. package/lib/typescript/network/components/NetworkHeaderLive.d.ts.map +1 -1
  43. package/lib/typescript/network/components/NetworkListScreen.d.ts.map +1 -1
  44. package/lib/typescript/network/components/NetworkModal.d.ts +34 -0
  45. package/lib/typescript/network/components/NetworkModal.d.ts.map +1 -1
  46. package/lib/typescript/network/components/NetworkSavedScreen.d.ts +29 -0
  47. package/lib/typescript/network/components/NetworkSavedScreen.d.ts.map +1 -0
  48. package/lib/typescript/network/components/NetworkUpgradeBanner.d.ts +17 -0
  49. package/lib/typescript/network/components/NetworkUpgradeBanner.d.ts.map +1 -0
  50. package/lib/typescript/network/hooks/useLiveNetworkEvent.d.ts +19 -0
  51. package/lib/typescript/network/hooks/useLiveNetworkEvent.d.ts.map +1 -0
  52. package/lib/typescript/network/hooks/useNetworkSaved.d.ts +21 -0
  53. package/lib/typescript/network/hooks/useNetworkSaved.d.ts.map +1 -0
  54. package/lib/typescript/network/index.d.ts +8 -1
  55. package/lib/typescript/network/index.d.ts.map +1 -1
  56. package/lib/typescript/network/sync/networkSyncAdapter.d.ts +52 -3
  57. package/lib/typescript/network/sync/networkSyncAdapter.d.ts.map +1 -1
  58. package/lib/typescript/network/types/index.d.ts +43 -0
  59. package/lib/typescript/network/types/index.d.ts.map +1 -1
  60. package/lib/typescript/network/utils/generateCurl.d.ts +3 -0
  61. package/lib/typescript/network/utils/generateCurl.d.ts.map +1 -0
  62. package/lib/typescript/network/utils/networkEventStore.d.ts.map +1 -1
  63. package/lib/typescript/network/utils/networkListener.d.ts +4 -0
  64. package/lib/typescript/network/utils/networkListener.d.ts.map +1 -1
  65. package/lib/typescript/network/utils/networkSavedStore.d.ts +186 -0
  66. package/lib/typescript/network/utils/networkSavedStore.d.ts.map +1 -0
  67. package/lib/typescript/network/utils/withPinnedFirst.d.ts +24 -0
  68. package/lib/typescript/network/utils/withPinnedFirst.d.ts.map +1 -0
  69. package/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { useState, useEffect } from "react";
3
+ import { useState, useEffect, useMemo } from "react";
4
4
  import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from "react-native";
5
5
  import { Clock, Upload, Download, AlertCircle, ChevronDown, ChevronUp, Lock, Unlock, FileJson, Filter, Globe, Link, Copy, InlineCopyButton, macOSColors, useFeatureGate, UpgradeModal } from "@buoy-gg/shared-ui";
6
6
  import { formatBytes, formatDuration, formatHttpStatus } from "../utils/formatting";
@@ -8,13 +8,86 @@ import { formatRelativeTime } from "@buoy-gg/shared-ui";
8
8
  import { DataViewer } from "@buoy-gg/shared-ui/dataViewer";
9
9
  import { formatGraphQLDisplay } from "../utils/formatGraphQLVariables";
10
10
  import { useNetworkBodyResolver } from "../sync/networkBodyResolver";
11
+
12
+ /**
13
+ * Colour for the icons that merely LABEL a section (Sent/Received, the JSON
14
+ * body glyphs). The Lock/Unlock pair keeps its colour because
15
+ * secure-vs-insecure is a genuine signal, as does the status pill.
16
+ */
11
17
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
18
+ const NEUTRAL_ICON = macOSColors.semantic.success;
19
+ /**
20
+ * The measurable slice of a request's lifetime, as proportional bars.
21
+ *
22
+ * Renders NOTHING when the interceptor couldn't measure it — an older device
23
+ * talking to a newer dashboard, or a request that died before headers. A phase
24
+ * row reading `0ms` would claim we measured zero, which is a different and
25
+ * false statement from "we didn't measure this".
26
+ *
27
+ * Only two phases, on purpose. Queueing / DNS / TLS / request-send all happen
28
+ * below the JS layer with nothing to read them from — see NetworkTimings. The
29
+ * design this came from shows five, but derives the other three by multiplying
30
+ * the total by fixed fractions, i.e. they are invented. Two real ones are worth
31
+ * more than five where three are decoration.
32
+ */
33
+ const TimingPhases = ({
34
+ timings,
35
+ total
36
+ }) => {
37
+ // Guard the divisor: a sub-millisecond request would otherwise divide by 0.
38
+ const span = Math.max(1, total);
39
+ const phases = [{
40
+ // Deliberately NOT called "server time". Measured from JS, this includes
41
+ // queueing, DNS, TLS and sending the request — everything before the
42
+ // headers land. The subtitle says so rather than implying Chrome-grade
43
+ // precision we don't have.
44
+ label: "Waiting (TTFB)",
45
+ hint: "incl. queue, DNS, TLS",
46
+ value: timings.ttfb,
47
+ color: macOSColors.semantic.warning
48
+ }, {
49
+ label: "Content download",
50
+ hint: undefined,
51
+ value: timings.download,
52
+ color: macOSColors.semantic.success
53
+ }];
54
+ return /*#__PURE__*/_jsx(View, {
55
+ style: styles.phaseList,
56
+ children: phases.map(phase => /*#__PURE__*/_jsxs(View, {
57
+ style: styles.phaseRow,
58
+ children: [/*#__PURE__*/_jsxs(View, {
59
+ style: styles.phaseLabelGroup,
60
+ children: [/*#__PURE__*/_jsx(Text, {
61
+ style: styles.phaseLabel,
62
+ children: phase.label
63
+ }), phase.hint ? /*#__PURE__*/_jsx(Text, {
64
+ style: styles.phaseHint,
65
+ children: phase.hint
66
+ }) : null]
67
+ }), /*#__PURE__*/_jsx(View, {
68
+ style: styles.phaseTrack,
69
+ children: /*#__PURE__*/_jsx(View, {
70
+ style: [styles.phaseBar, {
71
+ backgroundColor: phase.color,
72
+ // A floor so a 1ms phase is still a visible tick.
73
+ width: `${Math.max(1, phase.value / span * 100)}%`
74
+ }]
75
+ })
76
+ }), /*#__PURE__*/_jsx(Text, {
77
+ style: styles.phaseValue,
78
+ children: formatDuration(phase.value)
79
+ })]
80
+ }, phase.label))
81
+ });
82
+ };
83
+
12
84
  // Component for collapsible sections matching Sentry style
13
85
  const CollapsibleSection = ({
14
86
  title,
15
87
  icon,
16
88
  children,
17
- defaultOpen = false
89
+ defaultOpen = false,
90
+ count
18
91
  }) => {
19
92
  const [isOpen, setIsOpen] = useState(defaultOpen);
20
93
  return /*#__PURE__*/_jsxs(View, {
@@ -27,7 +100,10 @@ const CollapsibleSection = ({
27
100
  children: [icon, /*#__PURE__*/_jsx(Text, {
28
101
  style: styles.collapsibleTitleText,
29
102
  children: title
30
- })]
103
+ }), count !== undefined ? /*#__PURE__*/_jsx(Text, {
104
+ style: styles.collapsibleCount,
105
+ children: count
106
+ }) : null]
31
107
  }), isOpen ? /*#__PURE__*/_jsx(ChevronUp, {
32
108
  size: 16,
33
109
  color: macOSColors.text.secondary
@@ -95,7 +171,23 @@ const UrlBreakdown = ({
95
171
  };
96
172
  }
97
173
  };
98
- const urlParts = parseUrl(url);
174
+
175
+ /**
176
+ * MUST be memoized. `parseUrl` builds a fresh `params` object every call, and
177
+ * that object is handed to `DataViewer` as its `data` prop — whose flattening
178
+ * effect keys on `data` by IDENTITY. Computed inline, every render of this
179
+ * component produced a new object, so the effect re-fired and re-entered
180
+ * "Processing data…" forever. The detail view re-renders constantly on the
181
+ * dashboard (it tracks the live event as the device pushes snapshots ~4×/sec),
182
+ * so this pinned the query-parameter viewer in a permanent processing loop.
183
+ *
184
+ * `url` is a string, so this is stable by value.
185
+ */
186
+ const urlParts = useMemo(() => parseUrl(url),
187
+ // parseUrl closes over the graphql/operation props, which are what change
188
+ // the derived pathname.
189
+ // eslint-disable-next-line react-hooks/exhaustive-deps
190
+ [url, requestClient, operationName, graphqlVariables]);
99
191
  const [showParams, setShowParams] = useState(false);
100
192
  return /*#__PURE__*/_jsxs(View, {
101
193
  style: styles.urlBreakdown,
@@ -144,6 +236,9 @@ const UrlBreakdown = ({
144
236
  children: [/*#__PURE__*/_jsx(Text, {
145
237
  style: styles.queryParamsToggleText,
146
238
  children: "Query Parameters"
239
+ }), /*#__PURE__*/_jsx(Text, {
240
+ style: styles.collapsibleCount,
241
+ children: Object.keys(urlParts.params).length
147
242
  }), showParams ? /*#__PURE__*/_jsx(ChevronUp, {
148
243
  size: 14,
149
244
  color: macOSColors.text.secondary
@@ -359,34 +454,41 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
359
454
  }), /*#__PURE__*/_jsxs(Text, {
360
455
  style: styles.timingExact,
361
456
  children: ["(", new Date(event.timestamp).toLocaleTimeString(), ")"]
362
- })]
363
- }), event.requestSize || event.responseSize ? /*#__PURE__*/_jsxs(View, {
364
- style: styles.sizeRow,
365
- children: [event.requestSize !== undefined ? /*#__PURE__*/_jsxs(View, {
366
- style: styles.sizeItem,
367
- children: [/*#__PURE__*/_jsx(Upload, {
368
- size: 10,
369
- color: macOSColors.semantic.info
370
- }), /*#__PURE__*/_jsx(Text, {
371
- style: styles.sizeLabel,
372
- children: "Sent:"
373
- }), /*#__PURE__*/_jsx(Text, {
374
- style: styles.sizeValue,
375
- children: formatBytes(event.requestSize)
376
- })]
377
- }) : null, event.responseSize !== undefined ? /*#__PURE__*/_jsxs(View, {
378
- style: styles.sizeItem,
379
- children: [/*#__PURE__*/_jsx(Download, {
380
- size: 10,
381
- color: macOSColors.semantic.success
382
- }), /*#__PURE__*/_jsx(Text, {
383
- style: styles.sizeLabel,
384
- children: "Received:"
385
- }), /*#__PURE__*/_jsx(Text, {
386
- style: styles.sizeValue,
387
- children: formatBytes(event.responseSize)
388
- })]
457
+ }), event.requestSize || event.responseSize ? /*#__PURE__*/_jsxs(View, {
458
+ style: styles.sizeRow,
459
+ children: [event.requestSize !== undefined ? /*#__PURE__*/_jsxs(View, {
460
+ style: styles.sizeItem,
461
+ children: [/*#__PURE__*/_jsx(Upload, {
462
+ size: 10,
463
+ color: macOSColors.semantic.info
464
+ }), /*#__PURE__*/_jsx(Text, {
465
+ style: styles.sizeLabel,
466
+ children: "Sent:"
467
+ }), /*#__PURE__*/_jsx(Text, {
468
+ style: styles.sizeValue,
469
+ children: formatBytes(event.requestSize)
470
+ })]
471
+ }) : null, event.responseSize !== undefined ? /*#__PURE__*/_jsxs(View, {
472
+ style: styles.sizeItem,
473
+ children: [/*#__PURE__*/_jsx(Download, {
474
+ size: 10,
475
+ color: NEUTRAL_ICON
476
+ }), /*#__PURE__*/_jsx(Text, {
477
+ style: styles.sizeLabel,
478
+ children: "Received:"
479
+ }), /*#__PURE__*/_jsx(Text, {
480
+ style: styles.sizeValue,
481
+ children: formatBytes(event.responseSize)
482
+ })]
483
+ }) : null]
389
484
  }) : null]
485
+ }), event.timings ? /*#__PURE__*/_jsxs(_Fragment, {
486
+ children: [/*#__PURE__*/_jsx(View, {
487
+ style: styles.phaseDivider
488
+ }), /*#__PURE__*/_jsx(TimingPhases, {
489
+ timings: event.timings,
490
+ total: event.duration ?? event.timings.ttfb + event.timings.download
491
+ })]
390
492
  }) : null]
391
493
  }), /*#__PURE__*/_jsx(CollapsibleSection, {
392
494
  title: "Request Headers",
@@ -395,6 +497,7 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
395
497
  color: macOSColors.semantic.info
396
498
  }),
397
499
  defaultOpen: false,
500
+ count: Object.keys(event.requestHeaders).length,
398
501
  children: Object.keys(event.requestHeaders).length > 0 ? /*#__PURE__*/_jsx(View, {
399
502
  style: styles.dataViewerContainer,
400
503
  children: /*#__PURE__*/_jsx(DataViewer, {
@@ -413,9 +516,10 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
413
516
  title: "Response Headers",
414
517
  icon: /*#__PURE__*/_jsx(Download, {
415
518
  size: 14,
416
- color: macOSColors.semantic.success
519
+ color: NEUTRAL_ICON
417
520
  }),
418
521
  defaultOpen: false,
522
+ count: Object.keys(event.responseHeaders).length,
419
523
  children: Object.keys(event.responseHeaders).length > 0 ? /*#__PURE__*/_jsx(View, {
420
524
  style: styles.dataViewerContainer,
421
525
  children: /*#__PURE__*/_jsx(DataViewer, {
@@ -463,9 +567,12 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
463
567
  title: "Response Body",
464
568
  icon: /*#__PURE__*/_jsx(FileJson, {
465
569
  size: 14,
466
- color: macOSColors.semantic.success
467
- }),
468
- defaultOpen: false,
570
+ color: NEUTRAL_ICON
571
+ })
572
+ // The body is what you opened the row FOR — the design has it
573
+ // expanded on arrival, everything else closed.
574
+ ,
575
+ defaultOpen: true,
469
576
  children: /*#__PURE__*/_jsx(View, {
470
577
  style: styles.dataViewerContainer,
471
578
  children: /*#__PURE__*/_jsx(DataViewer, {
@@ -481,7 +588,7 @@ ${JSON.stringify(requestDetails.responseData, null, 2)}
481
588
  title: "Response Body",
482
589
  icon: /*#__PURE__*/_jsx(FileJson, {
483
590
  size: 14,
484
- color: macOSColors.semantic.success
591
+ color: NEUTRAL_ICON
485
592
  }),
486
593
  defaultOpen: true,
487
594
  children: /*#__PURE__*/_jsx(Text, {
@@ -707,6 +814,7 @@ const styles = StyleSheet.create({
707
814
  marginBottom: 8
708
815
  },
709
816
  queryParamsToggleText: {
817
+ flexGrow: 0,
710
818
  color: macOSColors.text.primary,
711
819
  fontSize: 12,
712
820
  fontWeight: "600",
@@ -725,7 +833,10 @@ const styles = StyleSheet.create({
725
833
  timingRow: {
726
834
  flexDirection: "row",
727
835
  alignItems: "center",
728
- gap: 6
836
+ gap: 6,
837
+ // Wraps rather than squashing when the card is narrow (a phone, or a
838
+ // half-width panel) — the sizes drop to their own line there.
839
+ flexWrap: "wrap"
729
840
  },
730
841
  timingLabel: {
731
842
  color: macOSColors.text.secondary,
@@ -743,11 +854,11 @@ const styles = StyleSheet.create({
743
854
  },
744
855
  sizeRow: {
745
856
  flexDirection: "row",
857
+ alignItems: "center",
746
858
  gap: 16,
747
- marginTop: 8,
748
- paddingTop: 8,
749
- borderTopWidth: 1,
750
- borderTopColor: macOSColors.border.default
859
+ // Pushed to the far end of the Started row. No top border/padding — it is
860
+ // no longer a separate strip.
861
+ marginLeft: "auto"
751
862
  },
752
863
  sizeItem: {
753
864
  flexDirection: "row",
@@ -764,6 +875,50 @@ const styles = StyleSheet.create({
764
875
  fontFamily: "monospace",
765
876
  fontWeight: "600"
766
877
  },
878
+ /* ---- Timing phases ---- */
879
+ phaseDivider: {
880
+ height: 1,
881
+ backgroundColor: macOSColors.border.default,
882
+ marginTop: 12,
883
+ marginBottom: 12
884
+ },
885
+ phaseList: {
886
+ gap: 8
887
+ },
888
+ phaseRow: {
889
+ flexDirection: "row",
890
+ alignItems: "center",
891
+ gap: 14
892
+ },
893
+ phaseLabelGroup: {
894
+ width: 150
895
+ },
896
+ phaseLabel: {
897
+ fontSize: 12,
898
+ color: macOSColors.text.secondary
899
+ },
900
+ phaseHint: {
901
+ fontSize: 10,
902
+ color: macOSColors.text.muted
903
+ },
904
+ phaseTrack: {
905
+ flex: 1,
906
+ height: 7,
907
+ borderRadius: 4,
908
+ backgroundColor: macOSColors.background.hover,
909
+ justifyContent: "center"
910
+ },
911
+ phaseBar: {
912
+ height: 7,
913
+ borderRadius: 4
914
+ },
915
+ phaseValue: {
916
+ width: 72,
917
+ textAlign: "right",
918
+ fontSize: 12,
919
+ fontFamily: "monospace",
920
+ color: macOSColors.text.primary
921
+ },
767
922
  // Collapsible section styles
768
923
  collapsibleSection: {
769
924
  marginHorizontal: 12,
@@ -779,6 +934,8 @@ const styles = StyleSheet.create({
779
934
  alignItems: "center",
780
935
  justifyContent: "space-between",
781
936
  padding: 12,
937
+ // `background.hover` is the SELECTION grey — heavy enough here that every
938
+ // section header read as a toolbar. The design uses a near-white strip.
782
939
  backgroundColor: macOSColors.background.hover
783
940
  },
784
941
  collapsibleTitle: {
@@ -791,6 +948,12 @@ const styles = StyleSheet.create({
791
948
  fontSize: 13,
792
949
  fontWeight: "600"
793
950
  },
951
+ /** The "how much is in here" number beside a section title. */
952
+ collapsibleCount: {
953
+ color: macOSColors.text.muted,
954
+ fontSize: 11.5,
955
+ fontFamily: "monospace"
956
+ },
794
957
  collapsibleContent: {
795
958
  padding: 12
796
959
  },
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { memo } from "react";
4
4
  import { StyleSheet, View, Text } from "react-native";
5
- import { ChevronRight, Upload, Download, Clock, AlertCircle, DevToolsCard, MethodBadge, TypeBadge, macOSColors } from "@buoy-gg/shared-ui";
5
+ import { ChevronRight, Upload, Download, Clock, AlertCircle, Bookmark, Pin, DevToolsCard, MethodBadge, TypeBadge, macOSColors } from "@buoy-gg/shared-ui";
6
6
  import { formatBytes, formatDuration } from "../utils/formatting";
7
7
  import { formatRelativeTime } from "@buoy-gg/shared-ui";
8
8
  import { useTickEveryMinute } from "../hooks/useTickEveryMinute";
@@ -32,6 +32,39 @@ function getContentTypeBadge(headers) {
32
32
  return null;
33
33
  }
34
34
 
35
+ /**
36
+ * The transport chip (`fetch` / `GQL` / `gRPC` / `XHR`), one brand colour each.
37
+ */
38
+ function getClientChipColors(requestClient) {
39
+ switch (requestClient) {
40
+ case "fetch":
41
+ return {
42
+ backgroundColor: "rgba(74, 144, 226, 0.15)",
43
+ color: "#4A90E2"
44
+ };
45
+ case "graphql":
46
+ return {
47
+ backgroundColor: "rgba(229, 53, 171, 0.15)",
48
+ color: "#E535AB"
49
+ };
50
+ case "grpc-web":
51
+ return {
52
+ backgroundColor: "rgba(16, 185, 129, 0.15)",
53
+ color: "#10B981"
54
+ };
55
+ case "xhr":
56
+ return {
57
+ backgroundColor: "rgba(245, 158, 11, 0.15)",
58
+ color: "#F59E0B"
59
+ };
60
+ default:
61
+ return {
62
+ backgroundColor: "rgba(147, 51, 234, 0.15)",
63
+ color: "#9333EA"
64
+ };
65
+ }
66
+ }
67
+
35
68
  // Decomposed components following rule3 - Component Composition
36
69
 
37
70
  // Status indicator component - single responsibility
@@ -134,7 +167,10 @@ export const NetworkEventItemCompact = /*#__PURE__*/memo(
134
167
  // renders to this component instead of a compiled anonymous name.
135
168
  function NetworkEventItemCompact({
136
169
  event,
137
- onPress
170
+ onPress,
171
+ pinned = false,
172
+ saved = false,
173
+ onLongPress
138
174
  }) {
139
175
  const statusColor = getStatusColor(event.status, event.error);
140
176
  const isPending = !event.status && !event.error;
@@ -159,23 +195,30 @@ function NetworkEventItemCompact({
159
195
  }
160
196
  return /*#__PURE__*/_jsxs(DevToolsCard, {
161
197
  onPress: () => onPress(event),
198
+ onLongPress: onLongPress ? () => onLongPress(event) : undefined,
162
199
  style: styles.cardWrapper,
163
200
  contentStyle: [styles.container, {
164
201
  borderLeftColor: statusColor
165
202
  }],
166
203
  children: [/*#__PURE__*/_jsxs(View, {
167
204
  style: styles.topRightContainer,
168
- children: [/*#__PURE__*/_jsx(StatusIndicator, {
205
+ children: [pinned ? /*#__PURE__*/_jsx(Pin, {
206
+ size: 10,
207
+ color: macOSColors.semantic.info
208
+ }) : null, saved ? /*#__PURE__*/_jsx(Bookmark, {
209
+ size: 10,
210
+ color: macOSColors.semantic.debug
211
+ }) : null, /*#__PURE__*/_jsx(StatusIndicator, {
169
212
  event: event,
170
213
  isPending: isPending,
171
214
  statusColor: statusColor
172
215
  }), event.requestClient ? /*#__PURE__*/_jsx(View, {
173
216
  style: [styles.topRightBadge, {
174
- backgroundColor: event.requestClient === "fetch" ? "rgba(74, 144, 226, 0.15)" : event.requestClient === "graphql" ? "rgba(229, 53, 171, 0.15)" : event.requestClient === "grpc-web" ? "rgba(16, 185, 129, 0.15)" : event.requestClient === "xhr" ? "rgba(245, 158, 11, 0.15)" : "rgba(147, 51, 234, 0.15)"
217
+ backgroundColor: getClientChipColors(event.requestClient).backgroundColor
175
218
  }],
176
219
  children: /*#__PURE__*/_jsx(Text, {
177
220
  style: [styles.clientText, {
178
- color: event.requestClient === "fetch" ? "#4A90E2" : event.requestClient === "graphql" ? "#E535AB" : event.requestClient === "grpc-web" ? "#10B981" : event.requestClient === "xhr" ? "#F59E0B" : "#9333EA"
221
+ color: getClientChipColors(event.requestClient).color
179
222
  }],
180
223
  children: event.requestClient === "graphql" ? "GQL" : event.requestClient === "grpc-web" ? "gRPC" : event.requestClient === "xhr" ? "XHR" : event.requestClient
181
224
  })
@@ -220,13 +263,17 @@ function NetworkEventItemCompact({
220
263
  NetworkEventItemCompact.displayName = "NetworkEventItemCompact";
221
264
  const styles = StyleSheet.create({
222
265
  cardWrapper: {
266
+ // Overrides DevToolsCard's own wrapper margin, so it has to opt into the
267
+ // edge-to-edge chat layout too or the rows keep their gutter.
223
268
  marginHorizontal: 8
224
269
  },
225
270
  container: {
226
271
  flexDirection: "row",
227
272
  alignItems: "center",
228
273
  borderLeftWidth: 3,
229
- borderLeftColor: "transparent"
274
+ borderLeftColor: "transparent",
275
+ // One hairline per row — the same value DevToolsCard's border uses.
276
+ borderBottomWidth: 1
230
277
  },
231
278
  leftSection: {
232
279
  marginRight: 8,
@@ -19,13 +19,15 @@
19
19
  */
20
20
 
21
21
  import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
22
- import { CheckCircle, XCircle, Clock, Copy, Trash2, CopyButton, macOSColors } from "@buoy-gg/shared-ui";
22
+ import { Bookmark, CheckCircle, XCircle, Clock, Copy, Pin, Trash2, CopyButton, macOSColors } from "@buoy-gg/shared-ui";
23
23
  import { networkEventStore } from "../utils/networkEventStore";
24
+ import { flagsForEventId, networkSavedStore, FREE_TIER_SAVED_LIMIT, MAX_PINNED, MAX_SAVED } from "../utils/networkSavedStore";
25
+ import { useNetworkSaved } from "../hooks/useNetworkSaved";
24
26
  import { useNetworkBadgeCounts } from "../hooks/useNetworkBadgeCounts";
25
27
  import { useNetworkHasEvents, useNetworkHasMatches } from "../hooks/useNetworkHasMatches";
26
28
  import { generateNetworkCopyText } from "../utils/generateNetworkCopyText";
27
29
  import { applyIgnoredPatterns, filterNetworkEvents } from "../utils/filterNetworkEvents";
28
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
30
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
29
31
  /** Success / error / pending chips — tap to toggle the status filter. */
30
32
  export function NetworkHeaderBadges({
31
33
  activeStatus,
@@ -98,6 +100,104 @@ export function NetworkClearButton({
98
100
  })
99
101
  });
100
102
  }
103
+ /**
104
+ * Entry point to the Saved screen, with its count. A self-subscribing leaf so
105
+ * the badge tracks the store without the modal header re-rendering.
106
+ */
107
+ export function NetworkSavedButton({
108
+ onPress
109
+ }) {
110
+ const {
111
+ savedRecords
112
+ } = useNetworkSaved();
113
+ const count = savedRecords.length;
114
+ return /*#__PURE__*/_jsxs(TouchableOpacity, {
115
+ "sentry-label": "ignore open saved",
116
+ onPress: onPress,
117
+ accessibilityLabel: "Saved requests",
118
+ style: styles.headerActionButton,
119
+ children: [/*#__PURE__*/_jsx(Bookmark, {
120
+ size: 14,
121
+ color: count > 0 ? macOSColors.semantic.debug : macOSColors.text.muted
122
+ }), count > 0 ? /*#__PURE__*/_jsx(View, {
123
+ style: styles.savedBadge,
124
+ children: /*#__PURE__*/_jsx(Text, {
125
+ style: styles.savedBadgeText,
126
+ children: count > 99 ? "99+" : count
127
+ })
128
+ }) : null]
129
+ });
130
+ }
131
+ /**
132
+ * Pin + Save toggles for the request-detail header.
133
+ *
134
+ * Lives here, as a self-subscribing leaf, for two reasons. First the usual
135
+ * one: the modal root stays free of subscriptions. Second, and the real
136
+ * constraint — these buttons deliberately do NOT go inside
137
+ * `NetworkEventDetailView`, because the Events tool renders that same detail
138
+ * view (UnifiedEventDetail) and has no saved store behind it. The header is
139
+ * the Network tool's own surface, so the feature stays where it belongs.
140
+ *
141
+ * The event object is resolved at PRESS time rather than held as a prop: the
142
+ * live store has the fresh copy while the request is in flight, and the saved
143
+ * store is the only copy once it has been cleared or aged out.
144
+ */
145
+ export function NetworkDetailPinActions({
146
+ eventId,
147
+ isPro,
148
+ onUpgradePress
149
+ }) {
150
+ const state = useNetworkSaved();
151
+ const {
152
+ pinned,
153
+ saved
154
+ } = flagsForEventId(state, eventId);
155
+ const savedLimit = isPro ? MAX_SAVED : FREE_TIER_SAVED_LIMIT;
156
+ const atPinCap = !pinned && state.pinnedEvents.length >= MAX_PINNED;
157
+ const atSavedCap = !saved && state.savedRecords.length >= savedLimit;
158
+ const resolveEvent = () => {
159
+ if (!eventId) return null;
160
+ return networkEventStore.getEvents().find(event => event.id === eventId) ?? networkSavedStore.getEventById(eventId);
161
+ };
162
+ const handlePin = () => {
163
+ const event = resolveEvent();
164
+ if (event) state.togglePin(event);
165
+ };
166
+ const handleSave = () => {
167
+ // Hitting the free ceiling is an upgrade moment, not a dead button.
168
+ if (atSavedCap && !isPro) {
169
+ onUpgradePress();
170
+ return;
171
+ }
172
+ const event = resolveEvent();
173
+ if (event) state.toggleSave(event);
174
+ };
175
+ return /*#__PURE__*/_jsxs(_Fragment, {
176
+ children: [/*#__PURE__*/_jsx(TouchableOpacity, {
177
+ "sentry-label": "ignore pin request",
178
+ onPress: handlePin,
179
+ disabled: atPinCap,
180
+ accessibilityLabel: pinned ? "Unpin request" : "Pin request",
181
+ accessibilityHint: atPinCap ? `Pin limit reached (${MAX_PINNED})` : undefined,
182
+ style: [styles.headerActionButton, pinned && styles.headerActionButtonActive, atPinCap && styles.headerActionButtonDisabled],
183
+ children: /*#__PURE__*/_jsx(Pin, {
184
+ size: 14,
185
+ color: pinned ? macOSColors.semantic.info : atPinCap ? macOSColors.text.disabled : macOSColors.text.secondary
186
+ })
187
+ }), /*#__PURE__*/_jsx(TouchableOpacity, {
188
+ "sentry-label": "ignore save request",
189
+ onPress: handleSave,
190
+ disabled: atSavedCap && isPro,
191
+ accessibilityLabel: saved ? "Remove from saved" : "Save request",
192
+ accessibilityHint: atSavedCap && isPro ? `Saved limit reached (${MAX_SAVED})` : undefined,
193
+ style: [styles.headerActionButton, saved && styles.headerActionButtonSaved, atSavedCap && isPro && styles.headerActionButtonDisabled],
194
+ children: /*#__PURE__*/_jsx(Bookmark, {
195
+ size: 14,
196
+ color: saved ? macOSColors.semantic.debug : atSavedCap && isPro ? macOSColors.text.disabled : macOSColors.text.secondary
197
+ })
198
+ })]
199
+ });
200
+ }
101
201
  /**
102
202
  * Copy-all-requests button. Subscribes to a flip-guarded boolean (its
103
203
  * enabled state); the copy text is generated at PRESS time from a live
@@ -149,6 +249,10 @@ const styles = StyleSheet.create({
149
249
  alignItems: "center",
150
250
  gap: 8
151
251
  },
252
+ /**
253
+ * Counter chips and toolbar buttons carry a permanent outline; the fill marks
254
+ * the active filter.
255
+ */
152
256
  headerChip: {
153
257
  flexDirection: "row",
154
258
  alignItems: "center",
@@ -189,5 +293,31 @@ const styles = StyleSheet.create({
189
293
  },
190
294
  headerActionButtonDisabled: {
191
295
  opacity: 0.55
296
+ },
297
+ headerActionButtonActive: {
298
+ backgroundColor: macOSColors.semantic.infoBackground,
299
+ borderColor: macOSColors.semantic.info + "50"
300
+ },
301
+ headerActionButtonSaved: {
302
+ backgroundColor: macOSColors.semantic.debug + "1A",
303
+ borderColor: macOSColors.semantic.debug + "50"
304
+ },
305
+ savedBadge: {
306
+ position: "absolute",
307
+ top: -3,
308
+ right: -3,
309
+ minWidth: 14,
310
+ height: 14,
311
+ paddingHorizontal: 3,
312
+ borderRadius: 7,
313
+ backgroundColor: macOSColors.semantic.debug,
314
+ alignItems: "center",
315
+ justifyContent: "center"
316
+ },
317
+ savedBadgeText: {
318
+ color: "#FFFFFF",
319
+ fontSize: 9,
320
+ fontWeight: "700",
321
+ fontFamily: "monospace"
192
322
  }
193
323
  });