@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/src/session-toolbar.ts
CHANGED
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
// never mint a grant themselves.
|
|
17
17
|
|
|
18
18
|
import type { BrassUser } from './types.js';
|
|
19
|
-
import type { Command } from './documents.js';
|
|
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,
|
|
@@ -59,9 +62,6 @@ export interface VersionPreviewDeps {
|
|
|
59
62
|
dashboardBaseUrl: string;
|
|
60
63
|
// Pins the embed's light/dark theme (matches the other hosted surfaces).
|
|
61
64
|
hostedUiTheme?: 'light' | 'dark' | 'auto';
|
|
62
|
-
// Fallback platform-session pointer, forwarded to the embed for the
|
|
63
|
-
// cookie-blocked (incognito / Safari ITP) case.
|
|
64
|
-
getSessionToken?: () => string | null | undefined;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export interface AccountMenuDeps {
|
|
@@ -69,9 +69,9 @@ export interface AccountMenuDeps {
|
|
|
69
69
|
onUserChange: (listener: (user: BrassUser | null) => void) => () => void;
|
|
70
70
|
signIn: () => void;
|
|
71
71
|
signOut: () => void;
|
|
72
|
-
// Base URL of the Brass dashboard, for the account menu's "Open
|
|
73
|
-
// link. The client's resolved dashboard origin (picks the
|
|
74
|
-
// with no trailing slash.
|
|
72
|
+
// Base URL of the Brass dashboard, for the account menu's "Open Brass
|
|
73
|
+
// dashboard" link. The client's resolved dashboard origin (picks the
|
|
74
|
+
// environment), with no trailing slash.
|
|
75
75
|
dashboardBaseUrl: string;
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -254,7 +254,30 @@ const CSS = `
|
|
|
254
254
|
fallback shown when no image is set or the image fails to load. */
|
|
255
255
|
.b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
256
256
|
.b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
|
|
257
|
-
|
|
257
|
+
/* The account menu opens on the identity it acts for: the avatar again at
|
|
258
|
+
reading size, the name, and the email under it. The rows below are the
|
|
259
|
+
actions on that identity, so they carry a glyph and sit apart from the
|
|
260
|
+
header behind a rule. */
|
|
261
|
+
.b-account .b-pop { min-width:236px; max-width:min(300px, calc(100vw - 16px)); }
|
|
262
|
+
.b-account-head { display:flex; align-items:center; gap:10px; padding:6px 8px 8px; }
|
|
263
|
+
.b-account-face { flex:0 0 auto; width:36px; height:36px; border-radius:50%;
|
|
264
|
+
display:inline-flex; align-items:center; justify-content:center; overflow:hidden;
|
|
265
|
+
background:var(--brass-accent); color:var(--brass-accent-fg);
|
|
266
|
+
font-size:15px; font-weight:600; }
|
|
267
|
+
/* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
|
|
268
|
+
menu: a grid item's default min-width is its content's intrinsic size. */
|
|
269
|
+
.b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
|
|
270
|
+
.b-account-name, .b-email { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
271
|
+
.b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
|
|
272
|
+
.b-email { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
|
|
273
|
+
.b-account .b-pop button, .b-account .b-pop a {
|
|
274
|
+
display:flex; align-items:center; gap:10px; font-size:13px; }
|
|
275
|
+
.b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
|
|
276
|
+
.b-account .b-pop button:hover svg, .b-account .b-pop a:hover svg { color:var(--brass-fg); }
|
|
277
|
+
/* Restated at this specificity so the flex rows above don't unhide a row the
|
|
278
|
+
guest / signed-in swap hides. */
|
|
279
|
+
.b-account .b-pop button[hidden], .b-account .b-pop a[hidden],
|
|
280
|
+
.b-account .b-pop .b-account-head[hidden], .b-account .b-pop .b-sep[hidden] { display:none; }
|
|
258
281
|
/* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
|
|
259
282
|
there is something to do. Clicking opens a top-layer Popover (never clipped
|
|
260
283
|
by the header overflow, light-dismissed on outside tap). */
|
|
@@ -289,6 +312,10 @@ const CSS = `
|
|
|
289
312
|
.b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
|
|
290
313
|
.b-drive-source:hover { text-decoration:underline; }
|
|
291
314
|
.b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
|
|
315
|
+
/* The drive rejecting a published file is the one line in this panel that
|
|
316
|
+
reports a problem, so it carries the danger tone rather than the muted one
|
|
317
|
+
every other line uses. */
|
|
318
|
+
.b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
|
|
292
319
|
/* Historical-version preview bar: a full-width strip the toolbar shows below
|
|
293
320
|
the bar whenever the active doc is previewing an earlier version. The strip
|
|
294
321
|
itself is the platform-served version-bar iframe (the SDK owns only this
|
|
@@ -304,6 +331,23 @@ const CSS = `
|
|
|
304
331
|
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
305
332
|
border-bottom:1px solid var(--brass-border); }
|
|
306
333
|
.b-version iframe { display:block; }
|
|
334
|
+
/* The SDK-drawn strip that stands in when the embed reports it cannot render.
|
|
335
|
+
Same box as the placeholder, plus the two controls that need no version
|
|
336
|
+
timeline, so returning to the live head stays reachable. */
|
|
337
|
+
.b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
|
|
338
|
+
min-height:44px; padding:0 14px; font-size:13px;
|
|
339
|
+
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
340
|
+
border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
|
|
341
|
+
.b-version-fallback[hidden] { display:none; }
|
|
342
|
+
/* Takes the strip's slack, so the controls hold the trailing edge whichever
|
|
343
|
+
of them is showing, and it is the only part that ellipsizes. */
|
|
344
|
+
.b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
|
|
345
|
+
text-overflow:ellipsis; white-space:nowrap; }
|
|
346
|
+
.b-version-fallback button { flex:0 0 auto; background:transparent;
|
|
347
|
+
border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
|
|
348
|
+
color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
|
|
349
|
+
.b-version-fallback button:hover { background:var(--brass-surface); }
|
|
350
|
+
.b-version-fallback button[hidden] { display:none; }
|
|
307
351
|
/* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
|
|
308
352
|
(negative outline-offset), because the left cluster and the title row clip
|
|
309
353
|
their overflow so a long name ellipsizes: a ring outside the box falls in
|
|
@@ -349,6 +393,43 @@ function initialOf(user: BrassUser): string {
|
|
|
349
393
|
return label.trim().charAt(0).toUpperCase() || '?';
|
|
350
394
|
}
|
|
351
395
|
|
|
396
|
+
// A user's face: the uploaded image over a monogram. The monogram is the
|
|
397
|
+
// fallback, shown when the user has no avatar image and restored if the image
|
|
398
|
+
// fails to load (a broken / unreachable URL renders the initial, never a
|
|
399
|
+
// broken-image glyph). The caller places the pair inside its own circle, so
|
|
400
|
+
// the account menu's trigger and its header draw the same face at two sizes.
|
|
401
|
+
function buildAvatarFace(): {
|
|
402
|
+
nodes: [HTMLImageElement, HTMLSpanElement];
|
|
403
|
+
render(user: BrassUser | null): void;
|
|
404
|
+
} {
|
|
405
|
+
const img = el('img', 'b-avatar-img');
|
|
406
|
+
img.alt = '';
|
|
407
|
+
const initial = el('span', 'b-avatar-initial');
|
|
408
|
+
const showMonogram = (): void => {
|
|
409
|
+
img.hidden = true;
|
|
410
|
+
initial.hidden = false;
|
|
411
|
+
};
|
|
412
|
+
img.addEventListener('error', () => {
|
|
413
|
+
img.removeAttribute('src');
|
|
414
|
+
showMonogram();
|
|
415
|
+
});
|
|
416
|
+
return {
|
|
417
|
+
nodes: [img, initial],
|
|
418
|
+
render(user: BrassUser | null): void {
|
|
419
|
+
initial.textContent = user ? initialOf(user) : '?';
|
|
420
|
+
const url = user?.avatar_url?.trim() ?? '';
|
|
421
|
+
if (!url) {
|
|
422
|
+
img.removeAttribute('src');
|
|
423
|
+
showMonogram();
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (img.getAttribute('src') !== url) img.src = url;
|
|
427
|
+
img.hidden = false;
|
|
428
|
+
initial.hidden = true;
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
352
433
|
function renderCommandButton(cmd: Command, asTool: boolean): HTMLButtonElement {
|
|
353
434
|
const b = el('button', asTool ? 'b-tool' : undefined);
|
|
354
435
|
b.type = 'button';
|
|
@@ -584,7 +665,11 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
584
665
|
loc.hidden = true;
|
|
585
666
|
root.append(titleRow, loc);
|
|
586
667
|
|
|
587
|
-
|
|
668
|
+
// The document the location chip describes, held by INSTANCE. A branch
|
|
669
|
+
// switch, and a re-open after the document moves, bind a new Document under
|
|
670
|
+
// the same id, and a move is exactly the event that changes the location
|
|
671
|
+
// label, so an id comparison would leave the chip naming the old project.
|
|
672
|
+
let boundDoc: DocumentSession['document'] = null;
|
|
588
673
|
// The non-default branch label (null on the default), read by `fitBranch`
|
|
589
674
|
// so a resize re-evaluates the drop without a full re-render.
|
|
590
675
|
let branchLabel: string | null = null;
|
|
@@ -608,8 +693,9 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
608
693
|
return deps.session.document?._currentBranchName() ?? null;
|
|
609
694
|
}
|
|
610
695
|
|
|
611
|
-
|
|
612
|
-
|
|
696
|
+
// Resolves the label for one document, named by the caller so the in-flight
|
|
697
|
+
// fetch is attributed to the Document it was started for.
|
|
698
|
+
async function refreshLocation(doc: DocumentSession['document']): Promise<void> {
|
|
613
699
|
const docId = doc?.id ?? null;
|
|
614
700
|
if (!docId) {
|
|
615
701
|
loc.hidden = true;
|
|
@@ -617,7 +703,7 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
617
703
|
}
|
|
618
704
|
try {
|
|
619
705
|
const record = await deps.getDocument(docId);
|
|
620
|
-
if (deps.session.document
|
|
706
|
+
if (deps.session.document !== doc) return; // superseded
|
|
621
707
|
// The server hands back an opaque location label (rendered verbatim);
|
|
622
708
|
// the toolbar never composes it from project / org ids, so the
|
|
623
709
|
// hierarchy above the document can evolve without a client change. A
|
|
@@ -633,14 +719,14 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
633
719
|
}
|
|
634
720
|
loc.hidden = false;
|
|
635
721
|
} catch {
|
|
636
|
-
if (deps.session.document
|
|
722
|
+
if (deps.session.document === doc) loc.hidden = true;
|
|
637
723
|
}
|
|
638
724
|
}
|
|
639
725
|
|
|
640
726
|
function runManage(): void {
|
|
641
727
|
void (async () => {
|
|
642
728
|
await deps.session.run('manage');
|
|
643
|
-
void refreshLocation();
|
|
729
|
+
void refreshLocation(deps.session.document);
|
|
644
730
|
})();
|
|
645
731
|
}
|
|
646
732
|
title.onclick = runManage;
|
|
@@ -675,11 +761,10 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
675
761
|
branch.title = `On branch "${branchLabel}"`;
|
|
676
762
|
}
|
|
677
763
|
fitBranch();
|
|
678
|
-
// Re-resolve location only when the
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
void refreshLocation();
|
|
764
|
+
// Re-resolve location only when the bound document changes.
|
|
765
|
+
if (doc !== boundDoc) {
|
|
766
|
+
boundDoc = doc;
|
|
767
|
+
void refreshLocation(doc);
|
|
683
768
|
}
|
|
684
769
|
};
|
|
685
770
|
render();
|
|
@@ -728,7 +813,12 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
728
813
|
injectStyles(document);
|
|
729
814
|
const root = el('div', 'brass-ui b-roster');
|
|
730
815
|
|
|
731
|
-
|
|
816
|
+
// The document whose presence client is subscribed, held by INSTANCE.
|
|
817
|
+
// Presence is scoped to a (document, branch) pair, and a branch switch binds
|
|
818
|
+
// a new Document under the SAME id, so an id comparison would keep the
|
|
819
|
+
// roster subscribed to the branch the user just left and show the wrong
|
|
820
|
+
// people as present on the one they are looking at.
|
|
821
|
+
let boundDoc: DocumentSession['document'] = null;
|
|
732
822
|
let presenceOff: (() => void) | null = null;
|
|
733
823
|
|
|
734
824
|
const renderRoster = (participants: readonly PresenceParticipant[]): void => {
|
|
@@ -773,9 +863,9 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
773
863
|
}
|
|
774
864
|
|
|
775
865
|
const render = (): void => {
|
|
776
|
-
const
|
|
777
|
-
if (
|
|
778
|
-
|
|
866
|
+
const doc = deps.session.document;
|
|
867
|
+
if (doc !== boundDoc) {
|
|
868
|
+
boundDoc = doc;
|
|
779
869
|
bindPresence();
|
|
780
870
|
}
|
|
781
871
|
};
|
|
@@ -790,10 +880,10 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
790
880
|
};
|
|
791
881
|
}
|
|
792
882
|
|
|
793
|
-
// A monochrome
|
|
883
|
+
// A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
|
|
794
884
|
// in the SVG namespace because `createElement('svg')` yields an HTML element
|
|
795
885
|
// that won't render.
|
|
796
|
-
function
|
|
886
|
+
function strokeIcon(...paths: string[]): SVGSVGElement {
|
|
797
887
|
const ns = 'http://www.w3.org/2000/svg';
|
|
798
888
|
const svg = document.createElementNS(ns, 'svg');
|
|
799
889
|
svg.setAttribute('width', '16');
|
|
@@ -805,10 +895,7 @@ function chainLinkIcon(): SVGSVGElement {
|
|
|
805
895
|
svg.setAttribute('stroke-linecap', 'round');
|
|
806
896
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
807
897
|
svg.setAttribute('aria-hidden', 'true');
|
|
808
|
-
for (const d of
|
|
809
|
-
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
810
|
-
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
|
|
811
|
-
]) {
|
|
898
|
+
for (const d of paths) {
|
|
812
899
|
const path = document.createElementNS(ns, 'path');
|
|
813
900
|
path.setAttribute('d', d);
|
|
814
901
|
svg.append(path);
|
|
@@ -816,11 +903,34 @@ function chainLinkIcon(): SVGSVGElement {
|
|
|
816
903
|
return svg;
|
|
817
904
|
}
|
|
818
905
|
|
|
906
|
+
function chainLinkIcon(): SVGSVGElement {
|
|
907
|
+
return strokeIcon(
|
|
908
|
+
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
909
|
+
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
819
913
|
// How long the popover holds its "Publishing..." affordance after a push
|
|
820
914
|
// before giving up on the synced confirmation. Long enough to cover a normal
|
|
821
915
|
// drive webhook round-trip.
|
|
822
916
|
const PUBLISH_HOLD_MS = 30000;
|
|
823
917
|
|
|
918
|
+
// `value` when it is an absolute http(s) URL, else null. The drive-sync
|
|
919
|
+
// popover's "View in <drive>" link is the one place the toolbar puts a
|
|
920
|
+
// server-supplied string into an `href`, and an href runs whatever scheme it
|
|
921
|
+
// names: a `javascript:` URL executes in the app's own page on the click.
|
|
922
|
+
// A relative URL throws out of `new URL` and is refused with the rest, which
|
|
923
|
+
// is right here, since the link points at an external drive item.
|
|
924
|
+
function httpUrlOrNull(value: string): string | null {
|
|
925
|
+
let url: URL;
|
|
926
|
+
try {
|
|
927
|
+
url = new URL(value);
|
|
928
|
+
} catch {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
|
|
932
|
+
}
|
|
933
|
+
|
|
824
934
|
// The drive-sync indicator: a link icon (dim at rest, accent / danger with a
|
|
825
935
|
// dot when there is something to do) whose popover describes the state
|
|
826
936
|
// (server-authored copy, verbatim) and offers publish / resolve. The receiver
|
|
@@ -849,8 +959,13 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
849
959
|
dot.setAttribute('aria-hidden', 'true');
|
|
850
960
|
button.append(dot);
|
|
851
961
|
|
|
852
|
-
|
|
962
|
+
// The document whose drive status is subscribed, held by INSTANCE. A branch
|
|
963
|
+
// switch binds a new Document under the same id, and each carries its own
|
|
964
|
+
// drive state, so an id comparison would leave the indicator describing the
|
|
965
|
+
// document that was closed.
|
|
966
|
+
let boundDoc: DocumentSession['document'] = null;
|
|
853
967
|
let statusOff: (() => void) | null = null;
|
|
968
|
+
let publishOff: (() => void) | null = null;
|
|
854
969
|
|
|
855
970
|
// A publish runs on a server worker and reaches `synced` only after the
|
|
856
971
|
// worker (and, on a file-roundtrip, the drive's importer) confirms. In that
|
|
@@ -864,24 +979,111 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
864
979
|
let publishing = false;
|
|
865
980
|
let publishError = false;
|
|
866
981
|
let publishTimer: ReturnType<typeof setTimeout> | null = null;
|
|
982
|
+
// Set on a click, cleared by the next publish-state frame. Between the two,
|
|
983
|
+
// the held state describes the PREVIOUS attempt, and rendering it would
|
|
984
|
+
// report the last failure over the publish just started.
|
|
985
|
+
let publishStateStale = false;
|
|
867
986
|
let currentStatus: DocumentDriveStatus | null = null;
|
|
868
987
|
const clearPublishing = (): void => {
|
|
869
988
|
publishing = false;
|
|
989
|
+
// The stale mark is cleared with the hold it belongs to. It is otherwise
|
|
990
|
+
// cleared only by a publish-state frame, so a publish that draws none
|
|
991
|
+
// would leave the panel reading the target's resting sentence for the
|
|
992
|
+
// life of the document.
|
|
993
|
+
publishStateStale = false;
|
|
870
994
|
if (publishTimer !== null) {
|
|
871
995
|
clearTimeout(publishTimer);
|
|
872
996
|
publishTimer = null;
|
|
873
997
|
}
|
|
874
998
|
};
|
|
875
999
|
|
|
1000
|
+
// The panel for a document with no source item: the server's sentence about
|
|
1001
|
+
// where the file lands, and the publish that puts it there. The first
|
|
1002
|
+
// publish creates the artifact and makes the document its mirror, at which
|
|
1003
|
+
// point the drive status arrives and the mirror panel above replaces this
|
|
1004
|
+
// one.
|
|
1005
|
+
const renderPublishOffer = (target: DrivePublishTarget): void => {
|
|
1006
|
+
root.hidden = false;
|
|
1007
|
+
// A document the drive has never received is as far out of sync as one
|
|
1008
|
+
// whose edits have not been published, so the icon carries the same
|
|
1009
|
+
// attention an `ahead` document's does. What separates them is what the
|
|
1010
|
+
// panel offers, not how much the reader is told to look.
|
|
1011
|
+
root.classList.add('has-changes');
|
|
1012
|
+
root.classList.remove('is-broken');
|
|
1013
|
+
const state = publishStateStale ? null : (boundDoc?._publishState ?? null);
|
|
1014
|
+
const inFlight =
|
|
1015
|
+
publishing || state?.publishing === true || target.publishing;
|
|
1016
|
+
button.title = inFlight
|
|
1017
|
+
? 'Publishing...'
|
|
1018
|
+
: state?.failed
|
|
1019
|
+
? `Publish to ${target.driveName} failed`
|
|
1020
|
+
: `Publish to ${target.driveName}`;
|
|
1021
|
+
|
|
1022
|
+
const failed =
|
|
1023
|
+
!inFlight && (state?.failed === true || target.publishFailed === true);
|
|
1024
|
+
|
|
1025
|
+
const rows: HTMLElement[] = [];
|
|
1026
|
+
// One line, server-authored either way: the worker's phase copy while it
|
|
1027
|
+
// runs, its account of the failure when it stopped, and the resting
|
|
1028
|
+
// sentence otherwise. Marked as a problem when it is one, since nothing
|
|
1029
|
+
// else in this panel distinguishes a document whose publish failed from
|
|
1030
|
+
// one that has simply never published.
|
|
1031
|
+
const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
|
|
1032
|
+
p.textContent = inFlight
|
|
1033
|
+
? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
|
|
1034
|
+
: (state?.detail ?? target.detail);
|
|
1035
|
+
rows.push(p);
|
|
1036
|
+
|
|
1037
|
+
const pub = el('button');
|
|
1038
|
+
pub.type = 'button';
|
|
1039
|
+
pub.disabled = inFlight;
|
|
1040
|
+
pub.textContent = inFlight
|
|
1041
|
+
? 'Publishing...'
|
|
1042
|
+
: publishError || failed
|
|
1043
|
+
? `Publish to ${target.driveName}, retry`
|
|
1044
|
+
: `Publish to ${target.driveName}`;
|
|
1045
|
+
pub.onclick = () => {
|
|
1046
|
+
const doc = deps.session.document;
|
|
1047
|
+
if (!doc) return;
|
|
1048
|
+
publishError = false;
|
|
1049
|
+
publishing = true;
|
|
1050
|
+
publishStateStale = true;
|
|
1051
|
+
// The same safety net the mirror panel keeps: a publish that neither
|
|
1052
|
+
// errors nor lands leaves the panel offering the action again rather
|
|
1053
|
+
// than held forever.
|
|
1054
|
+
publishTimer = setTimeout(() => {
|
|
1055
|
+
clearPublishing();
|
|
1056
|
+
render(currentStatus);
|
|
1057
|
+
}, PUBLISH_HOLD_MS);
|
|
1058
|
+
render(currentStatus);
|
|
1059
|
+
void doc.pushToDrive().catch(() => {
|
|
1060
|
+
clearPublishing();
|
|
1061
|
+
publishError = true;
|
|
1062
|
+
render(currentStatus);
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
rows.push(pub);
|
|
1066
|
+
pop.replaceChildren(...rows);
|
|
1067
|
+
};
|
|
1068
|
+
|
|
876
1069
|
const render = (status: DocumentDriveStatus | null): void => {
|
|
877
1070
|
currentStatus = status;
|
|
878
|
-
//
|
|
1071
|
+
// A document that mirrors nothing can still have somewhere to go: its
|
|
1072
|
+
// project may be linked to a drive that takes it as a new file, which is
|
|
1073
|
+
// what an upload into a linked project sits waiting for. Offer that
|
|
1074
|
+
// publish here, so an app gets it by mounting this piece rather than by
|
|
1075
|
+
// building a surface of its own.
|
|
879
1076
|
if (status === null) {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1077
|
+
const target = boundDoc?._publishTarget ?? null;
|
|
1078
|
+
if (target === null) {
|
|
1079
|
+
root.hidden = true;
|
|
1080
|
+
menu.close();
|
|
1081
|
+
clearPublishing();
|
|
1082
|
+
root.classList.remove('has-changes', 'is-broken');
|
|
1083
|
+
pop.replaceChildren();
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
renderPublishOffer(target);
|
|
885
1087
|
return;
|
|
886
1088
|
}
|
|
887
1089
|
// The publish settles once the doc actually reaches synced; drop the
|
|
@@ -916,10 +1118,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
916
1118
|
// there is one), then the commands. Rebuilt on every status change, so an
|
|
917
1119
|
// open popover updates live.
|
|
918
1120
|
const rows: HTMLElement[] = [];
|
|
919
|
-
|
|
1121
|
+
const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
|
|
1122
|
+
if (itemUrl) {
|
|
920
1123
|
const head = el('div', 'b-drive-head');
|
|
921
1124
|
const link = el('a', 'b-drive-source');
|
|
922
|
-
link.href =
|
|
1125
|
+
link.href = itemUrl;
|
|
923
1126
|
link.target = '_blank';
|
|
924
1127
|
link.rel = 'noopener noreferrer';
|
|
925
1128
|
link.textContent = `View in ${driveName}`;
|
|
@@ -946,6 +1149,15 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
946
1149
|
rows.push(p);
|
|
947
1150
|
}
|
|
948
1151
|
|
|
1152
|
+
// The drive could not read what the last publish delivered. Shown in the
|
|
1153
|
+
// drive's own words, above the commands: the publish succeeded, so
|
|
1154
|
+
// nothing else in this panel says anything is wrong.
|
|
1155
|
+
if (status.sourceUnreadable) {
|
|
1156
|
+
const warn = el('p', 'b-drive-unreadable');
|
|
1157
|
+
warn.textContent = status.sourceUnreadable;
|
|
1158
|
+
rows.push(warn);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
949
1161
|
if (isPublishing) {
|
|
950
1162
|
// Held while the publish is in flight (a local click hold, or the doc's
|
|
951
1163
|
// pending work-status), until it reaches synced (see `isPublishing`).
|
|
@@ -955,8 +1167,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
955
1167
|
pub.disabled = true;
|
|
956
1168
|
rows.push(pub);
|
|
957
1169
|
} else if (status.canPush) {
|
|
958
|
-
// `canPush` already encodes two-way + not broken + local edits
|
|
959
|
-
// never offers a
|
|
1170
|
+
// `canPush` already encodes two-way + not broken + local edits + a
|
|
1171
|
+
// server-writable format, so this never offers a publish the server
|
|
1172
|
+
// would refuse.
|
|
960
1173
|
const pub = el('button');
|
|
961
1174
|
pub.type = 'button';
|
|
962
1175
|
pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
|
|
@@ -1004,6 +1217,8 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1004
1217
|
function bind(): void {
|
|
1005
1218
|
statusOff?.();
|
|
1006
1219
|
statusOff = null;
|
|
1220
|
+
publishOff?.();
|
|
1221
|
+
publishOff = null;
|
|
1007
1222
|
const doc = deps.session.document;
|
|
1008
1223
|
if (!doc) {
|
|
1009
1224
|
render(null);
|
|
@@ -1014,12 +1229,23 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1014
1229
|
// started from any surface (the dashboard card, the app) flips it and the
|
|
1015
1230
|
// indicator holds "Publishing..." without a local click.
|
|
1016
1231
|
statusOff = doc.onDriveStatus(render);
|
|
1232
|
+
// The same signal for a document that mirrors nothing yet, which has no
|
|
1233
|
+
// drive status to carry it: its first publish is the one that creates the
|
|
1234
|
+
// artifact, and this is how its progress and its failure arrive.
|
|
1235
|
+
publishOff = doc._onPublishState(() => {
|
|
1236
|
+
publishStateStale = false;
|
|
1237
|
+
// A publish the server has finished with ends the local click hold:
|
|
1238
|
+
// holding "Publishing..." for the rest of it would report a push that
|
|
1239
|
+
// is over as one still running.
|
|
1240
|
+
if (!doc._publishState.publishing) clearPublishing();
|
|
1241
|
+
render(currentStatus);
|
|
1242
|
+
});
|
|
1017
1243
|
}
|
|
1018
1244
|
|
|
1019
1245
|
const onSession = (): void => {
|
|
1020
|
-
const
|
|
1021
|
-
if (
|
|
1022
|
-
|
|
1246
|
+
const doc = deps.session.document;
|
|
1247
|
+
if (doc !== boundDoc) {
|
|
1248
|
+
boundDoc = doc;
|
|
1023
1249
|
bind();
|
|
1024
1250
|
}
|
|
1025
1251
|
};
|
|
@@ -1030,6 +1256,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1030
1256
|
destroy(): void {
|
|
1031
1257
|
unsub();
|
|
1032
1258
|
statusOff?.();
|
|
1259
|
+
publishOff?.();
|
|
1033
1260
|
clearPublishing();
|
|
1034
1261
|
menu.destroy();
|
|
1035
1262
|
},
|
|
@@ -1044,18 +1271,41 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1044
1271
|
label: 'Account',
|
|
1045
1272
|
align: 'end',
|
|
1046
1273
|
});
|
|
1274
|
+
// The menu opens on the identity it acts for: the avatar at reading size,
|
|
1275
|
+
// the display name, and the email under it. The name line drops out when the
|
|
1276
|
+
// user has none, leaving the email as the single identifying line.
|
|
1277
|
+
const head = el('div', 'b-account-head');
|
|
1278
|
+
const headFace = buildAvatarFace();
|
|
1279
|
+
const faceEl = el('span', 'b-account-face');
|
|
1280
|
+
faceEl.append(...headFace.nodes);
|
|
1281
|
+
const nameEl = el('div', 'b-account-name');
|
|
1047
1282
|
const emailEl = el('div', 'b-email');
|
|
1283
|
+
const identity = el('div', 'b-account-id');
|
|
1284
|
+
identity.append(nameEl, emailEl);
|
|
1285
|
+
head.append(faceEl, identity);
|
|
1286
|
+
const headSep = el('hr', 'b-sep');
|
|
1287
|
+
headSep.setAttribute('role', 'separator');
|
|
1048
1288
|
// Opens the dashboard in a new tab, so the user keeps the app open in the
|
|
1049
1289
|
// current one. `/home` is the authenticated landing (resolves the active
|
|
1050
1290
|
// org); the bare origin is the sign-in page.
|
|
1051
1291
|
const dashboardLink = el('a');
|
|
1052
|
-
dashboardLink.
|
|
1292
|
+
dashboardLink.append(
|
|
1293
|
+
strokeIcon(
|
|
1294
|
+
'M14 3h7v7',
|
|
1295
|
+
'M10 14 21 3',
|
|
1296
|
+
'M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5',
|
|
1297
|
+
),
|
|
1298
|
+
'Open Brass dashboard',
|
|
1299
|
+
);
|
|
1053
1300
|
dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
|
|
1054
1301
|
dashboardLink.target = '_blank';
|
|
1055
1302
|
dashboardLink.rel = 'noopener noreferrer';
|
|
1056
1303
|
dashboardLink.onclick = () => menu.close();
|
|
1057
1304
|
const signOutBtn = el('button');
|
|
1058
|
-
signOutBtn.
|
|
1305
|
+
signOutBtn.append(
|
|
1306
|
+
strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'),
|
|
1307
|
+
'Sign out',
|
|
1308
|
+
);
|
|
1059
1309
|
signOutBtn.onclick = () => {
|
|
1060
1310
|
menu.close();
|
|
1061
1311
|
deps.signOut();
|
|
@@ -1065,50 +1315,37 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1065
1315
|
// meaningless, so the menu offers a single "Sign in" that takes the top
|
|
1066
1316
|
// window through the app's normal sign-in and returns to this URL.
|
|
1067
1317
|
const signInBtn = el('button');
|
|
1068
|
-
signInBtn.
|
|
1318
|
+
signInBtn.append(
|
|
1319
|
+
strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'),
|
|
1320
|
+
'Sign in',
|
|
1321
|
+
);
|
|
1069
1322
|
signInBtn.onclick = () => {
|
|
1070
1323
|
menu.close();
|
|
1071
1324
|
deps.signIn();
|
|
1072
1325
|
};
|
|
1073
|
-
menu.pop.append(
|
|
1326
|
+
menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
|
|
1074
1327
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
// restored if the image fails to load (a broken / unreachable URL renders
|
|
1078
|
-
// the initial, never a broken-image glyph).
|
|
1079
|
-
const avatarImg = el('img', 'b-avatar-img');
|
|
1080
|
-
avatarImg.alt = '';
|
|
1081
|
-
const initialEl = el('span', 'b-avatar-initial');
|
|
1082
|
-
menu.trigger.append(avatarImg, initialEl);
|
|
1083
|
-
|
|
1084
|
-
const showMonogram = (): void => {
|
|
1085
|
-
avatarImg.hidden = true;
|
|
1086
|
-
initialEl.hidden = false;
|
|
1087
|
-
};
|
|
1088
|
-
avatarImg.addEventListener('error', () => {
|
|
1089
|
-
avatarImg.removeAttribute('src');
|
|
1090
|
-
showMonogram();
|
|
1091
|
-
});
|
|
1328
|
+
const triggerFace = buildAvatarFace();
|
|
1329
|
+
menu.trigger.append(...triggerFace.nodes);
|
|
1092
1330
|
|
|
1093
1331
|
const renderUser = (user: BrassUser | null): void => {
|
|
1094
|
-
|
|
1095
|
-
|
|
1332
|
+
triggerFace.render(user);
|
|
1333
|
+
headFace.render(user);
|
|
1334
|
+
nameEl.textContent = user?.display_name ?? '';
|
|
1335
|
+
nameEl.hidden = !user?.display_name;
|
|
1336
|
+
emailEl.textContent = user ? (user.email ?? user.sub) : '';
|
|
1337
|
+
// The email ellipsizes to hold the menu's width, so the untruncated
|
|
1338
|
+
// address stays reachable on hover.
|
|
1339
|
+
emailEl.title = emailEl.textContent;
|
|
1096
1340
|
// Swap the menu body between the signed-in actions and the guest's lone
|
|
1097
1341
|
// "Sign in": a null user is an anonymous link holder.
|
|
1098
1342
|
const signedIn = user !== null;
|
|
1343
|
+
head.hidden = !signedIn;
|
|
1344
|
+
headSep.hidden = !signedIn;
|
|
1099
1345
|
emailEl.hidden = !signedIn;
|
|
1100
1346
|
dashboardLink.hidden = !signedIn;
|
|
1101
1347
|
signOutBtn.hidden = !signedIn;
|
|
1102
1348
|
signInBtn.hidden = signedIn;
|
|
1103
|
-
const url = user?.avatar_url?.trim() ?? '';
|
|
1104
|
-
if (url) {
|
|
1105
|
-
if (avatarImg.getAttribute('src') !== url) avatarImg.src = url;
|
|
1106
|
-
avatarImg.hidden = false;
|
|
1107
|
-
initialEl.hidden = true;
|
|
1108
|
-
} else {
|
|
1109
|
-
avatarImg.removeAttribute('src');
|
|
1110
|
-
showMonogram();
|
|
1111
|
-
}
|
|
1112
1349
|
};
|
|
1113
1350
|
renderUser(deps.getUser());
|
|
1114
1351
|
const unsub = deps.onUserChange(renderUser);
|
|
@@ -1144,6 +1381,14 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1144
1381
|
// closes it, when the active document changes, or when no doc is open.
|
|
1145
1382
|
let browsing = false;
|
|
1146
1383
|
let lastDocId: string | null = null;
|
|
1384
|
+
// The version the reader dismissed the bar at, when the document was left
|
|
1385
|
+
// holding one. A document that cannot return to its head (one pinned to a
|
|
1386
|
+
// version) stays on it, so without this the next render reads a non-null
|
|
1387
|
+
// `doc.version` and latches the bar the reader just dismissed straight back
|
|
1388
|
+
// on. A dismissal at the live head records nothing: there is no version
|
|
1389
|
+
// left to latch on, and the head is where the next browse-in-the-app
|
|
1390
|
+
// request arrives, so a record there would swallow it.
|
|
1391
|
+
let dismissedAt: number | undefined = undefined;
|
|
1147
1392
|
|
|
1148
1393
|
const session = deps.session;
|
|
1149
1394
|
const hideBar = (): void => {
|
|
@@ -1153,27 +1398,44 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1153
1398
|
frame = null;
|
|
1154
1399
|
}
|
|
1155
1400
|
};
|
|
1401
|
+
// A failed intent reaches the reader through the bar (which reverts to the
|
|
1402
|
+
// document's real state) and the developer through `Brass.onError`. Both
|
|
1403
|
+
// halves matter: the bar moves optimistically, so a swallowed failure is a
|
|
1404
|
+
// bar naming a version nothing is showing.
|
|
1405
|
+
const attempt = async (
|
|
1406
|
+
doc: BrassDocument,
|
|
1407
|
+
act: () => void | Promise<void>,
|
|
1408
|
+
): Promise<void> => {
|
|
1409
|
+
try {
|
|
1410
|
+
await act();
|
|
1411
|
+
} catch (err) {
|
|
1412
|
+
doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
|
|
1413
|
+
throw err;
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1156
1416
|
const frameDeps: VersionBarDeps = {
|
|
1157
1417
|
dashboardBaseUrl: deps.dashboardBaseUrl,
|
|
1158
1418
|
...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
|
|
1159
|
-
...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
|
|
1160
1419
|
// Read the live active document at call time: a bridge intent lands
|
|
1161
1420
|
// async, so it must not close over a doc that may have been superseded.
|
|
1162
|
-
|
|
1163
|
-
|
|
1421
|
+
currentVersion: () => session.document?.version ?? null,
|
|
1422
|
+
checkoutVersion: async (version) => {
|
|
1423
|
+
const doc = session.document;
|
|
1424
|
+
if (!doc) return;
|
|
1425
|
+
await attempt(doc, () => doc._checkoutVersion(version));
|
|
1164
1426
|
},
|
|
1165
|
-
returnToHead: () => {
|
|
1427
|
+
returnToHead: async () => {
|
|
1166
1428
|
// Return to the live head but keep the bar open (the browsing session
|
|
1167
1429
|
// persists); the bar re-renders in its "at latest" state with a Close
|
|
1168
1430
|
// button. Dismissal is the explicit `close` intent only.
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
// frozen / no live head: leave the preview as-is
|
|
1173
|
-
}
|
|
1431
|
+
const doc = session.document;
|
|
1432
|
+
if (!doc) return;
|
|
1433
|
+
await attempt(doc, () => doc.returnToHead());
|
|
1174
1434
|
},
|
|
1175
1435
|
fork: (fromVersion, name) => {
|
|
1176
|
-
|
|
1436
|
+
const doc = session.document;
|
|
1437
|
+
if (!doc) return;
|
|
1438
|
+
void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => {});
|
|
1177
1439
|
},
|
|
1178
1440
|
browse: () => {
|
|
1179
1441
|
void session.run('history');
|
|
@@ -1182,8 +1444,28 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1182
1444
|
// Explicit dismissal: end the browsing session and hide the bar
|
|
1183
1445
|
// synchronously so the tap is instant. The document's own browsing
|
|
1184
1446
|
// flag is cleared too, or the next render would latch the bar back on.
|
|
1447
|
+
const doc = session.document;
|
|
1185
1448
|
browsing = false;
|
|
1186
|
-
|
|
1449
|
+
dismissedAt = doc?.version ?? undefined;
|
|
1450
|
+
doc?._setVersionBrowsing(false);
|
|
1451
|
+
// Leaving the bar means leaving the preview: the bar is the only
|
|
1452
|
+
// surface that returns a document to its head, so a dismissal that
|
|
1453
|
+
// left it checked out would leave an app read-only with nothing to
|
|
1454
|
+
// clear it. A document pinned to its version has no head to return to
|
|
1455
|
+
// and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
|
|
1456
|
+
// Once the return lands, the dismissal has nothing left to hold
|
|
1457
|
+
// against: the doc is live, and a LATER pick of the same version in
|
|
1458
|
+
// the history dialog is a fresh preview that must latch the bar (its
|
|
1459
|
+
// pick sets only `version`, so a stale `dismissedAt` equal to it would
|
|
1460
|
+
// otherwise leave the reader checked out, read-only, with no bar and
|
|
1461
|
+
// no exit).
|
|
1462
|
+
if (doc && doc.version !== null) {
|
|
1463
|
+
void attempt(doc, () => doc.returnToHead())
|
|
1464
|
+
.then(() => {
|
|
1465
|
+
if (session.document === doc) dismissedAt = undefined;
|
|
1466
|
+
})
|
|
1467
|
+
.catch(() => {});
|
|
1468
|
+
}
|
|
1187
1469
|
hideBar();
|
|
1188
1470
|
},
|
|
1189
1471
|
};
|
|
@@ -1191,21 +1473,36 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1191
1473
|
const render = (): void => {
|
|
1192
1474
|
const doc = session.document;
|
|
1193
1475
|
const docId = doc?.id ?? null;
|
|
1194
|
-
// A new active document (or none) ends any prior browsing session.
|
|
1476
|
+
// A new active document (or none) ends any prior browsing session. Keyed
|
|
1477
|
+
// on the id rather than the Document instance because a checkout keeps the
|
|
1478
|
+
// instance and only moves its `version`, so the bar follows the document
|
|
1479
|
+
// the user is stepping through rather than closing under them.
|
|
1195
1480
|
if (docId !== lastDocId) {
|
|
1196
1481
|
lastDocId = docId;
|
|
1197
1482
|
browsing = false;
|
|
1483
|
+
dismissedAt = undefined;
|
|
1198
1484
|
}
|
|
1199
1485
|
// A live checkout (`doc.version !== null`) starts / keeps the browsing
|
|
1200
1486
|
// session. Other read-only states (a view-only share link, a still-loading
|
|
1201
1487
|
// doc) are not this bar's concern, and `doc.id` is null only on the offline
|
|
1202
1488
|
// / test path the embed can't serve.
|
|
1489
|
+
// `versionBrowsing` is set by the reader asking for this surface, and the
|
|
1490
|
+
// close cleared it, so it names a fresh request whatever version the
|
|
1491
|
+
// document is on and outranks the dismissal.
|
|
1203
1492
|
if (
|
|
1204
1493
|
doc != null &&
|
|
1205
1494
|
docId !== null &&
|
|
1206
|
-
(doc.version !== null || doc.versionBrowsing)
|
|
1495
|
+
(doc.version !== null || doc.versionBrowsing) &&
|
|
1496
|
+
(doc.versionBrowsing || doc.version !== dismissedAt)
|
|
1207
1497
|
) {
|
|
1208
1498
|
browsing = true;
|
|
1499
|
+
dismissedAt = undefined;
|
|
1500
|
+
// Mirror the open bar onto the document, which is what makes it
|
|
1501
|
+
// read-only for as long as the bar is up. A preview entered by picking
|
|
1502
|
+
// a version sets only `version`, so without this the reader who then
|
|
1503
|
+
// returns to latest keeps the bar and gets the document back, which is
|
|
1504
|
+
// the pair the flag exists to keep together.
|
|
1505
|
+
doc._setVersionBrowsing(true);
|
|
1209
1506
|
}
|
|
1210
1507
|
// The bar stays open for the whole session, including at the live head
|
|
1211
1508
|
// (`doc.version === null`, browsing still true), until `close`.
|
|
@@ -1217,6 +1514,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1217
1514
|
branch: doc.branchId ?? undefined,
|
|
1218
1515
|
version: doc.version,
|
|
1219
1516
|
branchName: doc._currentBranchName() ?? undefined,
|
|
1517
|
+
pinned: doc._isVersionPinned,
|
|
1220
1518
|
});
|
|
1221
1519
|
} else {
|
|
1222
1520
|
hideBar();
|