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

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