diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock index 8003f8fa..770af2a8 100644 --- a/apps/playground/ios/Podfile.lock +++ b/apps/playground/ios/Podfile.lock @@ -1265,6 +1265,27 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - react-native-performance (5.1.4): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - react-native-safe-area-context (5.5.2): - DoubleConversion - glog @@ -1598,6 +1619,27 @@ PODS: - React-logger (= 0.76.0) - React-perflogger (= 0.76.0) - React-utils (= 0.76.0) + - RNCAsyncStorage (2.2.0): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - RNScreens (4.4.0): - DoubleConversion - glog @@ -1686,6 +1728,7 @@ DEPENDENCIES: - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - react-native-get-random-values (from `../node_modules/react-native-get-random-values`) - react-native-mmkv (from `../node_modules/react-native-mmkv`) + - react-native-performance (from `../node_modules/react-native-performance`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - React-nativeconfig (from `../node_modules/react-native/ReactCommon`) - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -1714,6 +1757,7 @@ DEPENDENCIES: - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) - ReactCodegen (from `build/generated/ios`) - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" - RNScreens (from `../node_modules/react-native-screens`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1797,6 +1841,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-get-random-values" react-native-mmkv: :path: "../node_modules/react-native-mmkv" + react-native-performance: + :path: "../node_modules/react-native-performance" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" React-nativeconfig: @@ -1853,6 +1899,8 @@ EXTERNAL SOURCES: :path: build/generated/ios ReactCommon: :path: "../node_modules/react-native/ReactCommon" + RNCAsyncStorage: + :path: "../node_modules/@react-native-async-storage/async-storage" RNScreens: :path: "../node_modules/react-native-screens" Yoga: @@ -1896,6 +1944,7 @@ SPEC CHECKSUMS: React-microtasksnativemodule: cf1584fdc26003a6e7bf02b9d591e2d8353f7b75 react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba react-native-mmkv: d9af389b132ca3a56f93eb4225c92f397f435f33 + react-native-performance: a98bf1c728aa26daae095f7a313153316016dce3 react-native-safe-area-context: bb9dd7ee24674663cc345a423409e4542eabd1a9 React-nativeconfig: 72c10ff34863148ef90df9c9c8eacba99d2faaaa React-NativeModulesApple: 71fd2cce69bcd2c46673cf6afedb4935b47bf230 @@ -1924,6 +1973,7 @@ SPEC CHECKSUMS: React-utils: e74516d5b9483c5530ec61e249e28b88729321d2 ReactCodegen: ff7512e124e3dc1363a4930a209d033354d2042a ReactCommon: cde69a75746e8d7131f61c27155ee9dc42117003 + RNCAsyncStorage: f27db574d8d0d56438ec4e9ba345872f2d0f29f4 RNScreens: 351f431ef2a042a1887d4d90e1c1024b8ae9d123 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: f8ec45ce98bba1bc93dd28f2ee37215180e6d2b6 diff --git a/apps/playground/package.json b/apps/playground/package.json index b6af578f..903366af 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -8,7 +8,9 @@ "@react-navigation/elements": "^2.5.2", "@react-navigation/native": "^7.1.14", "@react-navigation/native-stack": "^7.3.21", + "@react-native-async-storage/async-storage": "^2.2.0", "@reduxjs/toolkit": "^2.8.2", + "@rozenite/async-storage-plugin": "workspace:*", "@rozenite/expo-atlas-plugin": "workspace:*", "@rozenite/mmkv-plugin": "workspace:*", "@rozenite/network-activity-plugin": "workspace:*", diff --git a/apps/playground/src/app/App.tsx b/apps/playground/src/app/App.tsx index e89e21d2..d61a2dd2 100644 --- a/apps/playground/src/app/App.tsx +++ b/apps/playground/src/app/App.tsx @@ -6,11 +6,14 @@ import { MMKVPluginScreen } from './screens/MMKVPluginScreen'; import { NetworkTestScreen } from './screens/NetworkTestScreen'; import { ReduxTestScreen } from './screens/ReduxTestScreen'; import { PerformanceMonitorScreen } from './screens/PerformanceMonitorScreen'; +import { AsyncStoragePluginScreen } from './screens/AsyncStoragePluginScreen'; import { ConfigScreen } from './screens/ConfigScreen'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useTanStackQueryDevTools } from '@rozenite/tanstack-query-plugin'; import { useNetworkActivityDevTools } from '@rozenite/network-activity-plugin'; import { useMMKVDevTools } from '@rozenite/mmkv-plugin'; +import { useAsyncStorageDevTools } from '@rozenite/async-storage-plugin'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { RootStackParamList } from './navigation/types'; import { Provider } from 'react-redux'; import { store } from './store'; @@ -27,6 +30,7 @@ const Wrapper = () => { storages: mmkvStorages, blacklist: /user-storage:sensitiveToken/, }); + useAsyncStorageDevTools(AsyncStorage); usePerformanceMonitorDevTools(); return ( @@ -45,6 +49,7 @@ const Wrapper = () => { name="PerformanceMonitor" component={PerformanceMonitorScreen} /> + { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + + // Add sample data on first load + useEffect(() => { + const addInitialData = async () => { + try { + // Check if data already exists + const keys = await AsyncStorage.getAllKeys(); + if (keys.length === 0) { + // Add demo data + await AsyncStorage.setItem('username', 'john_doe'); + await AsyncStorage.setItem('email', 'john@example.com'); + await AsyncStorage.setItem('user_id', '12345'); + await AsyncStorage.setItem('is_premium', 'true'); + await AsyncStorage.setItem('last_login', Date.now().toString()); + await AsyncStorage.setItem('profile', JSON.stringify({ + bio: 'Software Developer', + location: 'San Francisco' + })); + await AsyncStorage.setItem('app_settings', JSON.stringify({ + theme: 'dark', + notifications: true, + language: 'en' + })); + console.log('AsyncStorage initial data added'); + } + } catch (error) { + console.error('Failed to add initial AsyncStorage data:', error); + } finally { + // Load data regardless of whether initial data was added + loadData(); + } + }; + + addInitialData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const keys = await AsyncStorage.getAllKeys(); + const result = await AsyncStorage.multiGet(keys); + + const storageItems = result + .filter((item): item is [string, string] => item[1] !== null) + .map(([key, value]: [string, string]) => ({ key, value })); + + setItems(storageItems); + } catch (error) { + console.error('Failed to load AsyncStorage data:', error); + Alert.alert('Error', 'Failed to load storage data'); + } finally { + setLoading(false); + } + }; + + const addItem = async () => { + if (!key || !value) { + Alert.alert('Error', 'Both key and value are required'); + return; + } + + try { + await AsyncStorage.setItem(key, value); + setKey(''); + setValue(''); + loadData(); + Alert.alert('Success', 'Item added successfully'); + } catch (error) { + console.error('Failed to add item:', error); + Alert.alert('Error', 'Failed to add item'); + } + }; + + const removeItem = async (key: string) => { + try { + await AsyncStorage.removeItem(key); + loadData(); + } catch (error) { + console.error('Failed to remove item:', error); + Alert.alert('Error', 'Failed to remove item'); + } + }; + + const clearAll = async () => { + try { + await AsyncStorage.clear(); + loadData(); + Alert.alert('Success', 'All items cleared'); + } catch (error) { + console.error('Failed to clear storage:', error); + Alert.alert('Error', 'Failed to clear storage'); + } + }; + + // Helper function to detect value type + const detectValueType = (value: string): string => { + try { + // Try to parse JSON + if (value === 'true' || value === 'false') { + return 'boolean'; + } + + const num = Number(value); + if (!isNaN(num) && value !== '') { + return 'number'; + } + + // Check if it's valid JSON (object or array) + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return 'array'; + } + if (parsed && typeof parsed === 'object') { + return 'object'; + } + + return 'string'; + } catch { + return 'string'; + } + }; + + const renderItem = ({ item }: { item: StorageItem }) => { + // Determine the entry type + const type = detectValueType(item.value); + + // Format the display value + let displayValue = item.value; + if (type === 'object' || type === 'array') { + try { + displayValue = JSON.stringify(JSON.parse(item.value), null, 2).substring(0, 100); + if (displayValue.length >= 100) displayValue += '...'; + } catch { + // Keep original value if parsing fails + } + } + + return ( + + + {item.key} + + {type} + + + + + + {displayValue} + + + + + removeItem(item.key)} + > + Delete + + + + + ); + }; + + return ( + + + AsyncStorage Explorer + Inspect and manage your persistent data + + + + + + + Add Item + + + + + + Storage Entries ({items.length}) + + + + Refresh + + + Clear All + + + + + {loading ? ( + + + Loading storage entries... + + ) : items.length === 0 ? ( + + + No items in storage + + + Tap "Add Item" or "Add Sample Data" to create entries + + + ) : ( + item.key} + renderItem={renderItem} + style={styles.list} + contentContainerStyle={styles.listContainer} + ItemSeparatorComponent={() => } + showsVerticalScrollIndicator={false} + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#0a0a0a', + }, + header: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 40, + paddingTop: 60, + paddingBottom: 20, + }, + title: { + fontSize: 36, + fontWeight: 'bold', + color: '#ffffff', + textAlign: 'center', + marginBottom: 16, + letterSpacing: 1, + fontFamily: 'System', + }, + subtitle: { + fontSize: 16, + color: '#a0a0a0', + textAlign: 'center', + lineHeight: 24, + fontWeight: '400', + }, + contentHeader: { + gap: 8, + flexDirection: 'column', + alignItems: 'center', + marginBottom: 20, + paddingHorizontal: 20, + }, + contentTitle: { + fontSize: 18, + fontWeight: 'bold', + color: '#ffffff', + }, + contentActions: { + flexDirection: 'row', + gap: 12, + }, + inputContainer: { + marginBottom: 20, + paddingHorizontal: 20, + }, + input: { + backgroundColor: '#1a1a1a', + borderWidth: 1, + borderColor: '#333333', + borderRadius: 8, + padding: 12, + marginBottom: 8, + color: '#ffffff', + }, + button: { + backgroundColor: '#007AFF', + borderRadius: 6, + paddingVertical: 8, + paddingHorizontal: 16, + alignItems: 'center', + }, + addButton: { + backgroundColor: '#52c41a', + borderRadius: 8, + padding: 12, + alignItems: 'center', + }, + buttonText: { + color: '#ffffff', + fontWeight: '600', + fontSize: 14, + }, + list: { + flex: 1, + paddingHorizontal: 20, + }, + listContainer: { + paddingVertical: 10, + }, + item: { + backgroundColor: '#1a1a1a', + borderRadius: 12, + padding: 16, + flexDirection: 'column', + borderWidth: 1, + borderColor: '#333333', + }, + itemHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + itemContent: { + flex: 1, + }, + itemFooter: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: 8, + }, + spacer: { + flex: 1, + }, + entryType: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 4, + minWidth: 50, + alignItems: 'center', + marginLeft: 8, + }, + stringType: { + backgroundColor: '#007AFF', // blue + }, + numberType: { + backgroundColor: '#32CD32', // green + }, + booleanType: { + backgroundColor: '#FF7F00', // orange + }, + objectType: { + backgroundColor: '#9932CC', // purple + }, + arrayType: { + backgroundColor: '#DC143C', // crimson + }, + entryTypeText: { + fontSize: 12, + color: '#ffffff', + fontWeight: '600', + }, + entryActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginLeft: 8, + }, + key: { + fontWeight: 'bold', + fontSize: 16, + color: '#ffffff', + flex: 1, + }, + value: { + fontSize: 14, + color: '#a0a0a0', + marginTop: 4, + fontFamily: 'monospace', + marginBottom: 4, + }, + deleteButton: { + backgroundColor: '#ff3b30', + borderRadius: 4, + paddingVertical: 4, + paddingHorizontal: 8, + alignItems: 'center', + }, + deleteButtonText: { + color: 'white', + fontWeight: '600', + fontSize: 12, + }, + separator: { + height: 12, + }, + emptyState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + }, + emptyStateText: { + fontSize: 18, + color: '#a0a0a0', + textAlign: 'center', + marginBottom: 8, + }, + emptyStateSubtext: { + fontSize: 14, + color: '#666666', + textAlign: 'center', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + color: '#a0a0a0', + marginTop: 16, + fontSize: 16, + }, +}); diff --git a/apps/playground/src/app/screens/LandingScreen.tsx b/apps/playground/src/app/screens/LandingScreen.tsx index 12a249fd..61210e41 100644 --- a/apps/playground/src/app/screens/LandingScreen.tsx +++ b/apps/playground/src/app/screens/LandingScreen.tsx @@ -51,6 +51,13 @@ export const LandingScreen = () => { Performance Monitor + navigation.navigate('AsyncStorage' as never)} + > + AsyncStorage + + navigation.navigate('Config' as never)} diff --git a/apps/playground/tsconfig.app.json b/apps/playground/tsconfig.app.json index 42f51d48..9e7787e5 100644 --- a/apps/playground/tsconfig.app.json +++ b/apps/playground/tsconfig.app.json @@ -51,6 +51,9 @@ }, { "path": "../../packages/metro/tsconfig.lib.json" + }, + { + "path": "../../packages/async-storage-plugin" } ] } diff --git a/commitlint.config.js b/commitlint.config.js index d4836849..5d225942 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -21,6 +21,7 @@ export default { 'repack', 'performance-monitor-plugin', 'tools', + 'async-storage-plugin', '', ], ], diff --git a/nx.json b/nx.json index 8575a8ac..48960d29 100644 --- a/nx.json +++ b/nx.json @@ -54,7 +54,8 @@ "packages/network-activity-plugin/**/*", "packages/tanstack-query-plugin/**/*", "packages/redux-devtools-plugin/**/*", - "packages/performance-monitor-plugin/**/*" + "packages/performance-monitor-plugin/**/*", + "packages/async-storage-plugin/**/*" ] }, { diff --git a/packages/async-storage-plugin/README.md b/packages/async-storage-plugin/README.md new file mode 100644 index 00000000..b194e380 --- /dev/null +++ b/packages/async-storage-plugin/README.md @@ -0,0 +1,75 @@ +![rozenite-banner](https://www.rozenite.dev/rozenite-banner.jpg) + +### A Rozenite plugin that provides comprehensive AsyncStorage inspection for React Native applications. + +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge)](https://github.com/callstackincubator/rozenite/blob/main/CONTRIBUTING.md) +[![MIT License](https://img.shields.io/npm/l/rozenite?style=for-the-badge)](https://github.com/callstackincubator/rozenite/blob/main/LICENSE) + +The Rozenite AsyncStorage Plugin provides real-time storage inspection, data visualization, and management capabilities for AsyncStorage within your React Native DevTools environment. + +## Features + +- **Real-time Storage Inspection**: View all AsyncStorage entries and their contents in real-time +- **Data Type Detection**: Automatically detects and displays different data types (string, number, boolean, object, array) +- **Search & Filter**: Quickly find specific keys or values with real-time search functionality +- **Data Management**: Add, edit, and delete entries directly from the DevTools interface +- **Visual Data Representation**: Color-coded type indicators and formatted value display + +## Installation + +Install the AsyncStorage plugin as a dependency: + +```bash +npm install @rozenite/async-storage-plugin +``` + +**Note**: This plugin requires `@react-native-async-storage/async-storage` as a peer dependency. Make sure you have it installed: + +```bash +npm install @react-native-async-storage/async-storage +``` + +## Quick Start + +### 1. Install the Plugin + +```bash +npm install @rozenite/async-storage-plugin @react-native-async-storage/async-storage +``` + +### 2. Integrate with Your App + +Add the DevTools hook to your React Native app: + +```typescript +// App.tsx +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useAsyncStorageDevTools } from '@rozenite/async-storage-plugin'; + +function App() { + // Enable AsyncStorage DevTools in development + useAsyncStorageDevTools(AsyncStorage); + + return ; +} +``` + +### 3. Access DevTools + +Start your development server and open React Native DevTools. You'll find the "AsyncStorage" panel in the DevTools interface. + +## Usage + +The AsyncStorage plugin automatically connects to your app's AsyncStorage instance and provides: + +- **Storage Inspection**: View all stored keys with their types and values +- **Search Functionality**: Filter entries by key or value +- **Type Indicators**: Visual indicators for different data types (string, number, boolean, object, array) +- **Real-time Updates**: See changes to your AsyncStorage as they happen +- **Data Management**: Add, edit, and delete entries directly from the DevTools interface + +## Made with ❤️ at Callstack + +`rozenite` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. + +[Callstack](https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=rozenite&utm_term=readme-with-love) is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! diff --git a/packages/async-storage-plugin/package.json b/packages/async-storage-plugin/package.json new file mode 100644 index 00000000..f3285e6d --- /dev/null +++ b/packages/async-storage-plugin/package.json @@ -0,0 +1,35 @@ +{ + "name": "@rozenite/async-storage-plugin", + "version": "1.0.0-alpha.11", + "description": "react native async storage dev-tools plugin", + "type": "module", + "main": "./dist/react-native.cjs", + "module": "./dist/react-native.js", + "types": "./dist/react-native.d.ts", + "scripts": { + "build": "rozenite build", + "dev": "rozenite dev" + }, + "dependencies": { + "@rozenite/plugin-bridge": "1.0.0-alpha.3", + "nanoevents": "^9.1.0" + }, + "devDependencies": { + "vite": "^6.0.0", + "@rozenite/vite-plugin": "1.0.0-alpha.3", + "typescript": "^5.7.3", + "rozenite": "1.0.0-alpha.3", + "react-native-web": "0.21.0", + "react": "18.3.1", + "react-dom": "18.3.0", + "react-native": "0.76.0", + "@types/react": "~18.3.12", + "@react-native-async-storage/async-storage": "^2.2.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "@react-native-async-storage/async-storage": "*" + }, + "license": "MIT" +} diff --git a/packages/async-storage-plugin/project.json b/packages/async-storage-plugin/project.json new file mode 100644 index 00000000..765da4d6 --- /dev/null +++ b/packages/async-storage-plugin/project.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "@rozenite/async-storage-plugin", + "targets": { + "build": { + "cache": true, + "dependsOn": ["^build"], + "inputs": ["{projectRoot}/src/**/*"], + "outputs": ["{projectRoot}/dist"] + } + } +} diff --git a/packages/async-storage-plugin/react-native.ts b/packages/async-storage-plugin/react-native.ts new file mode 100644 index 00000000..030dbf75 --- /dev/null +++ b/packages/async-storage-plugin/react-native.ts @@ -0,0 +1,19 @@ +/** + * AsyncStorage Plugin for Rozenite DevTools + * + * This plugin provides a powerful interface for inspecting and managing + * AsyncStorage data in your React Native application. + */ + +import { useAsyncStorageDevTools as useDevToolsImpl } from './src/react-native/useAsyncStorageDevTools'; +export type { AsyncStorageAPI } from './src/react-native/async-storage-container'; + +const isWeb = typeof window !== 'undefined' && window.navigator.product !== 'ReactNative'; +const isDev = process.env.NODE_ENV !== 'production'; +const isServer = typeof window === 'undefined'; + +// In development and in a React Native environment, use the real implementation +export const useAsyncStorageDevTools = (!isWeb && !isServer && isDev) + ? useDevToolsImpl + : () => null; + \ No newline at end of file diff --git a/packages/async-storage-plugin/rozenite.config.ts b/packages/async-storage-plugin/rozenite.config.ts new file mode 100644 index 00000000..09084a3f --- /dev/null +++ b/packages/async-storage-plugin/rozenite.config.ts @@ -0,0 +1,8 @@ +export default { + panels: [ + { + name: 'AsyncStorage', + source: './src/ui/panel.tsx', + }, + ], +}; diff --git a/packages/async-storage-plugin/src/css-modules.d.ts b/packages/async-storage-plugin/src/css-modules.d.ts new file mode 100644 index 00000000..3d673e2e --- /dev/null +++ b/packages/async-storage-plugin/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/packages/async-storage-plugin/src/hello-world.tsx b/packages/async-storage-plugin/src/hello-world.tsx new file mode 100644 index 00000000..35683809 --- /dev/null +++ b/packages/async-storage-plugin/src/hello-world.tsx @@ -0,0 +1,162 @@ +import { Text, View, StyleSheet, ScrollView, SafeAreaView } from 'react-native'; + +export default function HelloWorldPanel() { + return ( + + + + + 💎 + + Welcome to Rozenite + React Native DevTools Framework + + + + ✨ Features + + + 🔧 + Plugin System + + Extensible architecture for custom dev tools + + + + + Fast & Lightweight + + Optimized for performance and developer experience + + + + 🎨 + Modern UI + + Beautiful, responsive interface built with React Native + + + + 🔌 + Easy Integration + + Simple setup and configuration process + + + + + + + + Built with ❤️ for the React Native community + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f8f9fa', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingBottom: 20, + }, + header: { + alignItems: 'center', + paddingVertical: 40, + paddingHorizontal: 20, + backgroundColor: '#ffffff', + borderBottomWidth: 1, + borderBottomColor: '#e9ecef', + }, + logoContainer: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: '#8232FF', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }, + logo: { + fontSize: 40, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + color: '#1a1a1a', + marginBottom: 8, + textAlign: 'center', + }, + subtitle: { + fontSize: 16, + color: '#666', + textAlign: 'center', + }, + featuresContainer: { + padding: 20, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 16, + }, + featureGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + }, + featureCard: { + width: '48%', + backgroundColor: '#ffffff', + padding: 16, + borderRadius: 12, + marginBottom: 12, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + featureIcon: { + fontSize: 24, + marginBottom: 8, + }, + featureTitle: { + fontSize: 14, + fontWeight: '600', + color: '#1a1a1a', + marginBottom: 4, + }, + featureDescription: { + fontSize: 12, + color: '#666', + lineHeight: 16, + }, + + footer: { + alignItems: 'center', + paddingVertical: 20, + paddingHorizontal: 20, + }, + footerText: { + fontSize: 12, + color: '#999', + textAlign: 'center', + }, +}); diff --git a/packages/async-storage-plugin/src/react-native/async-storage-container.ts b/packages/async-storage-plugin/src/react-native/async-storage-container.ts new file mode 100644 index 00000000..868d07d2 --- /dev/null +++ b/packages/async-storage-plugin/src/react-native/async-storage-container.ts @@ -0,0 +1,204 @@ +// Define a simple event emitter since we can't use nanoevents +type Unsubscribe = () => void; +type Callback = (...args: unknown[]) => void; + +// Simple event emitter implementation +function createEventEmitter>() { + const events = new Map(); + + return { + on(event: K, callback: Callback): Unsubscribe { + if (!events.has(event)) { + events.set(event, []); + } + + const callbacks = events.get(event); + if (callbacks) { + callbacks.push(callback); + } + + return () => { + const callbackList = events.get(event); + if (callbackList) { + const index = callbackList.indexOf(callback); + if (index !== -1) { + callbackList.splice(index, 1); + } + } + }; + }, + + emit(event: K, ...args: unknown[]): void { + const callbacks = events.get(event); + if (callbacks) { + [...callbacks].forEach(callback => callback(...args)); + } + } + }; +} + +import { AsyncStorageEntry } from '../shared/types'; + +export type AsyncStorageContainerEvents = { + 'value-changed': (key: string, value: string) => void; + 'value-removed': (key: string) => void; + 'storage-cleared': () => void; +}; + +export type AsyncStorageAPI = { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; + getAllKeys: () => Promise; + multiGet: (keys: readonly string[]) => Promise; + clear: () => Promise; +}; + +export type AsyncStorageContainer = { + getAllKeys: () => Promise; + getEntries: (keys?: string[]) => Promise; + setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; + clear: () => Promise; + on: ( + event: T, + listener: AsyncStorageContainerEvents[T] + ) => Unsubscribe; +}; + +// Detect and parse the value into a specific type +const detectValueType = (value: string): {type: AsyncStorageEntry['type']; parsedValue?: unknown} => { + if (value === null || value === undefined) { + return { type: 'null' }; + } + + try { + // Try to parse JSON + const parsed = JSON.parse(value); + + if (parsed === null) { + return { type: 'null', parsedValue: null }; + } + + if (Array.isArray(parsed)) { + return { type: 'array', parsedValue: parsed }; + } + + if (typeof parsed === 'object') { + return { type: 'object', parsedValue: parsed }; + } + + if (typeof parsed === 'number') { + return { type: 'number', parsedValue: parsed }; + } + + if (typeof parsed === 'boolean') { + return { type: 'boolean', parsedValue: parsed }; + } + + return { type: 'string', parsedValue: parsed }; + } catch { + // If JSON parsing fails, it's a plain string + return { type: 'string', parsedValue: value }; + } +}; + +export const getAsyncStorageContainer = ( + asyncStorage: AsyncStorageAPI +): AsyncStorageContainer => { + const eventEmitter = createEventEmitter(); + + // Override setItem to emit events when values change + const originalSetItem = asyncStorage.setItem; + asyncStorage.setItem = async (key: string, value: string) => { + await originalSetItem.call(asyncStorage, key, value); + eventEmitter.emit('value-changed', key, value); + }; + + // Override removeItem to emit events when values are removed + const originalRemoveItem = asyncStorage.removeItem; + asyncStorage.removeItem = async (key: string) => { + await originalRemoveItem.call(asyncStorage, key); + eventEmitter.emit('value-removed', key); + }; + + // Override clear to emit events when storage is cleared + const originalClear = asyncStorage.clear; + asyncStorage.clear = async () => { + await originalClear.call(asyncStorage); + eventEmitter.emit('storage-cleared'); + }; + + return { + getAllKeys: async () => { + try { + const keys = await asyncStorage.getAllKeys(); + // Convert readonly string[] to string[] + return [...keys]; + } catch (error) { + console.error('AsyncStorage getAllKeys error:', error); + return []; + } + }, + + getEntries: async (keys?: string[]) => { + try { + let keysToGet: readonly string[]; + + if (!keys || keys.length === 0) { + keysToGet = await asyncStorage.getAllKeys(); + } else { + keysToGet = keys; + } + + const entriesPairs = await asyncStorage.multiGet(keysToGet); + + return entriesPairs + .filter((pair): pair is [string, string] => pair[1] !== null) + .map(([key, value]) => { + const { type, parsedValue } = detectValueType(value); + return { + key, + value, + type, + parsedValue + }; + }); + } catch (error) { + console.error('AsyncStorage getEntries error:', error); + return []; + } + }, + + setItem: async (key: string, value: string) => { + try { + await asyncStorage.setItem(key, value); + } catch (error) { + console.error('AsyncStorage setItem error:', error); + throw error; + } + }, + + removeItem: async (key: string) => { + try { + await asyncStorage.removeItem(key); + } catch (error) { + console.error('AsyncStorage removeItem error:', error); + throw error; + } + }, + + clear: async () => { + try { + await asyncStorage.clear(); + } catch (error) { + console.error('AsyncStorage clear error:', error); + throw error; + } + }, + + on: (event, listener) => { + return eventEmitter.on(event, listener as Callback); + }, + }; +}; diff --git a/packages/async-storage-plugin/src/react-native/useAsyncStorageDevTools.ts b/packages/async-storage-plugin/src/react-native/useAsyncStorageDevTools.ts new file mode 100644 index 00000000..689a8955 --- /dev/null +++ b/packages/async-storage-plugin/src/react-native/useAsyncStorageDevTools.ts @@ -0,0 +1,88 @@ +import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge'; +import { useEffect } from 'react'; +import { AsyncStorageEventMap } from '../shared/messaging'; +import { getAsyncStorageContainer, AsyncStorageAPI } from './async-storage-container'; + +// Accept AsyncStorage instance as a parameter +export const useAsyncStorageDevTools = ( + asyncStorageInstance: AsyncStorageAPI +) => { + const client = useRozeniteDevToolsClient({ + pluginId: '@rozenite/async-storage-plugin', + }); + + useEffect(() => { + if (!client || !asyncStorageInstance) { + return; + } + + const container = getAsyncStorageContainer(asyncStorageInstance); + + // Setup event listeners for changes in AsyncStorage + const valueChangedSubscription = container.on('value-changed', async (key, value) => { + // When a value changes, notify the DevTools panel + client.send('host-entry-updated', { key, value }); + + // Also update all keys list + const allKeys = await container.getAllKeys(); + client.send('host-all-keys', allKeys); + }); + + const valueRemovedSubscription = container.on('value-removed', async () => { + // When a value is removed, update the keys list + const allKeys = await container.getAllKeys(); + client.send('host-all-keys', allKeys); + }); + + const storageClearedSubscription = container.on('storage-cleared', async () => { + // When storage is cleared, update the keys list + const allKeys = await container.getAllKeys(); + client.send('host-all-keys', allKeys); + }); + + // Handle DevTools panel requests + const getAllKeysSubscription = client.onMessage('guest-get-all-keys', async () => { + const allKeys = await container.getAllKeys(); + client.send('host-all-keys', allKeys); + }); + + const getEntriesSubscription = client.onMessage('guest-get-entries', async (event) => { + const entries = await container.getEntries(event.keys); + client.send('host-entries', entries); + }); + + const updateEntrySubscription = client.onMessage('guest-update-entry', async (event) => { + await container.setItem(event.key, event.value); + // The value-changed subscription will handle notifying DevTools + }); + + const removeEntrySubscription = client.onMessage('guest-remove-entry', async (event) => { + await container.removeItem(event.key); + // The value-removed subscription will handle notifying DevTools + }); + + const clearAllSubscription = client.onMessage('guest-clear-all', async () => { + await container.clear(); + // The storage-cleared subscription will handle notifying DevTools + }); + + // Send initial keys list + container.getAllKeys().then((keys) => { + client.send('host-all-keys', keys); + }); + + // Cleanup function + return () => { + valueChangedSubscription(); + valueRemovedSubscription(); + storageClearedSubscription(); + getAllKeysSubscription.remove(); + getEntriesSubscription.remove(); + updateEntrySubscription.remove(); + removeEntrySubscription.remove(); + clearAllSubscription.remove(); + }; + }, [client, asyncStorageInstance]); + + return client; +}; diff --git a/packages/async-storage-plugin/src/shared/messaging.ts b/packages/async-storage-plugin/src/shared/messaging.ts new file mode 100644 index 00000000..07f9d81c --- /dev/null +++ b/packages/async-storage-plugin/src/shared/messaging.ts @@ -0,0 +1,25 @@ +import { AsyncStorageEntry } from './types'; + +export type AsyncStorageEventMap = { + // Host (React Native) to DevTools events + 'host-all-keys': string[]; + 'host-entries': AsyncStorageEntry[]; + 'host-entry-updated': { + key: string; + value: string; + }; + + // DevTools to Host (React Native) events + 'guest-get-all-keys': unknown; + 'guest-get-entries': { + keys?: string[]; + }; + 'guest-update-entry': { + key: string; + value: string; + }; + 'guest-remove-entry': { + key: string; + }; + 'guest-clear-all': unknown; +}; diff --git a/packages/async-storage-plugin/src/shared/types.ts b/packages/async-storage-plugin/src/shared/types.ts new file mode 100644 index 00000000..f74c58c9 --- /dev/null +++ b/packages/async-storage-plugin/src/shared/types.ts @@ -0,0 +1,9 @@ +export type AsyncStorageEntry = { + key: string; + value: string; + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' | 'unknown'; + parsedValue?: any; +}; + +export type AsyncStorageEntryType = AsyncStorageEntry['type']; +export type AsyncStorageEntryValue = AsyncStorageEntry['value']; diff --git a/packages/async-storage-plugin/src/ui/add-entry-modal.tsx b/packages/async-storage-plugin/src/ui/add-entry-modal.tsx new file mode 100644 index 00000000..73508ba7 --- /dev/null +++ b/packages/async-storage-plugin/src/ui/add-entry-modal.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import './modal.css'; + +interface AddEntryModalProps { + isOpen: boolean; + onClose: () => void; + onAdd: (key: string, value: string) => void; + existingKeys: string[]; +} + +export function AddEntryModal({ isOpen, onClose, onAdd, existingKeys }: AddEntryModalProps) { + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + const [keyError, setKeyError] = useState(''); + const [valueError, setValueError] = useState(''); + + if (!isOpen) return null; + + const validateKey = (key: string) => { + if (!key.trim()) { + setKeyError('Key cannot be empty'); + return false; + } + + if (existingKeys.includes(key)) { + setKeyError('Key already exists'); + return false; + } + + setKeyError(''); + return true; + }; + + const validateValue = (value: string) => { + // Value can be empty, but we should warn + if (!value.trim()) { + setValueError('Warning: Empty value will be stored as empty string'); + // Still return true as this is just a warning + return true; + } + + setValueError(''); + return true; + }; + + const handleAdd = () => { + const isKeyValid = validateKey(key); + const isValueValid = validateValue(value); + + if (isKeyValid && isValueValid) { + onAdd(key, value); + setKey(''); + setValue(''); + onClose(); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Add New Entry

+ +
+ +
+
+ + { + setKey(e.target.value); + validateKey(e.target.value); + }} + placeholder="Enter key name" + /> + {keyError &&
{keyError}
} +
+ +
+ +