@jbrowse/product-core 4.3.0 → 5.0.0-beta.10

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.
Files changed (157) hide show
  1. package/esm/RootModel/BaseRootModel.d.ts +514 -323
  2. package/esm/RootModel/BaseRootModel.js +141 -14
  3. package/esm/RootModel/HierarchicalConfig.d.ts +30 -20
  4. package/esm/RootModel/HierarchicalConfig.js +36 -0
  5. package/esm/RootModel/InternetAccounts.d.ts +18 -1
  6. package/esm/RootModel/InternetAccounts.js +120 -22
  7. package/esm/RootModel/PreferencesConfig.d.ts +37 -0
  8. package/esm/RootModel/PreferencesConfig.js +75 -0
  9. package/esm/RootModel/createConfigModel.d.ts +257 -0
  10. package/esm/RootModel/createConfigModel.js +77 -0
  11. package/esm/RootModel/index.d.ts +10 -5
  12. package/esm/RootModel/index.js +10 -5
  13. package/esm/RootModel/menuItems.d.ts +24 -0
  14. package/esm/RootModel/menuItems.js +141 -0
  15. package/esm/Session/BaseSession.d.ts +285 -11
  16. package/esm/Session/BaseSession.js +383 -15
  17. package/esm/Session/Connections.d.ts +115 -8
  18. package/esm/Session/Connections.js +196 -74
  19. package/esm/Session/DrawerWidgets.d.ts +145 -13
  20. package/esm/Session/DrawerWidgets.js +219 -49
  21. package/esm/Session/MultipleViews.d.ts +1067 -1177
  22. package/esm/Session/MultipleViews.js +395 -64
  23. package/esm/Session/Preferences.d.ts +1389 -0
  24. package/esm/Session/Preferences.js +58 -0
  25. package/esm/Session/ReferenceManagement.d.ts +28 -3
  26. package/esm/Session/ReferenceManagement.js +56 -41
  27. package/esm/Session/SessionTracks.d.ts +1101 -1180
  28. package/esm/Session/SessionTracks.js +584 -34
  29. package/esm/Session/Themes.d.ts +136 -2
  30. package/esm/Session/Themes.js +267 -21
  31. package/esm/Session/TrackMenu.d.ts +88 -0
  32. package/esm/Session/TrackMenu.js +175 -0
  33. package/esm/Session/TrackMenuSessionMixin.d.ts +27 -0
  34. package/esm/Session/TrackMenuSessionMixin.js +42 -0
  35. package/esm/Session/Tracks.d.ts +945 -1159
  36. package/esm/Session/Tracks.js +229 -30
  37. package/esm/Session/assertSessionModel.d.ts +21 -0
  38. package/esm/Session/assertSessionModel.js +1 -0
  39. package/esm/Session/copyTrackSnapshot.d.ts +21 -0
  40. package/esm/Session/copyTrackSnapshot.js +20 -0
  41. package/esm/Session/developerMode.d.ts +7 -0
  42. package/esm/Session/developerMode.js +43 -0
  43. package/esm/Session/finalizeSession.d.ts +15 -0
  44. package/esm/Session/finalizeSession.js +28 -0
  45. package/esm/Session/hydratedForms.d.ts +16 -0
  46. package/esm/Session/hydratedForms.js +13 -0
  47. package/esm/Session/index.d.ts +27 -10
  48. package/esm/Session/index.js +16 -10
  49. package/esm/Session/pluginExtraTrackItems.d.ts +11 -0
  50. package/esm/Session/pluginExtraTrackItems.js +13 -0
  51. package/esm/Session/readdedTrackConf.d.ts +8 -0
  52. package/esm/Session/readdedTrackConf.js +24 -0
  53. package/esm/Session/shareableSnapshot.d.ts +23 -0
  54. package/esm/Session/shareableSnapshot.js +66 -0
  55. package/esm/Session/temporaryAssemblyTracks.d.ts +24 -0
  56. package/esm/Session/temporaryAssemblyTracks.js +37 -0
  57. package/esm/assertExtends.d.ts +55 -0
  58. package/esm/assertExtends.js +1 -0
  59. package/esm/controllerTracks.d.ts +76 -0
  60. package/esm/controllerTracks.js +125 -0
  61. package/esm/destroyViewState.d.ts +21 -0
  62. package/esm/destroyViewState.js +29 -0
  63. package/esm/embeddedController.d.ts +57 -0
  64. package/esm/embeddedController.js +100 -0
  65. package/esm/index.d.ts +32 -4
  66. package/esm/index.js +31 -4
  67. package/esm/loadPlugins.d.ts +38 -0
  68. package/esm/loadPlugins.js +45 -0
  69. package/esm/localFiles.d.ts +74 -0
  70. package/esm/localFiles.js +156 -0
  71. package/esm/observeSession.d.ts +55 -0
  72. package/esm/observeSession.js +90 -0
  73. package/esm/parallelChunkLoading.d.ts +30 -0
  74. package/esm/parallelChunkLoading.js +71 -0
  75. package/esm/pluginInput.d.ts +32 -0
  76. package/esm/pluginInput.js +19 -0
  77. package/esm/pruneUnbuildableNodes.d.ts +109 -0
  78. package/esm/pruneUnbuildableNodes.js +562 -0
  79. package/esm/resolveAssemblies.d.ts +68 -0
  80. package/esm/resolveAssemblies.js +77 -0
  81. package/esm/rpcWorker.d.ts +36 -4
  82. package/esm/rpcWorker.js +126 -20
  83. package/esm/sessionMigrations/index.d.ts +15 -0
  84. package/esm/sessionMigrations/index.js +178 -0
  85. package/esm/sessionUrl.d.ts +40 -0
  86. package/esm/sessionUrl.js +66 -0
  87. package/esm/sessionUtils.d.ts +74 -0
  88. package/esm/sessionUtils.js +599 -0
  89. package/esm/siblingCast.d.ts +14 -0
  90. package/esm/siblingCast.js +16 -0
  91. package/esm/snapshotUtils.d.ts +8 -0
  92. package/esm/snapshotUtils.js +12 -0
  93. package/esm/ui/AboutDialog.d.ts +8 -3
  94. package/esm/ui/AboutDialog.js +14 -5
  95. package/esm/ui/AboutDialogContents.d.ts +2 -6
  96. package/esm/ui/AboutDialogContents.js +13 -17
  97. package/esm/ui/AssemblyInfoPanel.d.ts +5 -0
  98. package/esm/ui/AssemblyInfoPanel.js +63 -0
  99. package/esm/ui/DescriptionPanel.d.ts +3 -0
  100. package/esm/ui/DescriptionPanel.js +25 -0
  101. package/esm/ui/Drawer.d.ts +21 -0
  102. package/esm/ui/Drawer.js +60 -0
  103. package/esm/ui/DrawerControls.d.ts +5 -0
  104. package/esm/ui/DrawerControls.js +28 -0
  105. package/esm/ui/DrawerHeader.d.ts +6 -0
  106. package/esm/ui/DrawerHeader.js +36 -0
  107. package/esm/ui/DrawerWidget.d.ts +5 -0
  108. package/esm/ui/DrawerWidget.js +12 -0
  109. package/esm/ui/DrawerWidgetSelector.d.ts +5 -0
  110. package/esm/ui/DrawerWidgetSelector.js +40 -0
  111. package/esm/ui/FileInfoPanel.d.ts +3 -6
  112. package/esm/ui/FileInfoPanel.js +36 -26
  113. package/esm/ui/GeneralPreferencesTab.d.ts +17 -0
  114. package/esm/ui/GeneralPreferencesTab.js +34 -0
  115. package/esm/ui/HeaderButtons.d.ts +2 -1
  116. package/esm/ui/HeaderButtons.js +15 -17
  117. package/esm/ui/ModalWidget.d.ts +13 -0
  118. package/esm/ui/ModalWidget.js +48 -0
  119. package/esm/ui/ModalWidgetAppBar.d.ts +8 -0
  120. package/esm/ui/ModalWidgetAppBar.js +9 -0
  121. package/esm/ui/PreferenceCheckbox.d.ts +6 -0
  122. package/esm/ui/PreferenceCheckbox.js +16 -0
  123. package/esm/ui/PreferencesDialog.d.ts +34 -0
  124. package/esm/ui/PreferencesDialog.js +55 -0
  125. package/esm/ui/PreferencesResetDialog.d.ts +8 -0
  126. package/esm/ui/PreferencesResetDialog.js +19 -0
  127. package/esm/ui/RefNameAliasesDialog.d.ts +9 -0
  128. package/esm/ui/RefNameAliasesDialog.js +65 -0
  129. package/esm/ui/RefNameInfoDialog.d.ts +3 -6
  130. package/esm/ui/RefNameInfoDialog.js +45 -63
  131. package/esm/ui/ViewPreferencesTab.d.ts +12 -0
  132. package/esm/ui/ViewPreferencesTab.js +15 -0
  133. package/esm/ui/WidgetBody.d.ts +18 -0
  134. package/esm/ui/WidgetBody.js +22 -0
  135. package/esm/ui/WidgetHeading.d.ts +7 -0
  136. package/esm/ui/WidgetHeading.js +7 -0
  137. package/esm/ui/aboutTestUtils.d.ts +140 -0
  138. package/esm/ui/aboutTestUtils.js +25 -0
  139. package/esm/ui/descriptionHtml.d.ts +25 -0
  140. package/esm/ui/descriptionHtml.js +64 -0
  141. package/esm/ui/drawerLayout.d.ts +18 -0
  142. package/esm/ui/drawerLayout.js +22 -0
  143. package/esm/ui/index.d.ts +15 -1
  144. package/esm/ui/index.js +13 -1
  145. package/esm/ui/preferencesReset.d.ts +18 -0
  146. package/esm/ui/preferencesReset.js +69 -0
  147. package/esm/ui/util.d.ts +22 -7
  148. package/esm/ui/util.js +19 -41
  149. package/esm/useEngineLifecycle.d.ts +19 -0
  150. package/esm/useEngineLifecycle.js +44 -0
  151. package/package.json +15 -18
  152. package/esm/RootModel/FormatAbout.d.ts +0 -12
  153. package/esm/RootModel/FormatAbout.js +0 -15
  154. package/esm/RootModel/FormatDetails.d.ts +0 -24
  155. package/esm/RootModel/FormatDetails.js +0 -27
  156. package/esm/Session/DialogQueue.d.ts +0 -17
  157. package/esm/Session/DialogQueue.js +0 -31
@@ -1,41 +1,599 @@
1
- import { types } from '@jbrowse/mobx-state-tree';
2
- import { isBaseSession } from "./BaseSession.js";
3
- import { TracksManagerSessionMixin } from "./Tracks.js";
1
+ import { readConfObject } from '@jbrowse/core/configuration';
2
+ import { diffTrackConfig, flattenTrackConfigDelta, mergeTrackConfig, } from '@jbrowse/core/util';
3
+ import { expandLooseTrackConfig } from '@jbrowse/core/util/tracks';
4
+ import { applySnapshot, getSnapshot, isStateTreeNode, types, } from '@jbrowse/mobx-state-tree';
5
+ import { compareStructural, computed } from 'mobx';
6
+ import { TracksManagerSessionMixin } from './Tracks.js';
7
+ import { hydratedTrackForm } from './hydratedForms.js';
8
+ import { assertNotReaddedDifferently } from './readdedTrackConf.js';
9
+ import { assertTrackConfOutlivesItsAssemblies } from './temporaryAssemblyTracks.js';
10
+ // jbrowse.tracks holds frozen plain objects in every product; single site for
11
+ // the cast from its loose frozen type.
12
+ function baseTracks(self) {
13
+ return self.jbrowse.tracks;
14
+ }
15
+ function sessionEntries(self) {
16
+ return getSnapshot(self.sessionTracks);
17
+ }
18
+ // Not a key count: every delta keeps its trackId, and one can hold nothing but
19
+ // content-free display stubs. A stored delta lights the edited badge, so only a
20
+ // changed slot — a reset included — counts.
21
+ function deltaHasChanges(base, delta) {
22
+ return flattenTrackConfigDelta(base, delta).length > 0;
23
+ }
24
+ // The assemblies a track config names that the config.json does not carry.
25
+ // `self.jbrowse.assemblies` is the catalog alone — the session's own
26
+ // `assemblies` getter adds sessionAssemblies to it, which is the opposite of
27
+ // what this asks.
28
+ //
29
+ // An assembly's `aliases` are names of that assembly, so a track naming one
30
+ // names something the catalog carries and publishes like any other. Matching on
31
+ // `name` alone diverted it to the session and told the admin the config.json
32
+ // has no such assembly while they were looking at it. Read off the configs
33
+ // rather than through the assembly manager: the question is what the config.json
34
+ // declares, and the manager also answers for sessionAssemblies, which is the
35
+ // case this exists to catch.
36
+ function assembliesNotInTheCatalog(self, trackConf) {
37
+ const catalog = new Set(self.jbrowse.assemblies.flatMap(a => [
38
+ readConfObject(a, 'name'),
39
+ ...(readConfObject(a, 'aliases') ?? []),
40
+ ]));
41
+ const names = readConfObject(trackConf, 'assemblyNames');
42
+ return names?.filter(name => !catalog.has(name)) ?? [];
43
+ }
44
+ function withoutDelta(deltas, trackId) {
45
+ const { [trackId]: _dropped, ...rest } = deltas;
46
+ return rest;
47
+ }
48
+ // Not evicted: bounded by the distinct ids read this session.
49
+ function perIdComputed(derive) {
50
+ const computeds = new Map();
51
+ return (trackId) => {
52
+ let c = computeds.get(trackId);
53
+ if (!c) {
54
+ c = computed(() => derive(trackId));
55
+ computeds.set(trackId, c);
56
+ }
57
+ return c.get();
58
+ };
59
+ }
60
+ /**
61
+ * #stateModel SessionTracksManagerSessionMixin
62
+ */
4
63
  export function SessionTracksManagerSessionMixin(pluginManager) {
64
+ // A base is the raw config-file object — shorthand `uri`, no display stubs —
65
+ // while a working copy's snapshot is hydrated. Diffing or merging across the
66
+ // two reads every expanded field as an edit, pinning whole adapters into the
67
+ // delta, and every member one form has and the other lacks as a reset. So
68
+ // both sides go through the same schema first; a base is memoized per
69
+ // frozen-base identity, which is stable until a jbrowse.tracks write.
70
+ const hydrate = hydratedTrackForm(pluginManager);
71
+ const canonicalBaseCache = new WeakMap();
72
+ function toPlainConfig(base) {
73
+ const cached = canonicalBaseCache.get(base);
74
+ if (cached) {
75
+ return cached;
76
+ }
77
+ const hydrated = hydrate(base);
78
+ canonicalBaseCache.set(base, hydrated);
79
+ return hydrated;
80
+ }
5
81
  return TracksManagerSessionMixin(pluginManager)
6
82
  .named('SessionTracksManagerSessionMixin')
7
83
  .props({
8
- sessionTracks: types.array(pluginManager.pluggableConfigSchemaType('track')),
84
+ /**
85
+ * #property
86
+ * Tracks the session added, each entry the base its edits
87
+ * (trackConfigDeltas) diff against, as a config.json entry is for a
88
+ * config track.
89
+ */
90
+ sessionTracks: types.stripDefault(types.array(pluginManager.pluggableConfigSchemaType('track')), []),
91
+ /**
92
+ * #property
93
+ * Per-track config overrides, keyed by trackId, stored as a *delta*
94
+ * against the base config (the sessionTracks or jbrowse.tracks entry)
95
+ * rather than a full copy —
96
+ * so a later change to an untouched field of the base still flows through
97
+ * (see trackConfigDelta.ts). A `null` member resets a slot the base sets.
98
+ * An admin's edits land here too, and reach jbrowse.tracks only through
99
+ * `promoteTrackConfigDeltas`. Frozen (not a typed track array) on purpose: a typed
100
+ * create() would fill defaults, erasing the "unset vs default"
101
+ * distinction the delta merge relies on.
102
+ *
103
+ * `stripDefault` for the reason every other persisted prop beside it has
104
+ * it: without it the empty map is written into every snapshot and every
105
+ * share link, so a session that never overrode a track still ships
106
+ * `"trackConfigDeltas":{}`. It was the one prop added since that convention
107
+ * without it.
108
+ */
109
+ trackConfigDeltas: types.stripDefault(types.frozen(), {}),
110
+ })
111
+ .volatile(() => ({
112
+ /**
113
+ * Per-track private working copies, keyed by trackId. A plain
114
+ * Map — not observable, not persisted — mirroring the pluginManager
115
+ * hydration cache: it holds the live MST config node a shown track's
116
+ * in-place quick-edits mutate, so the shared frozen base is never touched.
117
+ * See
118
+ * [ADR-032](https://github.com/GMOD/jbrowse-components/blob/main/agent-docs/architecture-decision-records/adr-032-track-config-nodes-are-throwaway-views.md).
119
+ *
120
+ * Not evicted: it's a pure memoization cache, bounded by the count of
121
+ * distinct tracks shown this session (each entry a lazily-hydrated config
122
+ * node), holding no authoritative state — the persisted delta is the
123
+ * source of truth, and reset/programmatic edits keep a retained copy in
124
+ * sync. Retention is volatile RAM only (never serialized), so it's not
125
+ * worth a reference-counted prune at every track-removal path.
126
+ *
127
+ * Each entry carries the resolved config it mirrors, so a delta or base
128
+ * replaced from outside this mixin invalidates it — see
129
+ * `getEditableTrackConfig`.
130
+ */
131
+ editableTrackConfigs: new Map(),
132
+ }))
133
+ .views(self => {
134
+ // Memoize merged configs per (base object, delta value) pair so the tracks
135
+ // getter returns stable object identity across unrelated recomputes. A
136
+ // fresh merged object each time would rehydrate a new MST node in
137
+ // TrackConfigurationReference, losing open display state (see CLAUDE.md).
138
+ // Both keys have stable identity until they actually change: a track's
139
+ // delta only when that track is edited, and the base only on a
140
+ // jbrowse.tracks write or a sessionTracks entry replaced. This relies on
141
+ // a base config never mutating in place. If an in-place base edit is ever
142
+ // added, key this cache on base content too.
143
+ const mergeCache = new WeakMap();
144
+ function withDelta(base, delta) {
145
+ if (!delta) {
146
+ return base;
147
+ }
148
+ const cached = mergeCache.get(base);
149
+ if (cached?.delta === delta) {
150
+ return cached.merged;
151
+ }
152
+ const merged = mergeTrackConfig(toPlainConfig(base), delta);
153
+ mergeCache.set(base, { delta, merged });
154
+ return merged;
155
+ }
156
+ // The bases in `tracks` order, each id's base and each id's position.
157
+ // A config.json that repeats an id resolves to its last entry.
158
+ const trackBaseIndex = computed(() => {
159
+ const entries = sessionEntries(self);
160
+ const sessionIds = new Set(entries.map(t => t.trackId));
161
+ const list = [
162
+ ...entries,
163
+ ...baseTracks(self).filter(t => !sessionIds.has(t.trackId)),
164
+ ];
165
+ const byId = new Map();
166
+ const at = new Map();
167
+ for (let i = 0; i < list.length; i++) {
168
+ const { trackId } = list[i];
169
+ byId.set(trackId, list[i]);
170
+ at.set(trackId, i);
171
+ }
172
+ return { list, byId, at };
173
+ }, { name: 'trackBaseIndex' });
174
+ // A write replaces the whole delta map but keeps every other id's delta
175
+ // by identity, so reading one id's through its own computed wakes only
176
+ // the edited track's readers.
177
+ const deltaFor = perIdComputed(trackId => self.trackConfigDeltas[trackId]);
178
+ const baseFor = perIdComputed(trackId => {
179
+ const base = self.trackBasesById.get(trackId);
180
+ return base
181
+ ? toPlainConfig(base)
182
+ : undefined;
183
+ });
184
+ const editableBySchema = new Map();
185
+ return {
186
+ /**
187
+ * #getter
188
+ * Each track's base by trackId: its sessionTracks entry, else its
189
+ * config.json entry. Rebuilt when either list changes, never on an
190
+ * edit.
191
+ */
192
+ get trackBasesById() {
193
+ return trackBaseIndex.get().byId;
194
+ },
195
+ /**
196
+ * #method
197
+ * `base` with its delta (trackConfigDeltas) merged over it, or `base`
198
+ * itself by identity when it has none, which keeps the hydration cache
199
+ * warm.
200
+ */
201
+ withTrackEdits(base) {
202
+ const plain = base;
203
+ return withDelta(plain, deltaFor(plain.trackId));
204
+ },
205
+ /**
206
+ * #getter
207
+ * Session tracks first, then the config tracks, each with its edits.
208
+ * An edit copies the list of bases and lays each delta at its track's
209
+ * position, so past that copy it costs the count of edited tracks.
210
+ */
211
+ get tracks() {
212
+ const { list, at } = trackBaseIndex.get();
213
+ const tracks = list.slice();
214
+ for (const [trackId, delta] of Object.entries(self.trackConfigDeltas)) {
215
+ const i = at.get(trackId);
216
+ if (i !== undefined) {
217
+ tracks[i] = withDelta(list[i], delta);
218
+ }
219
+ }
220
+ return tracks;
221
+ },
222
+ /**
223
+ * #method
224
+ * The entry `trackId` edits over, hydrated: the one the session added
225
+ * it with, or its config.json entry. What a display's "is this
226
+ * arranged" and "reset" compare its live config against. Per-id
227
+ * reactive, like `getTrackById`.
228
+ */
229
+ baseTrackConfig(trackId) {
230
+ return baseFor(trackId);
231
+ },
232
+ /**
233
+ * #method
234
+ * The config node `trackId` resolves to: a live node as it stands,
235
+ * otherwise a private working copy of its current frozen (base+delta)
236
+ * config, so a shown track's in-place quick-edits (setSlot) mutate
237
+ * this copy and never the shared frozen base (see ADR-032). Called by
238
+ * TrackConfigurationReference during lazy hydration.
239
+ *
240
+ * Cached against the resolved config it was built from, not by trackId
241
+ * alone. A write this mixin makes re-stamps the entry, so the copy an
242
+ * edit is still being typed into is never swapped out mid-keystroke;
243
+ * a delta or base replaced from outside — an undo's `applySnapshot` on
244
+ * the session, a session restore, a session track deleted and added
245
+ * again under its id — cannot, so the next read rebuilds the copy from
246
+ * what now resolves. Per-id reactive on the node rather than the
247
+ * config, so a display reading its config does not recompute when
248
+ * its own working copy is persisted. Per schema type as well, since
249
+ * an id re-added under another track type resolves through that
250
+ * type's reference.
251
+ */
252
+ getEditableTrackConfig(trackId, schemaType) {
253
+ let editable = editableBySchema.get(schemaType);
254
+ if (!editable) {
255
+ editable = perIdComputed(id => {
256
+ const resolved = self.getTrackById(id);
257
+ if (!resolved || isStateTreeNode(resolved)) {
258
+ return resolved;
259
+ }
260
+ const existing = self.editableTrackConfigs.get(id);
261
+ if (existing?.source === resolved) {
262
+ return existing.node;
263
+ }
264
+ const node = schemaType.create(resolved, { pluginManager });
265
+ self.editableTrackConfigs.set(id, { node, source: resolved });
266
+ return node;
267
+ });
268
+ editableBySchema.set(schemaType, editable);
269
+ }
270
+ return editable(trackId);
271
+ },
272
+ /**
273
+ * #method
274
+ * The overridden slots for `trackId` (empty when it has no delta): each
275
+ * changed setting's path, its base/default value and the edited value.
276
+ * Drives the "view changes" dialog opened from the edited badge.
277
+ */
278
+ getTrackConfigChanges(trackId) {
279
+ // Every rendered track row asks this (the edited badge); only an
280
+ // edited one has a delta, so an unedited row subscribes to no base.
281
+ const delta = deltaFor(trackId);
282
+ const base = delta ? baseFor(trackId) : undefined;
283
+ return delta && base ? flattenTrackConfigDelta(base, delta) : [];
284
+ },
285
+ };
9
286
  })
10
287
  .views(self => ({
11
- get tracks() {
12
- return [...self.sessionTracks, ...self.jbrowse.tracks];
288
+ /**
289
+ * #method
290
+ * Whether `trackId` carries an edit over its base config, which drives
291
+ * the "Reset track settings" item and the edited badge. Changed slots,
292
+ * not mere presence in trackConfigDeltas: a delta can hold only
293
+ * content-free display stubs, which must not read as an override.
294
+ */
295
+ isTrackOverride(trackId) {
296
+ return self.getTrackConfigChanges(trackId).length > 0;
297
+ },
298
+ /**
299
+ * #getter
300
+ * The edited tracks `promoteTrackConfigDeltas` can write: those whose
301
+ * base is a config.json entry. A session track's delta has no file to
302
+ * go to.
303
+ */
304
+ get promotableTrackIds() {
305
+ const sessionIds = new Set(self.sessionTracks.map(t => t.trackId));
306
+ return Object.keys(self.trackConfigDeltas).filter(id => !sessionIds.has(id) && self.trackBasesById.has(id));
307
+ },
308
+ }))
309
+ .actions(self => ({
310
+ afterAttach() {
311
+ // One-time format upgrade: a legacy session stored a non-admin's edits as
312
+ // a full-config sessionTracks entry shadowing the same-id admin track.
313
+ // Convert those to deltas so the whole app uses one override mechanism.
314
+ // Genuinely-added session tracks (no matching base) are left in place.
315
+ const configById = new Map(baseTracks(self).map(t => [t.trackId, t]));
316
+ const legacy = self.sessionTracks.filter(t => configById.has(t.trackId));
317
+ if (legacy.length > 0) {
318
+ const deltas = { ...self.trackConfigDeltas };
319
+ for (const track of legacy) {
320
+ const plainBase = toPlainConfig(configById.get(track.trackId));
321
+ const delta = diffTrackConfig(plainBase, getSnapshot(track));
322
+ // a legacy override identical to its base contributes no changed
323
+ // slots: drop it rather than migrate a content-free delta
324
+ if (deltaHasChanges(plainBase, delta)) {
325
+ deltas[track.trackId] = delta;
326
+ }
327
+ }
328
+ self.trackConfigDeltas = deltas;
329
+ for (const track of legacy) {
330
+ self.sessionTracks.remove(track);
331
+ }
332
+ }
13
333
  },
14
334
  }))
15
335
  .actions(self => {
16
- const { addTrackConf: superAddTrackConf, deleteTrackConf: superDeleteTrackConf, } = self;
336
+ const { publishTrackConf: superPublishTrackConf, deleteTrackConf: superDeleteTrackConf, updateTrackConfiguration: superUpdateTrackConfiguration, } = self;
337
+ // A cleared delta reverts the track's live working copy to the base in
338
+ // place, so an open view re-renders to the default. applySnapshot keeps
339
+ // the node identity (existing observers just update); no-op for a track
340
+ // that was never shown (no working copy).
341
+ function revertEditableTrackConfig(trackId) {
342
+ const entry = self.editableTrackConfigs.get(trackId);
343
+ const base = self.baseTrackConfig(trackId);
344
+ if (entry && base) {
345
+ applySnapshot(entry.node, base);
346
+ }
347
+ }
348
+ // Re-stamp a working copy with what its track resolves to after a write,
349
+ // so the copy this mixin just persisted from stays the one the next read
350
+ // resolves.
351
+ function stampEditableTrackConfig(trackId) {
352
+ const entry = self.editableTrackConfigs.get(trackId);
353
+ if (entry) {
354
+ entry.source = self.getTrackById(trackId);
355
+ }
356
+ }
357
+ // Single writer for trackConfigDeltas (pass undefined to clear). Clearing
358
+ // also reverts the working copy, which is what makes a Reset visible in an
359
+ // open view.
360
+ function writeDelta(trackId, delta) {
361
+ self.trackConfigDeltas = delta
362
+ ? { ...self.trackConfigDeltas, [trackId]: delta }
363
+ : withoutDelta(self.trackConfigDeltas, trackId);
364
+ if (!delta) {
365
+ revertEditableTrackConfig(trackId);
366
+ }
367
+ stampEditableTrackConfig(trackId);
368
+ }
369
+ // Push a *programmatic* update (the config editor's Apply, or any
370
+ // updateTrackConfiguration not driven by this node's own live edits) into
371
+ // the working copy. When the node IS the edit source its snapshot already
372
+ // equals fullConfig, so this skips — which also avoids clobbering an
373
+ // in-progress live drag.
374
+ function syncEditableTrackConfig(trackId, fullConfig) {
375
+ const entry = self.editableTrackConfigs.get(trackId);
376
+ if (entry && !compareStructural(getSnapshot(entry.node), fullConfig)) {
377
+ applySnapshot(entry.node, fullConfig);
378
+ }
379
+ }
380
+ // A working copy's own snapshot, which both savers pass, is already in
381
+ // the schema's form; anything else a caller hands in may not be.
382
+ function inTrackForm(trackConf) {
383
+ const entry = self.editableTrackConfigs.get(trackConf.trackId);
384
+ return entry && getSnapshot(entry.node) === trackConf
385
+ ? trackConf
386
+ : hydrate(trackConf);
387
+ }
388
+ // The session-scoped add, shared by the action that always means the
389
+ // session and the one that means it only for a non-admin. A plain closure
390
+ // rather than `this.addSessionTrackConf` so neither action's inferred
391
+ // return type depends on the other's.
392
+ function addToSession(loose) {
393
+ const trackConf = expandLooseTrackConfig(loose, pluginManager);
394
+ const { trackId, type } = trackConf;
395
+ if (!type) {
396
+ throw new Error(`track type not specified for "${trackId}"`);
397
+ }
398
+ assertTrackConfOutlivesItsAssemblies(self, trackConf, 'sessionTracks');
399
+ // Dedupe against everything the session can already resolve — config
400
+ // catalog (jbrowse.tracks), assembly sequences, connection tracks and
401
+ // prior sessionTracks — not just sessionTracks. Re-adding a config
402
+ // already in the catalog would otherwise push a full shadow into
403
+ // sessionTracks, silently demoting a catalog track to a session track
404
+ // and dropping its trackConfigDeltas override semantics.
405
+ const existing = self.getTrackById(trackId);
406
+ if (existing) {
407
+ const entry = self.sessionTracks.find(t => t.trackId === trackId);
408
+ if (entry) {
409
+ assertNotReaddedDifferently(pluginManager, entry, {
410
+ ...trackConf,
411
+ trackId,
412
+ type,
413
+ });
414
+ }
415
+ return existing;
416
+ }
417
+ // sessionTracks is a typed MST array (unlike the frozen
418
+ // jbrowse.tracks), so an invalid config throws on push. Surface it as
419
+ // a snackbar and skip the add, rather than letting it crash the app.
420
+ try {
421
+ const length = self.sessionTracks.push(trackConf);
422
+ return self.sessionTracks[length - 1];
423
+ }
424
+ catch (e) {
425
+ self.notifyError(`Track "${trackId}" has an invalid configuration: ${e}`, e);
426
+ return undefined;
427
+ }
428
+ }
17
429
  return {
430
+ /**
431
+ * #action
432
+ * Add a track config to *this session*: it lands in `sessionTracks`,
433
+ * travels with the session when it is saved or shared, and never
434
+ * reaches the config.json the server hands every visitor.
435
+ *
436
+ * **The default destination.** Everything that is not an Add-track
437
+ * workflow wants this one, whoever is looking — a session spec's
438
+ * `sessionTracks`, a URL's `&sessionTracks=`, and every track a feature
439
+ * stands up on the user's behalf. Mirrors `addSessionConnectionConf` in
440
+ * the connections mixins.
441
+ *
442
+ * Returns the entry, which is the track's base: edit the track through
443
+ * `updateTrackConfiguration` or a display's `setConf`, not the entry.
444
+ */
445
+ addSessionTrackConf(trackConf) {
446
+ return addToSession(trackConf);
447
+ },
448
+ /**
449
+ * #action
450
+ * Publish a track config to the shared catalog if this user can, and
451
+ * fall back to their session if they cannot: an admin's goes to
452
+ * `jbrowse.tracks`, which the admin server writes back into the
453
+ * config.json every visitor is served; everyone else's goes to
454
+ * `sessionTracks`.
455
+ *
456
+ * **Call it only from the "Add track" workflows**, where an admin
457
+ * adding a track means to add it for the whole site. For a track a
458
+ * feature creates on the user's behalf (a search result, a computed
459
+ * consensus, a reconstruction's segment labels), one admin click on
460
+ * this publishes it to every visitor, and each later click publishes
461
+ * another copy, because the per-launch trackId prevents deduplication.
462
+ * Use `addSessionTrackConf` above for those.
463
+ *
464
+ * An admin's track still goes to the session when it names an assembly
465
+ * the config.json does not carry: a session spec's assembly, a MAF
466
+ * sample's genome, or a comparative view's synthesized pair. The
467
+ * Add-track widget takes its assembly from the containing view, so an
468
+ * admin adding a track in such a view passes a name no visitor can
469
+ * resolve. Publishing it would write an entry the server sends to every
470
+ * visitor and no visitor can draw. Adding it to the session keeps the
471
+ * track usable and writes no dangling entry; a snackbar names the
472
+ * assembly that caused the fallback.
473
+ *
474
+ * **The snackbar names the assembly and not the track**, so a batch
475
+ * produces one message: `pushSnackbarMessage` dedupes on the exact
476
+ * text, and `BulkAddTracksWorkflow` publishes in a loop. Including the
477
+ * track name would show one snackbar per file to an admin adding
478
+ * thirty. The assembly is all the admin needs to act on.
479
+ */
480
+ publishTrackConf(trackConf) {
481
+ if (!self.adminMode) {
482
+ return addToSession(trackConf);
483
+ }
484
+ const missing = assembliesNotInTheCatalog(self, trackConf);
485
+ if (!missing.length) {
486
+ return superPublishTrackConf(trackConf);
487
+ }
488
+ self.notify(`A track naming assembly "${missing.join('", "')}" goes to this session rather than to the site configuration: the config.json does not carry that assembly, so a published track naming it would be served to every visitor and drawn by none of them.`, 'info');
489
+ return addToSession(trackConf);
490
+ },
491
+ /**
492
+ * #action
493
+ * Deprecated alias of `addSessionTrackConf`. Call that, or
494
+ * `publishTrackConf`.
495
+ *
496
+ * @deprecated
497
+ *
498
+ * The session-scoped add under its old name, for the prebuilt plugin
499
+ * bundles that reach it by name at runtime. See the base mixin's copy
500
+ * for why it survives and why it now means the session; nothing in tree
501
+ * may call it.
502
+ */
18
503
  addTrackConf(trackConf) {
19
- if (self.adminMode) {
20
- return superAddTrackConf(trackConf);
504
+ return addToSession(trackConf);
505
+ },
506
+ /**
507
+ * #action
508
+ * Persist an edited track config as a delta (trackConfigDeltas) against
509
+ * its base, the sessionTracks or config.json entry — only the changed
510
+ * slots — so the edits persist, are shared, undo and reset, while
511
+ * changes to untouched fields of the base still flow through. An
512
+ * admin's edit is a delta like anyone's: it reaches the config.json the
513
+ * server hands every visitor only through `promoteTrackConfigDeltas`.
514
+ * An opened connection track defers to the base mixin, which routes it
515
+ * to connectionTrackConfigs.
516
+ */
517
+ updateTrackConfiguration(trackConf) {
518
+ const { trackId } = trackConf;
519
+ const plainBase = self.baseTrackConfig(trackId);
520
+ if (plainBase) {
521
+ const delta = diffTrackConfig(plainBase, inTrackForm(trackConf));
522
+ // an edit that nets back to the base clears any prior override
523
+ if (deltaHasChanges(plainBase, delta)) {
524
+ // Two views showing the same track each run BaseTrackModel's
525
+ // persist reaction against the shared config node, so a single edit
526
+ // calls this twice with an identical delta (and the config editor
527
+ // can re-save an unchanged config). Skip a structurally-identical
528
+ // re-store: writing a fresh trackConfigDeltas object would churn its
529
+ // identity and make the tracks getter rehydrate a new merged node
530
+ // for no real change.
531
+ const existing = self.trackConfigDeltas[trackId];
532
+ if (!existing || !compareStructural(existing, delta)) {
533
+ writeDelta(trackId, delta);
534
+ }
535
+ syncEditableTrackConfig(trackId, trackConf);
536
+ }
537
+ else if (trackId in self.trackConfigDeltas) {
538
+ writeDelta(trackId, undefined);
539
+ }
21
540
  }
22
- const { trackId, type } = trackConf;
23
- if (!type) {
24
- throw new Error(`unknown track type ${type}`);
541
+ else {
542
+ // an opened connection track, or a homeless in-memory-only edit
543
+ superUpdateTrackConfiguration(trackConf);
544
+ syncEditableTrackConfig(trackId, trackConf);
545
+ stampEditableTrackConfig(trackId);
25
546
  }
26
- const track = self.sessionTracks.find(t => t.trackId === trackId);
27
- if (track) {
28
- return track;
547
+ },
548
+ /**
549
+ * #action
550
+ * Write this session's track edits into the base configs — the
551
+ * config.json an admin server hands every visitor — and drop the
552
+ * deltas they were held in. One track's, or every track's when
553
+ * `trackId` is omitted, of `promotableTrackIds`: a session track's
554
+ * edits stay in the session. An admin's alone: anyone else's base is
555
+ * the in-memory copy of a file they cannot write.
556
+ */
557
+ promoteTrackConfigDeltas(trackId) {
558
+ if (!self.adminMode) {
559
+ throw new Error('only an admin can save track settings to config');
560
+ }
561
+ const promotable = self.promotableTrackIds;
562
+ const ids = trackId === undefined
563
+ ? promotable
564
+ : promotable.includes(trackId)
565
+ ? [trackId]
566
+ : [];
567
+ const bases = self.trackBasesById;
568
+ for (const id of ids) {
569
+ const base = bases.get(id);
570
+ self.jbrowse.updateTrackConf(mergeTrackConfig(toPlainConfig(base), self.trackConfigDeltas[id]));
571
+ writeDelta(id, undefined);
29
572
  }
30
- const length = self.sessionTracks.push(trackConf);
31
- return self.sessionTracks[length - 1];
32
573
  },
33
- deleteTrackConf(trackConf) {
34
- const found = superDeleteTrackConf(trackConf);
35
- if (found) {
36
- return found;
574
+ /**
575
+ * #action
576
+ * Drop a track's delta (trackConfigDeltas) so it reverts to its base,
577
+ * the sessionTracks or jbrowse.tracks entry. Unlike deleteTrackConf this
578
+ * does not dereference the track from open views — the base config
579
+ * re-resolves in place, so an open track stays open and simply reverts.
580
+ */
581
+ resetTrackConfiguration(trackId) {
582
+ if (trackId in self.trackConfigDeltas) {
583
+ writeDelta(trackId, undefined);
37
584
  }
585
+ },
586
+ /**
587
+ * #action
588
+ */
589
+ deleteTrackConf(trackConf) {
590
+ superDeleteTrackConf(trackConf);
38
591
  const { trackId } = trackConf;
592
+ // A delta only outlives its base if the base is gone, so drop it here
593
+ // rather than strand it.
594
+ if (trackId in self.trackConfigDeltas) {
595
+ writeDelta(trackId, undefined);
596
+ }
39
597
  const idx = self.sessionTracks.findIndex(t => t.trackId === trackId);
40
598
  if (idx === -1) {
41
599
  return undefined;
@@ -43,18 +601,10 @@ export function SessionTracksManagerSessionMixin(pluginManager) {
43
601
  return self.sessionTracks.splice(idx, 1);
44
602
  },
45
603
  };
46
- })
47
- .postProcessSnapshot(snap => {
48
- if (!snap) {
49
- return snap;
50
- }
51
- const { sessionTracks, ...rest } = snap;
52
- return {
53
- ...rest,
54
- ...(sessionTracks?.length ? { sessionTracks } : {}),
55
- };
56
604
  });
57
605
  }
58
- export function isSessionWithSessionTracks(thing) {
59
- return isBaseSession(thing) && 'sessionTracks' in thing;
60
- }
606
+ // The `isSessionWithSessionTracks` guard that used to live here now lives in
607
+ // `@jbrowse/core/util/types` beside the rest of the session capability guards.
608
+ // Its only caller was the hierarchical track selector — a plugin, which could
609
+ // not reach product-core through the ABI it publishes against, so this package
610
+ // was a runtime dependency of two shipped plugins for three type guards.