@gogitcms/design-system 0.16.0-next.12 → 0.16.0-next.14

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 (33) hide show
  1. package/README.md +5 -2
  2. package/css/tokens.css +1 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/BranchMenu.test.tsx +38 -0
  5. package/src/__tests__/ContentBrowser.changes.test.tsx +23 -14
  6. package/src/__tests__/ContentBrowser.contentslot.test.tsx +10 -6
  7. package/src/__tests__/ContentBrowser.expand.test.tsx +170 -0
  8. package/src/__tests__/ContentBrowser.forms.test.tsx +25 -20
  9. package/src/__tests__/ContentBrowser.listrow.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.master.test.tsx +147 -0
  11. package/src/__tests__/ContentBrowser.media.test.tsx +2 -1
  12. package/src/__tests__/ContentBrowser.references.test.tsx +3 -3
  13. package/src/__tests__/ContentBrowser.reorder.test.tsx +2 -3
  14. package/src/__tests__/ContentBrowser.window.test.tsx +203 -0
  15. package/src/__tests__/MediaBrowser.test.tsx +15 -9
  16. package/src/__tests__/ProtectedBranchModal.test.tsx +14 -0
  17. package/src/__tests__/cssTokens.test.ts +1 -0
  18. package/src/__tests__/documentDrafts.test.ts +59 -0
  19. package/src/components/BranchMenu.tsx +14 -4
  20. package/src/components/ContentBrowser.tsx +584 -460
  21. package/src/components/Input.tsx +37 -5
  22. package/src/components/MediaField.tsx +2 -2
  23. package/src/components/Notifications.tsx +10 -1
  24. package/src/components/ProjectMenu.tsx +100 -26
  25. package/src/components/ProtectedBranchModal.tsx +19 -7
  26. package/src/components/Skeleton.tsx +12 -19
  27. package/src/components/WorkspaceMenu.tsx +163 -0
  28. package/src/components/documentDrafts.ts +19 -4
  29. package/src/components/layout.tsx +33 -1
  30. package/src/icons.ts +5 -0
  31. package/src/index.ts +4 -1
  32. package/src/references.ts +1 -1
  33. package/src/theme.ts +3 -1
@@ -8,11 +8,12 @@ import { Button, IconButton } from "./Button";
8
8
  import { Input } from "./Input";
9
9
  import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
10
10
  import { NavRow } from "./NavRow";
11
- import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
11
+ import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle, type WindowChrome } from "./layout";
12
12
  import { Segment, type SegmentItem } from "./Segment";
13
13
  import { readDraft, writeDraft, clearDraft, subscribeDrafts, hasDraft, draftCount } from "./documentDrafts";
14
14
  import { BranchMenu, type BranchRef } from "./BranchMenu";
15
- import { ProjectMenu, type ProjectRef } from "./ProjectMenu";
15
+ import { ProjectMenu, type ProjectMenuAction, type ProjectRef } from "./ProjectMenu";
16
+ import { WorkspaceMenu, type WorkspaceMenuProps } from "./WorkspaceMenu";
16
17
  import { ChangeDetail, type DocumentChange, type FieldConflict, type ConflictChoice } from "./ChangeDetail";
17
18
  import { ApplyChangesModal, type MergeProgress } from "./ApplyChangesModal";
18
19
  import { ProtectedBranchModal } from "./ProtectedBranchModal";
@@ -39,7 +40,7 @@ import { referenceValue } from "../references";
39
40
  import { MediaBrowser } from "./MediaBrowser";
40
41
  import { FormsBrowser } from "./FormsBrowser";
41
42
  import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
42
- import { type HistoryApi } from "../history";
43
+ import { relativeTime, type HistoryApi } from "../history";
43
44
  import { DocumentHistory } from "./DocumentHistory";
44
45
  import {
45
46
  MEDIA_NAV_KEY,
@@ -53,7 +54,19 @@ import {
53
54
  } from "../media";
54
55
  import type { IconName } from "../icons";
55
56
 
56
- export type CmsNavItem = { key: string; label: string; icon: IconName; count?: number; conflicts?: number };
57
+ export type CmsNavItem = {
58
+ key: string;
59
+ label: string;
60
+ icon: IconName;
61
+ count?: number;
62
+ conflicts?: number;
63
+ // The item has no list of its own: selecting it opens the thing itself in the
64
+ // detail region — a singleton document, a plugin screen — rather than pushing
65
+ // a list into the master pane. The sections stay on screen with its row lit,
66
+ // and the pushed list's title menu leaves it out, since there is no list to
67
+ // switch to. Omitted for a collection, Media and Forms, which all list.
68
+ direct?: boolean;
69
+ };
57
70
  // An entry in the user (avatar) menu: host settings screens and
58
71
  // plugin-contributed links. When any are present the avatar becomes a dropdown.
59
72
  export type UserMenuItem = { label: string; icon?: IconName; onSelect: () => void; testID?: string };
@@ -74,21 +87,26 @@ export type EntryField = {
74
87
  source?: string;
75
88
  format?: string;
76
89
  flavor?: string;
77
- // For a media picker (`component: "media"`): which configured media set to
78
- // browse, and — on a string field — which form of the path to write. `media`
79
- // comes from display.media, `storeAs` from display.store_as. On an array
80
- // field these describe its *items*, making each one a media picker.
90
+ // For a media picker (`component: "media"`, or an array whose items are —
91
+ // `itemComponent: "media"`): which configured media set to browse, and —
92
+ // for a string value — which form of the path to write. Both come from the
93
+ // field's `media:` block; on an array they describe its *items*.
81
94
  media?: string;
82
95
  storeAs?: string;
83
96
  // For a document picker (`component: "reference"`, or an array whose items
84
- // are references): what the field points at and stores. An object-shaped
85
- // reference's `fields` are the derived children — the key child and the
86
- // embedded copies — which the server recomputes on every save and the
87
- // editor shows read-only (docs/references.md).
97
+ // are — `itemComponent: "reference"`): what the field points at and stores.
98
+ // An object-shaped reference declares its `fields` — the key child and the
99
+ // embedded copies — and the copies are the server's: recomputed on every
100
+ // save and shown read-only here (docs/references.md).
88
101
  reference?: ReferenceDef;
89
102
  value: unknown;
90
103
  // Item type for an array field (string | number | boolean | object).
91
104
  of?: string;
105
+ // The control each item of an array renders, from display.item.component.
106
+ // `list` describes the array; what an item is — a text input, a media
107
+ // picker, a document picker — is declared here, never inferred from the
108
+ // array's media or reference block. Absent means the default for `of`.
109
+ itemComponent?: string;
92
110
  // Child fields for an object field (or an array whose `of` is object).
93
111
  fields?: EntryField[];
94
112
  // Object shapes a mixed-list array (component: "mixedList") can hold.
@@ -206,6 +224,12 @@ export type CmsEntry = {
206
224
  conflicted?: boolean;
207
225
  title: string;
208
226
  body: string;
227
+ // When this document last changed, as an ISO 8601 string. The list row shows
228
+ // its age in place of the file path — a document is its title and when it was
229
+ // last touched, and the path is one click away in the editor's Details. Hosts
230
+ // that have no such timestamp (the changes surface, whose rows are diffs of
231
+ // files) leave it off and keep the path.
232
+ updatedAt?: string;
209
233
  // When present, the detail pane renders a field editor instead of title/body.
210
234
  fields?: EntryField[];
211
235
  // A list entry whose full document (fields/body) hasn't loaded yet — the detail
@@ -302,14 +326,15 @@ export type ContentBrowserProps = {
302
326
  // Items shown in the avatar dropdown above Sign out. Absent/empty keeps the
303
327
  // pre-menu topbar (sign-out icon + static avatar) byte-identical.
304
328
  userMenuItems?: UserMenuItem[];
305
- // When set, the content area is replaced by this node (plugin screens): on
306
- // desktop the list + detail panes give way to one flexing pane, on mobile it
307
- // renders as its own screen titled `contentTitle`. Sidebar and topbar stay.
329
+ // When set, the detail region is replaced by this node (plugin screens): on
330
+ // desktop it becomes one flexing pane beside the master pane, which stays on
331
+ // the sections with the plugin's row lit; on mobile it renders as its own
332
+ // screen titled `contentTitle`. The topbar stays.
308
333
  contentSlot?: React.ReactNode;
309
334
  contentTitle?: string;
310
335
  // Providing entriesEmpty switches on "collection browsing" mode: nothing is
311
- // auto-selected, an empty collection selection shows this message, and an
312
- // unselected document shows "Select {collection}".
336
+ // auto-selected, the detail region shows this message while no collection
337
+ // is selected, and an unselected document shows "Select {collection}".
313
338
  entriesEmpty?: string;
314
339
 
315
340
  // Controlled document selection. When onSelectEntry is provided the component
@@ -496,9 +521,33 @@ export type ContentBrowserProps = {
496
521
  onSelectProject?: (project: ProjectRef) => void;
497
522
  /** cms.config.js pins this deployment to one project: shown, but inert. */
498
523
  projectLocked?: boolean;
524
+
525
+ // ---- an app window (all optional; the web and mobile apps pass none) -----
526
+ // The desktop app is a window per project, moving between workspaces rather
527
+ // than being addressed to one by a URL. These are what that needs of the
528
+ // desktop layout's top bar; the mobile layout ignores them.
529
+ /**
530
+ * The workspace switcher, in place of the static workspace tag: every
531
+ * workspace the user belongs to, and what choosing one does.
532
+ */
533
+ workspaceMenu?: WorkspaceMenuProps;
534
+ /** Offers each project in the project menu in a window of its own. */
535
+ onOpenProjectInNewWindow?: (project: ProjectRef) => void;
536
+ /** Rows under the project menu's list. */
537
+ projectMenuActions?: ProjectMenuAction[];
538
+ /**
539
+ * Room for the platform's window controls, and the top bar marked as the
540
+ * window's drag region, for a host drawing its own window chrome (the
541
+ * desktop app's chromeless macOS window).
542
+ */
543
+ windowChrome?: WindowChrome;
499
544
  // Omitted → no "New branch" row. Rejecting a name should throw so the inline
500
545
  // form can show why.
501
546
  onCreateBranch?: (name: string) => Promise<void> | void;
547
+ // The repository's branch prefix ("cms/"). Every branch the editor creates —
548
+ // from the branch menu or the protected-branch prompt — is named under it, so
549
+ // both forms show it read-only ahead of the name and hand back the full name.
550
+ branchPrefix?: string;
502
551
 
503
552
  // The comparison target on the changes surface. When set, the top bar shows a
504
553
  // `source → target` pair instead of a single branch menu — the source is the
@@ -634,6 +683,11 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
634
683
  // what git thinks of the file: a document can be a local draft, or committed
635
684
  // and modified on the branch, or both, and they are not the same state.
636
685
  const draft = useHasDraft(entry.id);
686
+ // "Updated 3d ago": the age alone reads as a bare number against the title,
687
+ // with nothing saying what happened then. An unparseable timestamp renders as
688
+ // "" — fall back to the path rather than leave the row's second line blank.
689
+ const age = entry.updatedAt ? relativeTime(entry.updatedAt) : "";
690
+ const updated = age ? `Updated ${age}` : "";
637
691
  return (
638
692
  <ListRow conflicted={entry.conflicted}>
639
693
  <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
@@ -652,8 +706,8 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
652
706
  {entry.status}
653
707
  </Text>
654
708
  ) : null}
655
- <Text variant="monoSm" color="tertiary" numberOfLines={1}>
656
- {entry.path}
709
+ <Text variant="monoSm" color="tertiary" numberOfLines={1} testID="entry-subtitle">
710
+ {updated || entry.path}
657
711
  </Text>
658
712
  </View>
659
713
  </ListRow>
@@ -1715,8 +1769,10 @@ function ListControl({
1715
1769
  // what was picked: the only thing such an item can hold is a picked
1716
1770
  // document, so an empty slot to fill in afterwards is a step nobody wants.
1717
1771
  // Without a host seam there is nothing to pick from, and the button is
1718
- // disabled as the field's own Choose button is.
1719
- const reference = field.reference;
1772
+ // disabled as the field's own Choose button is. "Reference list" is the
1773
+ // item's declared control, not the presence of a block: an array whose
1774
+ // items are not declared pickers is a plain list whatever else it carries.
1775
+ const reference = field.itemComponent === "reference" ? field.reference : undefined;
1720
1776
  const referenceApi = useReferenceApi();
1721
1777
  const [pickingNew, setPickingNew] = React.useState(false);
1722
1778
  const addItem = () => {
@@ -1732,20 +1788,21 @@ function ListControl({
1732
1788
  onChange([...items, referenceValue(target, isObject ? "object" : "string", reference, undefined)]);
1733
1789
  };
1734
1790
 
1735
- // Items inherit the array's *item-level* display config, not the array's own
1736
- // component — `list` describes the array, and is not a control an item could
1737
- // render. An array that names a media set makes each item a media picker;
1738
- // everything else falls back to the default control for the item type.
1791
+ // Items render the array's *declared* item control (display.item.component),
1792
+ // not the array's own component — `list` describes the array, and is not a
1793
+ // control an item could render. The media and reference blocks describe the
1794
+ // items too, so they travel with the item; they never pick its control. An
1795
+ // array that declares nothing falls back to the default control for `of`.
1739
1796
  const itemField: EntryField = {
1740
1797
  name: field.name,
1741
1798
  type: itemType,
1742
1799
  of: undefined,
1743
1800
  fields: field.fields,
1744
1801
  value: null,
1745
- ...(field.media ? { component: "media", media: field.media, storeAs: field.storeAs } : {}),
1746
- // An array that declares a reference makes each item a document picker, the
1747
- // same way an array naming a media set makes each item a media picker.
1748
- ...(field.reference ? { component: "reference", reference: field.reference } : {}),
1802
+ component: field.itemComponent,
1803
+ media: field.media,
1804
+ storeAs: field.storeAs,
1805
+ reference: field.reference,
1749
1806
  };
1750
1807
 
1751
1808
  // The list's own path, from the FieldControl wrapping this control; an item
@@ -2776,7 +2833,8 @@ function EntryDetail({
2776
2833
  history,
2777
2834
  active,
2778
2835
  onActivate,
2779
- onCollapse,
2836
+ expanded,
2837
+ onToggleExpand,
2780
2838
  onClose,
2781
2839
  dragHandle,
2782
2840
  }: {
@@ -2805,11 +2863,13 @@ function EntryDetail({
2805
2863
  // the header renders exactly as it did before history existed.
2806
2864
  history?: HistoryApi;
2807
2865
  // Desktop columns: `active` marks the focused column (accent + header press
2808
- // activates it via onActivate); onCollapse/onClose add the header rail/close
2809
- // controls. All omitted on mobile → unchanged single-detail behavior.
2866
+ // activates it via onActivate); onToggleExpand/onClose add the header
2867
+ // expand/close controls, and `expanded` says which way the expand control
2868
+ // points. All omitted on mobile → unchanged single-detail behavior.
2810
2869
  active?: boolean;
2811
2870
  onActivate?: () => void;
2812
- onCollapse?: () => void;
2871
+ expanded?: boolean;
2872
+ onToggleExpand?: () => void;
2813
2873
  onClose?: () => void;
2814
2874
  // A drag grip rendered at the head of the breadcrumb row to reorder columns.
2815
2875
  dragHandle?: React.ReactNode;
@@ -3299,8 +3359,15 @@ function EntryDetail({
3299
3359
  onMove={canMove ? () => setPrompt("move") : undefined}
3300
3360
  onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
3301
3361
  />
3302
- {onCollapse ? (
3303
- <IconButton name="panelLeft" size="sm" label="Collapse column" onPress={onCollapse} testID="column-collapse" />
3362
+ {onToggleExpand ? (
3363
+ <IconButton
3364
+ name={expanded ? "minimize" : "maximize"}
3365
+ size="sm"
3366
+ active={expanded}
3367
+ label={expanded ? "Collapse column" : "Expand column"}
3368
+ onPress={onToggleExpand}
3369
+ testID="column-expand"
3370
+ />
3304
3371
  ) : null}
3305
3372
  {onClose ? (
3306
3373
  <IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
@@ -3496,6 +3563,12 @@ const MIN_COL = 480;
3496
3563
  // a burst of keystrokes is one update rather than twenty.
3497
3564
  const DRAFT_DEBOUNCE_MS = 150;
3498
3565
 
3566
+ // Which pane, if any, has taken over the desktop layout: the master pane (the
3567
+ // sections or the list pushed into it), or a single document column. Expanding
3568
+ // is exclusive — one pane at a time, and everything else stops rendering — so
3569
+ // this is a single value rather than a flag per pane.
3570
+ type ExpandedPane = { kind: "master" } | { kind: "column"; id: string } | null;
3571
+
3499
3572
  // A normalized desktop column, unifying the host-controlled (openTabs) and
3500
3573
  // self-owned (openIds) sources so the render + sizing paths stay single. `entry`
3501
3574
  // is undefined while a controlled tab's document loads.
@@ -4000,6 +4073,7 @@ function ModalOverlays(props: ContentBrowserProps) {
4000
4073
  error={props.protectedBranchError}
4001
4074
  onCreate={props.onCreateBranchAndSave ?? (() => {})}
4002
4075
  onCancel={props.onCancelProtectedSave ?? (() => {})}
4076
+ prefix={props.branchPrefix}
4003
4077
  />
4004
4078
  ) : null}
4005
4079
  </>
@@ -4066,20 +4140,40 @@ function DesktopBrowser(props: ContentBrowserProps) {
4066
4140
 
4067
4141
  // ---- open document columns (ephemeral desktop state) --------------------
4068
4142
  // openIds are the documents shown as side-by-side columns, in display order.
4069
- // collapsedCols marks which columns are collapsed to a rail. Both reset when
4070
- // the collection changes (open columns belong to the active collection).
4143
+ // They reset when the collection changes (open columns belong to the active
4144
+ // collection).
4071
4145
  const [openIds, setOpenIds] = useState<string[]>([]);
4072
- const [collapsedCols, setCollapsedCols] = useState<Record<string, boolean>>({});
4073
- const [listCollapsed, setListCollapsed] = useState(false);
4146
+ // The one pane that has taken over the screen, if any: the master pane, or a
4147
+ // single document column. While it holds, nothing else renders, and the
4148
+ // control that expanded it puts the layout back.
4149
+ const [expanded, setExpanded] = useState<ExpandedPane>(null);
4074
4150
  const [region, setRegion] = useState({ w: 0, h: 0 });
4075
- // Resizable pane widths (desktop). Seeded from the layout defaults; dragging a
4076
- // seam between panes updates them live (clamped). Local, ephemeral view state.
4077
- const [navW, setNavW] = useState(t.layout.sidebar);
4078
- const [listW, setListW] = useState(340);
4151
+ // The master pane's width. Seeded from the layout default; dragging the seam
4152
+ // beside it updates it live (clamped). Local, ephemeral view state.
4153
+ const [masterW, setMasterW] = useState(t.layout.master);
4154
+ // ---- master / detail ----------------------------------------------------
4155
+ // One pane plays the sidebar and the content list in turn. At rest it lists
4156
+ // the sections; selecting an item that HAS a list pushes that list in, headed
4157
+ // by a Back control and a title that doubles as a switcher. A direct item — a
4158
+ // singleton, a plugin screen — has no list: it opens in the detail region and
4159
+ // the sections stay put with its row lit.
4160
+ const activeItem = navItemFor(sections, activeNavKey);
4161
+ // Media and Forms are named even when the host supplied no row for them
4162
+ // (a media key with no sets yet, a forms key on a host that lists forms
4163
+ // some other way): the pushed list still needs a title.
4164
+ const activeLabel = activeItem?.label ?? (mediaShowing ? "Media" : formsShowing ? "Forms" : "Content");
4165
+ const pushed = !!activeNavKey && !activeItem?.direct && props.contentSlot == null;
4079
4166
  // The self-owned column set (openIds) is only used in non-tabbed mode; in tabbed
4080
- // mode the host owns it, so these maintenance effects no-op. `collapsedCols` and
4081
- // `listCollapsed` stay local (pure view state) in both modes.
4082
- useEffect(() => { if (!tabbed) { setOpenIds([]); setCollapsedCols({}); } }, [activeNavKey, tabbed]);
4167
+ // mode the host owns it, so this maintenance effect no-ops. `expanded` stays
4168
+ // local (pure view state) in both modes.
4169
+ useEffect(() => { if (!tabbed) setOpenIds([]); }, [activeNavKey, tabbed]);
4170
+ // Anything that opens in the detail region puts a full-screen master back:
4171
+ // the thing just opened would otherwise be invisible. Called from every
4172
+ // control in the master that does so (a list row, a media set, a form), and
4173
+ // from the effect below for a direct item, which the host resolves.
4174
+ const revealDetail = () => setExpanded((x) => (x?.kind === "master" ? null : x));
4175
+ const directActive = !!activeNavKey && !pushed;
4176
+ useEffect(() => { if (directActive) revealDetail(); }, [directActive, activeNavKey]);
4083
4177
  // Clear the selection when the collection changes so the detail shows its empty
4084
4178
  // state. In controlled mode the host owns this (the URL changes with the nav).
4085
4179
  useEffect(() => {
@@ -4109,8 +4203,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
4109
4203
  // RNW surfaces the click modifiers on nativeEvent (typed without them here).
4110
4204
  const mods = ev?.nativeEvent as unknown as { shiftKey?: boolean; metaKey?: boolean } | undefined;
4111
4205
  const newColumn = !!(mods?.shiftKey || mods?.metaKey);
4112
- // Expand the column if this document is open but collapsed.
4113
- setCollapsedCols((c) => (c[e.id] ? { ...c, [e.id]: false } : c));
4206
+ // Opening a document from a full-screen list restores the layout: the column
4207
+ // it opens in lives in the region that list is covering.
4208
+ revealDetail();
4114
4209
  // Tabbed mode: hand the intent to the host (it owns the open-tab set).
4115
4210
  if (tabbed) { props.onOpenEntry?.(e, { newColumn }); return; }
4116
4211
  // Plain click on a not-yet-open document: swap it into the active column's
@@ -4129,12 +4224,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4129
4224
  };
4130
4225
  // Closing a column drops it; if it was active, activate a neighbor (or clear).
4131
4226
  const closeColumn = (id: string) => {
4132
- setCollapsedCols((c) => {
4133
- if (!(id in c)) return c;
4134
- const cp = { ...c };
4135
- delete cp[id];
4136
- return cp;
4137
- });
4227
+ setExpanded((x) => (x?.kind === "column" && x.id === id ? null : x));
4138
4228
  if (tabbed) { props.onCloseTab?.(id); return; }
4139
4229
  const idx = openIds.indexOf(id);
4140
4230
  const next = openIds.filter((x) => x !== id);
@@ -4149,8 +4239,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
4149
4239
  const { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys } = useMultiSelect(activeNavKey);
4150
4240
  const [movePrompt, setMovePrompt] = useState(false);
4151
4241
  const selected = entries.find((e) => e.id === selectedId) ?? (controlled || browsing ? null : entries[0] ?? null);
4152
- const activeLabel = sections.flatMap((s) => s.items).find((i) => i.key === activeNavKey)?.label ?? "Content";
4153
4242
  const showCreate = !!(creating && createFields && onSubmitCreate);
4243
+ // The create form opens as a column in the detail region, which an expanded
4244
+ // pane either hides (the list) or crowds out (a column). Starting a create
4245
+ // puts the layout back rather than opening a form nobody can see.
4246
+ useEffect(() => { if (showCreate) setExpanded(null); }, [showCreate]);
4154
4247
 
4155
4248
  // Folder drill-down for hierarchical collections: navigate the folder tree and
4156
4249
  // show only the folders + documents at the current level.
@@ -4212,16 +4305,22 @@ function DesktopBrowser(props: ContentBrowserProps) {
4212
4305
  canMove: !!onMove,
4213
4306
  collectionPath,
4214
4307
  }));
4215
- // Size the columns: one expanded column fills the region; several share it down
4216
- // to MIN_COL, past which the region scrolls horizontally. Collapsed rails take a
4217
- // fixed sliver.
4218
- const collapsedCount = columns.filter((c) => collapsedCols[c.id]).length;
4219
- const expandedCount = columns.length - collapsedCount + (showCreate ? 1 : 0);
4220
- const avail = Math.max(0, region.w - collapsedCount * RAIL_W);
4308
+ // Which pane is expanded, resolved against what is actually open: a column
4309
+ // that has since closed (or that belonged to another collection) falls back to
4310
+ // the normal layout rather than blanking the screen.
4311
+ const expandedCol = expanded?.kind === "column" ? columns.find((c) => c.id === expanded.id) ?? null : null;
4312
+ const masterExpanded = expanded?.kind === "master";
4313
+ // An expanded column is the only one on screen. The others stay open (they
4314
+ // keep their place in openIds/openTabs, and their unsaved edits) — they are
4315
+ // just not rendered until it collapses.
4316
+ const shownColumns = expandedCol ? [expandedCol] : columns;
4317
+ // Size the columns: a lone column fills the region; several share it down to
4318
+ // MIN_COL, past which the region scrolls horizontally.
4319
+ const colCount = shownColumns.length + (showCreate ? 1 : 0);
4221
4320
  let colW = MIN_COL;
4222
- if (region.w > 0 && expandedCount > 0) {
4223
- const per = avail / expandedCount;
4224
- colW = per >= MIN_COL ? Math.floor(per) : expandedCount <= 1 ? Math.max(1, Math.floor(avail)) : MIN_COL;
4321
+ if (region.w > 0 && colCount > 0) {
4322
+ const per = region.w / colCount;
4323
+ colW = per >= MIN_COL ? Math.floor(per) : colCount <= 1 ? Math.max(1, Math.floor(region.w)) : MIN_COL;
4225
4324
  }
4226
4325
  const hasColumns = columns.length > 0 || showCreate;
4227
4326
 
@@ -4231,8 +4330,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
4231
4330
  // origin plus the scroll offset), then `slotAtX` picks the gap it's nearest.
4232
4331
  // Reorder applies to the host (onReorderTabs) in tabbed mode or the local
4233
4332
  // openIds otherwise. Web-only affordance; the native shells never mount it.
4234
- const colIds = columns.map((c) => c.id);
4235
- const colWidths = columns.map((c) => (collapsedCols[c.id] ? RAIL_W : colW));
4333
+ // Reorder operates on the columns on screen, so an expanded column — the only
4334
+ // one rendered — has nothing to trade places with and grows no grip.
4335
+ const colIds = shownColumns.map((c) => c.id);
4336
+ const colWidths = shownColumns.map(() => colW);
4236
4337
  const idsRef = useRef(colIds);
4237
4338
  idsRef.current = colIds;
4238
4339
  const widthsRef = useRef(colWidths);
@@ -4303,34 +4404,64 @@ function DesktopBrowser(props: ContentBrowserProps) {
4303
4404
  <View key={key} style={{ width: 2, alignSelf: "stretch", backgroundColor: t.color.borderActive }} />
4304
4405
  );
4305
4406
 
4306
- const listLabel = browsing && !activeNavKey ? "Content" : activeLabel;
4307
4407
  const createBtn = canCreate && onStartCreate && activeNavKey ? (
4308
4408
  <IconButton name="plus" size="sm" label="Create" onPress={onStartCreate} testID="create-start" />
4309
4409
  ) : null;
4310
4410
 
4311
4411
  // Collection search + faceted filters. `search` is enabled for a real (non
4312
- // singleton) active collection; the icons/inputs render into the list header.
4412
+ // singleton) active collection; the icons/inputs render into the master header.
4313
4413
  const search = useCollectionSearch(props, activeNavKey);
4314
4414
  const searchEnabled = !!props.searchable && !!activeNavKey;
4315
4415
  const facetFields = props.facetFields ?? [];
4316
- // The entry-list header swaps to a search input while a search is open.
4317
- const listHeader = search.open ? (
4318
- <SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
4319
- ) : (
4416
+
4417
+ // The master pane's header. At rest it names the surface; with a list pushed
4418
+ // in it carries Back, the list's title (a switcher when there is more than one
4419
+ // list), and whatever controls that list has. The expand control is there in
4420
+ // both states, exactly as a column's is.
4421
+ const expandBtn = (
4422
+ <IconButton
4423
+ name={masterExpanded ? "minimize" : "maximize"}
4424
+ size="sm"
4425
+ active={masterExpanded}
4426
+ label={masterExpanded ? "Collapse pane" : "Expand pane"}
4427
+ onPress={() => setExpanded(masterExpanded ? null : { kind: "master" })}
4428
+ testID="master-expand"
4429
+ />
4430
+ );
4431
+ const masterHeader = (extras?: React.ReactNode) => (
4320
4432
  <>
4321
- <Text variant="h3" weight="semibold" style={{ flex: 1 }}>{listLabel}</Text>
4322
- {searchEnabled ? (
4323
- <>
4324
- <IconButton name="search" size="sm" label="Search" onPress={search.openSearch} testID="search-open" />
4325
- {facetFields.length > 0 ? (
4326
- <IconButton name="filter" size="sm" label="Filter" onPress={search.openAdvanced} testID="filter-open" />
4327
- ) : null}
4328
- </>
4433
+ {pushed ? (
4434
+ <IconButton name="chevronLeft" size="sm" label="Back to sections" onPress={() => onSelectNav("")} testID="master-back" />
4329
4435
  ) : null}
4330
- {createBtn}
4331
- <IconButton name="panelLeft" size="sm" label="Collapse list" onPress={() => setListCollapsed(true)} testID="list-collapse" />
4436
+ {pushed ? (
4437
+ <MasterTitle label={activeLabel} sections={sections} activeNavKey={activeNavKey} onSelect={onSelectNav} />
4438
+ ) : (
4439
+ <Text variant="h3" weight="semibold" style={{ flex: 1 }} testID="master-title">
4440
+ {(props.surface ?? "edit") === "changes" ? "Changes" : "Content"}
4441
+ </Text>
4442
+ )}
4443
+ {extras}
4444
+ {expandBtn}
4332
4445
  </>
4333
4446
  );
4447
+ // A collection's header swaps to a search input while a search is open.
4448
+ const collectionHeader = search.open ? (
4449
+ <SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
4450
+ ) : (
4451
+ masterHeader(
4452
+ <>
4453
+ {searchEnabled ? (
4454
+ <>
4455
+ <IconButton name="search" size="sm" label="Search" onPress={search.openSearch} testID="search-open" />
4456
+ {facetFields.length > 0 ? (
4457
+ <IconButton name="filter" size="sm" label="Filter" onPress={search.openAdvanced} testID="filter-open" />
4458
+ ) : null}
4459
+ </>
4460
+ ) : null}
4461
+ {createBtn}
4462
+ </>,
4463
+ )
4464
+ );
4334
4465
 
4335
4466
  // Activate a column: hand off to the host in tabbed mode (it switches the active
4336
4467
  // collection + URL to the tab); otherwise move the local selection.
@@ -4365,7 +4496,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
4365
4496
  title={col.title || "Untitled"}
4366
4497
  active={!showCreate && col.active}
4367
4498
  onActivate={() => activateColumn(col)}
4368
- onCollapse={() => setCollapsedCols((c) => ({ ...c, [col.id]: true }))}
4499
+ expanded={!!expandedCol}
4500
+ onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
4369
4501
  onClose={() => closeColumn(col.id)}
4370
4502
  dragHandle={columnGrip(col.id)}
4371
4503
  >
@@ -4392,7 +4524,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
4392
4524
  folderOptions={moveFolders}
4393
4525
  active={!showCreate && col.active}
4394
4526
  onActivate={() => activateColumn(col)}
4395
- onCollapse={() => setCollapsedCols((c) => ({ ...c, [col.id]: true }))}
4527
+ expanded={!!expandedCol}
4528
+ onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
4396
4529
  onClose={() => closeColumn(col.id)}
4397
4530
  dragHandle={columnGrip(col.id)}
4398
4531
  />
@@ -4406,9 +4539,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
4406
4539
 
4407
4540
  const topBar = (
4408
4541
  <TopBar
4542
+ windowChrome={props.windowChrome}
4409
4543
  left={
4410
4544
  <>
4411
- <WorkspaceTag workspace={workspace} />
4545
+ {props.workspaceMenu ? <WorkspaceMenu {...props.workspaceMenu} /> : <WorkspaceTag workspace={workspace} />}
4412
4546
  {/* Project before branch: the coarser choice reads first, matching the
4413
4547
  URL's own order (projects/{name}/branches/{id}). */}
4414
4548
  <ProjectMenu
@@ -4416,6 +4550,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
4416
4550
  projects={props.projects}
4417
4551
  onSelect={props.onSelectProject}
4418
4552
  locked={props.projectLocked}
4553
+ onOpenInNewWindow={props.onOpenProjectInNewWindow}
4554
+ actions={props.projectMenuActions}
4419
4555
  />
4420
4556
  <BranchMenu
4421
4557
  current={workspace.branch}
@@ -4423,6 +4559,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4423
4559
  defaultBranch={props.defaultBranch}
4424
4560
  onSelect={props.onSelectBranch}
4425
4561
  onCreate={props.onCreateBranch}
4562
+ prefix={props.onCreateBranch ? props.branchPrefix : undefined}
4426
4563
  />
4427
4564
  {/* On the changes surface the top bar reads `source → target`: the
4428
4565
  branch above is the source under review, this is what it's compared
@@ -4480,8 +4617,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
4480
4617
  />
4481
4618
  );
4482
4619
 
4483
- const navPane = (
4484
- <Pane width={navW} testID="pane-nav">
4620
+ // The sections list — the master pane at rest. Plain rows; a row with a list
4621
+ // pushes it in (through the host's nav key), a direct row opens its thing in
4622
+ // the detail region and stays lit here.
4623
+ const sectionsList = (
4624
+ <ScrollView testID="nav-list" contentContainerStyle={{ paddingVertical: t.space(2) }}>
4485
4625
  {sections.map((section, i) => (
4486
4626
  <View key={section.title || `section-${i}`}>
4487
4627
  {/* An untitled section is a standalone button (Media), not a group. */}
@@ -4501,7 +4641,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4501
4641
  ))}
4502
4642
  </View>
4503
4643
  ))}
4504
- </Pane>
4644
+ </ScrollView>
4505
4645
  );
4506
4646
 
4507
4647
  // A full-width read-only notice (branch frozen in a change request) below the
@@ -4511,274 +4651,206 @@ function DesktopBrowser(props: ContentBrowserProps) {
4511
4651
  );
4512
4652
  const modalOverlays = <ModalOverlays {...props} />;
4513
4653
 
4514
- // The changes surface reuses the same three-pane model as Edit, so switching
4515
- // between them doesn't relayout the screen — only what each pane contains
4516
- // changes. Everything that mutates a document (create, bulk select, search,
4517
- // folder drill-down, tabs, autosave) is deliberately absent: this describes a
4518
- // comparison between two branches, not a document you can save.
4519
- if ((props.surface ?? "edit") === "changes") {
4520
- const changeCount = entries.length;
4521
- return (
4522
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4523
- {navPane}
4524
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4525
-
4526
- <Pane
4527
- width={listW}
4528
- testID="pane-changes"
4529
- scroll={false}
4530
- header={<PaneTitle title={activeNavKey ? activeLabel : "Changes"} />}
4531
- >
4532
- {!activeNavKey ? (
4533
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4534
- <Text variant="body" color="tertiary" testID="changes-empty">
4535
- {entriesEmpty ?? "Select a collection"}
4536
- </Text>
4537
- </View>
4538
- ) : (
4539
- <EntriesList
4540
- variant="desktop"
4541
- entries={entries}
4542
- selectedId={selectedId}
4543
- onOpenEntry={(entry) => activate(entry)}
4544
- header={
4545
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
4546
- <Text variant="label" color="tertiary">
4547
- {`${changeCount} changed`}
4548
- </Text>
4549
- </View>
4550
- }
4551
- folders={[]}
4552
- onFolderPress={() => {}}
4553
- />
4554
- )}
4555
- </Pane>
4556
- <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-list" />
4654
+ const changes = (props.surface ?? "edit") === "changes";
4655
+ const emptyDetail = (message: string, testID: string) => (
4656
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4657
+ <Text variant="body" color="tertiary" testID={testID}>{message}</Text>
4658
+ </View>
4659
+ );
4557
4660
 
4558
- <ChangeDetail
4559
- change={props.selectedChange}
4560
- loading={props.loadingChange}
4561
- emptyMessage={activeNavKey ? "Select a change" : "No changes on this branch"}
4562
- conflicts={props.selectedConflicts}
4563
- onResolveConflict={props.onResolveConflict}
4564
- />
4565
- {modalOverlays}
4566
- </AppShell>
4661
+ // What the master pane holds and how it is headed, by what is pushed into it.
4662
+ // Nothing pushed → the sections. The changes surface, Media and Forms each
4663
+ // push a list of their own kind; a collection pushes its entries. The header
4664
+ // is the same shape throughout, so switching between them moves nothing.
4665
+ let masterHeaderNode: React.ReactNode;
4666
+ let masterBody: React.ReactNode;
4667
+ if (!pushed) {
4668
+ masterHeaderNode = masterHeader();
4669
+ masterBody = sectionsList;
4670
+ } else if (changes) {
4671
+ // The changes list: the same rows as the editor's, carrying a status and a
4672
+ // diff stat instead of a draft badge. Everything that mutates a document
4673
+ // (create, bulk select, search, folder drill-down) is deliberately absent:
4674
+ // this describes a comparison between two branches, not something to edit.
4675
+ masterHeaderNode = masterHeader();
4676
+ masterBody = (
4677
+ <EntriesList
4678
+ variant="desktop"
4679
+ entries={entries}
4680
+ selectedId={selectedId}
4681
+ onOpenEntry={(entry) => { revealDetail(); activate(entry); }}
4682
+ header={
4683
+ <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
4684
+ <Text variant="label" color="tertiary">
4685
+ {`${entries.length} changed`}
4686
+ </Text>
4687
+ </View>
4688
+ }
4689
+ folders={[]}
4690
+ onFolderPress={() => {}}
4691
+ />
4567
4692
  );
4568
- }
4569
-
4570
- // A plugin screen replaces the list + detail panes with one flexing pane;
4571
- // the sidebar keeps its place (and its highlight, via the host's nav key).
4572
- if (props.contentSlot != null) {
4573
- return (
4574
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4575
- {navPane}
4576
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4577
- <Pane flex={1} testID="pane-plugin" scroll={false}>
4578
- {props.contentSlot}
4579
- </Pane>
4580
- {modalOverlays}
4581
- </AppShell>
4693
+ } else if (formsShowing && props.forms) {
4694
+ // The forms list sits where a collection's documents would. Opening a form
4695
+ // hands the detail region to FormsBrowser, which owns the two levels beneath
4696
+ // (submissions → one submission); the list stays here so you can switch.
4697
+ masterHeaderNode = masterHeader();
4698
+ masterBody = (
4699
+ <FormsBrowser
4700
+ api={props.forms}
4701
+ form={null}
4702
+ onSelectForm={(name) => { revealDetail(); onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY); }}
4703
+ variant="desktop"
4704
+ />
4582
4705
  );
4583
- }
4584
-
4585
- // Media reuses the three-pane model rather than replacing it: the content pane
4586
- // lists the media sets exactly where a collection's documents would be, and the
4587
- // details pane holds the browser for whichever set is selected. Selecting a set
4588
- // goes through onSelectNav, so it lands in the URL like any other selection.
4589
- // Forms enter through the same content column every other surface uses: the
4590
- // list of forms sits exactly where a collection's documents would, at the
4591
- // list width, with the empty state beside it. A list of half a dozen rows
4592
- // stretched across everything after the sidebar reads as a different kind of
4593
- // screen than Edit, Changes and Media, when it is the same kind of screen.
4594
- //
4595
- // Opening a form is where forms stop fitting the three-pane model, and so it
4596
- // is where they leave it: that surface is two more levels deep (submissions →
4597
- // one submission), and threading those through panes meant for collection →
4598
- // document → editor would give a pane whose meaning changes with the level.
4599
- // FormsBrowser owns its own split from there.
4600
- if (formsShowing && props.forms) {
4601
- if (!activeForm) {
4602
- return (
4603
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4604
- {navPane}
4605
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4606
-
4607
- <Pane width={listW} testID="pane-forms" scroll={false} header={<PaneTitle title="Forms" />}>
4608
- <FormsBrowser
4609
- api={props.forms}
4610
- form={null}
4611
- onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4612
- variant="desktop"
4706
+ } else if (mediaShowing) {
4707
+ // Media lists its sets here and browses the chosen one in the detail region.
4708
+ // Selecting a set goes through onSelectNav, so it lands in the URL like any
4709
+ // other selection.
4710
+ masterHeaderNode = masterHeader();
4711
+ masterBody = (
4712
+ <MediaSetList
4713
+ sets={mediaSets}
4714
+ selected={mediaSet}
4715
+ onSelect={(name) => { revealDetail(); onSelectNav(mediaNavKey(name)); }}
4716
+ />
4717
+ );
4718
+ } else {
4719
+ masterHeaderNode = collectionHeader;
4720
+ masterBody = search.advanced ? (
4721
+ <AdvancedSearch
4722
+ fields={facetFields}
4723
+ initial={search.draft}
4724
+ onChange={search.setDraft}
4725
+ onApply={search.applyDraft}
4726
+ matchCount={search.matchCount}
4727
+ counting={search.counting}
4728
+ />
4729
+ ) : (
4730
+ <EntriesList
4731
+ variant="desktop"
4732
+ entries={visibleEntries}
4733
+ selectedId={selectedId}
4734
+ onOpenEntry={openEntry}
4735
+ header={
4736
+ showChecks && selectedCount > 0 && (onBulkDelete || canBulkMove) ? (
4737
+ <SelectionBar
4738
+ count={selectedCount}
4739
+ onClear={clear}
4740
+ onMove={canBulkMove ? () => setMovePrompt(true) : undefined}
4741
+ onDelete={onBulkDelete ? async () => { await onBulkDelete(moveIds); clear(); } : undefined}
4613
4742
  />
4614
- </Pane>
4615
- <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-forms" />
4616
-
4617
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4618
- <Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
4619
- </View>
4620
- {modalOverlays}
4621
- </AppShell>
4622
- );
4623
- }
4624
- return (
4625
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4626
- {navPane}
4627
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4628
- <Pane flex={1} testID="pane-forms" scroll={false}>
4629
- <FormsBrowser
4630
- api={props.forms}
4631
- form={activeForm}
4632
- onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4633
- selectedId={props.selectedSubmissionId ?? null}
4634
- onSelectSubmission={props.onSelectSubmission}
4635
- variant="desktop"
4636
- />
4637
- </Pane>
4638
- {modalOverlays}
4639
- </AppShell>
4743
+ ) : (
4744
+ <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
4745
+ <Text variant="label" color="tertiary">
4746
+ {`${totalEntries ?? entries.length} entries`}
4747
+ </Text>
4748
+ {/* The count is of documents with unsaved edits in this
4749
+ browser — the same state the rows below badge as Draft.
4750
+ It replaced workspace.changed here, which every host
4751
+ passed as a constant and which therefore told nobody
4752
+ anything. Rendered only when there is something to
4753
+ report: a permanent "0 uncommitted" is furniture. */}
4754
+ {draftsOpen > 0 ? (
4755
+ <>
4756
+ <Text variant="label" color="tertiary">·</Text>
4757
+ <Text variant="label" color="primary" testID="uncommitted-count">
4758
+ {`${draftsOpen} uncommitted`}
4759
+ </Text>
4760
+ </>
4761
+ ) : null}
4762
+ </View>
4763
+ )
4764
+ }
4765
+ upRow={
4766
+ hierarchical && folder.length > 0 ? (
4767
+ <PressableRow onPress={() => setFolder(folder.slice(0, -1))}>
4768
+ <UpListItem parentLabel={parentLabel(folder, activeLabel)} currentPath={folder.join("/")} />
4769
+ </PressableRow>
4770
+ ) : null
4771
+ }
4772
+ folders={
4773
+ hierarchical
4774
+ ? folderNames.map((name) => ({
4775
+ name,
4776
+ count: countItems(node!.folders.get(name)!),
4777
+ key: [...folder, name].join("/"),
4778
+ }))
4779
+ : []
4780
+ }
4781
+ onFolderPress={(name) => setFolder([...folder, name])}
4782
+ showChecks={showChecks}
4783
+ checked={checked}
4784
+ onToggleEntry={toggle}
4785
+ showFolderChecks={canBulkMove}
4786
+ checkedFolders={checkedFolders}
4787
+ onToggleFolder={toggleFolder}
4788
+ // Pagination is driven by the flat list; the folder drill-down loads
4789
+ // the whole set up front, so hasMore is false there.
4790
+ hasMore={!hierarchical && hasMoreEntries}
4791
+ loadingMore={loadingMoreEntries}
4792
+ onLoadMore={onLoadMoreEntries}
4793
+ />
4640
4794
  );
4641
4795
  }
4642
4796
 
4643
- if (mediaShowing) {
4644
- return (
4645
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4646
- {navPane}
4647
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4648
-
4649
- <Pane width={listW} testID="pane-media-sets" scroll={false} header={<PaneTitle title="Media" />}>
4650
- <MediaSetList
4651
- sets={mediaSets}
4652
- selected={mediaSet}
4653
- onSelect={(name) => onSelectNav(mediaNavKey(name))}
4654
- />
4655
- </Pane>
4656
- <ResizeHandle width={listW} min={240} max={520} onChange={setListW} testID="resize-media-sets" />
4797
+ // The master pane, expandable to the whole screen, and gone while a document
4798
+ // column has it. Its body owns its own scrolling (a FlatList, or the sections
4799
+ // ScrollView), so the pane itself does not.
4800
+ const masterPane = (
4801
+ <Pane width={masterExpanded ? undefined : masterW} testID="pane-master" scroll={false} header={masterHeaderNode}>
4802
+ {masterBody}
4803
+ </Pane>
4804
+ );
4657
4805
 
4658
- {mediaSet ? (
4659
- <MediaBrowser set={mediaSet} label={mediaSetLabel(mediaSets, mediaSet)} variant="desktop" />
4660
- ) : (
4661
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4662
- <Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
4663
- </View>
4664
- )}
4665
- {modalOverlays}
4666
- </AppShell>
4806
+ // The detail region, by what the master has pushed. A plugin screen and an
4807
+ // open form take it whole; Media browses the chosen set; the changes surface
4808
+ // shows the selected diff; the editor shows its document columns.
4809
+ let detail: React.ReactNode;
4810
+ if (changes) {
4811
+ detail = (
4812
+ <ChangeDetail
4813
+ change={props.selectedChange}
4814
+ loading={props.loadingChange}
4815
+ emptyMessage={activeNavKey ? "Select a change" : (entriesEmpty ?? "No changes on this branch")}
4816
+ conflicts={props.selectedConflicts}
4817
+ onResolveConflict={props.onResolveConflict}
4818
+ />
4667
4819
  );
4668
- }
4669
-
4670
- return (
4671
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4672
- {/* content types */}
4673
- {navPane}
4674
-
4675
- {/* draggable seam: resize the nav pane */}
4676
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4677
-
4678
- {/* entries — collapsible to a rotated-label rail */}
4679
- {listCollapsed ? (
4680
- <CollapsedRail
4681
- testID="list-rail"
4682
- label={listLabel}
4683
- onExpand={() => setListCollapsed(false)}
4684
- trailing={createBtn}
4820
+ } else if (props.contentSlot != null) {
4821
+ detail = (
4822
+ <Pane flex={1} testID="pane-plugin" scroll={false} borderRight={false}>
4823
+ {props.contentSlot}
4824
+ </Pane>
4825
+ );
4826
+ } else if (formsShowing && props.forms) {
4827
+ detail = activeForm ? (
4828
+ <Pane flex={1} testID="pane-forms" scroll={false} borderRight={false}>
4829
+ <FormsBrowser
4830
+ api={props.forms}
4831
+ form={activeForm}
4832
+ onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4833
+ selectedId={props.selectedSubmissionId ?? null}
4834
+ onSelectSubmission={props.onSelectSubmission}
4835
+ variant="desktop"
4685
4836
  />
4686
- ) : (
4687
- <Pane
4688
- width={listW}
4689
- testID="pane-entries"
4690
- scroll={false}
4691
- header={listHeader}
4692
- >
4693
- {search.advanced ? (
4694
- <AdvancedSearch
4695
- fields={facetFields}
4696
- initial={search.draft}
4697
- onChange={search.setDraft}
4698
- onApply={search.applyDraft}
4699
- matchCount={search.matchCount}
4700
- counting={search.counting}
4701
- />
4702
- ) : browsing && !activeNavKey ? (
4703
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4704
- <Text variant="body" color="tertiary" testID="entries-empty">{entriesEmpty}</Text>
4705
- </View>
4706
- ) : (
4707
- <EntriesList
4708
- variant="desktop"
4709
- entries={visibleEntries}
4710
- selectedId={selectedId}
4711
- onOpenEntry={openEntry}
4712
- header={
4713
- showChecks && selectedCount > 0 && (onBulkDelete || canBulkMove) ? (
4714
- <SelectionBar
4715
- count={selectedCount}
4716
- onClear={clear}
4717
- onMove={canBulkMove ? () => setMovePrompt(true) : undefined}
4718
- onDelete={onBulkDelete ? async () => { await onBulkDelete(moveIds); clear(); } : undefined}
4719
- />
4720
- ) : (
4721
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
4722
- <Text variant="label" color="tertiary">
4723
- {`${totalEntries ?? entries.length} entries`}
4724
- </Text>
4725
- {/* The count is of documents with unsaved edits in this
4726
- browser — the same state the rows below badge as Draft.
4727
- It replaced workspace.changed here, which every host
4728
- passed as a constant and which therefore told nobody
4729
- anything. Rendered only when there is something to
4730
- report: a permanent "0 uncommitted" is furniture. */}
4731
- {draftsOpen > 0 ? (
4732
- <>
4733
- <Text variant="label" color="tertiary">·</Text>
4734
- <Text variant="label" color="primary" testID="uncommitted-count">
4735
- {`${draftsOpen} uncommitted`}
4736
- </Text>
4737
- </>
4738
- ) : null}
4739
- </View>
4740
- )
4741
- }
4742
- upRow={
4743
- hierarchical && folder.length > 0 ? (
4744
- <PressableRow onPress={() => setFolder(folder.slice(0, -1))}>
4745
- <UpListItem parentLabel={parentLabel(folder, activeLabel)} currentPath={folder.join("/")} />
4746
- </PressableRow>
4747
- ) : null
4748
- }
4749
- folders={
4750
- hierarchical
4751
- ? folderNames.map((name) => ({
4752
- name,
4753
- count: countItems(node!.folders.get(name)!),
4754
- key: [...folder, name].join("/"),
4755
- }))
4756
- : []
4757
- }
4758
- onFolderPress={(name) => setFolder([...folder, name])}
4759
- showChecks={showChecks}
4760
- checked={checked}
4761
- onToggleEntry={toggle}
4762
- showFolderChecks={canBulkMove}
4763
- checkedFolders={checkedFolders}
4764
- onToggleFolder={toggleFolder}
4765
- // Pagination is driven by the flat list; the folder drill-down loads
4766
- // the whole set up front, so hasMore is false there.
4767
- hasMore={!hierarchical && hasMoreEntries}
4768
- loadingMore={loadingMoreEntries}
4769
- onLoadMore={onLoadMoreEntries}
4770
- />
4771
- )}
4772
- </Pane>
4773
- )}
4774
-
4775
- {/* draggable seam: resize the entry list (only while it's expanded) */}
4776
- {listCollapsed ? null : (
4777
- <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-list" />
4778
- )}
4779
-
4780
- {/* detail — a horizontally-scrolling row of side-by-side document columns.
4781
- Each open column is its own editor focus-mode viewport (see `column`). */}
4837
+ </Pane>
4838
+ ) : (
4839
+ emptyDetail("Select a form", "forms-empty")
4840
+ );
4841
+ } else if (mediaShowing) {
4842
+ detail = mediaSet ? (
4843
+ <MediaBrowser set={mediaSet} label={mediaSetLabel(mediaSets, mediaSet)} variant="desktop" />
4844
+ ) : (
4845
+ emptyDetail("Select a media collection", "media-sets-empty")
4846
+ );
4847
+ } else {
4848
+ // A horizontally-scrolling row of side-by-side document columns. Each open
4849
+ // column is its own editor focus-mode viewport (see `columnView`). Unmounted
4850
+ // while the master has the screen; the columns keep their state through
4851
+ // that, since the host owns the open-tab set and the drafts live outside
4852
+ // this tree.
4853
+ detail = (
4782
4854
  <View
4783
4855
  testID="pane-detail"
4784
4856
  ref={detailRef}
@@ -4801,27 +4873,13 @@ function DesktopBrowser(props: ContentBrowserProps) {
4801
4873
  scrollEventThrottle={16}
4802
4874
  onScroll={(ev) => { scrollXRef.current = ev.nativeEvent.contentOffset.x; }}
4803
4875
  >
4804
- {columns.flatMap((col, i) => {
4876
+ {shownColumns.flatMap((col, i) => {
4805
4877
  const nodes: React.ReactNode[] = [];
4806
4878
  if (dragId && overSlot === i) nodes.push(dropIndicator(`drop-${i}`));
4807
- nodes.push(
4808
- collapsedCols[col.id] ? (
4809
- <CollapsedRail
4810
- key={col.id}
4811
- testID={`column-${col.id}`}
4812
- label={col.title || col.entry?.title || "Untitled"}
4813
- onExpand={() => setCollapsedCols((c) => ({ ...c, [col.id]: false }))}
4814
- onClose={() => closeColumn(col.id)}
4815
- dragHandle={columnGrip(col.id)}
4816
- dimmed={dragId === col.id}
4817
- />
4818
- ) : (
4819
- columnView(col)
4820
- ),
4821
- );
4879
+ nodes.push(columnView(col));
4822
4880
  return nodes;
4823
4881
  })}
4824
- {dragId && overSlot === columns.length ? dropIndicator("drop-end") : null}
4882
+ {dragId && overSlot === shownColumns.length ? dropIndicator("drop-end") : null}
4825
4883
  {showCreate ? (
4826
4884
  <View
4827
4885
  testID="column-create"
@@ -4846,12 +4904,33 @@ function DesktopBrowser(props: ContentBrowserProps) {
4846
4904
  </ScrollView>
4847
4905
  ) : (
4848
4906
  <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
4907
+ {/* With nothing pushed the region says what to do; a pushed list
4908
+ says what to pick from it; a direct item is on its way and
4909
+ needs no prompt. */}
4849
4910
  <Text variant="body" color="tertiary" testID="detail-empty">
4850
- {browsing ? (activeNavKey ? `Select ${activeLabel}` : "") : "No entry selected."}
4911
+ {!browsing
4912
+ ? "No entry selected."
4913
+ : !activeNavKey
4914
+ ? entriesEmpty
4915
+ : activeItem?.direct
4916
+ ? ""
4917
+ : `Select ${activeLabel}`}
4851
4918
  </Text>
4852
4919
  </View>
4853
4920
  )}
4854
4921
  </View>
4922
+ );
4923
+ }
4924
+
4925
+ return (
4926
+ <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4927
+ {expandedCol ? null : masterPane}
4928
+ {/* draggable seam: resize the master pane — there is nothing to resize it
4929
+ against once a pane has taken the screen */}
4930
+ {masterExpanded || expandedCol ? null : (
4931
+ <ResizeHandle width={masterW} min={260} max={640} onChange={setMasterW} testID="resize-master" />
4932
+ )}
4933
+ {masterExpanded ? null : detail}
4855
4934
  {movePrompt && onBulkMove ? (
4856
4935
  <PromptDialog
4857
4936
  title={`Move ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
@@ -5274,10 +5353,6 @@ function MobileBrowser(props: ContentBrowserProps) {
5274
5353
  );
5275
5354
  }
5276
5355
 
5277
- // Width of a collapsed rail (the content list or a document column). Narrow,
5278
- // full-height, with its label rotated 90° — clicking the body re-expands it.
5279
- const RAIL_W = 44;
5280
-
5281
5356
  // RN's typed `cursor` only allows auto/pointer; react-native-web forwards any CSS
5282
5357
  // cursor to the DOM. Cast these desktop-only affordances through ViewStyle.
5283
5358
  const CURSOR_COL_RESIZE = { cursor: "col-resize" } as unknown as ViewStyle;
@@ -5354,14 +5429,15 @@ function ResizeHandle({
5354
5429
 
5355
5430
  // CustomColumn is a column whose body is a caller-supplied node rather than the
5356
5431
  // document editor (OpenTab.content). It reproduces the editor column's header
5357
- // chrome — grip, active accent, collapse, close — so a plugin's column is a
5432
+ // chrome — grip, active accent, expand, close — so a plugin's column is a
5358
5433
  // peer of the document columns rather than something visibly bolted on. The
5359
5434
  // body gets no padding and no scroll container: an iframe wants the whole box.
5360
5435
  function CustomColumn({
5361
5436
  title,
5362
5437
  active,
5363
5438
  onActivate,
5364
- onCollapse,
5439
+ expanded,
5440
+ onToggleExpand,
5365
5441
  onClose,
5366
5442
  dragHandle,
5367
5443
  children,
@@ -5369,7 +5445,8 @@ function CustomColumn({
5369
5445
  title: string;
5370
5446
  active?: boolean;
5371
5447
  onActivate?: () => void;
5372
- onCollapse?: () => void;
5448
+ expanded?: boolean;
5449
+ onToggleExpand?: () => void;
5373
5450
  onClose?: () => void;
5374
5451
  dragHandle?: React.ReactNode;
5375
5452
  children: React.ReactNode;
@@ -5391,8 +5468,15 @@ function CustomColumn({
5391
5468
  <Pressable onPress={onActivate} style={{ flex: 1 }}>
5392
5469
  <Text variant="monoSm" numberOfLines={1}>{title}</Text>
5393
5470
  </Pressable>
5394
- {onCollapse ? (
5395
- <IconButton name="panelLeft" size="sm" label="Collapse column" onPress={onCollapse} testID="column-collapse" />
5471
+ {onToggleExpand ? (
5472
+ <IconButton
5473
+ name={expanded ? "minimize" : "maximize"}
5474
+ size="sm"
5475
+ active={expanded}
5476
+ label={expanded ? "Collapse column" : "Expand column"}
5477
+ onPress={onToggleExpand}
5478
+ testID="column-expand"
5479
+ />
5396
5480
  ) : null}
5397
5481
  {onClose ? (
5398
5482
  <IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
@@ -5403,72 +5487,6 @@ function CustomColumn({
5403
5487
  );
5404
5488
  }
5405
5489
 
5406
- // CollapsedRail is the thin vertical strip a collapsed pane becomes. It mirrors
5407
- // the expanded pane's header: an optional top control row (a trailing slot such
5408
- // as the create "+", and/or a close button) over a pressable body whose rotated
5409
- // label matches the header's title. onExpand fires on a body press.
5410
- function CollapsedRail({
5411
- label,
5412
- onExpand,
5413
- onClose,
5414
- trailing,
5415
- dragHandle,
5416
- dimmed,
5417
- testID,
5418
- }: {
5419
- label: string;
5420
- onExpand: () => void;
5421
- onClose?: () => void;
5422
- trailing?: React.ReactNode;
5423
- // A drag grip (column rails) rendered above the close button in the control row.
5424
- dragHandle?: React.ReactNode;
5425
- // Dim the rail while it's the column being dragged.
5426
- dimmed?: boolean;
5427
- testID?: string;
5428
- }) {
5429
- const t = useTheme();
5430
- return (
5431
- <View
5432
- testID={testID}
5433
- style={{
5434
- width: RAIL_W, alignSelf: "stretch", minHeight: 0, overflow: "hidden",
5435
- borderRightWidth: 1, borderRightColor: t.color.borderSubtle,
5436
- backgroundColor: t.color.surfacePage,
5437
- opacity: dimmed ? 0.5 : 1,
5438
- }}
5439
- >
5440
- {trailing || onClose || dragHandle ? (
5441
- <View
5442
- style={{
5443
- height: t.layout.topbar, alignItems: "center", justifyContent: "center", gap: t.space(1),
5444
- borderBottomWidth: 1, borderBottomColor: t.color.borderSubtle,
5445
- }}
5446
- >
5447
- {dragHandle}
5448
- {trailing}
5449
- {onClose ? <IconButton name="x" size="sm" label="Close" onPress={onClose} testID="rail-close" /> : null}
5450
- </View>
5451
- ) : null}
5452
- <Pressable
5453
- onPress={onExpand}
5454
- style={({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => ({
5455
- flex: 1, alignItems: "center", justifyContent: "center", gap: t.space(3),
5456
- backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
5457
- })}
5458
- >
5459
- <Icon name="chevronRight" size={14} color={t.color.textTertiary} />
5460
- {/* rotate doesn't change the layout box, so the pre-rotation width becomes
5461
- the label's on-screen height; overflow:hidden on the rail clips it. */}
5462
- <View style={{ transform: [{ rotate: "90deg" }] }}>
5463
- <Text variant="monoSm" weight="medium" numberOfLines={1} style={{ width: 240, textAlign: "center" }}>
5464
- {label}
5465
- </Text>
5466
- </View>
5467
- </Pressable>
5468
- </View>
5469
- );
5470
- }
5471
-
5472
5490
  // Small Pressable wrapper so list items get press feedback without duplicating.
5473
5491
  // Row press feedback: the selected row holds a stronger tint (surfaceActive) so
5474
5492
  // it stays distinct from the lighter hover tint (surfaceHover) on other rows.
@@ -5530,23 +5548,6 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
5530
5548
  return set ? titleCaseWord(set.name) : titleCaseWord(name);
5531
5549
  }
5532
5550
 
5533
- // PaneTitle is the content pane's heading for the surfaces with no controls
5534
- // beside it — Changes, Media, Forms.
5535
- //
5536
- // Deliberately the same Text the collection header uses (`listHeader`), and
5537
- // nothing else. Pane's header row already supplies the height, the horizontal
5538
- // padding and the rule beneath, so the padded wrapper this used to add sat
5539
- // inside that padding and pushed the title a step right of every collection's —
5540
- // at `body` rather than `h3`, so it read a size smaller too. Switching between
5541
- // Posts and Media moved the heading twice over.
5542
- function PaneTitle({ title }: { title: string }) {
5543
- return (
5544
- <Text variant="h3" weight="semibold" style={{ flex: 1 }}>
5545
- {title}
5546
- </Text>
5547
- );
5548
- }
5549
-
5550
5551
  // MediaSetList is the content pane under Media: one row per media collection,
5551
5552
  // sitting exactly where a collection's documents sit. Each row shows how many
5552
5553
  // files the set holds and the glob it is discovered by, since that glob is the
@@ -5594,15 +5595,138 @@ function MediaSetList({
5594
5595
  );
5595
5596
  }
5596
5597
 
5597
- // navRowActive reports whether a sidebar row should read as current. The Media
5598
- // button stays lit while a set beneath it is open ("media:uploads"), because the
5599
- // set is not itself a sidebar row — losing the highlight there would leave the
5600
- // sidebar showing nothing selected while media is plainly on screen.
5598
+ // navRowActive reports whether a sections row should read as current. The Media
5599
+ // button stays lit while a set beneath it is open ("media:uploads"), and Forms
5600
+ // while a form is, because neither the set nor the form is itself a row —
5601
+ // losing the highlight there would leave the sections showing nothing selected
5602
+ // while media or a form is plainly on screen.
5601
5603
  function navRowActive(itemKey: string, activeNavKey: string): boolean {
5602
5604
  if (itemKey === MEDIA_NAV_KEY) return isMediaNavKey(activeNavKey);
5605
+ if (itemKey === FORMS_NAV_KEY) return isFormsNavKey(activeNavKey);
5603
5606
  return itemKey === activeNavKey;
5604
5607
  }
5605
5608
 
5609
+ // navItemFor resolves the sections row a nav key belongs to — the row itself,
5610
+ // or, for a media set or a form, the Media or Forms row they sit beneath.
5611
+ function navItemFor(sections: CmsNavSection[], key: string): CmsNavItem | undefined {
5612
+ if (!key) return undefined;
5613
+ for (const s of sections) {
5614
+ for (const item of s.items) {
5615
+ if (navRowActive(item.key, key)) return item;
5616
+ }
5617
+ }
5618
+ return undefined;
5619
+ }
5620
+
5621
+ // MasterTitle heads the master pane while a list is pushed into it. It is the
5622
+ // list's name and, when there is more than one list to choose from, a menu of
5623
+ // the rest — so switching collections does not go back through the sections.
5624
+ // Direct items (singletons, plugin screens) are left out: they have no list to
5625
+ // switch to. With a single list to offer there is no menu at all, matching the
5626
+ // branch and project menus' rule against a dead affordance.
5627
+ //
5628
+ // The menu sits inside the pane's header, which stacks above the pane body and
5629
+ // clips to the pane — so it is positioned against the header row, spans the
5630
+ // pane's width, and scrolls past a height the pane could not show.
5631
+ function MasterTitle({
5632
+ label,
5633
+ sections,
5634
+ activeNavKey,
5635
+ onSelect,
5636
+ }: {
5637
+ label: string;
5638
+ sections: CmsNavSection[];
5639
+ activeNavKey: string;
5640
+ onSelect: (key: string) => void;
5641
+ }) {
5642
+ const t = useTheme();
5643
+ const [open, setOpen] = useState(false);
5644
+ const listed = sections
5645
+ .map((s) => ({ ...s, items: s.items.filter((i) => !i.direct) }))
5646
+ .filter((s) => s.items.length > 0);
5647
+ const count = listed.reduce((n, s) => n + s.items.length, 0);
5648
+ if (count < 2) {
5649
+ return (
5650
+ <Text variant="h3" weight="semibold" style={{ flex: 1 }} numberOfLines={1} testID="master-title">
5651
+ {label}
5652
+ </Text>
5653
+ );
5654
+ }
5655
+ const rowStyle = ({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
5656
+ flexDirection: "row" as const, alignItems: "center" as const, gap: t.space(2),
5657
+ paddingHorizontal: t.space(3), paddingVertical: t.space(2),
5658
+ backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
5659
+ });
5660
+ return (
5661
+ <>
5662
+ <Pressable
5663
+ testID="master-title"
5664
+ accessibilityRole="button"
5665
+ accessibilityLabel={`${label}. Switch collection`}
5666
+ aria-expanded={open}
5667
+ onPress={() => setOpen((v) => !v)}
5668
+ style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: t.space(1.5), minWidth: 0 }}
5669
+ >
5670
+ <Text variant="h3" weight="semibold" numberOfLines={1} style={{ flexShrink: 1 }}>{label}</Text>
5671
+ <Icon name="chevronDown" size={14} color={t.color.textTertiary} />
5672
+ </Pressable>
5673
+ {open ? (
5674
+ <>
5675
+ {/* A catcher so a click anywhere in the pane dismisses; above the
5676
+ header's other controls so none of them fires through it. */}
5677
+ <Pressable
5678
+ onPress={() => setOpen(false)}
5679
+ testID="master-menu-backdrop"
5680
+ style={{ position: "absolute", top: -2000, left: -2000, right: -2000, bottom: -2000, zIndex: 15 }}
5681
+ />
5682
+ <View
5683
+ testID="master-menu"
5684
+ style={{
5685
+ position: "absolute", top: t.layout.topbar - t.space(1), left: t.space(2), right: t.space(2),
5686
+ maxHeight: 420, zIndex: 20,
5687
+ backgroundColor: t.color.surfaceRaised, borderWidth: 1, borderColor: t.color.borderDefault,
5688
+ borderRadius: t.radius.md, paddingVertical: t.space(1),
5689
+ }}
5690
+ >
5691
+ <ScrollView>
5692
+ {listed.map((section, i) => (
5693
+ <View key={section.title || `section-${i}`}>
5694
+ {section.title ? <SectionLabel>{section.title}</SectionLabel> : null}
5695
+ {section.items.map((item) => {
5696
+ const active = navRowActive(item.key, activeNavKey);
5697
+ return (
5698
+ <Pressable
5699
+ key={item.key}
5700
+ testID={`master-option-${item.key}`}
5701
+ accessibilityRole="menuitem"
5702
+ aria-current={active ? "page" : undefined}
5703
+ onPress={() => { setOpen(false); if (!active) onSelect(item.key); }}
5704
+ style={rowStyle}
5705
+ >
5706
+ <Icon
5707
+ name={active ? "check" : item.icon}
5708
+ size={15}
5709
+ color={active ? t.color.textPrimary : t.color.textSecondary}
5710
+ />
5711
+ <Text variant="sm" weight={active ? "semibold" : "regular"} numberOfLines={1} style={{ flex: 1 }}>
5712
+ {item.label}
5713
+ </Text>
5714
+ {item.count != null ? (
5715
+ <Text variant="monoSm" color="tertiary">{String(item.count)}</Text>
5716
+ ) : null}
5717
+ </Pressable>
5718
+ );
5719
+ })}
5720
+ </View>
5721
+ ))}
5722
+ </ScrollView>
5723
+ </View>
5724
+ </>
5725
+ ) : null}
5726
+ </>
5727
+ );
5728
+ }
5729
+
5606
5730
  // navLabelFor finds the sidebar label for a nav key, so a pane header shows the
5607
5731
  // same words the user clicked rather than re-deriving them from the key.
5608
5732
  function navLabelFor(sections: CmsNavSection[], key: string): string | undefined {