@brass-build/client 0.1.0 → 0.3.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 +97 -0
- package/README.md +325 -235
- 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 +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- 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-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-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 +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/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 +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- 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 +204 -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 +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- 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 +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- 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 +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -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 +356 -83
- 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 +162 -20
- 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/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 +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -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 +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- 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 +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- 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 +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -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 +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -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 +387 -89
- 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 +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- package/src/ws-frame-limit.ts +23 -17
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). */
|
|
@@ -179,6 +202,10 @@ const CSS = `
|
|
|
179
202
|
.b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
|
|
180
203
|
.b-drive-source:hover { text-decoration:underline; }
|
|
181
204
|
.b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
|
|
205
|
+
/* The drive rejecting a published file is the one line in this panel that
|
|
206
|
+
reports a problem, so it carries the danger tone rather than the muted one
|
|
207
|
+
every other line uses. */
|
|
208
|
+
.b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
|
|
182
209
|
/* Historical-version preview bar: a full-width strip the toolbar shows below
|
|
183
210
|
the bar whenever the active doc is previewing an earlier version. The strip
|
|
184
211
|
itself is the platform-served version-bar iframe (the SDK owns only this
|
|
@@ -194,6 +221,23 @@ const CSS = `
|
|
|
194
221
|
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
195
222
|
border-bottom:1px solid var(--brass-border); }
|
|
196
223
|
.b-version iframe { display:block; }
|
|
224
|
+
/* The SDK-drawn strip that stands in when the embed reports it cannot render.
|
|
225
|
+
Same box as the placeholder, plus the two controls that need no version
|
|
226
|
+
timeline, so returning to the live head stays reachable. */
|
|
227
|
+
.b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
|
|
228
|
+
min-height:44px; padding:0 14px; font-size:13px;
|
|
229
|
+
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
230
|
+
border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
|
|
231
|
+
.b-version-fallback[hidden] { display:none; }
|
|
232
|
+
/* Takes the strip's slack, so the controls hold the trailing edge whichever
|
|
233
|
+
of them is showing, and it is the only part that ellipsizes. */
|
|
234
|
+
.b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
|
|
235
|
+
text-overflow:ellipsis; white-space:nowrap; }
|
|
236
|
+
.b-version-fallback button { flex:0 0 auto; background:transparent;
|
|
237
|
+
border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
|
|
238
|
+
color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
|
|
239
|
+
.b-version-fallback button:hover { background:var(--brass-surface); }
|
|
240
|
+
.b-version-fallback button[hidden] { display:none; }
|
|
197
241
|
/* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
|
|
198
242
|
(negative outline-offset), because the left cluster and the title row clip
|
|
199
243
|
their overflow so a long name ellipsizes: a ring outside the box falls in
|
|
@@ -234,6 +278,40 @@ function initialOf(user) {
|
|
|
234
278
|
const label = user.display_name ?? user.email ?? user.sub;
|
|
235
279
|
return label.trim().charAt(0).toUpperCase() || '?';
|
|
236
280
|
}
|
|
281
|
+
// A user's face: the uploaded image over a monogram. The monogram is the
|
|
282
|
+
// fallback, shown when the user has no avatar image and restored if the image
|
|
283
|
+
// fails to load (a broken / unreachable URL renders the initial, never a
|
|
284
|
+
// broken-image glyph). The caller places the pair inside its own circle, so
|
|
285
|
+
// the account menu's trigger and its header draw the same face at two sizes.
|
|
286
|
+
function buildAvatarFace() {
|
|
287
|
+
const img = el('img', 'b-avatar-img');
|
|
288
|
+
img.alt = '';
|
|
289
|
+
const initial = el('span', 'b-avatar-initial');
|
|
290
|
+
const showMonogram = () => {
|
|
291
|
+
img.hidden = true;
|
|
292
|
+
initial.hidden = false;
|
|
293
|
+
};
|
|
294
|
+
img.addEventListener('error', () => {
|
|
295
|
+
img.removeAttribute('src');
|
|
296
|
+
showMonogram();
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
nodes: [img, initial],
|
|
300
|
+
render(user) {
|
|
301
|
+
initial.textContent = user ? initialOf(user) : '?';
|
|
302
|
+
const url = user?.avatar_url?.trim() ?? '';
|
|
303
|
+
if (!url) {
|
|
304
|
+
img.removeAttribute('src');
|
|
305
|
+
showMonogram();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (img.getAttribute('src') !== url)
|
|
309
|
+
img.src = url;
|
|
310
|
+
img.hidden = false;
|
|
311
|
+
initial.hidden = true;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
237
315
|
function renderCommandButton(cmd, asTool) {
|
|
238
316
|
const b = el('button', asTool ? 'b-tool' : undefined);
|
|
239
317
|
b.type = 'button';
|
|
@@ -425,7 +503,11 @@ function buildDocumentTitle(deps) {
|
|
|
425
503
|
loc.type = 'button';
|
|
426
504
|
loc.hidden = true;
|
|
427
505
|
root.append(titleRow, loc);
|
|
428
|
-
|
|
506
|
+
// The document the location chip describes, held by INSTANCE. A branch
|
|
507
|
+
// switch, and a re-open after the document moves, bind a new Document under
|
|
508
|
+
// the same id, and a move is exactly the event that changes the location
|
|
509
|
+
// label, so an id comparison would leave the chip naming the old project.
|
|
510
|
+
let boundDoc = null;
|
|
429
511
|
// The non-default branch label (null on the default), read by `fitBranch`
|
|
430
512
|
// so a resize re-evaluates the drop without a full re-render.
|
|
431
513
|
let branchLabel = null;
|
|
@@ -447,8 +529,9 @@ function buildDocumentTitle(deps) {
|
|
|
447
529
|
function activeBranchLabel() {
|
|
448
530
|
return deps.session.document?._currentBranchName() ?? null;
|
|
449
531
|
}
|
|
450
|
-
|
|
451
|
-
|
|
532
|
+
// Resolves the label for one document, named by the caller so the in-flight
|
|
533
|
+
// fetch is attributed to the Document it was started for.
|
|
534
|
+
async function refreshLocation(doc) {
|
|
452
535
|
const docId = doc?.id ?? null;
|
|
453
536
|
if (!docId) {
|
|
454
537
|
loc.hidden = true;
|
|
@@ -456,7 +539,7 @@ function buildDocumentTitle(deps) {
|
|
|
456
539
|
}
|
|
457
540
|
try {
|
|
458
541
|
const record = await deps.getDocument(docId);
|
|
459
|
-
if (deps.session.document
|
|
542
|
+
if (deps.session.document !== doc)
|
|
460
543
|
return; // superseded
|
|
461
544
|
// The server hands back an opaque location label (rendered verbatim);
|
|
462
545
|
// the toolbar never composes it from project / org ids, so the
|
|
@@ -475,14 +558,14 @@ function buildDocumentTitle(deps) {
|
|
|
475
558
|
loc.hidden = false;
|
|
476
559
|
}
|
|
477
560
|
catch {
|
|
478
|
-
if (deps.session.document
|
|
561
|
+
if (deps.session.document === doc)
|
|
479
562
|
loc.hidden = true;
|
|
480
563
|
}
|
|
481
564
|
}
|
|
482
565
|
function runManage() {
|
|
483
566
|
void (async () => {
|
|
484
567
|
await deps.session.run('manage');
|
|
485
|
-
void refreshLocation();
|
|
568
|
+
void refreshLocation(deps.session.document);
|
|
486
569
|
})();
|
|
487
570
|
}
|
|
488
571
|
title.onclick = runManage;
|
|
@@ -518,11 +601,10 @@ function buildDocumentTitle(deps) {
|
|
|
518
601
|
branch.title = `On branch "${branchLabel}"`;
|
|
519
602
|
}
|
|
520
603
|
fitBranch();
|
|
521
|
-
// Re-resolve location only when the
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
void refreshLocation();
|
|
604
|
+
// Re-resolve location only when the bound document changes.
|
|
605
|
+
if (doc !== boundDoc) {
|
|
606
|
+
boundDoc = doc;
|
|
607
|
+
void refreshLocation(doc);
|
|
526
608
|
}
|
|
527
609
|
};
|
|
528
610
|
render();
|
|
@@ -565,7 +647,12 @@ function buildUndoRedo(deps) {
|
|
|
565
647
|
function buildPresenceRoster(deps) {
|
|
566
648
|
injectStyles(document);
|
|
567
649
|
const root = el('div', 'brass-ui b-roster');
|
|
568
|
-
|
|
650
|
+
// The document whose presence client is subscribed, held by INSTANCE.
|
|
651
|
+
// Presence is scoped to a (document, branch) pair, and a branch switch binds
|
|
652
|
+
// a new Document under the SAME id, so an id comparison would keep the
|
|
653
|
+
// roster subscribed to the branch the user just left and show the wrong
|
|
654
|
+
// people as present on the one they are looking at.
|
|
655
|
+
let boundDoc = null;
|
|
569
656
|
let presenceOff = null;
|
|
570
657
|
const renderRoster = (participants) => {
|
|
571
658
|
root.replaceChildren(...participants.map((p) => {
|
|
@@ -607,9 +694,9 @@ function buildPresenceRoster(deps) {
|
|
|
607
694
|
typeof off === 'function' ? off : () => presence.off('change', renderRoster);
|
|
608
695
|
}
|
|
609
696
|
const render = () => {
|
|
610
|
-
const
|
|
611
|
-
if (
|
|
612
|
-
|
|
697
|
+
const doc = deps.session.document;
|
|
698
|
+
if (doc !== boundDoc) {
|
|
699
|
+
boundDoc = doc;
|
|
613
700
|
bindPresence();
|
|
614
701
|
}
|
|
615
702
|
};
|
|
@@ -623,10 +710,10 @@ function buildPresenceRoster(deps) {
|
|
|
623
710
|
},
|
|
624
711
|
};
|
|
625
712
|
}
|
|
626
|
-
// A monochrome
|
|
713
|
+
// A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
|
|
627
714
|
// in the SVG namespace because `createElement('svg')` yields an HTML element
|
|
628
715
|
// that won't render.
|
|
629
|
-
function
|
|
716
|
+
function strokeIcon(...paths) {
|
|
630
717
|
const ns = 'http://www.w3.org/2000/svg';
|
|
631
718
|
const svg = document.createElementNS(ns, 'svg');
|
|
632
719
|
svg.setAttribute('width', '16');
|
|
@@ -638,20 +725,36 @@ function chainLinkIcon() {
|
|
|
638
725
|
svg.setAttribute('stroke-linecap', 'round');
|
|
639
726
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
640
727
|
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
|
-
]) {
|
|
728
|
+
for (const d of paths) {
|
|
645
729
|
const path = document.createElementNS(ns, 'path');
|
|
646
730
|
path.setAttribute('d', d);
|
|
647
731
|
svg.append(path);
|
|
648
732
|
}
|
|
649
733
|
return svg;
|
|
650
734
|
}
|
|
735
|
+
function chainLinkIcon() {
|
|
736
|
+
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');
|
|
737
|
+
}
|
|
651
738
|
// How long the popover holds its "Publishing..." affordance after a push
|
|
652
739
|
// before giving up on the synced confirmation. Long enough to cover a normal
|
|
653
740
|
// drive webhook round-trip.
|
|
654
741
|
const PUBLISH_HOLD_MS = 30000;
|
|
742
|
+
// `value` when it is an absolute http(s) URL, else null. The drive-sync
|
|
743
|
+
// popover's "View in <drive>" link is the one place the toolbar puts a
|
|
744
|
+
// server-supplied string into an `href`, and an href runs whatever scheme it
|
|
745
|
+
// names: a `javascript:` URL executes in the app's own page on the click.
|
|
746
|
+
// A relative URL throws out of `new URL` and is refused with the rest, which
|
|
747
|
+
// is right here, since the link points at an external drive item.
|
|
748
|
+
function httpUrlOrNull(value) {
|
|
749
|
+
let url;
|
|
750
|
+
try {
|
|
751
|
+
url = new URL(value);
|
|
752
|
+
}
|
|
753
|
+
catch {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
|
|
757
|
+
}
|
|
655
758
|
// The drive-sync indicator: a link icon (dim at rest, accent / danger with a
|
|
656
759
|
// dot when there is something to do) whose popover describes the state
|
|
657
760
|
// (server-authored copy, verbatim) and offers publish / resolve. The receiver
|
|
@@ -679,8 +782,13 @@ function buildDriveSync(deps) {
|
|
|
679
782
|
const dot = el('span', 'b-drive-dot');
|
|
680
783
|
dot.setAttribute('aria-hidden', 'true');
|
|
681
784
|
button.append(dot);
|
|
682
|
-
|
|
785
|
+
// The document whose drive status is subscribed, held by INSTANCE. A branch
|
|
786
|
+
// switch binds a new Document under the same id, and each carries its own
|
|
787
|
+
// drive state, so an id comparison would leave the indicator describing the
|
|
788
|
+
// document that was closed.
|
|
789
|
+
let boundDoc = null;
|
|
683
790
|
let statusOff = null;
|
|
791
|
+
let publishOff = null;
|
|
684
792
|
// A publish runs on a server worker and reaches `synced` only after the
|
|
685
793
|
// worker (and, on a file-roundtrip, the drive's importer) confirms. In that
|
|
686
794
|
// gap the drive state still reads `ahead`, which would flash "unpublished
|
|
@@ -693,23 +801,105 @@ function buildDriveSync(deps) {
|
|
|
693
801
|
let publishing = false;
|
|
694
802
|
let publishError = false;
|
|
695
803
|
let publishTimer = null;
|
|
804
|
+
// Set on a click, cleared by the next publish-state frame. Between the two,
|
|
805
|
+
// the held state describes the PREVIOUS attempt, and rendering it would
|
|
806
|
+
// report the last failure over the publish just started.
|
|
807
|
+
let publishStateStale = false;
|
|
696
808
|
let currentStatus = null;
|
|
697
809
|
const clearPublishing = () => {
|
|
698
810
|
publishing = false;
|
|
811
|
+
// The stale mark is cleared with the hold it belongs to. It is otherwise
|
|
812
|
+
// cleared only by a publish-state frame, so a publish that draws none
|
|
813
|
+
// would leave the panel reading the target's resting sentence for the
|
|
814
|
+
// life of the document.
|
|
815
|
+
publishStateStale = false;
|
|
699
816
|
if (publishTimer !== null) {
|
|
700
817
|
clearTimeout(publishTimer);
|
|
701
818
|
publishTimer = null;
|
|
702
819
|
}
|
|
703
820
|
};
|
|
821
|
+
// The panel for a document with no source item: the server's sentence about
|
|
822
|
+
// where the file lands, and the publish that puts it there. The first
|
|
823
|
+
// publish creates the artifact and makes the document its mirror, at which
|
|
824
|
+
// point the drive status arrives and the mirror panel above replaces this
|
|
825
|
+
// one.
|
|
826
|
+
const renderPublishOffer = (target) => {
|
|
827
|
+
root.hidden = false;
|
|
828
|
+
// A document the drive has never received is as far out of sync as one
|
|
829
|
+
// whose edits have not been published, so the icon carries the same
|
|
830
|
+
// attention an `ahead` document's does. What separates them is what the
|
|
831
|
+
// panel offers, not how much the reader is told to look.
|
|
832
|
+
root.classList.add('has-changes');
|
|
833
|
+
root.classList.remove('is-broken');
|
|
834
|
+
const state = publishStateStale ? null : (boundDoc?._publishState ?? null);
|
|
835
|
+
const inFlight = publishing || state?.publishing === true || target.publishing;
|
|
836
|
+
button.title = inFlight
|
|
837
|
+
? 'Publishing...'
|
|
838
|
+
: state?.failed
|
|
839
|
+
? `Publish to ${target.driveName} failed`
|
|
840
|
+
: `Publish to ${target.driveName}`;
|
|
841
|
+
const failed = !inFlight && (state?.failed === true || target.publishFailed === true);
|
|
842
|
+
const rows = [];
|
|
843
|
+
// One line, server-authored either way: the worker's phase copy while it
|
|
844
|
+
// runs, its account of the failure when it stopped, and the resting
|
|
845
|
+
// sentence otherwise. Marked as a problem when it is one, since nothing
|
|
846
|
+
// else in this panel distinguishes a document whose publish failed from
|
|
847
|
+
// one that has simply never published.
|
|
848
|
+
const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
|
|
849
|
+
p.textContent = inFlight
|
|
850
|
+
? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
|
|
851
|
+
: (state?.detail ?? target.detail);
|
|
852
|
+
rows.push(p);
|
|
853
|
+
const pub = el('button');
|
|
854
|
+
pub.type = 'button';
|
|
855
|
+
pub.disabled = inFlight;
|
|
856
|
+
pub.textContent = inFlight
|
|
857
|
+
? 'Publishing...'
|
|
858
|
+
: publishError || failed
|
|
859
|
+
? `Publish to ${target.driveName}, retry`
|
|
860
|
+
: `Publish to ${target.driveName}`;
|
|
861
|
+
pub.onclick = () => {
|
|
862
|
+
const doc = deps.session.document;
|
|
863
|
+
if (!doc)
|
|
864
|
+
return;
|
|
865
|
+
publishError = false;
|
|
866
|
+
publishing = true;
|
|
867
|
+
publishStateStale = true;
|
|
868
|
+
// The same safety net the mirror panel keeps: a publish that neither
|
|
869
|
+
// errors nor lands leaves the panel offering the action again rather
|
|
870
|
+
// than held forever.
|
|
871
|
+
publishTimer = setTimeout(() => {
|
|
872
|
+
clearPublishing();
|
|
873
|
+
render(currentStatus);
|
|
874
|
+
}, PUBLISH_HOLD_MS);
|
|
875
|
+
render(currentStatus);
|
|
876
|
+
void doc.pushToDrive().catch(() => {
|
|
877
|
+
clearPublishing();
|
|
878
|
+
publishError = true;
|
|
879
|
+
render(currentStatus);
|
|
880
|
+
});
|
|
881
|
+
};
|
|
882
|
+
rows.push(pub);
|
|
883
|
+
pop.replaceChildren(...rows);
|
|
884
|
+
};
|
|
704
885
|
const render = (status) => {
|
|
705
886
|
currentStatus = status;
|
|
706
|
-
//
|
|
887
|
+
// A document that mirrors nothing can still have somewhere to go: its
|
|
888
|
+
// project may be linked to a drive that takes it as a new file, which is
|
|
889
|
+
// what an upload into a linked project sits waiting for. Offer that
|
|
890
|
+
// publish here, so an app gets it by mounting this piece rather than by
|
|
891
|
+
// building a surface of its own.
|
|
707
892
|
if (status === null) {
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
893
|
+
const target = boundDoc?._publishTarget ?? null;
|
|
894
|
+
if (target === null) {
|
|
895
|
+
root.hidden = true;
|
|
896
|
+
menu.close();
|
|
897
|
+
clearPublishing();
|
|
898
|
+
root.classList.remove('has-changes', 'is-broken');
|
|
899
|
+
pop.replaceChildren();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
renderPublishOffer(target);
|
|
713
903
|
return;
|
|
714
904
|
}
|
|
715
905
|
// The publish settles once the doc actually reaches synced; drop the
|
|
@@ -742,10 +932,11 @@ function buildDriveSync(deps) {
|
|
|
742
932
|
// there is one), then the commands. Rebuilt on every status change, so an
|
|
743
933
|
// open popover updates live.
|
|
744
934
|
const rows = [];
|
|
745
|
-
|
|
935
|
+
const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
|
|
936
|
+
if (itemUrl) {
|
|
746
937
|
const head = el('div', 'b-drive-head');
|
|
747
938
|
const link = el('a', 'b-drive-source');
|
|
748
|
-
link.href =
|
|
939
|
+
link.href = itemUrl;
|
|
749
940
|
link.target = '_blank';
|
|
750
941
|
link.rel = 'noopener noreferrer';
|
|
751
942
|
link.textContent = `View in ${driveName}`;
|
|
@@ -770,6 +961,14 @@ function buildDriveSync(deps) {
|
|
|
770
961
|
p.textContent = description;
|
|
771
962
|
rows.push(p);
|
|
772
963
|
}
|
|
964
|
+
// The drive could not read what the last publish delivered. Shown in the
|
|
965
|
+
// drive's own words, above the commands: the publish succeeded, so
|
|
966
|
+
// nothing else in this panel says anything is wrong.
|
|
967
|
+
if (status.sourceUnreadable) {
|
|
968
|
+
const warn = el('p', 'b-drive-unreadable');
|
|
969
|
+
warn.textContent = status.sourceUnreadable;
|
|
970
|
+
rows.push(warn);
|
|
971
|
+
}
|
|
773
972
|
if (isPublishing) {
|
|
774
973
|
// Held while the publish is in flight (a local click hold, or the doc's
|
|
775
974
|
// pending work-status), until it reaches synced (see `isPublishing`).
|
|
@@ -780,8 +979,9 @@ function buildDriveSync(deps) {
|
|
|
780
979
|
rows.push(pub);
|
|
781
980
|
}
|
|
782
981
|
else if (status.canPush) {
|
|
783
|
-
// `canPush` already encodes two-way + not broken + local edits
|
|
784
|
-
// never offers a
|
|
982
|
+
// `canPush` already encodes two-way + not broken + local edits + a
|
|
983
|
+
// server-writable format, so this never offers a publish the server
|
|
984
|
+
// would refuse.
|
|
785
985
|
const pub = el('button');
|
|
786
986
|
pub.type = 'button';
|
|
787
987
|
pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
|
|
@@ -827,6 +1027,8 @@ function buildDriveSync(deps) {
|
|
|
827
1027
|
function bind() {
|
|
828
1028
|
statusOff?.();
|
|
829
1029
|
statusOff = null;
|
|
1030
|
+
publishOff?.();
|
|
1031
|
+
publishOff = null;
|
|
830
1032
|
const doc = deps.session.document;
|
|
831
1033
|
if (!doc) {
|
|
832
1034
|
render(null);
|
|
@@ -837,11 +1039,23 @@ function buildDriveSync(deps) {
|
|
|
837
1039
|
// started from any surface (the dashboard card, the app) flips it and the
|
|
838
1040
|
// indicator holds "Publishing..." without a local click.
|
|
839
1041
|
statusOff = doc.onDriveStatus(render);
|
|
1042
|
+
// The same signal for a document that mirrors nothing yet, which has no
|
|
1043
|
+
// drive status to carry it: its first publish is the one that creates the
|
|
1044
|
+
// artifact, and this is how its progress and its failure arrive.
|
|
1045
|
+
publishOff = doc._onPublishState(() => {
|
|
1046
|
+
publishStateStale = false;
|
|
1047
|
+
// A publish the server has finished with ends the local click hold:
|
|
1048
|
+
// holding "Publishing..." for the rest of it would report a push that
|
|
1049
|
+
// is over as one still running.
|
|
1050
|
+
if (!doc._publishState.publishing)
|
|
1051
|
+
clearPublishing();
|
|
1052
|
+
render(currentStatus);
|
|
1053
|
+
});
|
|
840
1054
|
}
|
|
841
1055
|
const onSession = () => {
|
|
842
|
-
const
|
|
843
|
-
if (
|
|
844
|
-
|
|
1056
|
+
const doc = deps.session.document;
|
|
1057
|
+
if (doc !== boundDoc) {
|
|
1058
|
+
boundDoc = doc;
|
|
845
1059
|
bind();
|
|
846
1060
|
}
|
|
847
1061
|
};
|
|
@@ -852,6 +1066,7 @@ function buildDriveSync(deps) {
|
|
|
852
1066
|
destroy() {
|
|
853
1067
|
unsub();
|
|
854
1068
|
statusOff?.();
|
|
1069
|
+
publishOff?.();
|
|
855
1070
|
clearPublishing();
|
|
856
1071
|
menu.destroy();
|
|
857
1072
|
},
|
|
@@ -865,18 +1080,31 @@ function buildAccountMenu(deps) {
|
|
|
865
1080
|
label: 'Account',
|
|
866
1081
|
align: 'end',
|
|
867
1082
|
});
|
|
1083
|
+
// The menu opens on the identity it acts for: the avatar at reading size,
|
|
1084
|
+
// the display name, and the email under it. The name line drops out when the
|
|
1085
|
+
// user has none, leaving the email as the single identifying line.
|
|
1086
|
+
const head = el('div', 'b-account-head');
|
|
1087
|
+
const headFace = buildAvatarFace();
|
|
1088
|
+
const faceEl = el('span', 'b-account-face');
|
|
1089
|
+
faceEl.append(...headFace.nodes);
|
|
1090
|
+
const nameEl = el('div', 'b-account-name');
|
|
868
1091
|
const emailEl = el('div', 'b-email');
|
|
1092
|
+
const identity = el('div', 'b-account-id');
|
|
1093
|
+
identity.append(nameEl, emailEl);
|
|
1094
|
+
head.append(faceEl, identity);
|
|
1095
|
+
const headSep = el('hr', 'b-sep');
|
|
1096
|
+
headSep.setAttribute('role', 'separator');
|
|
869
1097
|
// Opens the dashboard in a new tab, so the user keeps the app open in the
|
|
870
1098
|
// current one. `/home` is the authenticated landing (resolves the active
|
|
871
1099
|
// org); the bare origin is the sign-in page.
|
|
872
1100
|
const dashboardLink = el('a');
|
|
873
|
-
dashboardLink.
|
|
1101
|
+
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
1102
|
dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
|
|
875
1103
|
dashboardLink.target = '_blank';
|
|
876
1104
|
dashboardLink.rel = 'noopener noreferrer';
|
|
877
1105
|
dashboardLink.onclick = () => menu.close();
|
|
878
1106
|
const signOutBtn = el('button');
|
|
879
|
-
signOutBtn.
|
|
1107
|
+
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
1108
|
signOutBtn.onclick = () => {
|
|
881
1109
|
menu.close();
|
|
882
1110
|
deps.signOut();
|
|
@@ -886,49 +1114,32 @@ function buildAccountMenu(deps) {
|
|
|
886
1114
|
// meaningless, so the menu offers a single "Sign in" that takes the top
|
|
887
1115
|
// window through the app's normal sign-in and returns to this URL.
|
|
888
1116
|
const signInBtn = el('button');
|
|
889
|
-
signInBtn.
|
|
1117
|
+
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
1118
|
signInBtn.onclick = () => {
|
|
891
1119
|
menu.close();
|
|
892
1120
|
deps.signIn();
|
|
893
1121
|
};
|
|
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
|
-
});
|
|
1122
|
+
menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
|
|
1123
|
+
const triggerFace = buildAvatarFace();
|
|
1124
|
+
menu.trigger.append(...triggerFace.nodes);
|
|
911
1125
|
const renderUser = (user) => {
|
|
912
|
-
|
|
913
|
-
|
|
1126
|
+
triggerFace.render(user);
|
|
1127
|
+
headFace.render(user);
|
|
1128
|
+
nameEl.textContent = user?.display_name ?? '';
|
|
1129
|
+
nameEl.hidden = !user?.display_name;
|
|
1130
|
+
emailEl.textContent = user ? (user.email ?? user.sub) : '';
|
|
1131
|
+
// The email ellipsizes to hold the menu's width, so the untruncated
|
|
1132
|
+
// address stays reachable on hover.
|
|
1133
|
+
emailEl.title = emailEl.textContent;
|
|
914
1134
|
// Swap the menu body between the signed-in actions and the guest's lone
|
|
915
1135
|
// "Sign in": a null user is an anonymous link holder.
|
|
916
1136
|
const signedIn = user !== null;
|
|
1137
|
+
head.hidden = !signedIn;
|
|
1138
|
+
headSep.hidden = !signedIn;
|
|
917
1139
|
emailEl.hidden = !signedIn;
|
|
918
1140
|
dashboardLink.hidden = !signedIn;
|
|
919
1141
|
signOutBtn.hidden = !signedIn;
|
|
920
1142
|
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
1143
|
};
|
|
933
1144
|
renderUser(deps.getUser());
|
|
934
1145
|
const unsub = deps.onUserChange(renderUser);
|
|
@@ -962,6 +1173,14 @@ function buildVersionPreview(deps) {
|
|
|
962
1173
|
// closes it, when the active document changes, or when no doc is open.
|
|
963
1174
|
let browsing = false;
|
|
964
1175
|
let lastDocId = null;
|
|
1176
|
+
// The version the reader dismissed the bar at, when the document was left
|
|
1177
|
+
// holding one. A document that cannot return to its head (one pinned to a
|
|
1178
|
+
// version) stays on it, so without this the next render reads a non-null
|
|
1179
|
+
// `doc.version` and latches the bar the reader just dismissed straight back
|
|
1180
|
+
// on. A dismissal at the live head records nothing: there is no version
|
|
1181
|
+
// left to latch on, and the head is where the next browse-in-the-app
|
|
1182
|
+
// request arrives, so a record there would swallow it.
|
|
1183
|
+
let dismissedAt = undefined;
|
|
965
1184
|
const session = deps.session;
|
|
966
1185
|
const hideBar = () => {
|
|
967
1186
|
root.classList.remove('b-version-on');
|
|
@@ -970,28 +1189,45 @@ function buildVersionPreview(deps) {
|
|
|
970
1189
|
frame = null;
|
|
971
1190
|
}
|
|
972
1191
|
};
|
|
1192
|
+
// A failed intent reaches the reader through the bar (which reverts to the
|
|
1193
|
+
// document's real state) and the developer through `Brass.onError`. Both
|
|
1194
|
+
// halves matter: the bar moves optimistically, so a swallowed failure is a
|
|
1195
|
+
// bar naming a version nothing is showing.
|
|
1196
|
+
const attempt = async (doc, act) => {
|
|
1197
|
+
try {
|
|
1198
|
+
await act();
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
|
|
1202
|
+
throw err;
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
973
1205
|
const frameDeps = {
|
|
974
1206
|
dashboardBaseUrl: deps.dashboardBaseUrl,
|
|
975
1207
|
...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
|
|
976
|
-
...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
|
|
977
1208
|
// Read the live active document at call time: a bridge intent lands
|
|
978
1209
|
// async, so it must not close over a doc that may have been superseded.
|
|
979
|
-
|
|
980
|
-
|
|
1210
|
+
currentVersion: () => session.document?.version ?? null,
|
|
1211
|
+
checkoutVersion: async (version) => {
|
|
1212
|
+
const doc = session.document;
|
|
1213
|
+
if (!doc)
|
|
1214
|
+
return;
|
|
1215
|
+
await attempt(doc, () => doc._checkoutVersion(version));
|
|
981
1216
|
},
|
|
982
|
-
returnToHead: () => {
|
|
1217
|
+
returnToHead: async () => {
|
|
983
1218
|
// Return to the live head but keep the bar open (the browsing session
|
|
984
1219
|
// persists); the bar re-renders in its "at latest" state with a Close
|
|
985
1220
|
// button. Dismissal is the explicit `close` intent only.
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
// frozen / no live head: leave the preview as-is
|
|
991
|
-
}
|
|
1221
|
+
const doc = session.document;
|
|
1222
|
+
if (!doc)
|
|
1223
|
+
return;
|
|
1224
|
+
await attempt(doc, () => doc.returnToHead());
|
|
992
1225
|
},
|
|
993
1226
|
fork: (fromVersion, name) => {
|
|
994
|
-
|
|
1227
|
+
const doc = session.document;
|
|
1228
|
+
if (!doc)
|
|
1229
|
+
return;
|
|
1230
|
+
void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => { });
|
|
995
1231
|
},
|
|
996
1232
|
browse: () => {
|
|
997
1233
|
void session.run('history');
|
|
@@ -1000,27 +1236,63 @@ function buildVersionPreview(deps) {
|
|
|
1000
1236
|
// Explicit dismissal: end the browsing session and hide the bar
|
|
1001
1237
|
// synchronously so the tap is instant. The document's own browsing
|
|
1002
1238
|
// flag is cleared too, or the next render would latch the bar back on.
|
|
1239
|
+
const doc = session.document;
|
|
1003
1240
|
browsing = false;
|
|
1004
|
-
|
|
1241
|
+
dismissedAt = doc?.version ?? undefined;
|
|
1242
|
+
doc?._setVersionBrowsing(false);
|
|
1243
|
+
// Leaving the bar means leaving the preview: the bar is the only
|
|
1244
|
+
// surface that returns a document to its head, so a dismissal that
|
|
1245
|
+
// left it checked out would leave an app read-only with nothing to
|
|
1246
|
+
// clear it. A document pinned to its version has no head to return to
|
|
1247
|
+
// and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
|
|
1248
|
+
// Once the return lands, the dismissal has nothing left to hold
|
|
1249
|
+
// against: the doc is live, and a LATER pick of the same version in
|
|
1250
|
+
// the history dialog is a fresh preview that must latch the bar (its
|
|
1251
|
+
// pick sets only `version`, so a stale `dismissedAt` equal to it would
|
|
1252
|
+
// otherwise leave the reader checked out, read-only, with no bar and
|
|
1253
|
+
// no exit).
|
|
1254
|
+
if (doc && doc.version !== null) {
|
|
1255
|
+
void attempt(doc, () => doc.returnToHead())
|
|
1256
|
+
.then(() => {
|
|
1257
|
+
if (session.document === doc)
|
|
1258
|
+
dismissedAt = undefined;
|
|
1259
|
+
})
|
|
1260
|
+
.catch(() => { });
|
|
1261
|
+
}
|
|
1005
1262
|
hideBar();
|
|
1006
1263
|
},
|
|
1007
1264
|
};
|
|
1008
1265
|
const render = () => {
|
|
1009
1266
|
const doc = session.document;
|
|
1010
1267
|
const docId = doc?.id ?? null;
|
|
1011
|
-
// A new active document (or none) ends any prior browsing session.
|
|
1268
|
+
// A new active document (or none) ends any prior browsing session. Keyed
|
|
1269
|
+
// on the id rather than the Document instance because a checkout keeps the
|
|
1270
|
+
// instance and only moves its `version`, so the bar follows the document
|
|
1271
|
+
// the user is stepping through rather than closing under them.
|
|
1012
1272
|
if (docId !== lastDocId) {
|
|
1013
1273
|
lastDocId = docId;
|
|
1014
1274
|
browsing = false;
|
|
1275
|
+
dismissedAt = undefined;
|
|
1015
1276
|
}
|
|
1016
1277
|
// A live checkout (`doc.version !== null`) starts / keeps the browsing
|
|
1017
1278
|
// session. Other read-only states (a view-only share link, a still-loading
|
|
1018
1279
|
// doc) are not this bar's concern, and `doc.id` is null only on the offline
|
|
1019
1280
|
// / test path the embed can't serve.
|
|
1281
|
+
// `versionBrowsing` is set by the reader asking for this surface, and the
|
|
1282
|
+
// close cleared it, so it names a fresh request whatever version the
|
|
1283
|
+
// document is on and outranks the dismissal.
|
|
1020
1284
|
if (doc != null &&
|
|
1021
1285
|
docId !== null &&
|
|
1022
|
-
(doc.version !== null || doc.versionBrowsing)
|
|
1286
|
+
(doc.version !== null || doc.versionBrowsing) &&
|
|
1287
|
+
(doc.versionBrowsing || doc.version !== dismissedAt)) {
|
|
1023
1288
|
browsing = true;
|
|
1289
|
+
dismissedAt = undefined;
|
|
1290
|
+
// Mirror the open bar onto the document, which is what makes it
|
|
1291
|
+
// read-only for as long as the bar is up. A preview entered by picking
|
|
1292
|
+
// a version sets only `version`, so without this the reader who then
|
|
1293
|
+
// returns to latest keeps the bar and gets the document back, which is
|
|
1294
|
+
// the pair the flag exists to keep together.
|
|
1295
|
+
doc._setVersionBrowsing(true);
|
|
1024
1296
|
}
|
|
1025
1297
|
// The bar stays open for the whole session, including at the live head
|
|
1026
1298
|
// (`doc.version === null`, browsing still true), until `close`.
|
|
@@ -1033,6 +1305,7 @@ function buildVersionPreview(deps) {
|
|
|
1033
1305
|
branch: doc.branchId ?? undefined,
|
|
1034
1306
|
version: doc.version,
|
|
1035
1307
|
branchName: doc._currentBranchName() ?? undefined,
|
|
1308
|
+
pinned: doc._isVersionPinned,
|
|
1036
1309
|
});
|
|
1037
1310
|
}
|
|
1038
1311
|
else {
|