@gogitcms/editor 0.25.0 → 0.28.0

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 (24) hide show
  1. package/app/src/App.tsx +163 -1
  2. package/app/src/forms.ts +77 -0
  3. package/app/src/history.ts +50 -0
  4. package/app/src/queries.ts +163 -0
  5. package/app/vendor/design-system/src/__tests__/ContentBrowser.forms.test.tsx +157 -0
  6. package/app/vendor/design-system/src/__tests__/ContentBrowser.history.test.tsx +385 -0
  7. package/app/vendor/design-system/src/__tests__/ContentBrowser.historycollab.test.tsx +433 -0
  8. package/app/vendor/design-system/src/__tests__/FormsBrowser.test.tsx +253 -0
  9. package/app/vendor/design-system/src/__tests__/ProtectedBranchModal.test.tsx +68 -0
  10. package/app/vendor/design-system/src/components/ChangeDetail.tsx +94 -11
  11. package/app/vendor/design-system/src/components/CollabField.tsx +71 -0
  12. package/app/vendor/design-system/src/components/ContentBrowser.tsx +366 -61
  13. package/app/vendor/design-system/src/components/DocumentHistory.tsx +408 -0
  14. package/app/vendor/design-system/src/components/FormsBrowser.tsx +543 -0
  15. package/app/vendor/design-system/src/components/ProtectedBranchModal.tsx +136 -0
  16. package/app/vendor/design-system/src/components/primitives.tsx +46 -1
  17. package/app/vendor/design-system/src/forms.ts +118 -0
  18. package/app/vendor/design-system/src/history.ts +82 -0
  19. package/app/vendor/design-system/src/index.ts +18 -1
  20. package/app/vendor/markdown-editor/src/MarkdownEditor.tsx +23 -0
  21. package/app/vendor/markdown-editor/src/field.tsx +6 -1
  22. package/app/vendor/markdown-editor/src/types.ts +13 -0
  23. package/npm-shrinkwrap.json +149 -188
  24. package/package.json +6 -6
@@ -0,0 +1,543 @@
1
+ import React from "react";
2
+ import { FlatList, Pressable, TextInput, View } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { Spinner } from "./Spinner";
7
+ import {
8
+ type FormInfo,
9
+ type FormsApi,
10
+ type SubmissionInfo,
11
+ summaryLine,
12
+ } from "../forms";
13
+
14
+ // The Forms surface: a list of forms, a form's submissions, and one submission
15
+ // rendered read-only (docs/forms.md §10.2).
16
+ //
17
+ // ── Why this does not reuse FieldControl ────────────────────────────────────
18
+ //
19
+ // The obvious move is to render a submission with the same controls the
20
+ // document editor uses, in readOnly mode. Two reasons not to.
21
+ //
22
+ // FieldControl reaches ~18 sibling components inside ContentBrowser.tsx, most of
23
+ // them collaboration-aware (CollabInput, CollabArrayField, GroupPresence…).
24
+ // Extracting it into a shared module so a second consumer could import it is a
25
+ // large refactor of a 5,000-line file, and a submission needs none of what it
26
+ // would drag along: no CRDT session, no draft state, no onChange, no media
27
+ // picker interaction.
28
+ //
29
+ // And it would be worse to read. A submission is a record of what somebody sent,
30
+ // not a document with editing switched off, so a row of disabled inputs is the
31
+ // wrong presentation for it — a label-and-value read view says "this is what
32
+ // they wrote" in a way a greyed-out form never does.
33
+
34
+ const PAGE_SIZE = 50;
35
+
36
+ export type FormsBrowserProps = {
37
+ api: FormsApi;
38
+ /** The form being browsed, from the nav key. Null shows the forms list. */
39
+ form: string | null;
40
+ /** Open a form (null returns to the list). The host binds this to the URL. */
41
+ onSelectForm: (form: string | null) => void;
42
+ /** The selected submission's id, and the reporter for taps. */
43
+ selectedId?: string | null;
44
+ onSelectSubmission?: (id: string | null) => void;
45
+ variant?: "desktop" | "mobile";
46
+ };
47
+
48
+ export function FormsBrowser(props: FormsBrowserProps) {
49
+ const { api, form, onSelectForm, selectedId, onSelectSubmission, variant = "desktop" } = props;
50
+ const t = useTheme();
51
+ const isDesktop = variant === "desktop";
52
+
53
+ const active = form ? api.forms.find((f) => f.name === form) ?? null : null;
54
+
55
+ if (!form || !active) {
56
+ return <FormList forms={api.forms} onSelect={onSelectForm} />;
57
+ }
58
+ return (
59
+ <SubmissionsView
60
+ api={api}
61
+ form={active}
62
+ onBack={() => onSelectForm(null)}
63
+ selectedId={selectedId ?? null}
64
+ onSelect={onSelectSubmission}
65
+ isDesktop={isDesktop}
66
+ />
67
+ );
68
+ }
69
+
70
+ // ── The forms list ──────────────────────────────────────────────────────────
71
+
72
+ // Each row reads like a folder in the hierarchical content models, because that
73
+ // is what a form is here: a container you open to find what is inside it.
74
+ function FormList({ forms, onSelect }: { forms: FormInfo[]; onSelect: (name: string) => void }) {
75
+ const t = useTheme();
76
+ if (forms.length === 0) {
77
+ return (
78
+ <View style={{ padding: t.space(6) }}>
79
+ <Text color="secondary">This branch declares no forms.</Text>
80
+ </View>
81
+ );
82
+ }
83
+ return (
84
+ <View style={{ flex: 1 }}>
85
+ <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(3) }}>
86
+ <Text variant="label" color="tertiary">
87
+ {forms.length} {forms.length === 1 ? "form" : "forms"}
88
+ </Text>
89
+ </View>
90
+ <FlatList
91
+ data={forms}
92
+ keyExtractor={(f) => f.name}
93
+ renderItem={({ item }) => <FormRow form={item} onPress={() => onSelect(item.name)} />}
94
+ />
95
+ </View>
96
+ );
97
+ }
98
+
99
+ function FormRow({ form, onPress }: { form: FormInfo; onPress: () => void }) {
100
+ const t = useTheme();
101
+ const [hover, setHover] = React.useState(false);
102
+ return (
103
+ <Pressable
104
+ onPress={onPress}
105
+ onHoverIn={() => setHover(true)}
106
+ onHoverOut={() => setHover(false)}
107
+ testID={`form-row-${form.name}`}
108
+ style={{
109
+ flexDirection: "row",
110
+ alignItems: "center",
111
+ gap: t.space(3),
112
+ paddingHorizontal: t.space(4),
113
+ paddingVertical: t.space(3),
114
+ borderBottomWidth: 1,
115
+ borderBottomColor: t.color.borderSubtle,
116
+ backgroundColor: hover ? t.color.surfaceHover : "transparent",
117
+ }}
118
+ >
119
+ <Icon name="listTree" size={16} color={t.color.textTertiary} />
120
+ <View style={{ flex: 1, gap: 2 }}>
121
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
122
+ <Text numberOfLines={1}>{form.label || form.name}</Text>
123
+ {form.versioned ? (
124
+ <Icon name="gitBranch" size={12} color={t.color.textTertiary} />
125
+ ) : null}
126
+ </View>
127
+ {form.description ? (
128
+ <Text variant="sm" color="tertiary" numberOfLines={1}>
129
+ {form.description}
130
+ </Text>
131
+ ) : null}
132
+ </View>
133
+ {/* How much has come in. The field count is deliberately not here: it
134
+ describes the form's shape rather than its inbox, and in a column this
135
+ narrow it competed with the number a reader actually scans for. */}
136
+ <Text variant="monoSm" color="secondary" style={{ minWidth: 48, textAlign: "right" }}>
137
+ {form.submissionCount}
138
+ </Text>
139
+ <Icon name="chevronRight" size={14} color={t.color.textTertiary} />
140
+ </Pressable>
141
+ );
142
+ }
143
+
144
+ // ── One form's submissions ──────────────────────────────────────────────────
145
+
146
+ function SubmissionsView({
147
+ api,
148
+ form,
149
+ onBack,
150
+ selectedId,
151
+ onSelect,
152
+ isDesktop,
153
+ }: {
154
+ api: FormsApi;
155
+ form: FormInfo;
156
+ onBack: () => void;
157
+ selectedId: string | null;
158
+ onSelect?: (id: string | null) => void;
159
+ isDesktop: boolean;
160
+ }) {
161
+ const t = useTheme();
162
+ const [items, setItems] = React.useState<SubmissionInfo[]>([]);
163
+ const [total, setTotal] = React.useState<number | null>(null);
164
+ const [loading, setLoading] = React.useState(true);
165
+ // A form can legitimately fail to list: local mode answers
166
+ // NO_SUBMISSION_STORE for a form that keeps its submissions in a database it
167
+ // does not have. Without somewhere to put that, the promise rejected and the
168
+ // pane spun forever — which is a worse answer than the empty inbox this was
169
+ // written to avoid.
170
+ const [failure, setFailure] = React.useState<string | null>(null);
171
+ const [loadingMore, setLoadingMore] = React.useState(false);
172
+ const [exhausted, setExhausted] = React.useState(false);
173
+ const [search, setSearch] = React.useState("");
174
+ const [status, setStatus] = React.useState<"received" | "spam">("received");
175
+
176
+ // Switching form, filter or search is a different result set: reset rather
177
+ // than paging one query's offsets into another's.
178
+ React.useEffect(() => {
179
+ let alive = true;
180
+ setLoading(true);
181
+ setExhausted(false);
182
+ setFailure(null);
183
+ Promise.all([
184
+ api.list({ form: form.name, status, search, limit: PAGE_SIZE, offset: 0 }),
185
+ api.count({ form: form.name, status, search }),
186
+ ])
187
+ .then(([rows, n]) => {
188
+ if (!alive) return;
189
+ setItems(rows);
190
+ setTotal(n);
191
+ setExhausted(rows.length >= n);
192
+ })
193
+ .catch((err: unknown) => {
194
+ if (!alive) return;
195
+ setItems([]);
196
+ setTotal(null);
197
+ // The server's own sentence, which for the case that actually happens
198
+ // says what to change in the config. A generic "couldn't load" would
199
+ // throw that away.
200
+ setFailure(messageOf(err));
201
+ })
202
+ .finally(() => alive && setLoading(false));
203
+ return () => {
204
+ alive = false;
205
+ };
206
+ }, [api, form.name, status, search]);
207
+
208
+ const loadMore = React.useCallback(() => {
209
+ if (loadingMore || exhausted || loading) return;
210
+ setLoadingMore(true);
211
+ api
212
+ .list({ form: form.name, status, search, limit: PAGE_SIZE, offset: items.length })
213
+ .then((rows) => {
214
+ setItems((prev) => [...prev, ...rows]);
215
+ if (rows.length < PAGE_SIZE) setExhausted(true);
216
+ })
217
+ .finally(() => setLoadingMore(false));
218
+ }, [api, form.name, status, search, items.length, loading, loadingMore, exhausted]);
219
+
220
+ const selected = selectedId ? items.find((s) => s.id === selectedId) ?? null : null;
221
+
222
+ const list = (
223
+ <View style={{ flex: 1, minWidth: 0 }}>
224
+ <View
225
+ style={{
226
+ flexDirection: "row",
227
+ alignItems: "center",
228
+ gap: t.space(2),
229
+ paddingHorizontal: t.space(3),
230
+ paddingVertical: t.space(2),
231
+ borderBottomWidth: 1,
232
+ borderBottomColor: t.color.borderSubtle,
233
+ }}
234
+ >
235
+ <Pressable onPress={onBack} testID="forms-back" style={{ padding: t.space(1) }}>
236
+ <Icon name="chevronLeft" size={16} color={t.color.textSecondary} />
237
+ </Pressable>
238
+ <Text numberOfLines={1} style={{ flex: 1 }}>
239
+ {form.label || form.name}
240
+ </Text>
241
+ <Text variant="monoSm" color="tertiary">
242
+ {total ?? "—"}
243
+ </Text>
244
+ </View>
245
+
246
+ <View
247
+ style={{
248
+ flexDirection: "row",
249
+ alignItems: "center",
250
+ gap: t.space(2),
251
+ paddingHorizontal: t.space(3),
252
+ paddingVertical: t.space(2),
253
+ }}
254
+ >
255
+ <TextInput
256
+ value={search}
257
+ onChangeText={setSearch}
258
+ placeholder="Search submissions"
259
+ placeholderTextColor={t.color.textTertiary}
260
+ testID="forms-search"
261
+ style={{
262
+ flex: 1,
263
+ height: t.control.sm,
264
+ paddingHorizontal: t.space(2),
265
+ borderWidth: 1,
266
+ borderColor: t.color.borderDefault,
267
+ borderRadius: t.radius.sm,
268
+ color: t.color.textPrimary,
269
+ }}
270
+ />
271
+ {/* Spam is recorded rather than discarded (a honeypot also catches
272
+ browser autofill), so it needs somewhere to be seen. */}
273
+ <Pressable
274
+ onPress={() => setStatus(status === "received" ? "spam" : "received")}
275
+ testID="forms-status-toggle"
276
+ style={{
277
+ height: t.control.sm,
278
+ justifyContent: "center",
279
+ paddingHorizontal: t.space(2),
280
+ borderWidth: 1,
281
+ borderColor: status === "spam" ? t.color.borderStrong : t.color.borderDefault,
282
+ borderRadius: t.radius.sm,
283
+ }}
284
+ >
285
+ <Text variant="monoSm" color={status === "spam" ? "primary" : "tertiary"}>
286
+ Spam
287
+ </Text>
288
+ </Pressable>
289
+ </View>
290
+
291
+ {loading ? (
292
+ <View style={{ padding: t.space(6), alignItems: "center" }}>
293
+ <Spinner />
294
+ </View>
295
+ ) : failure ? (
296
+ <View style={{ padding: t.space(6) }} testID="forms-error">
297
+ <Text color="secondary">{failure}</Text>
298
+ </View>
299
+ ) : items.length === 0 ? (
300
+ <View style={{ padding: t.space(6) }}>
301
+ <Text color="secondary">
302
+ {status === "spam" ? "Nothing caught as spam." : "No submissions yet."}
303
+ </Text>
304
+ </View>
305
+ ) : (
306
+ <FlatList
307
+ data={items}
308
+ keyExtractor={(s) => s.id}
309
+ onEndReached={loadMore}
310
+ onEndReachedThreshold={0.4}
311
+ ListFooterComponent={
312
+ loadingMore ? (
313
+ <View style={{ padding: t.space(4), alignItems: "center" }}>
314
+ <Spinner />
315
+ </View>
316
+ ) : null
317
+ }
318
+ renderItem={({ item }) => (
319
+ <SubmissionRow
320
+ submission={item}
321
+ form={form}
322
+ selected={item.id === selectedId}
323
+ onPress={() => onSelect?.(item.id)}
324
+ />
325
+ )}
326
+ />
327
+ )}
328
+ </View>
329
+ );
330
+
331
+ if (!isDesktop) {
332
+ // Mobile pushes: the list, or the submission on top of it.
333
+ return selected ? (
334
+ <SubmissionDetail submission={selected} form={form} onBack={() => onSelect?.(null)} />
335
+ ) : (
336
+ list
337
+ );
338
+ }
339
+
340
+ return (
341
+ <View style={{ flex: 1, flexDirection: "row", minWidth: 0 }}>
342
+ <View
343
+ style={{
344
+ width: t.layout.column,
345
+ borderRightWidth: 1,
346
+ borderRightColor: t.color.borderSubtle,
347
+ }}
348
+ >
349
+ {list}
350
+ </View>
351
+ <View style={{ flex: 1, minWidth: 0 }}>
352
+ {selected ? (
353
+ <SubmissionDetail submission={selected} form={form} />
354
+ ) : (
355
+ <View style={{ padding: t.space(6) }}>
356
+ <Text color="secondary">Select a submission</Text>
357
+ </View>
358
+ )}
359
+ </View>
360
+ </View>
361
+ );
362
+ }
363
+
364
+ function SubmissionRow({
365
+ submission,
366
+ form,
367
+ selected,
368
+ onPress,
369
+ }: {
370
+ submission: SubmissionInfo;
371
+ form: FormInfo;
372
+ selected: boolean;
373
+ onPress: () => void;
374
+ }) {
375
+ const t = useTheme();
376
+ const [hover, setHover] = React.useState(false);
377
+ return (
378
+ <Pressable
379
+ onPress={onPress}
380
+ onHoverIn={() => setHover(true)}
381
+ onHoverOut={() => setHover(false)}
382
+ testID={`submission-row-${submission.id}`}
383
+ style={{
384
+ gap: 2,
385
+ paddingHorizontal: t.space(4),
386
+ paddingVertical: t.space(3),
387
+ borderBottomWidth: 1,
388
+ borderBottomColor: t.color.borderSubtle,
389
+ backgroundColor: selected
390
+ ? t.color.surfaceActive
391
+ : hover
392
+ ? t.color.surfaceHover
393
+ : "transparent",
394
+ }}
395
+ >
396
+ <Text numberOfLines={1}>{summaryLine(submission, form)}</Text>
397
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
398
+ <Text variant="monoSm" color="tertiary">
399
+ {formatWhen(submission.submittedAt)}
400
+ </Text>
401
+ {submission.status === "spam" ? (
402
+ <Text variant="monoSm" color={t.color.diffDelFg}>
403
+ spam
404
+ </Text>
405
+ ) : null}
406
+ </View>
407
+ </Pressable>
408
+ );
409
+ }
410
+
411
+ // ── One submission, read-only ───────────────────────────────────────────────
412
+
413
+ function SubmissionDetail({
414
+ submission,
415
+ form,
416
+ onBack,
417
+ }: {
418
+ submission: SubmissionInfo;
419
+ form: FormInfo;
420
+ onBack?: () => void;
421
+ }) {
422
+ const t = useTheme();
423
+ return (
424
+ <View style={{ flex: 1 }}>
425
+ <View
426
+ style={{
427
+ flexDirection: "row",
428
+ alignItems: "center",
429
+ gap: t.space(2),
430
+ paddingHorizontal: t.space(4),
431
+ paddingVertical: t.space(3),
432
+ borderBottomWidth: 1,
433
+ borderBottomColor: t.color.borderSubtle,
434
+ }}
435
+ >
436
+ {onBack ? (
437
+ <Pressable onPress={onBack} testID="submission-back" style={{ padding: t.space(1) }}>
438
+ <Icon name="chevronLeft" size={16} color={t.color.textSecondary} />
439
+ </Pressable>
440
+ ) : null}
441
+ <View style={{ flex: 1, gap: 2 }}>
442
+ <Text numberOfLines={1}>{summaryLine(submission, form)}</Text>
443
+ <Text variant="monoSm" color="tertiary">
444
+ {formatWhen(submission.submittedAt)}
445
+ </Text>
446
+ </View>
447
+ </View>
448
+
449
+ <View style={{ padding: t.space(4), gap: t.space(4) }}>
450
+ {form.fields.map((f) => {
451
+ const present = Object.prototype.hasOwnProperty.call(submission.fields, f.name);
452
+ return (
453
+ <View key={f.name} style={{ gap: t.space(1) }}>
454
+ <Text variant="label" color="tertiary">
455
+ {f.label || f.name}
456
+ </Text>
457
+ {present ? (
458
+ <FieldValue value={submission.fields[f.name]} />
459
+ ) : (
460
+ // The visible payoff of storing an inapplicable field as ABSENT
461
+ // rather than null: "we never asked" is a different fact from
462
+ // "they left it blank", and the record can say which.
463
+ <Text variant="sm" color="disabled" testID={`submission-not-asked-${f.name}`}>
464
+ Not asked
465
+ </Text>
466
+ )}
467
+ </View>
468
+ );
469
+ })}
470
+
471
+ {submission.path ? (
472
+ <View style={{ gap: t.space(1), paddingTop: t.space(2) }}>
473
+ <Text variant="label" color="tertiary">
474
+ In the repository
475
+ </Text>
476
+ <Text variant="monoSm" color="secondary">
477
+ {submission.path}
478
+ </Text>
479
+ </View>
480
+ ) : null}
481
+ </View>
482
+ </View>
483
+ );
484
+ }
485
+
486
+ // FieldValue renders one stored value for reading. Deliberately small: the
487
+ // shapes a submission can hold are the config's field types, and each has an
488
+ // obvious read form.
489
+ function FieldValue({ value }: { value: unknown }) {
490
+ const t = useTheme();
491
+ if (value === null || value === undefined || value === "") {
492
+ return (
493
+ <Text variant="sm" color="disabled">
494
+
495
+ </Text>
496
+ );
497
+ }
498
+ if (typeof value === "boolean") {
499
+ return <Text>{value ? "Yes" : "No"}</Text>;
500
+ }
501
+ if (Array.isArray(value)) {
502
+ return (
503
+ <View style={{ gap: t.space(1) }}>
504
+ {value.map((v, i) => (
505
+ <Text key={i}>• {typeof v === "object" ? JSON.stringify(v) : String(v)}</Text>
506
+ ))}
507
+ </View>
508
+ );
509
+ }
510
+ if (typeof value === "object") {
511
+ return (
512
+ <View style={{ gap: t.space(1) }}>
513
+ {Object.entries(value as Record<string, unknown>).map(([k, v]) => (
514
+ <View key={k} style={{ flexDirection: "row", gap: t.space(2) }}>
515
+ <Text variant="monoSm" color="tertiary">
516
+ {k}
517
+ </Text>
518
+ <Text style={{ flex: 1 }}>{typeof v === "object" ? JSON.stringify(v) : String(v)}</Text>
519
+ </View>
520
+ ))}
521
+ </View>
522
+ );
523
+ }
524
+ return <Text>{String(value)}</Text>;
525
+ }
526
+
527
+ // messageOf digs the human sentence out of whatever the host's api threw. An
528
+ // Apollo error carries the server's message on graphQLErrors; everything else
529
+ // is an Error, or something that is not.
530
+ function messageOf(err: unknown): string {
531
+ const gql = (err as { graphQLErrors?: { message?: string }[] })?.graphQLErrors;
532
+ if (Array.isArray(gql) && gql[0]?.message) return gql[0].message!;
533
+ if (err instanceof Error && err.message) return err.message;
534
+ return "These submissions could not be loaded.";
535
+ }
536
+
537
+ // formatWhen renders a timestamp the way an inbox does. Falls back to the raw
538
+ // string rather than showing "Invalid Date" for anything unparseable.
539
+ export function formatWhen(iso: string): string {
540
+ const d = new Date(iso);
541
+ if (Number.isNaN(d.getTime())) return iso;
542
+ return d.toLocaleString();
543
+ }
@@ -0,0 +1,136 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { View, Pressable } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { Input } from "./Input";
7
+ import { Button } from "./Button";
8
+
9
+ export type ProtectedBranchModalProps = {
10
+ /** The protected branch the save was refused on. */
11
+ branch: string;
12
+ /** The document that could not be saved, for naming what is at stake. */
13
+ documentLabel?: string;
14
+ /** Prefills the field — the host derives it from the document. */
15
+ suggestedName?: string;
16
+ /** True while the branch is being created and the save applied. */
17
+ busy?: boolean;
18
+ /** The server's message, when the attempt failed. */
19
+ error?: string | null;
20
+ /** Create `name` from `branch` and save the pending edit onto it. */
21
+ onCreate: (name: string) => void;
22
+ /** Dismiss. The edit stays as an unsaved draft on the protected branch. */
23
+ onCancel: () => void;
24
+ };
25
+
26
+ /**
27
+ * Shown when a save is refused because the branch is protected.
28
+ *
29
+ * It is a prompt rather than an error banner because the refusal has exactly one
30
+ * remedy and the editor knows what it is: the work is intact, it just needs
31
+ * somewhere it can land. Presenting that as "here is a message, now go find the
32
+ * branch menu" would make the author reconstruct a plan the product already has.
33
+ *
34
+ * The draft is deliberately NOT discarded on cancel — a protected branch is
35
+ * often noticed mid-thought, and the author may want to keep writing and pick a
36
+ * branch name later. It stays in storage exactly as any other unsaved work does.
37
+ */
38
+ export function ProtectedBranchModal({
39
+ branch, documentLabel, suggestedName, busy, error, onCreate, onCancel,
40
+ }: ProtectedBranchModalProps) {
41
+ const t = useTheme();
42
+ const [name, setName] = useState(suggestedName ?? "");
43
+ // The suggestion is derived from the document, which the host may resolve a
44
+ // render after the modal opens. Adopted only while the field is untouched.
45
+ const [touched, setTouched] = useState(false);
46
+ useEffect(() => {
47
+ if (!touched && suggestedName) setName(suggestedName);
48
+ }, [suggestedName, touched]);
49
+
50
+ const trimmed = name.trim();
51
+ const submit = () => {
52
+ if (!trimmed || busy) return;
53
+ onCreate(trimmed);
54
+ };
55
+
56
+ return (
57
+ <View
58
+ testID="protected-branch-modal"
59
+ style={{
60
+ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, zIndex: 50,
61
+ alignItems: "center", justifyContent: "center", padding: t.space(4),
62
+ backgroundColor: "rgba(0,0,0,0.45)",
63
+ }}
64
+ >
65
+ <View
66
+ style={{
67
+ width: 460, maxWidth: "100%", gap: t.space(4), padding: t.space(5),
68
+ borderRadius: t.radius.lg, borderWidth: 1, borderColor: t.color.borderDefault,
69
+ backgroundColor: t.color.surfaceRaised,
70
+ }}
71
+ >
72
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
73
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
74
+ <Icon name="lock" size={16} color={t.color.textSecondary} />
75
+ <Text variant="h3" weight="semibold">This branch is protected</Text>
76
+ </View>
77
+ {busy ? null : (
78
+ <Pressable
79
+ testID="protected-branch-close"
80
+ accessibilityRole="button"
81
+ accessibilityLabel="Cancel"
82
+ onPress={onCancel}
83
+ style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}
84
+ >
85
+ <Icon name="x" size={18} color={t.color.textSecondary} />
86
+ </Pressable>
87
+ )}
88
+ </View>
89
+
90
+ <Text variant="body" color="secondary">
91
+ {documentLabel
92
+ ? `${branch} doesn’t accept changes directly, so “${documentLabel}” can’t be saved to it. Name a branch to save it to instead — your edit lands there.`
93
+ : `${branch} doesn’t accept changes directly. Name a branch to save your edit to instead.`}
94
+ </Text>
95
+
96
+ <Input
97
+ label="New branch"
98
+ value={name}
99
+ onChangeText={(v) => { setTouched(true); setName(v); }}
100
+ onSubmitEditing={submit}
101
+ placeholder="feat/my-change"
102
+ autoCapitalize="none"
103
+ autoFocus
104
+ mono
105
+ editable={!busy}
106
+ error={!!error}
107
+ testID="protected-branch-name"
108
+ />
109
+ <Text variant="monoSm" color="tertiary">{`Branched from ${branch}`}</Text>
110
+
111
+ {error ? (
112
+ <Text testID="protected-branch-error" variant="monoSm" color={t.color.diffDelFg}>{error}</Text>
113
+ ) : null}
114
+
115
+ <View style={{ flexDirection: "row", justifyContent: "flex-end", gap: t.space(2) }}>
116
+ <Button
117
+ title="Cancel"
118
+ variant="ghost"
119
+ size="md"
120
+ disabled={busy}
121
+ onPress={onCancel}
122
+ testID="protected-branch-cancel"
123
+ />
124
+ <Button
125
+ title={busy ? "Creating…" : "Create branch and save"}
126
+ variant="primary"
127
+ size="md"
128
+ disabled={busy || trimmed === ""}
129
+ onPress={submit}
130
+ testID="protected-branch-submit"
131
+ />
132
+ </View>
133
+ </View>
134
+ </View>
135
+ );
136
+ }