@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,100 @@
1
+ import { useMemo } from 'react';
2
+ import {
3
+ SimulateFirestoreRulesHandler,
4
+ type TestCase,
5
+ type RuleEvaluation,
6
+ type PathResolutionTrace,
7
+ } from 'pyric/rules/internal';
8
+ import type { FirestoreMethod } from '../types.js';
9
+
10
+ /**
11
+ * The captured request a host re-runs through the simulator to produce a
12
+ * `Denial`. A subset of the simulator's `TestCase` — no expectation /
13
+ * description (those are test-runner concerns); the host already knows
14
+ * the request was denied.
15
+ */
16
+ export interface DenialRequest {
17
+ method: FirestoreMethod;
18
+ /** Resource path, e.g. `notes/3agHoZHZ`. */
19
+ path: string;
20
+ /** `request.auth` — `null`/omitted for unauthenticated. */
21
+ auth?: { uid: string; token?: Record<string, unknown> } | null;
22
+ /** `request.resource.data` — for writes. */
23
+ requestData?: Record<string, unknown>;
24
+ /** `resource.data` — the existing document. */
25
+ resourceData?: Record<string, unknown> | null;
26
+ /** Override `request.time` (ISO-8601). Defaults to wallclock. */
27
+ requestTime?: string;
28
+ }
29
+
30
+ export interface DenialTrace {
31
+ /** Per allow-rule evaluation, in source order. Empty for no-match denials. */
32
+ evaluation: RuleEvaluation[];
33
+ /** Which `match` blocks the resolver tried — present for no-match denials. */
34
+ pathResolution?: PathResolutionTrace;
35
+ /** True when the simulator could parse + evaluate. False on a parse error. */
36
+ ok: boolean;
37
+ /** Populated when `ok` is false (e.g. the rules source failed to parse). */
38
+ error?: string;
39
+ }
40
+
41
+ function toTestCase(req: DenialRequest): TestCase {
42
+ return {
43
+ description: `denial-inspector: ${req.method} ${req.path}`,
44
+ // The request was denied — we re-run it expecting DENY so the
45
+ // simulator's PASSED/FAILED bookkeeping stays self-consistent. The
46
+ // trace (which we actually consume) is independent of expectation.
47
+ expectation: 'DENY',
48
+ method: req.method,
49
+ path: req.path,
50
+ auth: req.auth ?? null,
51
+ ...(req.requestData !== undefined ? { data: req.requestData } : {}),
52
+ ...(req.resourceData != null ? { resource: req.resourceData } : {}),
53
+ ...(req.requestTime !== undefined ? { requestTime: req.requestTime } : {}),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Re-run a captured (denied) Firestore request through the local rules
59
+ * simulator — tracing is always on there — and return the structured
60
+ * trace a `DenialInspector` renders.
61
+ *
62
+ * Memoized on `(request, rulesSource)`; the simulator is pure and
63
+ * in-process, so this is cheap to call on every render. A host produces
64
+ * a `Denial` by spreading the request fields alongside the result:
65
+ *
66
+ * ```ts
67
+ * const { evaluation, pathResolution } = useDenialTrace(req, rules);
68
+ * const denial: Denial = { ...req, decision: 'DENY', rulesSource: rules,
69
+ * at, evaluation, pathResolution };
70
+ * ```
71
+ */
72
+ export function useDenialTrace(
73
+ request: DenialRequest,
74
+ rulesSource: string,
75
+ ): DenialTrace {
76
+ // Stable identity key — `request` is often a fresh object literal each
77
+ // render, so memoize on its content, not its reference.
78
+ const key = useMemo(() => JSON.stringify(request), [request]);
79
+
80
+ return useMemo<DenialTrace>(() => {
81
+ const handler = new SimulateFirestoreRulesHandler();
82
+ const result = handler.simulate(rulesSource, [toTestCase(request)]);
83
+ if (!result.success) {
84
+ return { evaluation: [], ok: false, error: result.error.message };
85
+ }
86
+ const first = result.data.results[0];
87
+ if (!first) {
88
+ return { evaluation: [], ok: false, error: 'simulator returned no result' };
89
+ }
90
+ return {
91
+ evaluation: first.trace,
92
+ ...(first.pathResolution ? { pathResolution: first.pathResolution } : {}),
93
+ ok: true,
94
+ };
95
+ // `key` captures the content of `request`; `request` itself is read
96
+ // inside but intentionally excluded so a new-but-equal object literal
97
+ // doesn't re-run the simulator.
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [key, rulesSource]);
100
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `@pyric/ui/rules` — headless components for Firestore rules debugging.
3
+ *
4
+ * The Studio "Debug a denial" screen (`mocks/c-debug.html`) as a
5
+ * props-driven, zero-styling component: `DenialInspector` renders *why*
6
+ * one request was denied (reason, line-marked rule, expression
7
+ * step-through, data in scope, path resolution) and exposes a re-run /
8
+ * verify loop. Grounded entirely in the `pyric/rules` simulator trace —
9
+ * no engine work, pure presentation.
10
+ */
11
+ export * from './hooks/index.js';
12
+ export * from './components/index.js';
13
+
14
+ export type {
15
+ Denial,
16
+ DenialLens,
17
+ DenialInspectorProps,
18
+ LineVerdict,
19
+ RuleEvaluation,
20
+ PathResolutionTrace,
21
+ PathResolutionEntry,
22
+ ExprTraceEntry,
23
+ FirestoreMethod,
24
+ } from './types.js';
@@ -0,0 +1,91 @@
1
+ import type {
2
+ RuleEvaluation,
3
+ PathResolutionTrace,
4
+ PathResolutionEntry,
5
+ ExprTraceEntry,
6
+ FirestoreMethod,
7
+ } from 'pyric/rules/internal';
8
+
9
+ // Re-export the simulator trace types so consumers of `@pyric/ui/rules`
10
+ // can describe a `Denial` without reaching into `pyric/rules` themselves.
11
+ export type {
12
+ RuleEvaluation,
13
+ PathResolutionTrace,
14
+ PathResolutionEntry,
15
+ ExprTraceEntry,
16
+ FirestoreMethod,
17
+ };
18
+
19
+ /**
20
+ * The lens a request was issued under, mirroring the Studio's identity
21
+ * model:
22
+ * - `'admin'` — the admin handle (bypasses rules in production,
23
+ * shown here for provenance only)
24
+ * - `{ as: uid }` — acting as a specific signed-in user
25
+ * - `'app-session'` — the ambient app session (whoever is signed in
26
+ * in the running preview)
27
+ */
28
+ export type DenialLens = 'admin' | { as: string } | 'app-session';
29
+
30
+ /**
31
+ * One denied Firestore request, enriched with the simulator trace.
32
+ *
33
+ * The live denial *event* carries only `debugMessages`; the rich trace
34
+ * (`evaluation` / `pathResolution`) is produced by re-running the
35
+ * simulator (tracing always on) against the captured request. Build one
36
+ * with `useDenialTrace(request, rulesSource)` then spread the captured
37
+ * request fields alongside.
38
+ */
39
+ export interface Denial {
40
+ // ── the captured request ──────────────────────────────────────────
41
+ method: FirestoreMethod;
42
+ /** Resource path, e.g. `notes/3agHoZHZ`. */
43
+ path: string;
44
+ /** `request.auth` — `null` for an unauthenticated request. */
45
+ auth: { uid: string; token: Record<string, unknown> } | null;
46
+ /** Identity lens the request was issued under. */
47
+ lens?: DenialLens;
48
+ /** `request.resource.data` — present for writes. */
49
+ requestData?: Record<string, unknown>;
50
+ /** `resource.data` — the existing document, `null` when absent. */
51
+ resourceData?: Record<string, unknown> | null;
52
+ /** Capture time (epoch ms). */
53
+ at: number;
54
+
55
+ // ── the simulation result ─────────────────────────────────────────
56
+ /** `firestore.rules` source the request was evaluated against. */
57
+ rulesSource: string;
58
+ decision: 'DENY';
59
+ /**
60
+ * Per allow-rule evaluation, in source order. Each entry carries the
61
+ * `line`, `verdict`, `conditionText`, and `expressionTrace`.
62
+ */
63
+ evaluation: RuleEvaluation[];
64
+ /**
65
+ * Path-resolution attempts — present for no-match (default-deny)
66
+ * denials, where no `allow` rule was evaluated because no `match`
67
+ * block covered the path.
68
+ */
69
+ pathResolution?: PathResolutionTrace;
70
+ }
71
+
72
+ /**
73
+ * Per-line verdict for the rule-source view. `deny` is the deciding
74
+ * allow line; `skip` is an allow line whose operations don't include
75
+ * the request method ("not checked"); `allow` is any other allow line.
76
+ * Non-allow lines (match/braces/comments) get no verdict.
77
+ */
78
+ export type LineVerdict = 'deny' | 'allow' | 'skip';
79
+
80
+ export interface DenialInspectorProps {
81
+ denial: Denial;
82
+ /** Sibling denials produced by the same rule. */
83
+ cluster?: Denial[];
84
+ /** Re-run the request under `{ mode: 'as', uid }`. */
85
+ onRerunAs?(uid: string): void;
86
+ /** Re-run against an edited ruleset (a branch). */
87
+ onTestEditedRule?(): void;
88
+ /** A cluster sibling was selected. */
89
+ onSelectCluster?(d: Denial): void;
90
+ className?: string;
91
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * OS-copy collision renaming for uploads/drops — pure logic, no React.
3
+ *
4
+ * THE RULE (mirrors macOS Finder's "keep both" counter semantics, with
5
+ * the Windows-style ` (n)` spelling the spec pins):
6
+ *
7
+ * 1. Split the name into `stem + ext`. The extension is the suffix from
8
+ * the LAST dot, only when that dot is neither the first character nor
9
+ * the last: `photo.png` → `photo` + `.png`; `archive.tar.gz` →
10
+ * `archive.tar` + `.gz`; dotfiles (`.gitignore`) and trailing-dot
11
+ * names (`notes.`) have NO extension — the whole name is the stem;
12
+ * extensionless names (`Makefile`) likewise.
13
+ * 2. If the stem already ends in a ` (n)` counter (single space, plain
14
+ * decimal integer), the counter is INCREMENTED, not nested:
15
+ * `photo (1).png` colliding becomes `photo (2).png`, never
16
+ * `photo (1) (1).png`. (`photo (1) (2).png` → base `photo (1)`,
17
+ * counter 2 → `photo (1) (3).png`: only the final counter moves.)
18
+ * 3. Otherwise the first candidate is ` (1)`: `photo.png` →
19
+ * `photo (1).png`.
20
+ * 4. Candidates increment until one is not taken.
21
+ *
22
+ * A name that isn't taken is returned unchanged — the renamer only
23
+ * fires on collision.
24
+ */
25
+
26
+ /** `name` split as the rule defines: `ext` includes the leading dot,
27
+ * or is `''` when the name has no extension (dotfiles, trailing dots,
28
+ * extensionless names). */
29
+ export function splitStorageName(name: string): { stem: string; ext: string } {
30
+ const dot = name.lastIndexOf('.');
31
+ if (dot <= 0 || dot === name.length - 1) return { stem: name, ext: '' };
32
+ return { stem: name.slice(0, dot), ext: name.slice(dot) };
33
+ }
34
+
35
+ /** Trailing ` (n)` counter: `photo (3)` → base `photo`, counter 3.
36
+ * `counter: null` when the stem carries none. A counter beyond
37
+ * `Number.MAX_SAFE_INTEGER` is treated as plain text (no counter):
38
+ * incrementing it would be lossy — `n + 1 === n` in float land, which
39
+ * turns {@link resolveCollision}'s probe loop into a hang — and the
40
+ * candidate would render in scientific notation anyway. */
41
+ export function parseCopyCounter(stem: string): { base: string; counter: number | null } {
42
+ const m = /^(.*) \((\d+)\)$/.exec(stem);
43
+ if (!m) return { base: stem, counter: null };
44
+ const counter = Number(m[2]);
45
+ if (!Number.isSafeInteger(counter)) return { base: stem, counter: null };
46
+ return { base: m[1], counter };
47
+ }
48
+
49
+ /**
50
+ * Resolve one name against a set of taken sibling names. Returns the
51
+ * name unchanged when free; otherwise the first ` (n)` candidate that
52
+ * is free, per the module rule above.
53
+ */
54
+ export function resolveCollision(name: string, taken: ReadonlySet<string>): string {
55
+ if (!taken.has(name)) return name;
56
+ const { stem, ext } = splitStorageName(name);
57
+ const { base, counter } = parseCopyCounter(stem);
58
+ let n = counter === null ? 1 : counter + 1;
59
+ for (;;) {
60
+ const candidate = `${base} (${n})${ext}`;
61
+ if (!taken.has(candidate)) return candidate;
62
+ n += 1;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resolve a whole drop/pick batch against the destination folder's
68
+ * existing names, with OS drop semantics: collisions are detected and
69
+ * renamed at the batch's TOP LEVEL only (the names the OS drop
70
+ * "creates" in the destination — a plain file's name, or a dropped
71
+ * folder's root segment). Files inside a dropped folder ride their
72
+ * folder's rename and keep their inner structure untouched — exactly
73
+ * like dropping `photos/` next to an existing `photos/` yields
74
+ * `photos (1)/…` with the contents intact.
75
+ *
76
+ * Within one batch:
77
+ * - all paths sharing a top-level FOLDER segment share its resolution
78
+ * (one dropped folder = one rename), and
79
+ * - top-level FILES resolve individually in order, each claiming its
80
+ * resolved name, so two same-named files in one batch get successive
81
+ * counters.
82
+ *
83
+ * Only the destination's DIRECT children can be checked — that is all
84
+ * the drop target (one `listAll` level) knows. Deeper paths follow GCS
85
+ * overwrite semantics, which the folder-level rename already shields
86
+ * in practice (a colliding folder is renamed wholesale).
87
+ *
88
+ * Returns resolved paths in input order.
89
+ */
90
+ export function planBatchNames(
91
+ relativePaths: readonly string[],
92
+ taken: ReadonlySet<string>,
93
+ ): string[] {
94
+ const claimed = new Set(taken);
95
+ // One resolution per dropped folder root, shared across its files.
96
+ const folderRenames = new Map<string, string>();
97
+ return relativePaths.map((path) => {
98
+ const slash = path.indexOf('/');
99
+ if (slash === -1) {
100
+ // Top-level file: resolve individually, claim the result.
101
+ const resolved = resolveCollision(path, claimed);
102
+ claimed.add(resolved);
103
+ return resolved;
104
+ }
105
+ const root = path.slice(0, slash);
106
+ let renamed = folderRenames.get(root);
107
+ if (renamed === undefined) {
108
+ renamed = resolveCollision(root, claimed);
109
+ claimed.add(renamed);
110
+ folderRenames.set(root, renamed);
111
+ }
112
+ return `${renamed}${path.slice(slash)}`;
113
+ });
114
+ }
@@ -0,0 +1,193 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { FirebaseStorage } from 'pyric/storage';
3
+ import { useConfirm } from '../../primitives/useConfirm.js';
4
+ import { useToast } from '../../primitives/Toast.js';
5
+ import type { StorageSelectionEntry } from '../hooks/useStorageSelection.js';
6
+ import type { UseStorageRulesGateResult } from '../hooks/useStorageRulesGate.js';
7
+ import {
8
+ useStorageDelete,
9
+ type StorageDeleteOutcome,
10
+ type StorageRecursiveDeleteImpl,
11
+ type UseStorageDeleteOptions,
12
+ } from '../hooks/useStorageDelete.js';
13
+
14
+ export interface DeleteSelectionWithConfirmProps {
15
+ /** The package's single Storage handle prop. */
16
+ storage: FirebaseStorage | null | undefined;
17
+ /** What to delete — `useStorageSelection().selected` (or any
18
+ * `{kind, fullPath}` rows). Folders delete recursively. */
19
+ entries: StorageSelectionEntry[];
20
+ /** Folder-walk impl override (default: the `listAll`-driven one). */
21
+ impl?: StorageRecursiveDeleteImpl;
22
+ /** Optimistic seam from `useStorageList`. */
23
+ list?: UseStorageDeleteOptions['list'];
24
+ /**
25
+ * Rules-aware affordance — pass `useStorageRulesGate(storage)`.
26
+ * When ANY selected entry's DELETE verdict denies, the trigger
27
+ * disables with the reason (default trigger: `data-pyric-denied` +
28
+ * `data-pyric-denied-reason` + `title`; `renderTrigger` receives
29
+ * `deniedReason`). For folder entries the verdict evaluates the
30
+ * folder path itself — an approximation of the recursive walk
31
+ * (descendants matched by `{allPaths=**}` rules share the verdict).
32
+ */
33
+ gate?: Pick<UseStorageRulesGateResult, 'verdictFor'>;
34
+ /** Confirm-dialog title. Default derives from the entry count. */
35
+ title?: string;
36
+ /** Confirm-dialog body. Default lists the selected paths. */
37
+ body?: ReactNode;
38
+ confirmLabel?: string;
39
+ /** Fired after a run with NO failures (e.g. clear the selection +
40
+ * refresh the list). */
41
+ onDeleted?: (outcome: StorageDeleteOutcome) => void;
42
+ /** Fired after a run with failures (the toast already showed). */
43
+ onFailed?: (outcome: StorageDeleteOutcome) => void;
44
+ /** Render override for the trigger button. */
45
+ renderTrigger?: (props: {
46
+ onClick: () => void;
47
+ isRunning: boolean;
48
+ progress: number;
49
+ disabled: boolean;
50
+ /** Set when the rules gate denied the selection (see `gate`). */
51
+ deniedReason?: string;
52
+ }) => ReactNode;
53
+ /** Class forwarded to the default trigger button. */
54
+ className?: string;
55
+ }
56
+
57
+ /**
58
+ * Bulk delete behind the confirm-dialog primitive, with toasts on
59
+ * outcome — wires `useConfirm` + `useStorageDelete` + `useToast` the
60
+ * way `<DeleteWithConfirm>` wires the Firestore trio. Requires
61
+ * `<ConfirmProvider>` AND `<ToastProvider>` ancestors.
62
+ *
63
+ * Outcome toasts: all-success → one `success` toast with the count;
64
+ * any failure → an `error` toast listing each failed path with its
65
+ * typed `StorageError.code`.
66
+ *
67
+ * The default trigger styles via `[data-pyric-ui="delete-selection"]`
68
+ * (+ `[data-pyric-destructive]`, `[data-pyric-running]`,
69
+ * `[data-pyric-denied]` with the reason on
70
+ * `data-pyric-denied-reason`/`title`); it disables while running,
71
+ * when `entries` is empty, or when the rules `gate` denies the
72
+ * selection.
73
+ */
74
+ export function DeleteSelectionWithConfirm({
75
+ storage,
76
+ entries,
77
+ impl,
78
+ list,
79
+ gate,
80
+ title,
81
+ body,
82
+ confirmLabel = 'Delete',
83
+ onDeleted,
84
+ onFailed,
85
+ renderTrigger,
86
+ className,
87
+ }: DeleteSelectionWithConfirmProps) {
88
+ const confirm = useConfirm();
89
+ const { toast } = useToast();
90
+ const { deleteEntries, isRunning, progress } = useStorageDelete(storage, {
91
+ impl,
92
+ list,
93
+ });
94
+
95
+ // Pre-flight DELETE verdicts over the selection (advisory — the
96
+ // enforcement layer still decides; see the `gate` prop).
97
+ const denied = gate
98
+ ? entries
99
+ .map((e) => ({ entry: e, verdict: gate.verdictFor(e.fullPath) }))
100
+ .filter((d) => !d.verdict.delete)
101
+ : [];
102
+ const deniedReason =
103
+ denied.length === 0
104
+ ? undefined
105
+ : `Delete denied for ${denied[0].entry.fullPath}${
106
+ denied.length > 1 ? ` (+${denied.length - 1} more)` : ''
107
+ }: ${denied[0].verdict.reasons.write.join('; ')}`;
108
+
109
+ const disabled =
110
+ isRunning || entries.length === 0 || storage == null || denied.length > 0;
111
+
112
+ const handleClick = async () => {
113
+ if (disabled) return;
114
+ const ok = await confirm({
115
+ title:
116
+ title ??
117
+ (entries.length === 1
118
+ ? `Delete ${entries[0].fullPath}?`
119
+ : `Delete ${entries.length} items?`),
120
+ body: body ?? (
121
+ <ul data-pyric-delete-selection-paths>
122
+ {entries.map((e) => (
123
+ <li key={e.fullPath} data-pyric-entry-kind={e.kind}>
124
+ {e.fullPath}
125
+ {e.kind === 'folder' ? '/' : ''}
126
+ </li>
127
+ ))}
128
+ </ul>
129
+ ),
130
+ destructive: true,
131
+ confirmLabel,
132
+ });
133
+ if (!ok) return;
134
+
135
+ const outcome = await deleteEntries(entries);
136
+ if (outcome.failed.length === 0) {
137
+ toast({
138
+ title: `Deleted ${outcome.deleted.length} ${
139
+ outcome.deleted.length === 1 ? 'item' : 'items'
140
+ }`,
141
+ kind: 'success',
142
+ });
143
+ onDeleted?.(outcome);
144
+ } else {
145
+ toast({
146
+ title: `Delete failed for ${outcome.failed.length} of ${entries.length}`,
147
+ kind: 'error',
148
+ body: (
149
+ <ul data-pyric-delete-selection-failures>
150
+ {outcome.failed.map((f) => (
151
+ <li key={f.fullPath}>
152
+ {f.fullPath}:{' '}
153
+ {(f.error as { code?: string }).code ?? f.error.message}
154
+ </li>
155
+ ))}
156
+ </ul>
157
+ ),
158
+ });
159
+ onFailed?.(outcome);
160
+ }
161
+ };
162
+
163
+ if (renderTrigger) {
164
+ return (
165
+ <>
166
+ {renderTrigger({
167
+ onClick: handleClick,
168
+ isRunning,
169
+ progress,
170
+ disabled,
171
+ deniedReason,
172
+ })}
173
+ </>
174
+ );
175
+ }
176
+
177
+ return (
178
+ <button
179
+ type="button"
180
+ onClick={handleClick}
181
+ className={className}
182
+ disabled={disabled}
183
+ title={deniedReason}
184
+ data-pyric-ui="delete-selection"
185
+ data-pyric-destructive=""
186
+ data-pyric-running={isRunning ? '' : undefined}
187
+ data-pyric-denied={deniedReason ? '' : undefined}
188
+ data-pyric-denied-reason={deniedReason}
189
+ >
190
+ {isRunning ? `Deleting… (${progress})` : confirmLabel}
191
+ </button>
192
+ );
193
+ }