@brass-build/client 0.2.0 → 0.4.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.
- package/AGENTS.md +26 -5
- package/CHANGELOG.md +61 -2
- package/README.md +247 -87
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +278 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +583 -12
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +3 -0
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +51 -3
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +415 -36
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3 -3
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +28 -66
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +16 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +123 -35
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +13 -14
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +13 -14
- package/dist/record-paging.js.map +1 -1
- package/dist/serializer.d.ts +3 -3
- package/dist/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +191 -21
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +75 -4
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +4 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +70 -1
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +9 -10
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +7 -2
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +396 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +655 -15
- package/src/document-checkpoints.ts +30 -0
- package/src/document-contents.ts +104 -0
- package/src/document-signals.ts +113 -1
- package/src/document-streams.ts +15 -1
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +20 -10
- package/src/documents.ts +460 -33
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +7 -6
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +28 -62
- package/src/index.ts +43 -4
- package/src/internal.ts +10 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +179 -39
- package/src/presence.ts +6 -0
- package/src/record-paging.ts +13 -14
- package/src/serializer.ts +3 -3
- package/src/session-toolbar.ts +219 -22
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +77 -7
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +70 -1
- package/src/ws-frame-limit.ts +9 -10
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
package/src/session-toolbar.ts
CHANGED
|
@@ -19,7 +19,10 @@ import type { BrassUser } from './types.js';
|
|
|
19
19
|
import type { Command, Document as BrassDocument } from './documents.js';
|
|
20
20
|
import type { DocumentSession } from './session.js';
|
|
21
21
|
import type { PresenceParticipant } from './presence.js';
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
DocumentDriveStatus,
|
|
24
|
+
DrivePublishTarget,
|
|
25
|
+
} from './document-signals.js';
|
|
23
26
|
import {
|
|
24
27
|
createVersionBarFrame,
|
|
25
28
|
type VersionBarDeps,
|
|
@@ -70,6 +73,17 @@ export interface AccountMenuDeps {
|
|
|
70
73
|
// dashboard" link. The client's resolved dashboard origin (picks the
|
|
71
74
|
// environment), with no trailing slash.
|
|
72
75
|
dashboardBaseUrl: string;
|
|
76
|
+
// The organization this app is currently acting in, or null while none has
|
|
77
|
+
// been resolved or the one resolved has no name yet.
|
|
78
|
+
getActingOrganization?: () => { id: string; name: string } | null;
|
|
79
|
+
// Push-based, because the value is resolved lazily at the first act that
|
|
80
|
+
// needs it and can change when the reader switches.
|
|
81
|
+
subscribeActingOrganization?: (
|
|
82
|
+
listener: (organization: { id: string; name: string } | null) => void,
|
|
83
|
+
) => () => void;
|
|
84
|
+
// Opens the hosted surface that picks the organization to act in, which is
|
|
85
|
+
// also where one gets created.
|
|
86
|
+
switchOrganization?: () => void;
|
|
73
87
|
}
|
|
74
88
|
|
|
75
89
|
export type SessionToolbarDeps = FileMenuDeps &
|
|
@@ -197,7 +211,8 @@ const CSS = `
|
|
|
197
211
|
margin:4px 2px; }
|
|
198
212
|
/* [hidden] must beat the block rule above (the account menu swaps its body
|
|
199
213
|
between signed-in actions and the guest's "Sign in"). */
|
|
200
|
-
.b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden]
|
|
214
|
+
.b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden],
|
|
215
|
+
.b-pop .b-account-org[hidden] { display:none; }
|
|
201
216
|
.b-identity { display:flex; flex-direction:column; align-items:flex-start;
|
|
202
217
|
gap:1px; min-width:0; }
|
|
203
218
|
/* Cancels the horizontal padding the title and location carry for their focus
|
|
@@ -264,9 +279,12 @@ const CSS = `
|
|
|
264
279
|
/* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
|
|
265
280
|
menu: a grid item's default min-width is its content's intrinsic size. */
|
|
266
281
|
.b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
|
|
267
|
-
.b-account-name, .b-email
|
|
282
|
+
.b-account-name, .b-email, .b-account-org {
|
|
283
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
268
284
|
.b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
|
|
269
|
-
|
|
285
|
+
/* The organization line qualifies the identity rather than naming it, so it
|
|
286
|
+
sits at the address's quiet tier and never competes with the name above. */
|
|
287
|
+
.b-email, .b-account-org { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
|
|
270
288
|
.b-account .b-pop button, .b-account .b-pop a {
|
|
271
289
|
display:flex; align-items:center; gap:10px; font-size:13px; }
|
|
272
290
|
.b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
|
|
@@ -309,6 +327,10 @@ const CSS = `
|
|
|
309
327
|
.b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
|
|
310
328
|
.b-drive-source:hover { text-decoration:underline; }
|
|
311
329
|
.b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
|
|
330
|
+
/* The drive rejecting a published file is the one line in this panel that
|
|
331
|
+
reports a problem, so it carries the danger tone rather than the muted one
|
|
332
|
+
every other line uses. */
|
|
333
|
+
.b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
|
|
312
334
|
/* Historical-version preview bar: a full-width strip the toolbar shows below
|
|
313
335
|
the bar whenever the active doc is previewing an earlier version. The strip
|
|
314
336
|
itself is the platform-served version-bar iframe (the SDK owns only this
|
|
@@ -958,6 +980,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
958
980
|
// document that was closed.
|
|
959
981
|
let boundDoc: DocumentSession['document'] = null;
|
|
960
982
|
let statusOff: (() => void) | null = null;
|
|
983
|
+
let publishOff: (() => void) | null = null;
|
|
961
984
|
|
|
962
985
|
// A publish runs on a server worker and reaches `synced` only after the
|
|
963
986
|
// worker (and, on a file-roundtrip, the drive's importer) confirms. In that
|
|
@@ -971,24 +994,111 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
971
994
|
let publishing = false;
|
|
972
995
|
let publishError = false;
|
|
973
996
|
let publishTimer: ReturnType<typeof setTimeout> | null = null;
|
|
997
|
+
// Set on a click, cleared by the next publish-state frame. Between the two,
|
|
998
|
+
// the held state describes the PREVIOUS attempt, and rendering it would
|
|
999
|
+
// report the last failure over the publish just started.
|
|
1000
|
+
let publishStateStale = false;
|
|
974
1001
|
let currentStatus: DocumentDriveStatus | null = null;
|
|
975
1002
|
const clearPublishing = (): void => {
|
|
976
1003
|
publishing = false;
|
|
1004
|
+
// The stale mark is cleared with the hold it belongs to. It is otherwise
|
|
1005
|
+
// cleared only by a publish-state frame, so a publish that draws none
|
|
1006
|
+
// would leave the panel reading the target's resting sentence for the
|
|
1007
|
+
// life of the document.
|
|
1008
|
+
publishStateStale = false;
|
|
977
1009
|
if (publishTimer !== null) {
|
|
978
1010
|
clearTimeout(publishTimer);
|
|
979
1011
|
publishTimer = null;
|
|
980
1012
|
}
|
|
981
1013
|
};
|
|
982
1014
|
|
|
1015
|
+
// The panel for a document with no source item: the server's sentence about
|
|
1016
|
+
// where the file lands, and the publish that puts it there. The first
|
|
1017
|
+
// publish creates the artifact and makes the document its mirror, at which
|
|
1018
|
+
// point the drive status arrives and the mirror panel above replaces this
|
|
1019
|
+
// one.
|
|
1020
|
+
const renderPublishOffer = (target: DrivePublishTarget): void => {
|
|
1021
|
+
root.hidden = false;
|
|
1022
|
+
// A document the drive has never received is as far out of sync as one
|
|
1023
|
+
// whose edits have not been published, so the icon carries the same
|
|
1024
|
+
// attention an `ahead` document's does. What separates them is what the
|
|
1025
|
+
// panel offers, not how much the reader is told to look.
|
|
1026
|
+
root.classList.add('has-changes');
|
|
1027
|
+
root.classList.remove('is-broken');
|
|
1028
|
+
const state = publishStateStale ? null : (boundDoc?._publishState ?? null);
|
|
1029
|
+
const inFlight =
|
|
1030
|
+
publishing || state?.publishing === true || target.publishing;
|
|
1031
|
+
button.title = inFlight
|
|
1032
|
+
? 'Publishing...'
|
|
1033
|
+
: state?.failed
|
|
1034
|
+
? `Publish to ${target.driveName} failed`
|
|
1035
|
+
: `Publish to ${target.driveName}`;
|
|
1036
|
+
|
|
1037
|
+
const failed =
|
|
1038
|
+
!inFlight && (state?.failed === true || target.publishFailed === true);
|
|
1039
|
+
|
|
1040
|
+
const rows: HTMLElement[] = [];
|
|
1041
|
+
// One line, server-authored either way: the worker's phase copy while it
|
|
1042
|
+
// runs, its account of the failure when it stopped, and the resting
|
|
1043
|
+
// sentence otherwise. Marked as a problem when it is one, since nothing
|
|
1044
|
+
// else in this panel distinguishes a document whose publish failed from
|
|
1045
|
+
// one that has simply never published.
|
|
1046
|
+
const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
|
|
1047
|
+
p.textContent = inFlight
|
|
1048
|
+
? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
|
|
1049
|
+
: (state?.detail ?? target.detail);
|
|
1050
|
+
rows.push(p);
|
|
1051
|
+
|
|
1052
|
+
const pub = el('button');
|
|
1053
|
+
pub.type = 'button';
|
|
1054
|
+
pub.disabled = inFlight;
|
|
1055
|
+
pub.textContent = inFlight
|
|
1056
|
+
? 'Publishing...'
|
|
1057
|
+
: publishError || failed
|
|
1058
|
+
? `Publish to ${target.driveName}, retry`
|
|
1059
|
+
: `Publish to ${target.driveName}`;
|
|
1060
|
+
pub.onclick = () => {
|
|
1061
|
+
const doc = deps.session.document;
|
|
1062
|
+
if (!doc) return;
|
|
1063
|
+
publishError = false;
|
|
1064
|
+
publishing = true;
|
|
1065
|
+
publishStateStale = true;
|
|
1066
|
+
// The same safety net the mirror panel keeps: a publish that neither
|
|
1067
|
+
// errors nor lands leaves the panel offering the action again rather
|
|
1068
|
+
// than held forever.
|
|
1069
|
+
publishTimer = setTimeout(() => {
|
|
1070
|
+
clearPublishing();
|
|
1071
|
+
render(currentStatus);
|
|
1072
|
+
}, PUBLISH_HOLD_MS);
|
|
1073
|
+
render(currentStatus);
|
|
1074
|
+
void doc.pushToDrive().catch(() => {
|
|
1075
|
+
clearPublishing();
|
|
1076
|
+
publishError = true;
|
|
1077
|
+
render(currentStatus);
|
|
1078
|
+
});
|
|
1079
|
+
};
|
|
1080
|
+
rows.push(pub);
|
|
1081
|
+
pop.replaceChildren(...rows);
|
|
1082
|
+
};
|
|
1083
|
+
|
|
983
1084
|
const render = (status: DocumentDriveStatus | null): void => {
|
|
984
1085
|
currentStatus = status;
|
|
985
|
-
//
|
|
1086
|
+
// A document that mirrors nothing can still have somewhere to go: its
|
|
1087
|
+
// project may be linked to a drive that takes it as a new file, which is
|
|
1088
|
+
// what an upload into a linked project sits waiting for. Offer that
|
|
1089
|
+
// publish here, so an app gets it by mounting this piece rather than by
|
|
1090
|
+
// building a surface of its own.
|
|
986
1091
|
if (status === null) {
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1092
|
+
const target = boundDoc?._publishTarget ?? null;
|
|
1093
|
+
if (target === null) {
|
|
1094
|
+
root.hidden = true;
|
|
1095
|
+
menu.close();
|
|
1096
|
+
clearPublishing();
|
|
1097
|
+
root.classList.remove('has-changes', 'is-broken');
|
|
1098
|
+
pop.replaceChildren();
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
renderPublishOffer(target);
|
|
992
1102
|
return;
|
|
993
1103
|
}
|
|
994
1104
|
// The publish settles once the doc actually reaches synced; drop the
|
|
@@ -1054,6 +1164,15 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1054
1164
|
rows.push(p);
|
|
1055
1165
|
}
|
|
1056
1166
|
|
|
1167
|
+
// The drive could not read what the last publish delivered. Shown in the
|
|
1168
|
+
// drive's own words, above the commands: the publish succeeded, so
|
|
1169
|
+
// nothing else in this panel says anything is wrong.
|
|
1170
|
+
if (status.sourceUnreadable) {
|
|
1171
|
+
const warn = el('p', 'b-drive-unreadable');
|
|
1172
|
+
warn.textContent = status.sourceUnreadable;
|
|
1173
|
+
rows.push(warn);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1057
1176
|
if (isPublishing) {
|
|
1058
1177
|
// Held while the publish is in flight (a local click hold, or the doc's
|
|
1059
1178
|
// pending work-status), until it reaches synced (see `isPublishing`).
|
|
@@ -1063,8 +1182,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1063
1182
|
pub.disabled = true;
|
|
1064
1183
|
rows.push(pub);
|
|
1065
1184
|
} else if (status.canPush) {
|
|
1066
|
-
// `canPush` already encodes two-way + not broken + local edits
|
|
1067
|
-
// never offers a
|
|
1185
|
+
// `canPush` already encodes two-way + not broken + local edits + a
|
|
1186
|
+
// server-writable format, so this never offers a publish the server
|
|
1187
|
+
// would refuse.
|
|
1068
1188
|
const pub = el('button');
|
|
1069
1189
|
pub.type = 'button';
|
|
1070
1190
|
pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
|
|
@@ -1112,6 +1232,8 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1112
1232
|
function bind(): void {
|
|
1113
1233
|
statusOff?.();
|
|
1114
1234
|
statusOff = null;
|
|
1235
|
+
publishOff?.();
|
|
1236
|
+
publishOff = null;
|
|
1115
1237
|
const doc = deps.session.document;
|
|
1116
1238
|
if (!doc) {
|
|
1117
1239
|
render(null);
|
|
@@ -1122,6 +1244,17 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1122
1244
|
// started from any surface (the dashboard card, the app) flips it and the
|
|
1123
1245
|
// indicator holds "Publishing..." without a local click.
|
|
1124
1246
|
statusOff = doc.onDriveStatus(render);
|
|
1247
|
+
// The same signal for a document that mirrors nothing yet, which has no
|
|
1248
|
+
// drive status to carry it: its first publish is the one that creates the
|
|
1249
|
+
// artifact, and this is how its progress and its failure arrive.
|
|
1250
|
+
publishOff = doc._onPublishState(() => {
|
|
1251
|
+
publishStateStale = false;
|
|
1252
|
+
// A publish the server has finished with ends the local click hold:
|
|
1253
|
+
// holding "Publishing..." for the rest of it would report a push that
|
|
1254
|
+
// is over as one still running.
|
|
1255
|
+
if (!doc._publishState.publishing) clearPublishing();
|
|
1256
|
+
render(currentStatus);
|
|
1257
|
+
});
|
|
1125
1258
|
}
|
|
1126
1259
|
|
|
1127
1260
|
const onSession = (): void => {
|
|
@@ -1138,6 +1271,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1138
1271
|
destroy(): void {
|
|
1139
1272
|
unsub();
|
|
1140
1273
|
statusOff?.();
|
|
1274
|
+
publishOff?.();
|
|
1141
1275
|
clearPublishing();
|
|
1142
1276
|
menu.destroy();
|
|
1143
1277
|
},
|
|
@@ -1153,22 +1287,25 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1153
1287
|
align: 'end',
|
|
1154
1288
|
});
|
|
1155
1289
|
// The menu opens on the identity it acts for: the avatar at reading size,
|
|
1156
|
-
// the display name,
|
|
1157
|
-
//
|
|
1290
|
+
// the display name, the email under it, and the organization the app acts in
|
|
1291
|
+
// under that. The name line drops out when the user has none, leaving the
|
|
1292
|
+
// email as the single identifying line.
|
|
1158
1293
|
const head = el('div', 'b-account-head');
|
|
1159
1294
|
const headFace = buildAvatarFace();
|
|
1160
1295
|
const faceEl = el('span', 'b-account-face');
|
|
1161
1296
|
faceEl.append(...headFace.nodes);
|
|
1162
1297
|
const nameEl = el('div', 'b-account-name');
|
|
1163
1298
|
const emailEl = el('div', 'b-email');
|
|
1299
|
+
const orgEl = el('div', 'b-account-org');
|
|
1164
1300
|
const identity = el('div', 'b-account-id');
|
|
1165
|
-
identity.append(nameEl, emailEl);
|
|
1301
|
+
identity.append(nameEl, emailEl, orgEl);
|
|
1166
1302
|
head.append(faceEl, identity);
|
|
1167
1303
|
const headSep = el('hr', 'b-sep');
|
|
1168
1304
|
headSep.setAttribute('role', 'separator');
|
|
1169
1305
|
// Opens the dashboard in a new tab, so the user keeps the app open in the
|
|
1170
|
-
// current one
|
|
1171
|
-
//
|
|
1306
|
+
// current one, and lands in the organization the app is working in rather
|
|
1307
|
+
// than wherever the dashboard was left. `/home` is the authenticated
|
|
1308
|
+
// landing and stands in when the app is working in none.
|
|
1172
1309
|
const dashboardLink = el('a');
|
|
1173
1310
|
dashboardLink.append(
|
|
1174
1311
|
strokeIcon(
|
|
@@ -1182,6 +1319,23 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1182
1319
|
dashboardLink.target = '_blank';
|
|
1183
1320
|
dashboardLink.rel = 'noopener noreferrer';
|
|
1184
1321
|
dashboardLink.onclick = () => menu.close();
|
|
1322
|
+
// Sits directly above the dashboard link, so the organization the header
|
|
1323
|
+
// names and the action that changes it read as one block.
|
|
1324
|
+
const switchOrgBtn = el('button');
|
|
1325
|
+
switchOrgBtn.type = 'button';
|
|
1326
|
+
switchOrgBtn.append(
|
|
1327
|
+
strokeIcon(
|
|
1328
|
+
'm17 2 4 4-4 4',
|
|
1329
|
+
'M3 11v-1a4 4 0 0 1 4-4h14',
|
|
1330
|
+
'm7 22-4-4 4-4',
|
|
1331
|
+
'M21 13v1a4 4 0 0 1-4 4H3',
|
|
1332
|
+
),
|
|
1333
|
+
'Switch organization...',
|
|
1334
|
+
);
|
|
1335
|
+
switchOrgBtn.onclick = () => {
|
|
1336
|
+
menu.close();
|
|
1337
|
+
deps.switchOrganization?.();
|
|
1338
|
+
};
|
|
1185
1339
|
const signOutBtn = el('button');
|
|
1186
1340
|
signOutBtn.append(
|
|
1187
1341
|
strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'),
|
|
@@ -1204,12 +1358,18 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1204
1358
|
menu.close();
|
|
1205
1359
|
deps.signIn();
|
|
1206
1360
|
};
|
|
1207
|
-
menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
|
|
1361
|
+
menu.pop.append(head, headSep, switchOrgBtn, dashboardLink, signOutBtn, signInBtn);
|
|
1208
1362
|
|
|
1209
1363
|
const triggerFace = buildAvatarFace();
|
|
1210
1364
|
menu.trigger.append(...triggerFace.nodes);
|
|
1211
1365
|
|
|
1212
|
-
|
|
1366
|
+
// The menu acts for a (user, organization) pair, and each half arrives on
|
|
1367
|
+
// its own listener, so both are held here and one render draws them.
|
|
1368
|
+
let currentUser = deps.getUser();
|
|
1369
|
+
let organization = deps.getActingOrganization?.() ?? null;
|
|
1370
|
+
|
|
1371
|
+
const render = (): void => {
|
|
1372
|
+
const user = currentUser;
|
|
1213
1373
|
triggerFace.render(user);
|
|
1214
1374
|
headFace.render(user);
|
|
1215
1375
|
nameEl.textContent = user?.display_name ?? '';
|
|
@@ -1227,13 +1387,40 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1227
1387
|
dashboardLink.hidden = !signedIn;
|
|
1228
1388
|
signOutBtn.hidden = !signedIn;
|
|
1229
1389
|
signInBtn.hidden = signedIn;
|
|
1390
|
+
// The organization line names what the app is acting in, so it is drawn
|
|
1391
|
+
// only when there is something to name: an app that has not needed an
|
|
1392
|
+
// organization yet has not resolved one, and resolving one to fill this
|
|
1393
|
+
// line is the boot-time question the platform refuses.
|
|
1394
|
+
const organizationName = signedIn ? (organization?.name ?? '') : '';
|
|
1395
|
+
orgEl.textContent = organizationName;
|
|
1396
|
+
orgEl.title = organizationName;
|
|
1397
|
+
orgEl.hidden = organizationName === '';
|
|
1398
|
+
dashboardLink.href =
|
|
1399
|
+
signedIn && organization
|
|
1400
|
+
? `${deps.dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
|
|
1401
|
+
: `${deps.dashboardBaseUrl}/home`;
|
|
1402
|
+
// Offered to every signed-in reader, including one the line has nothing
|
|
1403
|
+
// to name and one who belongs to a single organization. The surface
|
|
1404
|
+
// behind it both moves the app and creates an organization, so gating it
|
|
1405
|
+
// on already having somewhere else to go would withhold it from exactly
|
|
1406
|
+
// the person it is for; and an app reached by its own URL has resolved
|
|
1407
|
+
// nothing to name, which is the state a deliberate choice settles.
|
|
1408
|
+
switchOrgBtn.hidden = !signedIn || deps.switchOrganization === undefined;
|
|
1230
1409
|
};
|
|
1231
|
-
|
|
1232
|
-
const unsub = deps.onUserChange(
|
|
1410
|
+
render();
|
|
1411
|
+
const unsub = deps.onUserChange((user) => {
|
|
1412
|
+
currentUser = user;
|
|
1413
|
+
render();
|
|
1414
|
+
});
|
|
1415
|
+
const unsubOrganization = deps.subscribeActingOrganization?.((next) => {
|
|
1416
|
+
organization = next;
|
|
1417
|
+
render();
|
|
1418
|
+
});
|
|
1233
1419
|
return {
|
|
1234
1420
|
root: menu.root,
|
|
1235
1421
|
destroy(): void {
|
|
1236
1422
|
unsub();
|
|
1423
|
+
unsubOrganization?.();
|
|
1237
1424
|
menu.destroy();
|
|
1238
1425
|
},
|
|
1239
1426
|
};
|
|
@@ -1334,8 +1521,18 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1334
1521
|
// left it checked out would leave an app read-only with nothing to
|
|
1335
1522
|
// clear it. A document pinned to its version has no head to return to
|
|
1336
1523
|
// and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
|
|
1524
|
+
// Once the return lands, the dismissal has nothing left to hold
|
|
1525
|
+
// against: the doc is live, and a LATER pick of the same version in
|
|
1526
|
+
// the history dialog is a fresh preview that must latch the bar (its
|
|
1527
|
+
// pick sets only `version`, so a stale `dismissedAt` equal to it would
|
|
1528
|
+
// otherwise leave the reader checked out, read-only, with no bar and
|
|
1529
|
+
// no exit).
|
|
1337
1530
|
if (doc && doc.version !== null) {
|
|
1338
|
-
void attempt(doc, () => doc.returnToHead())
|
|
1531
|
+
void attempt(doc, () => doc.returnToHead())
|
|
1532
|
+
.then(() => {
|
|
1533
|
+
if (session.document === doc) dismissedAt = undefined;
|
|
1534
|
+
})
|
|
1535
|
+
.catch(() => {});
|
|
1339
1536
|
}
|
|
1340
1537
|
hideBar();
|
|
1341
1538
|
},
|
package/src/tail-ops.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Tail ops arrive in one of two shapes and are used in only one.
|
|
2
2
|
//
|
|
3
|
-
// An op
|
|
4
|
-
//
|
|
3
|
+
// An op small enough to store inline carries its bytes. A larger one
|
|
4
|
+
// carries a short-lived signed URL instead, because a
|
|
5
5
|
// join response holding several megabytes of base64 is a response the
|
|
6
6
|
// server cannot build. Both mean the same thing to a reader: bytes to
|
|
7
7
|
// apply, in seq order.
|
package/src/types.ts
CHANGED
|
@@ -87,10 +87,12 @@ export interface UndoControllerOptions {
|
|
|
87
87
|
* A document's own view of how its sync is going, as the client sees it.
|
|
88
88
|
*
|
|
89
89
|
* The server records what reached it, so the facts that separate a write
|
|
90
|
-
* that was lost from a client that is merely slow live only here.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* that was lost from a client that is merely slow live only here.
|
|
91
|
+
*
|
|
92
|
+
* @internal Read on demand by Brass's own surfaces, through the `@internal`
|
|
93
|
+
* `Document.diagnostics()`. An app reads `savingCount` to know a save is
|
|
94
|
+
* outstanding and `Brass.onError` to hear a failure; the SDK resolves a
|
|
95
|
+
* stuck save itself.
|
|
94
96
|
*/
|
|
95
97
|
export interface DocSyncDiagnostics {
|
|
96
98
|
/** Ops committed locally that the server has not acknowledged. */
|
package/src/undo.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// local-peer-scoped (reverts only this peer's ops), group bracketing collapses
|
|
3
3
|
// same-stroke edits into one entry, and remote-import bookkeeping keeps the
|
|
4
4
|
// redo stack honest across other peers' edits.
|
|
5
|
-
import { UndoManager, type LoroDoc } from '
|
|
5
|
+
import { UndoManager, type LoroDoc } from './wasm-runtime.js';
|
|
6
6
|
|
|
7
7
|
import type { UndoControllerOptions } from './types.js';
|
|
8
8
|
|
package/src/version-bar.ts
CHANGED
|
@@ -88,6 +88,10 @@ function clampHeight(h: number): number {
|
|
|
88
88
|
// takes the strip. Matches the hosted-UI launcher's own long window.
|
|
89
89
|
const VERSION_BAR_READY_TIMEOUT_MS = 10_000;
|
|
90
90
|
|
|
91
|
+
// How long a frame that answered gets to draw its strip before the
|
|
92
|
+
// placeholder comes off anyway. See `armRevealTimer`.
|
|
93
|
+
const VERSION_BAR_REVEAL_TIMEOUT_MS = 10_000;
|
|
94
|
+
|
|
91
95
|
export function createVersionBarFrame(
|
|
92
96
|
root: HTMLElement,
|
|
93
97
|
deps: VersionBarDeps,
|
|
@@ -164,6 +168,10 @@ export function createVersionBarFrame(
|
|
|
164
168
|
// one that never answers, which is why this is a timer rather than a
|
|
165
169
|
// handler.
|
|
166
170
|
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
171
|
+
// The placeholder holds the strip's height until the embed has drawn its
|
|
172
|
+
// own, which `booted` reports and this timer backstops.
|
|
173
|
+
let revealed = false;
|
|
174
|
+
let revealTimer: ReturnType<typeof setTimeout> | undefined;
|
|
167
175
|
|
|
168
176
|
// Hand the strip to the SDK-drawn fallback, which carries the two controls
|
|
169
177
|
// that need no timeline (return to latest, close).
|
|
@@ -174,12 +182,37 @@ export function createVersionBarFrame(
|
|
|
174
182
|
clearTimeout(readyTimer);
|
|
175
183
|
readyTimer = undefined;
|
|
176
184
|
}
|
|
185
|
+
if (revealTimer !== undefined) {
|
|
186
|
+
clearTimeout(revealTimer);
|
|
187
|
+
revealTimer = undefined;
|
|
188
|
+
}
|
|
177
189
|
placeholder.style.display = 'none';
|
|
178
190
|
iframe.style.height = '0';
|
|
179
191
|
fallback.hidden = false;
|
|
180
192
|
renderFallback();
|
|
181
193
|
}
|
|
182
194
|
|
|
195
|
+
// Give the embed's strip the height the placeholder was holding.
|
|
196
|
+
function revealFrame(): void {
|
|
197
|
+
if (destroyed || failed || revealed) return;
|
|
198
|
+
revealed = true;
|
|
199
|
+
if (revealTimer !== undefined) {
|
|
200
|
+
clearTimeout(revealTimer);
|
|
201
|
+
revealTimer = undefined;
|
|
202
|
+
}
|
|
203
|
+
placeholder.style.display = 'none';
|
|
204
|
+
iframe.style.height = `${READY_HEIGHT}px`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A frame that answers and then draws nothing (a boot that dies past its
|
|
208
|
+
// first script, an embed page older than `booted`) holds the placeholder
|
|
209
|
+
// otherwise. The swap is what the reader's controls arrive with, so take
|
|
210
|
+
// it on the timer rather than leaving the strip inert.
|
|
211
|
+
function armRevealTimer(): void {
|
|
212
|
+
if (revealed || revealTimer !== undefined) return;
|
|
213
|
+
revealTimer = setTimeout(revealFrame, VERSION_BAR_REVEAL_TIMEOUT_MS);
|
|
214
|
+
}
|
|
215
|
+
|
|
183
216
|
function armReadyTimer(): void {
|
|
184
217
|
if (readyTimer !== undefined) clearTimeout(readyTimer);
|
|
185
218
|
readyTimer = setTimeout(() => {
|
|
@@ -239,11 +272,25 @@ export function createVersionBarFrame(
|
|
|
239
272
|
);
|
|
240
273
|
}
|
|
241
274
|
|
|
275
|
+
// Intents still awaiting their answer. While one is in flight, `update()`
|
|
276
|
+
// stays quiet: the checkout it runs fires the host's subscribers before its
|
|
277
|
+
// promise resolves, so a render lands here ahead of `confirmIntent`, and an
|
|
278
|
+
// uncorrelated `version-changed` posted then reaches the embed as a phantom
|
|
279
|
+
// external move one message before the answer to its own step. The answer
|
|
280
|
+
// reads the live version at settle time, so nothing the quiet window saw is
|
|
281
|
+
// lost.
|
|
282
|
+
let intentsInFlight = 0;
|
|
283
|
+
|
|
242
284
|
function runIntent(requestId: unknown, intent: () => Promise<void>): void {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
285
|
+
intentsInFlight += 1;
|
|
286
|
+
const settle = (ok: boolean) => (): void => {
|
|
287
|
+
// Decremented in every outcome. A count this leaves above zero silences
|
|
288
|
+
// `update()` for the life of the bar, so an external version change
|
|
289
|
+
// would never reach the embed again.
|
|
290
|
+
intentsInFlight -= 1;
|
|
291
|
+
confirmIntent(requestId, ok);
|
|
292
|
+
};
|
|
293
|
+
void intent().then(settle(true), settle(false));
|
|
247
294
|
}
|
|
248
295
|
|
|
249
296
|
function onMessage(event: MessageEvent): void {
|
|
@@ -253,16 +300,23 @@ export function createVersionBarFrame(
|
|
|
253
300
|
if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
|
|
254
301
|
switch (data['type']) {
|
|
255
302
|
case 'ready': {
|
|
303
|
+
// The frame is alive. It says nothing about the embed having drawn
|
|
304
|
+
// its strip yet, so the placeholder stays until `booted`: the embed
|
|
305
|
+
// posts this from a script that runs before its application, and
|
|
306
|
+
// swapping here would trade the themed placeholder for an empty one.
|
|
256
307
|
ready = true;
|
|
257
308
|
if (readyTimer !== undefined) {
|
|
258
309
|
clearTimeout(readyTimer);
|
|
259
310
|
readyTimer = undefined;
|
|
260
311
|
}
|
|
261
|
-
|
|
262
|
-
iframe.style.height = `${READY_HEIGHT}px`;
|
|
312
|
+
armRevealTimer();
|
|
263
313
|
postInit();
|
|
264
314
|
break;
|
|
265
315
|
}
|
|
316
|
+
case 'booted': {
|
|
317
|
+
revealFrame();
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
266
320
|
case 'resize': {
|
|
267
321
|
const h = data['height'];
|
|
268
322
|
if (typeof h === 'number') iframe.style.height = `${clampHeight(h)}px`;
|
|
@@ -322,6 +376,13 @@ export function createVersionBarFrame(
|
|
|
322
376
|
// page gets a fresh verdict, so the failed state resets with it.
|
|
323
377
|
ready = false;
|
|
324
378
|
failed = false;
|
|
379
|
+
// The placeholder is back up, so the swap the new page's `booted`
|
|
380
|
+
// asks for is one this bar has not made yet.
|
|
381
|
+
revealed = false;
|
|
382
|
+
if (revealTimer !== undefined) {
|
|
383
|
+
clearTimeout(revealTimer);
|
|
384
|
+
revealTimer = undefined;
|
|
385
|
+
}
|
|
325
386
|
deliveredVersion = undefined;
|
|
326
387
|
fallback.hidden = true;
|
|
327
388
|
placeholder.style.display = '';
|
|
@@ -336,7 +397,9 @@ export function createVersionBarFrame(
|
|
|
336
397
|
}
|
|
337
398
|
// Only an actual version change reaches the embed (dedup), so a
|
|
338
399
|
// re-render that didn't move the version can't clobber the bar's state.
|
|
339
|
-
|
|
400
|
+
// A change while an intent is in flight is that intent's own doing and
|
|
401
|
+
// is delivered by its answer instead.
|
|
402
|
+
if (ready && intentsInFlight === 0 && next.version !== deliveredVersion) {
|
|
340
403
|
deliveredVersion = next.version;
|
|
341
404
|
iframe.contentWindow?.postMessage(
|
|
342
405
|
{ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'version-changed', version: next.version },
|
|
@@ -346,10 +409,17 @@ export function createVersionBarFrame(
|
|
|
346
409
|
},
|
|
347
410
|
destroy(): void {
|
|
348
411
|
destroyed = true;
|
|
412
|
+
// A bar torn down mid-intent leaves the count standing; a rebuilt bar
|
|
413
|
+
// gets a fresh closure, but this keeps the teardown total.
|
|
414
|
+
intentsInFlight = 0;
|
|
349
415
|
if (readyTimer !== undefined) {
|
|
350
416
|
clearTimeout(readyTimer);
|
|
351
417
|
readyTimer = undefined;
|
|
352
418
|
}
|
|
419
|
+
if (revealTimer !== undefined) {
|
|
420
|
+
clearTimeout(revealTimer);
|
|
421
|
+
revealTimer = undefined;
|
|
422
|
+
}
|
|
353
423
|
window.removeEventListener('message', onMessage);
|
|
354
424
|
iframe.remove();
|
|
355
425
|
placeholder.remove();
|
package/src/version.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
|
3
3
|
// can measure the version distribution of deployed apps and time
|
|
4
4
|
// compatibility windows against it. A unit test pins this to
|
|
5
5
|
// package.json's version; the release bump touches both together.
|
|
6
|
-
export const SDK_VERSION = '0.
|
|
6
|
+
export const SDK_VERSION = '0.4.0';
|
|
7
7
|
|
|
8
8
|
export const SDK_CLIENT_ID = `sdk/${SDK_VERSION}`;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The once-only gate the browser runtime half readies its WASM behind, kept
|
|
2
|
+
// separate from it so it can be exercised without a real engine: the half
|
|
3
|
+
// itself names a WASM-shipping dependency, which pins it to an environment
|
|
4
|
+
// that can load one.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a function that runs `inits` once and hands every caller the same
|
|
8
|
+
* result.
|
|
9
|
+
*
|
|
10
|
+
* A rejection is NOT retained. Each init is a multi-megabyte fetch over
|
|
11
|
+
* whatever connection the reader has, and a page navigating away mid-download
|
|
12
|
+
* aborts it, so a retained rejection would answer every later call with a
|
|
13
|
+
* failure the network has long since recovered from. Dropping it makes the
|
|
14
|
+
* next call start over, which is what a reader who opens a document a minute
|
|
15
|
+
* later needs.
|
|
16
|
+
*/
|
|
17
|
+
export function createReadyGate(
|
|
18
|
+
inits: readonly (() => Promise<unknown>)[],
|
|
19
|
+
): () => Promise<void> {
|
|
20
|
+
let readying: Promise<void> | null = null;
|
|
21
|
+
return () => {
|
|
22
|
+
readying ??= Promise.all(inits.map((init) => init()))
|
|
23
|
+
.then(() => undefined)
|
|
24
|
+
.catch((err: unknown) => {
|
|
25
|
+
readying = null;
|
|
26
|
+
throw err;
|
|
27
|
+
});
|
|
28
|
+
return readying;
|
|
29
|
+
};
|
|
30
|
+
}
|