@jbrowse/app-core 5.0.0-beta.1 → 5.0.0-beta.3
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/JBrowseConfig/RootConfiguration.d.ts +4 -0
- package/esm/JBrowseConfig/index.d.ts +4 -0
- package/esm/JBrowseModel/index.d.ts +4 -0
- package/esm/JbApi/index.d.ts +2 -0
- package/esm/JbApi/index.js +1 -0
- package/esm/JbApi/jbApi.d.ts +322 -0
- package/esm/JbApi/jbApi.js +867 -0
- package/esm/RootMenu/index.d.ts +2 -11
- package/esm/RootMenu/index.js +0 -11
- package/esm/SessionSpec/loadSessionSpec.js +145 -78
- package/esm/SessionSpec/types.d.ts +10 -12
- package/esm/WorkspaceLayout/model.d.ts +24 -4
- package/esm/WorkspaceLayout/model.js +79 -17
- package/esm/WorkspaceLayout/spec.d.ts +40 -10
- package/esm/WorkspaceLayout/spec.js +81 -8
- package/esm/index.d.ts +3 -1
- package/esm/index.js +3 -0
- package/esm/menus.d.ts +15 -29
- package/esm/menus.js +76 -92
- package/esm/ui/App/AppReadyMarker.d.ts +10 -1
- package/esm/ui/App/AppReadyMarker.js +27 -21
- package/esm/ui/App/ViewButtons.js +2 -2
- package/esm/ui/App/ViewLauncher.js +1 -1
- package/package.json +5 -5
|
@@ -1,19 +1,49 @@
|
|
|
1
1
|
import type { LayoutTree, NodeKind } from './tree.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A view named by a layout leaf: an index into the view list the layout is
|
|
4
|
+
* applied against (a session spec's own `views`, or `session.views` for the
|
|
5
|
+
* live `applyLayoutSpec`), or a view id.
|
|
6
|
+
*/
|
|
7
|
+
export type LayoutViewRef = number | string;
|
|
2
8
|
/**
|
|
3
9
|
* The `layout` a session spec / URL param states, in the vocabulary users
|
|
4
|
-
* write it in
|
|
5
|
-
* so
|
|
6
|
-
*
|
|
10
|
+
* write it in, and the same shape the live `applyLayoutSpec` action takes — one
|
|
11
|
+
* shape, so an agent that has written one has written the other. **This shape
|
|
12
|
+
* is public** — it is documented as a URL parameter — so it keeps
|
|
13
|
+
* `horizontal`/`vertical`/`tabs` and percentage `size` rather than being
|
|
14
|
+
* renamed to match the internal tree.
|
|
15
|
+
*
|
|
16
|
+
* `resolveLayoutSpec` turns the indexes into ids; the tree builder below takes
|
|
17
|
+
* the resolved form only.
|
|
7
18
|
*/
|
|
8
|
-
export interface LayoutSpecNode {
|
|
19
|
+
export interface LayoutSpecNode<View = LayoutViewRef> {
|
|
9
20
|
/** a leaf: the views to stack vertically in one tab */
|
|
10
|
-
|
|
21
|
+
views?: View[];
|
|
11
22
|
/** how children divide the space; `tabs` puts them in one cell as tabs */
|
|
12
23
|
direction?: 'horizontal' | 'vertical' | 'tabs';
|
|
13
|
-
children?: LayoutSpecNode[];
|
|
24
|
+
children?: LayoutSpecNode<View>[];
|
|
14
25
|
/** share of the parent, as a percentage */
|
|
15
26
|
size?: number;
|
|
16
27
|
}
|
|
28
|
+
export type ResolvedLayoutSpecNode = LayoutSpecNode<string>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve every view a spec names to an id against `allViewIds`, the list its
|
|
31
|
+
* indexes count into. Anything that resolves to nothing throws, naming what was
|
|
32
|
+
* received: an untyped caller (the MCP `run_javascript` tool) that writes
|
|
33
|
+
* `viewIds` where `views` goes, or an index past the end, used to be accepted
|
|
34
|
+
* as an empty leaf and collapse the workspace into one blank tab with nothing
|
|
35
|
+
* said.
|
|
36
|
+
*
|
|
37
|
+
* A node stating no `views` and no `children` is NOT that slip — it is the
|
|
38
|
+
* empty panel `treeFromSpec` has always built, and refusing it here made the
|
|
39
|
+
* two surfaces disagree about the one shape they share. The slip is an
|
|
40
|
+
* unrecognized KEY, which is what `viewIds` actually is, and naming it beats
|
|
41
|
+
* the old message that could only say the node needed something else.
|
|
42
|
+
*
|
|
43
|
+
* `allViewIds` is undefined only on a model that composes no view list, where
|
|
44
|
+
* an id cannot be checked and an index cannot mean anything.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resolveLayoutSpec(spec: LayoutSpecNode, allViewIds: string[] | undefined): ResolvedLayoutSpecNode;
|
|
17
47
|
/** A request to move one view relative to the others. Public plugin API. */
|
|
18
48
|
export interface PendingMove {
|
|
19
49
|
type: 'newTab' | 'splitRight';
|
|
@@ -29,9 +59,9 @@ export interface PendingMove {
|
|
|
29
59
|
* nesting, so `size` applies wherever it is written and there is no all-or-
|
|
30
60
|
* nothing pass.
|
|
31
61
|
*/
|
|
32
|
-
export declare function treeFromSpec(spec:
|
|
62
|
+
export declare function treeFromSpec(spec: ResolvedLayoutSpecNode, nextId: (kind: NodeKind) => string): LayoutTree;
|
|
33
63
|
/** Every viewId a spec names, depth-first — the order it states. */
|
|
34
|
-
export declare function viewIdsInSpec(spec:
|
|
64
|
+
export declare function viewIdsInSpec(spec: ResolvedLayoutSpecNode): string[];
|
|
35
65
|
/** How "arrange everything" lays the whole session out. */
|
|
36
66
|
export type TileMode = 'tabs' | 'horizontal' | 'vertical' | 'grid';
|
|
37
67
|
/**
|
|
@@ -49,10 +79,10 @@ export type TileMode = 'tabs' | 'horizontal' | 'vertical' | 'grid';
|
|
|
49
79
|
* width, since `resolveSizes` reads bare siblings as an even share of their own
|
|
50
80
|
* branch rather than of the grid.
|
|
51
81
|
*/
|
|
52
|
-
export declare function tileLayoutSpec(viewIds: string[], mode: TileMode):
|
|
82
|
+
export declare function tileLayoutSpec(viewIds: string[], mode: TileMode): ResolvedLayoutSpecNode;
|
|
53
83
|
/**
|
|
54
84
|
* `setPendingMove` as a spec: everything else keeps its side, the named view
|
|
55
85
|
* takes the other. With nothing else on screen there is nothing to split from,
|
|
56
86
|
* so the view just takes the space.
|
|
57
87
|
*/
|
|
58
|
-
export declare function specForPendingMove(move: PendingMove, allViewIds: string[]):
|
|
88
|
+
export declare function specForPendingMove(move: PendingMove, allViewIds: string[]): ResolvedLayoutSpecNode;
|
|
@@ -1,4 +1,77 @@
|
|
|
1
1
|
import { normalize } from './tree.js';
|
|
2
|
+
// `views`/`children` are pulled off by name; the rest pass through to the tree,
|
|
3
|
+
// so an unrecognized one is a key the writer expected to mean something
|
|
4
|
+
const layoutNodeKeys = new Set(['views', 'direction', 'children', 'size']);
|
|
5
|
+
/**
|
|
6
|
+
* Resolve every view a spec names to an id against `allViewIds`, the list its
|
|
7
|
+
* indexes count into. Anything that resolves to nothing throws, naming what was
|
|
8
|
+
* received: an untyped caller (the MCP `run_javascript` tool) that writes
|
|
9
|
+
* `viewIds` where `views` goes, or an index past the end, used to be accepted
|
|
10
|
+
* as an empty leaf and collapse the workspace into one blank tab with nothing
|
|
11
|
+
* said.
|
|
12
|
+
*
|
|
13
|
+
* A node stating no `views` and no `children` is NOT that slip — it is the
|
|
14
|
+
* empty panel `treeFromSpec` has always built, and refusing it here made the
|
|
15
|
+
* two surfaces disagree about the one shape they share. The slip is an
|
|
16
|
+
* unrecognized KEY, which is what `viewIds` actually is, and naming it beats
|
|
17
|
+
* the old message that could only say the node needed something else.
|
|
18
|
+
*
|
|
19
|
+
* `allViewIds` is undefined only on a model that composes no view list, where
|
|
20
|
+
* an id cannot be checked and an index cannot mean anything.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveLayoutSpec(spec, allViewIds) {
|
|
23
|
+
const describe = (value) => typeof value === 'string' ? `"${value}"` : String(value);
|
|
24
|
+
const resolveRef = (ref) => {
|
|
25
|
+
if (typeof ref === 'number') {
|
|
26
|
+
if (!allViewIds) {
|
|
27
|
+
throw new Error(`Layout names view index ${ref}, but this session has no view list for an index to count into; name the view by id`);
|
|
28
|
+
}
|
|
29
|
+
const id = allViewIds[ref];
|
|
30
|
+
if (id === undefined) {
|
|
31
|
+
throw new Error(`Layout names view index ${ref}, but the session has ${allViewIds.length} view(s)${allViewIds.length ? ` (indexes 0-${allViewIds.length - 1})` : ''}`);
|
|
32
|
+
}
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
if (typeof ref !== 'string') {
|
|
36
|
+
throw new Error(`Layout "views" entries are view indexes or view ids; received ${describe(ref)}`);
|
|
37
|
+
}
|
|
38
|
+
if (allViewIds && !allViewIds.includes(ref)) {
|
|
39
|
+
throw new Error(`Layout names view id ${describe(ref)}, which is not a view in this session (ids: ${allViewIds.map(describe).join(', ') || 'none'})`);
|
|
40
|
+
}
|
|
41
|
+
return ref;
|
|
42
|
+
};
|
|
43
|
+
const resolveNode = (node) => {
|
|
44
|
+
const { views, children, ...rest } = node;
|
|
45
|
+
const unknown = Object.keys(rest).filter(key => !layoutNodeKeys.has(key));
|
|
46
|
+
if (unknown.length > 0) {
|
|
47
|
+
throw new Error(`Layout node has unrecognized key(s) ${unknown.map(describe).join(', ')}; a leaf names its views with "views" (view indexes or ids) and a container nests "children"`);
|
|
48
|
+
}
|
|
49
|
+
if (views !== undefined && !Array.isArray(views)) {
|
|
50
|
+
throw new Error(`Layout "views" is an array of view indexes or ids; received ${describe(views)}`);
|
|
51
|
+
}
|
|
52
|
+
if (children !== undefined && !Array.isArray(children)) {
|
|
53
|
+
throw new Error(`Layout "children" is an array of layout nodes; received ${describe(children)}`);
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
...rest,
|
|
57
|
+
...(views === undefined ? {} : { views: views.map(resolveRef) }),
|
|
58
|
+
...(children === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: { children: children.map(resolveNode) }),
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const resolved = resolveNode(spec);
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
const repeated = new Set(viewIdsInSpec(resolved).filter(id => {
|
|
66
|
+
const already = seen.has(id);
|
|
67
|
+
seen.add(id);
|
|
68
|
+
return already;
|
|
69
|
+
}));
|
|
70
|
+
if (repeated.size > 0) {
|
|
71
|
+
throw new Error(`Layout seats view ${[...repeated].map(describe).join(', ')} in more than one cell; a view lives in exactly one tab, so name it once`);
|
|
72
|
+
}
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
2
75
|
/**
|
|
3
76
|
* The effective size of each child of one branch.
|
|
4
77
|
*
|
|
@@ -44,11 +117,11 @@ function resolveSizes(children) {
|
|
|
44
117
|
*/
|
|
45
118
|
export function treeFromSpec(spec, nextId) {
|
|
46
119
|
function build(node, size) {
|
|
47
|
-
if (node.
|
|
120
|
+
if (node.views) {
|
|
48
121
|
return {
|
|
49
122
|
id: nextId('panel'),
|
|
50
123
|
size,
|
|
51
|
-
tabs: [{ id: nextId('tab'), viewIds: [...node.
|
|
124
|
+
tabs: [{ id: nextId('tab'), viewIds: [...node.views] }],
|
|
52
125
|
activeTabId: undefined,
|
|
53
126
|
};
|
|
54
127
|
}
|
|
@@ -67,7 +140,7 @@ export function treeFromSpec(spec, nextId) {
|
|
|
67
140
|
if (node.direction === 'tabs') {
|
|
68
141
|
const tabs = children.flatMap(child => {
|
|
69
142
|
const viewIds = viewIdsInSpec(child);
|
|
70
|
-
return child.
|
|
143
|
+
return child.views === undefined && viewIds.length === 0
|
|
71
144
|
? []
|
|
72
145
|
: [{ id: nextId('tab'), viewIds }];
|
|
73
146
|
});
|
|
@@ -98,7 +171,7 @@ export function treeFromSpec(spec, nextId) {
|
|
|
98
171
|
/** Every viewId a spec names, depth-first — the order it states. */
|
|
99
172
|
export function viewIdsInSpec(spec) {
|
|
100
173
|
return [
|
|
101
|
-
...(spec.
|
|
174
|
+
...(spec.views ?? []),
|
|
102
175
|
...(spec.children ?? []).flatMap(viewIdsInSpec),
|
|
103
176
|
];
|
|
104
177
|
}
|
|
@@ -121,9 +194,9 @@ export function tileLayoutSpec(viewIds, mode) {
|
|
|
121
194
|
// Nothing to arrange: one view is the whole workspace whatever the mode, and
|
|
122
195
|
// no views leaves the empty panel the tree already guarantees.
|
|
123
196
|
if (viewIds.length <= 1) {
|
|
124
|
-
return {
|
|
197
|
+
return { views: [...viewIds] };
|
|
125
198
|
}
|
|
126
|
-
const cell = (id) => ({
|
|
199
|
+
const cell = (id) => ({ views: [id] });
|
|
127
200
|
if (mode !== 'grid') {
|
|
128
201
|
return { direction: mode, children: viewIds.map(cell) };
|
|
129
202
|
}
|
|
@@ -147,7 +220,7 @@ export function specForPendingMove(move, allViewIds) {
|
|
|
147
220
|
return others.length > 0
|
|
148
221
|
? {
|
|
149
222
|
direction: move.type === 'splitRight' ? 'horizontal' : 'tabs',
|
|
150
|
-
children: [{
|
|
223
|
+
children: [{ views: others }, { views: [move.viewId] }],
|
|
151
224
|
}
|
|
152
|
-
: {
|
|
225
|
+
: { views: [move.viewId] };
|
|
153
226
|
}
|
package/esm/index.d.ts
CHANGED
|
@@ -13,4 +13,6 @@ export type { LayoutNode, LgvUrlInit, ParsedSessionSpec, ViewSpec, } from './Ses
|
|
|
13
13
|
export type { SessionModelFactory } from './sessionModelFactory.ts';
|
|
14
14
|
export { deleteQueryParams, readAllQueryParams, readQueryParams, setQueryParams, useQueryParam, } from './queryParams.ts';
|
|
15
15
|
export { processMutableMenuActions, resolveMenus } from './menus.ts';
|
|
16
|
-
export type { Menu, MenuAction
|
|
16
|
+
export type { Menu, MenuAction } from './menus.ts';
|
|
17
|
+
export { createJbApi, ensureReExports, safeJson, sessionOf, undeliveredNotifications, waitReady, } from './JbApi/index.ts';
|
|
18
|
+
export type { JbApi } from './JbApi/index.ts';
|
package/esm/index.js
CHANGED
|
@@ -11,3 +11,6 @@ export { addSessionTracks, buildLgvInit, buildLgvInitFromParams, hubConnectionSp
|
|
|
11
11
|
// embedded product must not rewrite its host's URL — see queryParams.ts
|
|
12
12
|
export { deleteQueryParams, readAllQueryParams, readQueryParams, setQueryParams, useQueryParam, } from './queryParams.js';
|
|
13
13
|
export { processMutableMenuActions, resolveMenus } from './menus.js';
|
|
14
|
+
// The helper library an agent drives the app through: desktop serves it to
|
|
15
|
+
// run_javascript over MCP, web hands it to the page as window.jb
|
|
16
|
+
export { createJbApi, ensureReExports, safeJson, sessionOf, undeliveredNotifications, waitReady, } from './JbApi/index.js';
|
package/esm/menus.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MenuItem
|
|
1
|
+
import type { MenuItem } from '@jbrowse/core/ui/menuItems';
|
|
2
2
|
/**
|
|
3
3
|
* Ensure a top-level menu exists. `position` appends when omitted and counts
|
|
4
4
|
* from the end when negative. A menu whose label is already on the bar is left
|
|
@@ -22,47 +22,33 @@ interface AddItemAction {
|
|
|
22
22
|
menuItem: MenuItem;
|
|
23
23
|
position?: number;
|
|
24
24
|
}
|
|
25
|
-
interface SetMenusAction {
|
|
26
|
-
type: 'setMenus';
|
|
27
|
-
newMenus: MenuDefinition[];
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* `addMenu`/`setMenus` change which menus exist, so they resolve eagerly — the
|
|
31
|
-
* app bar renders one button per menu before anything is opened. `addItem` only
|
|
32
|
-
* changes a menu's contents, so it resolves when that menu opens, against
|
|
33
|
-
* whatever its items are at that moment.
|
|
34
|
-
*/
|
|
35
|
-
export type MenuAction = AddMenuAction | AddItemAction | SetMenusAction;
|
|
36
25
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
26
|
+
* `addMenu` changes which menus exist, so it resolves eagerly — the app bar
|
|
27
|
+
* renders one button per menu before anything is opened. `addItem` only changes
|
|
28
|
+
* a menu's contents, so it resolves when that menu opens, against whatever its
|
|
29
|
+
* items are at that moment.
|
|
40
30
|
*/
|
|
41
|
-
export
|
|
42
|
-
label: string;
|
|
43
|
-
menuItems: MenuItemsGetter;
|
|
44
|
-
}
|
|
31
|
+
export type MenuAction = AddMenuAction | AddItemAction;
|
|
45
32
|
/**
|
|
46
|
-
* A menu
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
33
|
+
* A top-level menu. `menuItems` is a thunk the app bar calls when the menu
|
|
34
|
+
* opens and never before, so a root model's reads stay out of the bar's
|
|
35
|
+
* tracking scope and a plugin's contributions are merged onto each fresh
|
|
36
|
+
* result rather than spliced into a definition.
|
|
50
37
|
*/
|
|
51
38
|
export interface Menu {
|
|
52
39
|
label: string;
|
|
53
40
|
menuItems: () => MenuItem[];
|
|
54
41
|
}
|
|
55
42
|
/**
|
|
56
|
-
* Resolve a root model's
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* nothing is ever spliced into a definition.
|
|
43
|
+
* Resolve a root model's menus against the contributions plugins have pushed
|
|
44
|
+
* (via `RootAppMenuMixin`). Structural contributions apply now; item
|
|
45
|
+
* contributions are grouped by path and merged when the menu they target
|
|
46
|
+
* opens.
|
|
61
47
|
*
|
|
62
48
|
* Runs on every `menus()` evaluation, so it must not mutate `base` or anything
|
|
63
49
|
* an action carries.
|
|
64
50
|
*/
|
|
65
|
-
export declare function processMutableMenuActions(base:
|
|
51
|
+
export declare function processMutableMenuActions(base: Menu[], actions: MenuAction[]): Menu[];
|
|
66
52
|
/**
|
|
67
53
|
* Flatten resolved menus to plain data by opening every one of them. For tests
|
|
68
54
|
* and other callers that want to assert on menu contents rather than render
|
package/esm/menus.js
CHANGED
|
@@ -1,82 +1,77 @@
|
|
|
1
|
-
// a
|
|
2
|
-
//
|
|
1
|
+
// a conflict is reported once rather than on every open — the menu re-resolves
|
|
2
|
+
// on each open and, while open, on each observer re-render — and the action is
|
|
3
|
+
// what outlives the nodes, which are rebuilt on every menus() evaluation
|
|
3
4
|
const reported = new WeakSet();
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
function cloneMenuItem(item) {
|
|
24
|
-
return 'subMenu' in item
|
|
25
|
-
? { ...item, subMenu: cloneMenuItems(item.subMenu) }
|
|
26
|
-
: item;
|
|
27
|
-
}
|
|
28
|
-
function cloneMenuItems(items) {
|
|
29
|
-
return items.map(item => cloneMenuItem(item));
|
|
30
|
-
}
|
|
31
|
-
function materialize(menuItems) {
|
|
32
|
-
return typeof menuItems === 'function' ? menuItems() : menuItems;
|
|
5
|
+
function register(node, action, depth) {
|
|
6
|
+
const label = action.menuPath[depth];
|
|
7
|
+
if (label === undefined) {
|
|
8
|
+
node.entries.push(action);
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
let entry = node.entries.find((e) => e.type === 'subMenu' && e.label === label);
|
|
12
|
+
if (!entry) {
|
|
13
|
+
entry = {
|
|
14
|
+
type: 'subMenu',
|
|
15
|
+
label,
|
|
16
|
+
path: action.menuPath.slice(0, depth + 1),
|
|
17
|
+
node: { entries: [] },
|
|
18
|
+
first: action,
|
|
19
|
+
};
|
|
20
|
+
node.entries.push(entry);
|
|
21
|
+
}
|
|
22
|
+
register(entry.node, action, depth + 1);
|
|
23
|
+
}
|
|
33
24
|
}
|
|
34
25
|
// splice is already the "counts from the end when negative" rule, and clamps
|
|
35
26
|
// rather than wrapping past the start; the default is what makes it an append
|
|
36
27
|
function insertAt(items, item, position = items.length) {
|
|
37
28
|
items.splice(position, 0, item);
|
|
38
29
|
}
|
|
30
|
+
// the author's form is kept: an array sub-menu resolves now, a thunk sub-menu
|
|
31
|
+
// resolves when its panel opens, with the contributions merged in either way
|
|
32
|
+
function withContributions(item, node) {
|
|
33
|
+
const { subMenu } = item;
|
|
34
|
+
return {
|
|
35
|
+
...item,
|
|
36
|
+
subMenu: typeof subMenu === 'function'
|
|
37
|
+
? () => apply(subMenu(), node)
|
|
38
|
+
: apply(subMenu, node),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
39
41
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* sub-menu
|
|
43
|
-
*
|
|
42
|
+
* Return a copy of `items` with a node's contributions merged in. Nothing is
|
|
43
|
+
* written into `items`, into any sub-menu it holds, or into a contribution's
|
|
44
|
+
* own payload: a sub-menu that gains contributions is replaced by a copy whose
|
|
45
|
+
* `subMenu` resolves them, so the arrays behind a definition or an action are
|
|
46
|
+
* the same on every open.
|
|
47
|
+
*
|
|
48
|
+
* A path segment naming a row that is not a sub-menu is a plugin bug. It
|
|
49
|
+
* costs that subtree of contributions, reported once, and nothing else — this
|
|
50
|
+
* runs from the app bar's click handler, where a throw goes straight through
|
|
51
|
+
* React.
|
|
44
52
|
*/
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
for (const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (level.some(mi => 'label' in mi && mi.label === menuName)) {
|
|
51
|
-
const pathSoFar = menuPath.slice(0, idx + 2).join(' > ');
|
|
52
|
-
throw new Error(`"${menuName}" in path "${pathSoFar}" is not a subMenu`);
|
|
53
|
-
}
|
|
54
|
-
sub = { label: menuName, subMenu: [] };
|
|
55
|
-
level.push(sub);
|
|
53
|
+
function apply(items, node) {
|
|
54
|
+
const result = [...items];
|
|
55
|
+
for (const entry of node.entries) {
|
|
56
|
+
if (entry.type === 'addItem') {
|
|
57
|
+
insertAt(result, entry.menuItem, entry.position);
|
|
56
58
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
// cloned rather than inserted by reference — see cloneMenuItem
|
|
70
|
-
insertAt(target, cloneMenuItem(action.menuItem), action.position);
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
if (!reported.has(action)) {
|
|
74
|
-
reported.add(action);
|
|
75
|
-
console.error(error);
|
|
59
|
+
else {
|
|
60
|
+
const idx = result.findIndex(mi => 'label' in mi && mi.label === entry.label);
|
|
61
|
+
const found = result[idx];
|
|
62
|
+
if (found === undefined) {
|
|
63
|
+
result.push({ label: entry.label, subMenu: apply([], entry.node) });
|
|
64
|
+
}
|
|
65
|
+
else if ('subMenu' in found) {
|
|
66
|
+
result[idx] = withContributions(found, entry.node);
|
|
67
|
+
}
|
|
68
|
+
else if (!reported.has(entry.first)) {
|
|
69
|
+
reported.add(entry.first);
|
|
70
|
+
console.error(new Error(`"${entry.label}" in path "${entry.path.join(' > ')}" is not a subMenu`));
|
|
76
71
|
}
|
|
77
72
|
}
|
|
78
73
|
}
|
|
79
|
-
return
|
|
74
|
+
return result;
|
|
80
75
|
}
|
|
81
76
|
// first menu with this label, or a new empty one — an item action naming a menu
|
|
82
77
|
// that doesn't exist creates it, so a plugin can populate its own menu without
|
|
@@ -86,47 +81,33 @@ function findOrCreateMenu(pending, label, position) {
|
|
|
86
81
|
if (found) {
|
|
87
82
|
return found;
|
|
88
83
|
}
|
|
89
|
-
const menu = {
|
|
90
|
-
insertAt(pending, menu, position);
|
|
91
|
-
return menu;
|
|
92
|
-
}
|
|
93
|
-
function toMenu({ label, base, itemActions }) {
|
|
94
|
-
return {
|
|
84
|
+
const menu = {
|
|
95
85
|
label,
|
|
96
|
-
menuItems: () =>
|
|
86
|
+
menuItems: () => [],
|
|
87
|
+
node: { entries: [] },
|
|
97
88
|
};
|
|
89
|
+
insertAt(pending, menu, position);
|
|
90
|
+
return menu;
|
|
98
91
|
}
|
|
99
92
|
/**
|
|
100
|
-
* Resolve a root model's
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* nothing is ever spliced into a definition.
|
|
93
|
+
* Resolve a root model's menus against the contributions plugins have pushed
|
|
94
|
+
* (via `RootAppMenuMixin`). Structural contributions apply now; item
|
|
95
|
+
* contributions are grouped by path and merged when the menu they target
|
|
96
|
+
* opens.
|
|
105
97
|
*
|
|
106
98
|
* Runs on every `menus()` evaluation, so it must not mutate `base` or anything
|
|
107
99
|
* an action carries.
|
|
108
100
|
*/
|
|
109
101
|
export function processMutableMenuActions(base, actions) {
|
|
110
|
-
const
|
|
111
|
-
label: m.label,
|
|
112
|
-
base: m.menuItems,
|
|
113
|
-
itemActions: [],
|
|
114
|
-
});
|
|
115
|
-
let pending = base.map(toPending);
|
|
102
|
+
const pending = base.map(m => ({ ...m, node: { entries: [] } }));
|
|
116
103
|
for (const action of actions) {
|
|
117
104
|
switch (action.type) {
|
|
118
|
-
case 'setMenus': {
|
|
119
|
-
// replaces the menu bar wholesale, so item contributions made before it
|
|
120
|
-
// are dropped along with the menus they targeted
|
|
121
|
-
pending = action.newMenus.map(toPending);
|
|
122
|
-
break;
|
|
123
|
-
}
|
|
124
105
|
case 'addMenu': {
|
|
125
106
|
findOrCreateMenu(pending, action.menuName, action.position);
|
|
126
107
|
break;
|
|
127
108
|
}
|
|
128
109
|
case 'addItem': {
|
|
129
|
-
findOrCreateMenu(pending, action.menuPath[0]).
|
|
110
|
+
register(findOrCreateMenu(pending, action.menuPath[0]).node, action, 1);
|
|
130
111
|
break;
|
|
131
112
|
}
|
|
132
113
|
default: {
|
|
@@ -134,7 +115,10 @@ export function processMutableMenuActions(base, actions) {
|
|
|
134
115
|
}
|
|
135
116
|
}
|
|
136
117
|
}
|
|
137
|
-
return pending.map(
|
|
118
|
+
return pending.map(({ label, menuItems, node }) => ({
|
|
119
|
+
label,
|
|
120
|
+
menuItems: () => apply(menuItems(), node),
|
|
121
|
+
}));
|
|
138
122
|
}
|
|
139
123
|
/**
|
|
140
124
|
* Flatten resolved menus to plain data by opening every one of them. For tests
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AppSession } from './types.ts';
|
|
2
2
|
/**
|
|
3
|
-
* One element that says whether the whole app has finished.
|
|
3
|
+
* One element that says whether the whole app has finished, and what is open.
|
|
4
4
|
*
|
|
5
5
|
* `[data-app-phase="ready"]` is the entire readiness contract for anything
|
|
6
6
|
* driving JBrowse from outside — a screenshot tool, a test, an agent. Wait for
|
|
@@ -14,6 +14,15 @@ import type { AppSession } from './types.ts';
|
|
|
14
14
|
* a second on a two-track session. A positive attribute cannot be satisfied
|
|
15
15
|
* early, because something has to render it.
|
|
16
16
|
*
|
|
17
|
+
* The census attributes beside it — `data-app-views`, `data-app-assemblies`,
|
|
18
|
+
* `data-app-tracks` (the latter two JSON arrays) — publish WHAT is open the
|
|
19
|
+
* same way the phase publishes whether it is done, so an outside reader asking
|
|
20
|
+
* "is the track I requested actually open" reads one element instead of
|
|
21
|
+
* walking `window.JBrowseSession` with its own copy of the view nesting. The
|
|
22
|
+
* walk itself is `openViews`/`openTracks` over each view's declared
|
|
23
|
+
* `ownViews`/`ownTracks`, so this component knows nothing about which property
|
|
24
|
+
* a container view keeps its children on either.
|
|
25
|
+
*
|
|
17
26
|
* Its own component, and `hidden`, for two reasons: an observer here subscribes
|
|
18
27
|
* to every view's phase without re-rendering the app shell around it, and a
|
|
19
28
|
* marker that is never drawn cannot be mistaken for UI or shift a layout. It is
|
|
@@ -1,28 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { openTracks, openViews } from '@jbrowse/core/util/openViews';
|
|
2
3
|
import { observer } from 'mobx-react';
|
|
3
|
-
function
|
|
4
|
-
return
|
|
4
|
+
function trackLoading(track) {
|
|
5
|
+
return track.displays.some(d => d.displayPhase === 'loading');
|
|
5
6
|
}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
function anythingLoading(views) {
|
|
15
|
-
return views.some(view => view.showLoading === true ||
|
|
16
|
-
view.initialized === false ||
|
|
17
|
-
containerLoading(view) ||
|
|
18
|
-
// A view whose tracks hang off something else — the synteny view's are on
|
|
19
|
-
// its levels, one list per band. `view.tracks` is empty there, so without
|
|
20
|
-
// this a stack of ribbons still fetching reads as idle.
|
|
21
|
-
(view.trackContainers ?? []).some(containerLoading) ||
|
|
22
|
-
anythingLoading(view.views ?? []));
|
|
7
|
+
// A view whose init failed (its assembly was never found) stays uninitialized
|
|
8
|
+
// for good and paints its error, so like a display's terminal phase it is
|
|
9
|
+
// finished, not pending: without this the whole app read `loading` for as long
|
|
10
|
+
// as that view was open, and every settle answered false with no reason.
|
|
11
|
+
function viewLoading(view) {
|
|
12
|
+
const flags = view;
|
|
13
|
+
return (flags.error === undefined &&
|
|
14
|
+
(flags.showLoading === true || flags.initialized === false));
|
|
23
15
|
}
|
|
24
16
|
/**
|
|
25
|
-
* One element that says whether the whole app has finished.
|
|
17
|
+
* One element that says whether the whole app has finished, and what is open.
|
|
26
18
|
*
|
|
27
19
|
* `[data-app-phase="ready"]` is the entire readiness contract for anything
|
|
28
20
|
* driving JBrowse from outside — a screenshot tool, a test, an agent. Wait for
|
|
@@ -36,6 +28,15 @@ function anythingLoading(views) {
|
|
|
36
28
|
* a second on a two-track session. A positive attribute cannot be satisfied
|
|
37
29
|
* early, because something has to render it.
|
|
38
30
|
*
|
|
31
|
+
* The census attributes beside it — `data-app-views`, `data-app-assemblies`,
|
|
32
|
+
* `data-app-tracks` (the latter two JSON arrays) — publish WHAT is open the
|
|
33
|
+
* same way the phase publishes whether it is done, so an outside reader asking
|
|
34
|
+
* "is the track I requested actually open" reads one element instead of
|
|
35
|
+
* walking `window.JBrowseSession` with its own copy of the view nesting. The
|
|
36
|
+
* walk itself is `openViews`/`openTracks` over each view's declared
|
|
37
|
+
* `ownViews`/`ownTracks`, so this component knows nothing about which property
|
|
38
|
+
* a container view keeps its children on either.
|
|
39
|
+
*
|
|
39
40
|
* Its own component, and `hidden`, for two reasons: an observer here subscribes
|
|
40
41
|
* to every view's phase without re-rendering the app shell around it, and a
|
|
41
42
|
* marker that is never drawn cannot be mistaken for UI or shift a layout. It is
|
|
@@ -51,6 +52,11 @@ function anythingLoading(views) {
|
|
|
51
52
|
* and says why an error separates them). Both harnesses do it in that order.
|
|
52
53
|
*/
|
|
53
54
|
const AppReadyMarker = observer(function AppReadyMarker({ session, }) {
|
|
54
|
-
|
|
55
|
+
const views = openViews(session);
|
|
56
|
+
const tracks = openTracks(session);
|
|
57
|
+
const loading = views.some(viewLoading) || tracks.some(trackLoading);
|
|
58
|
+
return (_jsx("span", { hidden: true, "data-testid": "app-ready-marker", "data-app-phase": loading ? 'loading' : 'ready', "data-app-views": session.views.length, "data-app-assemblies": JSON.stringify([
|
|
59
|
+
...new Set(views.flatMap(v => v.assemblyNames ?? [])),
|
|
60
|
+
]), "data-app-tracks": JSON.stringify(tracks.map(t => t.configuration.trackId ?? '(unnamed)')) }));
|
|
55
61
|
});
|
|
56
62
|
export default AppReadyMarker;
|
|
@@ -20,9 +20,9 @@ const useStyles = makeStyles()(theme => ({
|
|
|
20
20
|
const ViewButtons = observer(function ViewButtons({ view, }) {
|
|
21
21
|
const { classes } = useStyles();
|
|
22
22
|
const session = getSession(view);
|
|
23
|
-
return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "data-testid": "minimize_view", onClick: () => {
|
|
23
|
+
return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-label": view.minimized ? 'Restore view' : 'Minimize view', "data-testid": "minimize_view", onClick: () => {
|
|
24
24
|
view.setMinimized(!view.minimized);
|
|
25
|
-
}, children: view.minimized ? (_jsx(AddIcon, { className: classes.icon, fontSize: "small" })) : (_jsx(MinimizeIcon, { className: classes.icon, fontSize: "small" })) }), _jsx(IconButton, { "data-testid": "close_view", onClick: () => {
|
|
25
|
+
}, children: view.minimized ? (_jsx(AddIcon, { className: classes.icon, fontSize: "small" })) : (_jsx(MinimizeIcon, { className: classes.icon, fontSize: "small" })) }), _jsx(IconButton, { "aria-label": "Close view", "data-testid": "close_view", onClick: () => {
|
|
26
26
|
session.removeView(view);
|
|
27
27
|
}, children: _jsx(CloseIcon, { className: classes.icon, fontSize: "small" }) })] }));
|
|
28
28
|
});
|
|
@@ -22,7 +22,7 @@ const ViewLauncher = observer(function ViewLauncher({ session, }) {
|
|
|
22
22
|
return (_jsxs(Paper, { className: classes.selectPaper, children: [_jsx(Typography, { children: "Select a view to launch" }), _jsx(FormControl, { className: classes.m2, children: _jsx(Select, { value: value, onChange: event => {
|
|
23
23
|
setValue(event.target.value);
|
|
24
24
|
}, children: viewTypes.map(({ displayName, name }) => (_jsx(MenuItem, { value: name, children: displayName }, name))) }) }), _jsx(FormControl, { className: classes.m2, children: _jsx(Button, { onClick: () => {
|
|
25
|
-
session.
|
|
25
|
+
void session.launchView(value, {});
|
|
26
26
|
}, variant: "contained", color: "primary", children: "Launch view" }) })] }));
|
|
27
27
|
});
|
|
28
28
|
export default ViewLauncher;
|