@jbrowse/app-core 4.3.0 → 5.0.0-beta.10
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/esm/AppSession/AppRootModel.d.ts +30 -0
- package/esm/AppSession/AppRootModel.js +1 -0
- package/esm/AppSession/AppSessionMixin.d.ts +1324 -0
- package/esm/AppSession/AppSessionMixin.js +62 -0
- package/esm/AppSession/index.d.ts +2 -0
- package/esm/AppSession/index.js +1 -0
- package/esm/Assemblies/AssembliesMixin.d.ts +294 -0
- package/esm/Assemblies/AssembliesMixin.js +154 -0
- package/esm/Assemblies/index.d.ts +1 -2
- package/esm/Assemblies/index.js +1 -2
- package/esm/HistoryManagement/index.d.ts +11 -0
- package/esm/HistoryManagement/index.js +43 -10
- package/esm/JBrowseConfig/RootConfiguration.d.ts +123 -78
- package/esm/JBrowseConfig/RootConfiguration.js +79 -7
- package/esm/JBrowseConfig/defaultShareUrl.d.ts +1 -0
- package/esm/JBrowseConfig/defaultShareUrl.js +5 -0
- package/esm/JBrowseConfig/index.d.ts +175 -84
- package/esm/JBrowseConfig/index.js +64 -2
- package/esm/JBrowseModel/index.d.ts +175 -97
- package/esm/JBrowseModel/index.js +132 -31
- package/esm/JbApi/index.d.ts +2 -0
- package/esm/JbApi/index.js +1 -0
- package/esm/JbApi/jbApi.d.ts +2767 -0
- package/esm/JbApi/jbApi.js +1383 -0
- package/esm/RootMenu/index.d.ts +69 -3
- package/esm/RootMenu/index.js +96 -61
- package/esm/SessionSpec/index.d.ts +6 -0
- package/esm/SessionSpec/index.js +3 -0
- package/esm/SessionSpec/lgvUrlInit.d.ts +79 -0
- package/esm/SessionSpec/lgvUrlInit.js +161 -0
- package/esm/SessionSpec/loadSessionSpec.d.ts +75 -0
- package/esm/SessionSpec/loadSessionSpec.js +473 -0
- package/esm/SessionSpec/parseSessionSpecUrl.d.ts +7 -0
- package/esm/SessionSpec/parseSessionSpecUrl.js +181 -0
- package/esm/SessionSpec/types.d.ts +37 -0
- package/esm/SessionSpec/types.js +1 -0
- package/esm/WorkspaceLayout/LayoutRenderer.d.ts +24 -0
- package/esm/WorkspaceLayout/LayoutRenderer.js +185 -0
- package/esm/WorkspaceLayout/PanelView.d.ts +13 -0
- package/esm/WorkspaceLayout/PanelView.js +99 -0
- package/esm/WorkspaceLayout/TabStrip.d.ts +14 -0
- package/esm/WorkspaceLayout/TabStrip.js +226 -0
- package/esm/WorkspaceLayout/WorkspaceContainer.d.ts +16 -0
- package/esm/WorkspaceLayout/WorkspaceContainer.js +107 -0
- package/esm/WorkspaceLayout/WorkspacePanelActions.d.ts +24 -0
- package/esm/WorkspaceLayout/WorkspacePanelActions.js +120 -0
- package/esm/WorkspaceLayout/WorkspaceTab.d.ts +27 -0
- package/esm/WorkspaceLayout/WorkspaceTab.js +105 -0
- package/esm/WorkspaceLayout/dockviewTheme.d.ts +69 -0
- package/esm/WorkspaceLayout/dockviewTheme.js +82 -0
- package/esm/WorkspaceLayout/dropZone.d.ts +105 -0
- package/esm/WorkspaceLayout/dropZone.js +113 -0
- package/esm/WorkspaceLayout/index.d.ts +8 -0
- package/esm/WorkspaceLayout/index.js +4 -0
- package/esm/WorkspaceLayout/model.d.ts +260 -0
- package/esm/WorkspaceLayout/model.js +586 -0
- package/esm/WorkspaceLayout/panelChrome.d.ts +19 -0
- package/esm/WorkspaceLayout/panelChrome.js +4 -0
- package/esm/WorkspaceLayout/spec.d.ts +88 -0
- package/esm/WorkspaceLayout/spec.js +226 -0
- package/esm/WorkspaceLayout/splitter.d.ts +25 -0
- package/esm/WorkspaceLayout/splitter.js +37 -0
- package/esm/WorkspaceLayout/tree.d.ts +158 -0
- package/esm/WorkspaceLayout/tree.js +379 -0
- package/esm/WorkspaceLayout/useLayoutDrag.d.ts +34 -0
- package/esm/WorkspaceLayout/useLayoutDrag.js +232 -0
- package/esm/index.d.ts +18 -9
- package/esm/index.js +16 -9
- package/esm/menus.d.ts +55 -68
- package/esm/menus.js +117 -86
- package/esm/queryParams.d.ts +9 -0
- package/esm/queryParams.js +93 -0
- package/esm/sessionModelFactory.d.ts +18 -0
- package/esm/sessionModelFactory.js +1 -0
- package/esm/ui/App/App.d.ts +3 -14
- package/esm/ui/App/App.js +39 -19
- package/esm/ui/App/AppFab.d.ts +12 -3
- package/esm/ui/App/AppFab.js +15 -8
- package/esm/ui/App/AppToolbar.d.ts +2 -14
- package/esm/ui/App/AppToolbar.js +45 -31
- package/esm/ui/App/ClassicViewsContainer.d.ts +4 -10
- package/esm/ui/App/ClassicViewsContainer.js +9 -8
- package/esm/ui/App/DialogQueue.d.ts +17 -1
- package/esm/ui/App/DialogQueue.js +30 -1
- package/esm/ui/App/Drawer.d.ts +1 -1
- package/esm/ui/App/Drawer.js +10 -39
- package/esm/ui/App/DrawerHeader.d.ts +2 -3
- package/esm/ui/App/DrawerHeader.js +24 -11
- package/esm/ui/App/DrawerHeaderHelpButton.d.ts +1 -1
- package/esm/ui/App/DrawerHeaderHelpButton.js +3 -3
- package/esm/ui/App/DrawerHeaderHelpDialog.d.ts +2 -2
- package/esm/ui/App/DrawerHeaderHelpDialog.js +3 -4
- package/esm/ui/App/DrawerWidget.d.ts +1 -1
- package/esm/ui/App/DrawerWidget.js +9 -21
- package/esm/ui/App/JBrowseTabMenu.d.ts +2 -3
- package/esm/ui/App/JBrowseTabMenu.js +24 -5
- package/esm/ui/App/ThemeModeButton.d.ts +12 -0
- package/esm/ui/App/ThemeModeButton.js +34 -0
- package/esm/ui/App/ViewButtons.d.ts +2 -4
- package/esm/ui/App/ViewButtons.js +15 -3
- package/esm/ui/App/ViewContainer.d.ts +3 -2
- package/esm/ui/App/ViewContainer.js +128 -24
- package/esm/ui/App/ViewContainerTitle.d.ts +1 -1
- package/esm/ui/App/ViewContainerTitle.js +21 -11
- package/esm/ui/App/ViewErrorFallback.d.ts +18 -0
- package/esm/ui/App/ViewErrorFallback.js +42 -0
- package/esm/ui/App/ViewHeader.d.ts +3 -4
- package/esm/ui/App/ViewHeader.js +28 -17
- package/esm/ui/App/ViewLauncher.d.ts +2 -8
- package/esm/ui/App/ViewLauncher.js +7 -5
- package/esm/ui/App/ViewMenu.d.ts +3 -5
- package/esm/ui/App/ViewMenu.js +87 -92
- package/esm/ui/App/ViewStack.d.ts +7 -0
- package/esm/ui/App/ViewStack.js +48 -0
- package/esm/ui/App/ViewWrapper.d.ts +15 -1
- package/esm/ui/App/ViewWrapper.js +26 -10
- package/esm/ui/App/ViewsContainer.d.ts +4 -11
- package/esm/ui/App/ViewsContainer.js +6 -9
- package/esm/ui/App/index.d.ts +2 -1
- package/esm/ui/App/index.js +3 -2
- package/esm/ui/App/lazyChunk.d.ts +13 -0
- package/esm/ui/App/lazyChunk.js +91 -0
- package/esm/ui/App/lazyParts.d.ts +32 -0
- package/esm/ui/App/lazyParts.js +8 -0
- package/esm/ui/App/preloadAppShell.d.ts +14 -0
- package/esm/ui/App/preloadAppShell.js +41 -0
- package/esm/ui/App/types.d.ts +17 -3
- package/esm/ui/App/types.js +1 -1
- package/esm/ui/App/useViewVisibility.d.ts +35 -0
- package/esm/ui/App/useViewVisibility.js +88 -0
- package/esm/ui/App/viewTitle.d.ts +12 -0
- package/esm/ui/App/viewTitle.js +17 -0
- package/esm/ui/index.d.ts +1 -1
- package/esm/ui/index.js +1 -1
- package/package.json +12 -17
- package/esm/AppFocus/index.d.ts +0 -5
- package/esm/AppFocus/index.js +0 -12
- package/esm/Assemblies/SessionAssembliesMixin.d.ts +0 -92
- package/esm/Assemblies/SessionAssembliesMixin.js +0 -53
- package/esm/Assemblies/TemporaryAssembliesMixin.d.ts +0 -10
- package/esm/Assemblies/TemporaryAssembliesMixin.js +0 -37
- package/esm/DockviewLayout/index.d.ts +0 -35
- package/esm/DockviewLayout/index.js +0 -111
- package/esm/ui/App/DockviewContext.d.ts +0 -22
- package/esm/ui/App/DockviewContext.js +0 -29
- package/esm/ui/App/DockviewLeftHeaderActions.d.ts +0 -2
- package/esm/ui/App/DockviewLeftHeaderActions.js +0 -58
- package/esm/ui/App/DockviewRightHeaderActions.d.ts +0 -2
- package/esm/ui/App/DockviewRightHeaderActions.js +0 -100
- package/esm/ui/App/DrawerControls.d.ts +0 -5
- package/esm/ui/App/DrawerControls.js +0 -25
- package/esm/ui/App/DrawerWidgetSelector.d.ts +0 -5
- package/esm/ui/App/DrawerWidgetSelector.js +0 -47
- package/esm/ui/App/JBrowseViewPanel.d.ts +0 -8
- package/esm/ui/App/JBrowseViewPanel.js +0 -40
- package/esm/ui/App/JBrowseViewTab.d.ts +0 -8
- package/esm/ui/App/JBrowseViewTab.js +0 -97
- package/esm/ui/App/ModalWidget.d.ts +0 -6
- package/esm/ui/App/ModalWidget.js +0 -48
- package/esm/ui/App/TiledViewsContainer.d.ts +0 -6
- package/esm/ui/App/TiledViewsContainer.js +0 -366
- package/esm/ui/App/copyView.d.ts +0 -1
- package/esm/ui/App/copyView.js +0 -29
- package/esm/ui/App/dockviewUtils.d.ts +0 -46
- package/esm/ui/App/dockviewUtils.js +0 -55
|
@@ -0,0 +1,1383 @@
|
|
|
1
|
+
import { getReExportRegistry, setReExportRegistry, } from '@jbrowse/core/ReExports/registry';
|
|
2
|
+
import { getConf, getConfigurationSchemaDefinition, getConfigurationSchemaMetadata, isSlotDefinitionEntry, readConfObject, shorthandTargets, } from '@jbrowse/core/configuration';
|
|
3
|
+
import { releaseAdapterSession, retainAdapterSession, } from '@jbrowse/core/data_adapters/adapterSessionRefcount';
|
|
4
|
+
import { adapterByteLimit } from '@jbrowse/core/rpc/byteBudget';
|
|
5
|
+
import { isElectron, isSessionWithAddSessionTrack, objectHash, parseLocString, sum, } from '@jbrowse/core/util';
|
|
6
|
+
import { scaleBandHeights } from '@jbrowse/core/util/bandHeight';
|
|
7
|
+
import { openTracks, openViews, viewAndNested, } from '@jbrowse/core/util/openViews';
|
|
8
|
+
import { allSessionTracks, getConfAssemblyNamesOrNone, getConfRpcSessionId, guessTrackConfForLocation, isSameAssemblyName, viewCanDisplayTrack, viewDisplayNames, } from '@jbrowse/core/util/tracks';
|
|
9
|
+
import { unknownKeysMessage } from '@jbrowse/core/util/withLaunchInput';
|
|
10
|
+
import * as mst from '@jbrowse/mobx-state-tree';
|
|
11
|
+
import { applySnapshot, getSnapshot, getType, isStateTreeNode, } from '@jbrowse/mobx-state-tree';
|
|
12
|
+
import * as mobx from 'mobx';
|
|
13
|
+
// relative, not '@jbrowse/app-core': a package self-import would make this
|
|
14
|
+
// module depend on the barrel that exports it
|
|
15
|
+
import { launchSpecView, launchableSpecView, loadSessionSpec, viewTypeProblem, } from '../SessionSpec/index.js';
|
|
16
|
+
// Matches BaseTrackModel's DEFAULT_EXPORT_BYTE_LIMIT: both bound a fetch
|
|
17
|
+
// someone asked for by name rather than one a view scrolled into, so they
|
|
18
|
+
// should refuse at the same size.
|
|
19
|
+
const DEFAULT_AGENT_BYTE_LIMIT = 5_000_000;
|
|
20
|
+
/**
|
|
21
|
+
* What jb.getFeatures may pull before it refuses. An explicit request wins so
|
|
22
|
+
* an agent can mean it; otherwise the adapter's own declared limit, so this
|
|
23
|
+
* does not disagree with the size the track's display already refuses to
|
|
24
|
+
* render; otherwise the default.
|
|
25
|
+
*/
|
|
26
|
+
export function agentByteLimit(declaredFetchSizeLimit, requested) {
|
|
27
|
+
return (requested ??
|
|
28
|
+
adapterByteLimit(declaredFetchSizeLimit, DEFAULT_AGENT_BYTE_LIMIT));
|
|
29
|
+
}
|
|
30
|
+
function viewSelf(view) {
|
|
31
|
+
return view;
|
|
32
|
+
}
|
|
33
|
+
// The census helpers over the views' own `ownViews`/`ownTracks`, so this module
|
|
34
|
+
// carries no copy of the view nesting: a synteny stack's rows and per-band
|
|
35
|
+
// containers, a breakpoint split view's panels, and any depth of nesting all
|
|
36
|
+
// arrive through each view declaring what it holds.
|
|
37
|
+
function viewTracks(view) {
|
|
38
|
+
return view.ownTracks;
|
|
39
|
+
}
|
|
40
|
+
function allTracks(session) {
|
|
41
|
+
return openTracks(session);
|
|
42
|
+
}
|
|
43
|
+
// The drawn display's on-screen state. `displayPhase` is the tree's own
|
|
44
|
+
// single-sourced vocabulary (render-core/displayPhase.ts) and the same one
|
|
45
|
+
// AppReadyMarker reads; `tooLarge` and `renderError` replace the display
|
|
46
|
+
// subtree rather than raising a snackbar, so a settle reporting only
|
|
47
|
+
// notifications calls them ready.
|
|
48
|
+
function displayState(track) {
|
|
49
|
+
const display = track.activeDisplay;
|
|
50
|
+
if (!display) {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const phase = display.displayPhase;
|
|
55
|
+
return {
|
|
56
|
+
display: display.type,
|
|
57
|
+
// "make it all fit in the window" is a normal thing to be asked, and
|
|
58
|
+
// without this the only way to answer it is measuring DOM rectangles
|
|
59
|
+
...(typeof display.height === 'number' ? { height: display.height } : {}),
|
|
60
|
+
...(phase === undefined || phase === 'ready' ? {} : { phase }),
|
|
61
|
+
...(phase === 'tooLarge' && display.regionTooLargeReason
|
|
62
|
+
? { reason: display.regionTooLargeReason }
|
|
63
|
+
: {}),
|
|
64
|
+
...(display.error ? { error: String(display.error) } : {}),
|
|
65
|
+
...(display.renderError
|
|
66
|
+
? { renderError: String(display.renderError) }
|
|
67
|
+
: {}),
|
|
68
|
+
...(display.notices?.length ? { notices: [...display.notices] } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
return { display: display.type, error: String(e) };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The drawer and any modal, for the same reason `offscreen` is reported: both
|
|
77
|
+
* are on top of the views in a screenshot that otherwise looks right.
|
|
78
|
+
*
|
|
79
|
+
* The drawer takes a column off every view rather than covering them, so a
|
|
80
|
+
* figure framed with the track selector open is narrower than the one asked
|
|
81
|
+
* for — which every filmed take worked around by telling the agent in its
|
|
82
|
+
* system prompt to close it. A modal covers the app outright.
|
|
83
|
+
*/
|
|
84
|
+
function sessionChrome(session) {
|
|
85
|
+
const s = session;
|
|
86
|
+
const drawer = s.drawerVisible && s.visibleWidget
|
|
87
|
+
? {
|
|
88
|
+
drawer: {
|
|
89
|
+
widget: s.visibleWidget.type,
|
|
90
|
+
...(typeof s.drawerWidth === 'number'
|
|
91
|
+
? { width: s.drawerWidth }
|
|
92
|
+
: {}),
|
|
93
|
+
note: 'the drawer takes this width off every view; session.hideAllWidgets() closes it',
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
: {};
|
|
97
|
+
const dialog = s.DialogComponent
|
|
98
|
+
? {
|
|
99
|
+
dialog: {
|
|
100
|
+
note: 'a modal dialog is open over the app, so a screenshot is of the dialog',
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
: {};
|
|
104
|
+
return { ...drawer, ...dialog };
|
|
105
|
+
}
|
|
106
|
+
export function sessionOf(pluginManager) {
|
|
107
|
+
return pluginManager?.rootModel?.session;
|
|
108
|
+
}
|
|
109
|
+
// A view whose init failed (its assembly was never found) paints the error in
|
|
110
|
+
// place of a genome and raises no toast, and stays uninitialized: without
|
|
111
|
+
// this, a spec with "volvix" for "volvox" settled false with nothing said.
|
|
112
|
+
/**
|
|
113
|
+
* Why a view is not finished, or nothing if it is.
|
|
114
|
+
*
|
|
115
|
+
* The three terms are AppReadyMarker's, and they have to be: the marker decides
|
|
116
|
+
* whether the settle ends, this decides what it says when it doesn't, and a
|
|
117
|
+
* term in one and not the other is a settle that answers `false` with an empty
|
|
118
|
+
* `notReady`, or one that names a view the marker was content with. `launching`
|
|
119
|
+
* is the one that was missing — `initialized` goes true the moment a linear
|
|
120
|
+
* view's regions land, while the same apply pass still has the spec's tracks to
|
|
121
|
+
* attach, so a spec load timing out there reported no reason at all.
|
|
122
|
+
*
|
|
123
|
+
* `showImportForm` excuses `initializing` on the same terms the marker gives
|
|
124
|
+
* it: a comparative view reads uninitialized for as long as its form is up, and
|
|
125
|
+
* the form is waiting on a person.
|
|
126
|
+
*/
|
|
127
|
+
function viewState(v) {
|
|
128
|
+
if (v.error !== undefined) {
|
|
129
|
+
return { error: String(v.error) };
|
|
130
|
+
}
|
|
131
|
+
if (v.initialized === false && v.showImportForm !== true) {
|
|
132
|
+
return { phase: 'initializing' };
|
|
133
|
+
}
|
|
134
|
+
return v.pendingLaunch === undefined
|
|
135
|
+
? {}
|
|
136
|
+
: {
|
|
137
|
+
phase: 'launching',
|
|
138
|
+
reason: 'the view is still applying what it was launched with (navigation, tracks)',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function viewSummary(view) {
|
|
142
|
+
const v = viewSelf(view);
|
|
143
|
+
return {
|
|
144
|
+
id: v.id,
|
|
145
|
+
type: v.type,
|
|
146
|
+
...viewState(v),
|
|
147
|
+
...(v.displayName ? { displayName: v.displayName } : {}),
|
|
148
|
+
...(v.assemblyNames?.length ? { assemblyNames: v.assemblyNames } : {}),
|
|
149
|
+
...(v.coarseVisibleLocStrings
|
|
150
|
+
? { visibleRegion: v.coarseVisibleLocStrings }
|
|
151
|
+
: {}),
|
|
152
|
+
// the stack's own height, beside each track's below it: "does this all fit
|
|
153
|
+
// on screen" is then arithmetic rather than three rounds of screenshotting
|
|
154
|
+
...(typeof v.height === 'number' ? { height: v.height } : {}),
|
|
155
|
+
...(viewTracks(view).length
|
|
156
|
+
? {
|
|
157
|
+
tracks: viewTracks(view).map(t => ({
|
|
158
|
+
trackId: t.configuration.trackId,
|
|
159
|
+
trackType: t.type,
|
|
160
|
+
...displayState(t),
|
|
161
|
+
})),
|
|
162
|
+
}
|
|
163
|
+
: {}),
|
|
164
|
+
...(view.ownViews.length
|
|
165
|
+
? { views: view.ownViews.map(sub => viewSummary(sub)) }
|
|
166
|
+
: {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function sessionSummary(session) {
|
|
170
|
+
return {
|
|
171
|
+
name: session.name,
|
|
172
|
+
assemblyNames: session.assemblyNames,
|
|
173
|
+
...sessionChrome(session),
|
|
174
|
+
views: session.views.map(v => viewSummary(v)),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
// MST nodes serialize as their snapshot (toJSON), so the live-model walk below
|
|
178
|
+
// can stringify whatever it lands on; the replacer guards what a snapshot
|
|
179
|
+
// cannot contain but a getter's return can.
|
|
180
|
+
export function safeJson(value) {
|
|
181
|
+
// the ANCESTOR chain, not every object seen: MST snapshots are cached and
|
|
182
|
+
// structurally shared, so the same frozen object legitimately appears at two
|
|
183
|
+
// paths, and a visited-set reports the second one as a cycle that is not
|
|
184
|
+
// there. `this` is the holder JSON.stringify is currently walking, which is
|
|
185
|
+
// what lets the chain unwind on the way back up — so this must stay a
|
|
186
|
+
// `function`, not an arrow.
|
|
187
|
+
const chain = [];
|
|
188
|
+
// typed by hand: JSON.stringify's declared return is string, but a bare
|
|
189
|
+
// Symbol/undefined at the top level really does yield undefined at runtime
|
|
190
|
+
const json = JSON.stringify(isStateTreeNode(value) ? getSnapshot(value) : value, function (_key, v) {
|
|
191
|
+
while (chain.length > 0 && chain.at(-1) !== this) {
|
|
192
|
+
chain.pop();
|
|
193
|
+
}
|
|
194
|
+
if (typeof v === 'function') {
|
|
195
|
+
return '[function]';
|
|
196
|
+
}
|
|
197
|
+
if (typeof v === 'bigint') {
|
|
198
|
+
return `${v}`;
|
|
199
|
+
}
|
|
200
|
+
if (v !== null && typeof v === 'object') {
|
|
201
|
+
if (chain.includes(v)) {
|
|
202
|
+
return '[circular]';
|
|
203
|
+
}
|
|
204
|
+
chain.push(v);
|
|
205
|
+
}
|
|
206
|
+
return v;
|
|
207
|
+
});
|
|
208
|
+
return json === undefined ? '"[unserializable]"' : json;
|
|
209
|
+
}
|
|
210
|
+
function describeBrief(value) {
|
|
211
|
+
if (Array.isArray(value)) {
|
|
212
|
+
return `array(${value.length})`;
|
|
213
|
+
}
|
|
214
|
+
if (value !== null && typeof value === 'object') {
|
|
215
|
+
const json = safeJson(value);
|
|
216
|
+
return json.length > 120 ? `object(~${json.length} bytes)` : json;
|
|
217
|
+
}
|
|
218
|
+
const json = safeJson(value);
|
|
219
|
+
return json.length > 120 ? `${json.slice(0, 117)}...` : json;
|
|
220
|
+
}
|
|
221
|
+
// The names a live node answers beyond its snapshot: MST defines views as own
|
|
222
|
+
// getter properties on the instance, which is exactly the high-value surface
|
|
223
|
+
// (visibleLocStrings, assemblyNames, totalBp, ...) a snapshot filters out.
|
|
224
|
+
function memberNames(node) {
|
|
225
|
+
const getters = [];
|
|
226
|
+
const methods = [];
|
|
227
|
+
for (const [key, desc] of Object.entries(Object.getOwnPropertyDescriptors(node))) {
|
|
228
|
+
if (!key.startsWith('$') && key !== 'toJSON') {
|
|
229
|
+
if (desc.get) {
|
|
230
|
+
getters.push(key);
|
|
231
|
+
}
|
|
232
|
+
else if (typeof desc.value === 'function') {
|
|
233
|
+
methods.push(key);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { getters: getters.sort(), methods: methods.sort() };
|
|
238
|
+
}
|
|
239
|
+
// The node itself. An agent writing `run_javascript` holds it already —
|
|
240
|
+
// jb.view(), session.views[0], track.activeDisplay — so there is nothing to
|
|
241
|
+
// learn here. A dot-path string used to be the only way to ask, which had
|
|
242
|
+
// agents reconstructing a path to a node in hand, in a grammar ("views.0")
|
|
243
|
+
// that is not JavaScript.
|
|
244
|
+
function inspectTarget(session, target) {
|
|
245
|
+
if (typeof target === 'string') {
|
|
246
|
+
throw new Error(`jb.inspect takes the node itself, not the name "${target}" — jb.inspect(jb.view()), jb.inspect(jb.trackModel('someTrackId')), jb.inspect(session.views[0].tracks[0]). Bare jb.inspect() is the session.`);
|
|
247
|
+
}
|
|
248
|
+
return target ?? session;
|
|
249
|
+
}
|
|
250
|
+
function inspectSession(session, args) {
|
|
251
|
+
const maxBytes = typeof args.maxBytes === 'number' ? args.maxBytes : 20_000;
|
|
252
|
+
const node = inspectTarget(session, args.target);
|
|
253
|
+
const members = typeof node === 'object' && !Array.isArray(node)
|
|
254
|
+
? memberNames(node)
|
|
255
|
+
: { getters: [], methods: [] };
|
|
256
|
+
const json = safeJson(node);
|
|
257
|
+
const base = {
|
|
258
|
+
// the name the docs tool files a type under: docs topic "model:<modelType>"
|
|
259
|
+
...(isStateTreeNode(node) ? { modelType: getType(node).name } : {}),
|
|
260
|
+
bytes: json.length,
|
|
261
|
+
...(members.getters.length ? { getters: members.getters } : {}),
|
|
262
|
+
...(members.methods.length ? { actions: members.methods } : {}),
|
|
263
|
+
};
|
|
264
|
+
if (json.length <= maxBytes) {
|
|
265
|
+
return { ...base, value: JSON.parse(json) };
|
|
266
|
+
}
|
|
267
|
+
const plain = JSON.parse(json);
|
|
268
|
+
return Array.isArray(plain)
|
|
269
|
+
? {
|
|
270
|
+
...base,
|
|
271
|
+
note: `too large to return whole — ${plain.length} items; inspect one of them or raise maxBytes`,
|
|
272
|
+
items: plain.slice(0, 20).map(item => describeBrief(item)),
|
|
273
|
+
}
|
|
274
|
+
: {
|
|
275
|
+
...base,
|
|
276
|
+
note: 'too large to return whole — inspect a child of it or raise maxBytes',
|
|
277
|
+
keys: Object.fromEntries(Object.entries(plain).map(([k, v]) => [
|
|
278
|
+
k,
|
|
279
|
+
describeBrief(v),
|
|
280
|
+
])),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const delay = (ms) => new Promise(resolve => {
|
|
284
|
+
setTimeout(resolve, ms);
|
|
285
|
+
});
|
|
286
|
+
// The readiness contract is AppReadyMarker's, not this file's: it publishes
|
|
287
|
+
// data-app-phase from the session (ready = no view resolving an assembly, no
|
|
288
|
+
// display fetching), and says so in its own comment.
|
|
289
|
+
//
|
|
290
|
+
// The hold is why the number is a second: `ready` has to survive past the
|
|
291
|
+
// ~600ms FetchVisibleRegions debounce, or one sample taken right after a
|
|
292
|
+
// navigation reads the pre-navigation frame as finished. Same reasoning and
|
|
293
|
+
// same constant as `waitForAppSettled` in products/jbrowse-capture/src/waits.ts
|
|
294
|
+
// (APP_SETTLED_HOLD_MS) — reached independently there, and measured.
|
|
295
|
+
const READY_HOLD_MS = 1000;
|
|
296
|
+
// An error toast stays up until a human dismisses it, and an info toast leaves
|
|
297
|
+
// on its own — so "the last five messages" repeated one stale error in every
|
|
298
|
+
// settle result for the rest of a run and named no level for the rest. Each
|
|
299
|
+
// toast is delivered to the caller once, by identity: the mobx array keeps the
|
|
300
|
+
// object it was pushed as, and removeSnackbarMessage relies on that too.
|
|
301
|
+
const deliveredNotifications = new WeakMap();
|
|
302
|
+
export function undeliveredNotifications(session) {
|
|
303
|
+
const entries = 'snackbarMessages' in session
|
|
304
|
+
? session.snackbarMessages
|
|
305
|
+
: [];
|
|
306
|
+
const known = deliveredNotifications.get(session);
|
|
307
|
+
const delivered = known ?? new WeakSet();
|
|
308
|
+
if (!known) {
|
|
309
|
+
deliveredNotifications.set(session, delivered);
|
|
310
|
+
}
|
|
311
|
+
const fresh = entries.filter(m => !delivered.has(m));
|
|
312
|
+
for (const m of fresh) {
|
|
313
|
+
delivered.add(m);
|
|
314
|
+
}
|
|
315
|
+
return fresh.map(m => ({
|
|
316
|
+
level: m.level ?? 'info',
|
|
317
|
+
message: m.message,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
export async function waitReady(timeoutMs, session,
|
|
321
|
+
// scoped rather than document-wide because @jbrowse/react-app2 embeds this
|
|
322
|
+
// app in a host page: two mounted apps publish two ready markers, and a
|
|
323
|
+
// document-wide query answers for whichever is first in document order
|
|
324
|
+
root = document) {
|
|
325
|
+
const deadline = Date.now() + timeoutMs;
|
|
326
|
+
let readySince;
|
|
327
|
+
let outcome;
|
|
328
|
+
while (outcome === undefined) {
|
|
329
|
+
const ready = root.querySelector('[data-app-phase="ready"]') !== null &&
|
|
330
|
+
root.querySelector('[data-testid="loading-overlay"]') === null &&
|
|
331
|
+
root.querySelector('[data-display-animating="true"]') === null;
|
|
332
|
+
readySince = ready ? (readySince ?? Date.now()) : undefined;
|
|
333
|
+
if (readySince !== undefined && Date.now() - readySince >= READY_HOLD_MS) {
|
|
334
|
+
outcome = { settled: true };
|
|
335
|
+
}
|
|
336
|
+
else if (Date.now() >= deadline) {
|
|
337
|
+
outcome = {
|
|
338
|
+
settled: false,
|
|
339
|
+
note: 'rendering had not settled by the timeout; tracks may still be loading',
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
await delay(200);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const messages = session ? undeliveredNotifications(session) : [];
|
|
347
|
+
// A display that refuses to draw — over the fetch-size gate, or errored —
|
|
348
|
+
// replaces its own subtree instead of raising a snackbar, so notifications
|
|
349
|
+
// alone report a clean settle over a browser with a blank track in it.
|
|
350
|
+
const notReady = session
|
|
351
|
+
? [
|
|
352
|
+
...openViews(session)
|
|
353
|
+
.map(v => ({
|
|
354
|
+
viewId: v.id,
|
|
355
|
+
type: v.type,
|
|
356
|
+
...viewState(viewSelf(v)),
|
|
357
|
+
}))
|
|
358
|
+
.filter(v => 'phase' in v || 'error' in v),
|
|
359
|
+
...allTracks(session)
|
|
360
|
+
.map(t => ({ trackId: t.configuration.trackId, ...displayState(t) }))
|
|
361
|
+
.filter(t => 'phase' in t || 'error' in t || 'notices' in t),
|
|
362
|
+
]
|
|
363
|
+
: [];
|
|
364
|
+
const offscreen = session ? offscreenViews(session, root) : undefined;
|
|
365
|
+
return {
|
|
366
|
+
...outcome,
|
|
367
|
+
...(messages.length ? { notifications: messages } : {}),
|
|
368
|
+
...(notReady.length ? { notReady } : {}),
|
|
369
|
+
...(offscreen ? { offscreen } : {}),
|
|
370
|
+
...(session ? sessionChrome(session) : {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// A session taller than the window scrolls, and a screenshot of the viewport
|
|
374
|
+
// then shows a plausible browser with the bottom view cut off or the top one
|
|
375
|
+
// scrolled away: every filmed take spent turns shrinking tracks after a
|
|
376
|
+
// picture, and one found its genome view at y = -267. Reported from the DOM
|
|
377
|
+
// the views are laid out in, so the agent knows before it looks.
|
|
378
|
+
function offscreenViews(session, root) {
|
|
379
|
+
const win = root.ownerDocument?.defaultView ?? window;
|
|
380
|
+
const windowHeight = win.innerHeight;
|
|
381
|
+
const measured = overflow(root);
|
|
382
|
+
const containers = new Map([
|
|
383
|
+
...root.querySelectorAll('[data-testid^="view-container-"]'),
|
|
384
|
+
].map(el => [el.dataset.testid.slice('view-container-'.length), el]));
|
|
385
|
+
const views = openViews(session).flatMap(view => {
|
|
386
|
+
const rect = containers.get(view.id)?.getBoundingClientRect();
|
|
387
|
+
return rect && (rect.top < 0 || rect.bottom > windowHeight)
|
|
388
|
+
? [
|
|
389
|
+
{
|
|
390
|
+
viewId: view.id,
|
|
391
|
+
top: Math.round(rect.top),
|
|
392
|
+
bottom: Math.round(rect.bottom),
|
|
393
|
+
},
|
|
394
|
+
]
|
|
395
|
+
: [];
|
|
396
|
+
});
|
|
397
|
+
return measured.pageHeight > measured.windowHeight || views.length
|
|
398
|
+
? {
|
|
399
|
+
pageHeight: measured.pageHeight,
|
|
400
|
+
windowHeight: measured.windowHeight,
|
|
401
|
+
scrollY: Math.round(win.scrollY),
|
|
402
|
+
views,
|
|
403
|
+
note: 'the session is taller than the window; a viewport screenshot cuts these views off — await jb.fitToWindow() to shrink what is open until it fits, or screenshot with fullPage: true. A view with a negative top is scrolled out above the viewport (scrollY says by how much), not missing',
|
|
404
|
+
}
|
|
405
|
+
: undefined;
|
|
406
|
+
}
|
|
407
|
+
// Neither app scrolls the document: a column inside the app scrolls, so the
|
|
408
|
+
// document's scrollHeight equals the window's height with a view running 700
|
|
409
|
+
// px past it. The overflow is the scrolling ancestor's, when one exists.
|
|
410
|
+
function scrollerOf(container) {
|
|
411
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
412
|
+
for (let el = container.parentElement; el; el = el.parentElement) {
|
|
413
|
+
const { overflowY } = win.getComputedStyle(el);
|
|
414
|
+
if ((overflowY === 'auto' || overflowY === 'scroll') &&
|
|
415
|
+
el.scrollHeight > el.clientHeight) {
|
|
416
|
+
return el;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
// The view ids each scrolling box holds, `undefined` keying the document. A
|
|
422
|
+
// workspace scrolls every panel on its own, so one panel fitting says nothing
|
|
423
|
+
// about the one beside it, and a view in a tab nobody is showing has no
|
|
424
|
+
// container and nothing to fit.
|
|
425
|
+
function scrollPorts(root) {
|
|
426
|
+
const ports = new Map();
|
|
427
|
+
for (const el of root.querySelectorAll('[data-testid^="view-container-"]')) {
|
|
428
|
+
const scroller = scrollerOf(el);
|
|
429
|
+
const ids = ports.get(scroller) ?? new Set();
|
|
430
|
+
ids.add(el.dataset.testid.slice('view-container-'.length));
|
|
431
|
+
ports.set(scroller, ids);
|
|
432
|
+
}
|
|
433
|
+
return ports;
|
|
434
|
+
}
|
|
435
|
+
// The trailing overscroll ViewStack renders below the last view, which exists so
|
|
436
|
+
// a newly-added view can scroll to the middle of the port. It is room, not
|
|
437
|
+
// content, and it is up to 300px of a scrollHeight, so a shrink that charges it
|
|
438
|
+
// to the views leaves them that much short of the window — the E. coli take's
|
|
439
|
+
// dotplot was cut to 312px of the 600 that fit.
|
|
440
|
+
function overscrollHeight(scroller) {
|
|
441
|
+
const spacer = scroller.querySelector('[data-testid="view-stack-overscroll"]');
|
|
442
|
+
return spacer ? Math.round(spacer.getBoundingClientRect().height) : 0;
|
|
443
|
+
}
|
|
444
|
+
function overflowOf(root, scroller) {
|
|
445
|
+
if (scroller) {
|
|
446
|
+
return {
|
|
447
|
+
pageHeight: scroller.scrollHeight - overscrollHeight(scroller),
|
|
448
|
+
windowHeight: scroller.clientHeight,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const win = root.ownerDocument?.defaultView ?? window;
|
|
452
|
+
return {
|
|
453
|
+
pageHeight: win.document.documentElement.scrollHeight,
|
|
454
|
+
windowHeight: win.innerHeight,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function excessOf(measured) {
|
|
458
|
+
return measured.pageHeight - measured.windowHeight;
|
|
459
|
+
}
|
|
460
|
+
// The port that overflows most, which is what a single answer reports
|
|
461
|
+
function overflow(root) {
|
|
462
|
+
return [...scrollPorts(root).keys()]
|
|
463
|
+
.map(scroller => overflowOf(root, scroller))
|
|
464
|
+
.reduce((worst, m) => (excessOf(m) > excessOf(worst) ? m : worst), overflowOf(root, undefined));
|
|
465
|
+
}
|
|
466
|
+
// What a shrink can leave a display, a synteny band or a dotplot at: enough to
|
|
467
|
+
// still read a track's name and see that it drew.
|
|
468
|
+
const MIN_HEIGHT_PX = 40;
|
|
469
|
+
// Everything on screen whose height is its own state: each shown track's
|
|
470
|
+
// display, each synteny band, and a view whose height is a declared property
|
|
471
|
+
// rather than the sum of its tracks (a dotplot). A linear view's `height` is a
|
|
472
|
+
// getter over its tracks, so shrinking it is shrinking them.
|
|
473
|
+
function shrinkables(views) {
|
|
474
|
+
return views.flatMap(viewAndNested).flatMap(view => {
|
|
475
|
+
const v = view;
|
|
476
|
+
const declared = isStateTreeNode(view) &&
|
|
477
|
+
getType(view).properties
|
|
478
|
+
?.height !== undefined;
|
|
479
|
+
const own = declared && typeof v.height === 'number' && v.setHeight
|
|
480
|
+
? [{ what: `view ${v.id}`, height: v.height, setHeight: v.setHeight }]
|
|
481
|
+
: [];
|
|
482
|
+
const bands = (v.levels ?? []).flatMap((level, i) => typeof level.height === 'number' && level.setHeight
|
|
483
|
+
? [
|
|
484
|
+
{
|
|
485
|
+
what: `view ${v.id} band ${i}`,
|
|
486
|
+
height: level.height,
|
|
487
|
+
setHeight: level.setHeight,
|
|
488
|
+
},
|
|
489
|
+
]
|
|
490
|
+
: []);
|
|
491
|
+
const displays = viewTracks(view).flatMap(track => {
|
|
492
|
+
// resizeHeight where the display has it: in grow mode it is the call
|
|
493
|
+
// that leaves grow, where setHeight writes a slot nothing reads
|
|
494
|
+
const d = track.activeDisplay;
|
|
495
|
+
const { setHeight, resizeHeight } = d ?? {};
|
|
496
|
+
return d && typeof d.height === 'number' && (resizeHeight || setHeight)
|
|
497
|
+
? [
|
|
498
|
+
{
|
|
499
|
+
what: track.configuration.trackId,
|
|
500
|
+
height: d.height,
|
|
501
|
+
setHeight: (px) => {
|
|
502
|
+
if (resizeHeight) {
|
|
503
|
+
resizeHeight(px - d.height);
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
setHeight?.(px);
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
]
|
|
511
|
+
: [];
|
|
512
|
+
});
|
|
513
|
+
return [...own, ...bands, ...displays];
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Shrink what is open until the session fits the window, and say what moved.
|
|
518
|
+
*
|
|
519
|
+
* Every filmed take spent two to five screenshot rounds on this by hand: the
|
|
520
|
+
* settle reported `offscreen`, the agent guessed which heights to cut and by
|
|
521
|
+
* how much, and looked again. The overflow is arithmetic the settle already
|
|
522
|
+
* does; this spends it across every display, band and dotplot in proportion to
|
|
523
|
+
* the headroom each has above the floor, so tall tracks give up the most and
|
|
524
|
+
* nothing goes below readable.
|
|
525
|
+
*/
|
|
526
|
+
async function fitToWindow(session, settleMs, root = document) {
|
|
527
|
+
const before = overflow(root);
|
|
528
|
+
const excess = excessOf(before);
|
|
529
|
+
if (excess <= 0) {
|
|
530
|
+
return { fits: true, ...before };
|
|
531
|
+
}
|
|
532
|
+
// each port spends only its own overflow, on its own views; a host that
|
|
533
|
+
// renders no view containers is one port, the document
|
|
534
|
+
const ports = scrollPorts(root);
|
|
535
|
+
const groups = ports.size
|
|
536
|
+
? [...ports].map(([scroller, ids]) => ({
|
|
537
|
+
scroller,
|
|
538
|
+
views: session.views.filter(v => ids.has(v.id)),
|
|
539
|
+
}))
|
|
540
|
+
: [{ scroller: undefined, views: session.views }];
|
|
541
|
+
const cuts = groups.map(({ scroller, views }) => {
|
|
542
|
+
const portExcess = excessOf(overflowOf(root, scroller));
|
|
543
|
+
if (portExcess <= 0) {
|
|
544
|
+
return { scroller, atFloor: false, shrunk: [] };
|
|
545
|
+
}
|
|
546
|
+
const items = shrinkables(views);
|
|
547
|
+
const heights = items.map(i => i.height);
|
|
548
|
+
const next = scaleBandHeights(heights, -portExcess, MIN_HEIGHT_PX);
|
|
549
|
+
const available = sum(heights) - sum(next);
|
|
550
|
+
return {
|
|
551
|
+
scroller,
|
|
552
|
+
atFloor: available < portExcess,
|
|
553
|
+
shrunk: items.flatMap((item, i) => {
|
|
554
|
+
const to = next[i];
|
|
555
|
+
if (to >= item.height) {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
item.setHeight(to);
|
|
559
|
+
return [{ what: item.what, from: item.height, to }];
|
|
560
|
+
}),
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
const settle = await waitReady(settleMs, session, root);
|
|
564
|
+
// each panel measured again on its own box, since one that now fits is no
|
|
565
|
+
// longer a scroller for `overflow` to find
|
|
566
|
+
const remaining = cuts.map(c => ({
|
|
567
|
+
atFloor: c.atFloor,
|
|
568
|
+
left: excessOf(overflowOf(root, c.scroller)),
|
|
569
|
+
}));
|
|
570
|
+
const left = Math.max(excessOf(overflowOf(root, undefined)), ...remaining.map(r => r.left));
|
|
571
|
+
return {
|
|
572
|
+
fits: left <= 0,
|
|
573
|
+
overflowBefore: excess,
|
|
574
|
+
overflowAfter: Math.max(0, left),
|
|
575
|
+
shrunk: cuts.flatMap(c => c.shrunk),
|
|
576
|
+
...(left > 0
|
|
577
|
+
? {
|
|
578
|
+
note: remaining.some(r => r.left > 0 && r.atFloor)
|
|
579
|
+
? `everything shrinkable is at its ${MIN_HEIGHT_PX} px floor — hide a track or a view, or screenshot with fullPage: true`
|
|
580
|
+
: 'still taller than the window after the settle — call jb.fitToWindow() again',
|
|
581
|
+
}
|
|
582
|
+
: {}),
|
|
583
|
+
...settle,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
// The catalog is the largest result of nearly every agent run — the 129 volvox
|
|
587
|
+
// rows were 20 KB — so a row carries what picks a track out of the list and
|
|
588
|
+
// nothing more. The adapter type is `jb.inspect`'s or `jb.describeSlots`' to
|
|
589
|
+
// answer for the one track an agent went on to use.
|
|
590
|
+
//
|
|
591
|
+
// Dropping assemblyNames wherever the session has a single assembly saved
|
|
592
|
+
// nothing and made the row shape vary by session: a config declares far more
|
|
593
|
+
// assemblies than it opens, and volvox — every eval run's config — declares 10.
|
|
594
|
+
function trackEntry(conf) {
|
|
595
|
+
return {
|
|
596
|
+
trackId: conf.trackId,
|
|
597
|
+
name: readConfObject(conf, 'name'),
|
|
598
|
+
type: conf.type,
|
|
599
|
+
// getConfAssemblyNamesOrNone, not the slot: an assembly's sequence track
|
|
600
|
+
// has no assemblyNames slot and answers through its parent assembly
|
|
601
|
+
assemblyNames: getConfAssemblyNamesOrNone(conf),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
// Each assembly's own sequence track, which no track list holds: the config
|
|
605
|
+
// keeps it under the assembly as `sequence`, so `jb.getFeatures` on its
|
|
606
|
+
// trackId reads bases while `listTracks` never named it.
|
|
607
|
+
function sequenceTracks(session) {
|
|
608
|
+
return session.assemblyManager.assemblyList.map(asm => asm.sequence);
|
|
609
|
+
}
|
|
610
|
+
function listTracks(session, searchArg, limitArg) {
|
|
611
|
+
const search = searchArg?.toLowerCase() ?? '';
|
|
612
|
+
const limit = limitArg ?? 100;
|
|
613
|
+
// allSessionTracks, not session.tracks: connection-supplied tracks (hubs,
|
|
614
|
+
// registries) are absent from the session lists but fully showable — a
|
|
615
|
+
// hand-rolled union here hid them from agents entirely
|
|
616
|
+
const matches = [...allSessionTracks(session), ...sequenceTracks(session)]
|
|
617
|
+
.map(c => trackEntry(c))
|
|
618
|
+
.filter(t => !search ||
|
|
619
|
+
t.trackId.toLowerCase().includes(search) ||
|
|
620
|
+
t.name.toLowerCase().includes(search));
|
|
621
|
+
return {
|
|
622
|
+
total: matches.length,
|
|
623
|
+
...(matches.length > limit
|
|
624
|
+
? { note: `showing ${limit} of ${matches.length}; refine with search` }
|
|
625
|
+
: {}),
|
|
626
|
+
tracks: matches.slice(0, limit),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
function pickView(session, args, capability, wants) {
|
|
630
|
+
const viewId = typeof args.viewId === 'string' ? args.viewId : undefined;
|
|
631
|
+
const candidates = viewScope(session, viewId);
|
|
632
|
+
const able = candidates.filter(v => typeof viewSelf(v)[capability] === 'function');
|
|
633
|
+
if (!able.length) {
|
|
634
|
+
throw new Error(`No ${inScope(viewId)} supports this (${candidates.map(v => v.type).join(', ') || 'none open'}) — jb.loadSessionSpec can open one.`);
|
|
635
|
+
}
|
|
636
|
+
// A view on another assembly would show the track and render nothing, with a
|
|
637
|
+
// successful-looking result — the same silent mismatch visibleRegionsOf
|
|
638
|
+
// guards against for reads
|
|
639
|
+
const onAssembly = wants?.assembly
|
|
640
|
+
? able.filter(v => viewSelf(v).assemblyNames?.some(name => isSameAssemblyName(name, wants.assembly, session.assemblyManager)))
|
|
641
|
+
: able;
|
|
642
|
+
if (!onAssembly.length) {
|
|
643
|
+
throw new Error(`No open view is on assembly "${wants.assembly}" (open views: ${able.map(v => `${v.id} on ${viewSelf(v).assemblyNames?.join(', ')}`).join('; ')}) — open one, or pass show: false`);
|
|
644
|
+
}
|
|
645
|
+
const canDisplay = wants?.track && wants.pluginManager
|
|
646
|
+
? onAssembly.filter(v => viewCanDisplayTrack(wants.pluginManager, viewDisplayNames(wants.pluginManager, v.type), wants.track))
|
|
647
|
+
: onAssembly;
|
|
648
|
+
if (!canDisplay.length) {
|
|
649
|
+
throw new Error(`No open view can display a ${wants.track.type} (open views: ${onAssembly.map(v => v.type).join(', ')})`);
|
|
650
|
+
}
|
|
651
|
+
return onlyView(session, canDisplay, 'could take this', viewId);
|
|
652
|
+
}
|
|
653
|
+
// A synteny view's rows count as open views (they are what shows a region and
|
|
654
|
+
// a track), and an agent looking at one synteny view read "3 views are open"
|
|
655
|
+
// with nothing saying two of them were its rows.
|
|
656
|
+
function parentView(session, view) {
|
|
657
|
+
return openViews(session).find(v => v.ownViews.includes(view));
|
|
658
|
+
}
|
|
659
|
+
function describeView(session, view) {
|
|
660
|
+
const v = viewSelf(view);
|
|
661
|
+
const on = v.assemblyNames?.length ? ` on ${v.assemblyNames.join(', ')}` : '';
|
|
662
|
+
const at = v.coarseVisibleLocStrings ? ` at ${v.coarseVisibleLocStrings}` : '';
|
|
663
|
+
const parent = parentView(session, view);
|
|
664
|
+
const row = parent ? `, a row of ${parent.id}` : '';
|
|
665
|
+
return `${v.id} (${v.type}${on}${at}${row})`;
|
|
666
|
+
}
|
|
667
|
+
function onlyView(session, candidates, relation, viewId) {
|
|
668
|
+
const named = candidates.find(v => v.id === viewId);
|
|
669
|
+
if (named) {
|
|
670
|
+
return named;
|
|
671
|
+
}
|
|
672
|
+
const [first, ...rest] = candidates;
|
|
673
|
+
if (!first) {
|
|
674
|
+
throw new Error('No open view');
|
|
675
|
+
}
|
|
676
|
+
if (rest.length) {
|
|
677
|
+
throw new Error(`${candidates.length} views ${relation}: ${candidates.map(v => describeView(session, v)).join('; ')} — pass viewId to say which`);
|
|
678
|
+
}
|
|
679
|
+
return first;
|
|
680
|
+
}
|
|
681
|
+
function viewById(session, viewId) {
|
|
682
|
+
const candidates = openViews(session);
|
|
683
|
+
if (viewId !== undefined) {
|
|
684
|
+
const named = candidates.find(v => v.id === viewId);
|
|
685
|
+
if (!named) {
|
|
686
|
+
throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => describeView(session, v)).join('; ') || 'none'}`);
|
|
687
|
+
}
|
|
688
|
+
return named;
|
|
689
|
+
}
|
|
690
|
+
if (!candidates.length) {
|
|
691
|
+
throw new Error('No view is open — jb.loadSessionSpec can open one');
|
|
692
|
+
}
|
|
693
|
+
return onlyView(session, candidates, 'are open');
|
|
694
|
+
}
|
|
695
|
+
function inScope(viewId) {
|
|
696
|
+
return viewId === undefined ? 'open view' : `view in ${viewId}`;
|
|
697
|
+
}
|
|
698
|
+
function viewScope(session, viewId) {
|
|
699
|
+
return viewId === undefined
|
|
700
|
+
? openViews(session)
|
|
701
|
+
: viewAndNested(viewById(session, viewId));
|
|
702
|
+
}
|
|
703
|
+
function shownTrackModel(session, trackId, viewId) {
|
|
704
|
+
const trackOf = (v) => viewTracks(v).find(t => t.configuration.trackId === trackId);
|
|
705
|
+
const showing = viewScope(session, viewId).filter(v => trackOf(v));
|
|
706
|
+
// undefined here was followed by ".activeDisplay" on the next line of every
|
|
707
|
+
// agent's code, and a TypeError says nothing about which of the two causes
|
|
708
|
+
// it was
|
|
709
|
+
if (!showing.length) {
|
|
710
|
+
throw new Error(session.getTrackById(trackId)
|
|
711
|
+
? `"${trackId}" is not shown in any ${inScope(viewId)} — await view.launchTrack("${trackId}") first; jb.sessionSummary() lists what each view shows`
|
|
712
|
+
: `No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
|
|
713
|
+
}
|
|
714
|
+
return trackOf(onlyView(session, showing, `show "${trackId}"`, viewId));
|
|
715
|
+
}
|
|
716
|
+
function isRegionLoc(loc) {
|
|
717
|
+
return (!!loc &&
|
|
718
|
+
typeof loc === 'object' &&
|
|
719
|
+
typeof loc.refName === 'string');
|
|
720
|
+
}
|
|
721
|
+
async function trackAssembly(session, conf, requested) {
|
|
722
|
+
// getConfAssemblyNamesOrNone, not the assemblyNames slot: an assembly's own
|
|
723
|
+
// sequence track has no such slot and answers through its parent assembly
|
|
724
|
+
const trackAssemblies = getConfAssemblyNamesOrNone(conf);
|
|
725
|
+
const assemblyName = requested ?? trackAssemblies[0];
|
|
726
|
+
if (assemblyName === undefined) {
|
|
727
|
+
throw new Error('The track names no assembly; pass assembly explicitly');
|
|
728
|
+
}
|
|
729
|
+
// a named assembly the track is not on renames the region against the wrong
|
|
730
|
+
// alias set and answers with the wrong assembly's coordinates, or nothing
|
|
731
|
+
if (requested !== undefined &&
|
|
732
|
+
trackAssemblies.length &&
|
|
733
|
+
!trackAssemblies.some(name => isSameAssemblyName(name, requested, session.assemblyManager))) {
|
|
734
|
+
throw new Error(`Track "${conf.trackId}" is on ${trackAssemblies.join(', ')}, not "${requested}"`);
|
|
735
|
+
}
|
|
736
|
+
const assembly = await session.assemblyManager.waitForAssembly(assemblyName);
|
|
737
|
+
if (!assembly) {
|
|
738
|
+
throw new Error(`Assembly "${assemblyName}" could not be loaded`);
|
|
739
|
+
}
|
|
740
|
+
return { assembly, assemblyName };
|
|
741
|
+
}
|
|
742
|
+
async function locToRegion(session, conf, loc, assemblyArg) {
|
|
743
|
+
if (typeof loc !== 'string' && !isRegionLoc(loc)) {
|
|
744
|
+
throw new Error(`jb.getFeatures takes loc as ONE place — a locstring ("ctgA:1-100") or a region object ({ refName, start, end }) — and got ${JSON.stringify(loc)}${Array.isArray(loc) ? '; several regions go in regions: [...]' : ''}`);
|
|
745
|
+
}
|
|
746
|
+
const { assembly, assemblyName } = await trackAssembly(session, conf, assemblyArg ?? (typeof loc === 'string' ? undefined : loc.assemblyName));
|
|
747
|
+
const parsed = typeof loc === 'string'
|
|
748
|
+
? parseLocString(loc, refName => assembly.isValidRefName(refName))
|
|
749
|
+
: loc;
|
|
750
|
+
const refName = assembly.getCanonicalRefName(parsed.refName) ?? parsed.refName;
|
|
751
|
+
// parseLocString asks this for the string form. A region object skipping it
|
|
752
|
+
// read empty on a name the assembly does not have, which is the answer this
|
|
753
|
+
// surface exists to turn into an error.
|
|
754
|
+
if (!assembly.isValidRefName(refName)) {
|
|
755
|
+
throw new Error(`"${parsed.refName}" is not a sequence in ${assemblyName} — jb.visibleRegions() and jb.sessionSummary() name what is there`);
|
|
756
|
+
}
|
|
757
|
+
const bounds = assembly.regions?.find(r => r.refName === refName);
|
|
758
|
+
return {
|
|
759
|
+
assemblyName,
|
|
760
|
+
refName,
|
|
761
|
+
start: parsed.start ?? bounds?.start ?? 0,
|
|
762
|
+
end: parsed.end ?? bounds?.end ?? Number.MAX_SAFE_INTEGER,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
async function visibleRegionsOf(session, viewId, preferTrackId, trackAssemblies = []) {
|
|
766
|
+
// `in`, not evaluation: visibleRegions is a getter that THROWS ("width
|
|
767
|
+
// undefined") until the view's component mounts and sets a width — a
|
|
768
|
+
// freshly spec-loaded view stays in that state briefly even after the
|
|
769
|
+
// app-phase marker reads ready, since a view with no width has no display
|
|
770
|
+
// fetching anything.
|
|
771
|
+
const regionBearing = viewScope(session, viewId).filter(v => 'visibleRegions' in v);
|
|
772
|
+
if (!regionBearing.length) {
|
|
773
|
+
throw new Error(`No ${inScope(viewId)} shows a region — pass loc, or open a linear view`);
|
|
774
|
+
}
|
|
775
|
+
// a view on another assembly than the track would hand its region to a file
|
|
776
|
+
// that has no such sequence, which answers nothing, silently
|
|
777
|
+
const candidates = trackAssemblies.length
|
|
778
|
+
? regionBearing.filter(v => !viewSelf(v).assemblyNames?.length ||
|
|
779
|
+
viewSelf(v).assemblyNames.some(name => trackAssemblies.some(t => isSameAssemblyName(name, t, session.assemblyManager))))
|
|
780
|
+
: regionBearing;
|
|
781
|
+
if (!candidates.length) {
|
|
782
|
+
throw new Error(`No open view is on ${trackAssemblies.join(', ')} (open views: ${regionBearing.map(v => describeView(session, v)).join('; ')}) — pass loc, or open a view on that assembly`);
|
|
783
|
+
}
|
|
784
|
+
// among region-bearing views, the ones actually showing the track — two
|
|
785
|
+
// views on two assemblies would otherwise send the first view's namespace to
|
|
786
|
+
// the second view's file, which answers nothing, silently
|
|
787
|
+
const showing = candidates.filter(v => viewTracks(v).some(t => t.configuration.trackId === preferTrackId));
|
|
788
|
+
const chosen = onlyView(session, showing.length ? showing : candidates, showing.length ? `show "${preferTrackId}"` : 'show a region', viewId);
|
|
789
|
+
const view = viewSelf(chosen);
|
|
790
|
+
const deadline = Date.now() + 10_000;
|
|
791
|
+
let visible;
|
|
792
|
+
while (visible === undefined) {
|
|
793
|
+
if (view.error !== undefined) {
|
|
794
|
+
throw new Error(`View ${view.id} failed to initialize: ${String(view.error)} — pass loc with assembly, or open a view that loads`);
|
|
795
|
+
}
|
|
796
|
+
if (view.initialized !== false) {
|
|
797
|
+
try {
|
|
798
|
+
visible = view.visibleRegions;
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
// not mounted yet
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
if (visible?.length) {
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
visible = undefined;
|
|
808
|
+
if (Date.now() >= deadline) {
|
|
809
|
+
throw new Error('The view has not finished initializing a visible region — pass loc, or navigate first');
|
|
810
|
+
}
|
|
811
|
+
await delay(250);
|
|
812
|
+
}
|
|
813
|
+
// whole bases: a block edge is a fractional bp, and 30000.000000000004
|
|
814
|
+
// reads as a bug in every answer that echoes it
|
|
815
|
+
return visible.map(({ refName, start, end, assemblyName }) => ({
|
|
816
|
+
refName,
|
|
817
|
+
start: Math.floor(start),
|
|
818
|
+
end: Math.ceil(end),
|
|
819
|
+
assemblyName,
|
|
820
|
+
}));
|
|
821
|
+
}
|
|
822
|
+
// The same RPCs the track's display issues, under the track's own session id
|
|
823
|
+
// (getConfRpcSessionId), so a shown track's parsed index and chunk cache are
|
|
824
|
+
// reused. Retained and released like a track does, so an un-shown track's
|
|
825
|
+
// worker cache is dropped once this read is done. Regions are renamed to the
|
|
826
|
+
// file's own refNames during serialization (RpcMethodTypeWithRenameRegions),
|
|
827
|
+
// and the features come back rebuilt as SimpleFeature. What crosses the worker
|
|
828
|
+
// boundary is bounded by the byte gate that runs first.
|
|
829
|
+
async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
|
|
830
|
+
const conf = session.getTrackById(trackId);
|
|
831
|
+
if (!conf) {
|
|
832
|
+
throw new Error(`No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
|
|
833
|
+
}
|
|
834
|
+
const adapterConfig = readConfObject(conf, 'adapter');
|
|
835
|
+
const sessionId = getConfRpcSessionId(conf);
|
|
836
|
+
// The adapter's own declared limit where it has one, so this does not quietly
|
|
837
|
+
// disagree with the size the track's display already refuses to render —
|
|
838
|
+
// the reasoning BaseTrackModel.exportByteLimit spells out for "Save track
|
|
839
|
+
// data", which is the other path that fetches because someone asked rather
|
|
840
|
+
// than because a view scrolled.
|
|
841
|
+
const byteLimit = agentByteLimit(readConfObject(conf, ['adapter', 'fetchSizeLimit']), requestedByteLimit);
|
|
842
|
+
const { rpcManager } = session;
|
|
843
|
+
const controller = new AbortController();
|
|
844
|
+
const { signal } = controller;
|
|
845
|
+
// desktop's MCP relay gives up at 150s, so the read must not outlive it —
|
|
846
|
+
// and an agent-triggered read of a dense region wants a ceiling either way
|
|
847
|
+
const stopTimer = setTimeout(() => {
|
|
848
|
+
controller.abort();
|
|
849
|
+
}, 120_000);
|
|
850
|
+
retainAdapterSession(rpcManager, sessionId);
|
|
851
|
+
try {
|
|
852
|
+
// The agent's "region too large". A display refuses to fetch over its own
|
|
853
|
+
// gate and paints the reason; this path has no display, so without the
|
|
854
|
+
// same question asked here an agent that names a whole chromosome pulls it
|
|
855
|
+
// — over someone else's data host, into the page it is about to
|
|
856
|
+
// screenshot. Same index-only estimate the gated RPC takes first, and a
|
|
857
|
+
// refusal rather than a truncation: a short answer that looked like the
|
|
858
|
+
// whole answer is the failure this surface exists to prevent.
|
|
859
|
+
const bytes = await rpcManager.call(sessionId, 'CoreGetRegionByteEstimate',
|
|
860
|
+
// eslint-disable-next-line no-restricted-syntax -- reports nothing: an agent awaits the returned features and no display or dialog exists to show a phase label or bar
|
|
861
|
+
{ adapterConfig, regions, scope: 'largestRegion', signal });
|
|
862
|
+
if (bytes !== undefined && bytes > byteLimit) {
|
|
863
|
+
throw new Error(`region too large for jb.getFeatures: the largest region is ~${bytes} bytes against a limit of ${byteLimit}. Narrow the region, or pass an explicit byteLimit if you mean to pull this much.`);
|
|
864
|
+
}
|
|
865
|
+
// eslint-disable-next-line no-restricted-syntax -- reports nothing: see the estimate above
|
|
866
|
+
return await rpcManager.call(sessionId, 'CoreGetFeatures', {
|
|
867
|
+
adapterConfig,
|
|
868
|
+
regions,
|
|
869
|
+
signal,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
catch (e) {
|
|
873
|
+
throw signal.aborted
|
|
874
|
+
? new Error(`jb.getFeatures gave up on track "${trackId}" after 120s. Narrow the region, or check that its data host is answering.`, { cause: e })
|
|
875
|
+
: e;
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
clearTimeout(stopTimer);
|
|
879
|
+
void releaseAdapterSession(rpcManager, sessionId);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
// A LocalPathLocation only reads under Electron: in a browser openLocation
|
|
883
|
+
// throws "can't use local files in the browser" at the FIRST READ, not here, so
|
|
884
|
+
// accepting one would report a track added and then fail inside the display
|
|
885
|
+
// where the agent is least likely to connect it to what it asked for.
|
|
886
|
+
const URL_TEXT = /^https?:\/\//;
|
|
887
|
+
const ABSOLUTE_PATH = /^(?:\/|[a-zA-Z]:[\\/]|\\\\)/;
|
|
888
|
+
function isFileLocationText(spec) {
|
|
889
|
+
return URL_TEXT.test(spec) || ABSOLUTE_PATH.test(spec);
|
|
890
|
+
}
|
|
891
|
+
function fileLocation(spec) {
|
|
892
|
+
if (URL_TEXT.test(spec)) {
|
|
893
|
+
return { uri: spec, locationType: 'UriLocation' };
|
|
894
|
+
}
|
|
895
|
+
// The app's working directory is not the agent's, and a relative path
|
|
896
|
+
// resolves against the app's: under a packaged app that is "/", so the read
|
|
897
|
+
// fails at the first fetch and reports through the display, not here.
|
|
898
|
+
if (!ABSOLUTE_PATH.test(spec)) {
|
|
899
|
+
throw new Error(`jb.addTrack needs an absolute local path or a URL: "${spec}" is relative and would resolve against the app's working directory, not yours.`);
|
|
900
|
+
}
|
|
901
|
+
if (!isElectron) {
|
|
902
|
+
throw new Error(`jb.addTrack needs a URL in a browser, and "${spec}" is a local path. JBrowse Web cannot read local files except through the Add track file picker, which needs a real file chosen by hand.`);
|
|
903
|
+
}
|
|
904
|
+
return { localPath: spec, locationType: 'LocalPathLocation' };
|
|
905
|
+
}
|
|
906
|
+
// A stacked set of bigWigs is one track over a MultiWiggleAdapter, which the
|
|
907
|
+
// per-file guesser cannot express — the web take hand-wrote the config. The
|
|
908
|
+
// subadapters form rather than the `bigWigs` shorthand: that one takes
|
|
909
|
+
// absolute URLs only, and desktop's locations are paths.
|
|
910
|
+
// the file's name without its bigWig extension, or undefined for any other file
|
|
911
|
+
function bigWigStem(location) {
|
|
912
|
+
const fileName = location.split(/[/\\]/).pop() ?? location;
|
|
913
|
+
const match = /^(.+)\.(bw|bigwig)$/i.exec(fileName);
|
|
914
|
+
return match?.[1];
|
|
915
|
+
}
|
|
916
|
+
function multiWiggleTrackConf(locations, assemblyName) {
|
|
917
|
+
const stems = locations.map(l => bigWigStem(l));
|
|
918
|
+
const notBigWig = locations.filter((_, i) => stems[i] === undefined);
|
|
919
|
+
if (notBigWig.length) {
|
|
920
|
+
throw new Error(`jb.addTrack takes a list of locations only for bigWigs (one stacked MultiQuantitativeTrack); not bigWig: ${notBigWig.join(', ')}. Add other formats one at a time.`);
|
|
921
|
+
}
|
|
922
|
+
const adapter = {
|
|
923
|
+
type: 'MultiWiggleAdapter',
|
|
924
|
+
subadapters: locations.map((l, i) => ({
|
|
925
|
+
type: 'BigWigAdapter',
|
|
926
|
+
name: stems[i],
|
|
927
|
+
bigWigLocation: fileLocation(l),
|
|
928
|
+
})),
|
|
929
|
+
};
|
|
930
|
+
return {
|
|
931
|
+
trackId: `multiwiggle-${objectHash(adapter).slice(0, 8)}`,
|
|
932
|
+
type: 'MultiQuantitativeTrack',
|
|
933
|
+
name: stems.join(', '),
|
|
934
|
+
assemblyNames: [assemblyName],
|
|
935
|
+
adapter,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
// one location, a list of them, or nothing usable
|
|
939
|
+
function locationsOf(value) {
|
|
940
|
+
return Array.isArray(value)
|
|
941
|
+
? value.filter((l) => typeof l === 'string')
|
|
942
|
+
: typeof value === 'string'
|
|
943
|
+
? value
|
|
944
|
+
: '';
|
|
945
|
+
}
|
|
946
|
+
function describeSchema(type) {
|
|
947
|
+
const meta = getConfigurationSchemaMetadata(type);
|
|
948
|
+
if (meta) {
|
|
949
|
+
const shorthand = shorthandTargets(meta);
|
|
950
|
+
return {
|
|
951
|
+
type: meta.name,
|
|
952
|
+
slots: describeSlots(type),
|
|
953
|
+
...(Object.keys(shorthand).length > 0 ? { shorthand } : {}),
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
const bare = mst.unwrapType(type);
|
|
957
|
+
if (mst.isArrayType(bare)) {
|
|
958
|
+
const items = describeSchema(bare.getChildType());
|
|
959
|
+
return items ? { type: 'array', items } : undefined;
|
|
960
|
+
}
|
|
961
|
+
if (mst.isUnionType(bare)) {
|
|
962
|
+
const members = [bare.getSubTypes()]
|
|
963
|
+
.flat()
|
|
964
|
+
.flatMap(sub => sub && typeof sub === 'object' ? [describeSchema(sub)] : [])
|
|
965
|
+
.filter(d => d !== undefined);
|
|
966
|
+
return members.length > 0
|
|
967
|
+
? {
|
|
968
|
+
type: 'union',
|
|
969
|
+
oneOf: Object.fromEntries(members.map(m => [m.type, m])),
|
|
970
|
+
}
|
|
971
|
+
: undefined;
|
|
972
|
+
}
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
// Vocabulary introspection: every config slot a config node's schema defines,
|
|
976
|
+
// read off the schema rather than the live node, so code never has to guess
|
|
977
|
+
// which settings keys exist — an unknown key is not an error, only an entry in
|
|
978
|
+
// applyDisplaySettings' `unapplied` list. A nested object (`facet`, `color`)
|
|
979
|
+
// lists its own slots and the slot its string shorthand lifts into, and a list
|
|
980
|
+
// of them (`marks`, `transform`) its entry's.
|
|
981
|
+
function describeSlots(confOrType) {
|
|
982
|
+
const definition = getConfigurationSchemaDefinition(confOrType) ?? {};
|
|
983
|
+
return Object.fromEntries(Object.entries(definition).flatMap(([name, def]) => {
|
|
984
|
+
if (isSlotDefinitionEntry(def)) {
|
|
985
|
+
return [
|
|
986
|
+
[
|
|
987
|
+
name,
|
|
988
|
+
{
|
|
989
|
+
type: def.type,
|
|
990
|
+
...(def.description ? { description: def.description } : {}),
|
|
991
|
+
defaultValue: def.defaultValue,
|
|
992
|
+
},
|
|
993
|
+
],
|
|
994
|
+
];
|
|
995
|
+
}
|
|
996
|
+
const described = mst.isType(def) ? describeSchema(def) : undefined;
|
|
997
|
+
return described ? [[name, described]] : [];
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
// The raw primitive under all of the above: Claude-authored code against the
|
|
1001
|
+
// live model graph. The renderer already runs with nodeIntegration and the
|
|
1002
|
+
// bridge socket is user-only, so this grants what the surface as a whole
|
|
1003
|
+
// already grants — expressed directly instead of through a curated verb.
|
|
1004
|
+
// The re-export registry is what pluginManager.jbrequire serves, the modules
|
|
1005
|
+
// external plugins link against. It stays empty until a runtime plugin loads,
|
|
1006
|
+
// and a loaded plugin fills it with the product's map, which serves every
|
|
1007
|
+
// bundled @jbrowse package rather than core alone — so this fills only an
|
|
1008
|
+
// empty registry.
|
|
1009
|
+
export async function ensureReExports() {
|
|
1010
|
+
if (Object.keys(getReExportRegistry()).length === 0) {
|
|
1011
|
+
setReExportRegistry((await import('@jbrowse/core/ReExports/modules')).default);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// The front door for an agent that just found `jb` and knows nothing else —
|
|
1015
|
+
// jbrowse-web's console banner and meta tag point here, because a browser
|
|
1016
|
+
// agent has no MCP docs tool and no initialize instructions to learn the
|
|
1017
|
+
// contract from.
|
|
1018
|
+
const JB_HELP = `jb drives this JBrowse app programmatically (window.jb in a browser; the same object is the "jb" argument of JBrowse Desktop's run_javascript MCP tool).
|
|
1019
|
+
|
|
1020
|
+
Orient first: jb.sessionSummary(). Introspect, never guess: jb.listTracks(search?, limit?) answers { total, tracks } with the trackIds; jb.describeSlots(jb.trackModel('someTrackId').activeDisplay.configuration) for the settings keys a display accepts — an unknown settings key is not an error, it lands in applyDisplaySettings' "unapplied" list as { key, reason }, so read the report; jb.inspect(node) — jb.view(), jb.trackModel('someTrackId'), session.views[0] — for its getters, actions and modelType.
|
|
1021
|
+
|
|
1022
|
+
The model is mobx-state-tree: mutate only through actions (raw assignment throws), and write display settings with track.applyDisplaySettings(settings). A feature or variant track groups and colors through two settings, track.applyDisplaySettings({ facet: 'strand' or { field, domain? }, color: a CSS color or { field: 'type', domain?, range? } }), where null resets any setting; it filters with activeDisplay.setFilter(['jexl:feature.score > 10']). An alignments track takes the same facet, its field a read dimension ('pairOrientation', 'mapq', ...) or a tag ('tags.HP'). LinearMarkDisplay plots a track's features as ggplot2 does, on a feature, alignments, variant or quantitative track, in five settings: marks, a list drawn in order of { mark: 'bar' | 'point' | 'rule' | 'line' | 'span' | 'text' | 'link', encoding: { y: 'score', x?, x2?, row?, color?, shape?, text?, size? }, each a feature field or a jexl: expression, a constant written { value: 'red' }, transform?: steps such as { type: 'coverage' } or a bin then an aggregate, which fill the y they write, minBpPerPx?, maxBpPerPx? }, beside transform, facet, rows and scales ({ y: { type, domainMin, domainMax, autoscaleGroup, title } }). await view.launchTrack(trackId, {}, { type: 'LinearMarkDisplay', marks: [...] }) draws a track with it, shown already or not (on a GWASTrack, type 'LinearManhattanDisplay'). Its markPlot is those five as JSON: edit structuredClone(activeDisplay.markPlot), check it with activeDisplay.plotProblems(plot), and apply it with applyDisplaySettings(plot), on this track or another track's mark display; activeDisplay.notices names a mark the config cannot draw, or one whose field no loaded feature holds. Build views declaratively with jb.loadSessionSpec({ views: [{ type: 'LinearGenomeView', assembly, loc, tracks: [...] }] }), which replaces the session; jb.addView(oneViewSpec) opens one more view beside what is open; jb.setSession(document) rewrites the session as a document — what jb.mst.getSnapshot(jb.session) answers, edited: a view keeps its id and is patched in place, loc on it navigates, a { trackId } entry in its tracks opens that track. Act on a view with await view.navToLocString('BRCA1' or 'chr1:1-1000'), which also SHOWS the track whose search index answered a gene name unless a 4th arg { showHitTrack: false } says not to; await view.launchTrack(trackId, {}, settings) shows a track with settings, view.hideTrack(trackId) hides it. Arrange open views into panels with session.layoutViews({ direction: 'horizontal', children: [{ views: [viewId] }, ...] }), a leaf naming view ids or session.views indexes; await jb.fitToWindow() shrinks what is open until the session fits the window; add data with jb.addTrack({ location }) (an absolute path or a URL), or show a track the catalog already holds with jb.addTrack({ trackId, settings? }); read data with await jb.getFeatures({ trackId, loc?, assembly?, viewId?, regions?, byteLimit? }), which renames refNames ("chr1" vs "1") so the file answers and reads on the worker the track's display uses. Anything lower level is jb.require('@jbrowse/core/util') and friends, the module registry plugins link against. After changing anything, await jb.waitReady(ms) and read its notifications and notReady lists before trusting the screen.
|
|
1023
|
+
|
|
1024
|
+
Views nest and several can be open. jb.view(viewId?) is the open view, and jb.view(), jb.trackModel(trackId), jb.visibleRegions(), jb.addTrack and jb.getFeatures over a visible region throw naming the candidates rather than picking one when more than one view could answer — pass viewId (from jb.sessionSummary()) to say which. A synteny or breakpoint view's viewId also covers its rows: the named view answers when it can, and otherwise its rows do.
|
|
1025
|
+
|
|
1026
|
+
Full guide: https://jbrowse.org/jb2/docs/agents_live_model (offline in JBrowse Desktop's MCP docs tool).`;
|
|
1027
|
+
// The helper library an agent drives the app through. Built from the plugin
|
|
1028
|
+
// manager alone, so one of these serves a whole app rather than one session —
|
|
1029
|
+
// which is what lets jbrowse-web hand the same object to every caller for the
|
|
1030
|
+
// life of a plugin manager.
|
|
1031
|
+
export function createJbApi(pluginManager, options = {}) {
|
|
1032
|
+
// Resolved per call, never captured: jb.loadSessionSpec REPLACES the session,
|
|
1033
|
+
// and a helper bound to the old one keeps answering from a detached tree —
|
|
1034
|
+
// which reads as stale data rather than throwing, so the agent is told about
|
|
1035
|
+
// a session that no longer exists. `jb.session` is how code re-reads it after
|
|
1036
|
+
// a spec load.
|
|
1037
|
+
const live = () => {
|
|
1038
|
+
const current = sessionOf(pluginManager);
|
|
1039
|
+
if (!current) {
|
|
1040
|
+
throw new Error('No session is open, so there is nothing for this helper to read. Build one with jb.loadSessionSpec({ views: [...] }), which needs no session of its own, or load a config with the open tool.');
|
|
1041
|
+
}
|
|
1042
|
+
return current;
|
|
1043
|
+
};
|
|
1044
|
+
// Every settle an agent can reach goes through here, so a notification
|
|
1045
|
+
// cannot be consumed by one of these helpers without the caller hearing
|
|
1046
|
+
// about it.
|
|
1047
|
+
// a declaration, not a generic arrow: `<T>(x: T) => …` in a .ts file is a
|
|
1048
|
+
// JSX tag to babel, which is what jest parses these with
|
|
1049
|
+
function reported(settle) {
|
|
1050
|
+
const messages = settle.notifications;
|
|
1051
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
1052
|
+
options.onNotifications?.(messages);
|
|
1053
|
+
}
|
|
1054
|
+
return settle;
|
|
1055
|
+
}
|
|
1056
|
+
const jb = {
|
|
1057
|
+
help: JB_HELP,
|
|
1058
|
+
get session() {
|
|
1059
|
+
return live();
|
|
1060
|
+
},
|
|
1061
|
+
get rootModel() {
|
|
1062
|
+
return pluginManager.rootModel;
|
|
1063
|
+
},
|
|
1064
|
+
require: pluginManager.jbrequire,
|
|
1065
|
+
ensureRequire: ensureReExports,
|
|
1066
|
+
mst,
|
|
1067
|
+
mobx,
|
|
1068
|
+
readConfObject,
|
|
1069
|
+
getConf,
|
|
1070
|
+
describeSlots,
|
|
1071
|
+
// a default because an omitted number made the deadline NaN, and a view
|
|
1072
|
+
// that never readied then held the call open to the relay's own timeout
|
|
1073
|
+
waitReady: async (timeoutMs = 30_000) => reported(await waitReady(timeoutMs, live())),
|
|
1074
|
+
sessionSummary: () => sessionSummary(live()),
|
|
1075
|
+
inspect: (target, maxInspectBytes) => inspectSession(live(), { target, maxBytes: maxInspectBytes }),
|
|
1076
|
+
listTracks: (search, limit) => listTracks(live(), search, limit),
|
|
1077
|
+
view: (viewId) => viewById(live(), viewId),
|
|
1078
|
+
trackModel: (trackId, viewId) => shownTrackModel(live(), trackId, viewId),
|
|
1079
|
+
visibleRegions: (viewId) => visibleRegionsOf(live(), viewId),
|
|
1080
|
+
loadSessionSpec: async (spec, settleMs) => reported(await loadSpec(pluginManager, { spec, settleMs })),
|
|
1081
|
+
addView: async (spec, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await addView(pluginManager, live(), spec, settleMs)),
|
|
1082
|
+
setSession: async (document, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await setSession(pluginManager, live(), document, settleMs)),
|
|
1083
|
+
fitToWindow: async (settleMs = 30_000) => reported(await fitToWindow(live(), settleMs)),
|
|
1084
|
+
addTrack: (opts) => addTrack(pluginManager, live(), opts).then(reported),
|
|
1085
|
+
// Two of four filmed takes wrote jb.getFeatures('trackId', loc) and lost a
|
|
1086
|
+
// turn to "No track with trackId undefined", and a third wrote
|
|
1087
|
+
// jb.getFeatures('trackId', loc, { assembly }) and had the options
|
|
1088
|
+
// silently ignored — so the positional form is accepted alongside the
|
|
1089
|
+
// object it was documented as, options third.
|
|
1090
|
+
getFeatures: async (args, positionalLoc, positionalOpts) => {
|
|
1091
|
+
const fetchArgs = typeof args === 'string'
|
|
1092
|
+
? { trackId: args, loc: positionalLoc, ...positionalOpts }
|
|
1093
|
+
: args;
|
|
1094
|
+
const session = live();
|
|
1095
|
+
const conf = session.getTrackById(fetchArgs.trackId);
|
|
1096
|
+
if (!conf) {
|
|
1097
|
+
throw new Error(`No track with trackId "${fetchArgs.trackId}" — jb.listTracks() shows what is available`);
|
|
1098
|
+
}
|
|
1099
|
+
// `regions` entries go through locToRegion as `loc` does. Passed
|
|
1100
|
+
// straight to the fetch they skipped the canonical rename, and an entry
|
|
1101
|
+
// carrying no assemblyName reached renameRegionsIfNeeded with nothing to
|
|
1102
|
+
// rename against — an alias spelling then read empty and said nothing.
|
|
1103
|
+
const regions = fetchArgs.regions
|
|
1104
|
+
? await Promise.all(fetchArgs.regions.map(region => locToRegion(session, conf, region, fetchArgs.assembly)))
|
|
1105
|
+
: fetchArgs.loc !== undefined
|
|
1106
|
+
? [
|
|
1107
|
+
await locToRegion(session, conf, fetchArgs.loc, fetchArgs.assembly),
|
|
1108
|
+
]
|
|
1109
|
+
: await visibleRegionsOf(session, fetchArgs.viewId, fetchArgs.trackId, getConfAssemblyNamesOrNone(conf));
|
|
1110
|
+
return fetchFeatures(session, fetchArgs.trackId, regions, fetchArgs.byteLimit);
|
|
1111
|
+
},
|
|
1112
|
+
};
|
|
1113
|
+
return jb;
|
|
1114
|
+
}
|
|
1115
|
+
// a spec load on a busy machine passed 30s and answered settled:false over
|
|
1116
|
+
// views that were fine; the wait exits the moment they are ready
|
|
1117
|
+
const ADD_TRACK_SETTLE_MS = 60_000;
|
|
1118
|
+
function namesAFile(value) {
|
|
1119
|
+
return Array.isArray(value)
|
|
1120
|
+
? value.some(l => typeof l === 'string' && isFileLocationText(l))
|
|
1121
|
+
: typeof value === 'string' && isFileLocationText(value);
|
|
1122
|
+
}
|
|
1123
|
+
// The name an agent meant as a catalog trackId: `trackId`, or a `location`
|
|
1124
|
+
// that is neither a URL nor a path. Agent runs reached for jb.addTrack to SHOW
|
|
1125
|
+
// a track already in the catalog in both spellings, and each paid a round trip
|
|
1126
|
+
// for a refusal that only named another helper.
|
|
1127
|
+
//
|
|
1128
|
+
// A location that names a file is the file route whatever else came with it:
|
|
1129
|
+
// `trackId` beside `location` is how a track CONFIG spells this, and taking it
|
|
1130
|
+
// as the catalog route would refuse the file the caller asked to add.
|
|
1131
|
+
function catalogName(args) {
|
|
1132
|
+
if (namesAFile(args.location)) {
|
|
1133
|
+
return undefined;
|
|
1134
|
+
}
|
|
1135
|
+
if (typeof args.trackId === 'string') {
|
|
1136
|
+
return args.trackId;
|
|
1137
|
+
}
|
|
1138
|
+
return typeof args.location === 'string' ? args.location : undefined;
|
|
1139
|
+
}
|
|
1140
|
+
async function addTrack(pluginManager, session, args) {
|
|
1141
|
+
const named = catalogName(args);
|
|
1142
|
+
const inCatalog = named === undefined ? undefined : session.getTrackById(named);
|
|
1143
|
+
// before the add-rights check: showing a track the catalog already holds
|
|
1144
|
+
// adds nothing to it
|
|
1145
|
+
if (inCatalog) {
|
|
1146
|
+
return showCatalogTrack(pluginManager, session, inCatalog, args);
|
|
1147
|
+
}
|
|
1148
|
+
if (named !== undefined && typeof args.trackId === 'string') {
|
|
1149
|
+
throw new Error(`No track with trackId "${args.trackId}" — jb.listTracks() shows what the catalog holds, and jb.addTrack({ location }) adds a file to it.`);
|
|
1150
|
+
}
|
|
1151
|
+
if (!isSessionWithAddSessionTrack(session)) {
|
|
1152
|
+
throw new Error('This session cannot add tracks');
|
|
1153
|
+
}
|
|
1154
|
+
const location = locationsOf(args.location);
|
|
1155
|
+
if (!location.length) {
|
|
1156
|
+
throw new Error('jb.addTrack needs a location (an absolute local path or a URL) to add a file, or the trackId of a track already in the catalog to show it — jb.listTracks() lists those.');
|
|
1157
|
+
}
|
|
1158
|
+
const requested = typeof args.assembly === 'string' ? args.assembly : session.assemblyNames[0];
|
|
1159
|
+
if (requested === undefined) {
|
|
1160
|
+
throw new Error('The session has no assemblies to attach the track to');
|
|
1161
|
+
}
|
|
1162
|
+
// resolved to the session's own spelling: an alias ("vvx" for "volvox",
|
|
1163
|
+
// "GRCh38" for "hg38") names the assembly and would otherwise be refused
|
|
1164
|
+
const assembly = session.assemblyNames.find(name => isSameAssemblyName(name, requested, session.assemblyManager));
|
|
1165
|
+
if (assembly === undefined) {
|
|
1166
|
+
throw new Error(`Assembly "${requested}" is not in this session (has: ${session.assemblyNames.join(', ')})`);
|
|
1167
|
+
}
|
|
1168
|
+
const conf = Array.isArray(location)
|
|
1169
|
+
? multiWiggleTrackConf(location, assembly)
|
|
1170
|
+
: guessTrackConfForLocation(fileLocation(location), typeof args.index === 'string' ? fileLocation(args.index) : undefined, pluginManager, assembly);
|
|
1171
|
+
session.addSessionTrackConf({
|
|
1172
|
+
...conf,
|
|
1173
|
+
...(typeof args.name === 'string' ? { name: args.name } : {}),
|
|
1174
|
+
});
|
|
1175
|
+
const summary = {
|
|
1176
|
+
trackId: conf.trackId,
|
|
1177
|
+
trackType: conf.type,
|
|
1178
|
+
adapterType: conf.adapter.type,
|
|
1179
|
+
assembly,
|
|
1180
|
+
};
|
|
1181
|
+
return args.show === false
|
|
1182
|
+
? summary
|
|
1183
|
+
: {
|
|
1184
|
+
...summary,
|
|
1185
|
+
...(await showTrack(pluginManager, session, {
|
|
1186
|
+
trackId: conf.trackId,
|
|
1187
|
+
trackType: conf.type,
|
|
1188
|
+
adapter: conf.adapter,
|
|
1189
|
+
assembly,
|
|
1190
|
+
}, args)),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
// A track already in the catalog, shown: the same view choice and the same
|
|
1194
|
+
// settle the file route ends in.
|
|
1195
|
+
// `conf` is whatever `session.getTrackById` answered, which is a track config
|
|
1196
|
+
// but not necessarily a `BaseTrackConfig` — an assembly's ReferenceSequenceTrack
|
|
1197
|
+
// declares a subset of those slots. The four reads below are all ones it has.
|
|
1198
|
+
async function showCatalogTrack(pluginManager, session, conf, args) {
|
|
1199
|
+
// index, assembly and name describe the file being added, and the catalog's
|
|
1200
|
+
// copy already has all three — taking them here would drop them in silence
|
|
1201
|
+
const fileArgs = ['index', 'assembly', 'name'].filter(key => args[key] !== undefined);
|
|
1202
|
+
if (fileArgs.length) {
|
|
1203
|
+
throw new Error(`"${conf.trackId}" is in the catalog already, so ${fileArgs.join(' and ')} would be ignored — drop ${fileArgs.length > 1 ? 'them' : 'it'}, or pass a location to add a new file.`);
|
|
1204
|
+
}
|
|
1205
|
+
const track = {
|
|
1206
|
+
trackId: conf.trackId,
|
|
1207
|
+
trackType: conf.type,
|
|
1208
|
+
assembly: getConfAssemblyNamesOrNone(conf)[0],
|
|
1209
|
+
};
|
|
1210
|
+
const adapterType = readConfObject(conf, ['adapter', 'type']);
|
|
1211
|
+
const summary = { ...track, ...(adapterType ? { adapterType } : {}) };
|
|
1212
|
+
// show:false over a catalog trackId asks for nothing — there is no file to
|
|
1213
|
+
// add — and showing it anyway would contradict the flag
|
|
1214
|
+
return args.show === false
|
|
1215
|
+
? summary
|
|
1216
|
+
: {
|
|
1217
|
+
...summary,
|
|
1218
|
+
...(await showTrack(pluginManager, session, { ...track, adapter: { type: adapterType } }, args)),
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
function isSettings(value) {
|
|
1222
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1223
|
+
}
|
|
1224
|
+
async function showTrack(pluginManager, session, track, args) {
|
|
1225
|
+
const view = pickView(session, args, 'launchTrack', {
|
|
1226
|
+
assembly: track.assembly,
|
|
1227
|
+
track: { type: track.trackType, adapter: track.adapter },
|
|
1228
|
+
pluginManager,
|
|
1229
|
+
});
|
|
1230
|
+
// awaited: a display state model is lazy in every plugin here, and the
|
|
1231
|
+
// synchronous showTrack answers before the chunk lands — so the settle below
|
|
1232
|
+
// would run against a session that does not hold the track yet, and report
|
|
1233
|
+
// ready over it
|
|
1234
|
+
await viewSelf(view).launchTrack(track.trackId, {}, isSettings(args.settings) ? args.settings : undefined);
|
|
1235
|
+
// 0 skips the settle, so several adds can share one jb.waitReady
|
|
1236
|
+
const settleMs = typeof args.settleMs === 'number' ? args.settleMs : ADD_TRACK_SETTLE_MS;
|
|
1237
|
+
return {
|
|
1238
|
+
shownInView: view.id,
|
|
1239
|
+
...(settleMs > 0 ? await waitReady(settleMs, session) : {}),
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* One view from one spec entry, into the open session, through the same
|
|
1244
|
+
* launcher extension point `jb.loadSessionSpec` takes for each of its views —
|
|
1245
|
+
* so the entry means the same thing in both places, a ProteinView's
|
|
1246
|
+
* `connectedView` included. `session.launchView` is the other route and does
|
|
1247
|
+
* not: it is `addView` with a snapshot, so it never runs a view's launcher.
|
|
1248
|
+
*
|
|
1249
|
+
* A key the view does not take throws before anything opens. A spec has no
|
|
1250
|
+
* return channel and launches anyway with an error toast; an awaited call has
|
|
1251
|
+
* one, and a view that opened without the setting the agent typed is the
|
|
1252
|
+
* quietly-wrong answer this surface exists to refuse.
|
|
1253
|
+
*/
|
|
1254
|
+
async function addView(pluginManager, session, spec, settleMs) {
|
|
1255
|
+
if (typeof spec !== 'object' || typeof spec.type !== 'string') {
|
|
1256
|
+
throw new Error('jb.addView takes one view spec — an entry of a session spec\'s "views" array, e.g. { type: "LinearGenomeView", assembly, loc, tracks } (docs topic "session-spec")');
|
|
1257
|
+
}
|
|
1258
|
+
const unlaunchable = viewTypeProblem(pluginManager, spec.type);
|
|
1259
|
+
if (unlaunchable) {
|
|
1260
|
+
throw new Error(unlaunchable);
|
|
1261
|
+
}
|
|
1262
|
+
const { view: entry, problem } = await launchableSpecView(pluginManager, spec);
|
|
1263
|
+
if (problem) {
|
|
1264
|
+
throw new Error(`${problem} — nothing was opened. docs topic "session-spec" lists the keys ${entry.type} takes`);
|
|
1265
|
+
}
|
|
1266
|
+
const { created, view, failure } = await launchSpecView(session, pluginManager, entry);
|
|
1267
|
+
if (failure) {
|
|
1268
|
+
throw new Error(failure.message, { cause: failure.cause });
|
|
1269
|
+
}
|
|
1270
|
+
if (!view) {
|
|
1271
|
+
throw new Error(`${entry.type} opened no view`);
|
|
1272
|
+
}
|
|
1273
|
+
const summary = {
|
|
1274
|
+
viewId: view.id,
|
|
1275
|
+
...(created.length > 1 ? { created: created.map(v => v.id) } : {}),
|
|
1276
|
+
};
|
|
1277
|
+
return settleMs > 0
|
|
1278
|
+
? { ...summary, ...(await waitReady(settleMs, session)) }
|
|
1279
|
+
: summary;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* The session as one document, rewritten: what `getSnapshot(session)` answers,
|
|
1283
|
+
* edited, handed back. MST reconciles by identifier, so a view, track or
|
|
1284
|
+
* display whose `id` the document keeps is patched in place and stays mounted,
|
|
1285
|
+
* one the document drops is closed, and an entry with no id is new. A view's
|
|
1286
|
+
* launch keys are accepted beside its built state — `loc` navigates it, a
|
|
1287
|
+
* `{ trackId }` entry in its `tracks` opens that track — because the partition
|
|
1288
|
+
* that sorts them runs on every snapshot the view is given, not only its
|
|
1289
|
+
* first.
|
|
1290
|
+
*
|
|
1291
|
+
* Top-level keys the document leaves out keep their current value, so
|
|
1292
|
+
* `{ views: [...] }` is a complete instruction.
|
|
1293
|
+
*
|
|
1294
|
+
* Views leave through the session's own detach first (ADR-069): `applySnapshot`
|
|
1295
|
+
* destroys what the target lacks in place, under components still mounted
|
|
1296
|
+
* over it, which is what undo had to route around.
|
|
1297
|
+
*/
|
|
1298
|
+
// A key nothing on the far side takes, per view entry. MST drops one silently,
|
|
1299
|
+
// and the partition that names it runs at attach — which a view the document
|
|
1300
|
+
// KEEPS never reaches, so the commonest edit was the quiet one.
|
|
1301
|
+
//
|
|
1302
|
+
// Reported, not refused, like the same mistake on a session spec: a document
|
|
1303
|
+
// that works today must not cost the whole call. `acceptedKeys` answers for a
|
|
1304
|
+
// legacy spelling too — a view's `passThrough` declares the ones its own
|
|
1305
|
+
// preProcessSnapshot converts, which is what makes this a question about the
|
|
1306
|
+
// view type rather than about whatever members the live node happens to have.
|
|
1307
|
+
function unknownViewKeys(pluginManager, entry) {
|
|
1308
|
+
if (typeof entry !== 'object' ||
|
|
1309
|
+
entry === null ||
|
|
1310
|
+
typeof entry.type !== 'string') {
|
|
1311
|
+
return [];
|
|
1312
|
+
}
|
|
1313
|
+
const { type, views, ...keyed } = entry;
|
|
1314
|
+
const nested = Array.isArray(views)
|
|
1315
|
+
? views.flatMap(view => unknownViewKeys(pluginManager, view))
|
|
1316
|
+
: [];
|
|
1317
|
+
if (!pluginManager.getElementTypeRecord('view').has(type)) {
|
|
1318
|
+
return nested;
|
|
1319
|
+
}
|
|
1320
|
+
const viewType = pluginManager.getViewType(type);
|
|
1321
|
+
const accepted = viewType.acceptedKeys ??
|
|
1322
|
+
Object.keys(viewType.stateModel.properties);
|
|
1323
|
+
const unknown = Object.keys(keyed).filter(key => !accepted.includes(key));
|
|
1324
|
+
return [
|
|
1325
|
+
...(unknown.length
|
|
1326
|
+
? [
|
|
1327
|
+
`${unknownKeysMessage(type, unknown)}${viewType.launchKeys
|
|
1328
|
+
? ''
|
|
1329
|
+
: ' — a document runs no launcher, so its launch keys go through jb.addView'}`,
|
|
1330
|
+
]
|
|
1331
|
+
: []),
|
|
1332
|
+
...nested,
|
|
1333
|
+
];
|
|
1334
|
+
}
|
|
1335
|
+
async function setSession(pluginManager, session, document, settleMs) {
|
|
1336
|
+
if (typeof document !== 'object' ||
|
|
1337
|
+
document === null ||
|
|
1338
|
+
Array.isArray(document)) {
|
|
1339
|
+
throw new Error('jb.setSession takes the session as a document: what jb.mst.getSnapshot(jb.session) answers, edited. Top-level keys left out keep their current value.');
|
|
1340
|
+
}
|
|
1341
|
+
const next = {
|
|
1342
|
+
...getSnapshot(session),
|
|
1343
|
+
...document,
|
|
1344
|
+
};
|
|
1345
|
+
// a lazily registered view or display type is not in the session's type union
|
|
1346
|
+
// until its model loads, and applySnapshot is synchronous
|
|
1347
|
+
await pluginManager.preloadSessionTypes(next);
|
|
1348
|
+
for (const problem of (Array.isArray(next.views) ? next.views : []).flatMap(entry => unknownViewKeys(pluginManager, entry))) {
|
|
1349
|
+
session.notifyError(problem);
|
|
1350
|
+
}
|
|
1351
|
+
try {
|
|
1352
|
+
;
|
|
1353
|
+
session.takeOutViewsMissingFrom?.(next);
|
|
1354
|
+
applySnapshot(session, next);
|
|
1355
|
+
}
|
|
1356
|
+
catch (e) {
|
|
1357
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
1358
|
+
throw new Error(`jb.setSession refused the document: ${detail.length > 1500 ? `${detail.slice(0, 1500)}…` : detail}`, { cause: e });
|
|
1359
|
+
}
|
|
1360
|
+
const settle = await waitReady(settleMs, session);
|
|
1361
|
+
return { ...settle, session: sessionSummary(session) };
|
|
1362
|
+
}
|
|
1363
|
+
// Under the 45 s one evaluation gets from the Claude in Chrome extension: a
|
|
1364
|
+
// cold hosted config settling for the full minute answered nothing there,
|
|
1365
|
+
// while the settle result already names what is still not ready
|
|
1366
|
+
const SPEC_SETTLE_DEFAULT_MS = 30_000;
|
|
1367
|
+
async function loadSpec(pluginManager, args) {
|
|
1368
|
+
const spec = args.spec;
|
|
1369
|
+
const valid = typeof spec === 'object' &&
|
|
1370
|
+
spec !== null &&
|
|
1371
|
+
Array.isArray(spec.views);
|
|
1372
|
+
if (!valid) {
|
|
1373
|
+
throw new Error('loadSessionSpec needs a spec object with a views array');
|
|
1374
|
+
}
|
|
1375
|
+
await loadSessionSpec(spec, pluginManager);
|
|
1376
|
+
// the session was REPLACED by the spec load — settle against the new one
|
|
1377
|
+
const session = sessionOf(pluginManager);
|
|
1378
|
+
const settle = await waitReady(args.settleMs ?? SPEC_SETTLE_DEFAULT_MS, session);
|
|
1379
|
+
return {
|
|
1380
|
+
...settle,
|
|
1381
|
+
...(session ? { session: sessionSummary(session) } : {}),
|
|
1382
|
+
};
|
|
1383
|
+
}
|