@buoy-gg/network 7.0.4 → 7.0.6

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
@@ -0,0 +1,241 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The SAVED screen — the network tool's favorites list.
5
+ *
6
+ * Everything here is read from the persisted saved store, never from the live
7
+ * event store. That is the whole point of the screen: these requests survive a
8
+ * Clear, the 500-event cap, and an app restart, so a failure you kept is still
9
+ * here tomorrow.
10
+ *
11
+ * Rows are the same two row components the live list uses, so a saved request
12
+ * looks exactly like the request it was. Long-press here UNSAVES rather than
13
+ * pins — in this list "get rid of it" is the action the row affords, and
14
+ * pinning is still available from the detail header.
15
+ *
16
+ * Stable-tree contract (rule4): hooks run unconditionally, then the component
17
+ * returns null when not visible.
18
+ */
19
+
20
+ import { useState } from "react";
21
+ import { FlatList, StyleSheet, Text, TextInput, View, TouchableOpacity } from "react-native";
22
+ import { Bookmark, CopyButton, Search, macOSColors } from "@buoy-gg/shared-ui";
23
+ import { NetworkEventItemCompact } from "./NetworkEventItemCompact";
24
+ import { NetworkUpgradeBanner } from "./NetworkUpgradeBanner";
25
+ import { generateNetworkCopyText } from "../utils/generateNetworkCopyText";
26
+ import { filterNetworkEvents } from "../utils/filterNetworkEvents";
27
+ import { useNetworkSaved } from "../hooks/useNetworkSaved";
28
+ import { FREE_TIER_SAVED_LIMIT, MAX_SAVED } from "../utils/networkSavedStore";
29
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
30
+ const keyExtractor = item => item.id;
31
+ function EmptyState() {
32
+ return /*#__PURE__*/_jsxs(View, {
33
+ style: styles.emptyState,
34
+ children: [/*#__PURE__*/_jsx(Bookmark, {
35
+ size: 32,
36
+ color: macOSColors.text.muted
37
+ }), /*#__PURE__*/_jsx(Text, {
38
+ style: styles.emptyTitle,
39
+ children: "No saved requests"
40
+ }), /*#__PURE__*/_jsx(Text, {
41
+ style: styles.emptyText,
42
+ children: "Open a request and tap the bookmark to keep it here \u2014 saved requests survive Clear and app restarts."
43
+ })]
44
+ });
45
+ }
46
+ export function NetworkSavedScreen({
47
+ visible,
48
+ isPro,
49
+ copySettings,
50
+ onEventPress,
51
+ onUpgradePress
52
+ }) {
53
+ const {
54
+ savedRecords,
55
+ pinnedLiveIds,
56
+ toggleSave,
57
+ clearSaved
58
+ } = useNetworkSaved();
59
+ const [search, setSearch] = useState("");
60
+ if (!visible) return null;
61
+
62
+ /**
63
+ * A downgrade (or a limit change) can leave a free user holding more saves
64
+ * than the free tier allows. Rather than deleting their data, the overflow
65
+ * is LOCKED — oldest first, so the newest saves stay reachable — and the
66
+ * banner offers the upgrade that unlocks it.
67
+ */
68
+ const limit = isPro ? MAX_SAVED : FREE_TIER_SAVED_LIMIT;
69
+ const unlocked = savedRecords.slice(0, limit);
70
+ const lockedCount = savedRecords.length - unlocked.length;
71
+ const events = unlocked.map(record => record.event);
72
+ const filtered = search ? filterNetworkEvents(events, {
73
+ searchText: search
74
+ }) : events;
75
+ const handleLongPress = event => {
76
+ toggleSave(event);
77
+ };
78
+ const renderItem = ({
79
+ item
80
+ }) => /*#__PURE__*/_jsx(NetworkEventItemCompact, {
81
+ event: item,
82
+ onPress: onEventPress,
83
+ onLongPress: handleLongPress,
84
+ saved: true,
85
+ pinned: pinnedLiveIds.has(item.id)
86
+ });
87
+ const renderListFooter = () => {
88
+ if (lockedCount === 0) return null;
89
+ return /*#__PURE__*/_jsx(NetworkUpgradeBanner, {
90
+ title: `${lockedCount} saved ${lockedCount === 1 ? "request" : "requests"} locked`,
91
+ subtitle: `Free keeps ${FREE_TIER_SAVED_LIMIT}. Upgrade to Pro to unlock the rest.`,
92
+ onPress: onUpgradePress
93
+ });
94
+ };
95
+ return /*#__PURE__*/_jsxs(View, {
96
+ style: styles.root,
97
+ children: [/*#__PURE__*/_jsxs(View, {
98
+ style: styles.toolbar,
99
+ children: [/*#__PURE__*/_jsx(Bookmark, {
100
+ size: 11,
101
+ color: macOSColors.semantic.debug
102
+ }), /*#__PURE__*/_jsxs(Text, {
103
+ style: styles.toolbarTitle,
104
+ children: ["SAVED \xB7 ", savedRecords.length, isPro ? "" : `/${FREE_TIER_SAVED_LIMIT}`]
105
+ }), isPro ? /*#__PURE__*/_jsx(CopyButton
106
+ // Lazy value, resolved at press time — no string is built per
107
+ // render, and it always reflects the current list.
108
+ , {
109
+ value: () => generateNetworkCopyText(filtered, copySettings),
110
+ size: 13,
111
+ buttonStyle: styles.toolbarButton,
112
+ colors: {
113
+ idle: macOSColors.text.secondary,
114
+ success: macOSColors.semantic.success,
115
+ error: macOSColors.semantic.error
116
+ },
117
+ disabled: filtered.length === 0
118
+ }) : null, /*#__PURE__*/_jsx(TouchableOpacity, {
119
+ onPress: clearSaved,
120
+ "sentry-label": "ignore clear saved",
121
+ style: styles.toolbarClear,
122
+ disabled: savedRecords.length === 0,
123
+ children: /*#__PURE__*/_jsx(Text, {
124
+ style: [styles.toolbarClearText, savedRecords.length === 0 && styles.toolbarClearTextDisabled],
125
+ children: "Clear all"
126
+ })
127
+ })]
128
+ }), savedRecords.length > 0 ? /*#__PURE__*/_jsxs(View, {
129
+ style: styles.searchRow,
130
+ children: [/*#__PURE__*/_jsx(Search, {
131
+ size: 13,
132
+ color: macOSColors.text.secondary
133
+ }), /*#__PURE__*/_jsx(TextInput, {
134
+ style: styles.searchInput,
135
+ placeholder: "Search saved requests\u2026",
136
+ placeholderTextColor: macOSColors.text.muted,
137
+ value: search,
138
+ onChangeText: setSearch,
139
+ "sentry-label": "ignore saved search",
140
+ accessibilityLabel: "Search saved requests",
141
+ autoCapitalize: "none",
142
+ autoCorrect: false,
143
+ returnKeyType: "search"
144
+ })]
145
+ }) : null, savedRecords.length === 0 ? /*#__PURE__*/_jsx(EmptyState, {}) : /*#__PURE__*/_jsx(FlatList, {
146
+ data: filtered,
147
+ renderItem: renderItem,
148
+ keyExtractor: keyExtractor,
149
+ contentContainerStyle: styles.listContent,
150
+ showsVerticalScrollIndicator: true,
151
+ removeClippedSubviews: true,
152
+ scrollEnabled: true,
153
+ initialNumToRender: 12,
154
+ maxToRenderPerBatch: 8,
155
+ windowSize: 5,
156
+ extraData: pinnedLiveIds,
157
+ "sentry-label": "ignore saved requests list",
158
+ ListFooterComponent: renderListFooter
159
+ })]
160
+ });
161
+ }
162
+ const styles = StyleSheet.create({
163
+ root: {
164
+ flex: 1,
165
+ minHeight: 0
166
+ },
167
+ toolbar: {
168
+ flexDirection: "row",
169
+ alignItems: "center",
170
+ gap: 6,
171
+ paddingHorizontal: 20,
172
+ paddingTop: 10,
173
+ paddingBottom: 6
174
+ },
175
+ toolbarTitle: {
176
+ flex: 1,
177
+ color: macOSColors.semantic.debug,
178
+ fontSize: 10,
179
+ fontWeight: "700",
180
+ letterSpacing: 0.6,
181
+ fontFamily: "monospace"
182
+ },
183
+ toolbarButton: {
184
+ width: 26,
185
+ height: 26,
186
+ borderRadius: 6,
187
+ alignItems: "center",
188
+ justifyContent: "center"
189
+ },
190
+ toolbarClear: {
191
+ paddingHorizontal: 6,
192
+ paddingVertical: 2
193
+ },
194
+ toolbarClearText: {
195
+ color: macOSColors.text.muted,
196
+ fontSize: 10
197
+ },
198
+ toolbarClearTextDisabled: {
199
+ color: macOSColors.text.disabled
200
+ },
201
+ searchRow: {
202
+ flexDirection: "row",
203
+ alignItems: "center",
204
+ gap: 6,
205
+ marginHorizontal: 12,
206
+ marginBottom: 8,
207
+ paddingHorizontal: 10,
208
+ paddingVertical: 5,
209
+ backgroundColor: macOSColors.background.input,
210
+ borderRadius: 10,
211
+ borderWidth: 1,
212
+ borderColor: macOSColors.border.default
213
+ },
214
+ searchInput: {
215
+ flex: 1,
216
+ color: macOSColors.text.primary,
217
+ fontSize: 13,
218
+ paddingVertical: 2
219
+ },
220
+ listContent: {
221
+ paddingTop: 4
222
+ },
223
+ emptyState: {
224
+ alignItems: "center",
225
+ paddingVertical: 40,
226
+ paddingHorizontal: 24
227
+ },
228
+ emptyTitle: {
229
+ color: macOSColors.text.primary,
230
+ fontSize: 14,
231
+ fontWeight: "600",
232
+ marginTop: 12,
233
+ marginBottom: 6
234
+ },
235
+ emptyText: {
236
+ color: macOSColors.text.muted,
237
+ fontSize: 12,
238
+ textAlign: "center",
239
+ lineHeight: 17
240
+ }
241
+ });
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * The "N things are locked, upgrade to see them" banner.
5
+ *
6
+ * Extracted because there are now two of them — locked request history on the
7
+ * live list, and locked saves on the Saved screen — and they are the same
8
+ * offer. Two copies would drift the moment one of them gets a copy tweak.
9
+ */
10
+
11
+ import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
12
+ import { ProBadge, Zap, buoyColors } from "@buoy-gg/shared-ui";
13
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
+ export function NetworkUpgradeBanner({
15
+ title,
16
+ subtitle,
17
+ onPress
18
+ }) {
19
+ return /*#__PURE__*/_jsxs(TouchableOpacity, {
20
+ style: styles.banner,
21
+ onPress: onPress,
22
+ activeOpacity: 0.8,
23
+ children: [/*#__PURE__*/_jsxs(View, {
24
+ style: styles.content,
25
+ children: [/*#__PURE__*/_jsxs(View, {
26
+ style: styles.left,
27
+ children: [/*#__PURE__*/_jsx(Zap, {
28
+ size: 16,
29
+ color: buoyColors.primary
30
+ }), /*#__PURE__*/_jsx(Text, {
31
+ style: styles.title,
32
+ children: title
33
+ })]
34
+ }), /*#__PURE__*/_jsx(ProBadge, {})]
35
+ }), /*#__PURE__*/_jsx(Text, {
36
+ style: styles.subtitle,
37
+ children: subtitle
38
+ })]
39
+ });
40
+ }
41
+ const styles = StyleSheet.create({
42
+ banner: {
43
+ backgroundColor: buoyColors.card,
44
+ borderRadius: 10,
45
+ borderWidth: 1,
46
+ borderColor: buoyColors.primary + "40",
47
+ padding: 12,
48
+ marginHorizontal: 12,
49
+ marginTop: 12,
50
+ marginBottom: 34
51
+ },
52
+ content: {
53
+ flexDirection: "row",
54
+ alignItems: "center",
55
+ justifyContent: "space-between",
56
+ marginBottom: 4
57
+ },
58
+ left: {
59
+ flexDirection: "row",
60
+ alignItems: "center",
61
+ gap: 8
62
+ },
63
+ title: {
64
+ color: buoyColors.text,
65
+ fontSize: 13,
66
+ fontWeight: "600"
67
+ },
68
+ subtitle: {
69
+ color: buoyColors.textMuted,
70
+ fontSize: 11
71
+ }
72
+ });
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Resolves a selected event ID to the LIVE event object in the store.
5
+ *
6
+ * The detail view used to be handed the `NetworkEvent` captured at press
7
+ * time. The store updates immutably — a response/error replaces the pending
8
+ * event with a NEW object — so a request opened while pending stayed
9
+ * "Pending" for as long as the detail view was open, and only showed its
10
+ * real status after going back and re-opening the row (which re-read the
11
+ * store). This hook closes that gap: callers keep the ID as UI state and the
12
+ * leaf that renders the detail subscribes for its current value.
13
+ *
14
+ * Subscribing here (not at the modal root) keeps the event stream out of the
15
+ * shell's render path — same rule the list/header leaves follow.
16
+ */
17
+
18
+ import { useEffect, useState } from "react";
19
+ import { networkEventStore } from "../utils/networkEventStore";
20
+ import { networkSavedStore } from "../utils/networkSavedStore";
21
+ /**
22
+ * Live store first, then the pinned/saved snapshots. The fallback is what
23
+ * makes a pinned request openable after a Clear, after it aged past the
24
+ * 500-event cap, or in a later app launch — cases where the live store has
25
+ * nothing and the persisted snapshot is the only copy left.
26
+ */
27
+ function readEvent(eventId) {
28
+ return networkEventStore.getEvents().find(e => e.id === eventId) ?? networkSavedStore.getEventById(eventId);
29
+ }
30
+ export function useLiveNetworkEvent(eventId, options = {}) {
31
+ const {
32
+ paused = false
33
+ } = options;
34
+ const [entry, setEntry] = useState(null);
35
+ useEffect(() => {
36
+ // `paused` is the power toggle's freeze-stream semantic: a frozen tool
37
+ // keeps showing the version it froze on.
38
+ if (!eventId || paused) return undefined;
39
+ // subscribeToEvents replays the current array immediately, so the open
40
+ // detail catches up on subscribe as well as on every later update.
41
+ return networkEventStore.subscribeToEvents(events => {
42
+ const next = events.find(e => e.id === eventId);
43
+ // Gone from the store (cleared, or aged past maxEvents) — keep the last
44
+ // known version rather than blanking an open detail view.
45
+ if (!next) return;
46
+ setEntry(prev => prev && prev.id === eventId && prev.event === next ? prev : {
47
+ id: eventId,
48
+ event: next
49
+ });
50
+ });
51
+ }, [eventId, paused]);
52
+ if (!eventId) return null;
53
+ // A store read during render covers the frame where `eventId` just changed:
54
+ // the effect's first emission lands a commit later, and until then `entry`
55
+ // still describes the previously selected request.
56
+ if (entry && entry.id === eventId) return entry.event;
57
+ return readEvent(eventId);
58
+ }
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Narrow subscription to the pinned/saved store.
5
+ *
6
+ * The store rebuilds its derived view once per mutation, so the object this
7
+ * returns keeps a stable identity between pins — memoized rows don't re-render
8
+ * because a network event arrived, only because the user pinned something.
9
+ *
10
+ * Mutations happen through module-scope callbacks (stable identities), so
11
+ * passing them into memoized rows costs nothing.
12
+ */
13
+
14
+ import { useEffect, useState } from "react";
15
+ import { networkSavedStore } from "../utils/networkSavedStore";
16
+ const togglePin = event => networkSavedStore.togglePin(event);
17
+ const toggleSave = event => networkSavedStore.toggleSave(event);
18
+ const remove = key => networkSavedStore.remove(key);
19
+ const clearSaved = () => networkSavedStore.clearSaved();
20
+ const clearPinned = () => networkSavedStore.clearPinned();
21
+ export function useNetworkSaved() {
22
+ const [state, setState] = useState(() => networkSavedStore.getState());
23
+ useEffect(() => networkSavedStore.subscribe(() => {
24
+ setState(networkSavedStore.getState());
25
+ }), []);
26
+ return {
27
+ ...state,
28
+ togglePin,
29
+ toggleSave,
30
+ remove,
31
+ clearSaved,
32
+ clearPinned
33
+ };
34
+ }
@@ -5,17 +5,23 @@
5
5
  */
6
6
 
7
7
  // Components
8
- export { NetworkModal } from "./components/NetworkModal";
8
+ export { NetworkModal, NetworkToolView } from "./components/NetworkModal";
9
9
  export { NetworkEventDetailView } from "./components/NetworkEventDetailView";
10
10
  export { NetworkEventItemCompact } from "./components/NetworkEventItemCompact";
11
+ export { NetworkSavedScreen } from "./components/NetworkSavedScreen";
12
+ export { NetworkSavedButton, NetworkDetailPinActions } from "./components/NetworkHeaderLive";
11
13
 
12
14
  // Hooks
13
15
  export { useNetworkEvents } from "./hooks/useNetworkEvents";
16
+ export { useLiveNetworkEvent } from "./hooks/useLiveNetworkEvent";
17
+ export { useNetworkSaved } from "./hooks/useNetworkSaved";
14
18
  export { TickProvider, useTickEveryMinute } from "./hooks/useTickEveryMinute";
15
19
 
16
20
  // Utils
17
21
  export { networkListener, startNetworkListener, stopNetworkListener, addNetworkListener, removeAllNetworkListeners, isNetworkListening, getNetworkListenerCount } from "./utils/networkListener";
18
22
  export { networkEventStore } from "./utils/networkEventStore";
23
+ export { networkSavedStore, flagsForEventId, FREE_TIER_SAVED_LIMIT, MAX_PINNED, MAX_SAVED, SESSION_ID as NETWORK_SESSION_ID } from "./utils/networkSavedStore";
24
+ export { splitPinned } from "./utils/withPinnedFirst";
19
25
  export { formatBytes, formatDuration, formatHttpStatus } from "./utils/formatting";
20
26
 
21
27
  // Types
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import { networkEventStore } from "../utils/networkEventStore";
4
+ import { flagsForEventId, networkSavedStore } from "../utils/networkSavedStore";
4
5
  /**
5
6
  * Request/response bodies larger than this (using the cached size proxy already
6
7
  * on the event — no per-snapshot stringify) are NOT streamed in the list
@@ -26,6 +27,9 @@ const stripLargeBodies = event => {
26
27
  if (stripResponse) stripped.responseData = undefined;
27
28
  return stripped;
28
29
  };
30
+ const resolveEvent = id => networkEventStore.getEventById(id) ?? networkSavedStore.getEventById(id);
31
+ const readId = params => params && typeof params === "object" ? params.id : undefined;
32
+ const readBool = (params, key) => !!(params && typeof params === "object" && params[key]);
29
33
 
30
34
  /**
31
35
  * Sync adapter for the network tool, consumed by @buoy-gg/external-sync's
@@ -37,29 +41,103 @@ const stripLargeBodies = event => {
37
41
  *
38
42
  * v2: large bodies are stripped from the snapshot and fetched lazily via
39
43
  * `getEventBody` (see SNAPSHOT_BODY_INLINE_LIMIT).
44
+ *
45
+ * v3: the payload became an OBJECT — `{ events, saved }` — so pinned/saved
46
+ * requests reach the dashboard. They can't ride as flags on the events: a pin
47
+ * outlives the event that produced it (that is its whole purpose), so it has
48
+ * to be its own list. Dashboards that predate v3 read `payload` as an array;
49
+ * `NetworkPanel`/`NetworkInline` accept either shape.
40
50
  */
41
51
  export const networkSyncAdapter = {
42
- version: 2,
43
- getSnapshot: () => networkEventStore.getEvents().map(stripLargeBodies),
44
- subscribe: onChange => networkEventStore.subscribeToEvents(onChange),
52
+ version: 3,
53
+ getSnapshot: () => ({
54
+ events: networkEventStore.getEvents().map(stripLargeBodies),
55
+ // Saved snapshots go through the same body strip. They are few (capped in
56
+ // the store) but they ride on EVERY snapshot, so a fat one would land in
57
+ // the same 200ms loop that the body-stripping exists to protect.
58
+ saved: networkSavedStore.getRecords().map(record => ({
59
+ ...record,
60
+ event: stripLargeBodies(record.event)
61
+ }))
62
+ }),
63
+ subscribe: onChange => {
64
+ // Pinning with no traffic in flight still has to reach the dashboard, so
65
+ // both stores drive the snapshot.
66
+ const unsubscribeEvents = networkEventStore.subscribeToEvents(onChange);
67
+ const unsubscribeSaved = networkSavedStore.subscribe(onChange);
68
+ return () => {
69
+ unsubscribeEvents();
70
+ unsubscribeSaved();
71
+ };
72
+ },
45
73
  actions: {
46
74
  clearEvents: () => {
47
75
  networkEventStore.clearEvents();
48
76
  },
49
77
  /**
50
78
  * Return the full (un-stripped) request/response bodies for one event,
51
- * fetched on demand by the dashboard when its detail view opens. Returns
52
- * null if the event is no longer in the store.
79
+ * fetched on demand by the dashboard when its detail view opens. Falls
80
+ * back to the saved store, which may hold the only remaining copy of a
81
+ * request that has since been cleared. Returns null if neither has it.
53
82
  */
54
83
  getEventBody: params => {
55
- const id = params && typeof params === "object" ? params.id : undefined;
84
+ const id = readId(params);
56
85
  if (!id) return null;
57
- const event = networkEventStore.getEventById(id);
86
+ const event = resolveEvent(id);
58
87
  if (!event) return null;
59
88
  return {
60
89
  requestData: event.requestData ?? null,
61
90
  responseData: event.responseData ?? null
62
91
  };
92
+ },
93
+ /** Pin/unpin a request from the dashboard. */
94
+ setPinned: params => {
95
+ const id = readId(params);
96
+ if (!id) return null;
97
+ const event = resolveEvent(id);
98
+ if (!event) return null;
99
+ const wanted = readBool(params, "pinned");
100
+ // `flagsForEventId`, not `isPinned` — the id may be a `saved:<key>`
101
+ // snapshot, which the live-id Sets deliberately don't carry.
102
+ const current = flagsForEventId(networkSavedStore.getState(), id).pinned;
103
+ if (current === wanted) return {
104
+ ok: true
105
+ };
106
+ return networkSavedStore.togglePin(event);
107
+ },
108
+ /** Save/unsave a request from the dashboard. */
109
+ setSaved: params => {
110
+ const id = readId(params);
111
+ if (!id) return null;
112
+ const event = resolveEvent(id);
113
+ if (!event) return null;
114
+ const wanted = readBool(params, "saved");
115
+ const current = flagsForEventId(networkSavedStore.getState(), id).saved;
116
+ if (current === wanted) return {
117
+ ok: true
118
+ };
119
+ return networkSavedStore.toggleSave(event);
120
+ },
121
+ /** Drop one pinned/saved record by its stable key. */
122
+ removeSavedRecord: params => {
123
+ const key = params && typeof params === "object" ? params.key : undefined;
124
+ if (!key) return null;
125
+ networkSavedStore.remove(key);
126
+ return {
127
+ ok: true
128
+ };
129
+ },
130
+ clearSavedRequests: () => {
131
+ networkSavedStore.clearSaved();
132
+ return {
133
+ ok: true
134
+ };
135
+ },
136
+ clearPinnedRequests: () => {
137
+ networkSavedStore.clearPinned();
138
+ return {
139
+ ok: true
140
+ };
63
141
  }
64
142
  }
65
143
  };
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Render a captured request as a runnable `curl` command.
5
+ *
6
+ * The point is to get a request OUT of the app and into a terminal, Postman or
7
+ * a bug report without retyping it — so the output has to actually run, which
8
+ * makes quoting the whole job.
9
+ */
10
+
11
+ /** Headers that describe the captured hop rather than the request, and would
12
+ * either break the replay or lie about it. curl sets these itself. */
13
+ const SKIPPED_HEADERS = new Set(["content-length",
14
+ // curl recomputes it; a stale value truncates the body
15
+ "host",
16
+ // implied by the URL
17
+ "connection", "accept-encoding" // curl would then have to decode what it asked for
18
+ ]);
19
+
20
+ /**
21
+ * Single-quote for POSIX shells.
22
+ *
23
+ * Inside single quotes every byte is literal, so the ONLY character needing
24
+ * care is the single quote itself — closed, escaped, reopened. Newlines and
25
+ * `$`/backtick survive untouched, which is what makes this safe for JSON
26
+ * bodies and tokens.
27
+ */
28
+ function shellQuote(value) {
29
+ return `'${value.replace(/'/g, `'\\''`)}'`;
30
+ }
31
+ function serializeBody(data) {
32
+ if (data === undefined || data === null) return null;
33
+ if (typeof data === "string") return data;
34
+ try {
35
+ return JSON.stringify(data);
36
+ } catch {
37
+ // Circular or otherwise unserializable — better to emit no body than a
38
+ // command that silently posts "[object Object]".
39
+ return null;
40
+ }
41
+ }
42
+ export function generateCurl(event) {
43
+ const method = (event.method || "GET").toUpperCase();
44
+ const body = serializeBody(event.requestData);
45
+
46
+ // Flags are collected as whole `-X PATCH` strings rather than as a flat token
47
+ // list, so the line-wrap below can't mis-pair them — an earlier version
48
+ // destructured `[cmd, url, ...rest]` and put the URL in the wrong slot the
49
+ // moment an -X was present.
50
+ const flags = [];
51
+
52
+ // curl defaults to GET, and to POST once -d is present, so -X only carries
53
+ // information when it disagrees with that.
54
+ if (method !== "GET" && !(method === "POST" && body !== null)) {
55
+ flags.push(`-X ${method}`);
56
+ }
57
+ for (const [key, value] of Object.entries(event.requestHeaders || {})) {
58
+ if (SKIPPED_HEADERS.has(key.toLowerCase())) continue;
59
+ flags.push(`-H ${shellQuote(`${key}: ${value}`)}`);
60
+ }
61
+ if (body !== null) flags.push(`--data-raw ${shellQuote(body)}`);
62
+ const head = `curl ${shellQuote(event.url)}`;
63
+ if (flags.length === 0) return head;
64
+ // One flag per line — these get long, and a wrapped blob is unreadable in a
65
+ // terminal or pasted into a ticket.
66
+ return `${head} \\\n ${flags.join(" \\\n ")}`;
67
+ }
@@ -24,6 +24,7 @@
24
24
  import { BaseEventStore } from "@buoy-gg/shared-ui";
25
25
  import { extractOperationName } from "./extractOperationName";
26
26
  import { networkListener, addNetworkListener } from "./networkListener";
27
+ import { networkSavedStore } from "./networkSavedStore";
27
28
 
28
29
  /**
29
30
  * Callback type for individual event notifications
@@ -155,7 +156,11 @@ class NetworkEventStore extends BaseEventStore {
155
156
  if (index !== -1) {
156
157
  const updatedEvent = {
157
158
  ...this.events[index],
158
- duration: event.duration
159
+ duration: event.duration,
160
+ // Left undefined when the interceptor couldn't measure it (e.g. a
161
+ // request that failed before headers). Consumers hide the breakdown
162
+ // in that case rather than rendering zeroes.
163
+ timings: event.timings
159
164
  };
160
165
  if (event.response) {
161
166
  updatedEvent.status = event.response.status;
@@ -171,6 +176,10 @@ class NetworkEventStore extends BaseEventStore {
171
176
  }
172
177
  this.events = this.events.map((e, i) => i === index ? updatedEvent : e);
173
178
  this.pendingRequests.delete(request.id);
179
+ // Refresh any pinned/saved snapshot of this request — a request pinned
180
+ // while in flight would otherwise stay frozen as "Pending" forever.
181
+ // No-ops on a single Set check when nothing is pinned.
182
+ networkSavedStore.syncFromLive(updatedEvent);
174
183
  this.notify(updatedEvent);
175
184
  this.notifyArrayListeners();
176
185
  }