@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,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,10 +1,9 @@
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) {
5
- return ('applyLayoutSpec' in session &&
6
- 'setUseWorkspaces' in session &&
7
- 'orderViews' in session);
6
+ return 'layoutViews' in session;
8
7
  }
9
8
  function isSessionWithSpecConnections(session) {
10
9
  return ('addConnectionConf' in session &&
@@ -57,37 +56,75 @@ async function whenConnectionsSettle(session) {
57
56
  await when(() => gone() || session.connectionInstances.every(conn => !conn.loading));
58
57
  return !gone();
59
58
  }
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 {};
59
+ // Resolve a spec layout's view indexes to ids before `applyLayoutSpec` counts
60
+ // them into `session.views`, which is a different list: a spec index names a
61
+ // spec entry, and `createdViewIds[i]` is EVERY view the i-th entry created, in
62
+ // creation order. A connected ProteinView creates its genome view and then
63
+ // itself, and the index names both — stacked in one cell — where recording one
64
+ // id per entry left the structure unaddressable, in no cell, and the panel
65
+ // naming it empty. A string is a view id and passes through; a reference that
66
+ // resolves to nothing is dropped here after `unresolvedLayoutRefs` reported it.
67
+ function convertLayoutNode(node, createdViewIds, knownIds, pinnedIds) {
68
+ const { views, children, ...rest } = node;
69
+ return {
70
+ ...rest,
71
+ ...(views === undefined
72
+ ? {}
73
+ : {
74
+ views: Array.isArray(views)
75
+ ? views.flatMap(ref => typeof ref === 'number'
76
+ ? (createdViewIds[ref] ?? []).filter(id => !pinnedIds.has(id))
77
+ : knownIds.has(ref)
78
+ ? [ref]
79
+ : [])
80
+ : views,
81
+ }),
82
+ ...(children === undefined
83
+ ? {}
84
+ : {
85
+ children: Array.isArray(children)
86
+ ? children.map(child => convertLayoutNode(child, createdViewIds, knownIds, pinnedIds))
87
+ : children,
88
+ }),
89
+ };
90
+ }
91
+ // An index names every view its entry created, so the entry that opens a genome
92
+ // view beside its own puts BOTH in the cell naming it — which is the point, and
93
+ // is wrong the moment another cell names one of them by id. `{views:[0]}` next
94
+ // to `{views:['structure']}` is the obvious spelling of "genome left, structure
95
+ // right", and expanding the index blind seats the structure in both cells: two
96
+ // React trees and two GPU contexts for one model, which `resolveLayoutSpec` now
97
+ // refuses outright. An id stated by hand is the more specific statement, so it
98
+ // wins its view and the index gives it up.
99
+ function pinnedLayoutIds(node, knownIds) {
100
+ return [
101
+ ...(Array.isArray(node.views)
102
+ ? node.views.filter((ref) => typeof ref === 'string' && knownIds.has(ref))
103
+ : []),
104
+ ...(Array.isArray(node.children)
105
+ ? node.children.flatMap(child => pinnedLayoutIds(child, knownIds))
106
+ : []),
107
+ ];
82
108
  }
83
109
  // A layout index is a position in the spec's `views` array, so one past the end
84
110
  // (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) {
111
+ // view from the layout, or, if every reference in a panel is bad, leave an
112
+ // empty panel with no clue why. An id names a view pinned with the spec's `id`
113
+ // key, so one nothing created is the same slip.
114
+ function unresolvedLayoutRefs(node, viewCount, knownIds) {
115
+ // Array-guarded rather than `?.`: a hand-written `views: 0` is non-nullish,
116
+ // so `?.` does not short-circuit and `(0).filter` threw a raw TypeError out
117
+ // of this validator — past `resolveLayoutSpec`, which has a real message for
118
+ // that exact slip and never got to say it.
88
119
  return [
89
- ...(node.views?.filter(idx => idx < 0 || idx >= viewCount) ?? []),
90
- ...(node.children?.flatMap(child => outOfRangeLayoutIndices(child, viewCount)) ?? []),
120
+ ...(Array.isArray(node.views)
121
+ ? node.views.filter(ref => typeof ref === 'number'
122
+ ? ref < 0 || ref >= viewCount
123
+ : !knownIds.has(ref))
124
+ : []),
125
+ ...(Array.isArray(node.children)
126
+ ? node.children.flatMap(child => unresolvedLayoutRefs(child, viewCount, knownIds))
127
+ : []),
91
128
  ];
92
129
  }
93
130
  // A `tabs` node shares one cell between its children rather than dividing the
@@ -103,9 +140,9 @@ function outOfRangeLayoutIndices(node, viewCount) {
103
140
  // nested or partial sizes had been ignored while the layout honoured them —
104
141
  // which is also what website/docs/urlparams.md promises.
105
142
  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));
143
+ const children = Array.isArray(layout.children) ? layout.children : [];
144
+ const sizedTabsChild = layout.direction === 'tabs' && children.some(c => c.size !== undefined);
145
+ return sizedTabsChild || children.some(unsizeableLayoutNodes);
109
146
  }
110
147
  // The other statement a `tabs` node cannot take literally, and the one that
111
148
  // used to fail silently rather than partially: a tab holds a FLAT stack of
@@ -115,9 +152,9 @@ function unsizeableLayoutNodes(layout) {
115
152
  // to be dropped from the layout, after which homing swept them into whichever
116
153
  // tab happened to be showing, so the arrangement was wrong with nothing said.
117
154
  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));
155
+ const children = Array.isArray(layout.children) ? layout.children : [];
156
+ const nestedTabsChild = layout.direction === 'tabs' && children.some(c => c.children !== undefined);
157
+ return nestedTabsChild || children.some(flattenedTabsContainers);
121
158
  }
122
159
  // use extension point named e.g. LaunchView-LinearGenomeView to initialize an
123
160
  // LGV session
@@ -202,27 +239,47 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
202
239
  if (noLauncher.length) {
203
240
  session?.notifyError(`View type(s) ${noLauncher.join(', ')} cannot be launched from a session spec: no LaunchView extension point is registered for them.`);
204
241
  }
205
- // The two ways of writing a view are not the same shape, and this is the
206
- // confusable pair:
242
+ // v4 nested a view's settings under `init`. A spec never becomes a
243
+ // snapshot, so `withLaunchInput`'s unwrap never runs on one and this
244
+ // surface does it itself, in the same order: the flat spelling wins.
245
+ const specViews = views.map(({ init, ...view }) => {
246
+ if (init) {
247
+ console.warn(legacyInitMessage(view.type));
248
+ }
249
+ return init ? { ...init, ...view } : view;
250
+ });
251
+ // The same classification a view snapshot gets, run here because a spec
252
+ // never becomes one: `LaunchView-<type>` takes these keys as arguments, so
253
+ // `withLaunchInput`'s partition never sees them and nothing names a typo.
254
+ // `{type: 'LinearGenomeView', asembly: 'volvox'}` reported only the
255
+ // launcher's downstream "No assembly provided", on the one surface written
256
+ // by hand with no compiler and no editor behind it.
257
+ //
258
+ // An ERROR rather than the snapshot path's warning: this surface's
259
+ // misplaced keys already report as errors, and the launcher's own failure
260
+ // lands as an error a line later — a warning under it reads as the lesser
261
+ // of the two, when it is the cause.
207
262
  //
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
263
+ // A view type that registers no launch keys classifies nothing: its
264
+ // launcher's vocabulary is undeclared, so every argument would read as a
265
+ // typo.
213
266
  //
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.`);
267
+ // A lazily registered view type declares its properties on a state model
268
+ // that is not loaded yet, so it is loaded first — the launch below would
269
+ // have loaded it anyway.
270
+ await Promise.all(specViews
271
+ .filter(({ type }) => viewTypes.has(type))
272
+ .map(({ type }) => pluginManager.getViewType(type).loadStateModel()));
273
+ for (const { type, ...view } of specViews) {
274
+ const accepted = viewTypes.has(type)
275
+ ? pluginManager.getViewType(type).acceptedKeys
276
+ : undefined;
277
+ const unknown = accepted
278
+ ? Object.keys(view).filter(key => !accepted.includes(key))
279
+ : [];
280
+ if (unknown.length) {
281
+ session?.notifyError(unknownKeysMessage(type, unknown));
282
+ }
226
283
  }
227
284
  // Launch sequentially and record the id each spec view created, so the
228
285
  // layout below can map its indices to real views. Reading session.views
@@ -230,13 +287,12 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
230
287
  // synchronously and in order; capturing the delta per launch instead is
231
288
  // correct even if a handler awaits or adds an auxiliary view, and lets a
232
289
  // 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.
290
+ // now already exists. `type` is the dispatch key, not a setting: forwarding
291
+ // it would land in the view's launch blob and trip the spurious
292
+ // "ignored unknown key(s): type" warning meant to catch typos.
236
293
  // `displayName` is applied here rather than forwarded because it is a base
237
294
  // 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.
295
+ // plugin-provided types whose launcher never heard of it).
240
296
  // Let any connection this spec registered finish before the views that
241
297
  // reference what it supplies are launched (see whenConnectionsSettle). Gated
242
298
  // on there being views: with none, nothing is waiting on the connection, and
@@ -251,7 +307,7 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
251
307
  return;
252
308
  }
253
309
  const createdViewIds = [];
254
- for (const { type, displayName, ...view } of views) {
310
+ for (const { type, displayName, ...view } of specViews) {
255
311
  const before = new Set(session?.views.map(v => v.id));
256
312
  // Strict so a launch handler that throws (missing/invalid assembly,
257
313
  // unresolved track, ...) surfaces as a snackbar instead of being swallowed
@@ -268,17 +324,27 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
268
324
  console.error(e);
269
325
  session?.notifyError(`Failed to launch ${type} view: ${e}`, e);
270
326
  }
271
- const created = session?.views.find(v => !before.has(v.id));
272
- if (created && displayName) {
273
- created.setDisplayName(displayName);
327
+ const created = session?.views.filter(v => !before.has(v.id)) ?? [];
328
+ // The entry's own view, when its launcher created others beside it: a
329
+ // connected ProteinView's name went to the genome view it opened first,
330
+ // and the structure came up "Untitled view".
331
+ const named = created.find(v => v.type === type) ?? created[0];
332
+ if (named && displayName) {
333
+ named.setDisplayName(displayName);
274
334
  }
275
- createdViewIds.push(created?.id);
335
+ createdViewIds.push(created.map(v => v.id));
276
336
  }
277
337
  if (layout && session) {
278
- const badIndices = outOfRangeLayoutIndices(layout, views.length);
338
+ const knownIds = new Set(createdViewIds.flat());
339
+ const bad = unresolvedLayoutRefs(layout, views.length, knownIds);
340
+ const badIndices = bad.filter(ref => typeof ref === 'number');
341
+ const badIds = bad.filter(ref => typeof ref === 'string');
279
342
  if (badIndices.length) {
280
343
  session.notifyError(`Session spec layout references view index ${badIndices.join(', ')}, but the spec has ${views.length} view(s).`);
281
344
  }
345
+ if (badIds.length) {
346
+ 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).`);
347
+ }
282
348
  if (unsizeableLayoutNodes(layout)) {
283
349
  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
350
  }
@@ -286,18 +352,19 @@ export async function loadSessionSpec({ views, sessionAssemblies = [], sessionCo
286
352
  session.notify('Session spec layout: a "tabs" node gives each child one tab, and a tab holds a flat stack of views — so a container inside one was flattened into a single tab rather than splitting it. The rest of the layout is unaffected.', 'info');
287
353
  }
288
354
  if (isSessionWithWorkspaceLayout(session)) {
289
- // Enable workspaces mode for this session only — a spec URL shouldn't
290
- // rewrite the visitor's own preference
291
- session.setUseWorkspaces(true);
292
- // A tab's `viewIds` is membership, not order: a tab renders its views
293
- // in `session.views` order (WorkspaceContainer's `viewsOf`, and
294
- // `viewIdsForTab`). So the top-to-bottom order a spec panel states —
295
- // `viewIds` is documented as "the views to stack vertically in one tab"
296
- // — only takes effect if it is applied to `session.views`, which is
297
- // what `applyLayoutSpec`'s return value is for. Drop this call and the
298
- // stated order is silently ignored: the tree holds it, nothing reads
299
- // it, and the views come back in launch order with no diagnostic.
300
- session.orderViews(session.applyLayoutSpec(convertLayoutNode(layout, createdViewIds)));
355
+ // Its own try/catch, the same reasoning as the per-view one above: the
356
+ // resolver throws for a layout it will not arrange, and this is the
357
+ // LAST thing the spec does — so an unarrangeable layout used to take
358
+ // the whole load's catch and report itself as the session's error. The
359
+ // layout is one statement in the spec; losing it should cost the spec
360
+ // its layout and nothing else.
361
+ try {
362
+ session.layoutViews(convertLayoutNode(layout, createdViewIds, knownIds, new Set(pinnedLayoutIds(layout, knownIds))));
363
+ }
364
+ catch (e) {
365
+ console.error(e);
366
+ session.notifyError(`Session spec layout: ${e}`, e);
367
+ }
301
368
  }
302
369
  else {
303
370
  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
  /**
@@ -233,6 +241,18 @@ export declare function WorkspaceLayoutMixin(): import("@jbrowse/mobx-state-tree
233
241
  * session spec's layout there is nothing for `orderViews` to apply.
234
242
  */
235
243
  tileViews(mode: TileMode, allViewIds: string[]): void;
244
+ /**
245
+ * #action
246
+ * Arrange the session's views into panels: `applyLayoutSpec` plus the
247
+ * two things it does not do on its own, each of which fails silently
248
+ * without it — turning workspaces mode on for this session (a layout
249
+ * renders nowhere else) and ordering `session.views` to the spec's
250
+ * top-to-bottom statement, which is where a tab reads its order from.
251
+ * Leaves take view ids or indexes into `session.views`; a layout that
252
+ * seats no view throws rather than leaving a blank tab. What a session
253
+ * spec's `layout` and an agent's live re-layout both call.
254
+ */
255
+ layoutViews(spec: LayoutSpecNode): string[];
236
256
  }, import("@jbrowse/mobx-state-tree")._NotCustomized, import("@jbrowse/mobx-state-tree")._NotCustomized>;
237
257
  export type WorkspaceLayoutMixinType = ReturnType<typeof WorkspaceLayoutMixin>;
238
258
  export interface WorkspaceLayout extends Instance<WorkspaceLayoutMixinType> {
@@ -1,7 +1,30 @@
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
+ function layoutHost(self) {
9
+ return self;
10
+ }
11
+ /**
12
+ * A caller-supplied view list narrowed to the views the session actually has.
13
+ *
14
+ * The list is the caller's snapshot, and `applyLayoutSpec` refuses an id no
15
+ * view has — so a view added after the list was built, or removed between
16
+ * building it and the call, would throw out of a sugar action no plugin wraps.
17
+ * The arrangement a stale name asks for is still the one it means for every
18
+ * view that IS there, so the name is dropped rather than the action. A repeat
19
+ * is dropped for the same reason: it would seat one view in two cells, which
20
+ * the resolver also refuses.
21
+ */
22
+ function liveViewIds(self, allViewIds) {
23
+ const live = sessionViewIds(self);
24
+ return [
25
+ ...new Set(live ? allViewIds.filter(id => live.includes(id)) : allViewIds),
26
+ ];
27
+ }
5
28
  /**
6
29
  * #stateModel WorkspaceLayoutMixin
7
30
  *
@@ -210,6 +233,13 @@ export function WorkspaceLayoutMixin() {
210
233
  * Returns where the view came from, or `undefined` if it is not in the
211
234
  * session, in which case nothing is applied.
212
235
  */
236
+ function everyViewId(action, allViewIds) {
237
+ const ids = allViewIds ?? sessionViewIds(self);
238
+ if (!ids) {
239
+ 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`);
240
+ }
241
+ return ids;
242
+ }
213
243
  function rehomeView(viewId, allViewIds, place) {
214
244
  const tree = home(self.tree, allViewIds);
215
245
  const from = tabContainingView(tree, viewId);
@@ -400,6 +430,14 @@ export function WorkspaceLayoutMixin() {
400
430
  /**
401
431
  * Arrange the workspace as a spec states.
402
432
  *
433
+ * The spec is the same shape a session spec's `layout` takes: a leaf's
434
+ * `views` names views by index into `session.views` or by id, so
435
+ * `{ direction: 'horizontal', children: [{ views: [0] }, { views: [1] }] }`
436
+ * means here what it means in a link. A leaf spelled any other way, an
437
+ * index past the end or an id no view has throws rather than arranging
438
+ * nothing — an untyped caller used to collapse the workspace into one
439
+ * blank tab that way.
440
+ *
403
441
  * There is no `init` property and no standing request: the spec is
404
442
  * converted and *becomes* the layout, here and now. `init` existed only
405
443
  * because dockview had to be told, could not be told before it mounted,
@@ -407,20 +445,21 @@ export function WorkspaceLayoutMixin() {
407
445
  * from the layout living somewhere this action could not reach.
408
446
  */
409
447
  applyLayoutSpec(spec) {
410
- apply(treeFromSpec(spec, nextId));
448
+ const resolved = resolveLayoutSpec(spec, sessionViewIds(self));
449
+ apply(treeFromSpec(resolved, nextId));
411
450
  self.activePanelId = panels(self.tree)[0]?.id;
412
- return viewIdsInSpec(spec);
451
+ return viewIdsInSpec(resolved);
413
452
  },
414
453
  /**
415
454
  * ViewMenu's "move to new tab": the view leaves its tab for a new one.
416
455
  *
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
456
+ * `allViewIds` is EVERY view in the session, defaulting to the host's
457
+ * own list — homing drops any view the list does not name, so the
419
458
  * `[viewId]` default this used to carry unhomed all the others.
420
459
  */
421
460
  moveViewToNewTab(viewId, allViewIds) {
422
461
  const tab = { id: nextId('tab'), viewIds: [viewId] };
423
- const from = rehomeView(viewId, allViewIds, (tree, at) => addTab(tree, at.panel.id, tab));
462
+ const from = rehomeView(viewId, everyViewId('moveViewToNewTab', allViewIds), (tree, at) => addTab(tree, at.panel.id, tab));
424
463
  if (!from) {
425
464
  return undefined;
426
465
  }
@@ -439,7 +478,7 @@ export function WorkspaceLayoutMixin() {
439
478
  tabs: [{ id: tabId, viewIds: [viewId] }],
440
479
  activeTabId: tabId,
441
480
  };
442
- const from = rehomeView(viewId, allViewIds, (tree, at) => splitPanel(tree, at.panel.id, 'row', panel));
481
+ const from = rehomeView(viewId, everyViewId('moveViewToSplitRight', allViewIds), (tree, at) => splitPanel(tree, at.panel.id, 'row', panel));
443
482
  if (!from) {
444
483
  return undefined;
445
484
  }
@@ -480,21 +519,20 @@ export function WorkspaceLayoutMixin() {
480
519
  * is spelled.
481
520
  */
482
521
  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
522
  // Not `?? []`: homing drops every view the list does not name, so an
491
523
  // empty one would answer "put this view beside nothing" and unhome the
492
524
  // rest. Nothing to say is nothing to do.
493
- const ids = allViewIds ?? views?.map(v => v.id);
525
+ const ids = allViewIds ?? sessionViewIds(self);
494
526
  if (!move || !ids) {
495
527
  return;
496
528
  }
497
- self.applyLayoutSpec(specForPendingMove(move, ids));
529
+ const known = liveViewIds(self, ids);
530
+ // Nothing to move: the view named is not in the session, so there is
531
+ // no arrangement to state about it.
532
+ if (sessionViewIds(self) && !known.includes(move.viewId)) {
533
+ return;
534
+ }
535
+ self.applyLayoutSpec(specForPendingMove(move, known));
498
536
  // Show where the view went. A spec states an arrangement and not a
499
537
  // selection, so `treeFromSpec` shows each cell's FIRST tab — and
500
538
  // `newTab` puts the moved view in a tab beside the others, which makes
@@ -518,7 +556,31 @@ export function WorkspaceLayoutMixin() {
518
556
  * session spec's layout there is nothing for `orderViews` to apply.
519
557
  */
520
558
  tileViews(mode, allViewIds) {
521
- self.applyLayoutSpec(tileLayoutSpec(allViewIds, mode));
559
+ self.applyLayoutSpec(tileLayoutSpec(liveViewIds(self, allViewIds), mode));
560
+ },
561
+ /**
562
+ * #action
563
+ * Arrange the session's views into panels: `applyLayoutSpec` plus the
564
+ * two things it does not do on its own, each of which fails silently
565
+ * without it — turning workspaces mode on for this session (a layout
566
+ * renders nowhere else) and ordering `session.views` to the spec's
567
+ * top-to-bottom statement, which is where a tab reads its order from.
568
+ * Leaves take view ids or indexes into `session.views`; a layout that
569
+ * seats no view throws rather than leaving a blank tab. What a session
570
+ * spec's `layout` and an agent's live re-layout both call.
571
+ */
572
+ layoutViews(spec) {
573
+ const host = layoutHost(self);
574
+ if (!host.setUseWorkspaces || !host.orderViews) {
575
+ throw new Error('This session has no view list to arrange: layoutViews needs a host composed with MultipleViewsSessionMixin');
576
+ }
577
+ host.setUseWorkspaces(true);
578
+ const ids = self.applyLayoutSpec(spec);
579
+ if (ids.length === 0) {
580
+ throw new Error('The layout seats no views: a leaf names its views with "views" (view ids, or indexes into session.views) and a container nests "children"');
581
+ }
582
+ host.orderViews(ids);
583
+ return ids;
522
584
  },
523
585
  })));
524
586
  }