@buoy-gg/storage 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 (148) hide show
  1. package/lib/commonjs/index.js +77 -1
  2. package/lib/commonjs/storage/components/DiffViewer/TreeDiffViewer.js +10 -3
  3. package/lib/commonjs/storage/components/DiffViewer/modes/ThemedSplitView.js +26 -8
  4. package/lib/commonjs/storage/components/GameUIStorageBrowser.js +263 -115
  5. package/lib/commonjs/storage/components/GameUIStorageStats.js +7 -7
  6. package/lib/commonjs/storage/components/MMKVInstanceInfoPanel.js +1 -1
  7. package/lib/commonjs/storage/components/MMKVInstanceSelector.js +1 -1
  8. package/lib/commonjs/storage/components/SelectionActionBar.js +2 -36
  9. package/lib/commonjs/storage/components/StorageActions.js +6 -6
  10. package/lib/commonjs/storage/components/StorageBrowserMode.js +16 -2
  11. package/lib/commonjs/storage/components/StorageEventDetailContent.js +106 -149
  12. package/lib/commonjs/storage/components/StorageEventListener.js +13 -13
  13. package/lib/commonjs/storage/components/StorageFilterCards.js +13 -135
  14. package/lib/commonjs/storage/components/StorageKeyCard.js +1 -1
  15. package/lib/commonjs/storage/components/StorageKeyEditorScreen.js +392 -0
  16. package/lib/commonjs/storage/components/StorageKeyRow.js +53 -15
  17. package/lib/commonjs/storage/components/StorageKeySection.js +10 -4
  18. package/lib/commonjs/storage/components/StorageKeyStats.js +10 -10
  19. package/lib/commonjs/storage/components/StorageModalWithTabs.js +272 -129
  20. package/lib/commonjs/storage/components/StorageScalarKeyEditor.js +46 -0
  21. package/lib/commonjs/storage/components/StorageTypeFilterBar.js +141 -0
  22. package/lib/commonjs/storage/components/StorageValueEditModal.js +361 -0
  23. package/lib/commonjs/storage/hooks/useKeyEditor.js +104 -0
  24. package/lib/commonjs/storage/hooks/useMeasuredWidth.js +74 -0
  25. package/lib/commonjs/storage/hooks/useSecureStoreKeys.js +70 -15
  26. package/lib/commonjs/storage/hooks/useTreeDraft.js +107 -0
  27. package/lib/commonjs/storage/index.js +29 -0
  28. package/lib/commonjs/storage/stores/storageEventStore.js +4 -2
  29. package/lib/commonjs/storage/sync/storageSyncAdapter.js +125 -3
  30. package/lib/commonjs/storage/utils/AsyncStorageListener.js +76 -12
  31. package/lib/commonjs/storage/utils/MMKVInstanceRegistry.js +10 -9
  32. package/lib/commonjs/storage/utils/MMKVListener.js +40 -4
  33. package/lib/commonjs/storage/utils/SecureStoreRegistry.js +52 -1
  34. package/lib/commonjs/storage/utils/deleteStorageKeys.js +53 -0
  35. package/lib/commonjs/storage/utils/index.js +13 -0
  36. package/lib/commonjs/storage/utils/lineDiff.js +21 -354
  37. package/lib/commonjs/storage/utils/setStorageValue.js +218 -0
  38. package/lib/commonjs/storage/utils/storageBrowserRefresh.js +49 -0
  39. package/lib/commonjs/storage/utils/storageOps.js +53 -0
  40. package/lib/commonjs/storage/utils/storageTimeTravelUtils.js +60 -38
  41. package/lib/commonjs/storage/utils/valueType.js +24 -0
  42. package/lib/module/index.js +18 -1
  43. package/lib/module/storage/components/DiffViewer/TreeDiffViewer.js +10 -3
  44. package/lib/module/storage/components/DiffViewer/modes/ThemedSplitView.js +26 -8
  45. package/lib/module/storage/components/GameUIStorageBrowser.js +266 -118
  46. package/lib/module/storage/components/GameUIStorageStats.js +7 -7
  47. package/lib/module/storage/components/MMKVInstanceInfoPanel.js +1 -1
  48. package/lib/module/storage/components/MMKVInstanceSelector.js +1 -1
  49. package/lib/module/storage/components/SelectionActionBar.js +2 -36
  50. package/lib/module/storage/components/StorageActions.js +7 -7
  51. package/lib/module/storage/components/StorageBrowserMode.js +16 -2
  52. package/lib/module/storage/components/StorageEventDetailContent.js +110 -151
  53. package/lib/module/storage/components/StorageEventListener.js +14 -14
  54. package/lib/module/storage/components/StorageFilterCards.js +13 -135
  55. package/lib/module/storage/components/StorageKeyCard.js +1 -1
  56. package/lib/module/storage/components/StorageKeyEditorScreen.js +388 -0
  57. package/lib/module/storage/components/StorageKeyRow.js +55 -17
  58. package/lib/module/storage/components/StorageKeySection.js +11 -5
  59. package/lib/module/storage/components/StorageKeyStats.js +11 -10
  60. package/lib/module/storage/components/StorageModalWithTabs.js +272 -130
  61. package/lib/module/storage/components/StorageScalarKeyEditor.js +42 -0
  62. package/lib/module/storage/components/StorageTypeFilterBar.js +136 -0
  63. package/lib/module/storage/components/StorageValueEditModal.js +357 -0
  64. package/lib/module/storage/hooks/useKeyEditor.js +100 -0
  65. package/lib/module/storage/hooks/useMeasuredWidth.js +71 -0
  66. package/lib/module/storage/hooks/useSecureStoreKeys.js +71 -16
  67. package/lib/module/storage/hooks/useTreeDraft.js +103 -0
  68. package/lib/module/storage/index.js +3 -0
  69. package/lib/module/storage/stores/storageEventStore.js +4 -2
  70. package/lib/module/storage/sync/storageSyncAdapter.js +125 -3
  71. package/lib/module/storage/utils/AsyncStorageListener.js +76 -12
  72. package/lib/module/storage/utils/MMKVInstanceRegistry.js +8 -7
  73. package/lib/module/storage/utils/MMKVListener.js +40 -4
  74. package/lib/module/storage/utils/SecureStoreRegistry.js +52 -1
  75. package/lib/module/storage/utils/deleteStorageKeys.js +49 -0
  76. package/lib/module/storage/utils/index.js +3 -0
  77. package/lib/module/storage/utils/lineDiff.js +7 -354
  78. package/lib/module/storage/utils/setStorageValue.js +209 -0
  79. package/lib/module/storage/utils/storageBrowserRefresh.js +44 -0
  80. package/lib/module/storage/utils/storageOps.js +46 -0
  81. package/lib/module/storage/utils/storageTimeTravelUtils.js +61 -40
  82. package/lib/module/storage/utils/valueType.js +23 -0
  83. package/lib/typescript/index.d.ts +12 -1
  84. package/lib/typescript/index.d.ts.map +1 -1
  85. package/lib/typescript/storage/components/DiffViewer/TreeDiffViewer.d.ts +4 -1
  86. package/lib/typescript/storage/components/DiffViewer/TreeDiffViewer.d.ts.map +1 -1
  87. package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts +7 -1
  88. package/lib/typescript/storage/components/DiffViewer/modes/ThemedSplitView.d.ts.map +1 -1
  89. package/lib/typescript/storage/components/GameUIStorageBrowser.d.ts +27 -1
  90. package/lib/typescript/storage/components/GameUIStorageBrowser.d.ts.map +1 -1
  91. package/lib/typescript/storage/components/SelectionActionBar.d.ts.map +1 -1
  92. package/lib/typescript/storage/components/StorageBrowserMode.d.ts +17 -1
  93. package/lib/typescript/storage/components/StorageBrowserMode.d.ts.map +1 -1
  94. package/lib/typescript/storage/components/StorageEventDetailContent.d.ts +4 -1
  95. package/lib/typescript/storage/components/StorageEventDetailContent.d.ts.map +1 -1
  96. package/lib/typescript/storage/components/StorageFilterCards.d.ts +9 -3
  97. package/lib/typescript/storage/components/StorageFilterCards.d.ts.map +1 -1
  98. package/lib/typescript/storage/components/StorageKeyEditorScreen.d.ts +42 -0
  99. package/lib/typescript/storage/components/StorageKeyEditorScreen.d.ts.map +1 -0
  100. package/lib/typescript/storage/components/StorageKeyRow.d.ts +17 -0
  101. package/lib/typescript/storage/components/StorageKeyRow.d.ts.map +1 -1
  102. package/lib/typescript/storage/components/StorageKeySection.d.ts +7 -1
  103. package/lib/typescript/storage/components/StorageKeySection.d.ts.map +1 -1
  104. package/lib/typescript/storage/components/StorageKeyStats.d.ts.map +1 -1
  105. package/lib/typescript/storage/components/StorageModalWithTabs.d.ts +28 -1
  106. package/lib/typescript/storage/components/StorageModalWithTabs.d.ts.map +1 -1
  107. package/lib/typescript/storage/components/StorageScalarKeyEditor.d.ts +24 -0
  108. package/lib/typescript/storage/components/StorageScalarKeyEditor.d.ts.map +1 -0
  109. package/lib/typescript/storage/components/StorageTypeFilterBar.d.ts +29 -0
  110. package/lib/typescript/storage/components/StorageTypeFilterBar.d.ts.map +1 -0
  111. package/lib/typescript/storage/components/StorageValueEditModal.d.ts +36 -0
  112. package/lib/typescript/storage/components/StorageValueEditModal.d.ts.map +1 -0
  113. package/lib/typescript/storage/hooks/useKeyEditor.d.ts +51 -0
  114. package/lib/typescript/storage/hooks/useKeyEditor.d.ts.map +1 -0
  115. package/lib/typescript/storage/hooks/useMeasuredWidth.d.ts +37 -0
  116. package/lib/typescript/storage/hooks/useMeasuredWidth.d.ts.map +1 -0
  117. package/lib/typescript/storage/hooks/useSecureStoreKeys.d.ts +8 -1
  118. package/lib/typescript/storage/hooks/useSecureStoreKeys.d.ts.map +1 -1
  119. package/lib/typescript/storage/hooks/useTreeDraft.d.ts +67 -0
  120. package/lib/typescript/storage/hooks/useTreeDraft.d.ts.map +1 -0
  121. package/lib/typescript/storage/index.d.ts +2 -0
  122. package/lib/typescript/storage/index.d.ts.map +1 -1
  123. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts +41 -0
  124. package/lib/typescript/storage/sync/storageSyncAdapter.d.ts.map +1 -1
  125. package/lib/typescript/storage/utils/AsyncStorageListener.d.ts +15 -0
  126. package/lib/typescript/storage/utils/AsyncStorageListener.d.ts.map +1 -1
  127. package/lib/typescript/storage/utils/MMKVInstanceRegistry.d.ts.map +1 -1
  128. package/lib/typescript/storage/utils/MMKVListener.d.ts +7 -0
  129. package/lib/typescript/storage/utils/MMKVListener.d.ts.map +1 -1
  130. package/lib/typescript/storage/utils/SecureStoreRegistry.d.ts +20 -0
  131. package/lib/typescript/storage/utils/SecureStoreRegistry.d.ts.map +1 -1
  132. package/lib/typescript/storage/utils/deleteStorageKeys.d.ts +27 -0
  133. package/lib/typescript/storage/utils/deleteStorageKeys.d.ts.map +1 -0
  134. package/lib/typescript/storage/utils/index.d.ts +1 -0
  135. package/lib/typescript/storage/utils/index.d.ts.map +1 -1
  136. package/lib/typescript/storage/utils/lineDiff.d.ts +7 -31
  137. package/lib/typescript/storage/utils/lineDiff.d.ts.map +1 -1
  138. package/lib/typescript/storage/utils/setStorageValue.d.ts +64 -0
  139. package/lib/typescript/storage/utils/setStorageValue.d.ts.map +1 -0
  140. package/lib/typescript/storage/utils/storageBrowserRefresh.d.ts +28 -0
  141. package/lib/typescript/storage/utils/storageBrowserRefresh.d.ts.map +1 -0
  142. package/lib/typescript/storage/utils/storageOps.d.ts +25 -0
  143. package/lib/typescript/storage/utils/storageOps.d.ts.map +1 -0
  144. package/lib/typescript/storage/utils/storageTimeTravelUtils.d.ts +15 -5
  145. package/lib/typescript/storage/utils/storageTimeTravelUtils.d.ts.map +1 -1
  146. package/lib/typescript/storage/utils/valueType.d.ts +13 -0
  147. package/lib/typescript/storage/utils/valueType.d.ts.map +1 -1
  148. package/package.json +5 -5
@@ -30,7 +30,10 @@ function StorageKeySection({
30
30
  onViewHistory,
31
31
  onHideKey,
32
32
  pinnedKeys,
33
- onTogglePin
33
+ onTogglePin,
34
+ onEditKey,
35
+ onToggleKey,
36
+ editBlockedFor
34
37
  }) {
35
38
  const [expandedKey, setExpandedKey] = (0, _react.useState)(null);
36
39
  const handleKeyPress = (0, _react.useCallback)(storageKey => {
@@ -85,6 +88,9 @@ function StorageKeySection({
85
88
  eventCount: eventCountByKey?.[storageKey.key],
86
89
  onViewHistory: onViewHistory ? () => onViewHistory(storageKey.key) : undefined,
87
90
  onHideKey: onHideKey,
91
+ onEditKey: onEditKey,
92
+ onToggleKey: onToggleKey,
93
+ editBlockedReason: editBlockedFor?.(storageKey),
88
94
  isPinned: pinnedKeys?.has(storageKey.key),
89
95
  onTogglePin: onTogglePin
90
96
  }, uniqueKey);
@@ -100,15 +106,15 @@ const styles = _reactNative.StyleSheet.create({
100
106
  // No gap needed, StorageKeyRow has its own margins
101
107
  },
102
108
  emptySection: {
103
- backgroundColor: "rgba(255, 255, 255, 0.02)",
109
+ backgroundColor: "rgba(128, 128, 128, 0.04)",
104
110
  borderRadius: 6,
105
111
  padding: 16,
106
112
  borderWidth: 1,
107
- borderColor: "rgba(255, 255, 255, 0.05)",
113
+ borderColor: "rgba(128, 128, 128, 0.1)",
108
114
  alignItems: "center"
109
115
  },
110
116
  emptySectionText: {
111
- color: "#6B7280",
117
+ color: _sharedUi.macOSColors.text.icon,
112
118
  fontSize: 11,
113
119
  textAlign: "center"
114
120
  }
@@ -259,15 +259,15 @@ function StorageKeyStatsSection({
259
259
  const styles = _reactNative.StyleSheet.create({
260
260
  statsContainer: {
261
261
  marginBottom: 24,
262
- backgroundColor: "rgba(255, 255, 255, 0.03)",
262
+ backgroundColor: "rgba(128, 128, 128, 0.06)",
263
263
  borderRadius: 8,
264
264
  padding: 16,
265
265
  borderWidth: 1,
266
- borderColor: "rgba(255, 255, 255, 0.08)"
266
+ borderColor: "rgba(128, 128, 128, 0.16)"
267
267
  },
268
268
  // Section titles
269
269
  sectionTitle: {
270
- color: "#9CA3AF",
270
+ color: _sharedUi.buoyColors.textSecondary,
271
271
  fontSize: 12,
272
272
  fontWeight: "500",
273
273
  marginBottom: 16,
@@ -283,11 +283,11 @@ const styles = _reactNative.StyleSheet.create({
283
283
  gap: 12
284
284
  },
285
285
  breakdownItem: {
286
- backgroundColor: "rgba(255, 255, 255, 0.02)",
286
+ backgroundColor: "rgba(128, 128, 128, 0.04)",
287
287
  padding: 12,
288
288
  borderRadius: 8,
289
289
  borderWidth: 1,
290
- borderColor: "rgba(255, 255, 255, 0.05)"
290
+ borderColor: "rgba(128, 128, 128, 0.1)"
291
291
  },
292
292
  breakdownItemRow: {
293
293
  flexDirection: "row",
@@ -310,12 +310,12 @@ const styles = _reactNative.StyleSheet.create({
310
310
  minWidth: 0
311
311
  },
312
312
  breakdownItemLabel: {
313
- color: "#FFFFFF",
313
+ color: _sharedUi.macOSColors.text.primary,
314
314
  fontSize: 14,
315
315
  fontWeight: "500"
316
316
  },
317
317
  breakdownItemDesc: {
318
- color: "#9CA3AF",
318
+ color: _sharedUi.buoyColors.textSecondary,
319
319
  fontSize: 12
320
320
  },
321
321
  breakdownItemRight: {
@@ -326,18 +326,18 @@ const styles = _reactNative.StyleSheet.create({
326
326
  fontWeight: "600"
327
327
  },
328
328
  breakdownPercentage: {
329
- color: "#6B7280",
329
+ color: _sharedUi.macOSColors.text.icon,
330
330
  fontSize: 10
331
331
  },
332
332
  // Empty state
333
333
  emptyState: {
334
334
  padding: 16,
335
- backgroundColor: "rgba(255, 255, 255, 0.02)",
335
+ backgroundColor: "rgba(128, 128, 128, 0.04)",
336
336
  borderRadius: 6,
337
337
  alignItems: "center"
338
338
  },
339
339
  emptyStateText: {
340
- color: "#6B7280",
340
+ color: _sharedUi.macOSColors.text.icon,
341
341
  fontSize: 11,
342
342
  textAlign: "center"
343
343
  }
@@ -5,11 +5,13 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.FREE_TIER_EVENT_LIMIT = void 0;
7
7
  exports.StorageModalWithTabs = StorageModalWithTabs;
8
+ exports.StorageToolView = StorageToolView;
8
9
  var _react = require("react");
9
10
  var _reactNative = require("react-native");
10
11
  var _asyncStorage = _interopRequireDefault(require("@react-native-async-storage/async-storage"));
11
12
  var _sharedUi = require("@buoy-gg/shared-ui");
12
13
  var _StorageBrowserMode = require("./StorageBrowserMode");
14
+ var _StorageTypeFilterBar = require("./StorageTypeFilterBar");
13
15
  var _clearAllStorage = require("../utils/clearAllStorage");
14
16
  var _useStorageEvents = require("../hooks/useStorageEvents");
15
17
  var _StorageEventDetailContent = require("./StorageEventDetailContent");
@@ -29,10 +31,16 @@ function StorageModalWithTabs({
29
31
  onBack,
30
32
  onMinimize,
31
33
  enableSharedModalDimensions = false,
32
- requiredStorageKeys = []
34
+ requiredStorageKeys = [],
35
+ inline = false,
36
+ onTabChange,
37
+ onRefresh
33
38
  }) {
34
39
  const isPro = (0, _license.useIsPro)();
35
40
  const [activeTab, setActiveTab] = (0, _react.useState)("browser");
41
+ (0, _react.useEffect)(() => {
42
+ onTabChange?.(activeTab);
43
+ }, [activeTab, onTabChange]);
36
44
 
37
45
  // Storage Browser state
38
46
  const [searchQuery, setSearchQuery] = (0, _react.useState)("");
@@ -58,6 +66,19 @@ function StorageModalWithTabs({
58
66
  const [showFilters, setShowFilters] = (0, _react.useState)(false);
59
67
  const [ignoredPatterns, setIgnoredPatterns] = (0, _react.useState)(new Set(["@react_buoy", "@RNAsyncStorage", "redux-persist", "persist:"]) // Auto-hide dev tool keys by default
60
68
  );
69
+ /**
70
+ * Which backend both tabs are looking at.
71
+ *
72
+ * Owned here rather than inside the browser because the Keys and Events tabs
73
+ * answer questions about the same three backends — filter the key list to
74
+ * Keychain, switch to Events to see what wrote to it, and the filter should
75
+ * still be Keychain.
76
+ *
77
+ * Distinct from `enabledStorageTypes` below: that's the persisted "capture
78
+ * this backend at all" setting from the Filters view. This is a view filter,
79
+ * and it never hides data the other one already dropped.
80
+ */
81
+ const [storageTypeFilter, setStorageTypeFilter] = (0, _react.useState)("all");
61
82
  const [enabledStorageTypes, setEnabledStorageTypes] = (0, _react.useState)(new Set(['async', 'mmkv', 'secure']) // All enabled by default
62
83
  );
63
84
  const [pinnedKeys, setPinnedKeys] = (0, _react.useState)(new Set());
@@ -69,8 +90,13 @@ function StorageModalWithTabs({
69
90
 
70
91
  // isListening = enabled and capturing (has subscribers)
71
92
  const isListening = isListeningEnabled && isCapturing;
72
- const handleModeChange = (0, _react.useCallback)(_mode => {
73
- // Mode changes handled by JsModal
93
+
94
+ // Track the modal's presentation mode so the stepper footer only reserves
95
+ // safe-area bottom padding while docked as a bottom sheet. In floating mode
96
+ // the footer isn't pinned to the screen edge, so that inset is dead space.
97
+ const [modalMode, setModalMode] = (0, _react.useState)("bottomSheet");
98
+ const handleModeChange = (0, _react.useCallback)(mode => {
99
+ setModalMode(mode);
74
100
  }, []);
75
101
 
76
102
  // Timer removed - using useTickEveryMinute hook instead
@@ -416,14 +442,34 @@ function StorageModalWithTabs({
416
442
  return counts;
417
443
  }, [conversations]);
418
444
 
445
+ /**
446
+ * When each key was last written, for the wide table's "Last write" column.
447
+ * Derived from the recorded event stream, NOT from the key read: a storage
448
+ * read has no timestamp of its own, so a key we never witnessed being written
449
+ * has no honest answer here and is simply absent.
450
+ */
451
+ const lastWriteByKey = (0, _react.useMemo)(() => {
452
+ const at = {};
453
+ conversations.forEach(conv => {
454
+ at[conv.key] = conv.lastEvent.timestamp.getTime();
455
+ });
456
+ return at;
457
+ }, [conversations]);
458
+
419
459
  // Selected history conversation (from browser tab "view history")
420
460
  const selectedHistoryConversation = (0, _react.useMemo)(() => {
421
461
  if (!selectedHistoryKey) return null;
422
462
  return conversations.find(c => c.key === selectedHistoryKey) || null;
423
463
  }, [selectedHistoryKey, conversations]);
424
464
 
425
- // Filtered raw events for the events tab (chronological stream, newest-first)
426
- const filteredEvents = (0, _react.useMemo)(() => {
465
+ /**
466
+ * Events passing everything EXCEPT the backend pills.
467
+ *
468
+ * Split out so the pills can show what each one would reveal. Counting the
469
+ * already-filtered list would zero out the three types you didn't pick, which
470
+ * tells you nothing about where to look next.
471
+ */
472
+ const eventsBeforeTypeFilter = (0, _react.useMemo)(() => {
427
473
  return events.filter(event => {
428
474
  if (!event.data?.key) return false;
429
475
  if (!enabledStorageTypes.has(event.storageType)) return false;
@@ -431,6 +477,13 @@ function StorageModalWithTabs({
431
477
  return !Array.from(ignoredPatterns).some(p => key.includes(p));
432
478
  });
433
479
  }, [events, ignoredPatterns, enabledStorageTypes]);
480
+ const eventTypeCounts = (0, _react.useMemo)(() => (0, _StorageTypeFilterBar.countByStorageType)(eventsBeforeTypeFilter), [eventsBeforeTypeFilter]);
481
+
482
+ // Filtered raw events for the events tab (chronological stream, newest-first)
483
+ const filteredEvents = (0, _react.useMemo)(() => {
484
+ if (storageTypeFilter === "all") return eventsBeforeTypeFilter;
485
+ return eventsBeforeTypeFilter.filter(event => event.storageType === storageTypeFilter);
486
+ }, [eventsBeforeTypeFilter, storageTypeFilter]);
434
487
  const getEventsSnapshot = (0, _react.useCallback)(() => {
435
488
  return filteredEvents.map(event => ({
436
489
  key: event.data?.key,
@@ -551,10 +604,17 @@ function StorageModalWithTabs({
551
604
  searchQuery: searchQuery,
552
605
  storageDataRef: storageDataRef,
553
606
  eventCountByKey: eventCountByKey,
607
+ lastWriteByKey: lastWriteByKey,
554
608
  onViewHistory: handleViewHistoryFromBrowser,
555
609
  enabledStorageTypes: enabledStorageTypes,
556
610
  pinnedKeys: pinnedKeys,
557
611
  onTogglePin: handleTogglePin
612
+ // Only a docked sheet sits on the screen's bottom edge; a floating
613
+ // window's own bottom is well above the home indicator.
614
+ ,
615
+ applySafeAreaInset: modalMode === "bottomSheet",
616
+ storageTypeFilter: storageTypeFilter,
617
+ onStorageTypeFilterChange: setStorageTypeFilter
558
618
  });
559
619
  }
560
620
 
@@ -570,48 +630,75 @@ function StorageModalWithTabs({
570
630
  })
571
631
  });
572
632
  }
633
+
634
+ // The pills render ABOVE the empty state as well as the list. Filtering to
635
+ // a backend that happens to have no events would otherwise hide the only
636
+ // control that undoes it, leaving the tab looking permanently broken.
637
+ const eventTypeFilterBar = /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
638
+ style: styles.eventFilterBar,
639
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_StorageTypeFilterBar.StorageTypeFilterBar, {
640
+ active: storageTypeFilter,
641
+ onChange: setStorageTypeFilter,
642
+ counts: eventTypeCounts
643
+ })
644
+ });
573
645
  if (filteredEvents.length === 0) {
646
+ const filtered = storageTypeFilter !== "all";
574
647
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
575
- style: styles.emptyState,
576
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Database, {
577
- size: 48,
578
- color: _sharedUi.macOSColors.text.muted
579
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
580
- style: styles.emptyTitle,
581
- children: isListening ? "No storage events yet" : "Event listener is paused"
582
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
583
- style: styles.emptySubtitle,
584
- children: isListening ? "Storage operations will appear here" : "Press play to start monitoring"
648
+ style: styles.flexFill,
649
+ children: [eventTypeFilterBar, /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
650
+ style: styles.emptyState,
651
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Database, {
652
+ size: 48,
653
+ color: _sharedUi.macOSColors.text.muted
654
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
655
+ style: styles.emptyTitle,
656
+ children: !isListening ? "Event listener is paused" : filtered ? `No ${storageTypeFilter} events` : "No storage events yet"
657
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
658
+ style: styles.emptySubtitle,
659
+ children: !isListening ? "Press play to start monitoring" : filtered ? "Other backends have events — pick All Types to see them" : "Storage operations will appear here"
660
+ })]
585
661
  })]
586
662
  });
587
663
  }
588
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
664
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
589
665
  style: styles.flexFill,
590
- data: visibleEvents,
591
- renderItem: renderEventItem,
592
- keyExtractor: eventKeyExtractor,
593
- onEndReachedThreshold: END_REACHED_THRESHOLD,
594
- contentContainerStyle: styles.listContent,
595
- ListFooterComponent: lockedEventCount > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
596
- style: styles.lockedBannerContainer,
597
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ProFeatureBanner, {
598
- featureName: `${lockedEventCount} event${lockedEventCount > 1 ? 's' : ''} locked`,
599
- description: "Upgrade to Pro to unlock full event history"
600
- })
601
- }) : null,
602
- initialNumToRender: 10,
603
- maxToRenderPerBatch: 10,
604
- windowSize: 10
666
+ children: [eventTypeFilterBar, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.FlatList, {
667
+ style: styles.flexFill,
668
+ data: visibleEvents,
669
+ renderItem: renderEventItem,
670
+ keyExtractor: eventKeyExtractor,
671
+ onEndReachedThreshold: END_REACHED_THRESHOLD,
672
+ contentContainerStyle: styles.listContent,
673
+ ListFooterComponent: lockedEventCount > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
674
+ style: styles.lockedBannerContainer,
675
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ProFeatureBanner, {
676
+ featureName: `${lockedEventCount} event${lockedEventCount > 1 ? 's' : ''} locked`,
677
+ description: "Upgrade to Pro to unlock full event history"
678
+ })
679
+ }) : null,
680
+ initialNumToRender: 10,
681
+ maxToRenderPerBatch: 10,
682
+ windowSize: 10
683
+ })]
605
684
  });
606
685
  };
607
- const footerNode = selectedConversation ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_StorageEventDetailContent.StorageEventDetailFooter, {
686
+
687
+ // The stepper renders nothing for a single-event conversation
688
+ // (EventStepperFooter bails at totalItems <= 1), but an element is truthy
689
+ // either way — so `footerHeight={footerNode ? 68 : 0}` below used to reserve
690
+ // 68px for a footer that drew nothing, leaving a dead band under the content.
691
+ // Gate the node on the event count so the reserve matches what's painted.
692
+ const footerNode = selectedConversation && selectedConversation.events.length > 1 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_StorageEventDetailContent.StorageEventDetailFooter, {
608
693
  conversation: selectedConversation,
609
694
  selectedEventIndex: selectedEventIndex,
610
- onEventIndexChange: setSelectedEventIndex
611
- }) : selectedHistoryConversation && selectedHistoryEventIndex !== null ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_StorageEventDetailContent.StorageEventDetailFooter, {
695
+ onEventIndexChange: setSelectedEventIndex,
696
+ applySafeAreaInset: modalMode === "bottomSheet"
697
+ }) : selectedHistoryConversation && selectedHistoryEventIndex !== null && selectedHistoryConversation.events.length > 1 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_StorageEventDetailContent.StorageEventDetailFooter, {
612
698
  conversation: selectedHistoryConversation,
613
699
  selectedEventIndex: selectedHistoryEventIndex,
614
- onEventIndexChange: setSelectedHistoryEventIndex
700
+ onEventIndexChange: setSelectedHistoryEventIndex,
701
+ applySafeAreaInset: modalMode === "bottomSheet"
615
702
  }) : null;
616
703
 
617
704
  // Determine the appropriate back handler based on current view state
@@ -619,6 +706,122 @@ function StorageModalWithTabs({
619
706
  setSelectedConversationKey(null);
620
707
  setSelectedEventIndex(0);
621
708
  } : onBack;
709
+ const headerContent = showFilters ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
710
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
711
+ onBack: () => setShowFilters(false)
712
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
713
+ title: "Filters"
714
+ })]
715
+ }) : selectedHistoryKey ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
716
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
717
+ onBack: selectedHistoryEventIndex !== null ? () => setSelectedHistoryEventIndex(null) : handleBackFromHistory
718
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
719
+ title: `${selectedHistoryKey} History`,
720
+ centered: true
721
+ })]
722
+ }) : selectedConversation ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
723
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
724
+ onBack: () => {
725
+ setSelectedConversationKey(null);
726
+ setSelectedEventIndex(0);
727
+ }
728
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
729
+ title: selectedConversation.key
730
+ })]
731
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
732
+ children: [onBack && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
733
+ onBack: onBack
734
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
735
+ title: "",
736
+ children: isSearchActive ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.SearchBar, {
737
+ value: searchQuery,
738
+ onChange: setSearchQuery,
739
+ onClear: () => {
740
+ setSearchQuery("");
741
+ setIsSearchActive(false);
742
+ },
743
+ placeholder: "Search storage keys...",
744
+ autoFocus: true,
745
+ onSubmitEditing: () => setIsSearchActive(false),
746
+ returnKeyType: "search",
747
+ suggestions: allStorageKeys,
748
+ containerStyle: styles.headerSearchContainer
749
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.TabSelector, {
750
+ tabs: [{
751
+ key: "browser",
752
+ label: "Storage"
753
+ }, {
754
+ key: "events",
755
+ label: `Events${filteredEvents.length > 0 ? ` (${filteredEvents.length})` : ""}`
756
+ }],
757
+ activeTab: activeTab,
758
+ onTabChange: tab => {
759
+ setActiveTab(tab);
760
+ setSelectedHistoryKey(null);
761
+ setSelectedHistoryEventIndex(null);
762
+ setSelectedConversationKey(null);
763
+ setSelectedEventIndex(0);
764
+ }
765
+ })
766
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader.Actions, {
767
+ children: [onRefresh && !isSearchActive ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
768
+ onPress: onRefresh,
769
+ style: styles.iconButton,
770
+ accessibilityLabel: "Refresh storage",
771
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.RefreshCw, {
772
+ size: 14,
773
+ color: _sharedUi.macOSColors.text.secondary
774
+ })
775
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
776
+ onPress: handleToggleFilters,
777
+ style: [styles.iconButton, (ignoredPatterns.size > 0 || enabledStorageTypes.size < 3) && styles.activeFilterButton],
778
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Filter, {
779
+ size: 14,
780
+ color: ignoredPatterns.size > 0 || enabledStorageTypes.size < 3 ? _sharedUi.macOSColors.semantic.debug : _sharedUi.macOSColors.text.secondary
781
+ })
782
+ }), activeTab === "browser" && !isSearchActive && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
783
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
784
+ onPress: () => setIsSearchActive(true),
785
+ style: styles.iconButton,
786
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
787
+ size: 14,
788
+ color: _sharedUi.macOSColors.text.secondary
789
+ })
790
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
791
+ value: getStorageSnapshot,
792
+ disabled: storageDataRef.current.length === 0,
793
+ buttonStyle: styles.iconButton
794
+ })]
795
+ }), activeTab === "events" && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
796
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
797
+ value: getEventsSnapshot,
798
+ disabled: filteredEvents.length === 0,
799
+ buttonStyle: styles.iconButton
800
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PowerToggleButton, {
801
+ isEnabled: isListening,
802
+ onToggle: handleToggleListening,
803
+ accessibilityLabel: "Toggle storage event monitoring"
804
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarClearButton, {
805
+ onPress: handleClearEvents,
806
+ buttonStyle: styles.iconButton
807
+ })]
808
+ })]
809
+ })]
810
+ });
811
+
812
+ // The ONLY difference between the phone and the desktop dashboard is which
813
+ // shell the header, body and footer go in. Everything above this is shared.
814
+ if (inline) {
815
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.TickProvider, {
816
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
817
+ style: styles.inlineRoot,
818
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
819
+ style: styles.inlineHeader,
820
+ children: headerContent
821
+ }), renderContent(), footerNode]
822
+ })
823
+ });
824
+ }
622
825
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.TickProvider, {
623
826
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.JsModal, {
624
827
  visible: visible,
@@ -628,100 +831,7 @@ function StorageModalWithTabs({
628
831
  persistenceKey: persistenceKey,
629
832
  header: {
630
833
  showToggleButton: true,
631
- customContent: showFilters ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
632
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
633
- onBack: () => setShowFilters(false)
634
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
635
- title: "Filters"
636
- })]
637
- }) : selectedHistoryKey ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
638
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
639
- onBack: selectedHistoryEventIndex !== null ? () => setSelectedHistoryEventIndex(null) : handleBackFromHistory
640
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
641
- title: `${selectedHistoryKey} History`,
642
- centered: true
643
- })]
644
- }) : selectedConversation ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
645
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
646
- onBack: () => {
647
- setSelectedConversationKey(null);
648
- setSelectedEventIndex(0);
649
- }
650
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
651
- title: selectedConversation.key
652
- })]
653
- }) : /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader, {
654
- children: [onBack && /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Navigation, {
655
- onBack: onBack
656
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ModalHeader.Content, {
657
- title: "",
658
- children: isSearchActive ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.SearchBar, {
659
- value: searchQuery,
660
- onChange: setSearchQuery,
661
- onClear: () => {
662
- setSearchQuery("");
663
- setIsSearchActive(false);
664
- },
665
- placeholder: "Search storage keys...",
666
- autoFocus: true,
667
- onSubmitEditing: () => setIsSearchActive(false),
668
- returnKeyType: "search",
669
- suggestions: allStorageKeys,
670
- containerStyle: styles.headerSearchContainer
671
- }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.TabSelector, {
672
- tabs: [{
673
- key: "browser",
674
- label: "Storage"
675
- }, {
676
- key: "events",
677
- label: `Events${filteredEvents.length > 0 ? ` (${filteredEvents.length})` : ""}`
678
- }],
679
- activeTab: activeTab,
680
- onTabChange: tab => {
681
- setActiveTab(tab);
682
- setSelectedHistoryKey(null);
683
- setSelectedHistoryEventIndex(null);
684
- setSelectedConversationKey(null);
685
- setSelectedEventIndex(0);
686
- }
687
- })
688
- }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_sharedUi.ModalHeader.Actions, {
689
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
690
- onPress: handleToggleFilters,
691
- style: [styles.iconButton, (ignoredPatterns.size > 0 || enabledStorageTypes.size < 3) && styles.activeFilterButton],
692
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Filter, {
693
- size: 14,
694
- color: ignoredPatterns.size > 0 || enabledStorageTypes.size < 3 ? _sharedUi.macOSColors.semantic.debug : _sharedUi.macOSColors.text.secondary
695
- })
696
- }), activeTab === "browser" && !isSearchActive && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
697
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.TouchableOpacity, {
698
- onPress: () => setIsSearchActive(true),
699
- style: styles.iconButton,
700
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.Search, {
701
- size: 14,
702
- color: _sharedUi.macOSColors.text.secondary
703
- })
704
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
705
- value: getStorageSnapshot,
706
- disabled: storageDataRef.current.length === 0,
707
- buttonStyle: styles.iconButton
708
- })]
709
- }), activeTab === "events" && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
710
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarCopyButton, {
711
- value: getEventsSnapshot,
712
- disabled: filteredEvents.length === 0,
713
- buttonStyle: styles.iconButton
714
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.PowerToggleButton, {
715
- isEnabled: isListening,
716
- onToggle: handleToggleListening,
717
- accessibilityLabel: "Toggle storage event monitoring"
718
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_sharedUi.ToolbarClearButton, {
719
- onPress: handleClearEvents,
720
- buttonStyle: styles.iconButton
721
- })]
722
- })]
723
- })]
724
- })
834
+ customContent: headerContent
725
835
  },
726
836
  onModeChange: handleModeChange,
727
837
  enablePersistence: true,
@@ -735,7 +845,34 @@ function StorageModalWithTabs({
735
845
  })
736
846
  });
737
847
  }
848
+
849
+ /**
850
+ * The same storage tool as `StorageModalWithTabs`, rendered INLINE for hosts
851
+ * that place tools in a panel rather than a modal. Exists so there is exactly
852
+ * one implementation — the desktop dashboard used to ship its own copy of this
853
+ * component's header, tabs and screen router.
854
+ */
855
+ function StorageToolView(props = {}) {
856
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(StorageModalWithTabs, {
857
+ ...props,
858
+ inline: true,
859
+ visible: true,
860
+ onClose: () => {}
861
+ });
862
+ }
738
863
  const styles = _reactNative.StyleSheet.create({
864
+ inlineRoot: {
865
+ flex: 1,
866
+ minHeight: 0,
867
+ backgroundColor: _sharedUi.macOSColors.background.base
868
+ },
869
+ inlineHeader: {
870
+ paddingHorizontal: 12,
871
+ paddingVertical: 8,
872
+ borderBottomWidth: 1,
873
+ borderBottomColor: _sharedUi.macOSColors.border.default,
874
+ backgroundColor: _sharedUi.macOSColors.background.card
875
+ },
739
876
  headerSearchContainer: {
740
877
  flex: 1
741
878
  },
@@ -765,6 +902,12 @@ const styles = _reactNative.StyleSheet.create({
765
902
  marginTop: 8,
766
903
  marginHorizontal: 16
767
904
  },
905
+ /** Gutter matching the event cards below it, so the pills line up with them. */
906
+ eventFilterBar: {
907
+ paddingHorizontal: 12,
908
+ paddingTop: 8,
909
+ paddingBottom: 4
910
+ },
768
911
  emptyState: {
769
912
  flex: 1,
770
913
  justifyContent: "center",
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.StorageScalarKeyEditor = StorageScalarKeyEditor;
7
+ var _react = require("react");
8
+ var _reactNative = require("react-native");
9
+ var _StorageValueEditModal = require("./StorageValueEditModal");
10
+ var _jsxRuntime = require("react/jsx-runtime");
11
+ /**
12
+ * Editing a key whose value is a single string, number or boolean.
13
+ *
14
+ * A tree of one row is not a tree, so there's nothing here to select, reorder
15
+ * or append to — which means none of the structural editor applies. And with
16
+ * one edit producing one write, there's nothing to buffer either: a draft would
17
+ * only add a step between typing the value and storing it.
18
+ *
19
+ * So this is just the value modal over whatever the user was looking at, on
20
+ * both the phone and the dashboard. Changing one string shouldn't black out the
21
+ * screen it came from.
22
+ */
23
+
24
+ function StorageScalarKeyEditor({
25
+ storageKey,
26
+ value,
27
+ onSave,
28
+ onClose
29
+ }) {
30
+ const [saving, setSaving] = (0, _react.useState)(false);
31
+ const save = (0, _react.useCallback)(next => {
32
+ if (saving) return;
33
+ setSaving(true);
34
+ onSave(JSON.stringify(next)).then(onClose).catch(e => {
35
+ setSaving(false);
36
+ _reactNative.Alert.alert("Couldn't save", e instanceof Error ? e.message : String(e));
37
+ });
38
+ }, [onClose, onSave, saving]);
39
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(_StorageValueEditModal.StorageValueEditModal, {
40
+ path: [storageKey.key],
41
+ value: value,
42
+ onSave: save,
43
+ onCancel: onClose,
44
+ busy: saving
45
+ });
46
+ }