@brass-build/client 0.1.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 +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
// `Brass.session`: the SDK-owned document session.
|
|
2
|
+
//
|
|
3
|
+
// The SDK owns the active-document lifecycle (open / create / pick / switch /
|
|
4
|
+
// version-preview / delete-reaction) and exposes a flat command list (New,
|
|
5
|
+
// Open, Version history, ..., plus app-registered commands); with a
|
|
6
|
+
// registered canvas it also auto-captures the thumbnail. The app renders the
|
|
7
|
+
// commands and draws from `session.document`. Every primitive the session
|
|
8
|
+
// calls stays public, so an app can ignore the session and wire them directly.
|
|
9
|
+
import { BrassError } from './errors.js';
|
|
10
|
+
import { makeLiveCommand } from './document-commands.js';
|
|
11
|
+
// Built-in orphan command labels (no active doc needed). Sentence case, ASCII
|
|
12
|
+
// ellipsis, per the copy-style rules. Both stay bare: an app's own word for
|
|
13
|
+
// what it holds is rarely "document" (a sketch, a model, a sheet), so a
|
|
14
|
+
// label naming it would read wrong in most of the apps this menu mounts in.
|
|
15
|
+
const SESSION_LABELS = {
|
|
16
|
+
new: 'New',
|
|
17
|
+
open: 'Open...',
|
|
18
|
+
};
|
|
19
|
+
function toError(err) {
|
|
20
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
21
|
+
}
|
|
22
|
+
// Fallback copy for a failed open with no server message to render (a
|
|
23
|
+
// network drop, no HTTP response). The HTTP case carries the server's own
|
|
24
|
+
// message instead, so the platform evolves the wording without an app change.
|
|
25
|
+
const OPEN_FAILED_MESSAGE = 'This document could not be opened';
|
|
26
|
+
// Copy parked on `session.error` when the open document is evicted remotely
|
|
27
|
+
// (a `doc_gone` the local user did not initiate). Matches the wording a
|
|
28
|
+
// post-eviction op frame degrades to ("the document is no longer available"),
|
|
29
|
+
// so a delete observed live and a fresh visit to the same deleted id read the
|
|
30
|
+
// same. A reload re-opens the id, hits the server 404, and swaps in the
|
|
31
|
+
// server-authored copy.
|
|
32
|
+
const DOCUMENT_GONE_MESSAGE = 'This document is no longer available';
|
|
33
|
+
// The error parked on `session.error` for a failed open, shaped so its
|
|
34
|
+
// `.message` is always presentable copy an app can render verbatim. An HTTP
|
|
35
|
+
// failure is returned as-is: its `.message` is the server's (non-disclosing,
|
|
36
|
+
// server-authored) copy, which is the whole point of rendering it verbatim.
|
|
37
|
+
// A non-HTTP failure has no server copy, so it falls back to a generic
|
|
38
|
+
// display string rather than leaking a raw `TypeError: Failed to fetch`.
|
|
39
|
+
function toDisplayError(err) {
|
|
40
|
+
if (err instanceof BrassError && typeof err.status === 'number' && err.status > 0) {
|
|
41
|
+
return err;
|
|
42
|
+
}
|
|
43
|
+
const status = err instanceof BrassError ? err.status ?? 0 : 0;
|
|
44
|
+
return new BrassError(OPEN_FAILED_MESSAGE, { status });
|
|
45
|
+
}
|
|
46
|
+
export function createDocumentSession(deps) {
|
|
47
|
+
let document = null;
|
|
48
|
+
let documentName = null;
|
|
49
|
+
let loading = false;
|
|
50
|
+
// Progress for an open in flight (a presigned snapshot streaming in),
|
|
51
|
+
// surfaced via `session.loadingMessage` / `loadingProgress` while
|
|
52
|
+
// `status` is 'loading' and before the Document exists.
|
|
53
|
+
let loadingMessage = null;
|
|
54
|
+
let loadingProgress = null;
|
|
55
|
+
// Bumped on every open / create / close so a slower in-flight open
|
|
56
|
+
// cannot clobber a newer one (last call wins).
|
|
57
|
+
let openGeneration = 0;
|
|
58
|
+
// The failure that put the session in the 'error' state, or null. A new
|
|
59
|
+
// open attempt clears it (at the generation bump in `activate`), so it only
|
|
60
|
+
// survives while no newer open supersedes the one that failed.
|
|
61
|
+
let openError = null;
|
|
62
|
+
// Dedups concurrent `start()` calls (an app's explicit boot call
|
|
63
|
+
// racing the auto-start the sign-in handler fires).
|
|
64
|
+
let startPromise = null;
|
|
65
|
+
// Signature of the last broadcast state; a notification is sent only when it
|
|
66
|
+
// changes, so a per-commit `doc.subscribe` that doesn't move a command's
|
|
67
|
+
// enabled state stays quiet.
|
|
68
|
+
let lastSignature = null;
|
|
69
|
+
// Outstanding `whenReady()` promises, settled in `emit()`: resolve once a
|
|
70
|
+
// document binds, reject if the user signs out before one ever does.
|
|
71
|
+
const whenReadyPending = [];
|
|
72
|
+
const listeners = new Set();
|
|
73
|
+
// Per-built-in `running` flag for orphan commands.
|
|
74
|
+
const orphanRunning = new Map();
|
|
75
|
+
let canvas = null;
|
|
76
|
+
// Per-doc useCanvas registration the session armed (so the session
|
|
77
|
+
// can detach it when the active doc swaps or useCanvas is cleared).
|
|
78
|
+
let sessionCanvasUnregister = null;
|
|
79
|
+
let defaultDocumentOptions = null;
|
|
80
|
+
// The branch the active open explicitly asked for, or null. Decides the URL
|
|
81
|
+
// branch: the default branch (no-branch-specified) is never pinned, so
|
|
82
|
+
// `?branch=` appears only for a chosen branch. `defaultBranchId` then
|
|
83
|
+
// refines it (a specified branch that turns out to be the default is
|
|
84
|
+
// dropped once known).
|
|
85
|
+
let activeSpecifiedBranch = null;
|
|
86
|
+
// Subscriptions onto the active document, dropped when it changes.
|
|
87
|
+
let docUnsub = null;
|
|
88
|
+
let goneUnsub = null;
|
|
89
|
+
// Doc ids the local user is deleting through the hosted share dialog. The
|
|
90
|
+
// dialog signals over the SDK bridge BEFORE its delete request goes out, so
|
|
91
|
+
// an id lands here ahead of the server's `doc_gone` frame (fanned out before
|
|
92
|
+
// the delete's 204). A `doc_gone` for an id in this set is the user's own
|
|
93
|
+
// delete: return to the empty state and clear the URL. Any other `doc_gone`
|
|
94
|
+
// is a remote eviction: keep the URL and park the 'error' surface. A failed
|
|
95
|
+
// delete retracts its id, so a later remote delete of the same doc is not
|
|
96
|
+
// misread as local.
|
|
97
|
+
const localDeletes = new Set();
|
|
98
|
+
function status() {
|
|
99
|
+
// 'unknown' before auth settles; the onUserChange handler re-fires
|
|
100
|
+
// (start() / emit()) and flips this to its resolved value.
|
|
101
|
+
if (deps.authStatus() === 'unknown')
|
|
102
|
+
return 'unknown';
|
|
103
|
+
// A guest (opened via `?brass_link=`) is not signed in but still has a
|
|
104
|
+
// read-only document to load, so it never reports 'signed-out' (which
|
|
105
|
+
// would render the app's sign-in surface instead of the document).
|
|
106
|
+
const isGuest = deps.guestLinkToken?.() != null;
|
|
107
|
+
if (!deps.isSignedIn() && !isGuest)
|
|
108
|
+
return 'signed-out';
|
|
109
|
+
if (loading)
|
|
110
|
+
return 'loading';
|
|
111
|
+
if (document)
|
|
112
|
+
return 'ready';
|
|
113
|
+
if (openError)
|
|
114
|
+
return 'error';
|
|
115
|
+
return 'empty';
|
|
116
|
+
}
|
|
117
|
+
function commandSig(c) {
|
|
118
|
+
let s = `${c.id}:${c.enabled ? 1 : 0}:${c.running ? 1 : 0}:${c.label}`;
|
|
119
|
+
if (c.selected)
|
|
120
|
+
s += ':S';
|
|
121
|
+
if (c.children)
|
|
122
|
+
s += `:[${c.children.map(commandSig).join(',')}]`;
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
function signature() {
|
|
126
|
+
const cmds = allCommands().map(commandSig).join(',');
|
|
127
|
+
// `loadingMessage` is in the signature so a download-progress tick
|
|
128
|
+
// re-fires subscribers; it is null outside an in-flight open.
|
|
129
|
+
const load = loading ? `${loadingMessage ?? ''}:${loadingProgress ?? ''}` : '';
|
|
130
|
+
// `openError` message joins the signature so a switch from one failed open
|
|
131
|
+
// to a different one (same 'error' status, no bound doc) still re-fires.
|
|
132
|
+
const err = openError ? `${openError.status ?? ''}:${openError.message}` : '';
|
|
133
|
+
return `${status()}|${document?.id ?? ''}|${documentName ?? ''}|${load}|${err}|${cmds}`;
|
|
134
|
+
}
|
|
135
|
+
function emit() {
|
|
136
|
+
const sig = signature();
|
|
137
|
+
if (sig === lastSignature)
|
|
138
|
+
return;
|
|
139
|
+
lastSignature = sig;
|
|
140
|
+
// Settle pending `whenReady()` promises BEFORE notifying, so an app whose
|
|
141
|
+
// subscribe handler awaits whenReady sees it already resolved by the time
|
|
142
|
+
// its listener runs.
|
|
143
|
+
settleWhenReady();
|
|
144
|
+
for (const listener of listeners) {
|
|
145
|
+
try {
|
|
146
|
+
listener(session);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
// A throwing listener must not strand the others.
|
|
150
|
+
console.error('[Brass] session listener threw:', err);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function settleWhenReady() {
|
|
155
|
+
if (whenReadyPending.length === 0)
|
|
156
|
+
return;
|
|
157
|
+
const current = status();
|
|
158
|
+
if (current === 'ready' && document) {
|
|
159
|
+
const doc = document;
|
|
160
|
+
const pending = whenReadyPending.splice(0, whenReadyPending.length);
|
|
161
|
+
for (const { resolve } of pending)
|
|
162
|
+
resolve(doc);
|
|
163
|
+
}
|
|
164
|
+
else if (current === 'signed-out') {
|
|
165
|
+
const error = new Error('Brass.session.whenReady: signed out before a document was bound.');
|
|
166
|
+
const pending = whenReadyPending.splice(0, whenReadyPending.length);
|
|
167
|
+
for (const { reject } of pending)
|
|
168
|
+
reject(error);
|
|
169
|
+
}
|
|
170
|
+
else if (current === 'error' && openError) {
|
|
171
|
+
// A user-initiated open failed terminally; reject rather than hang the
|
|
172
|
+
// awaiter forever. Same error object the session surfaces on `.error`.
|
|
173
|
+
const pending = whenReadyPending.splice(0, whenReadyPending.length);
|
|
174
|
+
for (const { reject } of pending)
|
|
175
|
+
reject(openError);
|
|
176
|
+
}
|
|
177
|
+
// No branch for 'empty': it is both the idle pre-open state (an awaiter
|
|
178
|
+
// legitimately waits for a future open) and the post-close state. A close
|
|
179
|
+
// that must reject its awaiters does so explicitly via
|
|
180
|
+
// `rejectWhenReadyPending`, so we do not blanket-reject on 'empty' here.
|
|
181
|
+
}
|
|
182
|
+
// Reject every outstanding `whenReady()` awaiter. Used by `close()`, whose
|
|
183
|
+
// 'empty' target has no `settleWhenReady` branch: an open in flight when the
|
|
184
|
+
// app closes never binds, so without this the promise hangs forever,
|
|
185
|
+
// violating the documented "never hangs" contract.
|
|
186
|
+
function rejectWhenReadyPending(message) {
|
|
187
|
+
if (whenReadyPending.length === 0)
|
|
188
|
+
return;
|
|
189
|
+
const error = new Error(message);
|
|
190
|
+
const pending = whenReadyPending.splice(0, whenReadyPending.length);
|
|
191
|
+
for (const { reject } of pending)
|
|
192
|
+
reject(error);
|
|
193
|
+
}
|
|
194
|
+
// Build a session-level (orphan) command. Same shape as Document's
|
|
195
|
+
// built-ins but runs against session state directly ('new' / 'open' need no
|
|
196
|
+
// active doc), with a session `emit` on each running flip.
|
|
197
|
+
function buildOrphanCommand(id, label, group, surface, isEnabled, impl) {
|
|
198
|
+
return makeLiveCommand({ id, label, group, surface, isEnabled, impl }, {
|
|
199
|
+
running: orphanRunning,
|
|
200
|
+
onError: (err) => deps.reportError(toError(err)),
|
|
201
|
+
notify: emit,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const cmdNew = buildOrphanCommand('new', SESSION_LABELS.new, 'session', 'menu', deps.isSignedIn, async () => {
|
|
205
|
+
await create();
|
|
206
|
+
});
|
|
207
|
+
const cmdOpen = buildOrphanCommand('open', SESSION_LABELS.open, 'session', 'menu', deps.isSignedIn, async () => {
|
|
208
|
+
await pick();
|
|
209
|
+
});
|
|
210
|
+
const orphanCommands = [cmdNew, cmdOpen];
|
|
211
|
+
// Concatenate orphan commands with the active doc's commands (whose
|
|
212
|
+
// instances carry their own `running` state; the session re-emits on the
|
|
213
|
+
// doc's `'commands'` synthetic event).
|
|
214
|
+
function allCommands() {
|
|
215
|
+
const list = [...orphanCommands];
|
|
216
|
+
if (document) {
|
|
217
|
+
for (const cmd of document.commands)
|
|
218
|
+
list.push(cmd);
|
|
219
|
+
}
|
|
220
|
+
return list;
|
|
221
|
+
}
|
|
222
|
+
// Swap the active document. Closes the previous one (so its WS subscription
|
|
223
|
+
// does not leak), drops the per-doc subscriptions, and attaches fresh ones:
|
|
224
|
+
// `doc.subscribe` so undo/redo enabled state tracks the live stack, and
|
|
225
|
+
// `doc.onGone` so a server-side delete tears the session down.
|
|
226
|
+
function setDocument(next, name, urlNav = 'skip') {
|
|
227
|
+
if (docUnsub) {
|
|
228
|
+
docUnsub();
|
|
229
|
+
docUnsub = null;
|
|
230
|
+
}
|
|
231
|
+
if (goneUnsub) {
|
|
232
|
+
goneUnsub();
|
|
233
|
+
goneUnsub = null;
|
|
234
|
+
}
|
|
235
|
+
// Detach the previous doc's session-armed useCanvas; re-armed on `next`
|
|
236
|
+
// below if a session-level canvas is still registered.
|
|
237
|
+
if (sessionCanvasUnregister) {
|
|
238
|
+
sessionCanvasUnregister();
|
|
239
|
+
sessionCanvasUnregister = null;
|
|
240
|
+
}
|
|
241
|
+
const previous = document;
|
|
242
|
+
document = next;
|
|
243
|
+
documentName = name;
|
|
244
|
+
if (next === null) {
|
|
245
|
+
activeSpecifiedBranch = null;
|
|
246
|
+
}
|
|
247
|
+
if (previous && previous !== next) {
|
|
248
|
+
try {
|
|
249
|
+
previous.close();
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// A close failure (already-closed, freed WASM) is non-fatal.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (next) {
|
|
256
|
+
// `subscribe` fires on every change including local mutations (the
|
|
257
|
+
// session's listeners drive UI that re-renders on local edits). The
|
|
258
|
+
// doc's `'commands'` / `'branches'` synthetic events also flow through,
|
|
259
|
+
// so the session re-emits on command-state changes and resolves the
|
|
260
|
+
// default branch for the URL.
|
|
261
|
+
docUnsub = next.subscribe((event) => {
|
|
262
|
+
// A `branches` event resolves `defaultBranchId`, which `urlBranch`
|
|
263
|
+
// needs to drop a specified-but-default branch; re-sync in place so
|
|
264
|
+
// the pinned branch settles once the default is known.
|
|
265
|
+
const kind = event?.kind;
|
|
266
|
+
if (kind === 'branches')
|
|
267
|
+
syncUrlForActive('replace');
|
|
268
|
+
// Take the doc's name whenever it moves. A rename reaches the doc
|
|
269
|
+
// from the server's `doc_meta` frame, which sets it directly, and
|
|
270
|
+
// `documentName` is what the emit signature reads: without this the
|
|
271
|
+
// signature is unchanged, `emit` short-circuits, and chrome
|
|
272
|
+
// rendering the name (the toolbar title) keeps the old one until
|
|
273
|
+
// something unrelated perturbs the signature.
|
|
274
|
+
if (next.name !== null)
|
|
275
|
+
documentName = next.name;
|
|
276
|
+
updatePageTitle();
|
|
277
|
+
emit();
|
|
278
|
+
});
|
|
279
|
+
goneUnsub = next.onGone((reason) => {
|
|
280
|
+
if (document !== next)
|
|
281
|
+
return;
|
|
282
|
+
const id = next.id;
|
|
283
|
+
if (id && localDeletes.delete(id)) {
|
|
284
|
+
// The user deleted this document through the hosted share dialog;
|
|
285
|
+
// return to the empty state and clear the URL.
|
|
286
|
+
close();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (reason === 'moved' && id) {
|
|
290
|
+
// A move keeps the same doc id: a cross-org/project re-home
|
|
291
|
+
// (`patchDocument`), a project merge, or the soft-delete move path
|
|
292
|
+
// all fan `doc_gone reason:'moved'` to evict the open editor, but the
|
|
293
|
+
// document still exists under a new location. Re-open the same id so
|
|
294
|
+
// the editor follows the move instead of showing a false "no longer
|
|
295
|
+
// available"; fall back to the gone surface only if the re-open fails
|
|
296
|
+
// (access was genuinely lost in the move). Detach the moved doc first
|
|
297
|
+
// so that failure parks the gone surface rather than rebinding a doc
|
|
298
|
+
// that is itself gone.
|
|
299
|
+
const branch = urlBranch(next);
|
|
300
|
+
document = null;
|
|
301
|
+
documentName = null;
|
|
302
|
+
void activate((onSnapshotProgress) => deps.openDocumentById(id, {
|
|
303
|
+
...withDefaults(),
|
|
304
|
+
...(branch ? { branch } : {}),
|
|
305
|
+
onSnapshotProgress,
|
|
306
|
+
}, 'skip'), null, 'skip', branch).catch(() => markGoneRemote());
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
// Evicted from under the viewer (remote delete, or a future eviction
|
|
310
|
+
// reason). Keep the URL and park an 'error' surface, so it reads the
|
|
311
|
+
// same as visiting the deleted id fresh rather than silently emptying.
|
|
312
|
+
markGoneRemote();
|
|
313
|
+
});
|
|
314
|
+
// Re-arm the session-level canvas (if any) onto the new doc; capture
|
|
315
|
+
// itself lives inside the Document.
|
|
316
|
+
if (canvas !== null) {
|
|
317
|
+
sessionCanvasUnregister = next.useCanvas(canvas);
|
|
318
|
+
}
|
|
319
|
+
// Prefer the caller-supplied name, else read the doc's own `name`.
|
|
320
|
+
if (name !== null)
|
|
321
|
+
next._setName(name);
|
|
322
|
+
else
|
|
323
|
+
documentName = next.name;
|
|
324
|
+
}
|
|
325
|
+
syncUrlForActive(urlNav);
|
|
326
|
+
updatePageTitle();
|
|
327
|
+
emit();
|
|
328
|
+
}
|
|
329
|
+
// The branch to carry in the URL: only a branch the user explicitly chose
|
|
330
|
+
// is pinned, so a default-branch selection reads back as a bare `?doc=`. The
|
|
331
|
+
// default is identified two ways (because `defaultBranchId` resolves only
|
|
332
|
+
// after the branches list loads): no branch was specified, or the specified
|
|
333
|
+
// one equals the now-known default. Before the list loads a specified branch
|
|
334
|
+
// is shown (so a switch still pushes a distinct entry) and dropped by the
|
|
335
|
+
// `branches` re-sync if it was the default.
|
|
336
|
+
function urlBranch(doc) {
|
|
337
|
+
const specified = activeSpecifiedBranch;
|
|
338
|
+
if (specified === null)
|
|
339
|
+
return null;
|
|
340
|
+
const defaultBranchId = doc._defaultBranchId();
|
|
341
|
+
if (defaultBranchId !== null && specified === defaultBranchId)
|
|
342
|
+
return null;
|
|
343
|
+
return specified;
|
|
344
|
+
}
|
|
345
|
+
// Reflect the active selection into the URL per `urlNav`. A null doc clears
|
|
346
|
+
// the selection; a doc with no server identity (offline / test) is left
|
|
347
|
+
// unpinned.
|
|
348
|
+
function syncUrlForActive(urlNav) {
|
|
349
|
+
if (urlNav === 'skip')
|
|
350
|
+
return;
|
|
351
|
+
if (!document) {
|
|
352
|
+
deps.clearUrlSelection();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!document.id)
|
|
356
|
+
return;
|
|
357
|
+
deps.writeUrlSelection({ docId: document.id, branchId: urlBranch(document) }, { push: urlNav === 'push' });
|
|
358
|
+
}
|
|
359
|
+
// The active branch's display name when it is NOT the default, else null.
|
|
360
|
+
// Qualifies the page title; resolves once the branch list loads and the
|
|
361
|
+
// `'branches'` event re-runs `updatePageTitle`.
|
|
362
|
+
function activeBranchName() {
|
|
363
|
+
return document?._currentBranchName() ?? null;
|
|
364
|
+
}
|
|
365
|
+
// Keep the page title on the active doc's name, qualified `Doc (branch)` on
|
|
366
|
+
// a non-default branch, or restore the original title when none is open.
|
|
367
|
+
// Prefers the doc's live `name` (tracks renames) over the seeded
|
|
368
|
+
// `documentName`, falling back while the name resolves. The app-name
|
|
369
|
+
// suffix (`Doc · App`) is applied one layer down in the client, which
|
|
370
|
+
// holds the captured original title used as the suffix.
|
|
371
|
+
function updatePageTitle() {
|
|
372
|
+
if (!document) {
|
|
373
|
+
deps.setPageTitle(null);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const base = document.name || documentName || '';
|
|
377
|
+
const branch = activeBranchName();
|
|
378
|
+
deps.setPageTitle(base && branch ? `${base} (${branch})` : base);
|
|
379
|
+
}
|
|
380
|
+
// Fetch the active doc's record once it binds, for the display name when
|
|
381
|
+
// the caller didn't supply one. Best-effort: a failed lookup leaves the
|
|
382
|
+
// name null.
|
|
383
|
+
async function resolveActiveDocRecord(doc, knownName) {
|
|
384
|
+
if (!doc.id)
|
|
385
|
+
return;
|
|
386
|
+
if (knownName !== null)
|
|
387
|
+
return;
|
|
388
|
+
try {
|
|
389
|
+
const record = await deps.documents.get(doc.id);
|
|
390
|
+
if (document !== doc)
|
|
391
|
+
return; // a newer open superseded this one
|
|
392
|
+
doc._setName(record.name);
|
|
393
|
+
documentName = record.name;
|
|
394
|
+
updatePageTitle();
|
|
395
|
+
emit();
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
// best-effort; see the doc comment above.
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Shared open path. `load` produces the document; `knownName` is the name
|
|
402
|
+
// when the caller has it (picker / create), else null and resolved in the
|
|
403
|
+
// background. `urlNav` decides the URL reflection (see `UrlNav`).
|
|
404
|
+
async function activate(load, knownName, urlNav = 'push', specifiedBranch = null,
|
|
405
|
+
// Whether a cold-open failure (no document to fall back to) parks the
|
|
406
|
+
// session in the 'error' state. On for user-initiated opens; off for the
|
|
407
|
+
// boot last-selected fallback, whose failure is a stale-persistence
|
|
408
|
+
// self-heal that should settle silently on empty (the user asked for
|
|
409
|
+
// nothing specific).
|
|
410
|
+
surfaceOpenError = true) {
|
|
411
|
+
const generation = ++openGeneration;
|
|
412
|
+
loading = true;
|
|
413
|
+
loadingMessage = null;
|
|
414
|
+
loadingProgress = null;
|
|
415
|
+
// A new open attempt supersedes any prior failure's error state.
|
|
416
|
+
openError = null;
|
|
417
|
+
// Detach (without closing) the active document for the load, so the app
|
|
418
|
+
// renders its loading surface instead of the outgoing editor sitting
|
|
419
|
+
// there stale and still editable. `document` is null and `status` is
|
|
420
|
+
// 'loading' until the new doc binds; a failed open restores the previous
|
|
421
|
+
// binding. The doc-event/gone subscriptions stay attached (`setDocument`
|
|
422
|
+
// re-points them, and the restore path watches the same doc).
|
|
423
|
+
const previousDocument = document;
|
|
424
|
+
const previousName = documentName;
|
|
425
|
+
const previousSpecifiedBranch = activeSpecifiedBranch;
|
|
426
|
+
document = null;
|
|
427
|
+
documentName = null;
|
|
428
|
+
emit();
|
|
429
|
+
// Surface snapshot-download progress. Ignored once a newer open supersedes
|
|
430
|
+
// this generation, so a slow stale download can't repaint the new open.
|
|
431
|
+
const onSnapshotProgress = (loaded, total) => {
|
|
432
|
+
if (generation !== openGeneration)
|
|
433
|
+
return;
|
|
434
|
+
const complete = total !== null && total > 0 && loaded >= total;
|
|
435
|
+
if (complete) {
|
|
436
|
+
// Bytes are in; the remaining work (loro import, projection, render)
|
|
437
|
+
// is not byte-paced, so label the phase instead of freezing at ~100%.
|
|
438
|
+
loadingProgress = null;
|
|
439
|
+
loadingMessage = 'Reading...';
|
|
440
|
+
}
|
|
441
|
+
else if (total !== null && total > 0) {
|
|
442
|
+
loadingProgress = Math.min(1, loaded / total);
|
|
443
|
+
loadingMessage = `Downloading... ${Math.floor(loadingProgress * 100)}%`;
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
loadingProgress = null;
|
|
447
|
+
loadingMessage = `Downloading... ${(loaded / 1_000_000).toFixed(1)} MB`;
|
|
448
|
+
}
|
|
449
|
+
emit();
|
|
450
|
+
};
|
|
451
|
+
let doc;
|
|
452
|
+
try {
|
|
453
|
+
doc = await load(onSnapshotProgress);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
if (generation === openGeneration) {
|
|
457
|
+
loading = false;
|
|
458
|
+
loadingMessage = null;
|
|
459
|
+
loadingProgress = null;
|
|
460
|
+
// Rebind the previous document so the user keeps their editor.
|
|
461
|
+
// 'replace' restores the URL without planting an entry for an open
|
|
462
|
+
// that never happened.
|
|
463
|
+
activeSpecifiedBranch = previousSpecifiedBranch;
|
|
464
|
+
// With no document to fall back to, a rebind lands on the empty state,
|
|
465
|
+
// which reads as the app's document-less home and hides the failure.
|
|
466
|
+
// Park the session in 'error' instead so a status-gated app renders
|
|
467
|
+
// the failure. A warm swap (previous doc present) keeps the editor and
|
|
468
|
+
// only rejects the caller, so the working surface is never clobbered.
|
|
469
|
+
if (surfaceOpenError && previousDocument === null) {
|
|
470
|
+
openError = toDisplayError(err);
|
|
471
|
+
// Leave the URL pointing at the doc that failed ('skip', not the
|
|
472
|
+
// 'replace' below that clears it for a null document): the error
|
|
473
|
+
// surface corresponds to that id, so a reload re-shows it and the
|
|
474
|
+
// id stays in the URL for a user to copy when requesting access.
|
|
475
|
+
setDocument(previousDocument, previousName, 'skip');
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
setDocument(previousDocument, previousName, 'replace');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
// A newer open / close superseded this load (and captured a null
|
|
483
|
+
// previous), so the detached document is this generation's to dispose.
|
|
484
|
+
closeQuietly(previousDocument);
|
|
485
|
+
}
|
|
486
|
+
throw err;
|
|
487
|
+
}
|
|
488
|
+
if (generation !== openGeneration) {
|
|
489
|
+
// A newer open / close superseded this one. Discard the orphan (so its
|
|
490
|
+
// WS subscription doesn't leak) plus the doc this generation detached.
|
|
491
|
+
closeQuietly(doc);
|
|
492
|
+
closeQuietly(previousDocument);
|
|
493
|
+
return doc;
|
|
494
|
+
}
|
|
495
|
+
// A doc opened mid-preparation (drive-stub hydration, upload extraction)
|
|
496
|
+
// arrives empty + read-only with a pending blocking status; the SDK
|
|
497
|
+
// imports the content and emits the terminal status when the work
|
|
498
|
+
// finishes. Hold the session in 'loading' (surfacing the server message)
|
|
499
|
+
// and bind only on that terminal status, so the editor's first paint sees
|
|
500
|
+
// the content. A pending push is non-blocking and binds immediately.
|
|
501
|
+
const work = doc.status;
|
|
502
|
+
if (work !== null && work.pending && work.blocking !== false) {
|
|
503
|
+
loadingMessage = work.message;
|
|
504
|
+
loadingProgress = work.progress ?? null;
|
|
505
|
+
emit();
|
|
506
|
+
await new Promise((resolve) => {
|
|
507
|
+
let offStatus = null;
|
|
508
|
+
let offGone = null;
|
|
509
|
+
const settle = () => {
|
|
510
|
+
offStatus?.();
|
|
511
|
+
offGone?.();
|
|
512
|
+
resolve();
|
|
513
|
+
};
|
|
514
|
+
offStatus = doc.onStatus((s) => {
|
|
515
|
+
if (generation !== openGeneration) {
|
|
516
|
+
settle();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (s.pending) {
|
|
520
|
+
loadingMessage = s.message;
|
|
521
|
+
loadingProgress = s.progress ?? null;
|
|
522
|
+
emit();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
settle();
|
|
526
|
+
});
|
|
527
|
+
// A doc deleted while preparing never reaches a terminal status; the
|
|
528
|
+
// gone signal is its terminal transition.
|
|
529
|
+
offGone = doc.onGone(() => settle());
|
|
530
|
+
});
|
|
531
|
+
if (generation !== openGeneration) {
|
|
532
|
+
closeQuietly(doc);
|
|
533
|
+
closeQuietly(previousDocument);
|
|
534
|
+
return doc;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
loading = false;
|
|
538
|
+
loadingMessage = null;
|
|
539
|
+
loadingProgress = null;
|
|
540
|
+
activeSpecifiedBranch = specifiedBranch;
|
|
541
|
+
setDocument(doc, knownName, urlNav);
|
|
542
|
+
// The outgoing doc was detached at load start (so `setDocument` saw a null
|
|
543
|
+
// previous); dispose it now the new one is bound.
|
|
544
|
+
closeQuietly(previousDocument);
|
|
545
|
+
void resolveActiveDocRecord(doc, knownName);
|
|
546
|
+
return doc;
|
|
547
|
+
}
|
|
548
|
+
// Close a document the session holds outside the active slot (a detached
|
|
549
|
+
// previous binding, a superseded open's orphan), swallowing teardown
|
|
550
|
+
// failures.
|
|
551
|
+
function closeQuietly(target) {
|
|
552
|
+
if (!target)
|
|
553
|
+
return;
|
|
554
|
+
try {
|
|
555
|
+
target.close();
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// non-fatal
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
async function doStart() {
|
|
562
|
+
// Guest boot: a `?brass_link=` token opens its document read-only,
|
|
563
|
+
// before (and instead of) the signed-in restore paths. A bad / expired
|
|
564
|
+
// token parks the 'error' state like any other failed explicit open.
|
|
565
|
+
const guestToken = deps.guestLinkToken?.();
|
|
566
|
+
if (guestToken && deps.openDocumentByLink) {
|
|
567
|
+
if (document)
|
|
568
|
+
return document;
|
|
569
|
+
const openGuest = deps.openDocumentByLink;
|
|
570
|
+
try {
|
|
571
|
+
return await activate((onSnapshotProgress) => openGuest(guestToken, { onSnapshotProgress }), null, 'skip', null);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (!deps.isSignedIn() || document)
|
|
578
|
+
return document;
|
|
579
|
+
// A pick handed back by the hosted picker's redirect fallback opens
|
|
580
|
+
// before the ordinary URL / last-selection restore, and independently of
|
|
581
|
+
// `syncDocToUrl`. `takePickSelection` strips the one-shot params as it
|
|
582
|
+
// reads them, so a reload lands on the ordinary restore, not the pick.
|
|
583
|
+
const pick = deps.takePickSelection();
|
|
584
|
+
const sel = pick ?? deps.readUrlSelection();
|
|
585
|
+
if (sel) {
|
|
586
|
+
try {
|
|
587
|
+
// 'replace': a boot restore pins the URL selection in place, not a
|
|
588
|
+
// navigation, so it must not plant a history entry.
|
|
589
|
+
return await activate((onSnapshotProgress) => deps.openDocumentById(sel.docId, {
|
|
590
|
+
...(sel.branchId ? { branch: sel.branchId } : {}),
|
|
591
|
+
onSnapshotProgress,
|
|
592
|
+
}, 'skip'), null, 'replace', sel.branchId);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// The user navigated to a specific `?doc=` and it could not be
|
|
596
|
+
// opened. `activate` has parked the session in the 'error' state and
|
|
597
|
+
// left the id in the URL (so the error corresponds to it and a reload
|
|
598
|
+
// re-shows it); return rather than fall through to a different
|
|
599
|
+
// last-selected doc, which would silently swap them onto content they
|
|
600
|
+
// did not ask for and hide the failure.
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const last = deps.lastSelectedDocument();
|
|
605
|
+
if (last) {
|
|
606
|
+
try {
|
|
607
|
+
return await activate((onSnapshotProgress) => deps.openDocumentById(last.doc_id, { onSnapshotProgress }, 'skip'), last.name, 'replace', null,
|
|
608
|
+
// A stale last-selection is a persistence self-heal, not a request
|
|
609
|
+
// the user made; settle silently on empty rather than parking an
|
|
610
|
+
// error.
|
|
611
|
+
false);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// Stale last-selection; settle on the empty state.
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
function start() {
|
|
620
|
+
if (startPromise)
|
|
621
|
+
return startPromise;
|
|
622
|
+
startPromise = doStart().finally(() => {
|
|
623
|
+
startPromise = null;
|
|
624
|
+
// Notify once the boot restore settles. The signed-out -> empty
|
|
625
|
+
// transition (signed in, no URL document) takes neither `activate`
|
|
626
|
+
// branch, so without this emit the `session.subscribe(render)` boot
|
|
627
|
+
// path would never hear that sign-in completed.
|
|
628
|
+
emit();
|
|
629
|
+
});
|
|
630
|
+
return startPromise;
|
|
631
|
+
}
|
|
632
|
+
function open(docId, options) {
|
|
633
|
+
return activate((onSnapshotProgress) => deps.openDocumentById(docId, {
|
|
634
|
+
...withDefaults(options),
|
|
635
|
+
onSnapshotProgress,
|
|
636
|
+
}), null, 'push', options?.branch ?? null);
|
|
637
|
+
}
|
|
638
|
+
async function create(options) {
|
|
639
|
+
// Placement is expressed by context, never an org this layer names.
|
|
640
|
+
// Create-in-place: a New doc anchors on the active one via `sibling_of`,
|
|
641
|
+
// resolved server-side (its project, or loose in its org). With no active
|
|
642
|
+
// doc the create runs cold and the server tells us if the caller must
|
|
643
|
+
// choose a destination.
|
|
644
|
+
// The wire always carries a name so the server's untitled-numbering has
|
|
645
|
+
// something to key on; `knownName` stays null when the caller gave none,
|
|
646
|
+
// so the stored "Untitled N" is adopted once the record binds.
|
|
647
|
+
const base = { name: options?.name ?? 'Untitled' };
|
|
648
|
+
if (options?.app_metadata !== undefined)
|
|
649
|
+
base.app_metadata = options.app_metadata;
|
|
650
|
+
if (document?.id)
|
|
651
|
+
base.siblingOf = document.id;
|
|
652
|
+
// Resolve to a docId BEFORE the loading state starts, so the hosted
|
|
653
|
+
// picker / acquire surface opens over the empty state, not a loading
|
|
654
|
+
// one. The server owns the single-vs-multi decision: a `needs-*` outcome
|
|
655
|
+
// is where we open the corresponding hosted surface and retry with the
|
|
656
|
+
// opaque destination it returns, relayed verbatim.
|
|
657
|
+
let args = base;
|
|
658
|
+
let knownName = options?.name ?? null;
|
|
659
|
+
let result;
|
|
660
|
+
for (;;) {
|
|
661
|
+
result = await deps.createDocumentId(args);
|
|
662
|
+
if (result.kind === 'created')
|
|
663
|
+
break;
|
|
664
|
+
if (result.kind === 'needs-organization') {
|
|
665
|
+
const acquired = await deps.acquireOrganization();
|
|
666
|
+
if (!acquired)
|
|
667
|
+
return null;
|
|
668
|
+
args = { ...args, destination: { organization_id: acquired.organization_id } };
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
// needs-destination
|
|
672
|
+
const destination = await deps.selectCreateDestination(options?.name !== undefined ? { name: options.name } : undefined);
|
|
673
|
+
if (destination === null)
|
|
674
|
+
return null;
|
|
675
|
+
// The dialog's final name wins on both the wire and the display: the
|
|
676
|
+
// user saw and could edit it.
|
|
677
|
+
args = { ...args, destination, name: destination.name };
|
|
678
|
+
knownName = destination.name ?? knownName;
|
|
679
|
+
}
|
|
680
|
+
const docId = result.docId;
|
|
681
|
+
const openOptions = options?.documentOptions
|
|
682
|
+
? { documentOptions: options.documentOptions }
|
|
683
|
+
: undefined;
|
|
684
|
+
// Pass the known name only when the caller or the picker supplied one;
|
|
685
|
+
// with no name the server owns it (resolving the next free "Untitled N"),
|
|
686
|
+
// and a null `knownName` is what lets `resolveActiveDocRecord` adopt the
|
|
687
|
+
// stored name once the record binds.
|
|
688
|
+
return activate((onSnapshotProgress) => deps.openDocumentById(docId, {
|
|
689
|
+
...withDefaults(openOptions),
|
|
690
|
+
onSnapshotProgress,
|
|
691
|
+
}), knownName);
|
|
692
|
+
}
|
|
693
|
+
async function pick() {
|
|
694
|
+
const result = await deps.selectDocument();
|
|
695
|
+
if (!result)
|
|
696
|
+
return null;
|
|
697
|
+
const opts = {
|
|
698
|
+
...withDefaults(),
|
|
699
|
+
...(result.branch_id ? { branch: result.branch_id } : {}),
|
|
700
|
+
};
|
|
701
|
+
return activate((onSnapshotProgress) => deps.openDocumentById(result.doc_id, { ...opts, onSnapshotProgress }), result.name, 'push', result.branch_id ?? null);
|
|
702
|
+
}
|
|
703
|
+
// Merge `defaultDocumentOptions` into the caller's options: caller values
|
|
704
|
+
// win per-field, the default fills gaps, and nested `undo` is shallow-merged
|
|
705
|
+
// so a partial override composes. Returns undefined when neither side has
|
|
706
|
+
// anything.
|
|
707
|
+
function withDefaults(options) {
|
|
708
|
+
const docDefaults = session._defaultDocumentOptions;
|
|
709
|
+
if (!docDefaults)
|
|
710
|
+
return options;
|
|
711
|
+
const callerDocOpts = options?.documentOptions;
|
|
712
|
+
const mergedDocOpts = {
|
|
713
|
+
...docDefaults,
|
|
714
|
+
...callerDocOpts,
|
|
715
|
+
};
|
|
716
|
+
// Shallow-merge `undo` so a partial override composes. (The explicit
|
|
717
|
+
// branch works around exactOptionalPropertyTypes' finicky
|
|
718
|
+
// `undo?: T | undefined` -> `undo?: T` narrowing.)
|
|
719
|
+
const mergedUndo = callerDocOpts?.undo
|
|
720
|
+
? { ...docDefaults.undo, ...callerDocOpts.undo }
|
|
721
|
+
: docDefaults.undo;
|
|
722
|
+
if (mergedUndo)
|
|
723
|
+
mergedDocOpts.undo = mergedUndo;
|
|
724
|
+
return {
|
|
725
|
+
...options,
|
|
726
|
+
documentOptions: mergedDocOpts,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
function close() {
|
|
730
|
+
// Bump the generation so an in-flight open can't resurrect a document
|
|
731
|
+
// after an explicit close.
|
|
732
|
+
openGeneration++;
|
|
733
|
+
loading = false;
|
|
734
|
+
// A deliberate close returns to the empty state, clearing any parked open
|
|
735
|
+
// failure (an app dismissing the error surface calls this).
|
|
736
|
+
openError = null;
|
|
737
|
+
// Reject awaiters of an open that was in flight at close time: 'empty' has
|
|
738
|
+
// no settleWhenReady branch, so they would otherwise never settle.
|
|
739
|
+
rejectWhenReadyPending('Brass.session.whenReady: closed before a document was bound.');
|
|
740
|
+
// 'replace': clears the URL selection and restores the title without an
|
|
741
|
+
// entry.
|
|
742
|
+
setDocument(null, null, 'replace');
|
|
743
|
+
}
|
|
744
|
+
// The active document was evicted remotely (a `doc_gone` the user did not
|
|
745
|
+
// initiate). Park the 'error' state with the gone message and leave the URL
|
|
746
|
+
// pointing at the now-deleted id ('skip', not the 'replace' that would clear
|
|
747
|
+
// it): the error surface corresponds to that id, so a reload re-shows it and
|
|
748
|
+
// the id stays in the URL. Mirrors the failed-open error path in `activate`.
|
|
749
|
+
function markGoneRemote() {
|
|
750
|
+
openGeneration++;
|
|
751
|
+
loading = false;
|
|
752
|
+
openError = new BrassError(DOCUMENT_GONE_MESSAGE, { status: 404 });
|
|
753
|
+
setDocument(null, null, 'skip');
|
|
754
|
+
}
|
|
755
|
+
// Browser Back / Forward moved the URL. Re-bind to the selection the URL now
|
|
756
|
+
// names WITHOUT writing history again (the entry exists, so 'skip'). Only
|
|
757
|
+
// user navigation fires `popstate`; the SDK's own push / replace do not, so
|
|
758
|
+
// this can't feed back into another write.
|
|
759
|
+
function handlePopState() {
|
|
760
|
+
if (deps.authStatus() === 'unknown' || !deps.isSignedIn())
|
|
761
|
+
return;
|
|
762
|
+
const sel = deps.readUrlSelection();
|
|
763
|
+
if (!sel) {
|
|
764
|
+
// Back / Forward landed on a bare URL: return to the empty state ('skip'
|
|
765
|
+
// since the URL is already bare). Reset when a document OR a parked open
|
|
766
|
+
// error is showing: a Forward into a now-deleted doc parks 'error' with a
|
|
767
|
+
// null document, and a Back off it must clear the error and land on empty
|
|
768
|
+
// rather than stranding the stale error surface over a bare URL.
|
|
769
|
+
if (document || openError) {
|
|
770
|
+
openGeneration++;
|
|
771
|
+
loading = false;
|
|
772
|
+
openError = null;
|
|
773
|
+
setDocument(null, null, 'skip');
|
|
774
|
+
}
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const current = document;
|
|
778
|
+
if (current &&
|
|
779
|
+
current.id === sel.docId &&
|
|
780
|
+
urlBranch(current) === sel.branchId) {
|
|
781
|
+
return; // already showing this selection
|
|
782
|
+
}
|
|
783
|
+
void activate((onSnapshotProgress) => deps.openDocumentById(sel.docId, {
|
|
784
|
+
...withDefaults(),
|
|
785
|
+
...(sel.branchId ? { branch: sel.branchId } : {}),
|
|
786
|
+
onSnapshotProgress,
|
|
787
|
+
}, 'skip'), null, 'skip', sel.branchId).catch(() => {
|
|
788
|
+
// Back / Forward to a doc that can't be opened parks the 'error' state
|
|
789
|
+
// (or, warm, keeps the current doc); the URL the user navigated to is
|
|
790
|
+
// left as-is, so the caught rejection needs no cleanup here.
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
deps.onUrlPopState(handlePopState);
|
|
794
|
+
// Sign-out (explicit, token expiry, or the boot resolution to signed-out)
|
|
795
|
+
// drops the active document but PRESERVES the URL's `?doc=` selection. A
|
|
796
|
+
// deep-linked visitor lands signed-out on `?doc=<id>`; `Brass.signIn()`
|
|
797
|
+
// carries `window.location.href` as its return_to, so the id must survive
|
|
798
|
+
// in the URL until that click, and `start()` restores it after sign-in.
|
|
799
|
+
// A user-initiated `close()` clears the URL (return to the empty state);
|
|
800
|
+
// sign-out must not, or the deep link is lost before the round-trip.
|
|
801
|
+
function teardownForSignOut() {
|
|
802
|
+
openGeneration++;
|
|
803
|
+
loading = false;
|
|
804
|
+
openError = null;
|
|
805
|
+
setDocument(null, null, 'skip');
|
|
806
|
+
}
|
|
807
|
+
// Sign-out drops the document; the first sign-in restores it. A guest
|
|
808
|
+
// (opened via `?brass_link=`) is never signed in, so the initial
|
|
809
|
+
// resolution arrives here with `signedIn === false`; it must boot the
|
|
810
|
+
// read-only link document rather than tear down to the sign-in surface.
|
|
811
|
+
deps.onUserChange((signedIn) => {
|
|
812
|
+
if (signedIn || deps.guestLinkToken?.() != null) {
|
|
813
|
+
if (!document)
|
|
814
|
+
void start();
|
|
815
|
+
else
|
|
816
|
+
emit();
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
teardownForSignOut();
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
const session = {
|
|
823
|
+
get document() {
|
|
824
|
+
return document;
|
|
825
|
+
},
|
|
826
|
+
get documentName() {
|
|
827
|
+
return documentName;
|
|
828
|
+
},
|
|
829
|
+
get status() {
|
|
830
|
+
return status();
|
|
831
|
+
},
|
|
832
|
+
get error() {
|
|
833
|
+
return status() === 'error' ? openError : null;
|
|
834
|
+
},
|
|
835
|
+
get loadingMessage() {
|
|
836
|
+
return loading ? loadingMessage : null;
|
|
837
|
+
},
|
|
838
|
+
get loadingProgress() {
|
|
839
|
+
return loading ? loadingProgress : null;
|
|
840
|
+
},
|
|
841
|
+
get commands() {
|
|
842
|
+
return allCommands();
|
|
843
|
+
},
|
|
844
|
+
get presence() {
|
|
845
|
+
return document?.presence ?? null;
|
|
846
|
+
},
|
|
847
|
+
subscribe(listener) {
|
|
848
|
+
listeners.add(listener);
|
|
849
|
+
try {
|
|
850
|
+
listener(session);
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
console.error('[Brass] session listener threw:', err);
|
|
854
|
+
}
|
|
855
|
+
return () => {
|
|
856
|
+
listeners.delete(listener);
|
|
857
|
+
};
|
|
858
|
+
},
|
|
859
|
+
start,
|
|
860
|
+
open,
|
|
861
|
+
create,
|
|
862
|
+
pick,
|
|
863
|
+
close,
|
|
864
|
+
async run(commandId) {
|
|
865
|
+
const command = allCommands().find((c) => c.id === commandId);
|
|
866
|
+
if (command)
|
|
867
|
+
await command.run();
|
|
868
|
+
},
|
|
869
|
+
useCanvas(source) {
|
|
870
|
+
// A forwarder: store the canvas at session scope and re-arm it on the
|
|
871
|
+
// active Document each time one becomes active. Apps can also call
|
|
872
|
+
// `doc.useCanvas(canvas)` directly for per-doc control.
|
|
873
|
+
canvas = source;
|
|
874
|
+
// Detach any previous session-armed registration, then arm the active
|
|
875
|
+
// doc.
|
|
876
|
+
if (sessionCanvasUnregister)
|
|
877
|
+
sessionCanvasUnregister();
|
|
878
|
+
sessionCanvasUnregister = document
|
|
879
|
+
? document.useCanvas(source)
|
|
880
|
+
: null;
|
|
881
|
+
return () => {
|
|
882
|
+
if (canvas === source) {
|
|
883
|
+
canvas = null;
|
|
884
|
+
if (sessionCanvasUnregister)
|
|
885
|
+
sessionCanvasUnregister();
|
|
886
|
+
sessionCanvasUnregister = null;
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
},
|
|
890
|
+
whenReady() {
|
|
891
|
+
if (document && status() === 'ready') {
|
|
892
|
+
return Promise.resolve(document);
|
|
893
|
+
}
|
|
894
|
+
// Mirror `status()` (not raw `isSignedIn()`): a link guest is not
|
|
895
|
+
// signed in yet legitimately has a read-only document loading, so only
|
|
896
|
+
// a genuine 'signed-out' rejects here. This is the same terminal test
|
|
897
|
+
// the async `settleWhenReady` path uses; a raw `!isSignedIn()` check
|
|
898
|
+
// would reject every guest before its document binds.
|
|
899
|
+
if (status() === 'signed-out') {
|
|
900
|
+
return Promise.reject(new Error('Brass.session.whenReady: signed out before a document was bound.'));
|
|
901
|
+
}
|
|
902
|
+
// Already-terminal open failure: reject now rather than wait for a bind
|
|
903
|
+
// that isn't coming.
|
|
904
|
+
if (status() === 'error' && openError) {
|
|
905
|
+
return Promise.reject(openError);
|
|
906
|
+
}
|
|
907
|
+
return new Promise((resolve, reject) => {
|
|
908
|
+
whenReadyPending.push({ resolve, reject });
|
|
909
|
+
});
|
|
910
|
+
},
|
|
911
|
+
setDefaultDocumentOptions(options) {
|
|
912
|
+
defaultDocumentOptions = options;
|
|
913
|
+
},
|
|
914
|
+
get _defaultDocumentOptions() {
|
|
915
|
+
return defaultDocumentOptions;
|
|
916
|
+
},
|
|
917
|
+
_noteLocalDelete(docId) {
|
|
918
|
+
localDeletes.add(docId);
|
|
919
|
+
},
|
|
920
|
+
_clearLocalDelete(docId) {
|
|
921
|
+
localDeletes.delete(docId);
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
return session;
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=session.js.map
|