rn-backstage 1.4.0 → 1.4.2

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.
@@ -0,0 +1,643 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ Alert,
4
+ FlatList,
5
+ KeyboardAvoidingView,
6
+ Modal,
7
+ Platform,
8
+ SafeAreaView,
9
+ ScrollView,
10
+ StyleSheet,
11
+ Text,
12
+ TextInput,
13
+ TouchableOpacity,
14
+ View,
15
+ } from 'react-native'
16
+ import { MonospaceFont } from '../constants'
17
+ import { useBackstageTheme } from '../ThemeContext'
18
+ import { JsonTreeView } from './JsonTreeView'
19
+ import type { StorageAdapter, BackstageTheme } from '../types'
20
+
21
+ // ─── Types ───────────────────────────────────────────────────────────────────
22
+
23
+ interface StorageTabProps {
24
+ adapter: StorageAdapter
25
+ jsonMaxDepth?: number
26
+ }
27
+
28
+ interface StorageEntry {
29
+ key: string
30
+ value: string
31
+ }
32
+
33
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
34
+
35
+ function tryParseJSON(text: string): unknown | null {
36
+ try {
37
+ const parsed = JSON.parse(text)
38
+ return typeof parsed === 'object' && parsed !== null ? parsed : null
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ function truncateValue(value: string, max = 80): string {
45
+ if (value.length <= max) return value
46
+ return value.substring(0, max) + '…'
47
+ }
48
+
49
+ // ─── Editor Modal ────────────────────────────────────────────────────────────
50
+
51
+ interface EditorModalProps {
52
+ visible: boolean
53
+ title: string
54
+ storageKey: string
55
+ storageValue: string
56
+ isNewEntry: boolean
57
+ onChangeKey: (text: string) => void
58
+ onChangeValue: (text: string) => void
59
+ onSave: () => void
60
+ onCancel: () => void
61
+ theme: BackstageTheme
62
+ }
63
+
64
+ const EditorModal: React.FC<EditorModalProps> = ({
65
+ visible,
66
+ title,
67
+ storageKey,
68
+ storageValue,
69
+ isNewEntry,
70
+ onChangeKey,
71
+ onChangeValue,
72
+ onSave,
73
+ onCancel,
74
+ theme,
75
+ }) => {
76
+ const s = useMemo(() => createStyles(theme), [theme])
77
+
78
+ return (
79
+ <Modal visible={visible} animationType="slide" transparent={false} onRequestClose={onCancel}>
80
+ <SafeAreaView style={s.modalContainer}>
81
+ {/* Header */}
82
+ <View style={s.modalHeader}>
83
+ <TouchableOpacity onPress={onCancel} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
84
+ <Text style={s.modalCancelText}>Cancel</Text>
85
+ </TouchableOpacity>
86
+ <Text style={s.modalTitle}>{title}</Text>
87
+ <TouchableOpacity onPress={onSave} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
88
+ <Text style={s.modalSaveText}>Save</Text>
89
+ </TouchableOpacity>
90
+ </View>
91
+
92
+ {/* Form */}
93
+ <KeyboardAvoidingView
94
+ style={s.modalBody}
95
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
96
+ >
97
+ <ScrollView
98
+ contentContainerStyle={s.modalBodyContent}
99
+ keyboardShouldPersistTaps="handled"
100
+ >
101
+ <Text style={s.modalLabel}>Key</Text>
102
+ <TextInput
103
+ style={[s.modalInput, !isNewEntry && s.modalInputDisabled]}
104
+ value={storageKey}
105
+ onChangeText={onChangeKey}
106
+ editable={isNewEntry}
107
+ autoCapitalize="none"
108
+ autoCorrect={false}
109
+ placeholderTextColor={theme.textMuted}
110
+ placeholder="Storage key"
111
+ />
112
+
113
+ <Text style={[s.modalLabel, { marginTop: 16 }]}>Value</Text>
114
+ <TextInput
115
+ style={[s.modalInput, s.modalValueInput]}
116
+ value={storageValue}
117
+ onChangeText={onChangeValue}
118
+ multiline
119
+ autoFocus={!isNewEntry}
120
+ textAlignVertical="top"
121
+ placeholderTextColor={theme.textMuted}
122
+ placeholder="Value"
123
+ />
124
+ </ScrollView>
125
+ </KeyboardAvoidingView>
126
+ </SafeAreaView>
127
+ </Modal>
128
+ )
129
+ }
130
+
131
+ // ─── Component ───────────────────────────────────────────────────────────────
132
+
133
+ export const StorageTab: React.FC<StorageTabProps> = ({ adapter, jsonMaxDepth }) => {
134
+ const theme = useBackstageTheme()
135
+ const s = useMemo(() => createStyles(theme), [theme])
136
+
137
+ const [entries, setEntries] = useState<StorageEntry[]>([])
138
+ const [loading, setLoading] = useState(true)
139
+ const [searchText, setSearchText] = useState('')
140
+
141
+ // Editor modal state
142
+ const [editorVisible, setEditorVisible] = useState(false)
143
+ const [editorIsNew, setEditorIsNew] = useState(false)
144
+ const [editorKey, setEditorKey] = useState('')
145
+ const [editorValue, setEditorValue] = useState('')
146
+
147
+ // Expanded entries (for JSON preview)
148
+ const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set())
149
+
150
+ // ─── Load Entries ────────────────────────────────────────
151
+ const loadEntries = useCallback(async () => {
152
+ setLoading(true)
153
+ try {
154
+ const keys = await adapter.getAllKeys()
155
+ const sorted = [...keys].sort((a, b) => a.localeCompare(b))
156
+ const results: StorageEntry[] = []
157
+ for (const key of sorted) {
158
+ const value = await adapter.getItem(key)
159
+ results.push({ key, value: value ?? '' })
160
+ }
161
+ setEntries(results)
162
+ } catch (err) {
163
+ console.error('[Backstage] StorageTab load error:', err)
164
+ } finally {
165
+ setLoading(false)
166
+ }
167
+ }, [adapter])
168
+
169
+ useEffect(() => {
170
+ loadEntries()
171
+ }, [loadEntries])
172
+
173
+ // ─── Filter ──────────────────────────────────────────────
174
+ const filteredEntries = useMemo(() => {
175
+ if (!searchText.trim()) return entries
176
+ const query = searchText.toLowerCase()
177
+ return entries.filter(
178
+ e => e.key.toLowerCase().includes(query) || e.value.toLowerCase().includes(query),
179
+ )
180
+ }, [entries, searchText])
181
+
182
+ // ─── Toggle Expand ───────────────────────────────────────
183
+ const toggleExpand = useCallback((key: string) => {
184
+ setExpandedKeys(prev => {
185
+ const next = new Set(prev)
186
+ if (next.has(key)) {
187
+ next.delete(key)
188
+ } else {
189
+ next.add(key)
190
+ }
191
+ return next
192
+ })
193
+ }, [])
194
+
195
+ // ─── Editor ──────────────────────────────────────────────
196
+ const openEditor = useCallback((entry: StorageEntry) => {
197
+ setEditorIsNew(false)
198
+ setEditorKey(entry.key)
199
+ setEditorValue(entry.value)
200
+ setEditorVisible(true)
201
+ }, [])
202
+
203
+ const openAddEditor = useCallback(() => {
204
+ setEditorIsNew(true)
205
+ setEditorKey('')
206
+ setEditorValue('')
207
+ setEditorVisible(true)
208
+ }, [])
209
+
210
+ const closeEditor = useCallback(() => {
211
+ setEditorVisible(false)
212
+ setEditorKey('')
213
+ setEditorValue('')
214
+ }, [])
215
+
216
+ const saveEditor = useCallback(async () => {
217
+ const trimmedKey = editorKey.trim()
218
+ if (!trimmedKey) {
219
+ Alert.alert('Error', 'Key cannot be empty')
220
+ return
221
+ }
222
+ try {
223
+ await adapter.setItem(trimmedKey, editorValue)
224
+ setEntries(prev => {
225
+ const exists = prev.findIndex(e => e.key === trimmedKey)
226
+ if (exists >= 0) {
227
+ const updated = [...prev]
228
+ updated[exists] = { key: trimmedKey, value: editorValue }
229
+ return updated
230
+ }
231
+ return [...prev, { key: trimmedKey, value: editorValue }].sort((a, b) =>
232
+ a.key.localeCompare(b.key),
233
+ )
234
+ })
235
+ closeEditor()
236
+ } catch (err) {
237
+ Alert.alert('Error', `Failed to save: ${err}`)
238
+ }
239
+ }, [adapter, editorKey, editorValue, closeEditor])
240
+
241
+ // ─── Delete ──────────────────────────────────────────────
242
+ const confirmDelete = useCallback(
243
+ (key: string) => {
244
+ Alert.alert('Delete Entry', `Remove "${key}" from storage?`, [
245
+ { text: 'Cancel', style: 'cancel' },
246
+ {
247
+ text: 'Delete',
248
+ style: 'destructive',
249
+ onPress: async () => {
250
+ try {
251
+ await adapter.removeItem(key)
252
+ setEntries(prev => prev.filter(e => e.key !== key))
253
+ setExpandedKeys(prev => {
254
+ const next = new Set(prev)
255
+ next.delete(key)
256
+ return next
257
+ })
258
+ } catch (err) {
259
+ Alert.alert('Error', `Failed to delete: ${err}`)
260
+ }
261
+ },
262
+ },
263
+ ])
264
+ },
265
+ [adapter],
266
+ )
267
+
268
+ // ─── Render Entry ────────────────────────────────────────
269
+ const renderItem = useCallback(
270
+ ({ item }: { item: StorageEntry }) => {
271
+ const isExpanded = expandedKeys.has(item.key)
272
+ const jsonValue = tryParseJSON(item.value)
273
+ const isJSON = jsonValue !== null
274
+
275
+ return (
276
+ <View style={s.entryContainer}>
277
+ {/* Key row */}
278
+ <View style={s.entryHeader}>
279
+ <TouchableOpacity
280
+ style={s.entryKeyContainer}
281
+ onPress={() => toggleExpand(item.key)}
282
+ activeOpacity={0.7}
283
+ >
284
+ <Text style={s.entryChevron}>{isExpanded ? '▼' : '▶'}</Text>
285
+ <Text style={s.entryKey} numberOfLines={1}>
286
+ {item.key}
287
+ </Text>
288
+ {isJSON && <Text style={s.jsonBadge}>JSON</Text>}
289
+ </TouchableOpacity>
290
+ <View style={s.entryActions}>
291
+ <TouchableOpacity
292
+ onPress={() => openEditor(item)}
293
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
294
+ >
295
+ <Text style={s.actionIcon}>✎</Text>
296
+ </TouchableOpacity>
297
+ <TouchableOpacity
298
+ onPress={() => confirmDelete(item.key)}
299
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
300
+ >
301
+ <Text style={[s.actionIcon, s.deleteIcon]}>✕</Text>
302
+ </TouchableOpacity>
303
+ </View>
304
+ </View>
305
+
306
+ {/* Value */}
307
+ {isExpanded ? (
308
+ <View style={s.expandedValue}>
309
+ {isJSON ? (
310
+ <View style={s.jsonContainer}>
311
+ <JsonTreeView data={jsonValue} hideRoot maxDepth={jsonMaxDepth} />
312
+ </View>
313
+ ) : (
314
+ <Text style={s.valueText} selectable>
315
+ {item.value || '(empty)'}
316
+ </Text>
317
+ )}
318
+ </View>
319
+ ) : (
320
+ <TouchableOpacity onPress={() => toggleExpand(item.key)} activeOpacity={0.7}>
321
+ <Text style={s.valuePreview} numberOfLines={1}>
322
+ {truncateValue(item.value) || '(empty)'}
323
+ </Text>
324
+ </TouchableOpacity>
325
+ )}
326
+ </View>
327
+ )
328
+ },
329
+ [
330
+ expandedKeys,
331
+ jsonMaxDepth,
332
+ s,
333
+ toggleExpand,
334
+ openEditor,
335
+ confirmDelete,
336
+ ],
337
+ )
338
+
339
+ const keyExtractor = useCallback((item: StorageEntry) => item.key, [])
340
+
341
+ return (
342
+ <View style={s.container}>
343
+ {/* Search + Add bar */}
344
+ <View style={s.searchContainer}>
345
+ <View style={s.searchInputWrapper}>
346
+ <Text style={s.searchIcon}>⌕</Text>
347
+ <TextInput
348
+ style={s.searchInput}
349
+ placeholder="Filter by key or value..."
350
+ placeholderTextColor={theme.textMuted}
351
+ value={searchText}
352
+ onChangeText={setSearchText}
353
+ autoCapitalize="none"
354
+ autoCorrect={false}
355
+ clearButtonMode="while-editing"
356
+ returnKeyType="search"
357
+ />
358
+ </View>
359
+ <TouchableOpacity
360
+ style={s.addButton}
361
+ onPress={openAddEditor}
362
+ activeOpacity={0.7}
363
+ >
364
+ <Text style={s.addButtonText}>+</Text>
365
+ </TouchableOpacity>
366
+ </View>
367
+
368
+ {/* Stats */}
369
+ <View style={s.statsBar}>
370
+ <Text style={s.statText}>
371
+ {filteredEntries.length === entries.length
372
+ ? `${entries.length} entr${entries.length !== 1 ? 'ies' : 'y'}`
373
+ : `${filteredEntries.length} of ${entries.length}`}
374
+ </Text>
375
+ <Text style={s.pullHint}>↓ pull to refresh</Text>
376
+ </View>
377
+
378
+ {/* List */}
379
+ <FlatList
380
+ data={filteredEntries}
381
+ renderItem={renderItem}
382
+ keyExtractor={keyExtractor}
383
+ refreshing={loading}
384
+ onRefresh={loadEntries}
385
+ style={s.list}
386
+ contentContainerStyle={filteredEntries.length === 0 ? s.emptyContainer : undefined}
387
+ ListEmptyComponent={
388
+ <View style={s.emptyState}>
389
+ <Text style={s.emptyIcon}>🗄</Text>
390
+ <Text style={s.emptyTitle}>{loading ? 'Loading…' : 'No entries'}</Text>
391
+ <Text style={s.emptySubtitle}>
392
+ {loading ? 'Reading storage keys' : 'Storage is empty or no entries match'}
393
+ </Text>
394
+ </View>
395
+ }
396
+ maxToRenderPerBatch={20}
397
+ windowSize={10}
398
+ initialNumToRender={20}
399
+ removeClippedSubviews={true}
400
+ />
401
+
402
+ {/* Editor Modal */}
403
+ <EditorModal
404
+ visible={editorVisible}
405
+ title={editorIsNew ? 'Add Entry' : `Edit "${editorKey}"`}
406
+ storageKey={editorKey}
407
+ storageValue={editorValue}
408
+ isNewEntry={editorIsNew}
409
+ onChangeKey={setEditorKey}
410
+ onChangeValue={setEditorValue}
411
+ onSave={saveEditor}
412
+ onCancel={closeEditor}
413
+ theme={theme}
414
+ />
415
+ </View>
416
+ )
417
+ }
418
+
419
+ // ─── Styles ──────────────────────────────────────────────────────────────────
420
+
421
+ const createStyles = (t: BackstageTheme) =>
422
+ StyleSheet.create({
423
+ container: { flex: 1 },
424
+
425
+ // ── Search ─────────────────────
426
+ searchContainer: {
427
+ flexDirection: 'row',
428
+ alignItems: 'center',
429
+ paddingHorizontal: 14,
430
+ paddingVertical: 10,
431
+ gap: 10,
432
+ borderBottomWidth: 1,
433
+ borderBottomColor: t.border,
434
+ },
435
+ searchInputWrapper: {
436
+ flex: 1,
437
+ flexDirection: 'row',
438
+ alignItems: 'center',
439
+ backgroundColor: t.surfaceElevated,
440
+ borderRadius: 10,
441
+ borderWidth: 1,
442
+ borderColor: t.border,
443
+ paddingHorizontal: 12,
444
+ height: 38,
445
+ },
446
+ searchIcon: { fontSize: 16, color: t.textMuted, marginRight: 8 },
447
+ searchInput: {
448
+ flex: 1,
449
+ fontFamily: MonospaceFont,
450
+ fontSize: 13,
451
+ color: t.text,
452
+ padding: 0,
453
+ },
454
+ addButton: {
455
+ width: 38,
456
+ height: 38,
457
+ borderRadius: 10,
458
+ backgroundColor: t.accentDim,
459
+ borderWidth: 1,
460
+ borderColor: t.accent,
461
+ alignItems: 'center',
462
+ justifyContent: 'center',
463
+ },
464
+ addButtonText: { fontSize: 18, color: t.accent, fontWeight: '700' },
465
+
466
+ // ── Stats ──────────────────────
467
+ statsBar: {
468
+ flexDirection: 'row',
469
+ justifyContent: 'space-between',
470
+ alignItems: 'center',
471
+ paddingHorizontal: 16,
472
+ paddingVertical: 6,
473
+ backgroundColor: t.surface,
474
+ },
475
+ statText: { fontFamily: MonospaceFont, fontSize: 11, color: t.textMuted },
476
+ pullHint: {
477
+ fontFamily: MonospaceFont,
478
+ fontSize: 10,
479
+ color: t.textMuted,
480
+ fontStyle: 'italic',
481
+ },
482
+
483
+ // ── List ───────────────────────
484
+ list: { flex: 1 },
485
+ emptyContainer: { flex: 1, justifyContent: 'center' },
486
+ emptyState: { alignItems: 'center', justifyContent: 'center', padding: 40 },
487
+ emptyIcon: { fontSize: 48, marginBottom: 16, opacity: 0.5 },
488
+ emptyTitle: {
489
+ fontFamily: MonospaceFont,
490
+ fontSize: 16,
491
+ fontWeight: '700',
492
+ color: t.textSecondary,
493
+ marginBottom: 8,
494
+ },
495
+ emptySubtitle: {
496
+ fontFamily: MonospaceFont,
497
+ fontSize: 13,
498
+ color: t.textMuted,
499
+ textAlign: 'center',
500
+ },
501
+
502
+ // ── Entry ──────────────────────
503
+ entryContainer: {
504
+ paddingHorizontal: 14,
505
+ paddingVertical: 10,
506
+ borderBottomWidth: 1,
507
+ borderBottomColor: t.border,
508
+ },
509
+ entryHeader: {
510
+ flexDirection: 'row',
511
+ alignItems: 'center',
512
+ justifyContent: 'space-between',
513
+ marginBottom: 4,
514
+ },
515
+ entryKeyContainer: {
516
+ flex: 1,
517
+ flexDirection: 'row',
518
+ alignItems: 'center',
519
+ },
520
+ entryChevron: {
521
+ fontFamily: MonospaceFont,
522
+ fontSize: 10,
523
+ color: t.textMuted,
524
+ marginRight: 6,
525
+ width: 12,
526
+ },
527
+ entryKey: {
528
+ fontFamily: MonospaceFont,
529
+ fontSize: 13,
530
+ fontWeight: '700',
531
+ color: t.text,
532
+ flex: 1,
533
+ },
534
+ jsonBadge: {
535
+ fontFamily: MonospaceFont,
536
+ fontSize: 9,
537
+ fontWeight: '800',
538
+ color: t.info,
539
+ backgroundColor: t.infoDim,
540
+ borderRadius: 3,
541
+ paddingHorizontal: 5,
542
+ paddingVertical: 1,
543
+ marginLeft: 6,
544
+ overflow: 'hidden',
545
+ },
546
+ entryActions: { flexDirection: 'row', alignItems: 'center', gap: 12, marginLeft: 8 },
547
+ actionIcon: {
548
+ fontSize: 16,
549
+ color: t.textMuted,
550
+ },
551
+ deleteIcon: { color: t.error },
552
+
553
+ // ── Value Preview ──────────────
554
+ valuePreview: {
555
+ fontFamily: MonospaceFont,
556
+ fontSize: 12,
557
+ color: t.textSecondary,
558
+ paddingLeft: 18,
559
+ lineHeight: 18,
560
+ },
561
+ expandedValue: { marginTop: 4, paddingLeft: 18 },
562
+ valueText: {
563
+ fontFamily: MonospaceFont,
564
+ fontSize: 12,
565
+ color: t.text,
566
+ lineHeight: 18,
567
+ },
568
+ jsonContainer: {
569
+ backgroundColor: t.surfaceElevated,
570
+ borderRadius: 8,
571
+ borderWidth: 1,
572
+ borderColor: t.border,
573
+ padding: 8,
574
+ },
575
+
576
+ // ── Editor Modal ───────────────
577
+ modalContainer: {
578
+ flex: 1,
579
+ backgroundColor: t.background,
580
+ },
581
+ modalHeader: {
582
+ flexDirection: 'row',
583
+ alignItems: 'center',
584
+ justifyContent: 'space-between',
585
+ paddingHorizontal: 16,
586
+ paddingVertical: 12,
587
+ borderBottomWidth: 1,
588
+ borderBottomColor: t.border,
589
+ },
590
+ modalTitle: {
591
+ fontFamily: MonospaceFont,
592
+ fontSize: 16,
593
+ fontWeight: '700',
594
+ color: t.text,
595
+ flex: 1,
596
+ textAlign: 'center',
597
+ },
598
+ modalCancelText: {
599
+ fontFamily: MonospaceFont,
600
+ fontSize: 14,
601
+ color: t.textSecondary,
602
+ fontWeight: '600',
603
+ },
604
+ modalSaveText: {
605
+ fontFamily: MonospaceFont,
606
+ fontSize: 14,
607
+ color: t.accent,
608
+ fontWeight: '700',
609
+ },
610
+ modalBody: {
611
+ flex: 1,
612
+ },
613
+ modalBodyContent: {
614
+ padding: 16,
615
+ },
616
+ modalLabel: {
617
+ fontFamily: MonospaceFont,
618
+ fontSize: 12,
619
+ fontWeight: '700',
620
+ color: t.textMuted,
621
+ textTransform: 'uppercase',
622
+ letterSpacing: 0.5,
623
+ marginBottom: 6,
624
+ },
625
+ modalInput: {
626
+ fontFamily: MonospaceFont,
627
+ fontSize: 13,
628
+ color: t.text,
629
+ backgroundColor: t.surfaceElevated,
630
+ borderRadius: 8,
631
+ borderWidth: 1,
632
+ borderColor: t.border,
633
+ paddingHorizontal: 12,
634
+ paddingVertical: 10,
635
+ },
636
+ modalInputDisabled: {
637
+ opacity: 0.5,
638
+ },
639
+ modalValueInput: {
640
+ minHeight: 160,
641
+ lineHeight: 20,
642
+ },
643
+ })