@gogitcms/design-system 0.16.0-next.13 → 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.
@@ -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 };
@@ -211,6 +224,12 @@ export type CmsEntry = {
211
224
  conflicted?: boolean;
212
225
  title: string;
213
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;
214
233
  // When present, the detail pane renders a field editor instead of title/body.
215
234
  fields?: EntryField[];
216
235
  // A list entry whose full document (fields/body) hasn't loaded yet — the detail
@@ -307,14 +326,15 @@ export type ContentBrowserProps = {
307
326
  // Items shown in the avatar dropdown above Sign out. Absent/empty keeps the
308
327
  // pre-menu topbar (sign-out icon + static avatar) byte-identical.
309
328
  userMenuItems?: UserMenuItem[];
310
- // When set, the content area is replaced by this node (plugin screens): on
311
- // desktop the list + detail panes give way to one flexing pane, on mobile it
312
- // 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.
313
333
  contentSlot?: React.ReactNode;
314
334
  contentTitle?: string;
315
335
  // Providing entriesEmpty switches on "collection browsing" mode: nothing is
316
- // auto-selected, an empty collection selection shows this message, and an
317
- // 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}".
318
338
  entriesEmpty?: string;
319
339
 
320
340
  // Controlled document selection. When onSelectEntry is provided the component
@@ -501,9 +521,33 @@ export type ContentBrowserProps = {
501
521
  onSelectProject?: (project: ProjectRef) => void;
502
522
  /** cms.config.js pins this deployment to one project: shown, but inert. */
503
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;
504
544
  // Omitted → no "New branch" row. Rejecting a name should throw so the inline
505
545
  // form can show why.
506
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;
507
551
 
508
552
  // The comparison target on the changes surface. When set, the top bar shows a
509
553
  // `source → target` pair instead of a single branch menu — the source is the
@@ -639,6 +683,11 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
639
683
  // what git thinks of the file: a document can be a local draft, or committed
640
684
  // and modified on the branch, or both, and they are not the same state.
641
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}` : "";
642
691
  return (
643
692
  <ListRow conflicted={entry.conflicted}>
644
693
  <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
@@ -657,8 +706,8 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
657
706
  {entry.status}
658
707
  </Text>
659
708
  ) : null}
660
- <Text variant="monoSm" color="tertiary" numberOfLines={1}>
661
- {entry.path}
709
+ <Text variant="monoSm" color="tertiary" numberOfLines={1} testID="entry-subtitle">
710
+ {updated || entry.path}
662
711
  </Text>
663
712
  </View>
664
713
  </ListRow>
@@ -2784,7 +2833,8 @@ function EntryDetail({
2784
2833
  history,
2785
2834
  active,
2786
2835
  onActivate,
2787
- onCollapse,
2836
+ expanded,
2837
+ onToggleExpand,
2788
2838
  onClose,
2789
2839
  dragHandle,
2790
2840
  }: {
@@ -2813,11 +2863,13 @@ function EntryDetail({
2813
2863
  // the header renders exactly as it did before history existed.
2814
2864
  history?: HistoryApi;
2815
2865
  // Desktop columns: `active` marks the focused column (accent + header press
2816
- // activates it via onActivate); onCollapse/onClose add the header rail/close
2817
- // 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.
2818
2869
  active?: boolean;
2819
2870
  onActivate?: () => void;
2820
- onCollapse?: () => void;
2871
+ expanded?: boolean;
2872
+ onToggleExpand?: () => void;
2821
2873
  onClose?: () => void;
2822
2874
  // A drag grip rendered at the head of the breadcrumb row to reorder columns.
2823
2875
  dragHandle?: React.ReactNode;
@@ -3307,8 +3359,15 @@ function EntryDetail({
3307
3359
  onMove={canMove ? () => setPrompt("move") : undefined}
3308
3360
  onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
3309
3361
  />
3310
- {onCollapse ? (
3311
- <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
+ />
3312
3371
  ) : null}
3313
3372
  {onClose ? (
3314
3373
  <IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
@@ -3504,6 +3563,12 @@ const MIN_COL = 480;
3504
3563
  // a burst of keystrokes is one update rather than twenty.
3505
3564
  const DRAFT_DEBOUNCE_MS = 150;
3506
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
+
3507
3572
  // A normalized desktop column, unifying the host-controlled (openTabs) and
3508
3573
  // self-owned (openIds) sources so the render + sizing paths stay single. `entry`
3509
3574
  // is undefined while a controlled tab's document loads.
@@ -4008,6 +4073,7 @@ function ModalOverlays(props: ContentBrowserProps) {
4008
4073
  error={props.protectedBranchError}
4009
4074
  onCreate={props.onCreateBranchAndSave ?? (() => {})}
4010
4075
  onCancel={props.onCancelProtectedSave ?? (() => {})}
4076
+ prefix={props.branchPrefix}
4011
4077
  />
4012
4078
  ) : null}
4013
4079
  </>
@@ -4074,20 +4140,40 @@ function DesktopBrowser(props: ContentBrowserProps) {
4074
4140
 
4075
4141
  // ---- open document columns (ephemeral desktop state) --------------------
4076
4142
  // openIds are the documents shown as side-by-side columns, in display order.
4077
- // collapsedCols marks which columns are collapsed to a rail. Both reset when
4078
- // 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).
4079
4145
  const [openIds, setOpenIds] = useState<string[]>([]);
4080
- const [collapsedCols, setCollapsedCols] = useState<Record<string, boolean>>({});
4081
- 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);
4082
4150
  const [region, setRegion] = useState({ w: 0, h: 0 });
4083
- // Resizable pane widths (desktop). Seeded from the layout defaults; dragging a
4084
- // seam between panes updates them live (clamped). Local, ephemeral view state.
4085
- const [navW, setNavW] = useState(t.layout.sidebar);
4086
- 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;
4087
4166
  // The self-owned column set (openIds) is only used in non-tabbed mode; in tabbed
4088
- // mode the host owns it, so these maintenance effects no-op. `collapsedCols` and
4089
- // `listCollapsed` stay local (pure view state) in both modes.
4090
- 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]);
4091
4177
  // Clear the selection when the collection changes so the detail shows its empty
4092
4178
  // state. In controlled mode the host owns this (the URL changes with the nav).
4093
4179
  useEffect(() => {
@@ -4117,8 +4203,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
4117
4203
  // RNW surfaces the click modifiers on nativeEvent (typed without them here).
4118
4204
  const mods = ev?.nativeEvent as unknown as { shiftKey?: boolean; metaKey?: boolean } | undefined;
4119
4205
  const newColumn = !!(mods?.shiftKey || mods?.metaKey);
4120
- // Expand the column if this document is open but collapsed.
4121
- 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();
4122
4209
  // Tabbed mode: hand the intent to the host (it owns the open-tab set).
4123
4210
  if (tabbed) { props.onOpenEntry?.(e, { newColumn }); return; }
4124
4211
  // Plain click on a not-yet-open document: swap it into the active column's
@@ -4137,12 +4224,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4137
4224
  };
4138
4225
  // Closing a column drops it; if it was active, activate a neighbor (or clear).
4139
4226
  const closeColumn = (id: string) => {
4140
- setCollapsedCols((c) => {
4141
- if (!(id in c)) return c;
4142
- const cp = { ...c };
4143
- delete cp[id];
4144
- return cp;
4145
- });
4227
+ setExpanded((x) => (x?.kind === "column" && x.id === id ? null : x));
4146
4228
  if (tabbed) { props.onCloseTab?.(id); return; }
4147
4229
  const idx = openIds.indexOf(id);
4148
4230
  const next = openIds.filter((x) => x !== id);
@@ -4157,8 +4239,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
4157
4239
  const { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys } = useMultiSelect(activeNavKey);
4158
4240
  const [movePrompt, setMovePrompt] = useState(false);
4159
4241
  const selected = entries.find((e) => e.id === selectedId) ?? (controlled || browsing ? null : entries[0] ?? null);
4160
- const activeLabel = sections.flatMap((s) => s.items).find((i) => i.key === activeNavKey)?.label ?? "Content";
4161
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]);
4162
4247
 
4163
4248
  // Folder drill-down for hierarchical collections: navigate the folder tree and
4164
4249
  // show only the folders + documents at the current level.
@@ -4220,16 +4305,22 @@ function DesktopBrowser(props: ContentBrowserProps) {
4220
4305
  canMove: !!onMove,
4221
4306
  collectionPath,
4222
4307
  }));
4223
- // Size the columns: one expanded column fills the region; several share it down
4224
- // to MIN_COL, past which the region scrolls horizontally. Collapsed rails take a
4225
- // fixed sliver.
4226
- const collapsedCount = columns.filter((c) => collapsedCols[c.id]).length;
4227
- const expandedCount = columns.length - collapsedCount + (showCreate ? 1 : 0);
4228
- 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);
4229
4320
  let colW = MIN_COL;
4230
- if (region.w > 0 && expandedCount > 0) {
4231
- const per = avail / expandedCount;
4232
- 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;
4233
4324
  }
4234
4325
  const hasColumns = columns.length > 0 || showCreate;
4235
4326
 
@@ -4239,8 +4330,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
4239
4330
  // origin plus the scroll offset), then `slotAtX` picks the gap it's nearest.
4240
4331
  // Reorder applies to the host (onReorderTabs) in tabbed mode or the local
4241
4332
  // openIds otherwise. Web-only affordance; the native shells never mount it.
4242
- const colIds = columns.map((c) => c.id);
4243
- 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);
4244
4337
  const idsRef = useRef(colIds);
4245
4338
  idsRef.current = colIds;
4246
4339
  const widthsRef = useRef(colWidths);
@@ -4311,34 +4404,64 @@ function DesktopBrowser(props: ContentBrowserProps) {
4311
4404
  <View key={key} style={{ width: 2, alignSelf: "stretch", backgroundColor: t.color.borderActive }} />
4312
4405
  );
4313
4406
 
4314
- const listLabel = browsing && !activeNavKey ? "Content" : activeLabel;
4315
4407
  const createBtn = canCreate && onStartCreate && activeNavKey ? (
4316
4408
  <IconButton name="plus" size="sm" label="Create" onPress={onStartCreate} testID="create-start" />
4317
4409
  ) : null;
4318
4410
 
4319
4411
  // Collection search + faceted filters. `search` is enabled for a real (non
4320
- // singleton) active collection; the icons/inputs render into the list header.
4412
+ // singleton) active collection; the icons/inputs render into the master header.
4321
4413
  const search = useCollectionSearch(props, activeNavKey);
4322
4414
  const searchEnabled = !!props.searchable && !!activeNavKey;
4323
4415
  const facetFields = props.facetFields ?? [];
4324
- // The entry-list header swaps to a search input while a search is open.
4325
- const listHeader = search.open ? (
4326
- <SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
4327
- ) : (
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) => (
4328
4432
  <>
4329
- <Text variant="h3" weight="semibold" style={{ flex: 1 }}>{listLabel}</Text>
4330
- {searchEnabled ? (
4331
- <>
4332
- <IconButton name="search" size="sm" label="Search" onPress={search.openSearch} testID="search-open" />
4333
- {facetFields.length > 0 ? (
4334
- <IconButton name="filter" size="sm" label="Filter" onPress={search.openAdvanced} testID="filter-open" />
4335
- ) : null}
4336
- </>
4433
+ {pushed ? (
4434
+ <IconButton name="chevronLeft" size="sm" label="Back to sections" onPress={() => onSelectNav("")} testID="master-back" />
4337
4435
  ) : null}
4338
- {createBtn}
4339
- <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}
4340
4445
  </>
4341
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
+ );
4342
4465
 
4343
4466
  // Activate a column: hand off to the host in tabbed mode (it switches the active
4344
4467
  // collection + URL to the tab); otherwise move the local selection.
@@ -4373,7 +4496,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
4373
4496
  title={col.title || "Untitled"}
4374
4497
  active={!showCreate && col.active}
4375
4498
  onActivate={() => activateColumn(col)}
4376
- onCollapse={() => setCollapsedCols((c) => ({ ...c, [col.id]: true }))}
4499
+ expanded={!!expandedCol}
4500
+ onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
4377
4501
  onClose={() => closeColumn(col.id)}
4378
4502
  dragHandle={columnGrip(col.id)}
4379
4503
  >
@@ -4400,7 +4524,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
4400
4524
  folderOptions={moveFolders}
4401
4525
  active={!showCreate && col.active}
4402
4526
  onActivate={() => activateColumn(col)}
4403
- onCollapse={() => setCollapsedCols((c) => ({ ...c, [col.id]: true }))}
4527
+ expanded={!!expandedCol}
4528
+ onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
4404
4529
  onClose={() => closeColumn(col.id)}
4405
4530
  dragHandle={columnGrip(col.id)}
4406
4531
  />
@@ -4414,9 +4539,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
4414
4539
 
4415
4540
  const topBar = (
4416
4541
  <TopBar
4542
+ windowChrome={props.windowChrome}
4417
4543
  left={
4418
4544
  <>
4419
- <WorkspaceTag workspace={workspace} />
4545
+ {props.workspaceMenu ? <WorkspaceMenu {...props.workspaceMenu} /> : <WorkspaceTag workspace={workspace} />}
4420
4546
  {/* Project before branch: the coarser choice reads first, matching the
4421
4547
  URL's own order (projects/{name}/branches/{id}). */}
4422
4548
  <ProjectMenu
@@ -4424,6 +4550,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
4424
4550
  projects={props.projects}
4425
4551
  onSelect={props.onSelectProject}
4426
4552
  locked={props.projectLocked}
4553
+ onOpenInNewWindow={props.onOpenProjectInNewWindow}
4554
+ actions={props.projectMenuActions}
4427
4555
  />
4428
4556
  <BranchMenu
4429
4557
  current={workspace.branch}
@@ -4431,6 +4559,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4431
4559
  defaultBranch={props.defaultBranch}
4432
4560
  onSelect={props.onSelectBranch}
4433
4561
  onCreate={props.onCreateBranch}
4562
+ prefix={props.onCreateBranch ? props.branchPrefix : undefined}
4434
4563
  />
4435
4564
  {/* On the changes surface the top bar reads `source → target`: the
4436
4565
  branch above is the source under review, this is what it's compared
@@ -4488,8 +4617,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
4488
4617
  />
4489
4618
  );
4490
4619
 
4491
- const navPane = (
4492
- <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) }}>
4493
4625
  {sections.map((section, i) => (
4494
4626
  <View key={section.title || `section-${i}`}>
4495
4627
  {/* An untitled section is a standalone button (Media), not a group. */}
@@ -4509,7 +4641,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
4509
4641
  ))}
4510
4642
  </View>
4511
4643
  ))}
4512
- </Pane>
4644
+ </ScrollView>
4513
4645
  );
4514
4646
 
4515
4647
  // A full-width read-only notice (branch frozen in a change request) below the
@@ -4519,274 +4651,206 @@ function DesktopBrowser(props: ContentBrowserProps) {
4519
4651
  );
4520
4652
  const modalOverlays = <ModalOverlays {...props} />;
4521
4653
 
4522
- // The changes surface reuses the same three-pane model as Edit, so switching
4523
- // between them doesn't relayout the screen — only what each pane contains
4524
- // changes. Everything that mutates a document (create, bulk select, search,
4525
- // folder drill-down, tabs, autosave) is deliberately absent: this describes a
4526
- // comparison between two branches, not a document you can save.
4527
- if ((props.surface ?? "edit") === "changes") {
4528
- const changeCount = entries.length;
4529
- return (
4530
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4531
- {navPane}
4532
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4533
-
4534
- <Pane
4535
- width={listW}
4536
- testID="pane-changes"
4537
- scroll={false}
4538
- header={<PaneTitle title={activeNavKey ? activeLabel : "Changes"} />}
4539
- >
4540
- {!activeNavKey ? (
4541
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4542
- <Text variant="body" color="tertiary" testID="changes-empty">
4543
- {entriesEmpty ?? "Select a collection"}
4544
- </Text>
4545
- </View>
4546
- ) : (
4547
- <EntriesList
4548
- variant="desktop"
4549
- entries={entries}
4550
- selectedId={selectedId}
4551
- onOpenEntry={(entry) => activate(entry)}
4552
- header={
4553
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
4554
- <Text variant="label" color="tertiary">
4555
- {`${changeCount} changed`}
4556
- </Text>
4557
- </View>
4558
- }
4559
- folders={[]}
4560
- onFolderPress={() => {}}
4561
- />
4562
- )}
4563
- </Pane>
4564
- <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
+ );
4565
4660
 
4566
- <ChangeDetail
4567
- change={props.selectedChange}
4568
- loading={props.loadingChange}
4569
- emptyMessage={activeNavKey ? "Select a change" : "No changes on this branch"}
4570
- conflicts={props.selectedConflicts}
4571
- onResolveConflict={props.onResolveConflict}
4572
- />
4573
- {modalOverlays}
4574
- </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
+ />
4575
4692
  );
4576
- }
4577
-
4578
- // A plugin screen replaces the list + detail panes with one flexing pane;
4579
- // the sidebar keeps its place (and its highlight, via the host's nav key).
4580
- if (props.contentSlot != null) {
4581
- return (
4582
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4583
- {navPane}
4584
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4585
- <Pane flex={1} testID="pane-plugin" scroll={false}>
4586
- {props.contentSlot}
4587
- </Pane>
4588
- {modalOverlays}
4589
- </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
+ />
4590
4705
  );
4591
- }
4592
-
4593
- // Media reuses the three-pane model rather than replacing it: the content pane
4594
- // lists the media sets exactly where a collection's documents would be, and the
4595
- // details pane holds the browser for whichever set is selected. Selecting a set
4596
- // goes through onSelectNav, so it lands in the URL like any other selection.
4597
- // Forms enter through the same content column every other surface uses: the
4598
- // list of forms sits exactly where a collection's documents would, at the
4599
- // list width, with the empty state beside it. A list of half a dozen rows
4600
- // stretched across everything after the sidebar reads as a different kind of
4601
- // screen than Edit, Changes and Media, when it is the same kind of screen.
4602
- //
4603
- // Opening a form is where forms stop fitting the three-pane model, and so it
4604
- // is where they leave it: that surface is two more levels deep (submissions →
4605
- // one submission), and threading those through panes meant for collection →
4606
- // document → editor would give a pane whose meaning changes with the level.
4607
- // FormsBrowser owns its own split from there.
4608
- if (formsShowing && props.forms) {
4609
- if (!activeForm) {
4610
- return (
4611
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4612
- {navPane}
4613
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4614
-
4615
- <Pane width={listW} testID="pane-forms" scroll={false} header={<PaneTitle title="Forms" />}>
4616
- <FormsBrowser
4617
- api={props.forms}
4618
- form={null}
4619
- onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4620
- 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}
4621
4742
  />
4622
- </Pane>
4623
- <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-forms" />
4624
-
4625
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4626
- <Text variant="body" color="tertiary" testID="forms-empty">Select a form</Text>
4627
- </View>
4628
- {modalOverlays}
4629
- </AppShell>
4630
- );
4631
- }
4632
- return (
4633
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4634
- {navPane}
4635
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4636
- <Pane flex={1} testID="pane-forms" scroll={false}>
4637
- <FormsBrowser
4638
- api={props.forms}
4639
- form={activeForm}
4640
- onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
4641
- selectedId={props.selectedSubmissionId ?? null}
4642
- onSelectSubmission={props.onSelectSubmission}
4643
- variant="desktop"
4644
- />
4645
- </Pane>
4646
- {modalOverlays}
4647
- </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
+ />
4648
4794
  );
4649
4795
  }
4650
4796
 
4651
- if (mediaShowing) {
4652
- return (
4653
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4654
- {navPane}
4655
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4656
-
4657
- <Pane width={listW} testID="pane-media-sets" scroll={false} header={<PaneTitle title="Media" />}>
4658
- <MediaSetList
4659
- sets={mediaSets}
4660
- selected={mediaSet}
4661
- onSelect={(name) => onSelectNav(mediaNavKey(name))}
4662
- />
4663
- </Pane>
4664
- <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
+ );
4665
4805
 
4666
- {mediaSet ? (
4667
- <MediaBrowser set={mediaSet} label={mediaSetLabel(mediaSets, mediaSet)} variant="desktop" />
4668
- ) : (
4669
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4670
- <Text variant="body" color="tertiary" testID="media-sets-empty">Select a media collection</Text>
4671
- </View>
4672
- )}
4673
- {modalOverlays}
4674
- </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
+ />
4675
4819
  );
4676
- }
4677
-
4678
- return (
4679
- <AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
4680
- {/* content types */}
4681
- {navPane}
4682
-
4683
- {/* draggable seam: resize the nav pane */}
4684
- <ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
4685
-
4686
- {/* entries — collapsible to a rotated-label rail */}
4687
- {listCollapsed ? (
4688
- <CollapsedRail
4689
- testID="list-rail"
4690
- label={listLabel}
4691
- onExpand={() => setListCollapsed(false)}
4692
- 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"
4693
4836
  />
4694
- ) : (
4695
- <Pane
4696
- width={listW}
4697
- testID="pane-entries"
4698
- scroll={false}
4699
- header={listHeader}
4700
- >
4701
- {search.advanced ? (
4702
- <AdvancedSearch
4703
- fields={facetFields}
4704
- initial={search.draft}
4705
- onChange={search.setDraft}
4706
- onApply={search.applyDraft}
4707
- matchCount={search.matchCount}
4708
- counting={search.counting}
4709
- />
4710
- ) : browsing && !activeNavKey ? (
4711
- <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
4712
- <Text variant="body" color="tertiary" testID="entries-empty">{entriesEmpty}</Text>
4713
- </View>
4714
- ) : (
4715
- <EntriesList
4716
- variant="desktop"
4717
- entries={visibleEntries}
4718
- selectedId={selectedId}
4719
- onOpenEntry={openEntry}
4720
- header={
4721
- showChecks && selectedCount > 0 && (onBulkDelete || canBulkMove) ? (
4722
- <SelectionBar
4723
- count={selectedCount}
4724
- onClear={clear}
4725
- onMove={canBulkMove ? () => setMovePrompt(true) : undefined}
4726
- onDelete={onBulkDelete ? async () => { await onBulkDelete(moveIds); clear(); } : undefined}
4727
- />
4728
- ) : (
4729
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
4730
- <Text variant="label" color="tertiary">
4731
- {`${totalEntries ?? entries.length} entries`}
4732
- </Text>
4733
- {/* The count is of documents with unsaved edits in this
4734
- browser — the same state the rows below badge as Draft.
4735
- It replaced workspace.changed here, which every host
4736
- passed as a constant and which therefore told nobody
4737
- anything. Rendered only when there is something to
4738
- report: a permanent "0 uncommitted" is furniture. */}
4739
- {draftsOpen > 0 ? (
4740
- <>
4741
- <Text variant="label" color="tertiary">·</Text>
4742
- <Text variant="label" color="primary" testID="uncommitted-count">
4743
- {`${draftsOpen} uncommitted`}
4744
- </Text>
4745
- </>
4746
- ) : null}
4747
- </View>
4748
- )
4749
- }
4750
- upRow={
4751
- hierarchical && folder.length > 0 ? (
4752
- <PressableRow onPress={() => setFolder(folder.slice(0, -1))}>
4753
- <UpListItem parentLabel={parentLabel(folder, activeLabel)} currentPath={folder.join("/")} />
4754
- </PressableRow>
4755
- ) : null
4756
- }
4757
- folders={
4758
- hierarchical
4759
- ? folderNames.map((name) => ({
4760
- name,
4761
- count: countItems(node!.folders.get(name)!),
4762
- key: [...folder, name].join("/"),
4763
- }))
4764
- : []
4765
- }
4766
- onFolderPress={(name) => setFolder([...folder, name])}
4767
- showChecks={showChecks}
4768
- checked={checked}
4769
- onToggleEntry={toggle}
4770
- showFolderChecks={canBulkMove}
4771
- checkedFolders={checkedFolders}
4772
- onToggleFolder={toggleFolder}
4773
- // Pagination is driven by the flat list; the folder drill-down loads
4774
- // the whole set up front, so hasMore is false there.
4775
- hasMore={!hierarchical && hasMoreEntries}
4776
- loadingMore={loadingMoreEntries}
4777
- onLoadMore={onLoadMoreEntries}
4778
- />
4779
- )}
4780
- </Pane>
4781
- )}
4782
-
4783
- {/* draggable seam: resize the entry list (only while it's expanded) */}
4784
- {listCollapsed ? null : (
4785
- <ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-list" />
4786
- )}
4787
-
4788
- {/* detail — a horizontally-scrolling row of side-by-side document columns.
4789
- 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 = (
4790
4854
  <View
4791
4855
  testID="pane-detail"
4792
4856
  ref={detailRef}
@@ -4809,27 +4873,13 @@ function DesktopBrowser(props: ContentBrowserProps) {
4809
4873
  scrollEventThrottle={16}
4810
4874
  onScroll={(ev) => { scrollXRef.current = ev.nativeEvent.contentOffset.x; }}
4811
4875
  >
4812
- {columns.flatMap((col, i) => {
4876
+ {shownColumns.flatMap((col, i) => {
4813
4877
  const nodes: React.ReactNode[] = [];
4814
4878
  if (dragId && overSlot === i) nodes.push(dropIndicator(`drop-${i}`));
4815
- nodes.push(
4816
- collapsedCols[col.id] ? (
4817
- <CollapsedRail
4818
- key={col.id}
4819
- testID={`column-${col.id}`}
4820
- label={col.title || col.entry?.title || "Untitled"}
4821
- onExpand={() => setCollapsedCols((c) => ({ ...c, [col.id]: false }))}
4822
- onClose={() => closeColumn(col.id)}
4823
- dragHandle={columnGrip(col.id)}
4824
- dimmed={dragId === col.id}
4825
- />
4826
- ) : (
4827
- columnView(col)
4828
- ),
4829
- );
4879
+ nodes.push(columnView(col));
4830
4880
  return nodes;
4831
4881
  })}
4832
- {dragId && overSlot === columns.length ? dropIndicator("drop-end") : null}
4882
+ {dragId && overSlot === shownColumns.length ? dropIndicator("drop-end") : null}
4833
4883
  {showCreate ? (
4834
4884
  <View
4835
4885
  testID="column-create"
@@ -4854,12 +4904,33 @@ function DesktopBrowser(props: ContentBrowserProps) {
4854
4904
  </ScrollView>
4855
4905
  ) : (
4856
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. */}
4857
4910
  <Text variant="body" color="tertiary" testID="detail-empty">
4858
- {browsing ? (activeNavKey ? `Select ${activeLabel}` : "") : "No entry selected."}
4911
+ {!browsing
4912
+ ? "No entry selected."
4913
+ : !activeNavKey
4914
+ ? entriesEmpty
4915
+ : activeItem?.direct
4916
+ ? ""
4917
+ : `Select ${activeLabel}`}
4859
4918
  </Text>
4860
4919
  </View>
4861
4920
  )}
4862
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}
4863
4934
  {movePrompt && onBulkMove ? (
4864
4935
  <PromptDialog
4865
4936
  title={`Move ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
@@ -5282,10 +5353,6 @@ function MobileBrowser(props: ContentBrowserProps) {
5282
5353
  );
5283
5354
  }
5284
5355
 
5285
- // Width of a collapsed rail (the content list or a document column). Narrow,
5286
- // full-height, with its label rotated 90° — clicking the body re-expands it.
5287
- const RAIL_W = 44;
5288
-
5289
5356
  // RN's typed `cursor` only allows auto/pointer; react-native-web forwards any CSS
5290
5357
  // cursor to the DOM. Cast these desktop-only affordances through ViewStyle.
5291
5358
  const CURSOR_COL_RESIZE = { cursor: "col-resize" } as unknown as ViewStyle;
@@ -5362,14 +5429,15 @@ function ResizeHandle({
5362
5429
 
5363
5430
  // CustomColumn is a column whose body is a caller-supplied node rather than the
5364
5431
  // document editor (OpenTab.content). It reproduces the editor column's header
5365
- // 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
5366
5433
  // peer of the document columns rather than something visibly bolted on. The
5367
5434
  // body gets no padding and no scroll container: an iframe wants the whole box.
5368
5435
  function CustomColumn({
5369
5436
  title,
5370
5437
  active,
5371
5438
  onActivate,
5372
- onCollapse,
5439
+ expanded,
5440
+ onToggleExpand,
5373
5441
  onClose,
5374
5442
  dragHandle,
5375
5443
  children,
@@ -5377,7 +5445,8 @@ function CustomColumn({
5377
5445
  title: string;
5378
5446
  active?: boolean;
5379
5447
  onActivate?: () => void;
5380
- onCollapse?: () => void;
5448
+ expanded?: boolean;
5449
+ onToggleExpand?: () => void;
5381
5450
  onClose?: () => void;
5382
5451
  dragHandle?: React.ReactNode;
5383
5452
  children: React.ReactNode;
@@ -5399,8 +5468,15 @@ function CustomColumn({
5399
5468
  <Pressable onPress={onActivate} style={{ flex: 1 }}>
5400
5469
  <Text variant="monoSm" numberOfLines={1}>{title}</Text>
5401
5470
  </Pressable>
5402
- {onCollapse ? (
5403
- <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
+ />
5404
5480
  ) : null}
5405
5481
  {onClose ? (
5406
5482
  <IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
@@ -5411,72 +5487,6 @@ function CustomColumn({
5411
5487
  );
5412
5488
  }
5413
5489
 
5414
- // CollapsedRail is the thin vertical strip a collapsed pane becomes. It mirrors
5415
- // the expanded pane's header: an optional top control row (a trailing slot such
5416
- // as the create "+", and/or a close button) over a pressable body whose rotated
5417
- // label matches the header's title. onExpand fires on a body press.
5418
- function CollapsedRail({
5419
- label,
5420
- onExpand,
5421
- onClose,
5422
- trailing,
5423
- dragHandle,
5424
- dimmed,
5425
- testID,
5426
- }: {
5427
- label: string;
5428
- onExpand: () => void;
5429
- onClose?: () => void;
5430
- trailing?: React.ReactNode;
5431
- // A drag grip (column rails) rendered above the close button in the control row.
5432
- dragHandle?: React.ReactNode;
5433
- // Dim the rail while it's the column being dragged.
5434
- dimmed?: boolean;
5435
- testID?: string;
5436
- }) {
5437
- const t = useTheme();
5438
- return (
5439
- <View
5440
- testID={testID}
5441
- style={{
5442
- width: RAIL_W, alignSelf: "stretch", minHeight: 0, overflow: "hidden",
5443
- borderRightWidth: 1, borderRightColor: t.color.borderSubtle,
5444
- backgroundColor: t.color.surfacePage,
5445
- opacity: dimmed ? 0.5 : 1,
5446
- }}
5447
- >
5448
- {trailing || onClose || dragHandle ? (
5449
- <View
5450
- style={{
5451
- height: t.layout.topbar, alignItems: "center", justifyContent: "center", gap: t.space(1),
5452
- borderBottomWidth: 1, borderBottomColor: t.color.borderSubtle,
5453
- }}
5454
- >
5455
- {dragHandle}
5456
- {trailing}
5457
- {onClose ? <IconButton name="x" size="sm" label="Close" onPress={onClose} testID="rail-close" /> : null}
5458
- </View>
5459
- ) : null}
5460
- <Pressable
5461
- onPress={onExpand}
5462
- style={({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => ({
5463
- flex: 1, alignItems: "center", justifyContent: "center", gap: t.space(3),
5464
- backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
5465
- })}
5466
- >
5467
- <Icon name="chevronRight" size={14} color={t.color.textTertiary} />
5468
- {/* rotate doesn't change the layout box, so the pre-rotation width becomes
5469
- the label's on-screen height; overflow:hidden on the rail clips it. */}
5470
- <View style={{ transform: [{ rotate: "90deg" }] }}>
5471
- <Text variant="monoSm" weight="medium" numberOfLines={1} style={{ width: 240, textAlign: "center" }}>
5472
- {label}
5473
- </Text>
5474
- </View>
5475
- </Pressable>
5476
- </View>
5477
- );
5478
- }
5479
-
5480
5490
  // Small Pressable wrapper so list items get press feedback without duplicating.
5481
5491
  // Row press feedback: the selected row holds a stronger tint (surfaceActive) so
5482
5492
  // it stays distinct from the lighter hover tint (surfaceHover) on other rows.
@@ -5538,23 +5548,6 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
5538
5548
  return set ? titleCaseWord(set.name) : titleCaseWord(name);
5539
5549
  }
5540
5550
 
5541
- // PaneTitle is the content pane's heading for the surfaces with no controls
5542
- // beside it — Changes, Media, Forms.
5543
- //
5544
- // Deliberately the same Text the collection header uses (`listHeader`), and
5545
- // nothing else. Pane's header row already supplies the height, the horizontal
5546
- // padding and the rule beneath, so the padded wrapper this used to add sat
5547
- // inside that padding and pushed the title a step right of every collection's —
5548
- // at `body` rather than `h3`, so it read a size smaller too. Switching between
5549
- // Posts and Media moved the heading twice over.
5550
- function PaneTitle({ title }: { title: string }) {
5551
- return (
5552
- <Text variant="h3" weight="semibold" style={{ flex: 1 }}>
5553
- {title}
5554
- </Text>
5555
- );
5556
- }
5557
-
5558
5551
  // MediaSetList is the content pane under Media: one row per media collection,
5559
5552
  // sitting exactly where a collection's documents sit. Each row shows how many
5560
5553
  // files the set holds and the glob it is discovered by, since that glob is the
@@ -5602,15 +5595,138 @@ function MediaSetList({
5602
5595
  );
5603
5596
  }
5604
5597
 
5605
- // navRowActive reports whether a sidebar row should read as current. The Media
5606
- // button stays lit while a set beneath it is open ("media:uploads"), because the
5607
- // set is not itself a sidebar row — losing the highlight there would leave the
5608
- // 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.
5609
5603
  function navRowActive(itemKey: string, activeNavKey: string): boolean {
5610
5604
  if (itemKey === MEDIA_NAV_KEY) return isMediaNavKey(activeNavKey);
5605
+ if (itemKey === FORMS_NAV_KEY) return isFormsNavKey(activeNavKey);
5611
5606
  return itemKey === activeNavKey;
5612
5607
  }
5613
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
+
5614
5730
  // navLabelFor finds the sidebar label for a nav key, so a pane header shows the
5615
5731
  // same words the user clicked rather than re-deriving them from the key.
5616
5732
  function navLabelFor(sections: CmsNavSection[], key: string): string | undefined {