@jbrowse/app-core 5.0.0-beta.8 → 5.0.0-beta.9
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/AppSessionMixin.d.ts +2 -8
- package/esm/Assemblies/AssembliesMixin.d.ts +170 -92
- package/esm/JBrowseConfig/RootConfiguration.d.ts +78 -126
- package/esm/JBrowseConfig/RootConfiguration.js +5 -11
- package/esm/JBrowseConfig/defaultShareUrl.d.ts +1 -0
- package/esm/JBrowseConfig/defaultShareUrl.js +5 -0
- package/esm/JBrowseConfig/index.d.ts +79 -79
- package/esm/JBrowseConfig/index.js +1 -1
- package/esm/JBrowseModel/index.d.ts +79 -82
- package/esm/JBrowseModel/index.js +19 -10
- package/esm/JbApi/jbApi.d.ts +1069 -33
- package/esm/JbApi/jbApi.js +508 -138
- package/esm/SessionSpec/index.d.ts +1 -1
- package/esm/SessionSpec/index.js +1 -1
- package/esm/SessionSpec/loadSessionSpec.d.ts +58 -2
- package/esm/SessionSpec/loadSessionSpec.js +173 -93
- package/esm/SessionSpec/types.d.ts +6 -0
- package/esm/WorkspaceLayout/model.d.ts +8 -8
- package/esm/WorkspaceLayout/model.js +8 -8
- package/esm/ui/App/App.js +1 -2
- package/esm/ui/App/ViewStack.js +7 -1
- package/esm/ui/App/index.d.ts +1 -1
- package/esm/ui/App/index.js +1 -1
- package/package.json +4 -4
- package/esm/ui/App/AppReadyMarker.d.ts +0 -43
- package/esm/ui/App/AppReadyMarker.js +0 -78
package/esm/JbApi/jbApi.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
|
-
import { setReExportRegistry } from '@jbrowse/core/ReExports/registry';
|
|
2
|
-
import { getConf, getConfigurationSchemaDefinition, isSlotDefinitionEntry, readConfObject, } from '@jbrowse/core/configuration';
|
|
1
|
+
import { getReExportRegistry, setReExportRegistry, } from '@jbrowse/core/ReExports/registry';
|
|
2
|
+
import { getConf, getConfigurationSchemaDefinition, getConfigurationSchemaOptions, isConfigurationSubschema, isSlotDefinitionEntry, readConfObject, } from '@jbrowse/core/configuration';
|
|
3
3
|
import { releaseAdapterSession, retainAdapterSession, } from '@jbrowse/core/data_adapters/adapterSessionRefcount';
|
|
4
4
|
import { adapterConfigCacheKey } from '@jbrowse/core/data_adapters/dataAdapterCache';
|
|
5
|
-
import { getFeatureAdapterOrThrow } from '@jbrowse/core/data_adapters/getFeatureAdapter';
|
|
6
5
|
import { adapterByteLimit } from '@jbrowse/core/rpc/byteBudget';
|
|
7
|
-
import {
|
|
8
|
-
import { openTracks, openViews } from '@jbrowse/core/util/openViews';
|
|
9
|
-
import { createStopToken, stopStopToken } from '@jbrowse/core/util/stopToken';
|
|
6
|
+
import { isElectron, isSessionWithAddSessionTrack, objectHash, parseLocString, } from '@jbrowse/core/util';
|
|
7
|
+
import { openTracks, openViews, viewAndNested, } from '@jbrowse/core/util/openViews';
|
|
10
8
|
import { allSessionTracks, getConfAssemblyNamesOrNone, guessTrackConfForLocation, isSameAssemblyName, viewCanDisplayTrack, viewDisplayNames, } from '@jbrowse/core/util/tracks';
|
|
9
|
+
import { unknownKeysMessage } from '@jbrowse/core/util/withLaunchInput';
|
|
11
10
|
import * as mst from '@jbrowse/mobx-state-tree';
|
|
12
|
-
import { getSnapshot, getType, isStateTreeNode } from '@jbrowse/mobx-state-tree';
|
|
11
|
+
import { applySnapshot, getSnapshot, getType, isStateTreeNode, } from '@jbrowse/mobx-state-tree';
|
|
13
12
|
import * as mobx from 'mobx';
|
|
14
13
|
// relative, not '@jbrowse/app-core': a package self-import would make this
|
|
15
14
|
// module depend on the barrel that exports it
|
|
16
|
-
import { loadSessionSpec } from '../SessionSpec/index.js';
|
|
15
|
+
import { launchSpecView, launchableSpecView, loadSessionSpec, viewTypeProblem, } from '../SessionSpec/index.js';
|
|
17
16
|
// Matches BaseTrackModel's DEFAULT_EXPORT_BYTE_LIMIT: both bound a fetch
|
|
18
17
|
// someone asked for by name rather than one a view scrolled into, so they
|
|
19
18
|
// should refuse at the same size.
|
|
@@ -66,6 +65,7 @@ function displayState(track) {
|
|
|
66
65
|
...(display.renderError
|
|
67
66
|
? { renderError: String(display.renderError) }
|
|
68
67
|
: {}),
|
|
68
|
+
...(display.notices?.length ? { notices: [...display.notices] } : {}),
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
catch (e) {
|
|
@@ -115,16 +115,20 @@ export function sessionOf(pluginManager) {
|
|
|
115
115
|
* The three terms are AppReadyMarker's, and they have to be: the marker decides
|
|
116
116
|
* whether the settle ends, this decides what it says when it doesn't, and a
|
|
117
117
|
* term in one and not the other is a settle that answers `false` with an empty
|
|
118
|
-
* `notReady
|
|
119
|
-
* the
|
|
120
|
-
*
|
|
121
|
-
* reason at all.
|
|
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.
|
|
122
126
|
*/
|
|
123
127
|
function viewState(v) {
|
|
124
128
|
if (v.error !== undefined) {
|
|
125
129
|
return { error: String(v.error) };
|
|
126
130
|
}
|
|
127
|
-
if (v.initialized === false) {
|
|
131
|
+
if (v.initialized === false && v.showImportForm !== true) {
|
|
128
132
|
return { phase: 'initializing' };
|
|
129
133
|
}
|
|
130
134
|
return v.pendingLaunch === undefined
|
|
@@ -157,8 +161,8 @@ function viewSummary(view) {
|
|
|
157
161
|
})),
|
|
158
162
|
}
|
|
159
163
|
: {}),
|
|
160
|
-
...(
|
|
161
|
-
? { views:
|
|
164
|
+
...(view.ownViews.length
|
|
165
|
+
? { views: view.ownViews.map(sub => viewSummary(sub)) }
|
|
162
166
|
: {}),
|
|
163
167
|
};
|
|
164
168
|
}
|
|
@@ -232,27 +236,25 @@ function memberNames(node) {
|
|
|
232
236
|
}
|
|
233
237
|
return { getters: getters.sort(), methods: methods.sort() };
|
|
234
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
|
+
}
|
|
235
250
|
function inspectSession(session, args) {
|
|
236
|
-
const path = typeof args.path === 'string' ? args.path : '';
|
|
237
251
|
const maxBytes = typeof args.maxBytes === 'number' ? args.maxBytes : 20_000;
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
for (const segment of path.split('.').filter(Boolean)) {
|
|
241
|
-
node =
|
|
242
|
-
node !== null && typeof node === 'object'
|
|
243
|
-
? node[segment]
|
|
244
|
-
: undefined;
|
|
245
|
-
if (node === undefined) {
|
|
246
|
-
throw new Error(`Nothing at "${path}" (undefined after "${walked.join('.') || '(root)'}"). Inspect the parent path to see its keys and getters.`);
|
|
247
|
-
}
|
|
248
|
-
walked.push(segment);
|
|
249
|
-
}
|
|
250
|
-
const members = node !== null && typeof node === 'object' && !Array.isArray(node)
|
|
252
|
+
const node = inspectTarget(session, args.target);
|
|
253
|
+
const members = typeof node === 'object' && !Array.isArray(node)
|
|
251
254
|
? memberNames(node)
|
|
252
255
|
: { getters: [], methods: [] };
|
|
253
256
|
const json = safeJson(node);
|
|
254
257
|
const base = {
|
|
255
|
-
path: path || '(session root)',
|
|
256
258
|
// the name the docs tool files a type under: docs topic "model:<modelType>"
|
|
257
259
|
...(isStateTreeNode(node) ? { modelType: getType(node).name } : {}),
|
|
258
260
|
bytes: json.length,
|
|
@@ -266,12 +268,12 @@ function inspectSession(session, args) {
|
|
|
266
268
|
return Array.isArray(plain)
|
|
267
269
|
? {
|
|
268
270
|
...base,
|
|
269
|
-
note: `too large to return whole — ${plain.length} items;
|
|
271
|
+
note: `too large to return whole — ${plain.length} items; inspect one of them or raise maxBytes`,
|
|
270
272
|
items: plain.slice(0, 20).map(item => describeBrief(item)),
|
|
271
273
|
}
|
|
272
274
|
: {
|
|
273
275
|
...base,
|
|
274
|
-
note: 'too large to return whole —
|
|
276
|
+
note: 'too large to return whole — inspect a child of it or raise maxBytes',
|
|
275
277
|
keys: Object.fromEntries(Object.entries(plain).map(([k, v]) => [
|
|
276
278
|
k,
|
|
277
279
|
describeBrief(v),
|
|
@@ -325,7 +327,8 @@ root = document) {
|
|
|
325
327
|
let outcome;
|
|
326
328
|
while (outcome === undefined) {
|
|
327
329
|
const ready = root.querySelector('[data-app-phase="ready"]') !== null &&
|
|
328
|
-
root.querySelector('[data-testid="loading-overlay"]') === null
|
|
330
|
+
root.querySelector('[data-testid="loading-overlay"]') === null &&
|
|
331
|
+
root.querySelector('[data-display-animating="true"]') === null;
|
|
329
332
|
readySince = ready ? (readySince ?? Date.now()) : undefined;
|
|
330
333
|
if (readySince !== undefined && Date.now() - readySince >= READY_HOLD_MS) {
|
|
331
334
|
outcome = { settled: true };
|
|
@@ -355,7 +358,7 @@ root = document) {
|
|
|
355
358
|
.filter(v => 'phase' in v || 'error' in v),
|
|
356
359
|
...allTracks(session)
|
|
357
360
|
.map(t => ({ trackId: t.configuration.trackId, ...displayState(t) }))
|
|
358
|
-
.filter(t => 'phase' in t || 'error' in t),
|
|
361
|
+
.filter(t => 'phase' in t || 'error' in t || 'notices' in t),
|
|
359
362
|
]
|
|
360
363
|
: [];
|
|
361
364
|
const offscreen = session ? offscreenViews(session, root) : undefined;
|
|
@@ -375,7 +378,7 @@ root = document) {
|
|
|
375
378
|
function offscreenViews(session, root) {
|
|
376
379
|
const win = root.ownerDocument?.defaultView ?? window;
|
|
377
380
|
const windowHeight = win.innerHeight;
|
|
378
|
-
const
|
|
381
|
+
const measured = overflow(root);
|
|
379
382
|
const containers = new Map([
|
|
380
383
|
...root.querySelectorAll('[data-testid^="view-container-"]'),
|
|
381
384
|
].map(el => [el.dataset.testid.slice('view-container-'.length), el]));
|
|
@@ -391,23 +394,154 @@ function offscreenViews(session, root) {
|
|
|
391
394
|
]
|
|
392
395
|
: [];
|
|
393
396
|
});
|
|
394
|
-
return pageHeight > windowHeight || views.length
|
|
397
|
+
return measured.pageHeight > measured.windowHeight || views.length
|
|
395
398
|
? {
|
|
396
|
-
pageHeight,
|
|
397
|
-
windowHeight,
|
|
399
|
+
pageHeight: measured.pageHeight,
|
|
400
|
+
windowHeight: measured.windowHeight,
|
|
398
401
|
scrollY: Math.round(win.scrollY),
|
|
399
402
|
views,
|
|
400
|
-
note: 'the session is taller than the window; a viewport screenshot cuts these views off — shrink
|
|
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',
|
|
401
404
|
}
|
|
402
405
|
: undefined;
|
|
403
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(root) {
|
|
411
|
+
const container = root.querySelector('[data-testid^="view-container-"]');
|
|
412
|
+
const win = root.ownerDocument?.defaultView ?? window;
|
|
413
|
+
for (let el = container?.parentElement; el; el = el.parentElement) {
|
|
414
|
+
const { overflowY } = win.getComputedStyle(el);
|
|
415
|
+
if ((overflowY === 'auto' || overflowY === 'scroll') &&
|
|
416
|
+
el.scrollHeight > el.clientHeight) {
|
|
417
|
+
return el;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return undefined;
|
|
421
|
+
}
|
|
422
|
+
// The trailing overscroll ViewStack renders below the last view, which exists so
|
|
423
|
+
// a newly-added view can scroll to the middle of the port. It is room, not
|
|
424
|
+
// content, and it is up to 300px of a scrollHeight, so a shrink that charges it
|
|
425
|
+
// to the views leaves them that much short of the window — the E. coli take's
|
|
426
|
+
// dotplot was cut to 312px of the 600 that fit.
|
|
427
|
+
function overscrollHeight(scroller) {
|
|
428
|
+
const spacer = scroller.querySelector('[data-testid="view-stack-overscroll"]');
|
|
429
|
+
return spacer ? Math.round(spacer.getBoundingClientRect().height) : 0;
|
|
430
|
+
}
|
|
431
|
+
function overflow(root) {
|
|
432
|
+
const scroller = scrollerOf(root);
|
|
433
|
+
if (scroller) {
|
|
434
|
+
return {
|
|
435
|
+
pageHeight: scroller.scrollHeight - overscrollHeight(scroller),
|
|
436
|
+
windowHeight: scroller.clientHeight,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
const win = root.ownerDocument?.defaultView ?? window;
|
|
440
|
+
return {
|
|
441
|
+
pageHeight: win.document.documentElement.scrollHeight,
|
|
442
|
+
windowHeight: win.innerHeight,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// What a shrink can leave a display, a synteny band or a dotplot at: enough to
|
|
446
|
+
// still read a track's name and see that it drew.
|
|
447
|
+
const MIN_HEIGHT_PX = 40;
|
|
448
|
+
// Everything on screen whose height is its own state: each shown track's
|
|
449
|
+
// display, each synteny band, and a view whose height is a declared property
|
|
450
|
+
// rather than the sum of its tracks (a dotplot). A linear view's `height` is a
|
|
451
|
+
// getter over its tracks, so shrinking it is shrinking them.
|
|
452
|
+
function shrinkables(session) {
|
|
453
|
+
return openViews(session).flatMap(view => {
|
|
454
|
+
const v = view;
|
|
455
|
+
const declared = isStateTreeNode(view) &&
|
|
456
|
+
getType(view).properties
|
|
457
|
+
?.height !== undefined;
|
|
458
|
+
const own = declared && typeof v.height === 'number' && v.setHeight
|
|
459
|
+
? [{ what: `view ${v.id}`, height: v.height, setHeight: v.setHeight }]
|
|
460
|
+
: [];
|
|
461
|
+
const bands = (v.levels ?? []).flatMap((level, i) => typeof level.height === 'number' && level.setHeight
|
|
462
|
+
? [
|
|
463
|
+
{
|
|
464
|
+
what: `view ${v.id} band ${i}`,
|
|
465
|
+
height: level.height,
|
|
466
|
+
setHeight: level.setHeight,
|
|
467
|
+
},
|
|
468
|
+
]
|
|
469
|
+
: []);
|
|
470
|
+
const displays = viewTracks(view).flatMap(track => {
|
|
471
|
+
const d = track.activeDisplay;
|
|
472
|
+
return d && typeof d.height === 'number' && d.setHeight
|
|
473
|
+
? [
|
|
474
|
+
{
|
|
475
|
+
what: track.configuration.trackId,
|
|
476
|
+
height: d.height,
|
|
477
|
+
setHeight: d.setHeight,
|
|
478
|
+
},
|
|
479
|
+
]
|
|
480
|
+
: [];
|
|
481
|
+
});
|
|
482
|
+
return [...own, ...bands, ...displays];
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Shrink what is open until the session fits the window, and say what moved.
|
|
487
|
+
*
|
|
488
|
+
* Every filmed take spent two to five screenshot rounds on this by hand: the
|
|
489
|
+
* settle reported `offscreen`, the agent guessed which heights to cut and by
|
|
490
|
+
* how much, and looked again. The overflow is arithmetic the settle already
|
|
491
|
+
* does; this spends it across every display, band and dotplot in proportion to
|
|
492
|
+
* the headroom each has above the floor, so tall tracks give up the most and
|
|
493
|
+
* nothing goes below readable.
|
|
494
|
+
*/
|
|
495
|
+
async function fitToWindow(session, settleMs, root = document) {
|
|
496
|
+
const before = overflow(root);
|
|
497
|
+
const excess = before.pageHeight - before.windowHeight;
|
|
498
|
+
if (excess <= 0) {
|
|
499
|
+
return { fits: true, ...before };
|
|
500
|
+
}
|
|
501
|
+
const items = shrinkables(session);
|
|
502
|
+
const headroom = items.map(i => Math.max(0, i.height - MIN_HEIGHT_PX));
|
|
503
|
+
const available = headroom.reduce((a, b) => a + b, 0);
|
|
504
|
+
const cut = Math.min(excess, available);
|
|
505
|
+
const shrunk = items.flatMap((item, i) => {
|
|
506
|
+
const share = available ? Math.round((cut * headroom[i]) / available) : 0;
|
|
507
|
+
if (share <= 0) {
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
510
|
+
const to = item.height - share;
|
|
511
|
+
item.setHeight(to);
|
|
512
|
+
return [{ what: item.what, from: item.height, to }];
|
|
513
|
+
});
|
|
514
|
+
const settle = await waitReady(settleMs, session, root);
|
|
515
|
+
const after = overflow(root);
|
|
516
|
+
const left = after.pageHeight - after.windowHeight;
|
|
517
|
+
return {
|
|
518
|
+
fits: left <= 0,
|
|
519
|
+
overflowBefore: excess,
|
|
520
|
+
overflowAfter: Math.max(0, left),
|
|
521
|
+
shrunk,
|
|
522
|
+
...(left > 0
|
|
523
|
+
? {
|
|
524
|
+
note: available < excess
|
|
525
|
+
? `everything shrinkable is at its ${MIN_HEIGHT_PX} px floor — hide a track or a view, or screenshot with fullPage: true`
|
|
526
|
+
: 'still taller than the window after the settle — call jb.fitToWindow() again',
|
|
527
|
+
}
|
|
528
|
+
: {}),
|
|
529
|
+
...settle,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
// The catalog is the largest result of nearly every agent run — the 129 volvox
|
|
533
|
+
// rows were 20 KB — so a row carries what picks a track out of the list and
|
|
534
|
+
// nothing more. The adapter type is `jb.inspect`'s or `jb.describeSlots`' to
|
|
535
|
+
// answer for the one track an agent went on to use.
|
|
536
|
+
//
|
|
537
|
+
// Dropping assemblyNames wherever the session has a single assembly saved
|
|
538
|
+
// nothing and made the row shape vary by session: a config declares far more
|
|
539
|
+
// assemblies than it opens, and volvox — every eval run's config — declares 10.
|
|
404
540
|
function trackEntry(conf) {
|
|
405
|
-
const adapter = readConfObject(conf, 'adapter');
|
|
406
541
|
return {
|
|
407
542
|
trackId: conf.trackId,
|
|
408
543
|
name: readConfObject(conf, 'name'),
|
|
409
544
|
type: conf.type,
|
|
410
|
-
...(adapter.type ? { adapterType: adapter.type } : {}),
|
|
411
545
|
// getConfAssemblyNamesOrNone, not the slot: an assembly's sequence track
|
|
412
546
|
// has no assemblyNames slot and answers through its parent assembly
|
|
413
547
|
assemblyNames: getConfAssemblyNamesOrNone(conf),
|
|
@@ -439,21 +573,11 @@ function listTracks(session, searchArg, limitArg) {
|
|
|
439
573
|
};
|
|
440
574
|
}
|
|
441
575
|
function pickView(session, args, capability, wants) {
|
|
442
|
-
const viewId = typeof args.viewId === 'string' ? args.viewId :
|
|
443
|
-
const candidates =
|
|
444
|
-
if (viewId) {
|
|
445
|
-
const named = candidates.find(v => v.id === viewId);
|
|
446
|
-
if (!named) {
|
|
447
|
-
throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => `${v.id} (${v.type})`).join(', ')}`);
|
|
448
|
-
}
|
|
449
|
-
if (typeof viewSelf(named)[capability] !== 'function') {
|
|
450
|
-
throw new Error(`View ${named.id} (${named.type}) does not support this`);
|
|
451
|
-
}
|
|
452
|
-
return named;
|
|
453
|
-
}
|
|
576
|
+
const viewId = typeof args.viewId === 'string' ? args.viewId : undefined;
|
|
577
|
+
const candidates = viewScope(session, viewId);
|
|
454
578
|
const able = candidates.filter(v => typeof viewSelf(v)[capability] === 'function');
|
|
455
579
|
if (!able.length) {
|
|
456
|
-
throw new Error(`No
|
|
580
|
+
throw new Error(`No ${inScope(viewId)} supports this (${candidates.map(v => v.type).join(', ') || 'none open'}) — jb.loadSessionSpec can open one.`);
|
|
457
581
|
}
|
|
458
582
|
// A view on another assembly would show the track and render nothing, with a
|
|
459
583
|
// successful-looking result — the same silent mismatch visibleRegionsOf
|
|
@@ -470,26 +594,33 @@ function pickView(session, args, capability, wants) {
|
|
|
470
594
|
if (!canDisplay.length) {
|
|
471
595
|
throw new Error(`No open view can display a ${wants.trackType} (open views: ${onAssembly.map(v => v.type).join(', ')})`);
|
|
472
596
|
}
|
|
473
|
-
return onlyView(canDisplay, 'could take this');
|
|
597
|
+
return onlyView(session, canDisplay, 'could take this', viewId);
|
|
598
|
+
}
|
|
599
|
+
// A synteny view's rows count as open views (they are what shows a region and
|
|
600
|
+
// a track), and an agent looking at one synteny view read "3 views are open"
|
|
601
|
+
// with nothing saying two of them were its rows.
|
|
602
|
+
function parentView(session, view) {
|
|
603
|
+
return openViews(session).find(v => v.ownViews.includes(view));
|
|
474
604
|
}
|
|
475
|
-
function describeView(view) {
|
|
605
|
+
function describeView(session, view) {
|
|
476
606
|
const v = viewSelf(view);
|
|
477
607
|
const on = v.assemblyNames?.length ? ` on ${v.assemblyNames.join(', ')}` : '';
|
|
478
608
|
const at = v.coarseVisibleLocStrings ? ` at ${v.coarseVisibleLocStrings}` : '';
|
|
479
|
-
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
609
|
+
const parent = parentView(session, view);
|
|
610
|
+
const row = parent ? `, a row of ${parent.id}` : '';
|
|
611
|
+
return `${v.id} (${v.type}${on}${at}${row})`;
|
|
612
|
+
}
|
|
613
|
+
function onlyView(session, candidates, relation, viewId) {
|
|
614
|
+
const named = candidates.find(v => v.id === viewId);
|
|
615
|
+
if (named) {
|
|
616
|
+
return named;
|
|
617
|
+
}
|
|
487
618
|
const [first, ...rest] = candidates;
|
|
488
619
|
if (!first) {
|
|
489
620
|
throw new Error('No open view');
|
|
490
621
|
}
|
|
491
622
|
if (rest.length) {
|
|
492
|
-
throw new Error(`${candidates.length} views ${relation}: ${candidates.map(v => describeView(v)).join('; ')} — pass viewId to say which`);
|
|
623
|
+
throw new Error(`${candidates.length} views ${relation}: ${candidates.map(v => describeView(session, v)).join('; ')} — pass viewId to say which`);
|
|
493
624
|
}
|
|
494
625
|
return first;
|
|
495
626
|
}
|
|
@@ -498,58 +629,77 @@ function viewById(session, viewId) {
|
|
|
498
629
|
if (viewId !== undefined) {
|
|
499
630
|
const named = candidates.find(v => v.id === viewId);
|
|
500
631
|
if (!named) {
|
|
501
|
-
throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => describeView(v)).join('; ') || 'none'}`);
|
|
632
|
+
throw new Error(`No view with id "${viewId}". Open views: ${candidates.map(v => describeView(session, v)).join('; ') || 'none'}`);
|
|
502
633
|
}
|
|
503
634
|
return named;
|
|
504
635
|
}
|
|
505
636
|
if (!candidates.length) {
|
|
506
637
|
throw new Error('No view is open — jb.loadSessionSpec can open one');
|
|
507
638
|
}
|
|
508
|
-
return onlyView(candidates, 'are open');
|
|
639
|
+
return onlyView(session, candidates, 'are open');
|
|
640
|
+
}
|
|
641
|
+
function inScope(viewId) {
|
|
642
|
+
return viewId === undefined ? 'open view' : `view in ${viewId}`;
|
|
643
|
+
}
|
|
644
|
+
function viewScope(session, viewId) {
|
|
645
|
+
return viewId === undefined
|
|
646
|
+
? openViews(session)
|
|
647
|
+
: viewAndNested(viewById(session, viewId));
|
|
509
648
|
}
|
|
510
649
|
function shownTrackModel(session, trackId, viewId) {
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
: viewTracks(viewById(session, viewId));
|
|
514
|
-
const shown = pool.filter(t => t.configuration.trackId === trackId);
|
|
515
|
-
if (shown.length > 1) {
|
|
516
|
-
const where = openViews(session)
|
|
517
|
-
.filter(v => viewTracks(v).some(t => t.configuration.trackId === trackId))
|
|
518
|
-
.map(v => describeView(v));
|
|
519
|
-
throw new Error(`"${trackId}" is shown in ${shown.length} views: ${where.join('; ')} — pass viewId to say which`);
|
|
520
|
-
}
|
|
521
|
-
const [track] = shown;
|
|
650
|
+
const trackOf = (v) => viewTracks(v).find(t => t.configuration.trackId === trackId);
|
|
651
|
+
const showing = viewScope(session, viewId).filter(v => trackOf(v));
|
|
522
652
|
// undefined here was followed by ".activeDisplay" on the next line of every
|
|
523
653
|
// agent's code, and a TypeError says nothing about which of the two causes
|
|
524
654
|
// it was
|
|
525
|
-
if (!
|
|
655
|
+
if (!showing.length) {
|
|
526
656
|
throw new Error(session.getTrackById(trackId)
|
|
527
|
-
? `"${trackId}" is not shown in
|
|
657
|
+
? `"${trackId}" is not shown in any ${inScope(viewId)} — await view.launchTrack("${trackId}") first; jb.sessionSummary() lists what each view shows`
|
|
528
658
|
: `No track with trackId "${trackId}" — jb.listTracks() shows what is available`);
|
|
529
659
|
}
|
|
530
|
-
return
|
|
660
|
+
return trackOf(onlyView(session, showing, `show "${trackId}"`, viewId));
|
|
531
661
|
}
|
|
532
|
-
|
|
662
|
+
function isRegionLoc(loc) {
|
|
663
|
+
return (!!loc &&
|
|
664
|
+
typeof loc === 'object' &&
|
|
665
|
+
typeof loc.refName === 'string');
|
|
666
|
+
}
|
|
667
|
+
async function trackAssembly(session, conf, requested) {
|
|
533
668
|
// getConfAssemblyNamesOrNone, not the assemblyNames slot: an assembly's own
|
|
534
669
|
// sequence track has no such slot and answers through its parent assembly
|
|
535
670
|
const trackAssemblies = getConfAssemblyNamesOrNone(conf);
|
|
536
|
-
const assemblyName =
|
|
671
|
+
const assemblyName = requested ?? trackAssemblies[0];
|
|
537
672
|
if (assemblyName === undefined) {
|
|
538
673
|
throw new Error('The track names no assembly; pass assembly explicitly');
|
|
539
674
|
}
|
|
540
675
|
// a named assembly the track is not on renames the region against the wrong
|
|
541
676
|
// alias set and answers with the wrong assembly's coordinates, or nothing
|
|
542
|
-
if (
|
|
677
|
+
if (requested !== undefined &&
|
|
543
678
|
trackAssemblies.length &&
|
|
544
|
-
!trackAssemblies.some(name => isSameAssemblyName(name,
|
|
545
|
-
throw new Error(`Track "${conf.trackId}" is on ${trackAssemblies.join(', ')}, not "${
|
|
679
|
+
!trackAssemblies.some(name => isSameAssemblyName(name, requested, session.assemblyManager))) {
|
|
680
|
+
throw new Error(`Track "${conf.trackId}" is on ${trackAssemblies.join(', ')}, not "${requested}"`);
|
|
546
681
|
}
|
|
547
682
|
const assembly = await session.assemblyManager.waitForAssembly(assemblyName);
|
|
548
683
|
if (!assembly) {
|
|
549
684
|
throw new Error(`Assembly "${assemblyName}" could not be loaded`);
|
|
550
685
|
}
|
|
551
|
-
|
|
686
|
+
return { assembly, assemblyName };
|
|
687
|
+
}
|
|
688
|
+
async function locToRegion(session, conf, loc, assemblyArg) {
|
|
689
|
+
if (typeof loc !== 'string' && !isRegionLoc(loc)) {
|
|
690
|
+
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: [...]' : ''}`);
|
|
691
|
+
}
|
|
692
|
+
const { assembly, assemblyName } = await trackAssembly(session, conf, assemblyArg ?? (typeof loc === 'string' ? undefined : loc.assemblyName));
|
|
693
|
+
const parsed = typeof loc === 'string'
|
|
694
|
+
? parseLocString(loc, refName => assembly.isValidRefName(refName))
|
|
695
|
+
: loc;
|
|
552
696
|
const refName = assembly.getCanonicalRefName(parsed.refName) ?? parsed.refName;
|
|
697
|
+
// parseLocString asks this for the string form. A region object skipping it
|
|
698
|
+
// read empty on a name the assembly does not have, which is the answer this
|
|
699
|
+
// surface exists to turn into an error.
|
|
700
|
+
if (!assembly.isValidRefName(refName)) {
|
|
701
|
+
throw new Error(`"${parsed.refName}" is not a sequence in ${assemblyName} — jb.visibleRegions() and jb.sessionSummary() name what is there`);
|
|
702
|
+
}
|
|
553
703
|
const bounds = assembly.regions?.find(r => r.refName === refName);
|
|
554
704
|
return {
|
|
555
705
|
assemblyName,
|
|
@@ -564,9 +714,9 @@ async function visibleRegionsOf(session, viewId, preferTrackId, trackAssemblies
|
|
|
564
714
|
// freshly spec-loaded view stays in that state briefly even after the
|
|
565
715
|
// app-phase marker reads ready, since a view with no width has no display
|
|
566
716
|
// fetching anything.
|
|
567
|
-
const regionBearing =
|
|
717
|
+
const regionBearing = viewScope(session, viewId).filter(v => 'visibleRegions' in v);
|
|
568
718
|
if (!regionBearing.length) {
|
|
569
|
-
throw new Error(
|
|
719
|
+
throw new Error(`No ${inScope(viewId)} shows a region — pass loc, or open a linear view`);
|
|
570
720
|
}
|
|
571
721
|
// a view on another assembly than the track would hand its region to a file
|
|
572
722
|
// that has no such sequence, which answers nothing, silently
|
|
@@ -575,13 +725,13 @@ async function visibleRegionsOf(session, viewId, preferTrackId, trackAssemblies
|
|
|
575
725
|
viewSelf(v).assemblyNames.some(name => trackAssemblies.some(t => isSameAssemblyName(name, t, session.assemblyManager))))
|
|
576
726
|
: regionBearing;
|
|
577
727
|
if (!candidates.length) {
|
|
578
|
-
throw new Error(`No open view is on ${trackAssemblies.join(', ')} (open views: ${regionBearing.map(v => describeView(v)).join('; ')}) — pass loc, or open a view on that assembly`);
|
|
728
|
+
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`);
|
|
579
729
|
}
|
|
580
730
|
// among region-bearing views, the ones actually showing the track — two
|
|
581
731
|
// views on two assemblies would otherwise send the first view's namespace to
|
|
582
732
|
// the second view's file, which answers nothing, silently
|
|
583
733
|
const showing = candidates.filter(v => viewTracks(v).some(t => t.configuration.trackId === preferTrackId));
|
|
584
|
-
const chosen = onlyView(showing.length ? showing : candidates, showing.length ? `show "${preferTrackId}"` : 'show a region');
|
|
734
|
+
const chosen = onlyView(session, showing.length ? showing : candidates, showing.length ? `show "${preferTrackId}"` : 'show a region', viewId);
|
|
585
735
|
const view = viewSelf(chosen);
|
|
586
736
|
const deadline = Date.now() + 10_000;
|
|
587
737
|
let visible;
|
|
@@ -638,11 +788,12 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
|
|
|
638
788
|
// than because a view scrolled.
|
|
639
789
|
const byteLimit = agentByteLimit(readConfObject(conf, ['adapter', 'fetchSizeLimit']), requestedByteLimit);
|
|
640
790
|
const { rpcManager } = session;
|
|
641
|
-
const
|
|
791
|
+
const controller = new AbortController();
|
|
792
|
+
const { signal } = controller;
|
|
642
793
|
// desktop's MCP relay gives up at 150s, so the read must not outlive it —
|
|
643
794
|
// and an agent-triggered read of a dense region wants a ceiling either way
|
|
644
795
|
const stopTimer = setTimeout(() => {
|
|
645
|
-
|
|
796
|
+
controller.abort();
|
|
646
797
|
}, 120_000);
|
|
647
798
|
retainAdapterSession(rpcManager, sessionId);
|
|
648
799
|
try {
|
|
@@ -655,7 +806,7 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
|
|
|
655
806
|
// whole answer is the failure this surface exists to prevent.
|
|
656
807
|
const bytes = await rpcManager.call(sessionId, 'CoreGetRegionByteEstimate',
|
|
657
808
|
// 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
|
|
658
|
-
{ adapterConfig, regions, scope: 'largestRegion',
|
|
809
|
+
{ adapterConfig, regions, scope: 'largestRegion', signal });
|
|
659
810
|
if (bytes !== undefined && bytes > byteLimit) {
|
|
660
811
|
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.`);
|
|
661
812
|
}
|
|
@@ -663,7 +814,7 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
|
|
|
663
814
|
return await rpcManager.call(sessionId, 'CoreGetFeatures', {
|
|
664
815
|
adapterConfig,
|
|
665
816
|
regions,
|
|
666
|
-
|
|
817
|
+
signal,
|
|
667
818
|
});
|
|
668
819
|
}
|
|
669
820
|
finally {
|
|
@@ -675,14 +826,19 @@ async function fetchFeatures(session, trackId, regions, requestedByteLimit) {
|
|
|
675
826
|
// throws "can't use local files in the browser" at the FIRST READ, not here, so
|
|
676
827
|
// accepting one would report a track added and then fail inside the display
|
|
677
828
|
// where the agent is least likely to connect it to what it asked for.
|
|
829
|
+
const URL_TEXT = /^https?:\/\//;
|
|
830
|
+
const ABSOLUTE_PATH = /^(?:\/|[a-zA-Z]:[\\/]|\\\\)/;
|
|
831
|
+
function isFileLocationText(spec) {
|
|
832
|
+
return URL_TEXT.test(spec) || ABSOLUTE_PATH.test(spec);
|
|
833
|
+
}
|
|
678
834
|
function fileLocation(spec) {
|
|
679
|
-
if (
|
|
835
|
+
if (URL_TEXT.test(spec)) {
|
|
680
836
|
return { uri: spec, locationType: 'UriLocation' };
|
|
681
837
|
}
|
|
682
838
|
// The app's working directory is not the agent's, and a relative path
|
|
683
839
|
// resolves against the app's: under a packaged app that is "/", so the read
|
|
684
840
|
// fails at the first fetch and reports through the display, not here.
|
|
685
|
-
if (
|
|
841
|
+
if (!ABSOLUTE_PATH.test(spec)) {
|
|
686
842
|
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.`);
|
|
687
843
|
}
|
|
688
844
|
if (!isElectron) {
|
|
@@ -733,32 +889,55 @@ function locationsOf(value) {
|
|
|
733
889
|
// Vocabulary introspection: every config slot a live config node's schema
|
|
734
890
|
// defines, so code never has to guess which settings keys exist — an unknown
|
|
735
891
|
// key is not an error, only an entry in applyDisplaySettings' `unapplied` list.
|
|
892
|
+
// A nested object (`facet`, `color`) lists its own slots, and the slot its
|
|
893
|
+
// string shorthand lifts into.
|
|
736
894
|
function describeSlots(conf) {
|
|
737
895
|
const definition = getConfigurationSchemaDefinition(conf) ?? {};
|
|
738
|
-
return Object.fromEntries(Object.entries(definition).flatMap(([name, def]) =>
|
|
739
|
-
|
|
740
|
-
[
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
896
|
+
return Object.fromEntries(Object.entries(definition).flatMap(([name, def]) => {
|
|
897
|
+
if (isSlotDefinitionEntry(def)) {
|
|
898
|
+
return [
|
|
899
|
+
[
|
|
900
|
+
name,
|
|
901
|
+
{
|
|
902
|
+
type: def.type,
|
|
903
|
+
...(def.description ? { description: def.description } : {}),
|
|
904
|
+
defaultValue: def.defaultValue,
|
|
905
|
+
},
|
|
906
|
+
],
|
|
907
|
+
];
|
|
908
|
+
}
|
|
909
|
+
if (isConfigurationSubschema(conf, name)) {
|
|
910
|
+
const node = conf[name];
|
|
911
|
+
const shorthand = getConfigurationSchemaOptions(node)?.shorthand;
|
|
912
|
+
return [
|
|
913
|
+
[
|
|
914
|
+
name,
|
|
915
|
+
{
|
|
916
|
+
type: mst
|
|
917
|
+
.getType(node)
|
|
918
|
+
.name.replace(/ConfigurationSchema$/, ''),
|
|
919
|
+
slots: describeSlots(node),
|
|
920
|
+
...(shorthand ? { shorthand } : {}),
|
|
921
|
+
},
|
|
922
|
+
],
|
|
923
|
+
];
|
|
924
|
+
}
|
|
925
|
+
return [];
|
|
926
|
+
}));
|
|
750
927
|
}
|
|
751
928
|
// The raw primitive under all of the above: Claude-authored code against the
|
|
752
929
|
// live model graph. The renderer already runs with nodeIntegration and the
|
|
753
930
|
// bridge socket is user-only, so this grants what the surface as a whole
|
|
754
931
|
// already grants — expressed directly instead of through a curated verb.
|
|
755
|
-
// The re-export registry is what pluginManager.jbrequire serves
|
|
756
|
-
//
|
|
757
|
-
//
|
|
758
|
-
//
|
|
759
|
-
//
|
|
932
|
+
// The re-export registry is what pluginManager.jbrequire serves, the modules
|
|
933
|
+
// external plugins link against. It stays empty until a runtime plugin loads,
|
|
934
|
+
// and a loaded plugin fills it with the product's map, which serves every
|
|
935
|
+
// bundled @jbrowse package rather than core alone — so this fills only an
|
|
936
|
+
// empty registry.
|
|
760
937
|
export async function ensureReExports() {
|
|
761
|
-
|
|
938
|
+
if (Object.keys(getReExportRegistry()).length === 0) {
|
|
939
|
+
setReExportRegistry((await import('@jbrowse/core/ReExports/modules')).default);
|
|
940
|
+
}
|
|
762
941
|
}
|
|
763
942
|
// The front door for an agent that just found `jb` and knows nothing else —
|
|
764
943
|
// jbrowse-web's console banner and meta tag point here, because a browser
|
|
@@ -766,13 +945,13 @@ export async function ensureReExports() {
|
|
|
766
945
|
// contract from.
|
|
767
946
|
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).
|
|
768
947
|
|
|
769
|
-
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, so read the report; jb.inspect(
|
|
948
|
+
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.
|
|
770
949
|
|
|
771
|
-
The model is mobx-state-tree: mutate only through actions (raw assignment throws), and write display settings with track.applyDisplaySettings(settings). Build views declaratively with jb.loadSessionSpec({ views: [{ type: 'LinearGenomeView', assembly, loc, tracks: [...] }] });
|
|
950
|
+
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.setJexlFilters([jexl]). An alignments track takes the same facet, its field a read dimension ('pairOrientation', 'mapq', ...) or a tag ('tags.HP'). 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.
|
|
772
951
|
|
|
773
|
-
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.
|
|
952
|
+
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.
|
|
774
953
|
|
|
775
|
-
Full guide: https://jbrowse.org/jb2/docs/agents_live_model (JBrowse Desktop
|
|
954
|
+
Full guide: https://jbrowse.org/jb2/docs/agents_live_model (offline in JBrowse Desktop's MCP docs tool).`;
|
|
776
955
|
// The helper library an agent drives the app through. Built from the plugin
|
|
777
956
|
// manager alone, so one of these serves a whole app rather than one session —
|
|
778
957
|
// which is what lets jbrowse-web hand the same object to every caller for the
|
|
@@ -817,22 +996,19 @@ export function createJbApi(pluginManager, options = {}) {
|
|
|
817
996
|
readConfObject,
|
|
818
997
|
getConf,
|
|
819
998
|
describeSlots,
|
|
820
|
-
parseLocString,
|
|
821
|
-
getFeatureAdapterOrThrow,
|
|
822
|
-
getRpcSessionId,
|
|
823
|
-
renameRegionsIfNeeded,
|
|
824
|
-
createStopToken,
|
|
825
|
-
stopStopToken,
|
|
826
999
|
// a default because an omitted number made the deadline NaN, and a view
|
|
827
1000
|
// that never readied then held the call open to the relay's own timeout
|
|
828
1001
|
waitReady: async (timeoutMs = 30_000) => reported(await waitReady(timeoutMs, live())),
|
|
829
1002
|
sessionSummary: () => sessionSummary(live()),
|
|
830
|
-
inspect: (
|
|
1003
|
+
inspect: (target, maxInspectBytes) => inspectSession(live(), { target, maxBytes: maxInspectBytes }),
|
|
831
1004
|
listTracks: (search, limit) => listTracks(live(), search, limit),
|
|
832
1005
|
view: (viewId) => viewById(live(), viewId),
|
|
833
1006
|
trackModel: (trackId, viewId) => shownTrackModel(live(), trackId, viewId),
|
|
834
1007
|
visibleRegions: (viewId) => visibleRegionsOf(live(), viewId),
|
|
835
1008
|
loadSessionSpec: async (spec, settleMs) => reported(await loadSpec(pluginManager, { spec, settleMs })),
|
|
1009
|
+
addView: async (spec, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await addView(pluginManager, live(), spec, settleMs)),
|
|
1010
|
+
setSession: async (document, settleMs = SPEC_SETTLE_DEFAULT_MS) => reported(await setSession(pluginManager, live(), document, settleMs)),
|
|
1011
|
+
fitToWindow: async (settleMs = 30_000) => reported(await fitToWindow(live(), settleMs)),
|
|
836
1012
|
addTrack: (opts) => addTrack(pluginManager, live(), opts).then(reported),
|
|
837
1013
|
// Two of four filmed takes wrote jb.getFeatures('trackId', loc) and lost a
|
|
838
1014
|
// turn to "No track with trackId undefined", and a third wrote
|
|
@@ -848,12 +1024,17 @@ export function createJbApi(pluginManager, options = {}) {
|
|
|
848
1024
|
if (!conf) {
|
|
849
1025
|
throw new Error(`No track with trackId "${fetchArgs.trackId}" — jb.listTracks() shows what is available`);
|
|
850
1026
|
}
|
|
851
|
-
|
|
852
|
-
|
|
1027
|
+
// `regions` entries go through locToRegion as `loc` does. Passed
|
|
1028
|
+
// straight to the fetch they skipped the canonical rename, and an entry
|
|
1029
|
+
// carrying no assemblyName reached renameRegionsIfNeeded with nothing to
|
|
1030
|
+
// rename against — an alias spelling then read empty and said nothing.
|
|
1031
|
+
const regions = fetchArgs.regions
|
|
1032
|
+
? await Promise.all(fetchArgs.regions.map(region => locToRegion(session, conf, region, fetchArgs.assembly)))
|
|
1033
|
+
: fetchArgs.loc !== undefined
|
|
853
1034
|
? [
|
|
854
1035
|
await locToRegion(session, conf, fetchArgs.loc, fetchArgs.assembly),
|
|
855
1036
|
]
|
|
856
|
-
: await visibleRegionsOf(session, fetchArgs.viewId, fetchArgs.trackId, getConfAssemblyNamesOrNone(conf))
|
|
1037
|
+
: await visibleRegionsOf(session, fetchArgs.viewId, fetchArgs.trackId, getConfAssemblyNamesOrNone(conf));
|
|
857
1038
|
return fetchFeatures(session, fetchArgs.trackId, regions, fetchArgs.byteLimit);
|
|
858
1039
|
},
|
|
859
1040
|
};
|
|
@@ -862,13 +1043,45 @@ export function createJbApi(pluginManager, options = {}) {
|
|
|
862
1043
|
// a spec load on a busy machine passed 30s and answered settled:false over
|
|
863
1044
|
// views that were fine; the wait exits the moment they are ready
|
|
864
1045
|
const ADD_TRACK_SETTLE_MS = 60_000;
|
|
1046
|
+
function namesAFile(value) {
|
|
1047
|
+
return Array.isArray(value)
|
|
1048
|
+
? value.some(l => typeof l === 'string' && isFileLocationText(l))
|
|
1049
|
+
: typeof value === 'string' && isFileLocationText(value);
|
|
1050
|
+
}
|
|
1051
|
+
// The name an agent meant as a catalog trackId: `trackId`, or a `location`
|
|
1052
|
+
// that is neither a URL nor a path. Agent runs reached for jb.addTrack to SHOW
|
|
1053
|
+
// a track already in the catalog in both spellings, and each paid a round trip
|
|
1054
|
+
// for a refusal that only named another helper.
|
|
1055
|
+
//
|
|
1056
|
+
// A location that names a file is the file route whatever else came with it:
|
|
1057
|
+
// `trackId` beside `location` is how a track CONFIG spells this, and taking it
|
|
1058
|
+
// as the catalog route would refuse the file the caller asked to add.
|
|
1059
|
+
function catalogName(args) {
|
|
1060
|
+
if (namesAFile(args.location)) {
|
|
1061
|
+
return undefined;
|
|
1062
|
+
}
|
|
1063
|
+
if (typeof args.trackId === 'string') {
|
|
1064
|
+
return args.trackId;
|
|
1065
|
+
}
|
|
1066
|
+
return typeof args.location === 'string' ? args.location : undefined;
|
|
1067
|
+
}
|
|
865
1068
|
async function addTrack(pluginManager, session, args) {
|
|
1069
|
+
const named = catalogName(args);
|
|
1070
|
+
const inCatalog = named === undefined ? undefined : session.getTrackById(named);
|
|
1071
|
+
// before the add-rights check: showing a track the catalog already holds
|
|
1072
|
+
// adds nothing to it
|
|
1073
|
+
if (inCatalog) {
|
|
1074
|
+
return showCatalogTrack(pluginManager, session, inCatalog, args);
|
|
1075
|
+
}
|
|
1076
|
+
if (named !== undefined && typeof args.trackId === 'string') {
|
|
1077
|
+
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.`);
|
|
1078
|
+
}
|
|
866
1079
|
if (!isSessionWithAddSessionTrack(session)) {
|
|
867
1080
|
throw new Error('This session cannot add tracks');
|
|
868
1081
|
}
|
|
869
1082
|
const location = locationsOf(args.location);
|
|
870
1083
|
if (!location.length) {
|
|
871
|
-
throw new Error('jb.addTrack needs a location (an absolute local path or a URL)');
|
|
1084
|
+
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.');
|
|
872
1085
|
}
|
|
873
1086
|
const requested = typeof args.assembly === 'string' ? args.assembly : session.assemblyNames[0];
|
|
874
1087
|
if (requested === undefined) {
|
|
@@ -893,25 +1106,182 @@ async function addTrack(pluginManager, session, args) {
|
|
|
893
1106
|
adapterType: conf.adapter.type,
|
|
894
1107
|
assembly,
|
|
895
1108
|
};
|
|
896
|
-
|
|
897
|
-
|
|
1109
|
+
return args.show === false
|
|
1110
|
+
? summary
|
|
1111
|
+
: {
|
|
1112
|
+
...summary,
|
|
1113
|
+
...(await showTrack(pluginManager, session, { trackId: conf.trackId, trackType: conf.type, assembly }, args)),
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
// A track already in the catalog, shown: the same view choice and the same
|
|
1117
|
+
// settle the file route ends in.
|
|
1118
|
+
// `conf` is whatever `session.getTrackById` answered, which is a track config
|
|
1119
|
+
// but not necessarily a `BaseTrackConfig` — an assembly's ReferenceSequenceTrack
|
|
1120
|
+
// declares a subset of those slots. The four reads below are all ones it has.
|
|
1121
|
+
async function showCatalogTrack(pluginManager, session, conf, args) {
|
|
1122
|
+
// index, assembly and name describe the file being added, and the catalog's
|
|
1123
|
+
// copy already has all three — taking them here would drop them in silence
|
|
1124
|
+
const fileArgs = ['index', 'assembly', 'name'].filter(key => args[key] !== undefined);
|
|
1125
|
+
if (fileArgs.length) {
|
|
1126
|
+
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.`);
|
|
898
1127
|
}
|
|
899
|
-
const
|
|
900
|
-
|
|
1128
|
+
const track = {
|
|
1129
|
+
trackId: conf.trackId,
|
|
901
1130
|
trackType: conf.type,
|
|
1131
|
+
assembly: getConfAssemblyNamesOrNone(conf)[0],
|
|
1132
|
+
};
|
|
1133
|
+
const adapterType = readConfObject(conf, ['adapter', 'type']);
|
|
1134
|
+
const summary = { ...track, ...(adapterType ? { adapterType } : {}) };
|
|
1135
|
+
// show:false over a catalog trackId asks for nothing — there is no file to
|
|
1136
|
+
// add — and showing it anyway would contradict the flag
|
|
1137
|
+
return args.show === false
|
|
1138
|
+
? summary
|
|
1139
|
+
: {
|
|
1140
|
+
...summary,
|
|
1141
|
+
...(await showTrack(pluginManager, session, track, args)),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
function isSettings(value) {
|
|
1145
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
1146
|
+
}
|
|
1147
|
+
async function showTrack(pluginManager, session, track, args) {
|
|
1148
|
+
const view = pickView(session, args, 'launchTrack', {
|
|
1149
|
+
assembly: track.assembly,
|
|
1150
|
+
trackType: track.trackType,
|
|
902
1151
|
pluginManager,
|
|
903
1152
|
});
|
|
904
1153
|
// awaited: a display state model is lazy in every plugin here, and the
|
|
905
1154
|
// synchronous showTrack answers before the chunk lands — so the settle below
|
|
906
1155
|
// would run against a session that does not hold the track yet, and report
|
|
907
1156
|
// ready over it
|
|
908
|
-
await viewSelf(view).launchTrack(
|
|
909
|
-
const shown = { ...summary, shownInView: view.id };
|
|
1157
|
+
await viewSelf(view).launchTrack(track.trackId, {}, isSettings(args.settings) ? args.settings : undefined);
|
|
910
1158
|
// 0 skips the settle, so several adds can share one jb.waitReady
|
|
911
1159
|
const settleMs = typeof args.settleMs === 'number' ? args.settleMs : ADD_TRACK_SETTLE_MS;
|
|
1160
|
+
return {
|
|
1161
|
+
shownInView: view.id,
|
|
1162
|
+
...(settleMs > 0 ? await waitReady(settleMs, session) : {}),
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* One view from one spec entry, into the open session, through the same
|
|
1167
|
+
* launcher extension point `jb.loadSessionSpec` takes for each of its views —
|
|
1168
|
+
* so the entry means the same thing in both places, a ProteinView's
|
|
1169
|
+
* `connectedView` included. `session.launchView` is the other route and does
|
|
1170
|
+
* not: it is `addView` with a snapshot, so it never runs a view's launcher.
|
|
1171
|
+
*
|
|
1172
|
+
* A key the view does not take throws before anything opens. A spec has no
|
|
1173
|
+
* return channel and launches anyway with an error toast; an awaited call has
|
|
1174
|
+
* one, and a view that opened without the setting the agent typed is the
|
|
1175
|
+
* quietly-wrong answer this surface exists to refuse.
|
|
1176
|
+
*/
|
|
1177
|
+
async function addView(pluginManager, session, spec, settleMs) {
|
|
1178
|
+
if (typeof spec !== 'object' || typeof spec.type !== 'string') {
|
|
1179
|
+
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")');
|
|
1180
|
+
}
|
|
1181
|
+
const unlaunchable = viewTypeProblem(pluginManager, spec.type);
|
|
1182
|
+
if (unlaunchable) {
|
|
1183
|
+
throw new Error(unlaunchable);
|
|
1184
|
+
}
|
|
1185
|
+
const { view: entry, problem } = await launchableSpecView(pluginManager, spec);
|
|
1186
|
+
if (problem) {
|
|
1187
|
+
throw new Error(`${problem} — nothing was opened. docs topic "session-spec" lists the keys ${entry.type} takes`);
|
|
1188
|
+
}
|
|
1189
|
+
const { created, view, failure } = await launchSpecView(session, pluginManager, entry);
|
|
1190
|
+
if (failure) {
|
|
1191
|
+
throw new Error(failure.message, { cause: failure.cause });
|
|
1192
|
+
}
|
|
1193
|
+
if (!view) {
|
|
1194
|
+
throw new Error(`${entry.type} opened no view`);
|
|
1195
|
+
}
|
|
1196
|
+
const summary = {
|
|
1197
|
+
viewId: view.id,
|
|
1198
|
+
...(created.length > 1 ? { created: created.map(v => v.id) } : {}),
|
|
1199
|
+
};
|
|
912
1200
|
return settleMs > 0
|
|
913
|
-
? { ...
|
|
914
|
-
:
|
|
1201
|
+
? { ...summary, ...(await waitReady(settleMs, session)) }
|
|
1202
|
+
: summary;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* The session as one document, rewritten: what `getSnapshot(session)` answers,
|
|
1206
|
+
* edited, handed back. MST reconciles by identifier, so a view, track or
|
|
1207
|
+
* display whose `id` the document keeps is patched in place and stays mounted,
|
|
1208
|
+
* one the document drops is closed, and an entry with no id is new. A view's
|
|
1209
|
+
* launch keys are accepted beside its built state — `loc` navigates it, a
|
|
1210
|
+
* `{ trackId }` entry in its `tracks` opens that track — because the partition
|
|
1211
|
+
* that sorts them runs on every snapshot the view is given, not only its
|
|
1212
|
+
* first.
|
|
1213
|
+
*
|
|
1214
|
+
* Top-level keys the document leaves out keep their current value, so
|
|
1215
|
+
* `{ views: [...] }` is a complete instruction.
|
|
1216
|
+
*
|
|
1217
|
+
* Views leave through the session's own detach first (ADR-069): `applySnapshot`
|
|
1218
|
+
* destroys what the target lacks in place, under components still mounted
|
|
1219
|
+
* over it, which is what undo had to route around.
|
|
1220
|
+
*/
|
|
1221
|
+
// A key nothing on the far side takes, per view entry. MST drops one silently,
|
|
1222
|
+
// and the partition that names it runs at attach — which a view the document
|
|
1223
|
+
// KEEPS never reaches, so the commonest edit was the quiet one.
|
|
1224
|
+
//
|
|
1225
|
+
// Reported, not refused, like the same mistake on a session spec: a document
|
|
1226
|
+
// that works today must not cost the whole call. `acceptedKeys` answers for a
|
|
1227
|
+
// legacy spelling too — a view's `passThrough` declares the ones its own
|
|
1228
|
+
// preProcessSnapshot converts, which is what makes this a question about the
|
|
1229
|
+
// view type rather than about whatever members the live node happens to have.
|
|
1230
|
+
function unknownViewKeys(pluginManager, entry) {
|
|
1231
|
+
if (typeof entry !== 'object' ||
|
|
1232
|
+
entry === null ||
|
|
1233
|
+
typeof entry.type !== 'string') {
|
|
1234
|
+
return [];
|
|
1235
|
+
}
|
|
1236
|
+
const { type, views, ...keyed } = entry;
|
|
1237
|
+
const nested = Array.isArray(views)
|
|
1238
|
+
? views.flatMap(view => unknownViewKeys(pluginManager, view))
|
|
1239
|
+
: [];
|
|
1240
|
+
if (!pluginManager.getElementTypeRecord('view').has(type)) {
|
|
1241
|
+
return nested;
|
|
1242
|
+
}
|
|
1243
|
+
const viewType = pluginManager.getViewType(type);
|
|
1244
|
+
const accepted = viewType.acceptedKeys ??
|
|
1245
|
+
Object.keys(viewType.stateModel.properties);
|
|
1246
|
+
const unknown = Object.keys(keyed).filter(key => !accepted.includes(key));
|
|
1247
|
+
return [
|
|
1248
|
+
...(unknown.length
|
|
1249
|
+
? [
|
|
1250
|
+
`${unknownKeysMessage(type, unknown)}${viewType.launchKeys
|
|
1251
|
+
? ''
|
|
1252
|
+
: ' — a document runs no launcher, so its launch keys go through jb.addView'}`,
|
|
1253
|
+
]
|
|
1254
|
+
: []),
|
|
1255
|
+
...nested,
|
|
1256
|
+
];
|
|
1257
|
+
}
|
|
1258
|
+
async function setSession(pluginManager, session, document, settleMs) {
|
|
1259
|
+
if (typeof document !== 'object' ||
|
|
1260
|
+
document === null ||
|
|
1261
|
+
Array.isArray(document)) {
|
|
1262
|
+
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.');
|
|
1263
|
+
}
|
|
1264
|
+
const next = {
|
|
1265
|
+
...getSnapshot(session),
|
|
1266
|
+
...document,
|
|
1267
|
+
};
|
|
1268
|
+
// a lazily registered view or display type is not in the session's type union
|
|
1269
|
+
// until its model loads, and applySnapshot is synchronous
|
|
1270
|
+
await pluginManager.preloadSessionTypes(next);
|
|
1271
|
+
for (const problem of (Array.isArray(next.views) ? next.views : []).flatMap(entry => unknownViewKeys(pluginManager, entry))) {
|
|
1272
|
+
session.notifyError(problem);
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
;
|
|
1276
|
+
session.takeOutViewsMissingFrom?.(next);
|
|
1277
|
+
applySnapshot(session, next);
|
|
1278
|
+
}
|
|
1279
|
+
catch (e) {
|
|
1280
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
1281
|
+
throw new Error(`jb.setSession refused the document: ${detail.length > 1500 ? `${detail.slice(0, 1500)}…` : detail}`, { cause: e });
|
|
1282
|
+
}
|
|
1283
|
+
const settle = await waitReady(settleMs, session);
|
|
1284
|
+
return { ...settle, session: sessionSummary(session) };
|
|
915
1285
|
}
|
|
916
1286
|
// Under the 45 s one evaluation gets from the Claude in Chrome extension: a
|
|
917
1287
|
// cold hosted config settling for the full minute answered nothing there,
|