@brass-build/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
@@ -0,0 +1,651 @@
1
+ // Document file-menu commands: the per-doc list of built-in actions a
2
+ // multi-doc app reads as data and places into its own chrome (Version
3
+ // history / Branches / Details / Share / Delete suggested for the menu,
4
+ // Undo / Redo suggested for the toolbar), plus the branches submenu.
5
+ //
6
+ // The SDK owns the action set, each command's live `enabled` / `running`
7
+ // state, and its `run()`; the app owns placement and styling. Each
8
+ // command carries a `surface` hint ('menu' | 'toolbar') the app is free
9
+ // to honor or ignore. The SDK does not render the menu itself: an app
10
+ // surfaces its own actions with its own buttons and calls SDK methods
11
+ // directly.
12
+
13
+ import type {
14
+ DocumentHistoryResult,
15
+ DocumentSettingsFocus,
16
+ DocumentSettingsResult,
17
+ DriveResolveResult,
18
+ } from './hosted-ui.js';
19
+ import type { BranchSummary } from './documents-namespace.js';
20
+
21
+ // Hosted-UI capabilities the built-in commands need (history modal,
22
+ // manage / share actions, branch picker). Injected by the factory; same
23
+ // DI shape as the Document's other providers.
24
+ export interface HostedUiProvider {
25
+ documentHistory(
26
+ docId: string,
27
+ options?: { branch?: string },
28
+ ): Promise<DocumentHistoryResult | null>;
29
+ // Resolves with the copy's id when the user duplicated the document
30
+ // there and asked to open it; `null` otherwise.
31
+ documentSettings(
32
+ docId: string,
33
+ options?: { focus?: DocumentSettingsFocus },
34
+ ): Promise<DocumentSettingsResult | null>;
35
+ // Optional callback invoked after a settings-driven rename so the
36
+ // Document can refresh its cached name. The factory wires this to
37
+ // a `documents.get` call; passing the new name keeps the round
38
+ // trip on the SDK side rather than forcing the app to re-fetch.
39
+ refreshName?: (docId: string) => Promise<string | null>;
40
+ // Called by the `history` built-in for a fork outcome or cross-branch
41
+ // version: the doc checks out only its own branch in place, so
42
+ // switching branches needs a higher-level coordinator. The session
43
+ // wires this to `session.open` (single-doc apps get instance-swap
44
+ // routing for free); multi-doc apps wire their own.
45
+ openSiblingDoc?: (
46
+ docId: string,
47
+ options: { branch?: string; version?: number },
48
+ ) => Promise<void>;
49
+ selectBranch?: (
50
+ docId: string,
51
+ options?: { branch?: string },
52
+ ) => Promise<{ branch_id: string } | null>;
53
+ // Open the hosted drive-divergence resolution surface
54
+ // (`Document.resolveDrive()` routes through this). Resolves with the
55
+ // outcome (a fresh mirror doc id, or `null` newDocId for an in-place
56
+ // publish-over), or `null` when dismissed.
57
+ driveResolve?: (docId: string) => Promise<DriveResolveResult | null>;
58
+ // Open the hosted acquire-organization surface for a caller with zero
59
+ // organizations; resolves with the organization they created or
60
+ // joined, or `null` if dismissed. `createDocument`'s implicit-org
61
+ // resolution falls back to this instead of throwing.
62
+ acquireOrganization?: () => Promise<{ organization_id: string } | null>;
63
+ // Whether someone can produce a file from this document, the same
64
+ // question the hosted share dialog gates its download row on. The
65
+ // `share` built-in's label names the download only when this resolves
66
+ // true, so the menu entry offers exactly what the dialog holds. The
67
+ // factory wires it to a `documents.get` read. `null` means the read
68
+ // could not tell (it was refused or it failed), which the label treats
69
+ // as a no and retries; only `true` / `false` settle it.
70
+ documentDownloadable?: (docId: string) => Promise<boolean | null>;
71
+ }
72
+
73
+ // One entry in the file menu. The Document owns these (each doc
74
+ // carries its own command list with its own `running` state); the
75
+ // session concatenates its orphan commands ('new' / 'open') with the
76
+ // active doc's commands so single-doc apps see one unified list.
77
+ export interface Command {
78
+ // Stable identifier: 'new' | 'open' | 'history' | 'branches' |
79
+ // 'manage' | 'share' | 'undo' | 'redo'.
80
+ readonly id: string;
81
+ // Display label, sentence case, ready to render verbatim. A command may
82
+ // relabel as its state settles (`share` names the download once it knows
83
+ // the document has one), so read it on every notification the way
84
+ // `enabled` is read.
85
+ readonly label: string;
86
+ // 'session' (New / Open, which put a different document on screen),
87
+ // 'document' (this document: version history, branches, share, manage),
88
+ // or 'edit' (Undo / Redo). Render a separator where the group changes
89
+ // when laying commands out in one surface.
90
+ readonly group: CommandGroup;
91
+ // Suggested chrome for this command: 'menu' (a hamburger / file menu)
92
+ // or 'toolbar' (a prominent button). A hint, not a requirement; the
93
+ // app filters `commands` by `surface` to place each one, or ignores
94
+ // it and lays them out however it likes.
95
+ readonly surface: CommandSurface;
96
+ // Whether invoking the command would do anything right now. The SDK
97
+ // recomputes this as state changes; re-read on every notification.
98
+ readonly enabled: boolean;
99
+ // True while `run()` is in flight. Drives the pending affordance
100
+ // (disable + relabel) without the app tracking it.
101
+ readonly running: boolean;
102
+ // Optional submenu items. When present, render the command as a
103
+ // submenu parent (label + disclosure chevron) and the children as
104
+ // nested items. The parent's own `run()` is a no-op; each child
105
+ // has its own `run()`. Used by the built-in `branches` command.
106
+ readonly children?: readonly Command[];
107
+ // True when this command represents the currently active choice in
108
+ // a submenu (e.g. the active branch). Render a checkmark or bold.
109
+ readonly selected?: boolean;
110
+ // Invoke the command. Always resolves; a failure is routed to
111
+ // `Brass.onError`, never thrown, so a render-loop `onclick` handler
112
+ // does not need a `.catch`.
113
+ run(): Promise<void>;
114
+ }
115
+
116
+ export type CommandGroup = 'session' | 'document' | 'edit';
117
+
118
+ // Suggested chrome for a command. The app is free to honor or ignore it.
119
+ export type CommandSurface = 'menu' | 'toolbar';
120
+
121
+ // Built-in doc-scoped command labels. The session adds 'new' / 'open'
122
+ // (which don't need an active doc) at its own level.
123
+ const DOC_COMMAND_LABELS = {
124
+ history: 'Version history...',
125
+ branches: 'Branches...',
126
+ // Rename / move / delete are one surface ("the document's place in the
127
+ // filesystem"), so they are one menu entry, not separate Details + Delete.
128
+ manage: 'Manage...',
129
+ share: 'Share...',
130
+ undo: 'Undo',
131
+ redo: 'Redo',
132
+ } as const;
133
+
134
+ // The `share` label on a document someone can take a file away from.
135
+ // Taking a copy out is one row of the share dialog, beside the access
136
+ // roster, the way a phone's share sheet holds "save a file" next to "send
137
+ // to a person", so it earns a name in the entry that opens that dialog
138
+ // rather than a menu entry of its own. A document with no file to hand
139
+ // over keeps the plain label, so the entry never names a download the
140
+ // dialog does not offer.
141
+ const SHARE_WITH_DOWNLOAD_LABEL = 'Share / Download...';
142
+
143
+ // How many times a document's downloadable read may be attempted before the
144
+ // label stays as it is for the rest of the document's life, and how long a
145
+ // read that could not tell holds off the next attempt. The delay is what
146
+ // makes the retry cover the case it exists for: a session emits several
147
+ // times in its first second, so retrying per render would spend every
148
+ // attempt before the install that refused the read has landed.
149
+ const DOWNLOADABLE_MAX_ATTEMPTS = 3;
150
+ const DOWNLOADABLE_RETRY_DELAY_MS = 5000;
151
+
152
+ // Suggested chrome per built-in: the per-doc actions live in the file
153
+ // menu; Undo / Redo are toolbar buttons (the shape canvas / text apps
154
+ // reach for). 'branches' is built separately but shares the 'menu' hint.
155
+ const DOC_COMMAND_SURFACES: Record<
156
+ keyof typeof DOC_COMMAND_LABELS,
157
+ CommandSurface
158
+ > = {
159
+ history: 'menu',
160
+ branches: 'menu',
161
+ manage: 'menu',
162
+ share: 'menu',
163
+ undo: 'toolbar',
164
+ redo: 'toolbar',
165
+ };
166
+
167
+ function commandToError(err: unknown): Error {
168
+ return err instanceof Error ? err : new Error(String(err));
169
+ }
170
+
171
+ // Assemble a live `Command`. The returned object exposes `label` /
172
+ // `enabled` / `running` as getters over the injected state, so one stable
173
+ // instance reflects state changes and reconcilers match it by reference;
174
+ // never spread it (`{ ...cmd }` snapshots the getters into frozen values).
175
+ export function makeLiveCommand(
176
+ spec: Pick<Command, 'id' | 'group' | 'surface' | 'selected'> & {
177
+ // A function for a label that tracks state (`share` names the download
178
+ // once the document is known to have one).
179
+ label: string | (() => string);
180
+ // Defaults to "enabled unless running".
181
+ isEnabled?: () => boolean;
182
+ impl: () => Promise<void> | void;
183
+ },
184
+ wiring: {
185
+ // Shared per-id running map; defaults to a flag local to this command.
186
+ running?: Map<string, boolean>;
187
+ onError: (err: unknown) => void;
188
+ // Fires after each running flip so subscribers re-read.
189
+ notify?: () => void;
190
+ },
191
+ ): Command {
192
+ const { running: map, onError, notify } = wiring;
193
+ const label = spec.label;
194
+ const readLabel = typeof label === 'function' ? label : (): string => label;
195
+ let localFlag = false;
196
+ const readRunning = map
197
+ ? (): boolean => map.get(spec.id) === true
198
+ : (): boolean => localFlag;
199
+ const setRunning = (value: boolean): void => {
200
+ if (map) map.set(spec.id, value);
201
+ else localFlag = value;
202
+ notify?.();
203
+ };
204
+ const isEnabled = spec.isEnabled ?? ((): boolean => !readRunning());
205
+ return {
206
+ id: spec.id,
207
+ group: spec.group,
208
+ surface: spec.surface,
209
+ ...(spec.selected !== undefined ? { selected: spec.selected } : {}),
210
+ get label(): string {
211
+ return readLabel();
212
+ },
213
+ get enabled(): boolean {
214
+ return isEnabled();
215
+ },
216
+ get running(): boolean {
217
+ return readRunning();
218
+ },
219
+ async run(): Promise<void> {
220
+ if (readRunning() || !isEnabled()) return;
221
+ setRunning(true);
222
+ try {
223
+ await spec.impl();
224
+ } catch (err) {
225
+ onError(err);
226
+ } finally {
227
+ setRunning(false);
228
+ }
229
+ },
230
+ };
231
+ }
232
+
233
+ // The contract the built-in command implementations need back from the
234
+ // owning Document. Wide because the file menu is, by design, the
235
+ // surface that reaches across every other doc capability (history /
236
+ // checkout, branches, undo, rename).
237
+ export interface CommandMenuHost {
238
+ docId(): string | null;
239
+ branchId(): string | null;
240
+ // True when the doc is showing a historical version (built-in
241
+ // `enabled` rules disable edit affordances then).
242
+ readOnly(): boolean;
243
+ // True when the doc is live (not closed / suspended).
244
+ isLive(): boolean;
245
+ canUndo(): boolean;
246
+ canRedo(): boolean;
247
+ undo(): void;
248
+ redo(): void;
249
+ // Same-branch version preview folds onto the doc in place.
250
+ checkoutVersion(version: number): Promise<void>;
251
+ // Show the version-browsing surface over the live document, without
252
+ // checking anything out. The hosted history dialog returns this when the
253
+ // reader wants to browse in the application rather than pick in a modal.
254
+ browseVersions(): void;
255
+ // Branches submenu reads the list + provider presence and creates.
256
+ branches(): readonly BranchSummary[];
257
+ hasBranchProvider(): boolean;
258
+ createBranch(input: { name?: string; from_version?: number }): Promise<BranchSummary>;
259
+ // Re-pull the branch list from the server. The `branches` built-in
260
+ // calls this after the hosted dialog closes without a switch, so a
261
+ // rename made in the dialog reaches the cached list the branch chip
262
+ // and page title read the active branch name from.
263
+ refreshBranches(): Promise<void>;
264
+ // The `details` built-in refreshes the cached doc name after a rename.
265
+ setName(name: string): void;
266
+ // Fire the Document's `commands` subscribe event so the menu
267
+ // re-renders (a label / running / registration change).
268
+ onCommandsChanged(): void;
269
+ }
270
+
271
+ export class CommandMenu {
272
+ // Hosted-UI provider for built-in commands. `null` until the factory
273
+ // wires it; the built-ins' `enabled` rule reads `_hostedUi !== null`
274
+ // so they disable in tests / offline flows.
275
+ private _hostedUi: HostedUiProvider | null = null;
276
+ // Per-built-in `running` flag, set during a command's `run()` and
277
+ // cleared after. Drives the pending-affordance UI.
278
+ private readonly _builtinRunning = new Map<string, boolean>();
279
+ // Lazily-built built-in command instances. The `Command` shape
280
+ // exposes getters that read live state (`enabled` / `running`), so
281
+ // the same object reflects state changes without recreating.
282
+ private _builtinCommands: readonly Command[] | null = null;
283
+ // Optional error reporter for built-in command failures. Wired by
284
+ // the factory to `Brass.onError`'s emit path; `null` falls back
285
+ // to `console.error`.
286
+ private _onError: ((err: Error) => void) | null = null;
287
+ // Whether this document has a file someone can take away, which is what
288
+ // the `share` label names. `false` until the read lands, so the entry
289
+ // only ever promises what is confirmed.
290
+ private _downloadable = false;
291
+ private _downloadableSettled = false;
292
+ private _downloadableInFlight = false;
293
+ private _downloadableAttempts = 0;
294
+ private _downloadableRetryAt = 0;
295
+
296
+ constructor(private readonly host: CommandMenuHost) {}
297
+
298
+ setHostedUiProvider(provider: HostedUiProvider | null): void {
299
+ this._hostedUi = provider;
300
+ }
301
+
302
+ // Optional error sink for the doc's built-in commands. Wired by
303
+ // the factory to `Brass.onError`'s emit path so async failures
304
+ // surface through one stream. `null` falls back to `console.error`.
305
+ setErrorReporter(reporter: ((err: Error) => void) | null): void {
306
+ this._onError = reporter;
307
+ }
308
+
309
+ // The per-doc file menu, as data. Each Document carries its own list
310
+ // with its own `running` state; multi-doc apps render one menu per
311
+ // pane. The app filters by `surface` to place each command.
312
+ get commands(): readonly Command[] {
313
+ this._resolveDownloadable();
314
+ return this._builtins();
315
+ }
316
+
317
+ // Ask, on a read of the list, whether this document has a file to hand
318
+ // over. Reading the list is what every menu renderer does as it mounts,
319
+ // so the answer lands long before a reader opens the menu, while an app
320
+ // that renders no menu never spends the request.
321
+ //
322
+ // A read that could not tell (`null`: refused, or failed) leaves the
323
+ // plain `Share...` label and lets a later render try again, because the
324
+ // moment it is most likely to be refused is the first create in an
325
+ // organization, where the install lands seconds afterwards and the
326
+ // document becomes readable. The delay and the attempt cap are what keep
327
+ // that to a few spaced requests rather than one per render.
328
+ private _resolveDownloadable(): void {
329
+ if (this._downloadableSettled || this._downloadableInFlight) return;
330
+ if (this._downloadableAttempts >= DOWNLOADABLE_MAX_ATTEMPTS) return;
331
+ if (Date.now() < this._downloadableRetryAt) return;
332
+ const resolve = this._hostedUi?.documentDownloadable;
333
+ const docId = this.host.docId();
334
+ if (!resolve || docId === null) return;
335
+ this._downloadableAttempts++;
336
+ this._downloadableInFlight = true;
337
+ void resolve(docId).then(
338
+ (downloadable) => {
339
+ this._downloadableInFlight = false;
340
+ if (downloadable === null) {
341
+ this._downloadableRetryAt = Date.now() + DOWNLOADABLE_RETRY_DELAY_MS;
342
+ return;
343
+ }
344
+ this._downloadableSettled = true;
345
+ if (downloadable === this._downloadable) return;
346
+ this._downloadable = downloadable;
347
+ this.host.onCommandsChanged();
348
+ },
349
+ () => {
350
+ this._downloadableInFlight = false;
351
+ this._downloadableRetryAt = Date.now() + DOWNLOADABLE_RETRY_DELAY_MS;
352
+ },
353
+ );
354
+ }
355
+
356
+ // Invoke a command by id. A convenience for keyboard shortcuts
357
+ // (`Ctrl-Z` -> `doc.run('undo')`). No-op for an unknown id.
358
+ async run(commandId: string): Promise<void> {
359
+ const command = this.commands.find((c) => c.id === commandId);
360
+ if (command) await command.run();
361
+ }
362
+
363
+ // Fork a new branch from a historical version and switch to it. Drives
364
+ // the hosted version bar's "Branch from here": create the branch at
365
+ // `fromVersion` (with the bar-supplied initial `name`, e.g. the version's
366
+ // timestamp), then open it via the same instance-swap the branches
367
+ // submenu's create uses. No-op without a hosted provider (offline /
368
+ // tests) or an open doc.
369
+ async forkFromVersion(fromVersion: number, name?: string): Promise<void> {
370
+ const hosted = this._hostedUi;
371
+ const docId = this.host.docId();
372
+ if (!hosted || docId === null) return;
373
+ const created = await this.host.createBranch({
374
+ from_version: fromVersion,
375
+ ...(name ? { name } : {}),
376
+ });
377
+ if (hosted.openSiblingDoc) {
378
+ await hosted.openSiblingDoc(docId, { branch: created.branch_id });
379
+ }
380
+ }
381
+
382
+ // Internal: lazy-build the doc-scoped built-in command list. The
383
+ // returned `Command` objects have getters that read live state, so
384
+ // an `enabled` / `running` change is reflected without recreating.
385
+ private _builtins(): readonly Command[] {
386
+ if (this._builtinCommands !== null) return this._builtinCommands;
387
+ const hasHostedUi = (): boolean => this._hostedUi !== null;
388
+ const editable = (): boolean => !this.host.readOnly() && this.host.isLive();
389
+ const list: Command[] = [
390
+ this._buildCommand(
391
+ 'history',
392
+ DOC_COMMAND_LABELS.history,
393
+ 'document',
394
+ DOC_COMMAND_SURFACES.history,
395
+ hasHostedUi,
396
+ async () => {
397
+ const hosted = this._hostedUi;
398
+ const docId = this.host.docId();
399
+ if (!hosted || docId === null) return;
400
+ const branchId = this.host.branchId();
401
+ const result = await hosted.documentHistory(
402
+ docId,
403
+ branchId ? { branch: branchId } : undefined,
404
+ );
405
+ if (!result) return;
406
+ // Same-branch version preview folds onto this doc in place.
407
+ // Cross-branch / fork outcomes can't (they switch state
408
+ // entirely), so delegate to `openSiblingDoc`. Discriminate on
409
+ // `typeof === 'number'`, not `!== undefined`: the hosted embed
410
+ // evolves independently, and a fork serialized as `version: null`
411
+ // (a natural JSON shape) must not be misread as a version open,
412
+ // which would drop the user into a read-only checkout at a bogus
413
+ // version instead of their new editable branch.
414
+ const version =
415
+ typeof result.version === 'number' ? result.version : null;
416
+ // Browse-in-the-app carries no version: it opens the host's
417
+ // version surface where the reader already is, so it is checked
418
+ // before the version / fork split rather than falling into the
419
+ // fork branch on its absent version.
420
+ if (version === null && result.browse === true) {
421
+ this.host.browseVersions();
422
+ } else if (version !== null && result.branch_id === branchId) {
423
+ await this.host.checkoutVersion(version);
424
+ } else if (hosted.openSiblingDoc) {
425
+ await hosted.openSiblingDoc(docId, {
426
+ branch: result.branch_id,
427
+ ...(version !== null ? { version } : {}),
428
+ });
429
+ }
430
+ },
431
+ ),
432
+ this._buildBranchesCommand(),
433
+ this._buildCommand(
434
+ 'share',
435
+ () =>
436
+ this._downloadable
437
+ ? SHARE_WITH_DOWNLOAD_LABEL
438
+ : DOC_COMMAND_LABELS.share,
439
+ 'document',
440
+ DOC_COMMAND_SURFACES.share,
441
+ hasHostedUi,
442
+ async () => {
443
+ const hosted = this._hostedUi;
444
+ const docId = this.host.docId();
445
+ if (!hosted || docId === null) return;
446
+ // The two surfaces are one dialog with a cross-link, so a
447
+ // duplicate started from Share reports its copy here too.
448
+ const outcome = await hosted.documentSettings(docId, {
449
+ focus: 'share',
450
+ });
451
+ await this._openDuplicate(outcome);
452
+ },
453
+ ),
454
+ // Last of the document's entries, because it is the one holding a
455
+ // delete.
456
+ this._buildCommand(
457
+ 'manage',
458
+ DOC_COMMAND_LABELS.manage,
459
+ 'document',
460
+ DOC_COMMAND_SURFACES.manage,
461
+ hasHostedUi,
462
+ async () => {
463
+ const hosted = this._hostedUi;
464
+ const docId = this.host.docId();
465
+ if (!hosted || docId === null) return;
466
+ // The Manage surface is rename + move + duplicate + delete in
467
+ // one. A delete there ends via the server's `doc_gone` frame
468
+ // (routed into `close()`); a rename is reflected by the
469
+ // refreshName pull below.
470
+ const outcome = await hosted.documentSettings(docId, {
471
+ focus: 'details',
472
+ });
473
+ if (await this._openDuplicate(outcome)) return;
474
+ if (hosted.refreshName) {
475
+ const newName = await hosted.refreshName(docId);
476
+ if (newName !== null) this.host.setName(newName);
477
+ }
478
+ },
479
+ ),
480
+ this._buildCommand(
481
+ 'undo',
482
+ DOC_COMMAND_LABELS.undo,
483
+ 'edit',
484
+ DOC_COMMAND_SURFACES.undo,
485
+ () => editable() && this.host.canUndo(),
486
+ () => {
487
+ this.host.undo();
488
+ },
489
+ ),
490
+ this._buildCommand(
491
+ 'redo',
492
+ DOC_COMMAND_LABELS.redo,
493
+ 'edit',
494
+ DOC_COMMAND_SURFACES.redo,
495
+ () => editable() && this.host.canRedo(),
496
+ () => {
497
+ this.host.redo();
498
+ },
499
+ ),
500
+ ];
501
+ this._builtinCommands = list;
502
+ return list;
503
+ }
504
+
505
+ private _reportError = (err: unknown): void => {
506
+ if (this._onError) this._onError(commandToError(err));
507
+ else console.error('[Brass] doc command failed:', err);
508
+ };
509
+
510
+ // Build a single command over the shared per-id running map, notifying
511
+ // the host on each flip so the menu re-renders.
512
+ private _buildCommand(
513
+ id: string,
514
+ label: string | (() => string),
515
+ group: CommandGroup,
516
+ surface: CommandSurface,
517
+ isEnabled: () => boolean,
518
+ impl: () => Promise<void> | void,
519
+ ): Command {
520
+ return makeLiveCommand(
521
+ { id, label, group, surface, isEnabled, impl },
522
+ {
523
+ running: this._builtinRunning,
524
+ onError: this._reportError,
525
+ notify: () => this.host.onCommandsChanged(),
526
+ },
527
+ );
528
+ }
529
+
530
+ // Open the copy a settings-surface duplicate produced. The copy is a
531
+ // different document, not another state of this one, so it goes through
532
+ // the same coordinator a branch switch does. Reports whether it opened,
533
+ // so a caller can skip the follow-up reads that describe the doc it just
534
+ // navigated away from.
535
+ private async _openDuplicate(
536
+ outcome: DocumentSettingsResult | null,
537
+ ): Promise<boolean> {
538
+ const hosted = this._hostedUi;
539
+ if (!outcome || !hosted?.openSiblingDoc) return false;
540
+ await hosted.openSiblingDoc(outcome.newDocId, {});
541
+ return true;
542
+ }
543
+
544
+ // Build the branches submenu command. The parent is always present
545
+ // (enabled when the doc has a hostedUi provider for openSiblingDoc);
546
+ // its children are rebuilt dynamically from `doc.branches` on every
547
+ // access so the menu tracks branch creation / refresh reactively.
548
+ private _buildBranchesCommand(): Command {
549
+ const isEnabled = (): boolean => this._hostedUi !== null;
550
+ const readRunning = (): boolean =>
551
+ this._builtinRunning.get('branches') === true;
552
+
553
+ const buildChild = (
554
+ id: string,
555
+ label: string,
556
+ selected: boolean,
557
+ impl: () => Promise<void> | void,
558
+ ): Command =>
559
+ makeLiveCommand(
560
+ {
561
+ id,
562
+ label,
563
+ group: 'document',
564
+ surface: DOC_COMMAND_SURFACES.branches,
565
+ selected,
566
+ impl,
567
+ },
568
+ { onError: this._reportError },
569
+ );
570
+
571
+ const buildChildren = (): readonly Command[] => {
572
+ const items: Command[] = [];
573
+ const docId = this.host.docId();
574
+ const branchId = this.host.branchId();
575
+ for (const b of this.host.branches()) {
576
+ const isCurrent = b.branch_id === branchId;
577
+ items.push(
578
+ buildChild(`branch:${b.branch_id}`, b.name, isCurrent, async () => {
579
+ if (isCurrent) return;
580
+ const hosted = this._hostedUi;
581
+ if (hosted?.openSiblingDoc && docId) {
582
+ await hosted.openSiblingDoc(docId, {
583
+ branch: b.branch_id,
584
+ });
585
+ }
586
+ }),
587
+ );
588
+ }
589
+ if (this.host.hasBranchProvider() && docId !== null) {
590
+ items.push(
591
+ buildChild('branch:new', 'New branch...', false, async () => {
592
+ await this.host.createBranch({});
593
+ const branches = this.host.branches();
594
+ const newest = branches[branches.length - 1];
595
+ if (newest) {
596
+ const hosted = this._hostedUi;
597
+ if (hosted?.openSiblingDoc && docId) {
598
+ await hosted.openSiblingDoc(docId, {
599
+ branch: newest.branch_id,
600
+ });
601
+ }
602
+ }
603
+ }),
604
+ );
605
+ }
606
+ return items;
607
+ };
608
+
609
+ return {
610
+ id: 'branches',
611
+ label: DOC_COMMAND_LABELS.branches,
612
+ group: 'document',
613
+ surface: DOC_COMMAND_SURFACES.branches,
614
+ get enabled(): boolean {
615
+ return isEnabled();
616
+ },
617
+ get running(): boolean {
618
+ return readRunning();
619
+ },
620
+ get children(): readonly Command[] {
621
+ return buildChildren();
622
+ },
623
+ run: async (): Promise<void> => {
624
+ const hosted = this._hostedUi;
625
+ const docId = this.host.docId();
626
+ if (!hosted || !docId) return;
627
+ if (hosted.selectBranch) {
628
+ const branchId = this.host.branchId();
629
+ const result = await hosted.selectBranch(
630
+ docId,
631
+ branchId ? { branch: branchId } : undefined,
632
+ );
633
+ if (result && result.branch_id !== branchId && hosted.openSiblingDoc) {
634
+ // A different branch was chosen: reopening on it reloads the
635
+ // branch list as part of the open.
636
+ await hosted.openSiblingDoc(docId, {
637
+ branch: result.branch_id,
638
+ });
639
+ } else {
640
+ // No switch (dialog dismissed, or the active branch re-picked).
641
+ // The dialog can rename / set-default in place, so re-pull the
642
+ // list; a rename of the current branch otherwise leaves the
643
+ // branch chip + page title stale (they read the name from the
644
+ // cached list, which the dialog never touches).
645
+ await this.host.refreshBranches();
646
+ }
647
+ }
648
+ },
649
+ };
650
+ }
651
+ }