@jbrowse/product-core 4.3.0 → 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.
Files changed (137) hide show
  1. package/esm/RootModel/BaseRootModel.d.ts +629 -304
  2. package/esm/RootModel/BaseRootModel.js +139 -14
  3. package/esm/RootModel/HierarchicalConfig.d.ts +57 -21
  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 +68 -0
  8. package/esm/RootModel/PreferencesConfig.js +76 -0
  9. package/esm/RootModel/createConfigModel.d.ts +240 -0
  10. package/esm/RootModel/createConfigModel.js +56 -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 +143 -0
  15. package/esm/Session/BaseSession.d.ts +312 -11
  16. package/esm/Session/BaseSession.js +458 -14
  17. package/esm/Session/Connections.d.ts +114 -8
  18. package/esm/Session/Connections.js +195 -74
  19. package/esm/Session/DrawerWidgets.d.ts +115 -12
  20. package/esm/Session/DrawerWidgets.js +161 -42
  21. package/esm/Session/MultipleViews.d.ts +1281 -1128
  22. package/esm/Session/MultipleViews.js +396 -64
  23. package/esm/Session/Preferences.d.ts +1667 -0
  24. package/esm/Session/Preferences.js +59 -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 +1277 -1138
  28. package/esm/Session/SessionTracks.js +540 -35
  29. package/esm/Session/Themes.d.ts +87 -2
  30. package/esm/Session/Themes.js +156 -23
  31. package/esm/Session/TrackMenu.d.ts +112 -0
  32. package/esm/Session/TrackMenu.js +200 -0
  33. package/esm/Session/TrackMenuSessionMixin.d.ts +27 -0
  34. package/esm/Session/TrackMenuSessionMixin.js +41 -0
  35. package/esm/Session/Tracks.d.ts +1171 -1117
  36. package/esm/Session/Tracks.js +183 -20
  37. package/esm/Session/assertSessionModel.d.ts +21 -0
  38. package/esm/Session/assertSessionModel.js +1 -0
  39. package/esm/Session/developerMode.d.ts +7 -0
  40. package/esm/Session/developerMode.js +43 -0
  41. package/esm/Session/finalizeSession.d.ts +16 -0
  42. package/esm/Session/finalizeSession.js +29 -0
  43. package/esm/Session/index.d.ts +22 -10
  44. package/esm/Session/index.js +13 -10
  45. package/esm/Session/readdedTrackConf.d.ts +8 -0
  46. package/esm/Session/readdedTrackConf.js +25 -0
  47. package/esm/Session/shareableSnapshot.d.ts +77 -0
  48. package/esm/Session/shareableSnapshot.js +196 -0
  49. package/esm/Session/temporaryAssemblyTracks.d.ts +24 -0
  50. package/esm/Session/temporaryAssemblyTracks.js +37 -0
  51. package/esm/assertExtends.d.ts +55 -0
  52. package/esm/assertExtends.js +1 -0
  53. package/esm/controllerTracks.d.ts +56 -0
  54. package/esm/controllerTracks.js +90 -0
  55. package/esm/destroyViewState.d.ts +21 -0
  56. package/esm/destroyViewState.js +29 -0
  57. package/esm/index.d.ts +31 -4
  58. package/esm/index.js +31 -4
  59. package/esm/loadPlugins.d.ts +33 -0
  60. package/esm/loadPlugins.js +41 -0
  61. package/esm/localFiles.d.ts +74 -0
  62. package/esm/localFiles.js +156 -0
  63. package/esm/observeSession.d.ts +56 -0
  64. package/esm/observeSession.js +91 -0
  65. package/esm/pluginInput.d.ts +32 -0
  66. package/esm/pluginInput.js +19 -0
  67. package/esm/pruneUnbuildableNodes.d.ts +109 -0
  68. package/esm/pruneUnbuildableNodes.js +568 -0
  69. package/esm/resolveAssemblies.d.ts +44 -0
  70. package/esm/resolveAssemblies.js +55 -0
  71. package/esm/rpcWorker.d.ts +26 -0
  72. package/esm/rpcWorker.js +98 -16
  73. package/esm/scheduleDetachedDestroy.d.ts +18 -0
  74. package/esm/scheduleDetachedDestroy.js +24 -0
  75. package/esm/sessionMigrations/index.d.ts +53 -0
  76. package/esm/sessionMigrations/index.js +432 -0
  77. package/esm/sessionUrl.d.ts +41 -0
  78. package/esm/sessionUrl.js +53 -0
  79. package/esm/sessionUtils.d.ts +69 -0
  80. package/esm/sessionUtils.js +613 -0
  81. package/esm/siblingCast.d.ts +14 -0
  82. package/esm/siblingCast.js +16 -0
  83. package/esm/snapshotUtils.d.ts +8 -0
  84. package/esm/snapshotUtils.js +12 -0
  85. package/esm/ui/AboutDialog.d.ts +3 -6
  86. package/esm/ui/AboutDialog.js +4 -4
  87. package/esm/ui/AboutDialogContents.d.ts +2 -6
  88. package/esm/ui/AboutDialogContents.js +34 -13
  89. package/esm/ui/AssemblyInfoPanel.d.ts +5 -0
  90. package/esm/ui/AssemblyInfoPanel.js +69 -0
  91. package/esm/ui/DisplayDefaultsSection.d.ts +36 -0
  92. package/esm/ui/DisplayDefaultsSection.js +65 -0
  93. package/esm/ui/Drawer.d.ts +21 -0
  94. package/esm/ui/Drawer.js +60 -0
  95. package/esm/ui/DrawerControls.d.ts +5 -0
  96. package/esm/ui/DrawerControls.js +28 -0
  97. package/esm/ui/DrawerHeader.d.ts +6 -0
  98. package/esm/ui/DrawerHeader.js +36 -0
  99. package/esm/ui/DrawerWidget.d.ts +5 -0
  100. package/esm/ui/DrawerWidget.js +12 -0
  101. package/esm/ui/DrawerWidgetSelector.d.ts +5 -0
  102. package/esm/ui/DrawerWidgetSelector.js +40 -0
  103. package/esm/ui/FileInfoPanel.d.ts +3 -6
  104. package/esm/ui/FileInfoPanel.js +35 -26
  105. package/esm/ui/HeaderButtons.d.ts +7 -1
  106. package/esm/ui/HeaderButtons.js +23 -18
  107. package/esm/ui/ModalWidget.d.ts +12 -0
  108. package/esm/ui/ModalWidget.js +33 -0
  109. package/esm/ui/ModalWidgetAppBar.d.ts +8 -0
  110. package/esm/ui/ModalWidgetAppBar.js +9 -0
  111. package/esm/ui/PreferencesDialog.d.ts +56 -0
  112. package/esm/ui/PreferencesDialog.js +145 -0
  113. package/esm/ui/PreferencesResetDialog.d.ts +8 -0
  114. package/esm/ui/PreferencesResetDialog.js +19 -0
  115. package/esm/ui/RefNameAliasesDialog.d.ts +9 -0
  116. package/esm/ui/RefNameAliasesDialog.js +65 -0
  117. package/esm/ui/RefNameInfoDialog.d.ts +3 -6
  118. package/esm/ui/RefNameInfoDialog.js +57 -63
  119. package/esm/ui/WidgetBody.d.ts +18 -0
  120. package/esm/ui/WidgetBody.js +22 -0
  121. package/esm/ui/WidgetHeading.d.ts +7 -0
  122. package/esm/ui/WidgetHeading.js +7 -0
  123. package/esm/ui/drawerLayout.d.ts +18 -0
  124. package/esm/ui/drawerLayout.js +22 -0
  125. package/esm/ui/index.d.ts +13 -1
  126. package/esm/ui/index.js +12 -1
  127. package/esm/ui/util.d.ts +37 -4
  128. package/esm/ui/util.js +35 -38
  129. package/esm/useEngineLifecycle.d.ts +19 -0
  130. package/esm/useEngineLifecycle.js +44 -0
  131. package/package.json +12 -15
  132. package/esm/RootModel/FormatAbout.d.ts +0 -12
  133. package/esm/RootModel/FormatAbout.js +0 -15
  134. package/esm/RootModel/FormatDetails.d.ts +0 -24
  135. package/esm/RootModel/FormatDetails.js +0 -27
  136. package/esm/Session/DialogQueue.d.ts +0 -17
  137. package/esm/Session/DialogQueue.js +0 -31
@@ -1,41 +1,554 @@
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 } from 'mobx';
6
+ import { TracksManagerSessionMixin } from './Tracks.js';
7
+ import { assertNotReaddedDifferently } from './readdedTrackConf.js';
8
+ import { assertTrackConfOutlivesItsAssemblies } from './temporaryAssemblyTracks.js';
9
+ // jbrowse.tracks holds frozen plain objects (app-core, web/desktop) or MST
10
+ // config nodes (product-core, embedded react views); the delta math reads both
11
+ // as plain track configs. Single site for that documented cast — per-node
12
+ // normalization still happens in toPlainConfig.
13
+ function baseTracks(self) {
14
+ return self.jbrowse.tracks;
15
+ }
16
+ // A delta must not be stored unless it records a real user edit, or it would
17
+ // flip isTrackOverride (edited badge + Reset menu) on with nothing actually
18
+ // overridden. Two ways an empty edit slips past a key count: diffTrackConfig
19
+ // always retains the self-identifying trackId, and a base config that omits
20
+ // `displays` diffs against the hydrated snapshot's injected {type, displayId}
21
+ // display stubs to yield `{trackId, displays: [...stubs]}` — nonzero keys but no
22
+ // changed slot. flattenTrackConfigDelta (which drops identity keys and empty
23
+ // display stubs) is the honest test of whether any real setting changed.
24
+ function deltaHasChanges(base, delta) {
25
+ return flattenTrackConfigDelta(base, delta).length > 0;
26
+ }
27
+ // The assemblies a track config names that the config.json does not carry.
28
+ // `self.jbrowse.assemblies` is the catalog alone — the session's own
29
+ // `assemblies` getter adds sessionAssemblies to it, which is the opposite of
30
+ // what this asks.
31
+ //
32
+ // An assembly's `aliases` are names of that assembly, so a track naming one
33
+ // names something the catalog carries and publishes like any other. Matching on
34
+ // `name` alone diverted it to the session and told the admin the config.json
35
+ // has no such assembly while they were looking at it. Read off the configs
36
+ // rather than through the assembly manager: the question is what the config.json
37
+ // declares, and the manager also answers for sessionAssemblies, which is the
38
+ // case this exists to catch.
39
+ function assembliesNotInTheCatalog(self, trackConf) {
40
+ const catalog = new Set(self.jbrowse.assemblies.flatMap(a => [
41
+ readConfObject(a, 'name'),
42
+ ...(readConfObject(a, 'aliases') ?? []),
43
+ ]));
44
+ const names = readConfObject(trackConf, 'assemblyNames');
45
+ return names?.filter(name => !catalog.has(name)) ?? [];
46
+ }
47
+ function withoutDelta(deltas, trackId) {
48
+ const { [trackId]: _dropped, ...rest } = deltas;
49
+ return rest;
50
+ }
51
+ /**
52
+ * #stateModel SessionTracksManagerSessionMixin
53
+ */
4
54
  export function SessionTracksManagerSessionMixin(pluginManager) {
55
+ // A jbrowse.tracks base entry and a shown track's persisted snapshot are in
56
+ // two different config "normal forms". A base (app-core web/desktop) is the
57
+ // raw config-file object: shorthand `uri`, no injected display stubs. But a
58
+ // shown track's snapshot comes from a live hydrated config node, so it is
59
+ // post-preProcessSnapshot: the `uri` shorthand is expanded to
60
+ // bamLocation/index, `baseUri` is propagated into those locations, and a
61
+ // {type, displayId} stub is injected per compatible display type. Diffing or
62
+ // merging across those two forms makes every expanded/injected field read as
63
+ // a user edit — pinning whole adapters and content-free display stubs into
64
+ // deltas, and defeating the delta's purpose (a later admin adapter-URL fix
65
+ // would then be masked by the pinned copy). Normalize a base to the hydrated
66
+ // form by running it through the same track schema, so diff/merge compare
67
+ // like with like and cancel everything untouched. Memoized per frozen-base
68
+ // identity (stable until a jbrowse.tracks write). A base that is already an
69
+ // MST config node (product-core embedded views) is snapshotted directly — it
70
+ // is already in the hydrated form.
71
+ //
72
+ // The isStateTreeNode/getSnapshot casts are load-bearing, don't "simplify"
73
+ // them: our MST fork types `isStateTreeNode`'s parameter as a state-tree node
74
+ // (not `unknown`), so `base` (a plain interface) must widen through `unknown`
75
+ // to be passed, and `getSnapshot` of that returns `unknown`.
76
+ const canonicalBaseCache = new WeakMap();
77
+ function toPlainConfig(base) {
78
+ const node = base;
79
+ if (isStateTreeNode(node)) {
80
+ return getSnapshot(node);
81
+ }
82
+ const cached = canonicalBaseCache.get(base);
83
+ if (cached) {
84
+ return cached;
85
+ }
86
+ const schema = pluginManager.pluggableConfigSchemaType('track');
87
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- tsc7 sees getSnapshot here as unknown (eslint's TS6 service disagrees; see header note)
88
+ /* oxlint-disable typescript/no-unnecessary-type-assertion */
89
+ const hydrated = getSnapshot(schema.create(base, { pluginManager }));
90
+ /* oxlint-enable typescript/no-unnecessary-type-assertion */
91
+ canonicalBaseCache.set(base, hydrated);
92
+ return hydrated;
93
+ }
5
94
  return TracksManagerSessionMixin(pluginManager)
6
95
  .named('SessionTracksManagerSessionMixin')
7
96
  .props({
8
- sessionTracks: types.array(pluginManager.pluggableConfigSchemaType('track')),
97
+ /**
98
+ * #property
99
+ * User-added session tracks (no matching admin config track). A non-admin's
100
+ * *edits* to an existing config track are stored as deltas
101
+ * (trackConfigDeltas), not here.
102
+ */
103
+ sessionTracks: types.stripDefault(types.array(pluginManager.pluggableConfigSchemaType('track')), []),
104
+ /**
105
+ * #property
106
+ * Per-track config overrides for a non-admin, keyed by trackId, stored as a
107
+ * *delta* against the admin-owned base config (jbrowse.tracks entry) rather
108
+ * than a full copy — so a later admin change to an untouched field still
109
+ * flows through (see trackConfigDelta.ts). Frozen (not a typed track array)
110
+ * on purpose: a typed create() would fill defaults, erasing the "unset vs
111
+ * default" distinction the delta merge relies on.
112
+ *
113
+ * `stripDefault` for the reason every other persisted prop beside it has
114
+ * it: without it the empty map is written into every snapshot and every
115
+ * share link, so a session that never overrode a track still ships
116
+ * `"trackConfigDeltas":{}`. It was the one prop added since that convention
117
+ * without it.
118
+ */
119
+ trackConfigDeltas: types.stripDefault(types.frozen(), {}),
9
120
  })
10
- .views(self => ({
11
- get tracks() {
12
- return [...self.sessionTracks, ...self.jbrowse.tracks];
13
- },
121
+ .volatile(() => ({
122
+ /**
123
+ * Per-track private working copies (non-admin), keyed by trackId. A plain
124
+ * Map — not observable, not persisted — mirroring the pluginManager
125
+ * hydration cache: it holds the live MST config node a shown track's
126
+ * in-place quick-edits mutate, so the shared frozen base is never touched.
127
+ * See
128
+ * [ADR-032](https://github.com/GMOD/jbrowse-components/blob/main/agent-docs/architecture-decision-records/adr-032-track-config-nodes-are-throwaway-views.md).
129
+ *
130
+ * Not evicted: it's a pure memoization cache, bounded by the count of
131
+ * distinct tracks shown this session (each entry a lazily-hydrated config
132
+ * node), holding no authoritative state — the persisted delta is the
133
+ * source of truth, and reset/programmatic edits keep a retained copy in
134
+ * sync. Retention is volatile RAM only (never serialized), so it's not
135
+ * worth a reference-counted prune at every track-removal path.
136
+ *
137
+ * Each entry carries the delta it mirrors, so a delta replaced from
138
+ * outside this mixin invalidates it — see `getEditableTrackConfig`.
139
+ */
140
+ editableTrackConfigs: new Map(),
14
141
  }))
15
- .actions(self => {
16
- const { addTrackConf: superAddTrackConf, deleteTrackConf: superDeleteTrackConf, } = self;
142
+ .views(self => {
143
+ // Memoize merged configs per (base object, delta value) pair so the tracks
144
+ // getter returns stable object identity across unrelated recomputes. A
145
+ // fresh merged object each time would rehydrate a new MST node in
146
+ // TrackConfigurationReference, losing open display state (see CLAUDE.md).
147
+ // Both keys have stable identity until they actually change: a track's
148
+ // delta only when that track is edited, and the base only on a
149
+ // jbrowse.tracks write. This relies on a base config never mutating in
150
+ // place: app-core's frozen array replaces the entry (new identity) on
151
+ // updateTrackConf, and product-core's MST-node bases have no edit path at
152
+ // all (no updateTrackConf; embedded sessions are adminMode:false). If an
153
+ // in-place base edit is ever added, key this cache on base content too.
154
+ const mergeCache = new WeakMap();
17
155
  return {
18
- addTrackConf(trackConf) {
156
+ /**
157
+ * #getter
158
+ * User-added session tracks first, then each admin config track with its
159
+ * delta (trackConfigDeltas) merged over it. A base track without a delta
160
+ * is returned unchanged by identity to keep the hydration cache warm.
161
+ */
162
+ get tracks() {
163
+ const deltas = self.trackConfigDeltas;
164
+ const sessionIds = new Set(self.sessionTracks.map(t => t.trackId));
165
+ const configTracks = baseTracks(self);
166
+ const merged = configTracks
167
+ .filter(t => !sessionIds.has(t.trackId))
168
+ .map(base => {
169
+ const delta = deltas[base.trackId];
170
+ if (!delta) {
171
+ return base;
172
+ }
173
+ const cached = mergeCache.get(base);
174
+ if (cached?.delta === delta) {
175
+ return cached.merged;
176
+ }
177
+ const mergedTrack = mergeTrackConfig(toPlainConfig(base), delta);
178
+ mergeCache.set(base, { delta, merged: mergedTrack });
179
+ return mergedTrack;
180
+ });
181
+ return [...self.sessionTracks, ...merged];
182
+ },
183
+ /**
184
+ * #method
185
+ * The overridden slots for `trackId` (empty when it has no delta): each
186
+ * changed setting's path, its base/default value and the edited value.
187
+ * Drives the "view changes" dialog opened from the edited badge.
188
+ */
189
+ getTrackConfigChanges(trackId) {
190
+ // Every rendered track row asks this (the edited badge), but only an
191
+ // edited track has a delta — so scan for the base only once one
192
+ // exists. Skipping it also keeps an unedited row from subscribing to
193
+ // the whole jbrowse.tracks array.
194
+ const delta = self.trackConfigDeltas[trackId];
195
+ const base = delta
196
+ ? baseTracks(self).find(t => t.trackId === trackId)
197
+ : undefined;
198
+ return delta && base
199
+ ? flattenTrackConfigDelta(toPlainConfig(base), delta)
200
+ : [];
201
+ },
202
+ /**
203
+ * #method
204
+ * A non-admin's private working copy of a track config, created on first
205
+ * access from the current frozen (base+delta) value and cached by
206
+ * trackId, so a shown track's in-place quick-edits (setSlot) mutate this
207
+ * copy and never the shared frozen base node (see ADR-032). Undefined in
208
+ * admin mode — there the base jbrowse.tracks entry is edited in place.
209
+ * Called by TrackConfigurationReference during lazy hydration.
210
+ *
211
+ * Cached against the delta it was built from, not by trackId alone. A
212
+ * delta this mixin wrote re-stamps the entry, so the copy an edit is
213
+ * still being typed into is never swapped out mid-keystroke; a delta
214
+ * replaced from outside — an undo's `applySnapshot` on the session, a
215
+ * session restore — cannot, so the next read rebuilds the copy from the
216
+ * delta that now exists. Reading `trackConfigDeltas` here is also what
217
+ * makes an undo re-resolve the reference at all: the resolver's caller
218
+ * is already subscribed to it through `getTrackById`.
219
+ */
220
+ getEditableTrackConfig(trackId, frozenConfig, schemaType) {
19
221
  if (self.adminMode) {
20
- return superAddTrackConf(trackConf);
222
+ return undefined;
21
223
  }
22
- const { trackId, type } = trackConf;
23
- if (!type) {
24
- throw new Error(`unknown track type ${type}`);
224
+ const delta = self.trackConfigDeltas[trackId];
225
+ const existing = self.editableTrackConfigs.get(trackId);
226
+ if (existing && existing.delta === delta) {
227
+ return existing.node;
25
228
  }
26
- const track = self.sessionTracks.find(t => t.trackId === trackId);
27
- if (track) {
28
- return track;
229
+ const node = schemaType.create(frozenConfig, {
230
+ pluginManager,
231
+ });
232
+ self.editableTrackConfigs.set(trackId, { node, delta });
233
+ return node;
234
+ },
235
+ };
236
+ })
237
+ .actions(self => ({
238
+ afterAttach() {
239
+ // One-time format upgrade: a legacy session stored a non-admin's edits as
240
+ // a full-config sessionTracks entry shadowing the same-id admin track.
241
+ // Convert those to deltas so the whole app uses one override mechanism.
242
+ // Genuinely-added session tracks (no matching base) are left in place.
243
+ const configById = new Map(baseTracks(self).map(t => [t.trackId, t]));
244
+ const legacy = self.sessionTracks.filter(t => configById.has(t.trackId));
245
+ if (legacy.length > 0) {
246
+ const deltas = { ...self.trackConfigDeltas };
247
+ for (const track of legacy) {
248
+ const plainBase = toPlainConfig(configById.get(track.trackId));
249
+ const delta = diffTrackConfig(plainBase, getSnapshot(track));
250
+ // a legacy override identical to its base contributes no changed
251
+ // slots: drop it rather than migrate a content-free delta
252
+ if (deltaHasChanges(plainBase, delta)) {
253
+ deltas[track.trackId] = delta;
254
+ }
255
+ }
256
+ self.trackConfigDeltas = deltas;
257
+ for (const track of legacy) {
258
+ self.sessionTracks.remove(track);
259
+ }
260
+ }
261
+ },
262
+ }))
263
+ .actions(self => {
264
+ const { publishTrackConf: superPublishTrackConf, deleteTrackConf: superDeleteTrackConf, updateTrackConfiguration: superUpdateTrackConfiguration, } = self;
265
+ // A cleared delta reverts the track's live working copy to the base in
266
+ // place, so an open view re-renders to the default. applySnapshot keeps
267
+ // the node identity (existing observers just update); no-op in admin mode
268
+ // or for a track that was never edited (no working copy).
269
+ //
270
+ // Through toPlainConfig like every other read of a base in this file, not
271
+ // the raw entry: in product-core's embedded sessions a base IS a live MST
272
+ // config node, and applySnapshot wants a snapshot — handing it a node
273
+ // reads nested nodes back out where plain objects belong. (For a frozen
274
+ // base the two agree, since applySnapshot re-runs the same
275
+ // preProcessSnapshot that toPlainConfig hydrates through, so this only
276
+ // makes that reliance explicit.)
277
+ function revertEditableTrackConfig(trackId) {
278
+ const entry = self.editableTrackConfigs.get(trackId);
279
+ const base = baseTracks(self).find(t => t.trackId === trackId);
280
+ if (entry && base) {
281
+ applySnapshot(entry.node, toPlainConfig(base));
282
+ }
283
+ }
284
+ // Re-stamp a working copy with the delta now in trackConfigDeltas, so the
285
+ // copy this mixin just persisted from stays the one the next read
286
+ // resolves. Read back off the prop rather than reusing the written object,
287
+ // so the stamp is whatever `types.frozen` actually stored.
288
+ function stampEditableTrackConfig(trackId) {
289
+ const entry = self.editableTrackConfigs.get(trackId);
290
+ if (entry) {
291
+ entry.delta = self.trackConfigDeltas[trackId];
292
+ }
293
+ }
294
+ // Single writer for trackConfigDeltas (pass undefined to clear). Clearing
295
+ // also reverts the working copy, which is what makes a Reset visible in an
296
+ // open view — except where the caller says the working copy IS the edit
297
+ // that produced the empty delta (`revertWorkingCopy: false`), where
298
+ // reverting undoes it.
299
+ function writeDelta(trackId, delta, { revertWorkingCopy = true } = {}) {
300
+ self.trackConfigDeltas = delta
301
+ ? { ...self.trackConfigDeltas, [trackId]: delta }
302
+ : withoutDelta(self.trackConfigDeltas, trackId);
303
+ if (!delta && revertWorkingCopy) {
304
+ revertEditableTrackConfig(trackId);
305
+ }
306
+ stampEditableTrackConfig(trackId);
307
+ }
308
+ // Whether `trackConf` is the working copy's own current state — i.e. this
309
+ // update came from the track's live `setSlot` edits rather than from the
310
+ // config editor's separate temporary node. `syncEditableTrackConfig` below
311
+ // asks the same question the other way round.
312
+ function isWorkingCopyState(trackId, trackConf) {
313
+ const entry = self.editableTrackConfigs.get(trackId);
314
+ return !!entry && compareStructural(getSnapshot(entry.node), trackConf);
315
+ }
316
+ // Push a *programmatic* update (the config editor's Apply, or any
317
+ // updateTrackConfiguration not driven by this node's own live edits) into
318
+ // the working copy. When the node IS the edit source its snapshot already
319
+ // equals fullConfig, so this skips — which also avoids clobbering an
320
+ // in-progress live drag.
321
+ function syncEditableTrackConfig(trackId, fullConfig) {
322
+ const entry = self.editableTrackConfigs.get(trackId);
323
+ if (entry && !compareStructural(getSnapshot(entry.node), fullConfig)) {
324
+ applySnapshot(entry.node, fullConfig);
325
+ }
326
+ }
327
+ // The session-scoped add, shared by the action that always means the
328
+ // session and the one that means it only for a non-admin. A plain closure
329
+ // rather than `this.addSessionTrackConf` so neither action's inferred
330
+ // return type depends on the other's.
331
+ function addToSession(loose) {
332
+ const trackConf = expandLooseTrackConfig(loose, pluginManager);
333
+ const { trackId, type } = trackConf;
334
+ if (!type) {
335
+ throw new Error(`track type not specified for "${trackId}"`);
336
+ }
337
+ assertTrackConfOutlivesItsAssemblies(self, trackConf, 'sessionTracks');
338
+ // Dedupe against everything the session can already resolve — config
339
+ // catalog (jbrowse.tracks), assembly sequences, connection tracks and
340
+ // prior sessionTracks — not just sessionTracks. Re-adding a config
341
+ // already in the catalog would otherwise push a full shadow into
342
+ // sessionTracks, silently demoting a catalog track to a session track
343
+ // and dropping its trackConfigDeltas override semantics.
344
+ const existing = self.getTrackById(trackId);
345
+ if (existing) {
346
+ if (self.sessionTracks.includes(existing)) {
347
+ assertNotReaddedDifferently(pluginManager, existing, {
348
+ ...trackConf,
349
+ trackId,
350
+ type,
351
+ });
29
352
  }
353
+ return existing;
354
+ }
355
+ // sessionTracks is a typed MST array (unlike the frozen
356
+ // jbrowse.tracks), so an invalid config throws on push. Surface it as
357
+ // a snackbar and skip the add, rather than letting it crash the app.
358
+ try {
30
359
  const length = self.sessionTracks.push(trackConf);
31
360
  return self.sessionTracks[length - 1];
361
+ }
362
+ catch (e) {
363
+ self.notifyError(`Track "${trackId}" has an invalid configuration: ${e}`, e);
364
+ return undefined;
365
+ }
366
+ }
367
+ return {
368
+ /**
369
+ * #action
370
+ * Add a track config to *this session*: it lands in `sessionTracks`,
371
+ * travels with the session when it is saved or shared, and never
372
+ * reaches the config.json the server hands every visitor.
373
+ *
374
+ * **The default destination.** Everything that is not an Add-track
375
+ * workflow wants this one, whoever is looking — a session spec's
376
+ * `sessionTracks`, a URL's `&sessionTracks=`, and every track a feature
377
+ * stands up on the user's behalf. Mirrors `addSessionConnectionConf` in
378
+ * the connections mixins.
379
+ */
380
+ addSessionTrackConf(trackConf) {
381
+ return addToSession(trackConf);
32
382
  },
33
- deleteTrackConf(trackConf) {
34
- const found = superDeleteTrackConf(trackConf);
35
- if (found) {
36
- return found;
383
+ /**
384
+ * #action
385
+ * Publish a track config to the shared catalog if this user can, and
386
+ * fall back to their session if they cannot: an admin's goes to
387
+ * `jbrowse.tracks`, which the admin server writes back into the
388
+ * config.json every visitor is served; everyone else's goes to
389
+ * `sessionTracks`.
390
+ *
391
+ * **Only the "Add track" workflows.** Those are where an admin adding a
392
+ * track means to add it for the whole site. A track a feature stands up
393
+ * on the user's behalf — a search result, a computed consensus, a
394
+ * reconstruction's segment labels — is not a catalog entry, and one
395
+ * admin click on this publishes it to every visitor, again on the next
396
+ * click, with a per-launch trackId that defeats the dedupe.
397
+ * `addSessionTrackConf` above is the destination for those.
398
+ *
399
+ * An admin's track still goes to the session when it names an assembly
400
+ * the config.json does not carry — a session spec's, a MAF sample's own
401
+ * genome, a comparative view's synthesized pair. The Add-track widget
402
+ * takes its assembly from the containing view, so an admin adding a
403
+ * track while looking at one of those arrives here with a name no
404
+ * visitor can resolve, and publishing it writes an entry the server
405
+ * hands everyone and nobody can draw. The session is the one
406
+ * destination where that track works, so the click keeps working and
407
+ * the dangling entry is never written; a snackbar says which assembly
408
+ * moved it.
409
+ *
410
+ * **The snackbar names the assembly and not the track**, which is what
411
+ * lets a batch collapse to one message: `pushSnackbarMessage` dedupes on
412
+ * the exact text, and `BulkAddTracksWorkflow` publishes in a loop, so
413
+ * interpolating the track name stacked one identical-but-for-a-name
414
+ * toast per file over an admin adding thirty. The assembly is the whole
415
+ * of what an admin has to act on anyway.
416
+ */
417
+ publishTrackConf(trackConf) {
418
+ if (!self.adminMode) {
419
+ return addToSession(trackConf);
420
+ }
421
+ const missing = assembliesNotInTheCatalog(self, trackConf);
422
+ if (!missing.length) {
423
+ return superPublishTrackConf(trackConf);
37
424
  }
425
+ 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');
426
+ return addToSession(trackConf);
427
+ },
428
+ /**
429
+ * #action
430
+ * Deprecated alias of `addSessionTrackConf`. Call that, or
431
+ * `publishTrackConf`.
432
+ *
433
+ * @deprecated
434
+ *
435
+ * The session-scoped add under its old name, for the prebuilt plugin
436
+ * bundles that reach it by name at runtime. See the base mixin's copy
437
+ * for why it survives and why it now means the session; nothing in tree
438
+ * may call it.
439
+ */
440
+ addTrackConf(trackConf) {
441
+ return addToSession(trackConf);
442
+ },
443
+ /**
444
+ * #action
445
+ * Persist a non-admin's edited track config as a delta (trackConfigDeltas)
446
+ * against the admin-owned base — only the changed slots — so the edits
447
+ * persist and are shared while admin changes to untouched fields still
448
+ * flow through. A user-added session track (no base) is edited in place.
449
+ * Everything else (admin edits, opened connection tracks) defers to the
450
+ * base mixin, which routes connection tracks to connectionTrackConfigs and
451
+ * the rest to the jbrowse config.
452
+ */
453
+ updateTrackConfiguration(trackConf) {
38
454
  const { trackId } = trackConf;
455
+ const base = self.adminMode
456
+ ? undefined
457
+ : baseTracks(self).find(t => t.trackId === trackId);
458
+ const sessionIdx = self.adminMode
459
+ ? -1
460
+ : self.sessionTracks.findIndex(t => t.trackId === trackId);
461
+ if (base) {
462
+ const plainBase = toPlainConfig(base);
463
+ const delta = diffTrackConfig(plainBase, trackConf);
464
+ // an edit that nets back to the base carries no changed slots: clear
465
+ // any prior override (implicit reset) instead of pinning a
466
+ // content-free delta; skip a no-op write when there's nothing to
467
+ // clear, so the tracks getter doesn't needlessly churn identity
468
+ if (deltaHasChanges(plainBase, delta)) {
469
+ // Two views showing the same track each run BaseTrackModel's
470
+ // persist reaction against the shared config node, so a single edit
471
+ // calls this twice with an identical delta (and the config editor
472
+ // can re-save an unchanged config). Skip a structurally-identical
473
+ // re-store: writing a fresh trackConfigDeltas object would churn its
474
+ // identity and make the tracks getter rehydrate a new merged node
475
+ // for no real change.
476
+ const existing = self.trackConfigDeltas[trackId];
477
+ if (!existing || !compareStructural(existing, delta)) {
478
+ writeDelta(trackId, delta);
479
+ }
480
+ syncEditableTrackConfig(trackId, trackConf);
481
+ }
482
+ else if (trackId in self.trackConfigDeltas) {
483
+ // An empty delta is two different situations and only one of them
484
+ // is a reset. `diffTrackConfig` records adds and changes, never
485
+ // deletions (its module note), so *unsetting* a slot the admin
486
+ // base sets diffs to nothing exactly as netting back to the base
487
+ // does — and reverting the working copy then undid the edit ~400ms
488
+ // after the user watched it land. The reachable case is a size
489
+ // row's reset, whose whole job is to unset a slot, over a
490
+ // promotable slot an admin `config.json` declares.
491
+ //
492
+ // The removal still doesn't survive a reload, which is the
493
+ // tombstone-free delta's documented limitation; what it no longer
494
+ // does is undo itself on screen. Only the working-copy-driven path
495
+ // skips the revert: the config editor edits a separate temporary
496
+ // node, so there the revert is the only thing that moves the open
497
+ // track back to the base.
498
+ writeDelta(trackId, undefined, {
499
+ revertWorkingCopy: !isWorkingCopyState(trackId, trackConf),
500
+ });
501
+ }
502
+ }
503
+ else if (sessionIdx !== -1) {
504
+ // a user-added session track (no admin base): edit it in place. A
505
+ // typed MST array throws on an invalid config — snackbar it.
506
+ try {
507
+ self.sessionTracks[sessionIdx] = trackConf;
508
+ }
509
+ catch (e) {
510
+ self.notifyError(`Track "${trackId}" has an invalid configuration: ${e}`, e);
511
+ }
512
+ }
513
+ else {
514
+ // admin edit, or a track with no admin base / sessionTracks entry
515
+ // (an opened connection track, or a homeless in-memory-only edit):
516
+ // the base mixin routes these
517
+ superUpdateTrackConfiguration(trackConf);
518
+ // An admin's edit rewrites the base config itself, so it supersedes
519
+ // any delta: a shared session authored by a non-admin carries their
520
+ // deltas, and an admin opening it edits jbrowse.tracks directly. Left
521
+ // in place, the delta merges straight back over that base in the
522
+ // `tracks` getter and the admin's own edit silently reverts.
523
+ if (self.adminMode && trackId in self.trackConfigDeltas) {
524
+ writeDelta(trackId, undefined);
525
+ }
526
+ }
527
+ },
528
+ /**
529
+ * #action
530
+ * Drop a non-admin's delta (trackConfigDeltas) so the track reverts to
531
+ * its admin config (jbrowse.tracks) default. Unlike deleteTrackConf this
532
+ * does not dereference the track from open views — the base config
533
+ * re-resolves in place, so an open track stays open and simply reverts.
534
+ */
535
+ resetTrackConfiguration(trackId) {
536
+ if (trackId in self.trackConfigDeltas) {
537
+ writeDelta(trackId, undefined);
538
+ }
539
+ },
540
+ /**
541
+ * #action
542
+ */
543
+ deleteTrackConf(trackConf) {
544
+ superDeleteTrackConf(trackConf);
545
+ const { trackId } = trackConf;
546
+ // A delta only outlives its base if the base is gone, so drop it here
547
+ // rather than strand it. Reachable only programmatically (the UI offers
548
+ // a non-admin Reset, not Delete, for a delta-bearing base track).
549
+ if (trackId in self.trackConfigDeltas) {
550
+ writeDelta(trackId, undefined);
551
+ }
39
552
  const idx = self.sessionTracks.findIndex(t => t.trackId === trackId);
40
553
  if (idx === -1) {
41
554
  return undefined;
@@ -43,18 +556,10 @@ export function SessionTracksManagerSessionMixin(pluginManager) {
43
556
  return self.sessionTracks.splice(idx, 1);
44
557
  },
45
558
  };
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
559
  });
57
560
  }
58
- export function isSessionWithSessionTracks(thing) {
59
- return isBaseSession(thing) && 'sessionTracks' in thing;
60
- }
561
+ // The `isSessionWithSessionTracks` guard that used to live here now lives in
562
+ // `@jbrowse/core/util/types` beside the rest of the session capability guards.
563
+ // Its only caller was the hierarchical track selector — a plugin, which could
564
+ // not reach product-core through the ABI it publishes against, so this package
565
+ // was a runtime dependency of two shipped plugins for three type guards.