@gogitcms/editor 0.26.0 → 0.29.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 +155 -168
  24. package/package.json +6 -6
@@ -6,7 +6,7 @@ import { Text } from "./Text";
6
6
  import { Icon } from "./Icon";
7
7
  import { Button, IconButton } from "./Button";
8
8
  import { Input } from "./Input";
9
- import { Avatar, Badge, DiffStat, Divider, SectionLabel } from "./primitives";
9
+ import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
10
10
  import { NavRow } from "./NavRow";
11
11
  import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
12
12
  import { Segment, type SegmentItem } from "./Segment";
@@ -15,6 +15,7 @@ import { BranchMenu, type BranchRef } from "./BranchMenu";
15
15
  import { ProjectMenu, type ProjectRef } from "./ProjectMenu";
16
16
  import { ChangeDetail, type DocumentChange, type FieldConflict, type ConflictChoice } from "./ChangeDetail";
17
17
  import { ApplyChangesModal, type MergeProgress } from "./ApplyChangesModal";
18
+ import { ProtectedBranchModal } from "./ProtectedBranchModal";
18
19
  import { NotificationBell, type NotificationItem } from "./Notifications";
19
20
  import {
20
21
  CollabInput,
@@ -22,6 +23,7 @@ import {
22
23
  PresenceField,
23
24
  PresenceAvatars,
24
25
  useCollabRegister,
26
+ writeCollabValue,
25
27
  useCollabMirror,
26
28
  useCollabSynced,
27
29
  useCollabValue,
@@ -31,6 +33,10 @@ import {
31
33
  import { clamp, reorder, slotAtX } from "./reorder";
32
34
  import { MediaField, MediaProvider, DocumentPathProvider } from "./MediaField";
33
35
  import { MediaBrowser } from "./MediaBrowser";
36
+ import { FormsBrowser } from "./FormsBrowser";
37
+ import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
38
+ import { type HistoryApi } from "../history";
39
+ import { DocumentHistory } from "./DocumentHistory";
34
40
  import {
35
41
  MEDIA_NAV_KEY,
36
42
  mediaNavKey,
@@ -109,6 +115,12 @@ export type FieldSlotArgs = {
109
115
  // CRDT and publish presence.
110
116
  collab?: CollabApi;
111
117
  path?: string;
118
+ // Bumped when the host replaces this field's value outright rather than the
119
+ // user editing it — restoring it from a past version. A controlled control
120
+ // needs nothing from this (it already re-renders with the new value), but the
121
+ // body editor does: in a live session it ignores `value` and mirrors the
122
+ // shared fragment, so "the value changed" is not a thing it can observe.
123
+ resetNonce?: number;
112
124
  };
113
125
  export type RenderField = (args: FieldSlotArgs) => React.ReactNode;
114
126
 
@@ -334,6 +346,22 @@ export type ContentBrowserProps = {
334
346
  // store gets.
335
347
  media?: MediaApi;
336
348
 
349
+ // Forms data seam, the same split (docs/forms.md §10.2). Omitted → the Forms
350
+ // surface never renders, which is what a config declaring no forms looks like.
351
+ forms?: FormsApi;
352
+
353
+ // Document-history data seam, the same split again: the DS owns the version
354
+ // browser and its ephemeral state, the host owns fetching. Provided → an open
355
+ // document's header gains a history button, and pressing it gives that
356
+ // column's body over to the version browser. Omitted → no affordance at all,
357
+ // which is what a deployment with no provider (local mode, desktop) gets: a
358
+ // feature the user never sees beats one that errors when clicked.
359
+ history?: HistoryApi;
360
+ // The selected submission's id, and the reporter for taps — bound to the URL
361
+ // by the host exactly as document selection is.
362
+ selectedSubmissionId?: string | null;
363
+ onSelectSubmission?: (id: string | null) => void;
364
+
337
365
  // When provided, field/body edits autosave 1s after the last keystroke and an
338
366
  // autosave indicator appears in the detail header. Omitted → edits stay local
339
367
  // (the demo/read-only behavior).
@@ -468,6 +496,19 @@ export type ContentBrowserProps = {
468
496
  changeRequestOpen?: boolean;
469
497
  changeRequestUrl?: string;
470
498
  creatingChangeRequest?: boolean;
499
+
500
+ // Protected-branch save prompt. The server refuses a save to a branch the
501
+ // provider protects; the host catches that, sets protectedBranch to the branch
502
+ // name, and this modal offers the one thing that resolves it — a branch to put
503
+ // the edit on. onCreateBranchAndSave receives the name; the host creates the
504
+ // branch and applies the pending edit to it in one server call.
505
+ protectedBranch?: string;
506
+ protectedBranchDocument?: string;
507
+ protectedBranchSuggestedName?: string;
508
+ creatingProtectedBranch?: boolean;
509
+ protectedBranchError?: string | null;
510
+ onCreateBranchAndSave?: (name: string) => void;
511
+ onCancelProtectedSave?: () => void;
471
512
  onCreateChangeRequest?: (explanation: string) => void;
472
513
  onViewChangeRequest?: () => void;
473
514
 
@@ -733,6 +774,11 @@ type FieldControlProps = {
733
774
  // When this number changes, the field programmatically focuses itself (used to
734
775
  // jump to a peer's field from its presence avatar).
735
776
  focusSignal?: number;
777
+ // Bumped when this field's value was replaced by the host rather than typed —
778
+ // restored from a past version. Only the host-supplied body editor needs it
779
+ // (see FieldSlotArgs.resetNonce); every built-in control is controlled and
780
+ // re-renders from `value` on its own.
781
+ resetNonce?: number;
736
782
  };
737
783
 
738
784
  // The scalar type an array's `of` maps to when rendering item controls.
@@ -746,7 +792,7 @@ function ofToType(of?: string): string {
746
792
  }
747
793
 
748
794
  function FieldControl(props: FieldControlProps) {
749
- const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal } = props;
795
+ const { field, value, onChange, renderField, readOnly = false, error, hideLabel, onOpenGroup, collab, path, focusSignal, resetNonce } = props;
750
796
  const t = useTheme();
751
797
  const label = hideLabel ? "" : field.label || field.name;
752
798
  // A text field participates in live collaboration when the document has a
@@ -759,7 +805,7 @@ function FieldControl(props: FieldControlProps) {
759
805
 
760
806
  // Host-supplied control (e.g. the markdown editor) takes precedence.
761
807
  if (renderField) {
762
- const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path });
808
+ const custom = renderField({ field, value, onChange, readOnly, collab: collabText, path, resetNonce });
763
809
  if (custom != null && custom !== false) {
764
810
  return (
765
811
  <View style={{ gap: t.space(2) }}>
@@ -2557,6 +2603,7 @@ function EntryDetail({
2557
2603
  onMove,
2558
2604
  collectionPath,
2559
2605
  folderOptions,
2606
+ history,
2560
2607
  active,
2561
2608
  onActivate,
2562
2609
  onCollapse,
@@ -2579,6 +2626,10 @@ function EntryDetail({
2579
2626
  collectionPath?: string;
2580
2627
  // Existing folders (relative to the glob base) offered by the move autocomplete.
2581
2628
  folderOptions?: string[];
2629
+ // The document-history seam. Provided → a history button appears in this
2630
+ // document's header and its column can show the version browser. Omitted →
2631
+ // the header renders exactly as it did before history existed.
2632
+ history?: HistoryApi;
2582
2633
  // Desktop columns: `active` marks the focused column (accent + header press
2583
2634
  // activates it via onActivate); onCollapse/onClose add the header rail/close
2584
2635
  // controls. All omitted on mobile → unchanged single-detail behavior.
@@ -2599,6 +2650,23 @@ function EntryDetail({
2599
2650
  const canRename = !readOnly && !!onRename;
2600
2651
  const canMove = !readOnly && !!onMove && glob.hierarchical;
2601
2652
  const [prompt, setPrompt] = useState<"rename" | "move" | null>(null);
2653
+ // Per-field "your value was replaced" counters, bumped by a restore. Only the
2654
+ // host-supplied body editor reads them (see FieldSlotArgs.resetNonce); every
2655
+ // built-in control is controlled and needs no telling.
2656
+ const [resetNonces, setResetNonces] = useState<Record<string, number>>({});
2657
+
2658
+ // Whether this column is showing the version browser instead of the form.
2659
+ // Deliberately per-column and ephemeral: history is something you open, read
2660
+ // and close, so it lives and dies with the column rather than in the URL.
2661
+ const [showHistory, setShowHistory] = useState(false);
2662
+ // A column reused for another document (the "preview tab" behavior) must not
2663
+ // carry the previous document's history view onto it — you asked to see that
2664
+ // document, not this one's past.
2665
+ const historyFor = useRef(entry.id);
2666
+ if (historyFor.current !== entry.id) {
2667
+ historyFor.current = entry.id;
2668
+ if (showHistory) setShowHistory(false);
2669
+ }
2602
2670
 
2603
2671
  // Both branches share one value map. The title+body branch is modeled as a
2604
2672
  // single body-source field so autosave logic is uniform.
@@ -2781,6 +2849,112 @@ function EntryDetail({
2781
2849
  [commit],
2782
2850
  );
2783
2851
 
2852
+ // Publish a value map the form did NOT arrive at by typing — a restore from a
2853
+ // past version, or a discard back to the saved document.
2854
+ //
2855
+ // Both used to write local state and stop there, which is only half the job in
2856
+ // a live session. The shared document is the source of truth there: every
2857
+ // collab control prefers its binding to the value handed to it, so a replaced
2858
+ // value was displayed for one frame and then overwritten by the room's
2859
+ // unchanged one, and no peer ever saw it. Anything that replaces values
2860
+ // wholesale has to come through here.
2861
+ //
2862
+ // `names` are the fields whose values were replaced; unchanged ones are left
2863
+ // alone so a replacement never churns the room (or nudges a peer's cursor)
2864
+ // over a value that did not move.
2865
+ const publishReplacement = useCallback(
2866
+ (previous: Record<string, unknown>, next: Record<string, unknown>, names: string[]) => {
2867
+ const moved = names.filter((name) => !sameValue(previous[name], next[name]));
2868
+ if (moved.length === 0) return;
2869
+
2870
+ if (entry.collab) {
2871
+ for (const name of moved) {
2872
+ // A body is a ProseMirror fragment rather than a field binding, so it
2873
+ // is not written here — its editor is told instead, by the nonce below.
2874
+ if (editFields.find((f) => f.name === name)?.source === "body") continue;
2875
+ writeCollabValue(entry.collab, name, previous[name], next[name]);
2876
+ }
2877
+ }
2878
+
2879
+ // Tell the replaced fields their value came from outside. Keyed per field
2880
+ // rather than one counter for the document: the body editor acts on this,
2881
+ // and replacing some unrelated field must not make it rebuild a body a
2882
+ // peer may be mid-sentence in.
2883
+ setResetNonces((prev) => {
2884
+ const bumped = { ...prev };
2885
+ for (const name of moved) bumped[name] = (bumped[name] ?? 0) + 1;
2886
+ return bumped;
2887
+ });
2888
+ },
2889
+ // editFields is derived from entry, captured by id.
2890
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2891
+ [entry.id, entry.collab],
2892
+ );
2893
+
2894
+ // Values taken from a past version and put back into the form.
2895
+ //
2896
+ // It is an edit like any other: the restored fields land in `values`, the
2897
+ // document goes unsaved, and the author saves it (or discards it) themselves.
2898
+ // Nothing is written to git here — history is a place you read, and rewriting
2899
+ // the document from it should still pass through the same review a typed edit
2900
+ // does.
2901
+ const restoreValues = useCallback(
2902
+ (restored: { fields: Record<string, unknown>; body?: string | null }) => {
2903
+ const previous = valuesRef.current;
2904
+ const next = { ...previous, ...restored.fields };
2905
+ // The body is a field in the form like any other; which one it is comes
2906
+ // from the schema (`source: "body"`), the same mapping buildChange uses in
2907
+ // the other direction. A document whose model has no body field simply
2908
+ // has no body to restore.
2909
+ const bodyField = "body" in restored ? editFields.find((f) => f.source === "body") : undefined;
2910
+ if (bodyField) next[bodyField.name] = restored.body ?? "";
2911
+
2912
+ const replaced = Object.keys(restored.fields);
2913
+ if (bodyField) replaced.push(bodyField.name);
2914
+
2915
+ // Touched under the first restored name so a restore that leaves a
2916
+ // required field empty shows its error immediately, rather than looking
2917
+ // saveable until Save is pressed.
2918
+ //
2919
+ // Committed BEFORE publishing: a collab control observing the room echoes
2920
+ // what it sees back through onChange, and that echo builds its next map by
2921
+ // spreading the current one. Publishing first would have it spread the
2922
+ // pre-restore map.
2923
+ commit(next, replaced[0] ?? SYNTHETIC_BODY);
2924
+ publishReplacement(previous, next, replaced);
2925
+ // Drilled-into groups address the pre-restore shape; a restored group
2926
+ // object can have different children entirely.
2927
+ setGroupPath([]);
2928
+ },
2929
+ // editFields is derived from entry, which commit already captures by id.
2930
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2931
+ [commit, entry.id, publishReplacement],
2932
+ );
2933
+
2934
+ // Throw away everything unsaved and go back to the document as stored.
2935
+ //
2936
+ // In a live session that is a change to the ROOM, not just to this screen: the
2937
+ // unsaved work is the collective state every client is looking at, and a
2938
+ // discard that only reverted the local form would leave the document it just
2939
+ // claimed to restore sitting in the CRDT, ready to come straight back.
2940
+ const discardEdits = useCallback(() => {
2941
+ const previous = valuesRef.current;
2942
+ const restored = seed();
2943
+ valuesRef.current = restored;
2944
+ setValues(restored);
2945
+ setTouched({});
2946
+ // The preview is rendering the draft being thrown away, and nothing else
2947
+ // will tell it otherwise: it is fed from edits, and a discard is the one
2948
+ // change to a document that produces no edit. Without this the preview
2949
+ // keeps showing work the author just deleted, until they type again.
2950
+ emitDraft(buildChange(entry.id, editFields, restored), entry);
2951
+ discard();
2952
+ publishReplacement(previous, restored, editFields.map((f) => f.name));
2953
+ setGroupPath([]);
2954
+ // seed/editFields derive from entry, captured by id.
2955
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2956
+ }, [entry.id, discard, emitDraft, publishReplacement]);
2957
+
2784
2958
  // A field edit within the active group-drill frame: write the value at the
2785
2959
  // frame's nested path, then commit the whole map (buildChange serializes it).
2786
2960
  const changeInFrame = useCallback(
@@ -2844,19 +3018,7 @@ function EntryDetail({
2844
3018
  {saveHandler && dirty ? (
2845
3019
  <Pressable
2846
3020
  testID="discard-changes"
2847
- onPress={() => {
2848
- const restored = seed();
2849
- valuesRef.current = restored;
2850
- setValues(restored);
2851
- setTouched({});
2852
- // The preview is rendering the draft that is being thrown away,
2853
- // and nothing else will tell it otherwise: it is fed from edits,
2854
- // and a discard is the one change to a document that produces no
2855
- // edit. Without this the preview keeps showing work the author
2856
- // just deleted, until they type again.
2857
- emitDraft(buildChange(entry.id, editFields, restored), entry);
2858
- discard();
2859
- }}
3021
+ onPress={discardEdits}
2860
3022
  style={{ paddingHorizontal: t.space(2), paddingVertical: t.space(1) }}
2861
3023
  >
2862
3024
  <Text variant="monoSm" color="tertiary">Discard</Text>
@@ -2884,6 +3046,20 @@ function EntryDetail({
2884
3046
  document's own actions, while the menu holds the destructive and
2885
3047
  path-changing ones that should stay one level down. */}
2886
3048
  {documentActions?.(entry)}
3049
+ {/* History is a read of this document, not a change to it, so it sits
3050
+ out here with the other non-destructive actions rather than in the
3051
+ menu beside Delete. It toggles: pressing it again returns the column
3052
+ to the form. */}
3053
+ {history ? (
3054
+ <IconButton
3055
+ name="history"
3056
+ size="sm"
3057
+ active={showHistory}
3058
+ label={showHistory ? "Close history" : "Document history"}
3059
+ onPress={() => setShowHistory((v) => !v)}
3060
+ testID="document-history-toggle"
3061
+ />
3062
+ ) : null}
2887
3063
  {canRename || canMove || (canDelete && onDeleteEntry) ? (
2888
3064
  <DetailMenu
2889
3065
  onRename={canRename ? () => setPrompt("rename") : undefined}
@@ -2899,10 +3075,35 @@ function EntryDetail({
2899
3075
  ) : null}
2900
3076
  </View>
2901
3077
 
2902
- {/* Keyed by entry.id so controls (incl. the markdown editor) remount with
3078
+ {/* History takes over the column's body, keeping its header: the header
3079
+ is what says which document you are looking at, and it carries the
3080
+ control that got you here and gets you back. */}
3081
+ {showHistory && history ? (
3082
+ <DocumentHistory
3083
+ documentId={entry.id}
3084
+ documentPath={entry.path}
3085
+ history={history}
3086
+ onClose={() => setShowHistory(false)}
3087
+ // Restoring needs somewhere for the values to go: a document that is
3088
+ // read-only, or that this host never wired a save for, gets the
3089
+ // history with no selection boxes at all rather than a form it can
3090
+ // dirty and never save. (saveHandler is already undefined when
3091
+ // readOnly, so this covers both.)
3092
+ onRestore={
3093
+ !saveHandler
3094
+ ? undefined
3095
+ : (restored) => {
3096
+ restoreValues(restored);
3097
+ // Back to the form, which is where the restored values now
3098
+ // are and where the decision to keep them is made.
3099
+ setShowHistory(false);
3100
+ }
3101
+ }
3102
+ />
3103
+ ) : /* Keyed by entry.id so controls (incl. the markdown editor) remount with
2903
3104
  fresh initial values (and the scroll resets to top) when the selected
2904
- document changes. The header above stays put; only the body scrolls. */}
2905
- {hasFields ? (
3105
+ document changes. The header above stays put; only the body scrolls. */
3106
+ hasFields ? (
2906
3107
  // The document's path reaches media fields at any nesting depth through
2907
3108
  // context: a picker inside a group or a list item needs it to interpret
2908
3109
  // relative references, and drilling it through every level would touch
@@ -2940,6 +3141,7 @@ function EntryDetail({
2940
3141
  collab={entry.collab}
2941
3142
  path={[...groupPath, f.name].join(".")}
2942
3143
  focusSignal={focusTarget && focusTarget.name === f.name ? focusTarget.nonce : undefined}
3144
+ resetNonce={resetNonces[f.name]}
2943
3145
  />
2944
3146
  ))}
2945
3147
  </ScrollView>
@@ -3019,27 +3221,6 @@ function useMultiSelect(resetKey: string) {
3019
3221
  return { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys };
3020
3222
  }
3021
3223
 
3022
- // CheckBox is the row-selection control for multi-select.
3023
- function CheckBox({ value, onToggle }: { value: boolean; onToggle: () => void }) {
3024
- const t = useTheme();
3025
- return (
3026
- <Pressable
3027
- // Stop the press from bubbling to the row's open handler (the checkbox
3028
- // sits inside the row Pressable) so toggling never also opens the entry.
3029
- onPress={(e?: { stopPropagation?: () => void }) => { e?.stopPropagation?.(); onToggle(); }}
3030
- testID="entry-checkbox"
3031
- style={{
3032
- width: 18, height: 18, borderRadius: t.radius.sm, borderWidth: 1,
3033
- borderColor: value ? t.color.borderStrong : t.color.borderDefault,
3034
- backgroundColor: value ? t.color.surfaceInverted : t.color.surfaceRaised,
3035
- alignItems: "center", justifyContent: "center",
3036
- }}
3037
- >
3038
- {value ? <Icon name="check" size={12} color={t.color.textInverted} /> : null}
3039
- </Pressable>
3040
- );
3041
- }
3042
-
3043
3224
  // SelectionBar appears above the entry list when rows are checked; it offers a
3044
3225
  // bulk move and/or delete plus a clear action (each shown only when wired).
3045
3226
  function SelectionBar({ count, onMove, onDelete, onClear }: { count: number; onMove?: () => void; onDelete?: () => void; onClear: () => void }) {
@@ -3182,7 +3363,7 @@ function EntriesList({
3182
3363
  <View style={{ flexDirection: "row", alignItems: "center" }}>
3183
3364
  {showChecks ? (
3184
3365
  <View style={{ paddingLeft: t.space(4) }}>
3185
- <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} />
3366
+ <CheckBox value={!!checked?.[e.id]} onToggle={() => onToggleEntry?.(e.id)} testID="entry-checkbox" />
3186
3367
  </View>
3187
3368
  ) : null}
3188
3369
  <View style={{ flex: 1 }}>
@@ -3557,9 +3738,28 @@ function ReadOnlyBanner({ notice, actionLabel, onAction }: { notice?: string; ac
3557
3738
  );
3558
3739
  }
3559
3740
 
3560
- // ApplyModalOverlay renders the apply-changes / change-request modal as a
3561
- // full-screen overlay. Shared by both layouts so the modal (and its "learn more"
3562
- // change-request link) is reachable on mobile too.
3741
+ // ModalOverlays renders the browser's full-screen modals apply changes /
3742
+ // change request, and the protected-branch save prompt. Shared by both layouts
3743
+ // so every modal is reachable on mobile too.
3744
+ function ModalOverlays(props: ContentBrowserProps) {
3745
+ return (
3746
+ <>
3747
+ <ApplyModalOverlay {...props} />
3748
+ {props.protectedBranch ? (
3749
+ <ProtectedBranchModal
3750
+ branch={props.protectedBranch}
3751
+ documentLabel={props.protectedBranchDocument}
3752
+ suggestedName={props.protectedBranchSuggestedName}
3753
+ busy={props.creatingProtectedBranch}
3754
+ error={props.protectedBranchError}
3755
+ onCreate={props.onCreateBranchAndSave ?? (() => {})}
3756
+ onCancel={props.onCancelProtectedSave ?? (() => {})}
3757
+ />
3758
+ ) : null}
3759
+ </>
3760
+ );
3761
+ }
3762
+
3563
3763
  function ApplyModalOverlay(props: ContentBrowserProps) {
3564
3764
  if (!props.applyOpen) return null;
3565
3765
  return (
@@ -3599,6 +3799,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
3599
3799
  const mediaShowing = isMediaNavKey(activeNavKey);
3600
3800
  const mediaSet = parseMediaNavKey(activeNavKey);
3601
3801
  const mediaSets = props.media?.sets ?? [];
3802
+ // Forms occupy their own prefixed nav-key space, mirroring media's — a
3803
+ // collection name cannot contain a colon, so neither can collide.
3804
+ const formsShowing = isFormsNavKey(activeNavKey);
3805
+ const activeForm = parseFormsNavKey(activeNavKey);
3602
3806
  // "Browsing" mode (collection-driven): nothing auto-selected, empty states shown.
3603
3807
  const browsing = entriesEmpty !== undefined;
3604
3808
  // Controlled selection: when the host wired onSelectEntry, selection lives in
@@ -3923,6 +4127,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
3923
4127
  <EntryDetail
3924
4128
  entry={col.entry}
3925
4129
  documentActions={props.documentActions}
4130
+ history={props.history}
3926
4131
  onEntryDraft={props.onEntryDraft}
3927
4132
  renderField={renderField}
3928
4133
  onSaveEntry={props.onSaveEntry}
@@ -4052,7 +4257,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4052
4257
  const readOnlyBanner = (
4053
4258
  <ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
4054
4259
  );
4055
- const applyModal = <ApplyModalOverlay {...props} />;
4260
+ const modalOverlays = <ModalOverlays {...props} />;
4056
4261
 
4057
4262
  // The changes surface reuses the same three-pane model as Edit, so switching
4058
4263
  // between them doesn't relayout the screen — only what each pane contains
@@ -4105,7 +4310,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4105
4310
  conflicts={props.selectedConflicts}
4106
4311
  onResolveConflict={props.onResolveConflict}
4107
4312
  />
4108
- {applyModal}
4313
+ {modalOverlays}
4109
4314
  </AppShell>
4110
4315
  );
4111
4316
  }
@@ -4120,7 +4325,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4120
4325
  <Pane flex={1} testID="pane-plugin" scroll={false}>
4121
4326
  {props.contentSlot}
4122
4327
  </Pane>
4123
- {applyModal}
4328
+ {modalOverlays}
4124
4329
  </AppShell>
4125
4330
  );
4126
4331
  }
@@ -4129,6 +4334,60 @@ function DesktopBrowser(props: ContentBrowserProps) {
4129
4334
  // lists the media sets exactly where a collection's documents would be, and the
4130
4335
  // details pane holds the browser for whichever set is selected. Selecting a set
4131
4336
  // goes through onSelectNav, so it lands in the URL like any other selection.
4337
+ // Forms enter through the same content column every other surface uses: the
4338
+ // list of forms sits exactly where a collection's documents would, at the
4339
+ // list width, with the empty state beside it. A list of half a dozen rows
4340
+ // stretched across everything after the sidebar reads as a different kind of
4341
+ // screen than Edit, Changes and Media, when it is the same kind of screen.
4342
+ //
4343
+ // Opening a form is where forms stop fitting the three-pane model, and so it
4344
+ // is where they leave it: that surface is two more levels deep (submissions →
4345
+ // one submission), and threading those through panes meant for collection →
4346
+ // document → editor would give a pane whose meaning changes with the level.
4347
+ // FormsBrowser owns its own split from there.
4348
+ if (formsShowing && props.forms) {
4349
+ if (!activeForm) {
4350
+ return (
4351
+ <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4352
+ {navPane}
4353
+ <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4354
+
4355
+ <Pane width={listW} testID="pane-forms" scroll={false} header={<PaneTitle title="Forms" />}>
4356
+ <FormsBrowser
4357
+ api={props.forms}
4358
+ form={null}
4359
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4360
+ variant="desktop"
4361
+ />
4362
+ </Pane>
4363
+ <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-forms" />
4364
+
4365
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4366
+ <Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
4367
+ </View>
4368
+ {modalOverlays}
4369
+ </AppShell>
4370
+ );
4371
+ }
4372
+ return (
4373
+ <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4374
+ {navPane}
4375
+ <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4376
+ <Pane flex={1} testID="pane-forms" scroll={false}>
4377
+ <FormsBrowser
4378
+ api={props.forms}
4379
+ form={activeForm}
4380
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4381
+ selectedId={props.selectedSubmissionId ?? null}
4382
+ onSelectSubmission={props.onSelectSubmission}
4383
+ variant="desktop"
4384
+ />
4385
+ </Pane>
4386
+ {modalOverlays}
4387
+ </AppShell>
4388
+ );
4389
+ }
4390
+
4132
4391
  if (mediaShowing) {
4133
4392
  return (
4134
4393
  <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
@@ -4151,7 +4410,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4151
4410
  <Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
4152
4411
  </View>
4153
4412
  )}
4154
- {applyModal}
4413
+ {modalOverlays}
4155
4414
  </AppShell>
4156
4415
  );
4157
4416
  }
@@ -4352,7 +4611,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4352
4611
  onClose={() => setMovePrompt(false)}
4353
4612
  />
4354
4613
  ) : null}
4355
- {applyModal}
4614
+ {modalOverlays}
4356
4615
  </AppShell>
4357
4616
  );
4358
4617
  }
@@ -4428,7 +4687,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4428
4687
  const readOnlyBanner = (
4429
4688
  <ReadOnlyBanner notice={props.readOnlyNotice} actionLabel={props.readOnlyNoticeActionLabel} onAction={props.onReadOnlyNoticeAction} />
4430
4689
  );
4431
- const applyModal = <ApplyModalOverlay {...props} />;
4690
+ const modalOverlays = <ModalOverlays {...props} />;
4432
4691
 
4433
4692
  // Media drills the same way a collection does — nav → list → detail — so the
4434
4693
  // back arrow means the same thing at every level. Pressing Media lists the
@@ -4436,6 +4695,8 @@ function MobileBrowser(props: ContentBrowserProps) {
4436
4695
  const mediaShowing = isMediaNavKey(activeNavKey);
4437
4696
  const mediaSet = parseMediaNavKey(activeNavKey);
4438
4697
  const mediaSets = props.media?.sets ?? [];
4698
+ const formsShowing = isFormsNavKey(activeNavKey);
4699
+ const activeForm = parseFormsNavKey(activeNavKey);
4439
4700
  // A plugin screen renders as its own drilled-in screen; Back returns to the
4440
4701
  // nav list like any other selection.
4441
4702
  if (props.contentSlot != null) {
@@ -4455,6 +4716,40 @@ function MobileBrowser(props: ContentBrowserProps) {
4455
4716
  </MobileScreen>
4456
4717
  );
4457
4718
  }
4719
+ if (formsShowing && props.forms) {
4720
+ return (
4721
+ <MobileScreen
4722
+ testID="mobile-forms"
4723
+ header={
4724
+ <>
4725
+ {/* Back steps up one level, not out to the nav — the same rule the
4726
+ media and collection screens follow. */}
4727
+ <IconButton
4728
+ name="chevronLeft"
4729
+ onPress={() => (activeForm ? onSelectNav(FORMS_NAV_KEY) : backToNav())}
4730
+ size="md"
4731
+ label="Back"
4732
+ />
4733
+ <Text variant="body" weight="semibold" style={{ flex: 1 }}>
4734
+ {activeForm
4735
+ ? props.forms.forms.find((f) => f.name === activeForm)?.label || activeForm
4736
+ : "Forms"}
4737
+ </Text>
4738
+ </>
4739
+ }
4740
+ >
4741
+ <FormsBrowser
4742
+ api={props.forms}
4743
+ form={activeForm}
4744
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4745
+ selectedId={props.selectedSubmissionId ?? null}
4746
+ onSelectSubmission={props.onSelectSubmission}
4747
+ variant="mobile"
4748
+ />
4749
+ </MobileScreen>
4750
+ );
4751
+ }
4752
+
4458
4753
  if (mediaShowing) {
4459
4754
  if (!mediaSet) {
4460
4755
  return (
@@ -4518,7 +4813,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4518
4813
  testID="mobile-shell"
4519
4814
  header={header}
4520
4815
  title={props.surface === "changes" ? "Changes" : "Content"}
4521
- overlay={applyModal}
4816
+ overlay={modalOverlays}
4522
4817
  >
4523
4818
  {/* Edit / Changes surface toggle (fits its content) with the Apply button
4524
4819
  to its right on the changes surface. The read-only notice sits below. */}
@@ -4553,8 +4848,11 @@ function MobileBrowser(props: ContentBrowserProps) {
4553
4848
  </View>
4554
4849
  ) : null}
4555
4850
  {readOnlyBanner}
4556
- {sections.map((section) => (
4557
- <View key={section.title}>
4851
+ {sections.map((section, i) => (
4852
+ // Keyed with the index as a fallback: an untitled section renders its
4853
+ // button without a heading, and there is more than one of those now
4854
+ // (Media, Forms), so the title alone is not unique.
4855
+ <View key={section.title || `section-${i}`}>
4558
4856
  <SectionLabel>{section.title}</SectionLabel>
4559
4857
  {section.items.map((item, i) => (
4560
4858
  <NavRow
@@ -4596,7 +4894,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4596
4894
  testID="mobile-entries"
4597
4895
  scroll={false}
4598
4896
  banner={readOnlyBanner}
4599
- overlay={applyModal}
4897
+ overlay={modalOverlays}
4600
4898
  header={
4601
4899
  search.open ? (
4602
4900
  <SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
@@ -4677,7 +4975,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4677
4975
  <MobileScreen
4678
4976
  testID="mobile-entry"
4679
4977
  banner={readOnlyBanner}
4680
- overlay={applyModal}
4978
+ overlay={modalOverlays}
4681
4979
  header={
4682
4980
  <>
4683
4981
  <IconButton name="chevronLeft" onPress={backToEntries} size="md" label="Back" />
@@ -4705,6 +5003,7 @@ function MobileBrowser(props: ContentBrowserProps) {
4705
5003
  <EntryDetail
4706
5004
  entry={entry}
4707
5005
  documentActions={props.documentActions}
5006
+ history={props.history}
4708
5007
  onEntryDraft={props.onEntryDraft}
4709
5008
  renderField={renderField}
4710
5009
  onSaveEntry={props.onSaveEntry}
@@ -4975,14 +5274,20 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
4975
5274
  return set ? titleCaseWord(set.name) : titleCaseWord(name);
4976
5275
  }
4977
5276
 
4978
- // PaneTitle is the content pane's heading, matching the entry list's header
4979
- // height so the panes line up when switching between content and media.
5277
+ // PaneTitle is the content pane's heading for the surfaces with no controls
5278
+ // beside it Changes, Media, Forms.
5279
+ //
5280
+ // Deliberately the same Text the collection header uses (`listHeader`), and
5281
+ // nothing else. Pane's header row already supplies the height, the horizontal
5282
+ // padding and the rule beneath, so the padded wrapper this used to add sat
5283
+ // inside that padding and pushed the title a step right of every collection's —
5284
+ // at `body` rather than `h3`, so it read a size smaller too. Switching between
5285
+ // Posts and Media moved the heading twice over.
4980
5286
  function PaneTitle({ title }: { title: string }) {
4981
- const t = useTheme();
4982
5287
  return (
4983
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(3) }}>
4984
- <Text variant="body" weight="semibold">{title}</Text>
4985
- </View>
5288
+ <Text variant="h3" weight="semibold" style={{ flex: 1 }}>
5289
+ {title}
5290
+ </Text>
4986
5291
  );
4987
5292
  }
4988
5293