@brass-build/client 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +72 -63
- package/CHANGELOG.md +86 -0
- package/README.md +317 -231
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +253 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +226 -75
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +140 -19
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +249 -80
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
|
@@ -73,7 +73,16 @@ export function readLastDocument(
|
|
|
73
73
|
): SelectDocumentResult | null {
|
|
74
74
|
const storage = getStorage();
|
|
75
75
|
if (!storage) return null;
|
|
76
|
-
|
|
76
|
+
let raw: string | null;
|
|
77
|
+
try {
|
|
78
|
+
raw = storage.getItem(docKey(appId));
|
|
79
|
+
} catch {
|
|
80
|
+
// A storage that holds a handle but refuses reads (a partitioned or
|
|
81
|
+
// locked-down context). Recalling the last document is a niceness, so it
|
|
82
|
+
// degrades to none rather than throwing out of the caller, which would
|
|
83
|
+
// replace the real reason a document could not be reopened.
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
77
86
|
if (raw === null) return null;
|
|
78
87
|
let parsed: unknown;
|
|
79
88
|
try {
|
package/src/serializer.ts
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
import type { Document } from './documents.js';
|
|
2
|
+
import type { ListHandle, MapHandle } from './handle-types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A container read whole.
|
|
6
|
+
*
|
|
7
|
+
* The handles an app holds do not offer this: an app is handed its records
|
|
8
|
+
* through `subscribe`, so a fire costs what changed rather than what the
|
|
9
|
+
* document holds. A serializer is the other kind of caller. It runs detached,
|
|
10
|
+
* with no subscription to have been fed by, and it needs the document once, so
|
|
11
|
+
* it is handed a view that reads whole containers instead of being told to go
|
|
12
|
+
* and fetch them.
|
|
13
|
+
*/
|
|
14
|
+
export interface StoredMap<T = unknown> extends MapHandle<T> {
|
|
15
|
+
read(): Record<string, T>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StoredList<T = unknown> extends ListHandle<T> {
|
|
19
|
+
read(): T[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The document as a serializer sees it: the same containers, readable whole.
|
|
24
|
+
*
|
|
25
|
+
* A `Document` satisfies this, which is what keeps a built-in codec and a
|
|
26
|
+
* third-party one on one surface. The difference between them is where they
|
|
27
|
+
* run, not what they are allowed to reach.
|
|
28
|
+
*/
|
|
29
|
+
export interface StoredDocument {
|
|
30
|
+
readonly id: string | null;
|
|
31
|
+
map<T = unknown>(key: string): StoredMap<T>;
|
|
32
|
+
list<T = unknown>(key: string): StoredList<T>;
|
|
33
|
+
}
|
|
2
34
|
|
|
3
35
|
// A per-app serializer that turns an app-owned (blob) document's records
|
|
4
36
|
// back into the source file's bytes: the inverse of the parse the app ran
|
|
@@ -11,5 +43,5 @@ import type { Document } from './documents.js';
|
|
|
11
43
|
// whole job. An app with no registered serializer cannot export its blob
|
|
12
44
|
// documents (the platform falls back to the stale-original passthrough).
|
|
13
45
|
export type DocumentSerializer = (
|
|
14
|
-
doc: Document,
|
|
46
|
+
doc: StoredDocument & Document,
|
|
15
47
|
) => Uint8Array | Promise<Uint8Array>;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Per-app sessionStorage layer for the incognito / third-party-cookie
|
|
2
|
-
// fallback session token: the opaque
|
|
3
|
-
// a hard reload in a cookie-blocked browser (incognito, Safari ITP)
|
|
4
|
-
// the user signed in. In a normal browser the cross-site cookie carries
|
|
5
|
-
// session and nothing is written here.
|
|
2
|
+
// fallback session token: the opaque pointer a `brass_code` exchange hands
|
|
3
|
+
// back, so a hard reload in a cookie-blocked browser (incognito, Safari ITP)
|
|
4
|
+
// keeps the user signed in. In a normal browser the cross-site cookie carries
|
|
5
|
+
// the session and nothing is written here.
|
|
6
6
|
//
|
|
7
|
-
// The
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// close) matches the cookie-channel
|
|
7
|
+
// The pointer is bound server-side to the app it was minted for, so its
|
|
8
|
+
// authority is this app and it dies with this app's per-app sign-out. That is
|
|
9
|
+
// what makes it safe to persist at all, and why the key is per-app id.
|
|
10
|
+
// `sessionStorage` (per-tab, dropped on tab close) matches the cookie-channel
|
|
11
|
+
// UX.
|
|
11
12
|
|
|
12
13
|
const KEY_PREFIX = '__brass_session_';
|
|
13
14
|
|
package/src/session-toolbar.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
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
22
|
import type { DocumentDriveStatus } from './document-signals.js';
|
|
@@ -59,9 +59,6 @@ export interface VersionPreviewDeps {
|
|
|
59
59
|
dashboardBaseUrl: string;
|
|
60
60
|
// Pins the embed's light/dark theme (matches the other hosted surfaces).
|
|
61
61
|
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
62
|
}
|
|
66
63
|
|
|
67
64
|
export interface AccountMenuDeps {
|
|
@@ -69,9 +66,9 @@ export interface AccountMenuDeps {
|
|
|
69
66
|
onUserChange: (listener: (user: BrassUser | null) => void) => () => void;
|
|
70
67
|
signIn: () => void;
|
|
71
68
|
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.
|
|
69
|
+
// Base URL of the Brass dashboard, for the account menu's "Open Brass
|
|
70
|
+
// dashboard" link. The client's resolved dashboard origin (picks the
|
|
71
|
+
// environment), with no trailing slash.
|
|
75
72
|
dashboardBaseUrl: string;
|
|
76
73
|
}
|
|
77
74
|
|
|
@@ -254,7 +251,30 @@ const CSS = `
|
|
|
254
251
|
fallback shown when no image is set or the image fails to load. */
|
|
255
252
|
.b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
256
253
|
.b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
|
|
257
|
-
|
|
254
|
+
/* The account menu opens on the identity it acts for: the avatar again at
|
|
255
|
+
reading size, the name, and the email under it. The rows below are the
|
|
256
|
+
actions on that identity, so they carry a glyph and sit apart from the
|
|
257
|
+
header behind a rule. */
|
|
258
|
+
.b-account .b-pop { min-width:236px; max-width:min(300px, calc(100vw - 16px)); }
|
|
259
|
+
.b-account-head { display:flex; align-items:center; gap:10px; padding:6px 8px 8px; }
|
|
260
|
+
.b-account-face { flex:0 0 auto; width:36px; height:36px; border-radius:50%;
|
|
261
|
+
display:inline-flex; align-items:center; justify-content:center; overflow:hidden;
|
|
262
|
+
background:var(--brass-accent); color:var(--brass-accent-fg);
|
|
263
|
+
font-size:15px; font-weight:600; }
|
|
264
|
+
/* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
|
|
265
|
+
menu: a grid item's default min-width is its content's intrinsic size. */
|
|
266
|
+
.b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
|
|
267
|
+
.b-account-name, .b-email { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
268
|
+
.b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
|
|
269
|
+
.b-email { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
|
|
270
|
+
.b-account .b-pop button, .b-account .b-pop a {
|
|
271
|
+
display:flex; align-items:center; gap:10px; font-size:13px; }
|
|
272
|
+
.b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
|
|
273
|
+
.b-account .b-pop button:hover svg, .b-account .b-pop a:hover svg { color:var(--brass-fg); }
|
|
274
|
+
/* Restated at this specificity so the flex rows above don't unhide a row the
|
|
275
|
+
guest / signed-in swap hides. */
|
|
276
|
+
.b-account .b-pop button[hidden], .b-account .b-pop a[hidden],
|
|
277
|
+
.b-account .b-pop .b-account-head[hidden], .b-account .b-pop .b-sep[hidden] { display:none; }
|
|
258
278
|
/* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
|
|
259
279
|
there is something to do. Clicking opens a top-layer Popover (never clipped
|
|
260
280
|
by the header overflow, light-dismissed on outside tap). */
|
|
@@ -304,6 +324,23 @@ const CSS = `
|
|
|
304
324
|
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
305
325
|
border-bottom:1px solid var(--brass-border); }
|
|
306
326
|
.b-version iframe { display:block; }
|
|
327
|
+
/* The SDK-drawn strip that stands in when the embed reports it cannot render.
|
|
328
|
+
Same box as the placeholder, plus the two controls that need no version
|
|
329
|
+
timeline, so returning to the live head stays reachable. */
|
|
330
|
+
.b-version-fallback { display:flex; align-items:center; gap:8px; width:100%;
|
|
331
|
+
min-height:44px; padding:0 14px; font-size:13px;
|
|
332
|
+
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
333
|
+
border-bottom:1px solid var(--brass-border); box-sizing:border-box; }
|
|
334
|
+
.b-version-fallback[hidden] { display:none; }
|
|
335
|
+
/* Takes the strip's slack, so the controls hold the trailing edge whichever
|
|
336
|
+
of them is showing, and it is the only part that ellipsizes. */
|
|
337
|
+
.b-version-fallback-msg { flex:1 1 auto; min-width:0; overflow:hidden;
|
|
338
|
+
text-overflow:ellipsis; white-space:nowrap; }
|
|
339
|
+
.b-version-fallback button { flex:0 0 auto; background:transparent;
|
|
340
|
+
border:1px solid var(--brass-border); border-radius:6px; cursor:pointer;
|
|
341
|
+
color:var(--brass-fg); font:inherit; font-size:12px; padding:4px 10px; }
|
|
342
|
+
.b-version-fallback button:hover { background:var(--brass-surface); }
|
|
343
|
+
.b-version-fallback button[hidden] { display:none; }
|
|
307
344
|
/* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
|
|
308
345
|
(negative outline-offset), because the left cluster and the title row clip
|
|
309
346
|
their overflow so a long name ellipsizes: a ring outside the box falls in
|
|
@@ -349,6 +386,43 @@ function initialOf(user: BrassUser): string {
|
|
|
349
386
|
return label.trim().charAt(0).toUpperCase() || '?';
|
|
350
387
|
}
|
|
351
388
|
|
|
389
|
+
// A user's face: the uploaded image over a monogram. The monogram is the
|
|
390
|
+
// fallback, shown when the user has no avatar image and restored if the image
|
|
391
|
+
// fails to load (a broken / unreachable URL renders the initial, never a
|
|
392
|
+
// broken-image glyph). The caller places the pair inside its own circle, so
|
|
393
|
+
// the account menu's trigger and its header draw the same face at two sizes.
|
|
394
|
+
function buildAvatarFace(): {
|
|
395
|
+
nodes: [HTMLImageElement, HTMLSpanElement];
|
|
396
|
+
render(user: BrassUser | null): void;
|
|
397
|
+
} {
|
|
398
|
+
const img = el('img', 'b-avatar-img');
|
|
399
|
+
img.alt = '';
|
|
400
|
+
const initial = el('span', 'b-avatar-initial');
|
|
401
|
+
const showMonogram = (): void => {
|
|
402
|
+
img.hidden = true;
|
|
403
|
+
initial.hidden = false;
|
|
404
|
+
};
|
|
405
|
+
img.addEventListener('error', () => {
|
|
406
|
+
img.removeAttribute('src');
|
|
407
|
+
showMonogram();
|
|
408
|
+
});
|
|
409
|
+
return {
|
|
410
|
+
nodes: [img, initial],
|
|
411
|
+
render(user: BrassUser | null): void {
|
|
412
|
+
initial.textContent = user ? initialOf(user) : '?';
|
|
413
|
+
const url = user?.avatar_url?.trim() ?? '';
|
|
414
|
+
if (!url) {
|
|
415
|
+
img.removeAttribute('src');
|
|
416
|
+
showMonogram();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (img.getAttribute('src') !== url) img.src = url;
|
|
420
|
+
img.hidden = false;
|
|
421
|
+
initial.hidden = true;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
352
426
|
function renderCommandButton(cmd: Command, asTool: boolean): HTMLButtonElement {
|
|
353
427
|
const b = el('button', asTool ? 'b-tool' : undefined);
|
|
354
428
|
b.type = 'button';
|
|
@@ -584,7 +658,11 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
584
658
|
loc.hidden = true;
|
|
585
659
|
root.append(titleRow, loc);
|
|
586
660
|
|
|
587
|
-
|
|
661
|
+
// The document the location chip describes, held by INSTANCE. A branch
|
|
662
|
+
// switch, and a re-open after the document moves, bind a new Document under
|
|
663
|
+
// the same id, and a move is exactly the event that changes the location
|
|
664
|
+
// label, so an id comparison would leave the chip naming the old project.
|
|
665
|
+
let boundDoc: DocumentSession['document'] = null;
|
|
588
666
|
// The non-default branch label (null on the default), read by `fitBranch`
|
|
589
667
|
// so a resize re-evaluates the drop without a full re-render.
|
|
590
668
|
let branchLabel: string | null = null;
|
|
@@ -608,8 +686,9 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
608
686
|
return deps.session.document?._currentBranchName() ?? null;
|
|
609
687
|
}
|
|
610
688
|
|
|
611
|
-
|
|
612
|
-
|
|
689
|
+
// Resolves the label for one document, named by the caller so the in-flight
|
|
690
|
+
// fetch is attributed to the Document it was started for.
|
|
691
|
+
async function refreshLocation(doc: DocumentSession['document']): Promise<void> {
|
|
613
692
|
const docId = doc?.id ?? null;
|
|
614
693
|
if (!docId) {
|
|
615
694
|
loc.hidden = true;
|
|
@@ -617,7 +696,7 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
617
696
|
}
|
|
618
697
|
try {
|
|
619
698
|
const record = await deps.getDocument(docId);
|
|
620
|
-
if (deps.session.document
|
|
699
|
+
if (deps.session.document !== doc) return; // superseded
|
|
621
700
|
// The server hands back an opaque location label (rendered verbatim);
|
|
622
701
|
// the toolbar never composes it from project / org ids, so the
|
|
623
702
|
// hierarchy above the document can evolve without a client change. A
|
|
@@ -633,14 +712,14 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
633
712
|
}
|
|
634
713
|
loc.hidden = false;
|
|
635
714
|
} catch {
|
|
636
|
-
if (deps.session.document
|
|
715
|
+
if (deps.session.document === doc) loc.hidden = true;
|
|
637
716
|
}
|
|
638
717
|
}
|
|
639
718
|
|
|
640
719
|
function runManage(): void {
|
|
641
720
|
void (async () => {
|
|
642
721
|
await deps.session.run('manage');
|
|
643
|
-
void refreshLocation();
|
|
722
|
+
void refreshLocation(deps.session.document);
|
|
644
723
|
})();
|
|
645
724
|
}
|
|
646
725
|
title.onclick = runManage;
|
|
@@ -675,11 +754,10 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
675
754
|
branch.title = `On branch "${branchLabel}"`;
|
|
676
755
|
}
|
|
677
756
|
fitBranch();
|
|
678
|
-
// Re-resolve location only when the
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
void refreshLocation();
|
|
757
|
+
// Re-resolve location only when the bound document changes.
|
|
758
|
+
if (doc !== boundDoc) {
|
|
759
|
+
boundDoc = doc;
|
|
760
|
+
void refreshLocation(doc);
|
|
683
761
|
}
|
|
684
762
|
};
|
|
685
763
|
render();
|
|
@@ -728,7 +806,12 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
728
806
|
injectStyles(document);
|
|
729
807
|
const root = el('div', 'brass-ui b-roster');
|
|
730
808
|
|
|
731
|
-
|
|
809
|
+
// The document whose presence client is subscribed, held by INSTANCE.
|
|
810
|
+
// Presence is scoped to a (document, branch) pair, and a branch switch binds
|
|
811
|
+
// a new Document under the SAME id, so an id comparison would keep the
|
|
812
|
+
// roster subscribed to the branch the user just left and show the wrong
|
|
813
|
+
// people as present on the one they are looking at.
|
|
814
|
+
let boundDoc: DocumentSession['document'] = null;
|
|
732
815
|
let presenceOff: (() => void) | null = null;
|
|
733
816
|
|
|
734
817
|
const renderRoster = (participants: readonly PresenceParticipant[]): void => {
|
|
@@ -773,9 +856,9 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
773
856
|
}
|
|
774
857
|
|
|
775
858
|
const render = (): void => {
|
|
776
|
-
const
|
|
777
|
-
if (
|
|
778
|
-
|
|
859
|
+
const doc = deps.session.document;
|
|
860
|
+
if (doc !== boundDoc) {
|
|
861
|
+
boundDoc = doc;
|
|
779
862
|
bindPresence();
|
|
780
863
|
}
|
|
781
864
|
};
|
|
@@ -790,10 +873,10 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
790
873
|
};
|
|
791
874
|
}
|
|
792
875
|
|
|
793
|
-
// A monochrome
|
|
876
|
+
// A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
|
|
794
877
|
// in the SVG namespace because `createElement('svg')` yields an HTML element
|
|
795
878
|
// that won't render.
|
|
796
|
-
function
|
|
879
|
+
function strokeIcon(...paths: string[]): SVGSVGElement {
|
|
797
880
|
const ns = 'http://www.w3.org/2000/svg';
|
|
798
881
|
const svg = document.createElementNS(ns, 'svg');
|
|
799
882
|
svg.setAttribute('width', '16');
|
|
@@ -805,10 +888,7 @@ function chainLinkIcon(): SVGSVGElement {
|
|
|
805
888
|
svg.setAttribute('stroke-linecap', 'round');
|
|
806
889
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
807
890
|
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
|
-
]) {
|
|
891
|
+
for (const d of paths) {
|
|
812
892
|
const path = document.createElementNS(ns, 'path');
|
|
813
893
|
path.setAttribute('d', d);
|
|
814
894
|
svg.append(path);
|
|
@@ -816,11 +896,34 @@ function chainLinkIcon(): SVGSVGElement {
|
|
|
816
896
|
return svg;
|
|
817
897
|
}
|
|
818
898
|
|
|
899
|
+
function chainLinkIcon(): SVGSVGElement {
|
|
900
|
+
return strokeIcon(
|
|
901
|
+
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
902
|
+
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
819
906
|
// How long the popover holds its "Publishing..." affordance after a push
|
|
820
907
|
// before giving up on the synced confirmation. Long enough to cover a normal
|
|
821
908
|
// drive webhook round-trip.
|
|
822
909
|
const PUBLISH_HOLD_MS = 30000;
|
|
823
910
|
|
|
911
|
+
// `value` when it is an absolute http(s) URL, else null. The drive-sync
|
|
912
|
+
// popover's "View in <drive>" link is the one place the toolbar puts a
|
|
913
|
+
// server-supplied string into an `href`, and an href runs whatever scheme it
|
|
914
|
+
// names: a `javascript:` URL executes in the app's own page on the click.
|
|
915
|
+
// A relative URL throws out of `new URL` and is refused with the rest, which
|
|
916
|
+
// is right here, since the link points at an external drive item.
|
|
917
|
+
function httpUrlOrNull(value: string): string | null {
|
|
918
|
+
let url: URL;
|
|
919
|
+
try {
|
|
920
|
+
url = new URL(value);
|
|
921
|
+
} catch {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : null;
|
|
925
|
+
}
|
|
926
|
+
|
|
824
927
|
// The drive-sync indicator: a link icon (dim at rest, accent / danger with a
|
|
825
928
|
// dot when there is something to do) whose popover describes the state
|
|
826
929
|
// (server-authored copy, verbatim) and offers publish / resolve. The receiver
|
|
@@ -849,7 +952,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
849
952
|
dot.setAttribute('aria-hidden', 'true');
|
|
850
953
|
button.append(dot);
|
|
851
954
|
|
|
852
|
-
|
|
955
|
+
// The document whose drive status is subscribed, held by INSTANCE. A branch
|
|
956
|
+
// switch binds a new Document under the same id, and each carries its own
|
|
957
|
+
// drive state, so an id comparison would leave the indicator describing the
|
|
958
|
+
// document that was closed.
|
|
959
|
+
let boundDoc: DocumentSession['document'] = null;
|
|
853
960
|
let statusOff: (() => void) | null = null;
|
|
854
961
|
|
|
855
962
|
// A publish runs on a server worker and reaches `synced` only after the
|
|
@@ -916,10 +1023,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
916
1023
|
// there is one), then the commands. Rebuilt on every status change, so an
|
|
917
1024
|
// open popover updates live.
|
|
918
1025
|
const rows: HTMLElement[] = [];
|
|
919
|
-
|
|
1026
|
+
const itemUrl = status.itemUrl ? httpUrlOrNull(status.itemUrl) : null;
|
|
1027
|
+
if (itemUrl) {
|
|
920
1028
|
const head = el('div', 'b-drive-head');
|
|
921
1029
|
const link = el('a', 'b-drive-source');
|
|
922
|
-
link.href =
|
|
1030
|
+
link.href = itemUrl;
|
|
923
1031
|
link.target = '_blank';
|
|
924
1032
|
link.rel = 'noopener noreferrer';
|
|
925
1033
|
link.textContent = `View in ${driveName}`;
|
|
@@ -1017,9 +1125,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1017
1125
|
}
|
|
1018
1126
|
|
|
1019
1127
|
const onSession = (): void => {
|
|
1020
|
-
const
|
|
1021
|
-
if (
|
|
1022
|
-
|
|
1128
|
+
const doc = deps.session.document;
|
|
1129
|
+
if (doc !== boundDoc) {
|
|
1130
|
+
boundDoc = doc;
|
|
1023
1131
|
bind();
|
|
1024
1132
|
}
|
|
1025
1133
|
};
|
|
@@ -1044,18 +1152,41 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1044
1152
|
label: 'Account',
|
|
1045
1153
|
align: 'end',
|
|
1046
1154
|
});
|
|
1155
|
+
// The menu opens on the identity it acts for: the avatar at reading size,
|
|
1156
|
+
// the display name, and the email under it. The name line drops out when the
|
|
1157
|
+
// user has none, leaving the email as the single identifying line.
|
|
1158
|
+
const head = el('div', 'b-account-head');
|
|
1159
|
+
const headFace = buildAvatarFace();
|
|
1160
|
+
const faceEl = el('span', 'b-account-face');
|
|
1161
|
+
faceEl.append(...headFace.nodes);
|
|
1162
|
+
const nameEl = el('div', 'b-account-name');
|
|
1047
1163
|
const emailEl = el('div', 'b-email');
|
|
1164
|
+
const identity = el('div', 'b-account-id');
|
|
1165
|
+
identity.append(nameEl, emailEl);
|
|
1166
|
+
head.append(faceEl, identity);
|
|
1167
|
+
const headSep = el('hr', 'b-sep');
|
|
1168
|
+
headSep.setAttribute('role', 'separator');
|
|
1048
1169
|
// Opens the dashboard in a new tab, so the user keeps the app open in the
|
|
1049
1170
|
// current one. `/home` is the authenticated landing (resolves the active
|
|
1050
1171
|
// org); the bare origin is the sign-in page.
|
|
1051
1172
|
const dashboardLink = el('a');
|
|
1052
|
-
dashboardLink.
|
|
1173
|
+
dashboardLink.append(
|
|
1174
|
+
strokeIcon(
|
|
1175
|
+
'M14 3h7v7',
|
|
1176
|
+
'M10 14 21 3',
|
|
1177
|
+
'M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5',
|
|
1178
|
+
),
|
|
1179
|
+
'Open Brass dashboard',
|
|
1180
|
+
);
|
|
1053
1181
|
dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
|
|
1054
1182
|
dashboardLink.target = '_blank';
|
|
1055
1183
|
dashboardLink.rel = 'noopener noreferrer';
|
|
1056
1184
|
dashboardLink.onclick = () => menu.close();
|
|
1057
1185
|
const signOutBtn = el('button');
|
|
1058
|
-
signOutBtn.
|
|
1186
|
+
signOutBtn.append(
|
|
1187
|
+
strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'),
|
|
1188
|
+
'Sign out',
|
|
1189
|
+
);
|
|
1059
1190
|
signOutBtn.onclick = () => {
|
|
1060
1191
|
menu.close();
|
|
1061
1192
|
deps.signOut();
|
|
@@ -1065,50 +1196,37 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1065
1196
|
// meaningless, so the menu offers a single "Sign in" that takes the top
|
|
1066
1197
|
// window through the app's normal sign-in and returns to this URL.
|
|
1067
1198
|
const signInBtn = el('button');
|
|
1068
|
-
signInBtn.
|
|
1199
|
+
signInBtn.append(
|
|
1200
|
+
strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'),
|
|
1201
|
+
'Sign in',
|
|
1202
|
+
);
|
|
1069
1203
|
signInBtn.onclick = () => {
|
|
1070
1204
|
menu.close();
|
|
1071
1205
|
deps.signIn();
|
|
1072
1206
|
};
|
|
1073
|
-
menu.pop.append(
|
|
1207
|
+
menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
|
|
1074
1208
|
|
|
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
|
-
});
|
|
1209
|
+
const triggerFace = buildAvatarFace();
|
|
1210
|
+
menu.trigger.append(...triggerFace.nodes);
|
|
1092
1211
|
|
|
1093
1212
|
const renderUser = (user: BrassUser | null): void => {
|
|
1094
|
-
|
|
1095
|
-
|
|
1213
|
+
triggerFace.render(user);
|
|
1214
|
+
headFace.render(user);
|
|
1215
|
+
nameEl.textContent = user?.display_name ?? '';
|
|
1216
|
+
nameEl.hidden = !user?.display_name;
|
|
1217
|
+
emailEl.textContent = user ? (user.email ?? user.sub) : '';
|
|
1218
|
+
// The email ellipsizes to hold the menu's width, so the untruncated
|
|
1219
|
+
// address stays reachable on hover.
|
|
1220
|
+
emailEl.title = emailEl.textContent;
|
|
1096
1221
|
// Swap the menu body between the signed-in actions and the guest's lone
|
|
1097
1222
|
// "Sign in": a null user is an anonymous link holder.
|
|
1098
1223
|
const signedIn = user !== null;
|
|
1224
|
+
head.hidden = !signedIn;
|
|
1225
|
+
headSep.hidden = !signedIn;
|
|
1099
1226
|
emailEl.hidden = !signedIn;
|
|
1100
1227
|
dashboardLink.hidden = !signedIn;
|
|
1101
1228
|
signOutBtn.hidden = !signedIn;
|
|
1102
1229
|
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
1230
|
};
|
|
1113
1231
|
renderUser(deps.getUser());
|
|
1114
1232
|
const unsub = deps.onUserChange(renderUser);
|
|
@@ -1144,6 +1262,14 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1144
1262
|
// closes it, when the active document changes, or when no doc is open.
|
|
1145
1263
|
let browsing = false;
|
|
1146
1264
|
let lastDocId: string | null = null;
|
|
1265
|
+
// The version the reader dismissed the bar at, when the document was left
|
|
1266
|
+
// holding one. A document that cannot return to its head (one pinned to a
|
|
1267
|
+
// version) stays on it, so without this the next render reads a non-null
|
|
1268
|
+
// `doc.version` and latches the bar the reader just dismissed straight back
|
|
1269
|
+
// on. A dismissal at the live head records nothing: there is no version
|
|
1270
|
+
// left to latch on, and the head is where the next browse-in-the-app
|
|
1271
|
+
// request arrives, so a record there would swallow it.
|
|
1272
|
+
let dismissedAt: number | undefined = undefined;
|
|
1147
1273
|
|
|
1148
1274
|
const session = deps.session;
|
|
1149
1275
|
const hideBar = (): void => {
|
|
@@ -1153,27 +1279,44 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1153
1279
|
frame = null;
|
|
1154
1280
|
}
|
|
1155
1281
|
};
|
|
1282
|
+
// A failed intent reaches the reader through the bar (which reverts to the
|
|
1283
|
+
// document's real state) and the developer through `Brass.onError`. Both
|
|
1284
|
+
// halves matter: the bar moves optimistically, so a swallowed failure is a
|
|
1285
|
+
// bar naming a version nothing is showing.
|
|
1286
|
+
const attempt = async (
|
|
1287
|
+
doc: BrassDocument,
|
|
1288
|
+
act: () => void | Promise<void>,
|
|
1289
|
+
): Promise<void> => {
|
|
1290
|
+
try {
|
|
1291
|
+
await act();
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
doc._reportChromeError(err instanceof Error ? err : new Error(String(err)));
|
|
1294
|
+
throw err;
|
|
1295
|
+
}
|
|
1296
|
+
};
|
|
1156
1297
|
const frameDeps: VersionBarDeps = {
|
|
1157
1298
|
dashboardBaseUrl: deps.dashboardBaseUrl,
|
|
1158
1299
|
...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
|
|
1159
|
-
...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
|
|
1160
1300
|
// Read the live active document at call time: a bridge intent lands
|
|
1161
1301
|
// async, so it must not close over a doc that may have been superseded.
|
|
1162
|
-
|
|
1163
|
-
|
|
1302
|
+
currentVersion: () => session.document?.version ?? null,
|
|
1303
|
+
checkoutVersion: async (version) => {
|
|
1304
|
+
const doc = session.document;
|
|
1305
|
+
if (!doc) return;
|
|
1306
|
+
await attempt(doc, () => doc._checkoutVersion(version));
|
|
1164
1307
|
},
|
|
1165
|
-
returnToHead: () => {
|
|
1308
|
+
returnToHead: async () => {
|
|
1166
1309
|
// Return to the live head but keep the bar open (the browsing session
|
|
1167
1310
|
// persists); the bar re-renders in its "at latest" state with a Close
|
|
1168
1311
|
// button. Dismissal is the explicit `close` intent only.
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
// frozen / no live head: leave the preview as-is
|
|
1173
|
-
}
|
|
1312
|
+
const doc = session.document;
|
|
1313
|
+
if (!doc) return;
|
|
1314
|
+
await attempt(doc, () => doc.returnToHead());
|
|
1174
1315
|
},
|
|
1175
1316
|
fork: (fromVersion, name) => {
|
|
1176
|
-
|
|
1317
|
+
const doc = session.document;
|
|
1318
|
+
if (!doc) return;
|
|
1319
|
+
void attempt(doc, () => doc._forkFromVersion(fromVersion, name)).catch(() => {});
|
|
1177
1320
|
},
|
|
1178
1321
|
browse: () => {
|
|
1179
1322
|
void session.run('history');
|
|
@@ -1182,8 +1325,18 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1182
1325
|
// Explicit dismissal: end the browsing session and hide the bar
|
|
1183
1326
|
// synchronously so the tap is instant. The document's own browsing
|
|
1184
1327
|
// flag is cleared too, or the next render would latch the bar back on.
|
|
1328
|
+
const doc = session.document;
|
|
1185
1329
|
browsing = false;
|
|
1186
|
-
|
|
1330
|
+
dismissedAt = doc?.version ?? undefined;
|
|
1331
|
+
doc?._setVersionBrowsing(false);
|
|
1332
|
+
// Leaving the bar means leaving the preview: the bar is the only
|
|
1333
|
+
// surface that returns a document to its head, so a dismissal that
|
|
1334
|
+
// left it checked out would leave an app read-only with nothing to
|
|
1335
|
+
// clear it. A document pinned to its version has no head to return to
|
|
1336
|
+
// and keeps showing it; `dismissedAt` is what keeps the bar dismissed.
|
|
1337
|
+
if (doc && doc.version !== null) {
|
|
1338
|
+
void attempt(doc, () => doc.returnToHead()).catch(() => {});
|
|
1339
|
+
}
|
|
1187
1340
|
hideBar();
|
|
1188
1341
|
},
|
|
1189
1342
|
};
|
|
@@ -1191,21 +1344,36 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1191
1344
|
const render = (): void => {
|
|
1192
1345
|
const doc = session.document;
|
|
1193
1346
|
const docId = doc?.id ?? null;
|
|
1194
|
-
// A new active document (or none) ends any prior browsing session.
|
|
1347
|
+
// A new active document (or none) ends any prior browsing session. Keyed
|
|
1348
|
+
// on the id rather than the Document instance because a checkout keeps the
|
|
1349
|
+
// instance and only moves its `version`, so the bar follows the document
|
|
1350
|
+
// the user is stepping through rather than closing under them.
|
|
1195
1351
|
if (docId !== lastDocId) {
|
|
1196
1352
|
lastDocId = docId;
|
|
1197
1353
|
browsing = false;
|
|
1354
|
+
dismissedAt = undefined;
|
|
1198
1355
|
}
|
|
1199
1356
|
// A live checkout (`doc.version !== null`) starts / keeps the browsing
|
|
1200
1357
|
// session. Other read-only states (a view-only share link, a still-loading
|
|
1201
1358
|
// doc) are not this bar's concern, and `doc.id` is null only on the offline
|
|
1202
1359
|
// / test path the embed can't serve.
|
|
1360
|
+
// `versionBrowsing` is set by the reader asking for this surface, and the
|
|
1361
|
+
// close cleared it, so it names a fresh request whatever version the
|
|
1362
|
+
// document is on and outranks the dismissal.
|
|
1203
1363
|
if (
|
|
1204
1364
|
doc != null &&
|
|
1205
1365
|
docId !== null &&
|
|
1206
|
-
(doc.version !== null || doc.versionBrowsing)
|
|
1366
|
+
(doc.version !== null || doc.versionBrowsing) &&
|
|
1367
|
+
(doc.versionBrowsing || doc.version !== dismissedAt)
|
|
1207
1368
|
) {
|
|
1208
1369
|
browsing = true;
|
|
1370
|
+
dismissedAt = undefined;
|
|
1371
|
+
// Mirror the open bar onto the document, which is what makes it
|
|
1372
|
+
// read-only for as long as the bar is up. A preview entered by picking
|
|
1373
|
+
// a version sets only `version`, so without this the reader who then
|
|
1374
|
+
// returns to latest keeps the bar and gets the document back, which is
|
|
1375
|
+
// the pair the flag exists to keep together.
|
|
1376
|
+
doc._setVersionBrowsing(true);
|
|
1209
1377
|
}
|
|
1210
1378
|
// The bar stays open for the whole session, including at the live head
|
|
1211
1379
|
// (`doc.version === null`, browsing still true), until `close`.
|
|
@@ -1217,6 +1385,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1217
1385
|
branch: doc.branchId ?? undefined,
|
|
1218
1386
|
version: doc.version,
|
|
1219
1387
|
branchName: doc._currentBranchName() ?? undefined,
|
|
1388
|
+
pinned: doc._isVersionPinned,
|
|
1220
1389
|
});
|
|
1221
1390
|
} else {
|
|
1222
1391
|
hideBar();
|