@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,94 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ /** Strip leading/trailing slashes and collapse repeats — mirrors
4
+ * `pyric/storage`'s reference normalization so `usePathState` and
5
+ * `useStorageList` always agree on what a path is. */
6
+ export function normalizeStoragePath(path: string): string {
7
+ return path.split('/').filter(Boolean).join('/');
8
+ }
9
+
10
+ export interface UsePathStateOptions {
11
+ /**
12
+ * Controlled value. When provided, the hook derives everything
13
+ * from it and navigation calls only fire `onPathChange` — the
14
+ * owner owns the state (e.g. a router binding `?path=`).
15
+ */
16
+ path?: string;
17
+ /** Fired with the normalized next path on every navigation. Called
18
+ * in both modes. */
19
+ onPathChange?: (path: string) => void;
20
+ /** Uncontrolled initial value. Default `''` (bucket root). */
21
+ defaultPath?: string;
22
+ }
23
+
24
+ export interface UsePathStateResult {
25
+ /** Current normalized path. `''` is the bucket root. */
26
+ path: string;
27
+ /** Path split into segments. `[]` at root. */
28
+ segments: string[];
29
+ /** Jump to an absolute path (normalized). */
30
+ setPath: (path: string) => void;
31
+ /** Descend into a child folder — accepts a bare name (`'sub'`) or
32
+ * an absolute path (`'docs/sub'`, e.g. a prefix's `fullPath`). */
33
+ enter: (nameOrPath: string) => void;
34
+ /** Ascend one level. No-op at root. */
35
+ up: () => void;
36
+ /**
37
+ * Jump to the ancestor ending at `segments[index]` — the breadcrumb
38
+ * click. `navigateToIndex(-1)` (or any negative) is the root.
39
+ */
40
+ navigateToIndex: (index: number) => void;
41
+ }
42
+
43
+ /**
44
+ * Path navigation state for the storage browser. Controlled when
45
+ * `path` is provided (the owner re-renders with the next value),
46
+ * uncontrolled otherwise — standard React value/defaultValue
47
+ * semantics. All emitted paths are normalized (`normalizeStoragePath`).
48
+ */
49
+ export function usePathState(
50
+ options: UsePathStateOptions = {},
51
+ ): UsePathStateResult {
52
+ const { path: controlled, onPathChange, defaultPath = '' } = options;
53
+ const isControlled = controlled !== undefined;
54
+ const [internal, setInternal] = useState(() => normalizeStoragePath(defaultPath));
55
+ const path = isControlled ? normalizeStoragePath(controlled) : internal;
56
+
57
+ const setPath = useCallback(
58
+ (next: string) => {
59
+ const normalized = normalizeStoragePath(next);
60
+ if (!isControlled) setInternal(normalized);
61
+ onPathChange?.(normalized);
62
+ },
63
+ [isControlled, onPathChange],
64
+ );
65
+
66
+ const segments = useMemo(
67
+ () => (path === '' ? [] : path.split('/')),
68
+ [path],
69
+ );
70
+
71
+ const enter = useCallback(
72
+ (nameOrPath: string) => {
73
+ const target = normalizeStoragePath(nameOrPath);
74
+ // An absolute descendant path (a prefix ref's fullPath) is used
75
+ // as-is; a bare name appends to the current path.
76
+ setPath(target.includes('/') || path === '' ? target : `${path}/${target}`);
77
+ },
78
+ [path, setPath],
79
+ );
80
+
81
+ const up = useCallback(() => {
82
+ if (segments.length === 0) return;
83
+ setPath(segments.slice(0, -1).join('/'));
84
+ }, [segments, setPath]);
85
+
86
+ const navigateToIndex = useCallback(
87
+ (index: number) => {
88
+ setPath(index < 0 ? '' : segments.slice(0, index + 1).join('/'));
89
+ },
90
+ [segments, setPath],
91
+ );
92
+
93
+ return { path, segments, setPath, enter, up, navigateToIndex };
94
+ }
@@ -0,0 +1,195 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import {
3
+ deleteObject as defaultDeleteObject,
4
+ listAll as defaultListAll,
5
+ type FirebaseStorage,
6
+ type StorageReference,
7
+ } from 'pyric/storage';
8
+ import type { StorageSelectionEntry } from './useStorageSelection.js';
9
+ import type { UseStorageListResult } from './useStorageList.js';
10
+ import { useStorageApi, type StorageApi } from '../storageApi.js';
11
+ import { asFolderPlaceholder } from '../folderPlaceholder.js';
12
+
13
+ export interface StorageDeleteProgress {
14
+ /** Objects deleted so far in this folder walk. */
15
+ deletedCount: number;
16
+ /** True for the final emission. */
17
+ done: boolean;
18
+ }
19
+
20
+ /**
21
+ * Recursive folder delete implementation — the same injection seam
22
+ * as the Firestore half's `RecursiveDeleteImpl`. Unlike Firestore
23
+ * (where tree-walking needs sandbox introspection or a Cloud
24
+ * Function), the public storage surface CAN walk a prefix, so the
25
+ * package ships {@link createListAllDeleteImpl} as the default;
26
+ * inject your own for server-driven deletes.
27
+ */
28
+ export interface StorageRecursiveDeleteImpl {
29
+ start: (
30
+ target: StorageReference,
31
+ ) => AsyncIterableIterator<StorageDeleteProgress>;
32
+ }
33
+
34
+ /**
35
+ * The default, `listAll`-driven impl: walks the prefix tree,
36
+ * `deleteObject`s every item (yielding progress per object), then
37
+ * sweeps each visited folder's `<path>/` placeholder so emptied
38
+ * create-folder folders disappear too (`listAll` hides placeholders,
39
+ * so the walk alone would leave ghost folders). Placeholder sweeps
40
+ * are best-effort — `deletedCount` counts listed objects only.
41
+ */
42
+ export function createListAllDeleteImpl(
43
+ api?: Pick<StorageApi, 'listAll' | 'deleteObject'>,
44
+ ): StorageRecursiveDeleteImpl {
45
+ return {
46
+ async *start(target: StorageReference) {
47
+ const operations = api ?? {
48
+ listAll: defaultListAll,
49
+ deleteObject: defaultDeleteObject,
50
+ };
51
+ let deletedCount = 0;
52
+ const stack: StorageReference[] = [target];
53
+ const visited: StorageReference[] = [];
54
+ while (stack.length > 0) {
55
+ const folder = stack.pop()!;
56
+ visited.push(folder);
57
+ const result = await operations.listAll(folder);
58
+ stack.push(...result.prefixes);
59
+ for (const item of result.items) {
60
+ await operations.deleteObject(item);
61
+ deletedCount++;
62
+ yield { deletedCount, done: false };
63
+ }
64
+ }
65
+ for (const folder of visited) {
66
+ try {
67
+ await operations.deleteObject(asFolderPlaceholder(folder));
68
+ } catch {
69
+ // Best-effort: a strict backend may reject the structural
70
+ // placeholder or throw not-found. Neither should fail the
71
+ // recursive delete that already succeeded.
72
+ }
73
+ }
74
+ yield { deletedCount, done: true };
75
+ },
76
+ };
77
+ }
78
+
79
+ /** One entry's failure in a bulk run. `error` is the typed
80
+ * `StorageError` (`.code` e.g. `storage/unauthorized`). */
81
+ export interface StorageDeleteFailure {
82
+ fullPath: string;
83
+ error: Error;
84
+ }
85
+
86
+ export interface StorageDeleteOutcome {
87
+ /** fullPaths of entries fully deleted. */
88
+ deleted: string[];
89
+ failed: StorageDeleteFailure[];
90
+ }
91
+
92
+ export interface UseStorageDeleteOptions {
93
+ /** Folder-walk implementation. Default {@link createListAllDeleteImpl}. */
94
+ impl?: StorageRecursiveDeleteImpl;
95
+ /**
96
+ * Optimistic seam from `useStorageList`: entries vanish from the
97
+ * local list immediately and roll back (object → item, folder →
98
+ * trailing-slash prefix insert) on failure.
99
+ */
100
+ list?: Pick<UseStorageListResult, 'insertItem' | 'removeItem'>;
101
+ }
102
+
103
+ export interface UseStorageDeleteResult {
104
+ /**
105
+ * Delete a mixed object/folder selection (objects via
106
+ * `deleteObject`, folders via the recursive impl), sequentially in
107
+ * selection order. Resolves with the outcome and never rejects —
108
+ * per-entry failures land in `outcome.failed` (and `error` keeps
109
+ * the first one for simple renders).
110
+ */
111
+ deleteEntries: (
112
+ entries: StorageSelectionEntry[],
113
+ ) => Promise<StorageDeleteOutcome>;
114
+ /** Objects deleted in the current/last run (folder walks included). */
115
+ progress: number;
116
+ isRunning: boolean;
117
+ /** First failure of the current/last run. Cleared on the next call. */
118
+ error: Error | undefined;
119
+ }
120
+
121
+ /**
122
+ * Drive bulk + recursive deletes from a React component — the
123
+ * storage counterpart of `useRecursiveDelete` (same progress /
124
+ * isRunning / error shape, same stale-run generation token), bulk
125
+ * because storage selections are flat multi-row affairs.
126
+ */
127
+ export function useStorageDelete(
128
+ storage: FirebaseStorage | null | undefined,
129
+ options: UseStorageDeleteOptions = {},
130
+ ): UseStorageDeleteResult {
131
+ const api = useStorageApi();
132
+ const [progress, setProgress] = useState(0);
133
+ const [isRunning, setIsRunning] = useState(false);
134
+ const [error, setError] = useState<Error | undefined>(undefined);
135
+ const generationRef = useRef(0);
136
+ // Latest-value ref so `deleteEntries` stays stable across option
137
+ // identity changes (house pattern).
138
+ const optionsRef = useRef(options);
139
+ optionsRef.current = options;
140
+
141
+ const deleteEntries = useCallback(
142
+ async (entries: StorageSelectionEntry[]): Promise<StorageDeleteOutcome> => {
143
+ const myGen = ++generationRef.current;
144
+ setProgress(0);
145
+ setError(undefined);
146
+ const outcome: StorageDeleteOutcome = { deleted: [], failed: [] };
147
+ if (storage == null || entries.length === 0) return outcome;
148
+ setIsRunning(true);
149
+ const impl = optionsRef.current.impl ?? createListAllDeleteImpl(api);
150
+ let count = 0;
151
+ try {
152
+ for (const entry of entries) {
153
+ // Optimistic removal — the row vanishes immediately.
154
+ optionsRef.current.list?.removeItem(entry.fullPath);
155
+ try {
156
+ if (entry.kind === 'object') {
157
+ await api.deleteObject(api.ref(storage, entry.fullPath));
158
+ count++;
159
+ } else {
160
+ for await (const evt of impl.start(api.ref(storage, entry.fullPath))) {
161
+ if (myGen !== generationRef.current) return outcome;
162
+ // The impl reports counts local to its walk; add the
163
+ // objects already deleted by earlier entries.
164
+ setProgress(count + evt.deletedCount);
165
+ if (evt.done) {
166
+ count += evt.deletedCount;
167
+ break;
168
+ }
169
+ }
170
+ }
171
+ outcome.deleted.push(entry.fullPath);
172
+ if (myGen === generationRef.current) setProgress(count);
173
+ } catch (e) {
174
+ const err = e instanceof Error ? e : new Error(String(e));
175
+ outcome.failed.push({ fullPath: entry.fullPath, error: err });
176
+ // Roll the optimistic removal back: objects re-insert as
177
+ // items, folders as trailing-slash prefixes.
178
+ optionsRef.current.list?.insertItem(
179
+ entry.kind === 'folder' ? `${entry.fullPath}/` : entry.fullPath,
180
+ );
181
+ if (myGen === generationRef.current && outcome.failed.length === 1) {
182
+ setError(err);
183
+ }
184
+ }
185
+ }
186
+ return outcome;
187
+ } finally {
188
+ if (myGen === generationRef.current) setIsRunning(false);
189
+ }
190
+ },
191
+ [api, storage],
192
+ );
193
+
194
+ return { deleteEntries, progress, isRunning, error };
195
+ }
@@ -0,0 +1,261 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type {
3
+ FirebaseStorage,
4
+ StorageReference,
5
+ } from 'pyric/storage';
6
+ import { useStorageApi } from '../storageApi.js';
7
+
8
+ export type StorageListStatus = 'idle' | 'loading' | 'success' | 'error';
9
+
10
+ /**
11
+ * One row of the merged folder/object model, the prefix→folder
12
+ * synthesis ported (as an idea, not code) from the emulator UI's
13
+ * `useStorageFiles`: `listAll`'s `prefixes` become `kind: 'folder'`
14
+ * rows, its `items` become `kind: 'object'` rows, folders first.
15
+ */
16
+ export interface StorageListEntry {
17
+ kind: 'folder' | 'object';
18
+ /** Last path segment, display name. */
19
+ name: string;
20
+ /** Bucket-rooted path (no trailing slash, even for folders). */
21
+ fullPath: string;
22
+ ref: StorageReference;
23
+ }
24
+
25
+ export interface UseStorageListResult {
26
+ /** `'idle'` only when `storage` is null/undefined. */
27
+ status: StorageListStatus;
28
+ /** Direct child objects under `path`. Sorted by `fullPath`. */
29
+ items: StorageReference[];
30
+ /** Synthetic folder prefixes under `path`. Sorted by `fullPath`. */
31
+ prefixes: StorageReference[];
32
+ /** Folders-first merged row model. Derived from `prefixes` + `items`. */
33
+ entries: StorageListEntry[];
34
+ /**
35
+ * `StorageError` (with a typed `storage/<code>` on `.code`) from the
36
+ * sandbox. A denied list is `error.code === 'storage/unauthorized'`
37
+ * (ST-B2).
38
+ */
39
+ error: Error | undefined;
40
+ /** Re-run `listAll` for the current path. */
41
+ refresh: () => void;
42
+ /**
43
+ * Optimistic seam (consumed by M3 upload / M6 bulk ops, exposed
44
+ * now so those hooks layer on without reshaping this one). Inserts
45
+ * `fullPath` into the local list immediately, applying the same
46
+ * prefix→folder synthesis `listAll` would: a direct child becomes
47
+ * an item, a deeper descendant surfaces as its first-segment
48
+ * folder. A trailing slash declares a folder (the GCS placeholder
49
+ * convention `useObjectUpload.createFolder` writes): a direct
50
+ * trailing-slash child inserts a prefix, not an item. No-op for
51
+ * paths outside the listed path, duplicates, or when `status` is
52
+ * `'idle'`. What each call ACTUALLY inserted is recorded (keyed by
53
+ * the given `fullPath`) so `removeItem` can reverse it precisely.
54
+ * Rollback = `removeItem` or `refresh`.
55
+ */
56
+ insertItem: (fullPath: string) => void;
57
+ /**
58
+ * Optimistic counterpart. When `fullPath` was previously given to
59
+ * `insertItem`, this reverses EXACTLY what that call inserted: a
60
+ * deep upload that synthesized a first-segment folder row removes
61
+ * that folder row, and an insert that was a no-op (the row already
62
+ * existed — e.g. a real, listed folder) removes NOTHING, so a
63
+ * failed upload can never delete server-truth rows. For paths never
64
+ * seen by `insertItem` it removes the matching item/folder row
65
+ * directly (the optimistic-delete use). Rollback = `refresh`.
66
+ */
67
+ removeItem: (fullPath: string) => void;
68
+ }
69
+
70
+ interface ListState {
71
+ status: StorageListStatus;
72
+ items: StorageReference[];
73
+ prefixes: StorageReference[];
74
+ error: Error | undefined;
75
+ }
76
+
77
+ /** Strip leading/trailing slashes so `'a/b/'`, `'/a/b'`, `'a/b'` agree. */
78
+ function normalizePath(path: string): string {
79
+ return path.replace(/^\/+|\/+$/g, '');
80
+ }
81
+
82
+ function byFullPath(a: StorageReference, b: StorageReference): number {
83
+ return a.fullPath < b.fullPath ? -1 : a.fullPath > b.fullPath ? 1 : 0;
84
+ }
85
+
86
+ function insertSorted(
87
+ list: StorageReference[],
88
+ next: StorageReference,
89
+ ): StorageReference[] {
90
+ if (list.some((r) => r.fullPath === next.fullPath)) return list;
91
+ return [...list, next].sort(byFullPath);
92
+ }
93
+
94
+ /**
95
+ * List the objects + synthetic folders directly under `path` :
96
+ * `listAll` over the package's sandbox Storage handle. Read-via-get,
97
+ * not realtime: the list updates on `refresh`, path change, or the
98
+ * optimistic seam. Pass `''` (or the result of `usePathState`) for
99
+ * the bucket root.
100
+ *
101
+ * `listAll` has no pagination; a very large prefix arrives as one flat
102
+ * result (virtualize the rendering, which `<ObjectBrowser>` does).
103
+ */
104
+ export function useStorageList(
105
+ storage: FirebaseStorage | null | undefined,
106
+ path: string,
107
+ ): UseStorageListResult {
108
+ // Injected: in-process `pyric/storage` by default, or the SharedWorker client
109
+ // bundle in Studio served mode (via StorageApiProvider).
110
+ const { listAll, ref: refFn } = useStorageApi();
111
+ const normalized = normalizePath(path);
112
+ const [state, setState] = useState<ListState>(() => ({
113
+ status: storage == null ? 'idle' : 'loading',
114
+ items: [],
115
+ prefixes: [],
116
+ error: undefined,
117
+ }));
118
+ const [tick, setTick] = useState(0);
119
+
120
+ // What each `insertItem(fullPath)` call ACTUALLY inserted, keyed by the
121
+ // normalized argument: the inserted row's fullPath, or null when the call
122
+ // was a no-op (row already present). `removeItem` consults this to reverse
123
+ // precisely; a fresh listing (server truth) clears the ledger.
124
+ const optimisticInserts = useRef(new Map<string, string | null>());
125
+
126
+ useEffect(() => {
127
+ optimisticInserts.current.clear();
128
+ if (storage == null) {
129
+ setState({ status: 'idle', items: [], prefixes: [], error: undefined });
130
+ return;
131
+ }
132
+ let cancelled = false;
133
+ setState({ status: 'loading', items: [], prefixes: [], error: undefined });
134
+ listAll(refFn(storage, normalized))
135
+ .then((result) => {
136
+ if (cancelled) return;
137
+ setState({
138
+ status: 'success',
139
+ // Defensive copy + sort pins the invariant the optimistic seam
140
+ // relies on even if the backing implementation changes.
141
+ items: [...result.items].sort(byFullPath),
142
+ prefixes: [...result.prefixes].sort(byFullPath),
143
+ error: undefined,
144
+ });
145
+ })
146
+ .catch((e) => {
147
+ if (cancelled) return;
148
+ setState({
149
+ status: 'error',
150
+ items: [],
151
+ prefixes: [],
152
+ error: e instanceof Error ? e : new Error(String(e)),
153
+ });
154
+ });
155
+ return () => {
156
+ cancelled = true;
157
+ };
158
+ }, [storage, normalized, tick]);
159
+
160
+ const refresh = useCallback(() => setTick((n) => n + 1), []);
161
+
162
+ const insertItem = useCallback(
163
+ (fullPath: string) => {
164
+ if (storage == null) return;
165
+ // Trailing slash = folder declaration (a `<path>/` placeholder
166
+ // object surfaces as a prefix, never an item, same synthesis
167
+ // `listAll` applies).
168
+ const isFolder = fullPath.endsWith('/');
169
+ const target = normalizePath(fullPath);
170
+ const scanPrefix = normalized === '' ? '' : `${normalized}/`;
171
+ if (!target.startsWith(scanPrefix) || target === normalized) return;
172
+ const relative = target.slice(scanPrefix.length);
173
+ const slashIdx = relative.indexOf('/');
174
+ setState((prev) => {
175
+ if (prev.status === 'idle') return prev;
176
+ // Record what this call really adds (null = no-op) so removeItem can
177
+ // reverse it precisely. Idempotent under StrictMode's double-invoke:
178
+ // the same prev yields the same record.
179
+ if (slashIdx === -1) {
180
+ if (isFolder) {
181
+ // Direct child folder.
182
+ optimisticInserts.current.set(
183
+ target,
184
+ prev.prefixes.some((r) => r.fullPath === target) ? null : target,
185
+ );
186
+ return {
187
+ ...prev,
188
+ prefixes: insertSorted(prev.prefixes, refFn(storage, target)),
189
+ };
190
+ }
191
+ // Direct child object.
192
+ optimisticInserts.current.set(
193
+ target,
194
+ prev.items.some((r) => r.fullPath === target) ? null : target,
195
+ );
196
+ return { ...prev, items: insertSorted(prev.items, refFn(storage, target)) };
197
+ }
198
+ // Deeper descendant, surface its first segment as a folder,
199
+ // exactly like listAll's synthesis.
200
+ const folderPath = `${scanPrefix}${relative.slice(0, slashIdx)}`;
201
+ optimisticInserts.current.set(
202
+ target,
203
+ prev.prefixes.some((r) => r.fullPath === folderPath) ? null : folderPath,
204
+ );
205
+ return {
206
+ ...prev,
207
+ prefixes: insertSorted(prev.prefixes, refFn(storage, folderPath)),
208
+ };
209
+ });
210
+ },
211
+ [storage, normalized],
212
+ );
213
+
214
+ const removeItem = useCallback((fullPath: string) => {
215
+ const target = normalizePath(fullPath);
216
+ const record = optimisticInserts.current.get(target);
217
+ optimisticInserts.current.delete(target);
218
+ // This path was optimistically inserted and the insert added nothing
219
+ // (the row pre-existed) — there is nothing of ours to remove, and
220
+ // removing by path would delete a server-truth row.
221
+ if (record === null) return;
222
+ // Reverse the exact row the insert created (a deep upload's synthesized
223
+ // first-segment folder differs from the upload's own fullPath); fall
224
+ // back to the path itself for plain optimistic deletes.
225
+ const rowPath = record ?? target;
226
+ setState((prev) => ({
227
+ ...prev,
228
+ items: prev.items.filter((r) => r.fullPath !== rowPath),
229
+ prefixes: prev.prefixes.filter((r) => r.fullPath !== rowPath),
230
+ }));
231
+ }, []);
232
+
233
+ const entries = useMemo<StorageListEntry[]>(
234
+ () => [
235
+ ...state.prefixes.map((p) => ({
236
+ kind: 'folder' as const,
237
+ name: p.name,
238
+ fullPath: p.fullPath,
239
+ ref: p,
240
+ })),
241
+ ...state.items.map((i) => ({
242
+ kind: 'object' as const,
243
+ name: i.name,
244
+ fullPath: i.fullPath,
245
+ ref: i,
246
+ })),
247
+ ],
248
+ [state.prefixes, state.items],
249
+ );
250
+
251
+ return {
252
+ status: state.status,
253
+ items: state.items,
254
+ prefixes: state.prefixes,
255
+ entries,
256
+ error: state.error,
257
+ refresh,
258
+ insertItem,
259
+ removeItem,
260
+ };
261
+ }