@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.
@@ -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. **This shape is public** — it is documented as a URL parameter —
5
- * so it keeps `horizontal`/`vertical`/`tabs` and percentage `size` rather than
6
- * being renamed to match the internal tree.
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
- viewIds?: string[];
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: LayoutSpecNode, nextId: (kind: NodeKind) => string): LayoutTree;
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: LayoutSpecNode): string[];
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): LayoutSpecNode;
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[]): LayoutSpecNode;
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.viewIds) {
120
+ if (node.views) {
48
121
  return {
49
122
  id: nextId('panel'),
50
123
  size,
51
- tabs: [{ id: nextId('tab'), viewIds: [...node.viewIds] }],
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.viewIds === undefined && viewIds.length === 0
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.viewIds ?? []),
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 { viewIds: [...viewIds] };
197
+ return { views: [...viewIds] };
125
198
  }
126
- const cell = (id) => ({ viewIds: [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: [{ viewIds: others }, { viewIds: [move.viewId] }],
223
+ children: [{ views: others }, { views: [move.viewId] }],
151
224
  }
152
- : { viewIds: [move.viewId] };
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, MenuDefinition } from './menus.ts';
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, MenuItemsGetter } from '@jbrowse/core/ui';
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
- * A menu as a root model authors it. Items may be a plain array, or a thunk for
38
- * a menu whose contents depend on state that would otherwise be read — and so
39
- * tracked — every time the app bar renders.
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 interface MenuDefinition {
42
- label: string;
43
- menuItems: MenuItemsGetter;
44
- }
31
+ export type MenuAction = AddMenuAction | AddItemAction;
45
32
  /**
46
- * A menu as the app bar consumes it. Always a thunk returning a fresh array: a
47
- * menu's items are produced when it opens and never before, so no consumer has
48
- * to care which form the root model authored, and a plugin's contributions are
49
- * merged in at that point rather than spliced into the definition.
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 menu definitions against the contributions plugins
57
- * have pushed (via `RootAppMenuMixin`). Structural contributions apply now;
58
- * item contributions are recorded against their target menu and applied when
59
- * that menu opens, so a menu that computes its items lazily stays lazy and
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: MenuDefinition[], actions: MenuAction[]): Menu[];
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 contribution that threw is reported once rather than on every open — the
2
- // menu re-resolves on each open and, while open, on each observer re-render
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
- * The action log is permanent; the menu it builds is thrown away when the menu
6
- * closes. Everything a resolution walks therefore has to belong to the
7
- * throwaway, because resolving a path is a **write**:
8
- * `appendToSubMenu(['Add', 'My plugin'], two)` means "find the array labelled
9
- * My plugin and push into it".
10
- *
11
- * So when an earlier action is what supplied that array —
12
- * `appendToMenu('Add', { label: 'My plugin', subMenu: [one] })` — and it goes
13
- * into the menu by reference, the push lands in the stored action instead. The
14
- * next open replays a log that now reads `subMenu: [one, two]` and pushes
15
- * again, so the sub-menu gains an item on every open, and on every observer
16
- * re-render while open.
17
- *
18
- * Cloning is what keeps the two apart: the copy is what the path resolves to
19
- * and what gets written, and it dies with the open. Leaf items are shared by
20
- * reference — nothing writes into them — so this costs an allocation only for
21
- * the rows that nest.
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
- * Walk `menuPath` past its first segment (the top-level menu, already
41
- * resolved), creating empty sub-menus as needed, and return the deepest
42
- * sub-menu's item array. Throws if a path segment names an item that is not a
43
- * sub-menu.
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 resolveSubMenu(items, menuPath) {
46
- let level = items;
47
- for (const [idx, menuName] of menuPath.slice(1).entries()) {
48
- let sub = level.find((mi) => 'subMenu' in mi && mi.label === menuName);
49
- if (!sub) {
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
- level = sub.subMenu;
58
- }
59
- return level;
60
- }
61
- // each contribution is applied independently: a plugin that names a bad path
62
- // loses its own item rather than every other plugin's, and never the session —
63
- // this runs from the app bar's click handler, where a throw goes straight
64
- // through React
65
- function applyItemActions(items, actions) {
66
- for (const action of actions) {
67
- try {
68
- const target = resolveSubMenu(items, action.menuPath);
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 items;
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 = { label, base: [], itemActions: [] };
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: () => applyItemActions(cloneMenuItems(materialize(base)), itemActions),
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 menu definitions against the contributions plugins
101
- * have pushed (via `RootAppMenuMixin`). Structural contributions apply now;
102
- * item contributions are recorded against their target menu and applied when
103
- * that menu opens, so a menu that computes its items lazily stays lazy and
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 toPending = (m) => ({
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]).itemActions.push(action);
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(m => toMenu(m));
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 containerLoading(container) {
4
- return (container.tracks ?? []).some(track => (track.displays ?? []).some(d => d.displayPhase === 'loading'));
4
+ function trackLoading(track) {
5
+ return track.displays.some(d => d.displayPhase === 'loading');
5
6
  }
6
- /**
7
- * Is anything in this session still working?
8
- *
9
- * The same two facts the per-element attributes publish — a view still
10
- * resolving its assembly, a display still in its own fetch — reduced to one
11
- * answer for the whole app. Duck-typed rather than imported: every view type
12
- * would otherwise have to be a dependency of the app shell.
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
- return (_jsx("span", { hidden: true, "data-testid": "app-ready-marker", "data-app-phase": anythingLoading(session.views) ? 'loading' : 'ready' }));
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.addView(value, {});
25
+ void session.launchView(value, {});
26
26
  }, variant: "contained", color: "primary", children: "Launch view" }) })] }));
27
27
  });
28
28
  export default ViewLauncher;