@brass-build/client 0.1.0 → 0.2.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 +72 -63
- package/CHANGELOG.md +86 -0
- package/README.md +317 -231
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +253 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +226 -75
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +140 -19
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +249 -80
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
package/dist/session-toolbar.js
CHANGED
|
@@ -144,7 +144,30 @@ const CSS = `
|
|
|
144
144
|
fallback shown when no image is set or the image fails to load. */
|
|
145
145
|
.b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
146
146
|
.b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
|
|
147
|
-
|
|
147
|
+
/* The account menu opens on the identity it acts for: the avatar again at
|
|
148
|
+
reading size, the name, and the email under it. The rows below are the
|
|
149
|
+
actions on that identity, so they carry a glyph and sit apart from the
|
|
150
|
+
header behind a rule. */
|
|
151
|
+
.b-account .b-pop { min-width:236px; max-width:min(300px, calc(100vw - 16px)); }
|
|
152
|
+
.b-account-head { display:flex; align-items:center; gap:10px; padding:6px 8px 8px; }
|
|
153
|
+
.b-account-face { flex:0 0 auto; width:36px; height:36px; border-radius:50%;
|
|
154
|
+
display:inline-flex; align-items:center; justify-content:center; overflow:hidden;
|
|
155
|
+
background:var(--brass-accent); color:var(--brass-accent-fg);
|
|
156
|
+
font-size:15px; font-weight:600; }
|
|
157
|
+
/* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
|
|
158
|
+
menu: a grid item's default min-width is its content's intrinsic size. */
|
|
159
|
+
.b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
|
|
160
|
+
.b-account-name, .b-email { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
161
|
+
.b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
|
|
162
|
+
.b-email { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
|
|
163
|
+
.b-account .b-pop button, .b-account .b-pop a {
|
|
164
|
+
display:flex; align-items:center; gap:10px; font-size:13px; }
|
|
165
|
+
.b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
|
|
166
|
+
.b-account .b-pop button:hover svg, .b-account .b-pop a:hover svg { color:var(--brass-fg); }
|
|
167
|
+
/* Restated at this specificity so the flex rows above don't unhide a row the
|
|
168
|
+
guest / signed-in swap hides. */
|
|
169
|
+
.b-account .b-pop button[hidden], .b-account .b-pop a[hidden],
|
|
170
|
+
.b-account .b-pop .b-account-head[hidden], .b-account .b-pop .b-sep[hidden] { display:none; }
|
|
148
171
|
/* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
|
|
149
172
|
there is something to do. Clicking opens a top-layer Popover (never clipped
|
|
150
173
|
by the header overflow, light-dismissed on outside tap). */
|
|
@@ -194,6 +217,23 @@ const CSS = `
|
|
|
194
217
|
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
195
218
|
border-bottom:1px solid var(--brass-border); }
|
|
196
219
|
.b-version iframe { display:block; }
|
|
220
|
+
/* The SDK-drawn strip that stands in when the embed reports it cannot render.
|
|
221
|
+
Same box as the placeholder, plus the two controls that need no version
|
|
222
|
+
timeline, so returning to the live head stays reachable. */
|
|
223
|
+
.b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
|
|
224
|
+
min-height:44px; padding:0 14px; font-size:13px;
|
|
225
|
+
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
226
|
+
border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
|
|
227
|
+
.b-version-fallback[hidden] { display:none; }
|
|
228
|
+
/* Takes the strip's slack, so the controls hold the trailing edge whichever
|
|
229
|
+
of them is showing, and it is the only part that ellipsizes. */
|
|
230
|
+
.b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
|
|
231
|
+
text-overflow:ellipsis; white-space:nowrap; }
|
|
232
|
+
.b-version-fallback button { flex:0 0 auto; background:transparent;
|
|
233
|
+
border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
|
|
234
|
+
color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
|
|
235
|
+
.b-version-fallback button:hover { background:var(--brass-surface); }
|
|
236
|
+
.b-version-fallback button[hidden] { display:none; }
|
|
197
237
|
/* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
|
|
198
238
|
(negative outline-offset), because the left cluster and the title row clip
|
|
199
239
|
their overflow so a long name ellipsizes: a ring outside the box falls in
|
|
@@ -234,6 +274,40 @@ function initialOf(user) {
|
|
|
234
274
|
const label = user.display_name ?? user.email ?? user.sub;
|
|
235
275
|
return label.trim().charAt(0).toUpperCase() || '?';
|
|
236
276
|
}
|
|
277
|
+
// A user's face: the uploaded image over a monogram. The monogram is the
|
|
278
|
+
// fallback, shown when the user has no avatar image and restored if the image
|
|
279
|
+
// fails to load (a broken / unreachable URL renders the initial, never a
|
|
280
|
+
// broken-image glyph). The caller places the pair inside its own circle, so
|
|
281
|
+
// the account menu's trigger and its header draw the same face at two sizes.
|
|
282
|
+
function buildAvatarFace() {
|
|
283
|
+
const img = el('img', 'b-avatar-img');
|
|
284
|
+
img.alt = '';
|
|
285
|
+
const initial = el('span', 'b-avatar-initial');
|
|
286
|
+
const showMonogram = () => {
|
|
287
|
+
img.hidden = true;
|
|
288
|
+
initial.hidden = false;
|
|
289
|
+
};
|
|
290
|
+
img.addEventListener('error', () => {
|
|
291
|
+
img.removeAttribute('src');
|
|
292
|
+
showMonogram();
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
nodes: [img, initial],
|
|
296
|
+
render(user) {
|
|
297
|
+
initial.textContent = user ? initialOf(user) : '?';
|
|
298
|
+
const url = user?.avatar_url?.trim() ?? '';
|
|
299
|
+
if (!url) {
|
|
300
|
+
img.removeAttribute('src');
|
|
301
|
+
showMonogram();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (img.getAttribute('src') !== url)
|
|
305
|
+
img.src = url;
|
|
306
|
+
img.hidden = false;
|
|
307
|
+
initial.hidden = true;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
237
311
|
function renderCommandButton(cmd, asTool) {
|
|
238
312
|
const b = el('button', asTool ? 'b-tool' : undefined);
|
|
239
313
|
b.type = 'button';
|
|
@@ -425,7 +499,11 @@ function buildDocumentTitle(deps) {
|
|
|
425
499
|
loc.type = 'button';
|
|
426
500
|
loc.hidden = true;
|
|
427
501
|
root.append(titleRow, loc);
|
|
428
|
-
|
|
502
|
+
// The document the location chip describes, held by INSTANCE. A branch
|
|
503
|
+
// switch, and a re-open after the document moves, bind a new Document under
|
|
504
|
+
// the same id, and a move is exactly the event that changes the location
|
|
505
|
+
// label, so an id comparison would leave the chip naming the old project.
|
|
506
|
+
let boundDoc = null;
|
|
429
507
|
// The non-default branch label (null on the default), read by `fitBranch`
|
|
430
508
|
// so a resize re-evaluates the drop without a full re-render.
|
|
431
509
|
let branchLabel = null;
|
|
@@ -447,8 +525,9 @@ function buildDocumentTitle(deps) {
|
|
|
447
525
|
function activeBranchLabel() {
|
|
448
526
|
return deps.session.document?._currentBranchName() ?? null;
|
|
449
527
|
}
|
|
450
|
-
|
|
451
|
-
|
|
528
|
+
// Resolves the label for one document, named by the caller so the in-flight
|
|
529
|
+
// fetch is attributed to the Document it was started for.
|
|
530
|
+
async function refreshLocation(doc) {
|
|
452
531
|
const docId = doc?.id ?? null;
|
|
453
532
|
if (!docId) {
|
|
454
533
|
loc.hidden = true;
|
|
@@ -456,7 +535,7 @@ function buildDocumentTitle(deps) {
|
|
|
456
535
|
}
|
|
457
536
|
try {
|
|
458
537
|
const record = await deps.getDocument(docId);
|
|
459
|
-
if (deps.session.document
|
|
538
|
+
if (deps.session.document !== doc)
|
|
460
539
|
return; // superseded
|
|
461
540
|
// The server hands back an opaque location label (rendered verbatim);
|
|
462
541
|
// the toolbar never composes it from project / org ids, so the
|
|
@@ -475,14 +554,14 @@ function buildDocumentTitle(deps) {
|
|
|
475
554
|
loc.hidden = false;
|
|
476
555
|
}
|
|
477
556
|
catch {
|
|
478
|
-
if (deps.session.document
|
|
557
|
+
if (deps.session.document === doc)
|
|
479
558
|
loc.hidden = true;
|
|
480
559
|
}
|
|
481
560
|
}
|
|
482
561
|
function runManage() {
|
|
483
562
|
void (async () => {
|
|
484
563
|
await deps.session.run('manage');
|
|
485
|
-
void refreshLocation();
|
|
564
|
+
void refreshLocation(deps.session.document);
|
|
486
565
|
})();
|
|
487
566
|
}
|
|
488
567
|
title.onclick = runManage;
|
|
@@ -518,11 +597,10 @@ function buildDocumentTitle(deps) {
|
|
|
518
597
|
branch.title = `On branch "${branchLabel}"`;
|
|
519
598
|
}
|
|
520
599
|
fitBranch();
|
|
521
|
-
// Re-resolve location only when the
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
void refreshLocation();
|
|
600
|
+
// Re-resolve location only when the bound document changes.
|
|
601
|
+
if (doc !== boundDoc) {
|
|
602
|
+
boundDoc = doc;
|
|
603
|
+
void refreshLocation(doc);
|
|
526
604
|
}
|
|
527
605
|
};
|
|
528
606
|
render();
|
|
@@ -565,7 +643,12 @@ function buildUndoRedo(deps) {
|
|
|
565
643
|
function buildPresenceRoster(deps) {
|
|
566
644
|
injectStyles(document);
|
|
567
645
|
const root = el('div', 'brass-ui b-roster');
|
|
568
|
-
|
|
646
|
+
// The document whose presence client is subscribed, held by INSTANCE.
|
|
647
|
+
// Presence is scoped to a (document, branch) pair, and a branch switch binds
|
|
648
|
+
// a new Document under the SAME id, so an id comparison would keep the
|
|
649
|
+
// roster subscribed to the branch the user just left and show the wrong
|
|
650
|
+
// people as present on the one they are looking at.
|
|
651
|
+
let boundDoc = null;
|
|
569
652
|
let presenceOff = null;
|
|
570
653
|
const renderRoster = (participants) => {
|
|
571
654
|
root.replaceChildren(...participants.map((p) => {
|
|
@@ -607,9 +690,9 @@ function buildPresenceRoster(deps) {
|
|
|
607
690
|
typeof off === 'function' ? off : () => presence.off('change', renderRoster);
|
|
608
691
|
}
|
|
609
692
|
const render = () => {
|
|
610
|
-
const
|
|
611
|
-
if (
|
|
612
|
-
|
|
693
|
+
const doc = deps.session.document;
|
|
694
|
+
if (doc !== boundDoc) {
|
|
695
|
+
boundDoc = doc;
|
|
613
696
|
bindPresence();
|
|
614
697
|
}
|
|
615
698
|
};
|
|
@@ -623,10 +706,10 @@ function buildPresenceRoster(deps) {
|
|
|
623
706
|
},
|
|
624
707
|
};
|
|
625
708
|
}
|
|
626
|
-
// A monochrome
|
|
709
|
+
// A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
|
|
627
710
|
// in the SVG namespace because `createElement('svg')` yields an HTML element
|
|
628
711
|
// that won't render.
|
|
629
|
-
function
|
|
712
|
+
function strokeIcon(...paths) {
|
|
630
713
|
const ns = 'http://www.w3.org/2000/svg';
|
|
631
714
|
const svg = document.createElementNS(ns, 'svg');
|
|
632
715
|
svg.setAttribute('width', '16');
|
|
@@ -638,20 +721,36 @@ function chainLinkIcon() {
|
|
|
638
721
|
svg.setAttribute('stroke-linecap', 'round');
|
|
639
722
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
640
723
|
svg.setAttribute('aria-hidden', 'true');
|
|
641
|
-
for (const d of
|
|
642
|
-
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
643
|
-
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
|
|
644
|
-
]) {
|
|
724
|
+
for (const d of paths) {
|
|
645
725
|
const path = document.createElementNS(ns, 'path');
|
|
646
726
|
path.setAttribute('d', d);
|
|
647
727
|
svg.append(path);
|
|
648
728
|
}
|
|
649
729
|
return svg;
|
|
650
730
|
}
|
|
731
|
+
function chainLinkIcon() {
|
|
732
|
+
return strokeIcon('M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71', 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71');
|
|
733
|
+
}
|
|
651
734
|
// How long the popover holds its "Publishing..." affordance after a push
|
|
652
735
|
// before giving up on the synced confirmation. Long enough to cover a normal
|
|
653
736
|
// drive webhook round-trip.
|
|
654
737
|
const PUBLISH_HOLD_MS = 30000;
|
|
738
|
+
// `value` when it is an absolute http(s) URL, else null. The drive-sync
|
|
739
|
+
// popover's "View in <drive>" link is the one place the toolbar puts a
|
|
740
|
+
// server-supplied string into an `href`, and an href runs whatever scheme it
|
|
741
|
+
// names: a `javascript:` URL executes in the app's own page on the click.
|
|
742
|
+
// A relative URL throws out of `new URL` and is refused with the rest, which
|
|
743
|
+
// is right here, since the link points at an external drive item.
|
|
744
|
+
function httpUrlOrNull(value) {
|
|
745
|
+
let url;
|
|
746
|
+
try {
|
|
747
|
+
url = new URL(value);
|
|
748
|
+
}
|
|
749
|
+
catch {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
|
|
753
|
+
}
|
|
655
754
|
// The drive-sync indicator: a link icon (dim at rest, accent / danger with a
|
|
656
755
|
// dot when there is something to do) whose popover describes the state
|
|
657
756
|
// (server-authored copy, verbatim) and offers publish / resolve. The receiver
|
|
@@ -679,7 +778,11 @@ function buildDriveSync(deps) {
|
|
|
679
778
|
const dot = el('span', 'b-drive-dot');
|
|
680
779
|
dot.setAttribute('aria-hidden', 'true');
|
|
681
780
|
button.append(dot);
|
|
682
|
-
|
|
781
|
+
// The document whose drive status is subscribed, held by INSTANCE. A branch
|
|
782
|
+
// switch binds a new Document under the same id, and each carries its own
|
|
783
|
+
// drive state, so an id comparison would leave the indicator describing the
|
|
784
|
+
// document that was closed.
|
|
785
|
+
let boundDoc = null;
|
|
683
786
|
let statusOff = null;
|
|
684
787
|
// A publish runs on a server worker and reaches `synced` only after the
|
|
685
788
|
// worker (and, on a file-roundtrip, the drive's importer) confirms. In that
|
|
@@ -742,10 +845,11 @@ function buildDriveSync(deps) {
|
|
|
742
845
|
// there is one), then the commands. Rebuilt on every status change, so an
|
|
743
846
|
// open popover updates live.
|
|
744
847
|
const rows = [];
|
|
745
|
-
|
|
848
|
+
const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
|
|
849
|
+
if (itemUrl) {
|
|
746
850
|
const head = el('div', 'b-drive-head');
|
|
747
851
|
const link = el('a', 'b-drive-source');
|
|
748
|
-
link.href =
|
|
852
|
+
link.href = itemUrl;
|
|
749
853
|
link.target = '_blank';
|
|
750
854
|
link.rel = 'noopener noreferrer';
|
|
751
855
|
link.textContent = `View in ${driveName}`;
|
|
@@ -839,9 +943,9 @@ function buildDriveSync(deps) {
|
|
|
839
943
|
statusOff = doc.onDriveStatus(render);
|
|
840
944
|
}
|
|
841
945
|
const onSession = () => {
|
|
842
|
-
const
|
|
843
|
-
if (
|
|
844
|
-
|
|
946
|
+
const doc = deps.session.document;
|
|
947
|
+
if (doc !== boundDoc) {
|
|
948
|
+
boundDoc = doc;
|
|
845
949
|
bind();
|
|
846
950
|
}
|
|
847
951
|
};
|
|
@@ -865,18 +969,31 @@ function buildAccountMenu(deps) {
|
|
|
865
969
|
label: 'Account',
|
|
866
970
|
align: 'end',
|
|
867
971
|
});
|
|
972
|
+
// The menu opens on the identity it acts for: the avatar at reading size,
|
|
973
|
+
// the display name, and the email under it. The name line drops out when the
|
|
974
|
+
// user has none, leaving the email as the single identifying line.
|
|
975
|
+
const head = el('div', 'b-account-head');
|
|
976
|
+
const headFace = buildAvatarFace();
|
|
977
|
+
const faceEl = el('span', 'b-account-face');
|
|
978
|
+
faceEl.append(...headFace.nodes);
|
|
979
|
+
const nameEl = el('div', 'b-account-name');
|
|
868
980
|
const emailEl = el('div', 'b-email');
|
|
981
|
+
const identity = el('div', 'b-account-id');
|
|
982
|
+
identity.append(nameEl, emailEl);
|
|
983
|
+
head.append(faceEl, identity);
|
|
984
|
+
const headSep = el('hr', 'b-sep');
|
|
985
|
+
headSep.setAttribute('role', 'separator');
|
|
869
986
|
// Opens the dashboard in a new tab, so the user keeps the app open in the
|
|
870
987
|
// current one. `/home` is the authenticated landing (resolves the active
|
|
871
988
|
// org); the bare origin is the sign-in page.
|
|
872
989
|
const dashboardLink = el('a');
|
|
873
|
-
dashboardLink.
|
|
990
|
+
dashboardLink.append(strokeIcon('M14 3h7v7', 'M10 14 21 3', 'M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5'), 'Open Brass dashboard');
|
|
874
991
|
dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
|
|
875
992
|
dashboardLink.target = '_blank';
|
|
876
993
|
dashboardLink.rel = 'noopener noreferrer';
|
|
877
994
|
dashboardLink.onclick = () => menu.close();
|
|
878
995
|
const signOutBtn = el('button');
|
|
879
|
-
signOutBtn.
|
|
996
|
+
signOutBtn.append(strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'), 'Sign out');
|
|
880
997
|
signOutBtn.onclick = () => {
|
|
881
998
|
menu.close();
|
|
882
999
|
deps.signOut();
|
|
@@ -886,49 +1003,32 @@ function buildAccountMenu(deps) {
|
|
|
886
1003
|
// meaningless, so the menu offers a single "Sign in" that takes the top
|
|
887
1004
|
// window through the app's normal sign-in and returns to this URL.
|
|
888
1005
|
const signInBtn = el('button');
|
|
889
|
-
signInBtn.
|
|
1006
|
+
signInBtn.append(strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'), 'Sign in');
|
|
890
1007
|
signInBtn.onclick = () => {
|
|
891
1008
|
menu.close();
|
|
892
1009
|
deps.signIn();
|
|
893
1010
|
};
|
|
894
|
-
menu.pop.append(
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
// restored if the image fails to load (a broken / unreachable URL renders
|
|
898
|
-
// the initial, never a broken-image glyph).
|
|
899
|
-
const avatarImg = el('img', 'b-avatar-img');
|
|
900
|
-
avatarImg.alt = '';
|
|
901
|
-
const initialEl = el('span', 'b-avatar-initial');
|
|
902
|
-
menu.trigger.append(avatarImg, initialEl);
|
|
903
|
-
const showMonogram = () => {
|
|
904
|
-
avatarImg.hidden = true;
|
|
905
|
-
initialEl.hidden = false;
|
|
906
|
-
};
|
|
907
|
-
avatarImg.addEventListener('error', () => {
|
|
908
|
-
avatarImg.removeAttribute('src');
|
|
909
|
-
showMonogram();
|
|
910
|
-
});
|
|
1011
|
+
menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
|
|
1012
|
+
const triggerFace = buildAvatarFace();
|
|
1013
|
+
menu.trigger.append(...triggerFace.nodes);
|
|
911
1014
|
const renderUser = (user) => {
|
|
912
|
-
|
|
913
|
-
|
|
1015
|
+
triggerFace.render(user);
|
|
1016
|
+
headFace.render(user);
|
|
1017
|
+
nameEl.textContent = user?.display_name ?? '';
|
|
1018
|
+
nameEl.hidden = !user?.display_name;
|
|
1019
|
+
emailEl.textContent = user ? (user.email ?? user.sub) : '';
|
|
1020
|
+
// The email ellipsizes to hold the menu's width, so the untruncated
|
|
1021
|
+
// address stays reachable on hover.
|
|
1022
|
+
emailEl.title = emailEl.textContent;
|
|
914
1023
|
// Swap the menu body between the signed-in actions and the guest's lone
|
|
915
1024
|
// "Sign in": a null user is an anonymous link holder.
|
|
916
1025
|
const signedIn = user !== null;
|
|
1026
|
+
head.hidden = !signedIn;
|
|
1027
|
+
headSep.hidden = !signedIn;
|
|
917
1028
|
emailEl.hidden = !signedIn;
|
|
918
1029
|
dashboardLink.hidden = !signedIn;
|
|
919
1030
|
signOutBtn.hidden = !signedIn;
|
|
920
1031
|
signInBtn.hidden = signedIn;
|
|
921
|
-
const url = user?.avatar_url?.trim() ?? '';
|
|
922
|
-
if (url) {
|
|
923
|
-
if (avatarImg.getAttribute('src') !== url)
|
|
924
|
-
avatarImg.src = url;
|
|
925
|
-
avatarImg.hidden = false;
|
|
926
|
-
initialEl.hidden = true;
|
|
927
|
-
}
|
|
928
|
-
else {
|
|
929
|
-
avatarImg.removeAttribute('src');
|
|
930
|
-
showMonogram();
|
|
931
|
-
}
|
|
932
1032
|
};
|
|
933
1033
|
renderUser(deps.getUser());
|
|
934
1034
|
const unsub = deps.onUserChange(renderUser);
|
|
@@ -962,6 +1062,14 @@ function buildVersionPreview(deps) {
|
|
|
962
1062
|
// closes it, when the active document changes, or when no doc is open.
|
|
963
1063
|
let browsing = false;
|
|
964
1064
|
let lastDocId = null;
|
|
1065
|
+
// The version the reader dismissed the bar at, when the document was left
|
|
1066
|
+
// holding one. A document that cannot return to its head (one pinned to a
|
|
1067
|
+
// version) stays on it, so without this the next render reads a non-null
|
|
1068
|
+
// `doc.version` and latches the bar the reader just dismissed straight back
|
|
1069
|
+
// on. A dismissal at the live head records nothing: there is no version
|
|
1070
|
+
// left to latch on, and the head is where the next browse-in-the-app
|
|
1071
|
+
// request arrives, so a record there would swallow it.
|
|
1072
|
+
let dismissedAt = undefined;
|
|
965
1073
|
const session = deps.session;
|
|
966
1074
|
const hideBar = () => {
|
|
967
1075
|
root.classList.remove('b-version-on');
|
|
@@ -970,28 +1078,45 @@ function buildVersionPreview(deps) {
|
|
|
970
1078
|
frame = null;
|
|
971
1079
|
}
|
|
972
1080
|
};
|
|
1081
|
+
// A failed intent reaches the reader through the bar (which reverts to the
|
|
1082
|
+
// document's real state) and the developer through `Brass.onError`. Both
|
|
1083
|
+
// halves matter: the bar moves optimistically, so a swallowed failure is a
|
|
1084
|
+
// bar naming a version nothing is showing.
|
|
1085
|
+
const attempt = async (doc, act) => {
|
|
1086
|
+
try {
|
|
1087
|
+
await act();
|
|
1088
|
+
}
|
|
1089
|
+
catch (err) {
|
|
1090
|
+
doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
|
|
1091
|
+
throw err;
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
973
1094
|
const frameDeps = {
|
|
974
1095
|
dashboardBaseUrl: deps.dashboardBaseUrl,
|
|
975
1096
|
...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
|
|
976
|
-
...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
|
|
977
1097
|
// Read the live active document at call time: a bridge intent lands
|
|
978
1098
|
// async, so it must not close over a doc that may have been superseded.
|
|
979
|
-
|
|
980
|
-
|
|
1099
|
+
currentVersion: () => session.document?.version ?? null,
|
|
1100
|
+
checkoutVersion: async (version) => {
|
|
1101
|
+
const doc = session.document;
|
|
1102
|
+
if (!doc)
|
|
1103
|
+
return;
|
|
1104
|
+
await attempt(doc, () => doc._checkoutVersion(version));
|
|
981
1105
|
},
|
|
982
|
-
returnToHead: () => {
|
|
1106
|
+
returnToHead: async () => {
|
|
983
1107
|
// Return to the live head but keep the bar open (the browsing session
|
|
984
1108
|
// persists); the bar re-renders in its "at latest" state with a Close
|
|
985
1109
|
// button. Dismissal is the explicit `close` intent only.
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
// frozen / no live head: leave the preview as-is
|
|
991
|
-
}
|
|
1110
|
+
const doc = session.document;
|
|
1111
|
+
if (!doc)
|
|
1112
|
+
return;
|
|
1113
|
+
await attempt(doc, () => doc.returnToHead());
|
|
992
1114
|
},
|
|
993
1115
|
fork: (fromVersion, name) => {
|
|
994
|
-
|
|
1116
|
+
const doc = session.document;
|
|
1117
|
+
if (!doc)
|
|
1118
|
+
return;
|
|
1119
|
+
void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => { });
|
|
995
1120
|
},
|
|
996
1121
|
browse: () => {
|
|
997
1122
|
void session.run('history');
|
|
@@ -1000,27 +1125,52 @@ function buildVersionPreview(deps) {
|
|
|
1000
1125
|
// Explicit dismissal: end the browsing session and hide the bar
|
|
1001
1126
|
// synchronously so the tap is instant. The document's own browsing
|
|
1002
1127
|
// flag is cleared too, or the next render would latch the bar back on.
|
|
1128
|
+
const doc = session.document;
|
|
1003
1129
|
browsing = false;
|
|
1004
|
-
|
|
1130
|
+
dismissedAt = doc?.version ?? undefined;
|
|
1131
|
+
doc?._setVersionBrowsing(false);
|
|
1132
|
+
// Leaving the bar means leaving the preview: the bar is the only
|
|
1133
|
+
// surface that returns a document to its head, so a dismissal that
|
|
1134
|
+
// left it checked out would leave an app read-only with nothing to
|
|
1135
|
+
// clear it. A document pinned to its version has no head to return to
|
|
1136
|
+
// and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
|
|
1137
|
+
if (doc && doc.version !== null) {
|
|
1138
|
+
void attempt(doc, () => doc.returnToHead()).catch(() => { });
|
|
1139
|
+
}
|
|
1005
1140
|
hideBar();
|
|
1006
1141
|
},
|
|
1007
1142
|
};
|
|
1008
1143
|
const render = () => {
|
|
1009
1144
|
const doc = session.document;
|
|
1010
1145
|
const docId = doc?.id ?? null;
|
|
1011
|
-
// A new active document (or none) ends any prior browsing session.
|
|
1146
|
+
// A new active document (or none) ends any prior browsing session. Keyed
|
|
1147
|
+
// on the id rather than the Document instance because a checkout keeps the
|
|
1148
|
+
// instance and only moves its `version`, so the bar follows the document
|
|
1149
|
+
// the user is stepping through rather than closing under them.
|
|
1012
1150
|
if (docId !== lastDocId) {
|
|
1013
1151
|
lastDocId = docId;
|
|
1014
1152
|
browsing = false;
|
|
1153
|
+
dismissedAt = undefined;
|
|
1015
1154
|
}
|
|
1016
1155
|
// A live checkout (`doc.version !== null`) starts / keeps the browsing
|
|
1017
1156
|
// session. Other read-only states (a view-only share link, a still-loading
|
|
1018
1157
|
// doc) are not this bar's concern, and `doc.id` is null only on the offline
|
|
1019
1158
|
// / test path the embed can't serve.
|
|
1159
|
+
// `versionBrowsing` is set by the reader asking for this surface, and the
|
|
1160
|
+
// close cleared it, so it names a fresh request whatever version the
|
|
1161
|
+
// document is on and outranks the dismissal.
|
|
1020
1162
|
if (doc != null &&
|
|
1021
1163
|
docId !== null &&
|
|
1022
|
-
(doc.version !== null || doc.versionBrowsing)
|
|
1164
|
+
(doc.version !== null || doc.versionBrowsing) &&
|
|
1165
|
+
(doc.versionBrowsing || doc.version !== dismissedAt)) {
|
|
1023
1166
|
browsing = true;
|
|
1167
|
+
dismissedAt = undefined;
|
|
1168
|
+
// Mirror the open bar onto the document, which is what makes it
|
|
1169
|
+
// read-only for as long as the bar is up. A preview entered by picking
|
|
1170
|
+
// a version sets only `version`, so without this the reader who then
|
|
1171
|
+
// returns to latest keeps the bar and gets the document back, which is
|
|
1172
|
+
// the pair the flag exists to keep together.
|
|
1173
|
+
doc._setVersionBrowsing(true);
|
|
1024
1174
|
}
|
|
1025
1175
|
// The bar stays open for the whole session, including at the live head
|
|
1026
1176
|
// (`doc.version === null`, browsing still true), until `close`.
|
|
@@ -1033,6 +1183,7 @@ function buildVersionPreview(deps) {
|
|
|
1033
1183
|
branch: doc.branchId ?? undefined,
|
|
1034
1184
|
version: doc.version,
|
|
1035
1185
|
branchName: doc._currentBranchName() ?? undefined,
|
|
1186
|
+
pinned: doc._isVersionPinned,
|
|
1036
1187
|
});
|
|
1037
1188
|
}
|
|
1038
1189
|
else {
|