@jbrowse/app-core 5.0.0-beta.1 → 5.0.0-beta.2

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,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 the views' own `allViews`/`ownTracks` contract (BaseViewModel
23
+ * derives it), so this component knows nothing about which property a
24
+ * 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,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { observer } from 'mobx-react';
3
- function containerLoading(container) {
4
- return (container.tracks ?? []).some(track => (track.displays ?? []).some(d => d.displayPhase === 'loading'));
3
+ function trackLoading(track) {
4
+ return track.displays.some(d => d.displayPhase === 'loading');
5
5
  }
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 ?? []));
6
+ function viewLoading(view) {
7
+ const flags = view;
8
+ return flags.showLoading === true || flags.initialized === false;
23
9
  }
24
10
  /**
25
- * One element that says whether the whole app has finished.
11
+ * One element that says whether the whole app has finished, and what is open.
26
12
  *
27
13
  * `[data-app-phase="ready"]` is the entire readiness contract for anything
28
14
  * driving JBrowse from outside — a screenshot tool, a test, an agent. Wait for
@@ -36,6 +22,15 @@ function anythingLoading(views) {
36
22
  * a second on a two-track session. A positive attribute cannot be satisfied
37
23
  * early, because something has to render it.
38
24
  *
25
+ * The census attributes beside it — `data-app-views`, `data-app-assemblies`,
26
+ * `data-app-tracks` (the latter two JSON arrays) — publish WHAT is open the
27
+ * same way the phase publishes whether it is done, so an outside reader asking
28
+ * "is the track I requested actually open" reads one element instead of
29
+ * walking `window.JBrowseSession` with its own copy of the view nesting. The
30
+ * walk itself is the views' own `allViews`/`ownTracks` contract (BaseViewModel
31
+ * derives it), so this component knows nothing about which property a
32
+ * container view keeps its children on either.
33
+ *
39
34
  * Its own component, and `hidden`, for two reasons: an observer here subscribes
40
35
  * to every view's phase without re-rendering the app shell around it, and a
41
36
  * marker that is never drawn cannot be mistaken for UI or shift a layout. It is
@@ -51,6 +46,11 @@ function anythingLoading(views) {
51
46
  * and says why an error separates them). Both harnesses do it in that order.
52
47
  */
53
48
  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' }));
49
+ const views = session.views.flatMap(v => v.allViews);
50
+ const tracks = views.flatMap(v => v.ownTracks);
51
+ const loading = views.some(viewLoading) || tracks.some(trackLoading);
52
+ 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([
53
+ ...new Set(views.flatMap(v => v.assemblyNames ?? [])),
54
+ ]), "data-app-tracks": JSON.stringify(tracks.map(t => t.configuration.trackId ?? '(unnamed)')) }));
55
55
  });
56
56
  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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/app-core",
3
- "version": "5.0.0-beta.1",
3
+ "version": "5.0.0-beta.2",
4
4
  "type": "module",
5
5
  "description": "JBrowse 2 code shared between the 'full featured' apps e.g. jbrowse-web and jbrowse-desktop",
6
6
  "keywords": [
@@ -25,12 +25,12 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@jbrowse/mobx-state-tree": "^6.5.1",
28
- "@mui/icons-material": "^9.3.1",
29
- "@mui/material": "^9.3.1",
28
+ "@mui/icons-material": "^9.4.0",
29
+ "@mui/material": "^9.4.0",
30
30
  "mobx": "^7.0.3",
31
31
  "mobx-react": "^10.0.2",
32
- "@jbrowse/product-core": "^5.0.0-beta.1",
33
- "@jbrowse/core": "^5.0.0-beta.1"
32
+ "@jbrowse/product-core": "^5.0.0-beta.2",
33
+ "@jbrowse/core": "^5.0.0-beta.2"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": ">=19.0.0",