@pyric/ui 0.1.0-alpha.11 → 0.1.0-alpha.13

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 (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
@@ -0,0 +1,113 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import { ConfirmDialog } from './ConfirmDialog.js';
11
+
12
+ export interface ConfirmOptions {
13
+ title: string;
14
+ body?: ReactNode;
15
+ destructive?: boolean;
16
+ confirmLabel?: string;
17
+ cancelLabel?: string;
18
+ }
19
+
20
+ export type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
21
+
22
+ const Ctx = createContext<ConfirmFn | null>(null);
23
+
24
+ /**
25
+ * Imperative confirmation hook. Returns a function that opens the
26
+ * provider-managed dialog and resolves to `true` on confirm, `false`
27
+ * on cancel/dismiss.
28
+ *
29
+ * const confirm = useConfirm();
30
+ * const ok = await confirm({ title: 'Delete?', destructive: true });
31
+ * if (ok) await deleteDoc(ref);
32
+ *
33
+ * Requires a `<ConfirmProvider>` ancestor.
34
+ */
35
+ export function useConfirm(): ConfirmFn {
36
+ const fn = useContext(Ctx);
37
+ if (!fn) {
38
+ throw new Error('useConfirm: missing <ConfirmProvider> ancestor');
39
+ }
40
+ return fn;
41
+ }
42
+
43
+ interface PendingConfirm {
44
+ options: ConfirmOptions;
45
+ resolve: (ok: boolean) => void;
46
+ }
47
+
48
+ export interface ConfirmProviderProps {
49
+ children: ReactNode;
50
+ /** Forwarded to the rendered `<ConfirmDialog>`. */
51
+ dialogClassName?: string;
52
+ }
53
+
54
+ /**
55
+ * Mounts a single managed `<ConfirmDialog>` and exposes the
56
+ * imperative `confirm()` API through context. Scoped — multiple
57
+ * providers can coexist in different subtrees, each managing its
58
+ * own dialog.
59
+ */
60
+ export function ConfirmProvider({ children, dialogClassName }: ConfirmProviderProps) {
61
+ const [pending, setPending] = useState<PendingConfirm | null>(null);
62
+ // Track which resolution path fired so the close handler doesn't
63
+ // double-resolve when both onConfirm + onOpenChange(false) trigger.
64
+ const resolvedRef = useRef(false);
65
+
66
+ const confirm = useCallback<ConfirmFn>((options) => {
67
+ return new Promise<boolean>((resolve) => {
68
+ resolvedRef.current = false;
69
+ setPending({ options, resolve });
70
+ });
71
+ }, []);
72
+
73
+ const handleConfirm = useCallback(() => {
74
+ if (!pending) return;
75
+ if (resolvedRef.current) return;
76
+ resolvedRef.current = true;
77
+ pending.resolve(true);
78
+ setPending(null);
79
+ }, [pending]);
80
+
81
+ const handleOpenChange = useCallback(
82
+ (open: boolean) => {
83
+ if (open) return;
84
+ if (!pending) return;
85
+ if (resolvedRef.current) return;
86
+ resolvedRef.current = true;
87
+ pending.resolve(false);
88
+ setPending(null);
89
+ },
90
+ [pending],
91
+ );
92
+
93
+ const ctxValue = useMemo(() => confirm, [confirm]);
94
+
95
+ return (
96
+ <Ctx.Provider value={ctxValue}>
97
+ {children}
98
+ {pending ? (
99
+ <ConfirmDialog
100
+ open
101
+ onOpenChange={handleOpenChange}
102
+ title={pending.options.title}
103
+ body={pending.options.body}
104
+ destructive={pending.options.destructive}
105
+ confirmLabel={pending.options.confirmLabel}
106
+ cancelLabel={pending.options.cancelLabel}
107
+ onConfirm={handleConfirm}
108
+ className={dialogClassName}
109
+ />
110
+ ) : null}
111
+ </Ctx.Provider>
112
+ );
113
+ }
@@ -0,0 +1,135 @@
1
+ import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
2
+ import { parseRtdbPathInput, rtdbCrumbs } from '../pathInput.js';
3
+ import { normalizeRtdbPath } from '../values.js';
4
+
5
+ export interface RtdbPathBarProps {
6
+ /** Current absolute database path (`'/'` for root). */
7
+ path: string;
8
+ /** Fired with the parsed absolute path on crumb click or input submit. */
9
+ onNavigate: (path: string) => void;
10
+ /** Root crumb label — the database/instance identity (e.g. the sandbox
11
+ * slug). Default `'/'`. */
12
+ rootLabel?: ReactNode;
13
+ /** Text shown before the input while editing (the non-editable URL part). */
14
+ inputPrefix?: string;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * The editable path bar of the RTDB viewer — the interaction form of the
20
+ * Firebase console / firebase-tools-ui database URL bar (clean-room
21
+ * adaptation): in DISPLAY mode the path renders as clickable crumbs
22
+ * (root → … → current) plus an edit affordance; EDIT mode swaps in a text
23
+ * input seeded with the current path — Enter navigates, Escape or blur
24
+ * cancels. Pasted full URLs and missing leading slashes are tolerated
25
+ * (`parseRtdbPathInput`).
26
+ *
27
+ * Headless. Consumers style via:
28
+ * - `[data-pyric-ui="rtdb-path-bar"]` — the root (`data-rtdb-editing` when editing)
29
+ * - `[data-rtdb-crumb]` / `[data-rtdb-crumb-root]` / `[data-pyric-current]`
30
+ * - `[data-rtdb-crumb-separator]`
31
+ * - `[data-rtdb-path-edit]` — the edit button
32
+ * - `[data-rtdb-path-form]`, `[data-rtdb-path-prefix]`, `[data-rtdb-path-input]`
33
+ */
34
+ export function RtdbPathBar({
35
+ path,
36
+ onNavigate,
37
+ rootLabel = '/',
38
+ inputPrefix,
39
+ className,
40
+ }: RtdbPathBarProps) {
41
+ const normalized = normalizeRtdbPath(path);
42
+ const [editing, setEditing] = useState(false);
43
+ // UNCONTROLLED input, read at submit: the input mounts fresh per edit
44
+ // session (display mode unmounts it) seeded with the current path, so a
45
+ // cancelled draft never leaks into the next session.
46
+ const inputRef = useRef<HTMLInputElement>(null);
47
+
48
+ useEffect(() => {
49
+ if (editing) inputRef.current?.select();
50
+ }, [editing]);
51
+
52
+ const submit = (e: FormEvent) => {
53
+ e.preventDefault();
54
+ onNavigate(parseRtdbPathInput(inputRef.current?.value ?? ''));
55
+ setEditing(false);
56
+ };
57
+
58
+ const cancel = () => setEditing(false);
59
+
60
+ const crumbs = rtdbCrumbs(normalized);
61
+
62
+ return (
63
+ <nav
64
+ className={className}
65
+ data-pyric-ui="rtdb-path-bar"
66
+ data-rtdb-editing={editing ? '' : undefined}
67
+ aria-label="Database path"
68
+ >
69
+ {editing ? (
70
+ <form data-rtdb-path-form onSubmit={submit}>
71
+ {inputPrefix ? <span data-rtdb-path-prefix>{inputPrefix}</span> : null}
72
+ <input
73
+ ref={inputRef}
74
+ data-rtdb-path-input
75
+ aria-label="Database path"
76
+ autoFocus
77
+ defaultValue={normalized}
78
+ onBlur={cancel}
79
+ onKeyDown={(e) => {
80
+ if (e.key === 'Escape') cancel();
81
+ }}
82
+ spellCheck={false}
83
+ />
84
+ </form>
85
+ ) : (
86
+ <>
87
+ <ol data-rtdb-crumb-list>
88
+ <li data-rtdb-crumb-item>
89
+ <button
90
+ type="button"
91
+ data-rtdb-crumb
92
+ data-rtdb-crumb-root
93
+ data-pyric-current={crumbs.length === 0 ? '' : undefined}
94
+ aria-current={crumbs.length === 0 ? 'page' : undefined}
95
+ onClick={() => onNavigate('/')}
96
+ >
97
+ {rootLabel}
98
+ </button>
99
+ </li>
100
+ {crumbs.map((crumb, i) => {
101
+ const isCurrent = i === crumbs.length - 1;
102
+ return (
103
+ <li key={crumb.path} data-rtdb-crumb-item>
104
+ <span aria-hidden data-rtdb-crumb-separator>
105
+ /
106
+ </span>
107
+ <button
108
+ type="button"
109
+ data-rtdb-crumb
110
+ data-pyric-current={isCurrent ? '' : undefined}
111
+ aria-current={isCurrent ? 'page' : undefined}
112
+ onClick={() => onNavigate(crumb.path)}
113
+ >
114
+ {crumb.label}
115
+ </button>
116
+ </li>
117
+ );
118
+ })}
119
+ </ol>
120
+ <button
121
+ type="button"
122
+ data-rtdb-path-edit
123
+ aria-label="Edit path"
124
+ title="Edit path"
125
+ onClick={() => setEditing(true)}
126
+ >
127
+ <svg aria-hidden viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
128
+ <path d="M11.7 1.3a1 1 0 0 1 1.4 0l1.6 1.6a1 1 0 0 1 0 1.4l-8.5 8.5-3.4 1.1a.5.5 0 0 1-.63-.63l1.1-3.4 8.43-8.57ZM10.9 3.5l1.6 1.6 1.2-1.2-1.6-1.6-1.2 1.2Z" />
129
+ </svg>
130
+ </button>
131
+ </>
132
+ )}
133
+ </nav>
134
+ );
135
+ }
@@ -0,0 +1,409 @@
1
+ import { useRef, useState, type FormEvent, type ReactNode } from 'react';
2
+ import type { RtdbApi } from '../rtdbApi.js';
3
+ import type { RtdbTreeController } from '../hooks/useRtdbTree.js';
4
+ import {
5
+ RTDB_EDITOR_TYPES,
6
+ coerceRtdbEditorValue,
7
+ formatRtdbEditorValue,
8
+ inferRtdbEditorType,
9
+ rtdbKeyInputError,
10
+ type RtdbEditorType,
11
+ } from '../editor.js';
12
+ import {
13
+ formatRtdbValueLabel,
14
+ hasRtdbChildren,
15
+ joinRtdbPath,
16
+ rtdbPathSegments,
17
+ rtdbValueKind,
18
+ } from '../values.js';
19
+
20
+ export interface RtdbTreeProps {
21
+ /** The live tree controller from `useRtdbTree`. */
22
+ tree: RtdbTreeController;
23
+ /** Mutation backend (admin lens in Studio). */
24
+ api: RtdbApi;
25
+ /** Key-click navigation: re-roots the viewer at the clicked node (wire to
26
+ * the same path state as the path bar). */
27
+ onNavigate?: (path: string) => void;
28
+ /** Label for the view-root row when the root is `'/'` — the database /
29
+ * instance identity. Default `'/'`. */
30
+ rootLabel?: ReactNode;
31
+ className?: string;
32
+ }
33
+
34
+ /**
35
+ * The RTDB data tree — the interaction form of the Firebase console /
36
+ * firebase-tools-ui database viewer (clean-room adaptation of the
37
+ * NodeContainer/NodeParent/NodeLeaf split):
38
+ *
39
+ * - parents render a caret (expand/collapse), leaves a `key: value` row;
40
+ * - keys navigate (re-root the view), carets only toggle;
41
+ * - hover/focus reveals per-node actions: `+` add child, `×` delete —
42
+ * delete flips to an INLINE two-step confirm (no modal, C3);
43
+ * - leaf values are click-to-edit inline (type select: string / number /
44
+ * boolean / JSON);
45
+ * - wide levels page at `tree.pageSize` with a "show more" row (the console's
46
+ * form — see `reducers/tree.ts` for why paging over virtualization).
47
+ *
48
+ * Headless: consumers style `[data-pyric-ui="rtdb-tree"]` and the
49
+ * `data-rtdb-*` attributes (`node`, `row`, `caret`, `key`, `sep`, `value`,
50
+ * `actions`, `action-add`, `action-delete`, `confirm`, `editor`, `children`,
51
+ * `show-more`, `error`, `loading`). An empty root renders the console's
52
+ * classic form — `<root>: null` — not an instructional empty state.
53
+ */
54
+ export function RtdbTree({ tree, api, onNavigate, rootLabel = '/', className }: RtdbTreeProps) {
55
+ const { state } = tree;
56
+ const rootSegments = rtdbPathSegments(state.path);
57
+ const rootKey: ReactNode =
58
+ rootSegments.length === 0 ? rootLabel : rootSegments[rootSegments.length - 1];
59
+
60
+ return (
61
+ <div className={className} data-pyric-ui="rtdb-tree">
62
+ {state.status === 'loading' ? (
63
+ <p data-rtdb-loading>Loading…</p>
64
+ ) : state.status === 'error' ? (
65
+ <p role="alert" data-rtdb-error>
66
+ {state.error}
67
+ </p>
68
+ ) : (
69
+ <Node
70
+ tree={tree}
71
+ api={api}
72
+ onNavigate={onNavigate}
73
+ path={state.path}
74
+ label={rootKey}
75
+ isViewRoot
76
+ />
77
+ )}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ interface NodeProps {
83
+ tree: RtdbTreeController;
84
+ api: RtdbApi;
85
+ onNavigate?: (path: string) => void;
86
+ /** Absolute database path of this node. */
87
+ path: string;
88
+ /** The key text (or the root label node for the view root at `'/'`). */
89
+ label: ReactNode;
90
+ isViewRoot?: boolean;
91
+ }
92
+
93
+ function Node({ tree, api, onNavigate, path, label, isViewRoot }: NodeProps) {
94
+ const value = tree.valueAt(path);
95
+ const isParent = hasRtdbChildren(value);
96
+ const expanded = tree.isExpanded(path);
97
+ const update = tree.updateAt(path);
98
+
99
+ const [adding, setAdding] = useState(false);
100
+ const [editing, setEditing] = useState(false);
101
+ const [confirming, setConfirming] = useState(false);
102
+ const [actionError, setActionError] = useState<string | null>(null);
103
+
104
+ const run = async (op: () => Promise<void>, done: () => void) => {
105
+ try {
106
+ await op();
107
+ setActionError(null);
108
+ done();
109
+ } catch (e) {
110
+ setActionError(e instanceof Error ? e.message : String(e));
111
+ }
112
+ };
113
+
114
+ const openAdd = () => {
115
+ setAdding(true);
116
+ setConfirming(false);
117
+ if (isParent && !expanded) tree.toggle(path);
118
+ };
119
+
120
+ const children = isParent && expanded ? tree.childrenAt(path) : null;
121
+
122
+ return (
123
+ <div
124
+ data-rtdb-node
125
+ data-rtdb-kind={isParent ? 'parent' : 'leaf'}
126
+ data-rtdb-view-root={isViewRoot ? '' : undefined}
127
+ data-rtdb-expanded={isParent && expanded ? '' : undefined}
128
+ >
129
+ <div
130
+ data-rtdb-row
131
+ data-pyric-update={update?.kind}
132
+ data-pyric-update-cycle={update?.cycle}
133
+ >
134
+ {isParent && !isViewRoot ? (
135
+ <button
136
+ type="button"
137
+ data-rtdb-caret
138
+ aria-expanded={expanded}
139
+ aria-label={expanded ? 'Collapse' : 'Expand'}
140
+ onClick={() => tree.toggle(path)}
141
+ >
142
+ <svg aria-hidden viewBox="0 0 8 8" width="8" height="8" fill="currentColor">
143
+ <path d="M1 0l6 4-6 4z" />
144
+ </svg>
145
+ </button>
146
+ ) : (
147
+ <span aria-hidden data-rtdb-caret-spacer />
148
+ )}
149
+
150
+ <button
151
+ type="button"
152
+ data-rtdb-key
153
+ aria-label="Key name"
154
+ onClick={() => onNavigate?.(path)}
155
+ >
156
+ {label}
157
+ </button>
158
+
159
+ {!isParent ? (
160
+ <>
161
+ <span aria-hidden data-rtdb-sep>
162
+ :
163
+ </span>
164
+ <button
165
+ type="button"
166
+ data-rtdb-value
167
+ data-rtdb-type={rtdbValueKind(value)}
168
+ title="Edit value"
169
+ onClick={() => {
170
+ setEditing(true);
171
+ setConfirming(false);
172
+ }}
173
+ >
174
+ {formatRtdbValueLabel(value)}
175
+ </button>
176
+ </>
177
+ ) : null}
178
+
179
+ <span data-rtdb-actions>
180
+ {confirming ? (
181
+ <span data-rtdb-confirm>
182
+ <span data-rtdb-confirm-label>Delete{isParent ? ' subtree' : ''}?</span>
183
+ <button
184
+ type="button"
185
+ data-rtdb-confirm-yes
186
+ onClick={() =>
187
+ void run(
188
+ () => api.remove(path),
189
+ () => setConfirming(false),
190
+ )
191
+ }
192
+ >
193
+ Delete
194
+ </button>
195
+ <button type="button" data-rtdb-confirm-no onClick={() => setConfirming(false)}>
196
+ Cancel
197
+ </button>
198
+ </span>
199
+ ) : (
200
+ <>
201
+ <button
202
+ type="button"
203
+ data-rtdb-action-add
204
+ aria-label={`Add child to ${pathLabel(path)}`}
205
+ title="Add child"
206
+ onClick={openAdd}
207
+ >
208
+ +
209
+ </button>
210
+ <button
211
+ type="button"
212
+ data-rtdb-action-delete
213
+ aria-label={`Delete ${pathLabel(path)}`}
214
+ title="Delete"
215
+ onClick={() => setConfirming(true)}
216
+ >
217
+ ×
218
+ </button>
219
+ </>
220
+ )}
221
+ </span>
222
+ </div>
223
+
224
+ {actionError ? (
225
+ <p role="alert" data-rtdb-error>
226
+ {actionError}
227
+ </p>
228
+ ) : null}
229
+
230
+ {editing ? (
231
+ <ValueEditor
232
+ initialType={inferRtdbEditorType(value)}
233
+ initialText={formatRtdbEditorValue(value, inferRtdbEditorType(value))}
234
+ submitLabel="Save"
235
+ onCancel={() => setEditing(false)}
236
+ onSubmit={(next) =>
237
+ run(
238
+ () => api.set(path, next),
239
+ () => setEditing(false),
240
+ )
241
+ }
242
+ />
243
+ ) : null}
244
+
245
+ {adding ? (
246
+ <ValueEditor
247
+ withKey
248
+ initialType="string"
249
+ initialText=""
250
+ submitLabel="Add"
251
+ onCancel={() => setAdding(false)}
252
+ onSubmit={(next, key) =>
253
+ run(
254
+ () => api.set(joinRtdbPath(path, key!), next),
255
+ () => setAdding(false),
256
+ )
257
+ }
258
+ />
259
+ ) : null}
260
+
261
+ {children ? (
262
+ <ul data-rtdb-children>
263
+ {children.entries.map(([key]) => (
264
+ <li key={key}>
265
+ <Node
266
+ tree={tree}
267
+ api={api}
268
+ onNavigate={onNavigate}
269
+ path={joinRtdbPath(path, key)}
270
+ label={key}
271
+ />
272
+ </li>
273
+ ))}
274
+ {children.hiddenCount > 0 ? (
275
+ <li data-rtdb-show-more-item>
276
+ <button type="button" data-rtdb-show-more onClick={() => tree.showMore(path)}>
277
+ Show more ({children.hiddenCount} hidden)
278
+ </button>
279
+ </li>
280
+ ) : null}
281
+ </ul>
282
+ ) : null}
283
+ </div>
284
+ );
285
+ }
286
+
287
+ /** Last segment for aria labels; `'/'` reads as "root". */
288
+ function pathLabel(path: string): string {
289
+ const segments = rtdbPathSegments(path);
290
+ return segments.length === 0 ? 'root' : segments[segments.length - 1];
291
+ }
292
+
293
+ interface ValueEditorProps {
294
+ /** Add mode: include the child-key input. */
295
+ withKey?: boolean;
296
+ initialType: RtdbEditorType;
297
+ initialText: string;
298
+ submitLabel: string;
299
+ onSubmit: (value: unknown, key?: string) => Promise<void> | void;
300
+ onCancel: () => void;
301
+ }
302
+
303
+ /**
304
+ * Inline key/value editor row (add + edit flows). Escape cancels.
305
+ *
306
+ * The key + value fields are UNCONTROLLED and read at submit — validation
307
+ * happens on submit (errors render inline), so per-keystroke state buys
308
+ * nothing. Only the type select is controlled (it decides whether the value
309
+ * control is a text input or a true/false select).
310
+ */
311
+ function ValueEditor({
312
+ withKey,
313
+ initialType,
314
+ initialText,
315
+ submitLabel,
316
+ onSubmit,
317
+ onCancel,
318
+ }: ValueEditorProps) {
319
+ const keyRef = useRef<HTMLInputElement>(null);
320
+ const valueRef = useRef<HTMLInputElement | HTMLSelectElement>(null);
321
+ const [type, setType] = useState<RtdbEditorType>(initialType);
322
+ const [error, setError] = useState<string | null>(null);
323
+
324
+ const submit = (e: FormEvent) => {
325
+ e.preventDefault();
326
+ const key = keyRef.current?.value ?? '';
327
+ if (withKey) {
328
+ const keyError = rtdbKeyInputError(key);
329
+ if (keyError) {
330
+ setError(keyError);
331
+ return;
332
+ }
333
+ }
334
+ const coerced = coerceRtdbEditorValue(type, valueRef.current?.value ?? '');
335
+ if (!coerced.ok) {
336
+ setError(coerced.error);
337
+ return;
338
+ }
339
+ setError(null);
340
+ void onSubmit(coerced.value, withKey ? key.trim() : undefined);
341
+ };
342
+
343
+ const onEscape = (e: { key: string }) => {
344
+ if (e.key === 'Escape') onCancel();
345
+ };
346
+
347
+ return (
348
+ <form data-rtdb-editor onSubmit={submit}>
349
+ {withKey ? (
350
+ <input
351
+ ref={keyRef}
352
+ data-rtdb-editor-key
353
+ aria-label="Child key"
354
+ placeholder="key"
355
+ autoFocus
356
+ onKeyDown={onEscape}
357
+ spellCheck={false}
358
+ />
359
+ ) : null}
360
+ <select
361
+ data-rtdb-editor-type
362
+ aria-label="Value type"
363
+ value={type}
364
+ onChange={(e) => setType(e.currentTarget.value as RtdbEditorType)}
365
+ onKeyDown={onEscape}
366
+ >
367
+ {RTDB_EDITOR_TYPES.map((t) => (
368
+ <option key={t} value={t}>
369
+ {t}
370
+ </option>
371
+ ))}
372
+ </select>
373
+ {type === 'boolean' ? (
374
+ <select
375
+ ref={valueRef as { current: HTMLSelectElement | null }}
376
+ data-rtdb-editor-value
377
+ aria-label="Value"
378
+ defaultValue={initialText.trim().toLowerCase() === 'true' ? 'true' : 'false'}
379
+ onKeyDown={onEscape}
380
+ >
381
+ <option value="true">true</option>
382
+ <option value="false">false</option>
383
+ </select>
384
+ ) : (
385
+ <input
386
+ ref={valueRef as { current: HTMLInputElement | null }}
387
+ data-rtdb-editor-value
388
+ aria-label="Value"
389
+ placeholder={type === 'json' ? '{ "key": "value" }' : 'value'}
390
+ autoFocus={!withKey}
391
+ defaultValue={initialText}
392
+ onKeyDown={onEscape}
393
+ spellCheck={false}
394
+ />
395
+ )}
396
+ <button type="submit" data-rtdb-editor-save>
397
+ {submitLabel}
398
+ </button>
399
+ <button type="button" data-rtdb-editor-cancel onClick={onCancel}>
400
+ Cancel
401
+ </button>
402
+ {error ? (
403
+ <span role="alert" data-rtdb-editor-error>
404
+ {error}
405
+ </span>
406
+ ) : null}
407
+ </form>
408
+ );
409
+ }