@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,20 +1,11 @@
1
- import type { MenuAction, MenuDefinition } from '../menus.ts';
2
- import type { MenuItem } from '@jbrowse/core/ui/Menu';
1
+ import type { MenuAction } from '../menus.ts';
2
+ import type { MenuItem } from '@jbrowse/core/ui/menuItems';
3
3
  /**
4
4
  * #stateModel RootAppMenuMixin
5
5
  */
6
6
  export declare function RootAppMenuMixin(): import("@jbrowse/mobx-state-tree").IModelType<{}, {
7
7
  mutableMenuActions: MenuAction[];
8
8
  } & {
9
- /**
10
- * #action
11
- * Replace the menu bar wholesale. Item contributions recorded before
12
- * this one are dropped along with the menus they targeted, so a plugin
13
- * adding to the existing bar wants `appendToMenu` instead.
14
- *
15
- * @param newMenus - The complete new set of top-level menus.
16
- */
17
- setMenus(newMenus: MenuDefinition[]): void;
18
9
  /**
19
10
  * #action
20
11
  * Add a top-level menu, if the app bar does not already have one with
@@ -14,17 +14,6 @@ export function RootAppMenuMixin() {
14
14
  self.mutableMenuActions = [...self.mutableMenuActions, action];
15
15
  }
16
16
  return {
17
- /**
18
- * #action
19
- * Replace the menu bar wholesale. Item contributions recorded before
20
- * this one are dropped along with the menus they targeted, so a plugin
21
- * adding to the existing bar wants `appendToMenu` instead.
22
- *
23
- * @param newMenus - The complete new set of top-level menus.
24
- */
25
- setMenus(newMenus) {
26
- pushAction({ type: 'setMenus', newMenus });
27
- },
28
17
  /**
29
18
  * #action
30
19
  * Add a top-level menu, if the app bar does not already have one with
@@ -1,4 +1,5 @@
1
1
  import { isSessionWithAddAssembly, isSessionWithAddSessionTrack, } from '@jbrowse/core/util';
2
+ import { legacyInitMessage, unknownKeysMessage, } from '@jbrowse/core/util/withLaunchInput';
2
3
  import { isAlive, isStateTreeNode } from '@jbrowse/mobx-state-tree';
3
4
  import { when } from 'mobx';
4
5
  function isSessionWithWorkspaceLayout(session) {
@@ -57,37 +58,75 @@ async function whenConnectionsSettle(session) {
57
58
  await when(() => gone() || session.connectionInstances.every(conn => !conn.loading));
58
59
  return !gone();
59
60
  }
60
- // Convert LayoutNode (view indices into the spec's `views` array) to
61
- // LayoutSpecNode (view IDs). `viewIds` is indexed the same as that spec
62
- // array — element i is the id of the view the i-th spec entry created, or
63
- // undefined if it created none — so a layout index maps to the right view
64
- // regardless of what order the views ended up in on the session.
65
- function convertLayoutNode(node, viewIds) {
66
- if (node.views !== undefined) {
67
- // Panel node - convert view indices to view IDs
68
- const ids = node.views
69
- .map(idx => viewIds[idx])
70
- .filter((id) => id !== undefined);
71
- return { viewIds: ids, size: node.size };
72
- }
73
- if (node.children) {
74
- // Container node - recursively convert children
75
- return {
76
- direction: node.direction,
77
- children: node.children.map(child => convertLayoutNode(child, viewIds)),
78
- size: node.size,
79
- };
80
- }
81
- return {};
61
+ // Resolve a spec layout's view indexes to ids before `applyLayoutSpec` counts
62
+ // them into `session.views`, which is a different list: a spec index names a
63
+ // spec entry, and `createdViewIds[i]` is EVERY view the i-th entry created, in
64
+ // creation order. A connected ProteinView creates its genome view and then
65
+ // itself, and the index names both — stacked in one cell — where recording one
66
+ // id per entry left the structure unaddressable, in no cell, and the panel
67
+ // naming it empty. A string is a view id and passes through; a reference that
68
+ // resolves to nothing is dropped here after `unresolvedLayoutRefs` reported it.
69
+ function convertLayoutNode(node, createdViewIds, knownIds, pinnedIds) {
70
+ const { views, children, ...rest } = node;
71
+ return {
72
+ ...rest,
73
+ ...(views === undefined
74
+ ? {}
75
+ : {
76
+ views: Array.isArray(views)
77
+ ? views.flatMap(ref => typeof ref === 'number'
78
+ ? (createdViewIds[ref] ?? []).filter(id => !pinnedIds.has(id))
79
+ : knownIds.has(ref)
80
+ ? [ref]
81
+ : [])
82
+ : views,
83
+ }),
84
+ ...(children === undefined
85
+ ? {}
86
+ : {
87
+ children: Array.isArray(children)
88
+ ? children.map(child => convertLayoutNode(child, createdViewIds, knownIds, pinnedIds))
89
+ : children,
90
+ }),
91
+ };
92
+ }
93
+ // An index names every view its entry created, so the entry that opens a genome
94
+ // view beside its own puts BOTH in the cell naming it — which is the point, and
95
+ // is wrong the moment another cell names one of them by id. `{views:[0]}` next
96
+ // to `{views:['structure']}` is the obvious spelling of "genome left, structure
97
+ // right", and expanding the index blind seats the structure in both cells: two
98
+ // React trees and two GPU contexts for one model, which `resolveLayoutSpec` now
99
+ // refuses outright. An id stated by hand is the more specific statement, so it
100
+ // wins its view and the index gives it up.
101
+ function pinnedLayoutIds(node, knownIds) {
102
+ return [
103
+ ...(Array.isArray(node.views)
104
+ ? node.views.filter((ref) => typeof ref === 'string' && knownIds.has(ref))
105
+ : []),
106
+ ...(Array.isArray(node.children)
107
+ ? node.children.flatMap(child => pinnedLayoutIds(child, knownIds))
108
+ : []),
109
+ ];
82
110
  }
83
111
  // A layout index is a position in the spec's `views` array, so one past the end
84
112
  // (or negative) is an authoring slip that would otherwise silently drop that
85
- // view from the layout, or, if every index in a panel is bad, leave an empty
86
- // panel with no clue why.
87
- function outOfRangeLayoutIndices(node, viewCount) {
113
+ // view from the layout, or, if every reference in a panel is bad, leave an
114
+ // empty panel with no clue why. An id names a view pinned with the spec's `id`
115
+ // key, so one nothing created is the same slip.
116
+ function unresolvedLayoutRefs(node, viewCount, knownIds) {
117
+ // Array-guarded rather than `?.`: a hand-written `views: 0` is non-nullish,
118
+ // so `?.` does not short-circuit and `(0).filter` threw a raw TypeError out
119
+ // of this validator — past `resolveLayoutSpec`, which has a real message for
120
+ // that exact slip and never got to say it.
88
121
  return [
89
- ...(node.views?.filter(idx => idx < 0 || idx >= viewCount) ?? []),
90
- ...(node.children?.flatMap(child => outOfRangeLayoutIndices(child, viewCount)) ?? []),
122
+ ...(Array.isArray(node.views)
123
+ ? node.views.filter(ref => typeof ref === 'number'
124
+ ? ref < 0 || ref >= viewCount
125
+ : !knownIds.has(ref))
126
+ : []),
127
+ ...(Array.isArray(node.children)
128
+ ? node.children.flatMap(child => unresolvedLayoutRefs(child, viewCount, knownIds))
129
+ : []),
91
130
  ];
92
131
  }
93
132
  // A `tabs` node shares one cell between its children rather than dividing the
@@ -103,9 +142,9 @@ function outOfRangeLayoutIndices(node, viewCount) {
103
142
  // nested or partial sizes had been ignored while the layout honoured them —
104
143
  // which is also what website/docs/urlparams.md promises.
105
144
  function unsizeableLayoutNodes(layout) {
106
- const sizedTabsChild = layout.direction === 'tabs' &&
107
- (layout.children?.some(c => c.size !== undefined) ?? false);
108
- return (sizedTabsChild || (layout.children?.some(unsizeableLayoutNodes) ?? false));
145
+ const children = Array.isArray(layout.children) ? layout.children : [];
146
+ const sizedTabsChild = layout.direction === 'tabs' && children.some(c => c.size !== undefined);
147
+ return sizedTabsChild || children.some(unsizeableLayoutNodes);
109
148
  }
110
149
  // The other statement a `tabs` node cannot take literally, and the one that
111
150
  // used to fail silently rather than partially: a tab holds a FLAT stack of
@@ -115,9 +154,9 @@ function unsizeableLayoutNodes(layout) {
115
154
  // to be dropped from the layout, after which homing swept them into whichever
116
155
  // tab happened to be showing, so the arrangement was wrong with nothing said.
117
156
  function flattenedTabsContainers(layout) {
118
- const nestedTabsChild = layout.direction === 'tabs' &&
119
- (layout.children?.some(c => c.children !== undefined) ?? false);
120
- return (nestedTabsChild || (layout.children?.some(flattenedTabsContainers) ?? false));
157
+ const children = Array.isArray(layout.children) ? layout.children : [];
158
+ const nestedTabsChild = layout.direction === 'tabs' && children.some(c => c.children !== undefined);
159
+ return nestedTabsChild || children.some(flattenedTabsContainers);
121
160
  }
122
161
  // use extension point named e.g. LaunchView-LinearGenomeView to initialize an
123
162
  // LGV session
@@ -202,27 +241,47 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
202
241
  if (noLauncher.length) {
203
242
  session?.notifyError(`View type(s) ${noLauncher.join(', ')} cannot be launched from a session spec: no LaunchView extension point is registered for them.`);
204
243
  }
205
- // The two ways of writing a view are not the same shape, and this is the
206
- // confusable pair:
244
+ // v4 nested a view's settings under `init`. A spec never becomes a
245
+ // snapshot, so `withLaunchInput`'s unwrap never runs on one and this
246
+ // surface does it itself, in the same order: the flat spelling wins.
247
+ const specViews = views.map(({ init, ...view }) => {
248
+ if (init) {
249
+ console.warn(legacyInitMessage(view.type));
250
+ }
251
+ return init ? { ...init, ...view } : view;
252
+ });
253
+ // The same classification a view snapshot gets, run here because a spec
254
+ // never becomes one: `LaunchView-<type>` takes these keys as arguments, so
255
+ // `withLaunchInput`'s partition never sees them and nothing names a typo.
256
+ // `{type: 'LinearGenomeView', asembly: 'volvox'}` reported only the
257
+ // launcher's downstream "No assembly provided", on the one surface written
258
+ // by hand with no compiler and no editor behind it.
259
+ //
260
+ // An ERROR rather than the snapshot path's warning: this surface's
261
+ // misplaced keys already report as errors, and the launcher's own failure
262
+ // lands as an error a line later — a warning under it reads as the lesser
263
+ // of the two, when it is the cause.
207
264
  //
208
- // - a spec view is flat *arguments* to LaunchView-<type>, in that launcher's
209
- // own vocabulary (a dotplot takes `views`, a spreadsheet takes `uri`), and
210
- // the launcher sorts them into what the new view needs
211
- // - a config/defaultSession view is MST *state*, so keys that need resolving
212
- // on load have nowhere to live but the view's own `init` property
265
+ // A view type that registers no launch keys classifies nothing: its
266
+ // launcher's vocabulary is undeclared, so every argument would read as a
267
+ // typo.
213
268
  //
214
- // Reported rather than unwrapped here, deliberately: only a launcher knows
215
- // which of its keys are one-shot commands versus plain view props, so
216
- // flattening `init` centrally would erase that distinction and let a view prop
217
- // nested inside it be honored from a spec while the identical config drops it.
218
- // Teaching each launcher to accept both instead is per-view-type work that
219
- // every future launcher has to remember — the shapes then differ by view type,
220
- // which is worse than differing by surface. One check here covers every view
221
- // type, including plugin-provided ones. Without it the LGV reports the
222
- // downstream symptom, "No assembly provided", for a misplaced block.
223
- const nested = views.filter(view => 'init' in view).map(view => view.type);
224
- if (nested.length) {
225
- session?.notifyError(`Session spec view(s) ${nested.join(', ')} nest their settings under "init". A spec takes those keys flat (assembly, loc, tracks, …); "init" is the config/defaultSession form.`);
269
+ // A lazily registered view type declares its properties on a state model
270
+ // that is not loaded yet, so it is loaded first — the launch below would
271
+ // have loaded it anyway.
272
+ await Promise.all(specViews
273
+ .filter(({ type }) => viewTypes.has(type))
274
+ .map(({ type }) => pluginManager.getViewType(type).loadStateModel()));
275
+ for (const { type, ...view } of specViews) {
276
+ const accepted = viewTypes.has(type)
277
+ ? pluginManager.getViewType(type).acceptedKeys
278
+ : undefined;
279
+ const unknown = accepted
280
+ ? Object.keys(view).filter(key => !accepted.includes(key))
281
+ : [];
282
+ if (unknown.length) {
283
+ session?.notifyError(unknownKeysMessage(type, unknown));
284
+ }
226
285
  }
227
286
  // Launch sequentially and record the id each spec view created, so the
228
287
  // layout below can map its indices to real views. Reading session.views
@@ -230,13 +289,12 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
230
289
  // synchronously and in order; capturing the delta per launch instead is
231
290
  // correct even if a handler awaits or adds an auxiliary view, and lets a
232
291
  // later spec view (e.g. a connected MsaView) reference an earlier one that
233
- // now already exists. `type` is the dispatch key, not view init data:
234
- // forwarding it would land in the view's declarative init blob and trip the
235
- // spurious "init ignored unknown key(s): type" warning meant to catch typos.
292
+ // now already exists. `type` is the dispatch key, not a setting: forwarding
293
+ // it would land in the view's launch blob and trip the spurious
294
+ // "ignored unknown key(s): type" warning meant to catch typos.
236
295
  // `displayName` is applied here rather than forwarded because it is a base
237
296
  // view prop every view type has, so one path covers all of them (including
238
- // plugin-provided types whose launcher never heard of it). A nested `init` is
239
- // NOT unwrapped the same way — see the diagnostic above for why.
297
+ // plugin-provided types whose launcher never heard of it).
240
298
  // Let any connection this spec registered finish before the views that
241
299
  // reference what it supplies are launched (see whenConnectionsSettle). Gated
242
300
  // on there being views: with none, nothing is waiting on the connection, and
@@ -251,7 +309,7 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
251
309
  return;
252
310
  }
253
311
  const createdViewIds = [];
254
- for (const { type, displayName, ...view } of views) {
312
+ for (const { type, displayName, ...view } of specViews) {
255
313
  const before = new Set(session?.views.map(v => v.id));
256
314
  // Strict so a launch handler that throws (missing/invalid assembly,
257
315
  // unresolved track, ...) surfaces as a snackbar instead of being swallowed
@@ -268,17 +326,27 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
268
326
  console.error(e);
269
327
  session?.notifyError(`Failed to launch ${type} view: ${e}`, e);
270
328
  }
271
- const created = session?.views.find(v => !before.has(v.id));
272
- if (created && displayName) {
273
- created.setDisplayName(displayName);
329
+ const created = session?.views.filter(v => !before.has(v.id)) ?? [];
330
+ // The entry's own view, when its launcher created others beside it: a
331
+ // connected ProteinView's name went to the genome view it opened first,
332
+ // and the structure came up "Untitled view".
333
+ const named = created.find(v => v.type === type) ?? created[0];
334
+ if (named && displayName) {
335
+ named.setDisplayName(displayName);
274
336
  }
275
- createdViewIds.push(created?.id);
337
+ createdViewIds.push(created.map(v => v.id));
276
338
  }
277
339
  if (layout && session) {
278
- const badIndices = outOfRangeLayoutIndices(layout, views.length);
340
+ const knownIds = new Set(createdViewIds.flat());
341
+ const bad = unresolvedLayoutRefs(layout, views.length, knownIds);
342
+ const badIndices = bad.filter(ref => typeof ref === 'number');
343
+ const badIds = bad.filter(ref => typeof ref === 'string');
279
344
  if (badIndices.length) {
280
345
  session.notifyError(`Session spec layout references view index ${badIndices.join(', ')}, but the spec has ${views.length} view(s).`);
281
346
  }
347
+ if (badIds.length) {
348
+ session.notifyError(`Session spec layout references view id ${badIds.map(id => `"${id}"`).join(', ')}, but no view in the spec has that id (a view's "id" key pins it).`);
349
+ }
282
350
  if (unsizeableLayoutNodes(layout)) {
283
351
  session.notify('Session spec layout: a "tabs" node shares one cell between its children rather than dividing the space, so the "size" on them was ignored. The rest of the layout is unaffected.', 'info');
284
352
  }
@@ -297,7 +365,20 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
297
365
  // what `applyLayoutSpec`'s return value is for. Drop this call and the
298
366
  // stated order is silently ignored: the tree holds it, nothing reads
299
367
  // it, and the views come back in launch order with no diagnostic.
300
- session.orderViews(session.applyLayoutSpec(convertLayoutNode(layout, createdViewIds)));
368
+ // Its own try/catch, the same reasoning as the per-view one above: the
369
+ // resolver throws for a layout it will not arrange, and this is the
370
+ // LAST thing the spec does — so an unarrangeable layout used to take
371
+ // the whole load's catch, after `setUseWorkspaces(true)` had already
372
+ // run, and report itself as the session's error with the views left
373
+ // unordered. The layout is one statement in the spec; losing it should
374
+ // cost the spec its layout and nothing else.
375
+ try {
376
+ session.orderViews(session.applyLayoutSpec(convertLayoutNode(layout, createdViewIds, knownIds, new Set(pinnedLayoutIds(layout, knownIds)))));
377
+ }
378
+ catch (e) {
379
+ console.error(e);
380
+ session.notifyError(`Session spec layout: ${e}`, e);
381
+ }
301
382
  }
302
383
  else {
303
384
  session.notifyError('Session spec has a "layout", but this application does not support workspace layouts');
@@ -1,3 +1,4 @@
1
+ import type { LayoutSpecNode } from '../WorkspaceLayout/spec.ts';
1
2
  import type { TrackInit } from '@jbrowse/core/util/tracks';
2
3
  export interface ViewSpec {
3
4
  type: string;
@@ -6,15 +7,17 @@ export interface ViewSpec {
6
7
  tracks?: TrackInit[];
7
8
  assembly?: string;
8
9
  loc?: string;
10
+ /**
11
+ * @deprecated v4's nesting. Write every setting directly on the view object;
12
+ * this is unwrapped on the way in and warns.
13
+ */
14
+ init?: Record<string, unknown>;
9
15
  }
10
16
  /**
11
- * Nested layout structure for workspaces.
17
+ * A spec's `layout`: the one layout shape, with a leaf's `views` counting into
18
+ * the spec's own `views` array (an index names every view that entry created)
19
+ * or naming a view id the spec pinned with `id`.
12
20
  *
13
- * A LayoutNode is either:
14
- * - A panel (has `views` array) - displays views stacked vertically
15
- * - A container (has `children` array) - arranges children horizontally or vertically
16
- *
17
- * Example - horizontal split with custom sizes:
18
21
  * ```json
19
22
  * {
20
23
  * "direction": "horizontal",
@@ -25,9 +28,4 @@ export interface ViewSpec {
25
28
  * }
26
29
  * ```
27
30
  */
28
- export interface LayoutNode {
29
- views?: number[];
30
- direction?: 'horizontal' | 'vertical' | 'tabs';
31
- children?: LayoutNode[];
32
- size?: number;
33
- }
31
+ export type LayoutNode = LayoutSpecNode;
@@ -182,6 +182,14 @@ export declare function WorkspaceLayoutMixin(): import("@jbrowse/mobx-state-tree
182
182
  /**
183
183
  * Arrange the workspace as a spec states.
184
184
  *
185
+ * The spec is the same shape a session spec's `layout` takes: a leaf's
186
+ * `views` names views by index into `session.views` or by id, so
187
+ * `{ direction: 'horizontal', children: [{ views: [0] }, { views: [1] }] }`
188
+ * means here what it means in a link. A leaf spelled any other way, an
189
+ * index past the end or an id no view has throws rather than arranging
190
+ * nothing — an untyped caller used to collapse the workspace into one
191
+ * blank tab that way.
192
+ *
185
193
  * There is no `init` property and no standing request: the spec is
186
194
  * converted and *becomes* the layout, here and now. `init` existed only
187
195
  * because dockview had to be told, could not be told before it mounted,
@@ -192,16 +200,16 @@ export declare function WorkspaceLayoutMixin(): import("@jbrowse/mobx-state-tree
192
200
  /**
193
201
  * ViewMenu's "move to new tab": the view leaves its tab for a new one.
194
202
  *
195
- * `allViewIds` is EVERY view in the session, and is required for that
196
- * reason — homing drops any view the list does not name, so the
203
+ * `allViewIds` is EVERY view in the session, defaulting to the host's
204
+ * own list — homing drops any view the list does not name, so the
197
205
  * `[viewId]` default this used to carry unhomed all the others.
198
206
  */
199
- moveViewToNewTab(viewId: string, allViewIds: string[]): string | undefined;
207
+ moveViewToNewTab(viewId: string, allViewIds?: string[]): string | undefined;
200
208
  /**
201
209
  * ViewMenu's "move to split view": the view leaves for a new cell.
202
210
  * `allViewIds` is every view in the session — see `moveViewToNewTab`.
203
211
  */
204
- moveViewToSplitRight(viewId: string, allViewIds: string[]): string | undefined;
212
+ moveViewToSplitRight(viewId: string, allViewIds?: string[]): string | undefined;
205
213
  homeUnassignedViews(viewIds: string[]): void;
206
214
  } & {
207
215
  /**
@@ -1,7 +1,27 @@
1
1
  import { createElementId } from '@jbrowse/core/util/types/mst';
2
2
  import { cast, getSnapshot, types } from '@jbrowse/mobx-state-tree';
3
- import { specForPendingMove, tileLayoutSpec, treeFromSpec, viewIdsInSpec, } from './spec.js';
3
+ import { resolveLayoutSpec, specForPendingMove, tileLayoutSpec, treeFromSpec, viewIdsInSpec, } from './spec.js';
4
4
  import { activeTabIn, addTab, addViewToTab, findTab, homeViews, moveTabToPanel, normalize, panelContainingView, panels, pruneEmptyPanel, pruneEmptyTabIn, removePanel, removeTab, removeView, renameTab, setActiveTab, setSizes, splitPanel, tabContainingView, tabs, } from './tree.js';
5
+ function sessionViewIds(self) {
6
+ return self.views?.map(v => v.id);
7
+ }
8
+ /**
9
+ * A caller-supplied view list narrowed to the views the session actually has.
10
+ *
11
+ * The list is the caller's snapshot, and `applyLayoutSpec` refuses an id no
12
+ * view has — so a view added after the list was built, or removed between
13
+ * building it and the call, would throw out of a sugar action no plugin wraps.
14
+ * The arrangement a stale name asks for is still the one it means for every
15
+ * view that IS there, so the name is dropped rather than the action. A repeat
16
+ * is dropped for the same reason: it would seat one view in two cells, which
17
+ * the resolver also refuses.
18
+ */
19
+ function liveViewIds(self, allViewIds) {
20
+ const live = sessionViewIds(self);
21
+ return [
22
+ ...new Set(live ? allViewIds.filter(id => live.includes(id)) : allViewIds),
23
+ ];
24
+ }
5
25
  /**
6
26
  * #stateModel WorkspaceLayoutMixin
7
27
  *
@@ -210,6 +230,13 @@ export function WorkspaceLayoutMixin() {
210
230
  * Returns where the view came from, or `undefined` if it is not in the
211
231
  * session, in which case nothing is applied.
212
232
  */
233
+ function everyViewId(action, allViewIds) {
234
+ const ids = allViewIds ?? sessionViewIds(self);
235
+ if (!ids) {
236
+ throw new Error(`${action}(viewId, allViewIds): allViewIds is every view id in the session, and this session has no view list to default it from`);
237
+ }
238
+ return ids;
239
+ }
213
240
  function rehomeView(viewId, allViewIds, place) {
214
241
  const tree = home(self.tree, allViewIds);
215
242
  const from = tabContainingView(tree, viewId);
@@ -400,6 +427,14 @@ export function WorkspaceLayoutMixin() {
400
427
  /**
401
428
  * Arrange the workspace as a spec states.
402
429
  *
430
+ * The spec is the same shape a session spec's `layout` takes: a leaf's
431
+ * `views` names views by index into `session.views` or by id, so
432
+ * `{ direction: 'horizontal', children: [{ views: [0] }, { views: [1] }] }`
433
+ * means here what it means in a link. A leaf spelled any other way, an
434
+ * index past the end or an id no view has throws rather than arranging
435
+ * nothing — an untyped caller used to collapse the workspace into one
436
+ * blank tab that way.
437
+ *
403
438
  * There is no `init` property and no standing request: the spec is
404
439
  * converted and *becomes* the layout, here and now. `init` existed only
405
440
  * because dockview had to be told, could not be told before it mounted,
@@ -407,20 +442,21 @@ export function WorkspaceLayoutMixin() {
407
442
  * from the layout living somewhere this action could not reach.
408
443
  */
409
444
  applyLayoutSpec(spec) {
410
- apply(treeFromSpec(spec, nextId));
445
+ const resolved = resolveLayoutSpec(spec, sessionViewIds(self));
446
+ apply(treeFromSpec(resolved, nextId));
411
447
  self.activePanelId = panels(self.tree)[0]?.id;
412
- return viewIdsInSpec(spec);
448
+ return viewIdsInSpec(resolved);
413
449
  },
414
450
  /**
415
451
  * ViewMenu's "move to new tab": the view leaves its tab for a new one.
416
452
  *
417
- * `allViewIds` is EVERY view in the session, and is required for that
418
- * reason — homing drops any view the list does not name, so the
453
+ * `allViewIds` is EVERY view in the session, defaulting to the host's
454
+ * own list — homing drops any view the list does not name, so the
419
455
  * `[viewId]` default this used to carry unhomed all the others.
420
456
  */
421
457
  moveViewToNewTab(viewId, allViewIds) {
422
458
  const tab = { id: nextId('tab'), viewIds: [viewId] };
423
- const from = rehomeView(viewId, allViewIds, (tree, at) => addTab(tree, at.panel.id, tab));
459
+ const from = rehomeView(viewId, everyViewId('moveViewToNewTab', allViewIds), (tree, at) => addTab(tree, at.panel.id, tab));
424
460
  if (!from) {
425
461
  return undefined;
426
462
  }
@@ -439,7 +475,7 @@ export function WorkspaceLayoutMixin() {
439
475
  tabs: [{ id: tabId, viewIds: [viewId] }],
440
476
  activeTabId: tabId,
441
477
  };
442
- const from = rehomeView(viewId, allViewIds, (tree, at) => splitPanel(tree, at.panel.id, 'row', panel));
478
+ const from = rehomeView(viewId, everyViewId('moveViewToSplitRight', allViewIds), (tree, at) => splitPanel(tree, at.panel.id, 'row', panel));
443
479
  if (!from) {
444
480
  return undefined;
445
481
  }
@@ -480,21 +516,20 @@ export function WorkspaceLayoutMixin() {
480
516
  * is spelled.
481
517
  */
482
518
  setPendingMove(move, allViewIds) {
483
- // The session's whole set of views, for the one entry point that cannot
484
- // be handed it. Every other one takes it as an argument on purpose —
485
- // this mixin owns the tree and knows nothing else — and this would too,
486
- // if its caller were ours. Duck-typed because `views` is
487
- // MultipleViewsSessionMixin's and composition is the only thing that
488
- // puts the two together.
489
- const { views } = self;
490
519
  // Not `?? []`: homing drops every view the list does not name, so an
491
520
  // empty one would answer "put this view beside nothing" and unhome the
492
521
  // rest. Nothing to say is nothing to do.
493
- const ids = allViewIds ?? views?.map(v => v.id);
522
+ const ids = allViewIds ?? sessionViewIds(self);
494
523
  if (!move || !ids) {
495
524
  return;
496
525
  }
497
- self.applyLayoutSpec(specForPendingMove(move, ids));
526
+ const known = liveViewIds(self, ids);
527
+ // Nothing to move: the view named is not in the session, so there is
528
+ // no arrangement to state about it.
529
+ if (sessionViewIds(self) && !known.includes(move.viewId)) {
530
+ return;
531
+ }
532
+ self.applyLayoutSpec(specForPendingMove(move, known));
498
533
  // Show where the view went. A spec states an arrangement and not a
499
534
  // selection, so `treeFromSpec` shows each cell's FIRST tab — and
500
535
  // `newTab` puts the moved view in a tab beside the others, which makes
@@ -518,7 +553,7 @@ export function WorkspaceLayoutMixin() {
518
553
  * session spec's layout there is nothing for `orderViews` to apply.
519
554
  */
520
555
  tileViews(mode, allViewIds) {
521
- self.applyLayoutSpec(tileLayoutSpec(allViewIds, mode));
556
+ self.applyLayoutSpec(tileLayoutSpec(liveViewIds(self, allViewIds), mode));
522
557
  },
523
558
  })));
524
559
  }
@@ -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;