From 44ff2a3e0540432c52e5c6b675d4345be05107c2 Mon Sep 17 00:00:00 2001 From: Colin Date: Thu, 4 Dec 2025 13:37:48 -0500 Subject: [PATCH 1/8] Modularizing and removing mixins --- .../src/Assemblies/SessionAssembliesMixin.ts | 15 +- .../Assemblies/TemporaryAssembliesMixin.ts | 55 +- packages/app-core/src/index.ts | 3 + packages/app-core/src/menuItems.ts | 139 +++ packages/app-core/src/types.ts | 8 + packages/app-core/src/util.ts | 69 ++ .../src/RootModel/BaseRootModel.ts | 21 +- .../src/RootModel/InternetAccounts.ts | 41 +- .../product-core/src/Session/Connections.ts | 200 ++-- packages/product-core/src/Session/Themes.ts | 63 +- packages/web-core/src/BaseWebSession/index.ts | 465 --------- .../web-core/src/BaseWebSession/index.tsx | 954 ++++++++++++++++++ packages/web-core/src/SessionConnections.ts | 76 -- packages/web-core/src/index.ts | 1 - .../components/AddConnectionWidget.tsx | 3 +- .../jbrowse-desktop/src/indexJobsModel.ts | 35 +- .../src/rootModel/rootModel.ts | 96 +- .../src/sessionModel/DesktopSession.ts | 39 - .../src/sessionModel/TrackMenu.ts | 114 --- .../src/sessionModel/sessionModel.ts | 176 ---- .../src/sessionModel/sessionModel.tsx | 807 +++++++++++++++ .../src/rootModel/rootModel.ts | 151 +-- products/jbrowse-react-app/src/util.ts | 70 -- .../src/createModel/createSessionModel.ts | 159 --- .../src/createModel/createSessionModel.tsx | 550 ++++++++++ .../src/createModel/createSessionModel.ts | 165 --- .../src/createModel/createSessionModel.tsx | 613 +++++++++++ .../jbrowse-web/src/rootModel/rootModel.ts | 173 +--- .../jbrowse-web/src/sessionModel/index.ts | 5 +- products/jbrowse-web/src/util.ts | 71 -- 30 files changed, 3467 insertions(+), 1870 deletions(-) create mode 100644 packages/app-core/src/menuItems.ts create mode 100644 packages/app-core/src/types.ts create mode 100644 packages/app-core/src/util.ts delete mode 100644 packages/web-core/src/BaseWebSession/index.ts create mode 100644 packages/web-core/src/BaseWebSession/index.tsx delete mode 100644 packages/web-core/src/SessionConnections.ts delete mode 100644 products/jbrowse-desktop/src/sessionModel/DesktopSession.ts delete mode 100644 products/jbrowse-desktop/src/sessionModel/TrackMenu.ts delete mode 100644 products/jbrowse-desktop/src/sessionModel/sessionModel.ts create mode 100644 products/jbrowse-desktop/src/sessionModel/sessionModel.tsx delete mode 100644 products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.ts create mode 100644 products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx delete mode 100644 products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.ts create mode 100644 products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx diff --git a/packages/app-core/src/Assemblies/SessionAssembliesMixin.ts b/packages/app-core/src/Assemblies/SessionAssembliesMixin.ts index ee6d23539b..60274f78ed 100644 --- a/packages/app-core/src/Assemblies/SessionAssembliesMixin.ts +++ b/packages/app-core/src/Assemblies/SessionAssembliesMixin.ts @@ -3,14 +3,23 @@ import { types } from '@jbrowse/mobx-state-tree' import type PluginManager from '@jbrowse/core/PluginManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' import type { AnyConfiguration } from '@jbrowse/core/configuration' -import type { BaseSession } from '@jbrowse/product-core' + +interface SessionAssembliesMixinJBrowse { + addAssemblyConf: (conf: AnyConfiguration) => void + removeAssemblyConf: (name: string) => void +} + +interface SessionAssembliesMixinContext { + adminMode: boolean + jbrowse: SessionAssembliesMixinJBrowse +} /** * #stateModel SessionAssembliesMixin * #category root */ export function SessionAssembliesMixin( - pluginManager: PluginManager, + _pluginManager: PluginManager, assemblyConfigSchemasType: BaseAssemblyConfigSchema, ) { return types @@ -21,7 +30,7 @@ export function SessionAssembliesMixin( sessionAssemblies: types.array(assemblyConfigSchemasType), }) .actions(s => { - const self = s as typeof s & BaseSession + const self = s as typeof s & SessionAssembliesMixinContext return { /** * #action diff --git a/packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts b/packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts index e08881ccec..fd5870e2bb 100644 --- a/packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts +++ b/packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts @@ -2,14 +2,13 @@ import { types } from '@jbrowse/mobx-state-tree' import type PluginManager from '@jbrowse/core/PluginManager' import type { AnyConfiguration } from '@jbrowse/core/configuration' -import type { BaseSession } from '@jbrowse/product-core' /** * #stateModel TemporaryAssembliesMixin * #category root */ export function TemporaryAssembliesMixin( - pluginManager: PluginManager, + _pluginManager: PluginManager, assemblyConfigSchemasType = types.frozen(), ) { return types @@ -19,33 +18,29 @@ export function TemporaryAssembliesMixin( */ temporaryAssemblies: types.array(assemblyConfigSchemasType), }) + .actions(self => ({ + /** + * #action + * used for read vs ref type assemblies. + */ + addTemporaryAssembly(conf: AnyConfiguration) { + const asm = self.temporaryAssemblies.find(f => f.name === conf.name) + if (asm) { + console.warn(`Assembly ${conf.name} was already existing`) + return asm + } + const length = self.temporaryAssemblies.push(conf) + return self.temporaryAssemblies[length - 1] + }, - .actions(s => { - const self = s as typeof s & BaseSession - return { - /** - * #action - * used for read vs ref type assemblies. - */ - addTemporaryAssembly(conf: AnyConfiguration) { - const asm = self.temporaryAssemblies.find(f => f.name === conf.name) - if (asm) { - console.warn(`Assembly ${conf.name} was already existing`) - return asm - } - const length = self.temporaryAssemblies.push(conf) - return self.temporaryAssemblies[length - 1] - }, - - /** - * #action - */ - removeTemporaryAssembly(name: string) { - const elt = self.temporaryAssemblies.find(a => a.name === name) - if (elt) { - self.temporaryAssemblies.remove(elt) - } - }, - } - }) + /** + * #action + */ + removeTemporaryAssembly(name: string) { + const elt = self.temporaryAssemblies.find(a => a.name === name) + if (elt) { + self.temporaryAssemblies.remove(elt) + } + }, + })) } diff --git a/packages/app-core/src/index.ts b/packages/app-core/src/index.ts index 35678c72b1..6d9d5e032e 100644 --- a/packages/app-core/src/index.ts +++ b/packages/app-core/src/index.ts @@ -6,3 +6,6 @@ export * from './JBrowseModel' export * from './Assemblies' export * from './RootMenu' export * from './menus' +export * from './menuItems' +export * from './types' +export * from './util' diff --git a/packages/app-core/src/menuItems.ts b/packages/app-core/src/menuItems.ts new file mode 100644 index 0000000000..87e8d98be3 --- /dev/null +++ b/packages/app-core/src/menuItems.ts @@ -0,0 +1,139 @@ +import { getSnapshot } from '@jbrowse/mobx-state-tree' +import ExtensionIcon from '@mui/icons-material/Extension' +import GetAppIcon from '@mui/icons-material/GetApp' +import PublishIcon from '@mui/icons-material/Publish' +import RedoIcon from '@mui/icons-material/Redo' +import StorageIcon from '@mui/icons-material/Storage' +import UndoIcon from '@mui/icons-material/Undo' +import { Cable } from '@jbrowse/core/ui/Icons' + +import type { MenuItem } from '@jbrowse/core/ui' +import type { SessionWithWidgets } from '@jbrowse/core/util' +import type { AbstractViewModel } from '@jbrowse/core/util' +import type { IAnyStateTreeNode } from '@jbrowse/mobx-state-tree' + +interface SessionWithViews { + views: AbstractViewModel[] +} + +interface HistoryManager { + canUndo: boolean + canRedo: boolean + undo: () => void + redo: () => void +} + +export function getOpenTrackMenuItem(): MenuItem { + return { + label: 'Open track...', + icon: StorageIcon, + onClick: (session: SessionWithWidgets) => { + if (session.views.length === 0) { + session.notify('Please open a view to add a track first') + } else if (session.views.length > 0) { + const widget = session.addWidget('AddTrackWidget', 'addTrackWidget', { + view: session.views[0]!.id, + }) + session.showWidget(widget) + if (session.views.length > 1) { + session.notify( + 'This will add a track to the first view. Note: if you want to open a track in a specific view open the track selector for that view and use the add track (plus icon) in the bottom right', + ) + } + } + }, + } +} + +export function getOpenConnectionMenuItem(): MenuItem { + return { + label: 'Open connection...', + icon: Cable, + onClick: (session: SessionWithWidgets) => { + const widget = session.addWidget( + 'AddConnectionWidget', + 'addConnectionWidget', + ) + session.showWidget(widget) + }, + } +} + +export function getUndoMenuItem( + getHistory: () => HistoryManager | undefined, +): MenuItem { + return { + label: 'Undo', + icon: UndoIcon, + onClick: () => { + const history = getHistory() + if (history?.canUndo) { + history.undo() + } + }, + } +} + +export function getRedoMenuItem( + getHistory: () => HistoryManager | undefined, +): MenuItem { + return { + label: 'Redo', + icon: RedoIcon, + onClick: () => { + const history = getHistory() + if (history?.canRedo) { + history.redo() + } + }, + } +} + +export function getPluginStoreMenuItem( + getSession: () => SessionWithWidgets | undefined, +): MenuItem { + return { + label: 'Plugin store', + icon: ExtensionIcon, + onClick: () => { + const session = getSession() + if (session) { + session.showWidget( + session.addWidget('PluginStoreWidget', 'pluginStoreWidget'), + ) + } + }, + } +} + +export function getImportSessionMenuItem(): MenuItem { + return { + label: 'Import session…', + icon: PublishIcon, + onClick: (session: SessionWithWidgets) => { + const widget = session.addWidget( + 'ImportSessionWidget', + 'importSessionWidget', + ) + session.showWidget(widget) + }, + } +} + +export function getExportSessionMenuItem(): MenuItem { + return { + label: 'Export session', + icon: GetAppIcon, + onClick: async (session: IAnyStateTreeNode) => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + const { saveAs } = await import('file-saver-es') + + saveAs( + new Blob([JSON.stringify({ session: getSnapshot(session) }, null, 2)], { + type: 'text/plain;charset=utf-8', + }), + 'session.json', + ) + }, + } +} diff --git a/packages/app-core/src/types.ts b/packages/app-core/src/types.ts new file mode 100644 index 0000000000..11d1bd0327 --- /dev/null +++ b/packages/app-core/src/types.ts @@ -0,0 +1,8 @@ +import type PluginManager from '@jbrowse/core/PluginManager' +import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' +import type { IAnyType } from '@jbrowse/mobx-state-tree' + +export type SessionModelFactory = (args: { + pluginManager: PluginManager + assemblyConfigSchema: BaseAssemblyConfigSchema +}) => IAnyType diff --git a/packages/app-core/src/util.ts b/packages/app-core/src/util.ts new file mode 100644 index 0000000000..9245c8a337 --- /dev/null +++ b/packages/app-core/src/util.ts @@ -0,0 +1,69 @@ +import { + getChildType, + getPropertyMembers, + isArrayType, + isMapType, + isModelType, + isReferenceType, + isValidReference, +} from '@jbrowse/mobx-state-tree' + +import type { + IAnyStateTreeNode, + IAnyType, + Instance, + types, +} from '@jbrowse/mobx-state-tree' + +type MSTArray = Instance> +type MSTMap = Instance> + +/** + * Attempts to remove undefined references from the given MST model. Can only + * actually remove them from arrays and maps. Throws MST undefined ref error if + * it encounters undefined refs in model properties. + */ +export function filterSessionInPlace(node: IAnyStateTreeNode, type: IAnyType) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (node === undefined) { + return + } + if (isArrayType(type)) { + const array = node as MSTArray + const childType = getChildType(node) + if (isReferenceType(childType)) { + // filter array elements + for (let i = 0; i < array.length; ) { + if (!isValidReference(() => array[i])) { + array.splice(i, 1) + } else { + i += 1 + } + } + } + for (const el of array) { + filterSessionInPlace(el, childType) + } + } else if (isMapType(type)) { + const map = node as MSTMap + const childType = getChildType(map) + if (isReferenceType(childType)) { + // filter the map members + for (const key in map.keys()) { + if (!isValidReference(() => map.get(key))) { + map.delete(key) + } + } + } + for (const child of map) { + filterSessionInPlace(child, childType) + } + } else if (isModelType(type)) { + // iterate over children + const { properties } = getPropertyMembers(node) + + for (const [pname, ptype] of Object.entries(properties)) { + filterSessionInPlace(node[pname], ptype) + } + } +} diff --git a/packages/product-core/src/RootModel/BaseRootModel.ts b/packages/product-core/src/RootModel/BaseRootModel.ts index 822b4459d3..f39f437d7f 100644 --- a/packages/product-core/src/RootModel/BaseRootModel.ts +++ b/packages/product-core/src/RootModel/BaseRootModel.ts @@ -1,9 +1,11 @@ +import { filterSessionInPlace } from '@jbrowse/app-core' import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import assemblyManagerFactory from '@jbrowse/core/assemblyManager' import RpcManager from '@jbrowse/core/rpc/RpcManager' import { cast, getSnapshot, + getType, isStateTreeNode, types, } from '@jbrowse/mobx-state-tree' @@ -12,6 +14,12 @@ import type PluginManager from '@jbrowse/core/PluginManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' import type { IAnyType, Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' +/** Minimal session snapshot shape for type checking */ +export interface BaseSessionSnapshot { + name: string + id?: string +} + /** * #stateModel BaseRootModel * #category root @@ -95,8 +103,19 @@ export function BaseRootModelFactory({ /** * #action */ - setSession(sessionSnapshot?: SnapshotIn) { + setSession(sessionSnapshot?: SnapshotIn & BaseSessionSnapshot) { + const oldSession = self.session self.session = cast(sessionSnapshot) + if (self.session) { + // validate all references in the session snapshot + try { + filterSessionInPlace(self.session, getType(self.session)) + } catch (error) { + // throws error if session filtering failed + self.session = oldSession + throw error + } + } }, /** * #action diff --git a/packages/product-core/src/RootModel/InternetAccounts.ts b/packages/product-core/src/RootModel/InternetAccounts.ts index 7aab7b2a94..5c69db2217 100644 --- a/packages/product-core/src/RootModel/InternetAccounts.ts +++ b/packages/product-core/src/RootModel/InternetAccounts.ts @@ -1,12 +1,17 @@ import { addDisposer, types } from '@jbrowse/mobx-state-tree' import { autorun } from 'mobx' -import type { BaseRootModel } from './BaseRootModel' import type PluginManager from '@jbrowse/core/PluginManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { UriLocation } from '@jbrowse/core/util' import type { Instance } from '@jbrowse/mobx-state-tree' +interface InternetAccountsMixinContext { + jbrowse: { + internetAccounts: AnyConfigurationModel[] + } +} + /** * #stateModel InternetAccountsMixin * #category root @@ -107,22 +112,24 @@ export function InternetAccountsRootModelMixin(pluginManager: PluginManager) { : null }, })) - .actions(self => ({ - afterCreate() { - addDisposer( - self, - autorun( - function internetAccountsAutorun() { - const { jbrowse } = self as typeof self & BaseRootModel - for (const internetAccount of jbrowse.internetAccounts) { - self.initializeInternetAccount(internetAccount) - } - }, - { name: 'InternetAccounts' }, - ), - ) - }, - })) + .actions(self => { + const ctx = self as typeof self & InternetAccountsMixinContext + return { + afterCreate() { + addDisposer( + self, + autorun( + function internetAccountsAutorun() { + for (const internetAccount of ctx.jbrowse.internetAccounts) { + self.initializeInternetAccount(internetAccount) + } + }, + { name: 'InternetAccounts' }, + ), + ) + }, + } + }) } export type RootModelWithInternetAccountsType = ReturnType< diff --git a/packages/product-core/src/Session/Connections.ts b/packages/product-core/src/Session/Connections.ts index 74c065522c..69f2cd920a 100644 --- a/packages/product-core/src/Session/Connections.ts +++ b/packages/product-core/src/Session/Connections.ts @@ -4,12 +4,21 @@ import { types } from '@jbrowse/mobx-state-tree' import { isBaseSession } from './BaseSession' import type { SessionWithReferenceManagementType } from './ReferenceManagement' -import type { BaseRootModelType } from '../RootModel/BaseRootModel' import type PluginManager from '@jbrowse/core/PluginManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' +interface ConnectionMixinJBrowse { + connections: BaseConnectionConfigModel[] + deleteConnectionConf: (conf: AnyConfigurationModel) => unknown + addConnectionConf: (conf: AnyConfigurationModel) => unknown +} + +interface ConnectionMixinContext { + jbrowse: ConnectionMixinJBrowse +} + /** * #stateModel ConnectionManagementSessionMixin */ @@ -23,104 +32,107 @@ export function ConnectionManagementSessionMixin(pluginManager: PluginManager) { pluginManager.pluggableMstType('connection', 'stateModel'), ), }) - .views(self => ({ - /** - * #getter - */ - get connections(): BaseConnectionConfigModel[] { - const { jbrowse } = self as typeof self & Instance - return jbrowse.connections - }, - })) - .actions(self => ({ - /** - * #action - */ - makeConnection( - configuration: AnyConfigurationModel, - initialSnapshot = {}, - ) { - const type = configuration.type as string - if (!type) { - throw new Error('track configuration has no `type` listed') - } - const name = readConfObject(configuration, 'name') - const connectionType = pluginManager.getConnectionType(type) - if (!connectionType) { - throw new Error(`unknown connection type ${type}`) - } - const length = self.connectionInstances.push({ - ...initialSnapshot, - name, - type, - configuration, - }) - return self.connectionInstances[length - 1] - }, + .views(self => { + const ctx = self as typeof self & ConnectionMixinContext + return { + /** + * #getter + */ + get connections(): BaseConnectionConfigModel[] { + return ctx.jbrowse.connections + }, + } + }) + .actions(self => { + const ctx = self as typeof self & ConnectionMixinContext + return { + /** + * #action + */ + makeConnection( + configuration: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const type = configuration.type as string + if (!type) { + throw new Error('track configuration has no `type` listed') + } + const name = readConfObject(configuration, 'name') + const connectionType = pluginManager.getConnectionType(type) + if (!connectionType) { + throw new Error(`unknown connection type ${type}`) + } + const length = self.connectionInstances.push({ + ...initialSnapshot, + name, + type, + configuration, + }) + return self.connectionInstances[length - 1] + }, - /** - * #action - */ - prepareToBreakConnection(configuration: AnyConfigurationModel) { - const root = self as typeof self & - Instance - const callbacksToDeref: (() => void)[] = [] - const derefTypeCount: Record = {} - const name = readConfObject(configuration, 'name') - const connection = self.connectionInstances.find(c => c.name === name) - if (!connection) { - return undefined - } - for (const track of connection.tracks) { - const ref = root.getReferring(track) - root.removeReferring(ref, track, callbacksToDeref, derefTypeCount) - } - return [ - () => { - for (const cb of callbacksToDeref) { - cb() - } - this.breakConnection(configuration) - }, - derefTypeCount, - ] - }, + /** + * #action + */ + prepareToBreakConnection(configuration: AnyConfigurationModel) { + const root = self as typeof self & + Instance + const callbacksToDeref: (() => void)[] = [] + const derefTypeCount: Record = {} + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + return undefined + } + for (const track of connection.tracks) { + const ref = root.getReferring(track) + root.removeReferring(ref, track, callbacksToDeref, derefTypeCount) + } + return [ + () => { + for (const cb of callbacksToDeref) { + cb() + } + this.breakConnection(configuration) + }, + derefTypeCount, + ] + }, - /** - * #action - */ - breakConnection(configuration: AnyConfigurationModel) { - const name = readConfObject(configuration, 'name') - const connection = self.connectionInstances.find(c => c.name === name) - if (!connection) { - throw new Error(`no connection found with name ${name}`) - } - self.connectionInstances.remove(connection) - }, + /** + * #action + */ + breakConnection(configuration: AnyConfigurationModel) { + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + throw new Error(`no connection found with name ${name}`) + } + self.connectionInstances.remove(connection) + }, - /** - * #action - */ - deleteConnection(configuration: AnyConfigurationModel) { - const { jbrowse } = self as typeof self & Instance - return jbrowse.deleteConnectionConf(configuration) - }, + /** + * #action + */ + deleteConnection(configuration: AnyConfigurationModel) { + return ctx.jbrowse.deleteConnectionConf(configuration) + }, - /** - * #action - */ - addConnectionConf(connectionConf: AnyConfigurationModel) { - const { jbrowse } = self as typeof self & Instance - return jbrowse.addConnectionConf(connectionConf) - }, + /** + * #action + */ + addConnectionConf(connectionConf: AnyConfigurationModel) { + return ctx.jbrowse.addConnectionConf(connectionConf) + }, - /** - * #action - */ - clearConnections() { - self.connectionInstances.clear() - }, - })) + /** + * #action + */ + clearConnections() { + self.connectionInstances.clear() + }, + } + }) } /** Session mixin MST type for a session that has connections */ diff --git a/packages/product-core/src/Session/Themes.ts b/packages/product-core/src/Session/Themes.ts index 98c6d2d17f..2bc1e6a8d1 100644 --- a/packages/product-core/src/Session/Themes.ts +++ b/packages/product-core/src/Session/Themes.ts @@ -1,16 +1,20 @@ -import { getConf } from '@jbrowse/core/configuration' +import { readConfObject } from '@jbrowse/core/configuration' import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' import { localStorageGetItem, localStorageSetItem } from '@jbrowse/core/util' import { addDisposer, types } from '@jbrowse/mobx-state-tree' import { autorun } from 'mobx' -import type { BaseSession } from './BaseSession' +import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type PluginManager from '@jbrowse/core/PluginManager' import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' import type { ThemeOptions } from '@mui/material' type ThemeMap = Record +interface ThemeMixinContext { + jbrowse: AnyConfigurationModel +} + /** * #stateModel ThemeManagerSessionMixin */ @@ -20,33 +24,34 @@ export function ThemeManagerSessionMixin(_pluginManager: PluginManager) { .volatile(() => ({ sessionThemeName: localStorageGetItem('themeName') || 'default', })) - .views(s => ({ - /** - * #method - */ - allThemes(): ThemeMap { - const self = s as typeof s & BaseSession - const extraThemes = getConf(self.jbrowse, 'extraThemes') - return { ...defaultThemes, ...extraThemes } - }, - /** - * #getter - */ - get themeName() { - const { sessionThemeName } = s - const all = this.allThemes() - return all[sessionThemeName] ? sessionThemeName : 'default' - }, - /** - * #getter - */ - get theme() { - const self = s as typeof s & BaseSession - const configTheme = getConf(self.jbrowse, 'theme') - const all = this.allThemes() - return createJBrowseTheme(configTheme, all, this.themeName) - }, - })) + .views(s => { + const self = s as typeof s & ThemeMixinContext + return { + /** + * #method + */ + allThemes(): ThemeMap { + const extraThemes = readConfObject(self.jbrowse, 'extraThemes') + return { ...defaultThemes, ...extraThemes } + }, + /** + * #getter + */ + get themeName() { + const { sessionThemeName } = s + const all = this.allThemes() + return all[sessionThemeName] ? sessionThemeName : 'default' + }, + /** + * #getter + */ + get theme() { + const configTheme = readConfObject(self.jbrowse, 'theme') + const all = this.allThemes() + return createJBrowseTheme(configTheme, all, this.themeName) + }, + } + }) .actions(self => ({ /** * #action diff --git a/packages/web-core/src/BaseWebSession/index.ts b/packages/web-core/src/BaseWebSession/index.ts deleted file mode 100644 index 970088e5e3..0000000000 --- a/packages/web-core/src/BaseWebSession/index.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { lazy } from 'react' - -import { - AppFocusMixin, - SessionAssembliesMixin, - TemporaryAssembliesMixin, -} from '@jbrowse/app-core' -import { getConf, readConfObject } from '@jbrowse/core/configuration' -import SnackbarModel from '@jbrowse/core/ui/SnackbarModel' -import { localStorageGetItem, localStorageSetItem } from '@jbrowse/core/util' -import { - addDisposer, - cast, - getParent, - getSnapshot, - types, -} from '@jbrowse/mobx-state-tree' -import { - DialogQueueSessionMixin, - DrawerWidgetSessionMixin, - MultipleViewsSessionMixin, - ReferenceManagementSessionMixin, - SessionTracksManagerSessionMixin, - ThemeManagerSessionMixin, -} from '@jbrowse/product-core' -import DeleteIcon from '@mui/icons-material/Delete' -import CopyIcon from '@mui/icons-material/FileCopy' -import InfoIcon from '@mui/icons-material/Info' -import SettingsIcon from '@mui/icons-material/Settings' -import { autorun } from 'mobx' - -import { WebSessionConnectionsMixin } from '../SessionConnections' - -import type { Menu } from '@jbrowse/app-core' -import type { PluginDefinition } from '@jbrowse/core/PluginLoader' -import type PluginManager from '@jbrowse/core/PluginManager' -import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' -import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' -import type { - AnyConfiguration, - AnyConfigurationModel, -} from '@jbrowse/core/configuration' -import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' -import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' -import type { AssemblyManager, JBrowsePlugin } from '@jbrowse/core/util/types' -import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' - -// lazies -const AboutDialog = lazy(() => import('./AboutDialog')) - -interface Display { - displayId: string -} - -/** - * #stateModel BaseWebSession - * used for "web based" products, including jbrowse-web and react-app - * composed of - * - [ReferenceManagementSessionMixin](../referencemanagementsessionmixin) - * - [DrawerWidgetSessionMixin](../drawerwidgetsessionmixin) - * - [DialogQueueSessionMixin](../dialogqueuesessionmixin) - * - [ThemeManagerSessionMixin](../thememanagersessionmixin) - * - [MultipleViewsSessionMixin](../multipleviewssessionmixin) - * - [SessionTracksManagerSessionMixin](../sessiontracksmanagersessionmixin) - * - [SessionAssembliesMixin](../sessionassembliesmixin) - * - [TemporaryAssembliesMixin](../temporaryassembliesmixin) - * - [WebSessionConnectionsMixin](../websessionconnectionsmixin) - * - [AppFocusMixin](../appfocusmixin) - */ -export function BaseWebSession({ - pluginManager, - assemblyConfigSchema, -}: { - pluginManager: PluginManager - assemblyConfigSchema: BaseAssemblyConfigSchema -}) { - const sessionModel = types - .compose( - 'WebCoreSessionModel', - types.compose( - 'WebCoreSessionModelGroupA', - ReferenceManagementSessionMixin(pluginManager), - DrawerWidgetSessionMixin(pluginManager), - DialogQueueSessionMixin(pluginManager), - ThemeManagerSessionMixin(pluginManager), - MultipleViewsSessionMixin(pluginManager), - ), - types.compose( - 'WebCoreSessionModelGroupB', - SessionTracksManagerSessionMixin(pluginManager), - SessionAssembliesMixin(pluginManager, assemblyConfigSchema), - TemporaryAssembliesMixin(pluginManager, assemblyConfigSchema), - WebSessionConnectionsMixin(pluginManager), - AppFocusMixin(), - SnackbarModel(), - ), - ) - .props({ - /** - * #property - */ - margin: 0, - /** - * #property - */ - sessionPlugins: types.array(types.frozen()), - }) - .volatile((/* self */) => ({ - /** - * #volatile - */ - sessionThemeName: localStorageGetItem('themeName') || 'default', - /** - * #volatile - * this is the current "task" that is being performed in the UI. this is - * usually an object of the form - * - * `{ taskName: "configure", target: thing_being_configured }` - */ - task: undefined, - })) - .views(self => ({ - /** - * #getter - */ - get tracks(): AnyConfigurationModel[] { - return [...self.sessionTracks, ...self.jbrowse.tracks] - }, - /** - * #getter - */ - get root() { - return getParent(self) - }, - /** - * #getter - * list of sessionAssemblies and jbrowse config assemblies, does not - * include temporaryAssemblies. basically the list to be displayed in a - * AssemblySelector dropdown - */ - get assemblies(): Instance { - return [...self.jbrowse.assemblies, ...self.sessionAssemblies] - }, - /** - * #getter - * list of config connections and session connections - */ - get connections(): BaseConnectionConfigModel[] { - return [...self.jbrowse.connections, ...self.sessionConnections] - }, - })) - .actions(self => ({ - /** - * #action - */ - setName(str: string) { - self.name = str - }, - })) - - .views(self => ({ - /** - * #getter - * list of sessionAssemblies and jbrowse config assemblies, does not - * include temporaryAssemblies. basically the list to be displayed in a - * AssemblySelector dropdown - */ - get assemblyNames() { - return self.assemblies.map(f => readConfObject(f, 'name') as string) - }, - /** - * #getter - */ - get version() { - return self.root.version - }, - /** - * #getter - */ - get shareURL() { - return getConf(self.jbrowse, 'shareURL') - }, - /** - * #getter - */ - get textSearchManager(): TextSearchManager { - return self.root.textSearchManager - }, - /** - * #getter - */ - get assemblyManager(): AssemblyManager { - return self.root.assemblyManager - }, - /** - * #getter - */ - get savedSessionMetadata() { - return self.root.savedSessionMetadata - }, - /** - * #getter - */ - get previousAutosaveId() { - return self.root.previousAutosaveId - }, - - /** - * #getter - */ - get history() { - return self.root.history - }, - - /** - * #method - */ - renderProps() { - return { - theme: self.theme, - highResolutionScaling: getConf(self, 'highResolutionScaling'), - } - }, - })) - .actions(self => ({ - /** - * #action - */ - addAssemblyConf(conf: AnyConfiguration) { - self.jbrowse.addAssemblyConf(conf) - }, - /** - * #action - */ - addSessionPlugin(plugin: JBrowsePlugin) { - if (self.sessionPlugins.some(p => p.name === plugin.name)) { - throw new Error('session plugin cannot be installed twice') - } - self.sessionPlugins.push(plugin) - self.root.setPluginsUpdated(true) - }, - - /** - * #action - */ - removeSessionPlugin(pluginDefinition: PluginDefinition) { - self.sessionPlugins = cast( - self.sessionPlugins.filter( - plugin => - // @ts-expect-error - plugin.url !== pluginDefinition.url || - // @ts-expect-error - plugin.umdUrl !== pluginDefinition.umdUrl || - // @ts-expect-error - plugin.cjsUrl !== pluginDefinition.cjsUrl || - // @ts-expect-error - plugin.esmUrl !== pluginDefinition.esmUrl, - ), - ) - getParent(self).setPluginsUpdated(true) - }, - - /** - * #action - */ - addSavedSession(sessionSnapshot: SnapshotIn) { - return self.root.addSavedSession(sessionSnapshot) - }, - - /** - * #action - */ - deleteSavedSession(id: string) { - return self.root.deleteSavedSession(id) - }, - - /** - * #action - */ - favoriteSavedSession(id: string) { - return self.root.favoriteSavedSession(id) - }, - - /** - * #action - */ - unfavoriteSavedSession(id: string) { - return self.root.unfavoriteSavedSession(id) - }, - /** - * #action - */ - renameCurrentSession(sessionName: string) { - return self.root.renameCurrentSession(sessionName) - }, - - /** - * #action - */ - duplicateCurrentSession() { - return self.root.duplicateCurrentSession() - }, - /** - * #action - */ - activateSession(sessionName: string) { - return self.root.activateSession(sessionName) - }, - - /** - * #action - */ - setDefaultSession() { - return self.root.setDefaultSession() - }, - - /** - * #action - */ - saveSessionToLocalStorage() { - return self.root.saveSessionToLocalStorage() - }, - - /** - * #action - */ - loadAutosaveSession() { - return self.root.loadAutosaveSession() - }, - - /** - * #action - */ - setSession(sessionSnapshot: SnapshotIn) { - return self.root.setSession(sessionSnapshot) - }, - })) - .actions(self => ({ - /** - * #action - */ - editTrackConfiguration(configuration: AnyConfigurationModel) { - const { adminMode, sessionTracks } = self - if (!adminMode && !sessionTracks.includes(configuration)) { - throw new Error("Can't edit the configuration of a non-session track") - } - self.editConfiguration(configuration) - }, - })) - .views(self => ({ - /** - * #method - */ - getTrackActionMenuItems(config: BaseTrackConfig) { - const { adminMode, sessionTracks } = self - const canEdit = - adminMode || sessionTracks.find(t => t.trackId === config.trackId) - - // disable if it is a reference sequence track - const isRefSeq = config.type === 'ReferenceSequenceTrack' - return [ - { - label: 'About track', - priority: 1002, - onClick: () => { - self.queueDialog(handleClose => [ - AboutDialog, - { - config, - handleClose, - }, - ]) - }, - icon: InfoIcon, - }, - { - label: 'Settings', - priority: 1001, - disabled: !canEdit, - icon: SettingsIcon, - onClick: () => { - self.editTrackConfiguration(config) - }, - }, - { - label: 'Delete track', - priority: 1000, - disabled: !canEdit || isRefSeq, - icon: DeleteIcon, - onClick: () => { - self.deleteTrackConf(config) - }, - }, - { - label: 'Copy track', - priority: 999, - disabled: isRefSeq, - onClick: () => { - const snap = structuredClone(getSnapshot(config)) as { - [key: string]: unknown - displays: Display[] - } - const now = Date.now() - snap.trackId += `-${now}` - for (const display of snap.displays) { - display.displayId += `-${now}` - } - // the -sessionTrack suffix to trackId is used as metadata for - // the track selector to store the track in a special category, - // and default category is also cleared - if (!self.adminMode) { - snap.trackId += '-sessionTrack' - snap.category = undefined - } - snap.name += ' (copy)' - self.addTrackConf(snap) - }, - icon: CopyIcon, - }, - { type: 'divider' }, - ] - }, - - /** - * #method - */ - menus(): Menu[] { - return self.root.menus() - }, - })) - .actions(self => ({ - afterAttach() { - addDisposer( - self, - autorun( - function sessionLocalStorageAutorun() { - localStorageSetItem('drawerPosition', self.drawerPosition) - localStorageSetItem('themeName', self.themeName) - }, - { name: 'SessionLocalStorage' }, - ), - ) - }, - })) - - const extendedSessionModel = pluginManager.evaluateExtensionPoint( - 'Core-extendSession', - sessionModel, - ) as typeof sessionModel - - return types.snapshotProcessor(extendedSessionModel, { - // @ts-expect-error - preProcessor(snapshot) { - // @ts-expect-error - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const { connectionInstances, ...rest } = snapshot || {} - - // connectionInstances schema changed from object to an array, so any old - // connectionInstances as object is in snapshot, filter it out - // xref https://github.com/GMOD/jbrowse-components/issues/1903 - return !Array.isArray(connectionInstances) ? rest : snapshot - }, - }) -} diff --git a/packages/web-core/src/BaseWebSession/index.tsx b/packages/web-core/src/BaseWebSession/index.tsx new file mode 100644 index 0000000000..241f159287 --- /dev/null +++ b/packages/web-core/src/BaseWebSession/index.tsx @@ -0,0 +1,954 @@ +import { lazy } from 'react' + +import { + getConf, + isConfigurationModel, + readConfObject, +} from '@jbrowse/core/configuration' +import { + getContainingView, + isSessionModelWithWidgets, + localStorageGetBoolean, + localStorageGetItem, + localStorageSetBoolean, + localStorageSetItem, +} from '@jbrowse/core/util' +import { ElementId } from '@jbrowse/core/util/types/mst' +import { + addDisposer, + cast, + getMembers, + getParent, + getSnapshot, + getType, + isAlive, + isModelType, + isReferenceType, + types, + walk, +} from '@jbrowse/mobx-state-tree' +import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' +import { autorun, observable } from 'mobx' +import DeleteIcon from '@mui/icons-material/Delete' +import CopyIcon from '@mui/icons-material/FileCopy' +import InfoIcon from '@mui/icons-material/Info' +import SettingsIcon from '@mui/icons-material/Settings' +import Report from '@mui/icons-material/Report' + +import type { Menu } from '@jbrowse/app-core' +import type { PluginDefinition } from '@jbrowse/core/PluginLoader' +import type PluginManager from '@jbrowse/core/PluginManager' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' +import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' +import type { + AnyConfiguration, + AnyConfigurationModel, +} from '@jbrowse/core/configuration' +import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes' +import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type { + AssemblyManager, + DialogComponentType, + JBrowsePlugin, + NotificationLevel, + SnackAction, + TrackViewModel, +} from '@jbrowse/core/util/types' +import type { + IAnyStateTreeNode, + Instance, + SnapshotIn, +} from '@jbrowse/mobx-state-tree' +import type { ThemeOptions } from '@mui/material' + +// lazies +const AboutDialog = lazy(() => import('./AboutDialog')) +const ErrorMessageStackTraceDialog = lazy( + () => import('@jbrowse/core/ui/ErrorMessageStackTraceDialog'), +) + +const minDrawerWidth = 128 + +export type ThemeMap = Record + +export interface Display { + displayId: string +} + +export interface ReferringNode { + node: IAnyStateTreeNode + key: string +} + +export interface SnackbarMessage { + message: string + level?: NotificationLevel + action?: SnackAction +} + +export interface WebRootModel { + version: string + textSearchManager: TextSearchManager + assemblyManager: AssemblyManager + savedSessionMetadata: unknown + previousAutosaveId: string | undefined + history: unknown + setPluginsUpdated: (flag: boolean) => void + addSavedSession: (session: unknown) => unknown + deleteSavedSession: (id: string) => unknown + favoriteSavedSession: (id: string) => unknown + unfavoriteSavedSession: (id: string) => unknown + renameCurrentSession: (name: string) => unknown + duplicateCurrentSession: () => unknown + activateSession: (name: string) => unknown + setDefaultSession: () => unknown + saveSessionToLocalStorage: () => unknown + loadAutosaveSession: () => unknown + setSession: (session: unknown) => unknown + menus: () => Menu[] +} + +/** + * #stateModel BaseWebSession + * used for "web based" products, including jbrowse-web and react-app + * + * Consolidated from the following mixins: + * - ReferenceManagementSessionMixin + * - DrawerWidgetSessionMixin + * - DialogQueueSessionMixin + * - ThemeManagerSessionMixin + * - MultipleViewsSessionMixin + * - SessionTracksManagerSessionMixin + * - SessionAssembliesMixin + * - TemporaryAssembliesMixin + * - WebSessionConnectionsMixin (ConnectionManagementSessionMixin + session connections) + * - AppFocusMixin + * - SnackbarModel + */ +export function BaseWebSession({ + pluginManager, + assemblyConfigSchema, +}: { + pluginManager: PluginManager + assemblyConfigSchema: BaseAssemblyConfigSchema +}) { + const widgetStateModelType = pluginManager.pluggableMstType( + 'widget', + 'stateModel', + ) + type WidgetStateModel = Instance + + const sessionModel = types + .model('WebCoreSessionModel', { + // BaseSessionModel props + id: ElementId, + name: types.string, + margin: 0, + + // DrawerWidgetSessionMixin props + drawerPosition: types.optional( + types.string, + () => localStorageGetItem('drawerPosition') || 'right', + ), + drawerWidth: types.optional( + types.refinement(types.integer, width => width >= minDrawerWidth), + 384, + ), + widgets: types.map(widgetStateModelType), + activeWidgets: types.map(types.safeReference(widgetStateModelType)), + minimized: types.optional(types.boolean, false), + + // MultipleViewsSessionMixin props + views: types.array(pluginManager.pluggableMstType('view', 'stateModel')), + stickyViewHeaders: types.optional(types.boolean, () => + localStorageGetBoolean('stickyViewHeaders', true), + ), + + // ConnectionManagementSessionMixin props + connectionInstances: types.array( + pluginManager.pluggableMstType('connection', 'stateModel'), + ), + + // SessionTracksManagerSessionMixin props + sessionTracks: types.array( + pluginManager.pluggableConfigSchemaType('track'), + ), + + // SessionAssembliesMixin props + sessionAssemblies: types.array(assemblyConfigSchema), + + // TemporaryAssembliesMixin props + temporaryAssemblies: types.array(assemblyConfigSchema), + + // WebSessionConnectionsMixin props + sessionConnections: types.array( + pluginManager.pluggableConfigSchemaType('connection'), + ), + + // AppFocusMixin props + focusedViewId: types.maybe(types.string), + + // BaseWebSession props + sessionPlugins: types.array(types.frozen()), + }) + .volatile(() => ({ + // BaseSessionModel volatiles + selection: undefined as unknown, + hovered: undefined as unknown, + + // DialogQueueSessionMixin volatile + queueOfDialogs: [] as [DialogComponentType, unknown][], + + // ThemeManagerSessionMixin volatile + sessionThemeName: localStorageGetItem('themeName') || 'default', + + // SnackbarModel volatile + snackbarMessages: observable.array(), + + // Task volatile + task: undefined, + })) + .views(self => ({ + get root(): WebRootModel { + return getParent(self) + }, + get jbrowse() { + return getParent<{ jbrowse: AnyConfigurationModel }>(self).jbrowse + }, + get rpcManager(): RpcManager { + return getParent<{ rpcManager: RpcManager }>(self).rpcManager + }, + get configuration() { + return this.jbrowse.configuration + }, + get adminMode() { + return getParent<{ adminMode: boolean }>(self).adminMode + }, + })) + .views(self => ({ + // Tracks views + get tracks(): AnyConfigurationModel[] { + return [...self.sessionTracks, ...self.jbrowse.tracks] + }, + + // Assemblies views + get assemblies(): Instance[] { + return [...self.jbrowse.assemblies, ...self.sessionAssemblies] + }, + + // Connections views + get connections(): BaseConnectionConfigModel[] { + return [...self.jbrowse.connections, ...self.sessionConnections] + }, + + // DrawerWidgetSessionMixin views + get visibleWidget() { + if (isAlive(self)) { + return [...self.activeWidgets.values()][self.activeWidgets.size - 1] + } + return undefined + }, + + // DialogQueueSessionMixin views + get DialogComponent() { + return self.queueOfDialogs[0]?.[0] + }, + get DialogProps() { + return self.queueOfDialogs[0]?.[1] + }, + + // ThemeManagerSessionMixin views + allThemes(): ThemeMap { + const extraThemes = readConfObject(self.jbrowse, 'extraThemes') + return { ...defaultThemes, ...extraThemes } + }, + get themeName() { + const { sessionThemeName } = self + const all = this.allThemes() + return all[sessionThemeName] ? sessionThemeName : 'default' + }, + get theme() { + const configTheme = readConfObject(self.jbrowse, 'theme') + const all = this.allThemes() + return createJBrowseTheme(configTheme, all, this.themeName) + }, + + // SnackbarModel views + get snackbarMessageSet() { + return new Map(self.snackbarMessages.map(s => [s.message, s])) + }, + + // ReferenceManagementSessionMixin views + getReferring(object: IAnyStateTreeNode) { + const refs: ReferringNode[] = [] + walk(getParent(self), node => { + if (isModelType(getType(node))) { + const members = getMembers(node) + for (const [key, value] of Object.entries(members.properties)) { + if (isReferenceType(value) && node[key] === object) { + refs.push({ node, key }) + } + } + } + }) + return refs + }, + })) + .views(self => ({ + get assemblyNames() { + return self.assemblies.map(f => readConfObject(f, 'name') as string) + }, + get version() { + return self.root.version + }, + get shareURL() { + return readConfObject(self.jbrowse, 'shareURL') + }, + get textSearchManager(): TextSearchManager { + return self.root.textSearchManager + }, + get assemblyManager(): AssemblyManager { + return self.root.assemblyManager + }, + get savedSessionMetadata() { + return self.root.savedSessionMetadata + }, + get previousAutosaveId() { + return self.root.previousAutosaveId + }, + get history() { + return self.root.history + }, + + renderProps() { + return { + theme: self.theme, + highResolutionScaling: getConf(self, 'highResolutionScaling'), + } + }, + })) + .actions(self => ({ + // BaseSessionModel actions + setSelection(thing: unknown) { + self.selection = thing + }, + clearSelection() { + self.selection = undefined + }, + setHovered(thing: unknown) { + self.hovered = thing + }, + + setName(str: string) { + self.name = str + }, + + // DialogQueueSessionMixin actions + removeActiveDialog() { + self.queueOfDialogs = self.queueOfDialogs.slice(1) + }, + queueDialog( + cb: (doneCallback: () => void) => [DialogComponentType, unknown], + ) { + const [component, props] = cb(() => { + this.removeActiveDialog() + }) + self.queueOfDialogs = [...self.queueOfDialogs, [component, props]] + }, + + // ThemeManagerSessionMixin actions + setThemeName(name: string) { + self.sessionThemeName = name + }, + + // DrawerWidgetSessionMixin actions + setDrawerPosition(arg: string) { + self.drawerPosition = arg + localStorage.setItem('drawerPosition', arg) + }, + updateDrawerWidth(drawerWidth: number) { + if (drawerWidth === self.drawerWidth) { + return self.drawerWidth + } + let newDrawerWidth = drawerWidth + if (newDrawerWidth < minDrawerWidth) { + newDrawerWidth = minDrawerWidth + } + self.drawerWidth = newDrawerWidth + return newDrawerWidth + }, + resizeDrawer(distance: number) { + if (self.drawerPosition === 'left') { + distance *= -1 + } + const oldDrawerWidth = self.drawerWidth + const newDrawerWidth = this.updateDrawerWidth(oldDrawerWidth - distance) + return oldDrawerWidth - newDrawerWidth + }, + addWidget( + typeName: string, + id: string, + initialState = {}, + conf?: unknown, + ) { + const typeDefinition = pluginManager.getElementType('widget', typeName) + if (!typeDefinition) { + throw new Error(`unknown widget type ${typeName}`) + } + const data = { + ...initialState, + id, + type: typeName, + configuration: conf || { type: typeName }, + } + self.widgets.set(id, data) + return self.widgets.get(id) + }, + showWidget(widget: WidgetStateModel) { + if (self.activeWidgets.has(widget.id)) { + self.activeWidgets.delete(widget.id) + } + self.activeWidgets.set(widget.id, widget) + self.minimized = false + }, + hasWidget(widget: WidgetStateModel) { + return self.activeWidgets.has(widget.id) + }, + hideWidget(widget: WidgetStateModel) { + self.activeWidgets.delete(widget.id) + }, + minimizeWidgetDrawer() { + self.minimized = true + }, + showWidgetDrawer() { + self.minimized = false + }, + hideAllWidgets() { + self.activeWidgets.clear() + }, + editConfiguration(configuration: AnyConfigurationModel) { + if (!isConfigurationModel(configuration)) { + throw new Error( + 'must pass a configuration model to editConfiguration', + ) + } + const editor = this.addWidget( + 'ConfigurationEditorWidget', + 'configEditor', + { target: configuration }, + ) + this.showWidget(editor) + }, + + // MultipleViewsSessionMixin actions + moveViewDown(id: string) { + const idx = self.views.findIndex(v => v.id === id) + if (idx !== -1 && idx < self.views.length - 1) { + self.views.splice(idx, 2, self.views[idx + 1], self.views[idx]) + } + }, + moveViewUp(id: string) { + const idx = self.views.findIndex(view => view.id === id) + if (idx > 0) { + self.views.splice(idx - 1, 2, self.views[idx], self.views[idx - 1]) + } + }, + moveViewToTop(id: string) { + const idx = self.views.findIndex(view => view.id === id) + self.views = cast([ + self.views[idx], + ...self.views.filter(view => view.id !== id), + ]) + }, + moveViewToBottom(id: string) { + const idx = self.views.findIndex(view => view.id === id) + self.views = cast([ + ...self.views.filter(view => view.id !== id), + self.views[idx], + ]) + }, + addView(typeName: string, initialState = {}) { + const typeDefinition = pluginManager.getElementType('view', typeName) + if (!typeDefinition) { + throw new Error(`unknown view type ${typeName}`) + } + const length = self.views.push({ + ...initialState, + type: typeName, + }) + return self.views[length - 1] + }, + removeView(view: IBaseViewModel) { + for (const [, widget] of self.activeWidgets) { + if (widget.view && widget.view.id === view.id) { + this.hideWidget(widget) + } + } + self.views.remove(view) + }, + setStickyViewHeaders(sticky: boolean) { + self.stickyViewHeaders = sticky + }, + + // ReferenceManagementSessionMixin actions + removeReferring( + referring: ReferringNode[], + track: BaseTrackConfig, + callbacks: (() => void)[], + dereferenceTypeCount: Record, + ) { + for (const { node } of referring) { + let dereferenced = false + try { + const type = 'open track(s)' + const view = getContainingView(node) as TrackViewModel + callbacks.push(() => { + view.hideTrack(track.trackId) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } catch { + // ignore + } + + if (isSessionModelWithWidgets(self) && self.widgets.has(node.id)) { + const type = 'configuration editor widget(s)' + callbacks.push(() => { + this.hideWidget(node) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } + if (!dereferenced) { + throw new Error( + `Error when closing this connection, the following node is still referring to a track configuration: ${JSON.stringify( + getSnapshot(node), + )}`, + ) + } + } + }, + + // ConnectionManagementSessionMixin actions + makeConnection( + configuration: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const type = configuration.type as string + if (!type) { + throw new Error('track configuration has no `type` listed') + } + const name = readConfObject(configuration, 'name') + const connectionType = pluginManager.getConnectionType(type) + if (!connectionType) { + throw new Error(`unknown connection type ${type}`) + } + const length = self.connectionInstances.push({ + ...initialSnapshot, + name, + type, + configuration, + }) + return self.connectionInstances[length - 1] + }, + prepareToBreakConnection(configuration: AnyConfigurationModel) { + const callbacksToDeref: (() => void)[] = [] + const derefTypeCount: Record = {} + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + return undefined + } + for (const track of connection.tracks) { + const ref = self.getReferring(track) + this.removeReferring(ref, track, callbacksToDeref, derefTypeCount) + } + return [ + () => { + for (const cb of callbacksToDeref) { + cb() + } + this.breakConnection(configuration) + }, + derefTypeCount, + ] + }, + breakConnection(configuration: AnyConfigurationModel) { + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + throw new Error(`no connection found with name ${name}`) + } + self.connectionInstances.remove(connection) + }, + clearConnections() { + self.connectionInstances.clear() + }, + + // WebSessionConnectionsMixin actions (extends ConnectionManagementSessionMixin) + addConnectionConf(connectionConf: BaseConnectionConfigModel) { + if (self.adminMode) { + return self.jbrowse.addConnectionConf(connectionConf) + } else { + const { connectionId, type } = connectionConf + if (!type) { + throw new Error(`unknown connection type "${type}"`) + } + const connection = self.sessionConnections.find( + c => c.connectionId === connectionId, + ) + if (connection) { + return connection + } else { + const length = self.sessionConnections.push(connectionConf) + return self.sessionConnections[length - 1] + } + } + }, + deleteConnection(configuration: AnyConfigurationModel) { + if (self.adminMode) { + return self.jbrowse.deleteConnectionConf(configuration) + } else { + const { connectionId } = configuration + const idx = self.sessionConnections.findIndex( + c => c.connectionId === connectionId, + ) + return idx === -1 + ? undefined + : self.sessionConnections.splice(idx, 1) + } + }, + + // TracksManagerSessionMixin / SessionTracksManagerSessionMixin actions + addTrackConf(trackConf: AnyConfiguration) { + if (self.adminMode) { + return self.jbrowse.addTrackConf(trackConf) + } + const { trackId, type } = trackConf as { + type: string + trackId: string + } + if (!type) { + throw new Error(`unknown track type ${type}`) + } + const track = self.sessionTracks.find(t => t.trackId === trackId) + if (track) { + return track + } + const length = self.sessionTracks.push(trackConf) + return self.sessionTracks[length - 1] + }, + deleteTrackConf(trackConf: AnyConfigurationModel) { + const callbacksToDereferenceTrack: (() => void)[] = [] + const dereferenceTypeCount: Record = {} + const referring = self.getReferring(trackConf) + this.removeReferring( + referring, + trackConf, + callbacksToDereferenceTrack, + dereferenceTypeCount, + ) + for (const cb of callbacksToDereferenceTrack) { + cb() + } + + // try to delete from jbrowse config if in admin mode + if (self.adminMode) { + const found = self.jbrowse.deleteTrackConf(trackConf) + if (found) { + return found + } + } + + // try to delete from sessionTracks + const { trackId } = trackConf + const idx = self.sessionTracks.findIndex(t => t.trackId === trackId) + if (idx === -1) { + return undefined + } + return self.sessionTracks.splice(idx, 1) + }, + + // SessionAssembliesMixin actions + addSessionAssembly(conf: AnyConfiguration) { + const asm = self.sessionAssemblies.find(f => f.name === conf.name) + if (asm) { + console.warn(`Assembly ${conf.name} already exists`) + return asm + } + const length = self.sessionAssemblies.push(conf) + return self.sessionAssemblies[length - 1] + }, + addAssembly(conf: AnyConfiguration) { + if (self.adminMode) { + self.jbrowse.addAssemblyConf(conf) + } else { + this.addSessionAssembly(conf) + } + }, + removeAssembly(name: string) { + if (self.adminMode) { + self.jbrowse.removeAssemblyConf(name) + } else { + this.removeSessionAssembly(name) + } + }, + removeSessionAssembly(assemblyName: string) { + const elt = self.sessionAssemblies.find(a => a.name === assemblyName) + if (elt) { + self.sessionAssemblies.remove(elt) + } + }, + + // TemporaryAssembliesMixin actions + addTemporaryAssembly(conf: AnyConfiguration) { + const asm = self.temporaryAssemblies.find(f => f.name === conf.name) + if (asm) { + console.warn(`Assembly ${conf.name} was already existing`) + return asm + } + const length = self.temporaryAssemblies.push(conf) + return self.temporaryAssemblies[length - 1] + }, + removeTemporaryAssembly(name: string) { + const elt = self.temporaryAssemblies.find(a => a.name === name) + if (elt) { + self.temporaryAssemblies.remove(elt) + } + }, + + // AppFocusMixin actions + setFocusedViewId(viewId: string) { + self.focusedViewId = viewId + }, + + // SnackbarModel actions + notify(message: string, level?: NotificationLevel, action?: SnackAction) { + this.pushSnackbarMessage(message, level, action) + if (level === 'info' || level === 'success') { + setTimeout(() => { + this.removeSnackbarMessage(message) + }, 5000) + } + }, + notifyError(errorMessage: string, error?: unknown, extra?: unknown) { + this.notify(errorMessage, 'error', { + name: , + onClick: () => { + this.queueDialog((onClose: () => void) => [ + ErrorMessageStackTraceDialog, + { onClose, error, extra }, + ]) + }, + }) + }, + pushSnackbarMessage( + message: string, + level?: NotificationLevel, + action?: SnackAction, + ) { + if (action || !self.snackbarMessageSet.has(message)) { + self.snackbarMessages.push({ message, level, action }) + } + }, + popSnackbarMessage() { + return self.snackbarMessages.pop() + }, + removeSnackbarMessage(message: string) { + const element = self.snackbarMessageSet.get(message) + if (element !== undefined) { + self.snackbarMessages.remove(element) + } + }, + + // BaseWebSession actions + addAssemblyConf(conf: AnyConfiguration) { + self.jbrowse.addAssemblyConf(conf) + }, + addSessionPlugin(plugin: JBrowsePlugin) { + if (self.sessionPlugins.some(p => p.name === plugin.name)) { + throw new Error('session plugin cannot be installed twice') + } + self.sessionPlugins.push(plugin) + self.root.setPluginsUpdated(true) + }, + removeSessionPlugin(pluginDefinition: PluginDefinition) { + self.sessionPlugins = cast( + self.sessionPlugins.filter( + plugin => + // @ts-expect-error + plugin.url !== pluginDefinition.url || + // @ts-expect-error + plugin.umdUrl !== pluginDefinition.umdUrl || + // @ts-expect-error + plugin.cjsUrl !== pluginDefinition.cjsUrl || + // @ts-expect-error + plugin.esmUrl !== pluginDefinition.esmUrl, + ), + ) + getParent<{ setPluginsUpdated: (flag: boolean) => void }>( + self, + ).setPluginsUpdated(true) + }, + addSavedSession(sessionSnapshot: SnapshotIn) { + return self.root.addSavedSession(sessionSnapshot) + }, + deleteSavedSession(id: string) { + return self.root.deleteSavedSession(id) + }, + favoriteSavedSession(id: string) { + return self.root.favoriteSavedSession(id) + }, + unfavoriteSavedSession(id: string) { + return self.root.unfavoriteSavedSession(id) + }, + renameCurrentSession(sessionName: string) { + return self.root.renameCurrentSession(sessionName) + }, + duplicateCurrentSession() { + return self.root.duplicateCurrentSession() + }, + activateSession(sessionName: string) { + return self.root.activateSession(sessionName) + }, + setDefaultSession() { + return self.root.setDefaultSession() + }, + saveSessionToLocalStorage() { + return self.root.saveSessionToLocalStorage() + }, + loadAutosaveSession() { + return self.root.loadAutosaveSession() + }, + setSession(sessionSnapshot: SnapshotIn) { + return self.root.setSession(sessionSnapshot) + }, + })) + .actions(self => ({ + editTrackConfiguration(configuration: AnyConfigurationModel) { + const { adminMode, sessionTracks } = self + if (!adminMode && !sessionTracks.includes(configuration)) { + throw new Error("Can't edit the configuration of a non-session track") + } + self.editConfiguration(configuration) + }, + })) + .views(self => ({ + getTrackActionMenuItems(config: BaseTrackConfig) { + const { adminMode, sessionTracks } = self + const canEdit = + adminMode || sessionTracks.find(t => t.trackId === config.trackId) + + // disable if it is a reference sequence track + const isRefSeq = config.type === 'ReferenceSequenceTrack' + return [ + { + label: 'About track', + priority: 1002, + onClick: () => { + self.queueDialog(handleClose => [ + AboutDialog, + { + config, + handleClose, + }, + ]) + }, + icon: InfoIcon, + }, + { + label: 'Settings', + priority: 1001, + disabled: !canEdit, + icon: SettingsIcon, + onClick: () => { + self.editTrackConfiguration(config) + }, + }, + { + label: 'Delete track', + priority: 1000, + disabled: !canEdit || isRefSeq, + icon: DeleteIcon, + onClick: () => { + self.deleteTrackConf(config) + }, + }, + { + label: 'Copy track', + priority: 999, + disabled: isRefSeq, + onClick: () => { + const snap = structuredClone(getSnapshot(config)) as { + [key: string]: unknown + displays: Display[] + } + const now = Date.now() + snap.trackId += `-${now}` + for (const display of snap.displays) { + display.displayId += `-${now}` + } + // the -sessionTrack suffix to trackId is used as metadata for + // the track selector to store the track in a special category, + // and default category is also cleared + if (!self.adminMode) { + snap.trackId += '-sessionTrack' + snap.category = undefined + } + snap.name += ' (copy)' + self.addTrackConf(snap) + }, + icon: CopyIcon, + }, + { type: 'divider' }, + ] + }, + + menus(): Menu[] { + return self.root.menus() + }, + })) + .actions(self => ({ + afterAttach() { + addDisposer( + self, + autorun( + function sessionLocalStorageAutorun() { + localStorageSetItem('drawerPosition', self.drawerPosition) + localStorageSetItem('themeName', self.themeName) + localStorageSetBoolean('stickyViewHeaders', self.stickyViewHeaders) + }, + { name: 'SessionLocalStorage' }, + ), + ) + }, + })) + + const extendedSessionModel = pluginManager.evaluateExtensionPoint( + 'Core-extendSession', + sessionModel, + ) as typeof sessionModel + + return types.snapshotProcessor(extendedSessionModel, { + // @ts-expect-error + preProcessor(snapshot) { + // @ts-expect-error + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const { connectionInstances, ...rest } = snapshot || {} + + // connectionInstances schema changed from object to an array, so any old + // connectionInstances as object is in snapshot, filter it out + // xref https://github.com/GMOD/jbrowse-components/issues/1903 + return !Array.isArray(connectionInstances) ? rest : snapshot + }, + }) +} diff --git a/packages/web-core/src/SessionConnections.ts b/packages/web-core/src/SessionConnections.ts deleted file mode 100644 index a3d0bac03b..0000000000 --- a/packages/web-core/src/SessionConnections.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' -import { ConnectionManagementSessionMixin } from '@jbrowse/product-core' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { AnyConfigurationModel } from '@jbrowse/core/configuration' -import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' -import type { - BaseSession, - SessionWithSessionTracks, -} from '@jbrowse/product-core' - -/** - * #stateModel WebSessionConnectionsMixin - * #category session - */ -export function WebSessionConnectionsMixin(pluginManager: PluginManager) { - return types - .compose( - 'SessionConnectionsManagement', - ConnectionManagementSessionMixin(pluginManager), - types.model({ - /** - * #property - */ - sessionConnections: types.array( - pluginManager.pluggableConfigSchemaType('connection'), - ), - }), - ) - .actions(s => { - const self = s as typeof s & BaseSession & SessionWithSessionTracks - const superDeleteConnection = self.deleteConnection - const superAddConnectionConf = self.addConnectionConf - return { - /** - * #action - */ - addConnectionConf(connectionConf: BaseConnectionConfigModel) { - if (self.adminMode) { - return superAddConnectionConf(connectionConf) - } else { - const { connectionId, type } = connectionConf - if (!type) { - throw new Error(`unknown connection type "${type}"`) - } - const connection = self.sessionConnections.find( - c => c.connectionId === connectionId, - ) - if (connection) { - return connection - } else { - const length = self.sessionConnections.push(connectionConf) - return self.sessionConnections[length - 1] - } - } - }, - - /** - * #action - */ - deleteConnection(configuration: AnyConfigurationModel) { - if (self.adminMode) { - return superDeleteConnection(configuration) - } else { - const { connectionId } = configuration - const idx = self.sessionConnections.findIndex( - c => c.connectionId === connectionId, - ) - return idx === -1 - ? undefined - : self.sessionConnections.splice(idx, 1) - } - }, - } - }) -} diff --git a/packages/web-core/src/index.ts b/packages/web-core/src/index.ts index 6d94243a0f..193e16f606 100644 --- a/packages/web-core/src/index.ts +++ b/packages/web-core/src/index.ts @@ -1,2 +1 @@ -export * from './SessionConnections' export * from './BaseWebSession' diff --git a/plugins/data-management/src/AddConnectionWidget/components/AddConnectionWidget.tsx b/plugins/data-management/src/AddConnectionWidget/components/AddConnectionWidget.tsx index c74cfc9b41..e1dde22088 100644 --- a/plugins/data-management/src/AddConnectionWidget/components/AddConnectionWidget.tsx +++ b/plugins/data-management/src/AddConnectionWidget/components/AddConnectionWidget.tsx @@ -13,6 +13,7 @@ import { observer } from 'mobx-react' import ConfigureConnection from './ConfigureConnection' import ConnectionTypeSelect from './ConnectionTypeSelect' +import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { ConnectionType } from '@jbrowse/core/pluggableElementTypes' const useStyles = makeStyles()(theme => ({ @@ -100,7 +101,7 @@ const AddConnectionWidget = observer(function ({ model }: { model: unknown }) { if (activeStep === steps.length - 1) { if (configModel && isSessionWithConnections(session)) { const conf = session.addConnectionConf(configModel) - session.makeConnection(conf) + session.makeConnection(conf as AnyConfigurationModel) } else { session.notify('No config model to add') } diff --git a/products/jbrowse-desktop/src/indexJobsModel.ts b/products/jbrowse-desktop/src/indexJobsModel.ts index 1520ed59cf..6ed39c67e5 100644 --- a/products/jbrowse-desktop/src/indexJobsModel.ts +++ b/products/jbrowse-desktop/src/indexJobsModel.ts @@ -15,10 +15,22 @@ import { import { autorun, observable, toJS } from 'mobx' import type PluginManager from '@jbrowse/core/PluginManager' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' +import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { SessionWithDrawerWidgets } from '@jbrowse/core/util' import type { Instance } from '@jbrowse/mobx-state-tree' import type { JobsListModel } from '@jbrowse/plugin-jobs-management/src/JobsListWidget/model' +interface JobsModelRootModel { + jbrowse: { + rpcManager: RpcManager + tracks: AnyConfigurationModel[] + aggregateTextSearchAdapters: { textSearchAdapterId: string }[] + } + session: SessionWithDrawerWidgets + sessionPath: string +} + const { ipcRenderer } = window.require('electron') const ONE_HOUR = 60 * 60 * 1000 @@ -77,33 +89,32 @@ export default function jobsModelFactory(_pluginManager: PluginManager) { * #getter */ get rpcManager() { - return getParent(self).jbrowse.rpcManager + return getParent(self).jbrowse.rpcManager }, /** * #getter */ get tracks() { - return getParent(self).jbrowse.tracks + return getParent(self).jbrowse.tracks }, /** * #getter */ get sessionPath() { - return getParent(self).sessionPath + return getParent(self).sessionPath }, /** * #getter */ get session() { - return getParent<{ session: SessionWithDrawerWidgets }>(self).session + return getParent(self).session }, /** * #getter */ get aggregateTextSearchAdapters() { - return getParent(self).jbrowse.aggregateTextSearchAdapters as { - textSearchAdapterId: string - }[] + return getParent(self).jbrowse + .aggregateTextSearchAdapters }, })) .actions(self => ({ @@ -228,10 +239,12 @@ export default function jobsModelFactory(_pluginManager: PluginManager) { indexType, } = toJS(entry.indexingParams) const rpcManager = self.rpcManager - const trackConfigs = findTrackConfigsToIndex(self.tracks, trackIds).map( - // @ts-expect-error - c => JSON.parse(JSON.stringify(getSnapshot(c))), - ) + const trackConfigs: { trackId: string; assemblyNames: string[] }[] = + // @ts-expect-error self.tracks is AnyConfigurationModel[] but findTrackConfigsToIndex expects Track[] + findTrackConfigsToIndex(self.tracks, trackIds).map(c => + // @ts-expect-error Track type doesn't extend IStateTreeNode but works at runtime + structuredClone(getSnapshot(c)), + ) try { this.setRunning(true) this.setJobName(entry.name) diff --git a/products/jbrowse-desktop/src/rootModel/rootModel.ts b/products/jbrowse-desktop/src/rootModel/rootModel.ts index a3b9964e53..3e643f129b 100644 --- a/products/jbrowse-desktop/src/rootModel/rootModel.ts +++ b/products/jbrowse-desktop/src/rootModel/rootModel.ts @@ -3,11 +3,17 @@ import { lazy } from 'react' import { HistoryManagementMixin, RootAppMenuMixin, + getOpenTrackMenuItem, + getOpenConnectionMenuItem, + getUndoMenuItem, + getRedoMenuItem, + getPluginStoreMenuItem, processMutableMenuActions, } from '@jbrowse/app-core' +import type { Menu, SessionModelFactory } from '@jbrowse/app-core' import assemblyConfigSchemaF from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import RpcManager from '@jbrowse/core/rpc/RpcManager' -import { Cable, DNA } from '@jbrowse/core/ui/Icons' +import { DNA } from '@jbrowse/core/ui/Icons' import { types } from '@jbrowse/mobx-state-tree' import { AssemblyManager } from '@jbrowse/plugin-data-management' import { @@ -15,14 +21,10 @@ import { InternetAccountsRootModelMixin, } from '@jbrowse/product-core' import AppsIcon from '@mui/icons-material/Apps' -import ExtensionIcon from '@mui/icons-material/Extension' import OpenIcon from '@mui/icons-material/FolderOpen' import MeetingRoomIcon from '@mui/icons-material/MeetingRoom' -import RedoIcon from '@mui/icons-material/Redo' import SaveAsIcon from '@mui/icons-material/SaveAs' import SettingsIcon from '@mui/icons-material/Settings' -import StorageIcon from '@mui/icons-material/Storage' -import UndoIcon from '@mui/icons-material/Undo' import { DesktopSessionManagementMixin, getSaveSession } from './Sessions' import packageJSON from '../../package.json' @@ -34,9 +36,8 @@ import makeWorkerInstance from '../makeWorkerInstance' import type PluginManager from '@jbrowse/core/PluginManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' -import type { MenuItem } from '@jbrowse/core/ui' import type { AbstractSessionModel } from '@jbrowse/core/util' -import type { IAnyType, Instance } from '@jbrowse/mobx-state-tree' +import type { Instance } from '@jbrowse/mobx-state-tree' import type { SessionWithDialogs } from '@jbrowse/product-core' // lazies @@ -44,16 +45,6 @@ const PreferencesDialog = lazy(() => import('../components/PreferencesDialog')) const { ipcRenderer } = window.require('electron') -export interface Menu { - label: string - menuItems: MenuItem[] -} - -type SessionModelFactory = (args: { - pluginManager: PluginManager - assemblyConfigSchema: BaseAssemblyConfigSchema -}) => IAnyType - /** * #stateModel JBrowseDesktopRootModel * #category root @@ -206,41 +197,8 @@ export default function rootModelFactory({ } }, }, - { - label: 'Open track...', - icon: StorageIcon, - - onClick: (session: any) => { - if (session.views.length === 0) { - session.notify('Please open a view to add a track first') - } else if (session.views.length > 0) { - const widget = session.addWidget( - 'AddTrackWidget', - 'addTrackWidget', - { view: session.views[0].id }, - ) - session.showWidget(widget) - if (session.views.length > 1) { - session.notify( - 'This will add a track to the first view. Note: if you want to open a track in a specific view open the track selector for that view and use the add track (plus icon) in the bottom right', - ) - } - } - }, - }, - { - label: 'Open connection...', - icon: Cable, - onClick: () => { - if (self.session) { - const widget = self.session.addWidget( - 'AddConnectionWidget', - 'addConnectionWidget', - ) - self.session.showWidget(widget) - } - }, - }, + getOpenTrackMenuItem(), + getOpenConnectionMenuItem(), { type: 'divider', }, @@ -267,38 +225,10 @@ export default function rootModelFactory({ { label: 'Tools', menuItems: [ - { - label: 'Undo', - icon: UndoIcon, - onClick: () => { - if (self.history.canUndo) { - self.history.undo() - } - }, - }, - { - label: 'Redo', - icon: RedoIcon, - onClick: () => { - if (self.history.canRedo) { - self.history.redo() - } - }, - }, + getUndoMenuItem(() => self.history), + getRedoMenuItem(() => self.history), { type: 'divider' }, - { - label: 'Plugin store', - icon: ExtensionIcon, - onClick: () => { - if (self.session) { - const widget = self.session.addWidget( - 'PluginStoreWidget', - 'pluginStoreWidget', - ) - self.session.showWidget(widget) - } - }, - }, + getPluginStoreMenuItem(() => self.session), { label: 'Preferences', icon: SettingsIcon, diff --git a/products/jbrowse-desktop/src/sessionModel/DesktopSession.ts b/products/jbrowse-desktop/src/sessionModel/DesktopSession.ts deleted file mode 100644 index f953b4a514..0000000000 --- a/products/jbrowse-desktop/src/sessionModel/DesktopSession.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BaseSessionModel } from '@jbrowse/product-core' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { Instance } from '@jbrowse/mobx-state-tree' - -/** - * #stateModel DesktopSessionModel - * #category session - * extends [BaseSessionModel](../basesessionmodel) - */ -export function DesktopSessionFactory(pluginManager: PluginManager) { - return BaseSessionModel(pluginManager) - .props({ - /** - * #property - */ - margin: 0, - }) - .views(() => ({ - /** - * #getter - */ - get adminMode() { - return true - }, - })) - .volatile((/* self */) => ({ - /** - * this is the current "task" that is being performed in the UI. - * this is usually an object of the form - * `{ taskName: "configure", target: thing_being_configured }` - */ - task: undefined, - })) -} - -export type DesktopSessionModel = Instance< - ReturnType -> diff --git a/products/jbrowse-desktop/src/sessionModel/TrackMenu.ts b/products/jbrowse-desktop/src/sessionModel/TrackMenu.ts deleted file mode 100644 index 2f3de5ccb3..0000000000 --- a/products/jbrowse-desktop/src/sessionModel/TrackMenu.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { lazy } from 'react' - -import { Indexing } from '@jbrowse/core/ui/Icons' -import { isSupportedIndexingAdapter } from '@jbrowse/core/util' -import { getParent, getSnapshot, types } from '@jbrowse/mobx-state-tree' -import DeleteIcon from '@mui/icons-material/Delete' -import CopyIcon from '@mui/icons-material/FileCopy' -import InfoIcon from '@mui/icons-material/Info' -import SettingsIcon from '@mui/icons-material/Settings' - -import type { DesktopRootModel } from '../rootModel/rootModel' -import type PluginManager from '@jbrowse/core/PluginManager' -import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' -import type { - SessionWithDialogs, - SessionWithDrawerWidgets, - SessionWithTracks, -} from '@jbrowse/product-core' - -const AboutDialog = lazy(() => import('./AboutDialog')) - -/** - * #stateModel DesktopSessionTrackMenuMixin - */ -export function DesktopSessionTrackMenuMixin(_pluginManager: PluginManager) { - return types.model({}).views(self => ({ - /** - * #method - */ - getTrackActionMenuItems(trackConfig: BaseTrackConfig) { - const session = self as SessionWithDialogs & - SessionWithTracks & - SessionWithDrawerWidgets - const trackSnapshot = structuredClone(getSnapshot(trackConfig)) - return [ - { - label: 'About track', - onClick: () => { - session.queueDialog(doneCallback => [ - AboutDialog, - { config: trackConfig, handleClose: doneCallback }, - ]) - }, - icon: InfoIcon, - }, - { - label: 'Settings', - onClick: () => { - session.editConfiguration(trackConfig) - }, - icon: SettingsIcon, - }, - { - label: 'Delete track', - onClick: () => { - session.deleteTrackConf(trackConfig) - }, - icon: DeleteIcon, - }, - { - label: 'Copy track', - onClick: () => { - const now = Date.now() - trackSnapshot.trackId += `-${now}` - for (const d of trackSnapshot.displays) { - d.displayId += `-${now}` - } - trackSnapshot.name += ' (copy)' - trackSnapshot.category = undefined - session.addTrackConf(trackSnapshot) - }, - icon: CopyIcon, - }, - ...(isSupportedIndexingAdapter(trackSnapshot.adapter?.type) - ? [ - { - label: trackSnapshot.textSearching - ? 'Re-index track' - : 'Index track', - onClick: () => { - const rootModel = getParent(self) - const { jobsManager } = rootModel - const { trackId, assemblyNames, textSearching, name } = - trackSnapshot - const indexName = `${name}-index` - // TODO: open jobs list widget - jobsManager.queueJob({ - indexingParams: { - attributes: textSearching?.indexingAttributes || [ - 'Name', - 'ID', - ], - exclude: textSearching?.indexingFeatureTypesToExclude || [ - 'CDS', - 'exon', - ], - assemblies: assemblyNames, - tracks: [trackId], - indexType: 'perTrack', - timestamp: new Date().toISOString(), - name: indexName, - }, - name: indexName, - }) - }, - icon: Indexing, - }, - ] - : []), - { type: 'divider' }, - ] - }, - })) -} diff --git a/products/jbrowse-desktop/src/sessionModel/sessionModel.ts b/products/jbrowse-desktop/src/sessionModel/sessionModel.ts deleted file mode 100644 index e917829974..0000000000 --- a/products/jbrowse-desktop/src/sessionModel/sessionModel.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - AppFocusMixin, - SessionAssembliesMixin, - TemporaryAssembliesMixin, -} from '@jbrowse/app-core' -import { getConf, readConfObject } from '@jbrowse/core/configuration' -import SnackbarModel from '@jbrowse/core/ui/SnackbarModel' -import { getParent, types } from '@jbrowse/mobx-state-tree' -import { - ConnectionManagementSessionMixin, - DialogQueueSessionMixin, - DrawerWidgetSessionMixin, - MultipleViewsSessionMixin, - ReferenceManagementSessionMixin, - ThemeManagerSessionMixin, - TracksManagerSessionMixin, -} from '@jbrowse/product-core' - -import { DesktopSessionFactory } from './DesktopSession' -import { DesktopSessionTrackMenuMixin } from './TrackMenu' - -import type { DesktopRootModel } from '../rootModel/rootModel' -import type PluginManager from '@jbrowse/core/PluginManager' -import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' -import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' -import type { AbstractSessionModel } from '@jbrowse/core/util' -import type { Instance } from '@jbrowse/mobx-state-tree' - -/** - * #stateModel JBrowseDesktopSessionModel - * composed of - * - ReferenceManagementSessionMixin - * - ConnectionManagementSessionMixin - * - DrawerWidgetSessionMixin - * - DialogQueueSessionMixin - * - ThemeManagerSessionMixin - * - TracksManagerSessionMixin - * - MultipleViewsSessionMixin - * - DesktopSessionMixin - * - SessionAssembliesMixin - * - TemporaryAssembliesMixin - * - DesktopSessionTrackMenuMixin - * - SnackbarModel - * - AppFocusMixin - * - */ -export default function sessionModelFactory({ - pluginManager, - assemblyConfigSchema, -}: { - pluginManager: PluginManager - assemblyConfigSchema: BaseAssemblyConfigSchema -}) { - const sessionModel = types - .compose( - 'JBrowseDesktopSessionModel', - types.compose( - ReferenceManagementSessionMixin(pluginManager), - ConnectionManagementSessionMixin(pluginManager), - DrawerWidgetSessionMixin(pluginManager), - DialogQueueSessionMixin(pluginManager), - ThemeManagerSessionMixin(pluginManager), - TracksManagerSessionMixin(pluginManager), - MultipleViewsSessionMixin(pluginManager), - DesktopSessionFactory(pluginManager), - ), - SessionAssembliesMixin(pluginManager, assemblyConfigSchema), - TemporaryAssembliesMixin(pluginManager, assemblyConfigSchema), - DesktopSessionTrackMenuMixin(pluginManager), - AppFocusMixin(), - SnackbarModel(), - ) - .views(self => ({ - /** - * #getter - */ - get assemblies(): Instance { - return [...self.jbrowse.assemblies, ...self.sessionAssemblies] - }, - /** - * #getter - */ - get root() { - return getParent(self) - }, - })) - .actions(self => ({ - /** - * #action - */ - renameCurrentSession(sessionName: string) { - self.root.renameCurrentSession(sessionName) - }, - /** - * #action - */ - editTrackConfiguration(configuration: BaseTrackConfig) { - self.editConfiguration(configuration) - }, - })) - .views(self => ({ - /** - * #getter - */ - get assemblyNames(): string[] { - return self.assemblies.map(a => readConfObject(a, 'name')) - }, - /** - * #getter - */ - get version() { - return self.root.version - }, - /** - * #getter - */ - get history() { - return self.root.history - }, - /** - * #getter - */ - get menus() { - return self.root.menus - }, - /** - * #getter - */ - get assemblyManager() { - return self.root.assemblyManager - }, - - /** - * #method - */ - renderProps() { - return { - theme: self.theme, - highResolutionScaling: getConf(self, 'highResolutionScaling'), - } - }, - })) - - const extendedSessionModel = pluginManager.evaluateExtensionPoint( - 'Core-extendSession', - sessionModel, - ) as typeof sessionModel - - return types.snapshotProcessor(extendedSessionModel, { - // @ts-expect-error - preProcessor(snapshot) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (snapshot) { - // @ts-expect-error - const { connectionInstances, ...rest } = snapshot - // connectionInstances schema changed from object to an array, so any old - // connectionInstances as object is in snapshot, filter it out - // https://github.com/GMOD/jbrowse-components/issues/1903 - if (!Array.isArray(connectionInstances)) { - return rest - } - } - return snapshot - }, - }) -} - -export type DesktopSessionModelType = ReturnType -export type SessionStateModel = Instance - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function z(x: Instance): AbstractSessionModel { - // this function's sole purpose is to get typescript to check - // that the session model implements all of AbstractSessionModel - return x -} diff --git a/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx new file mode 100644 index 0000000000..b7eeaaddad --- /dev/null +++ b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx @@ -0,0 +1,807 @@ +import { lazy } from 'react' + +import { + getConf, + isConfigurationModel, + readConfObject, +} from '@jbrowse/core/configuration' +import { + getContainingView, + isSessionModelWithWidgets, + isSupportedIndexingAdapter, + localStorageGetBoolean, + localStorageGetItem, + localStorageSetBoolean, + localStorageSetItem, +} from '@jbrowse/core/util' +import { ElementId } from '@jbrowse/core/util/types/mst' +import { Indexing } from '@jbrowse/core/ui/Icons' +import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' +import { + addDisposer, + cast, + getMembers, + getParent, + getSnapshot, + getType, + isAlive, + isModelType, + isReferenceType, + types, + walk, +} from '@jbrowse/mobx-state-tree' +import { autorun, observable } from 'mobx' +import DeleteIcon from '@mui/icons-material/Delete' +import CopyIcon from '@mui/icons-material/FileCopy' +import InfoIcon from '@mui/icons-material/Info' +import SettingsIcon from '@mui/icons-material/Settings' +import Report from '@mui/icons-material/Report' + +import type { DesktopRootModel } from '../rootModel/rootModel' +import type PluginManager from '@jbrowse/core/PluginManager' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' +import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' +import type { + AnyConfiguration, + AnyConfigurationModel, +} from '@jbrowse/core/configuration' +import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes' +import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type { + AssemblyManager, + DialogComponentType, + NotificationLevel, + SnackAction, + TrackViewModel, +} from '@jbrowse/core/util' +import type { AbstractSessionModel } from '@jbrowse/core/util' +import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' +import type { ThemeOptions } from '@mui/material' + +// lazies +const AboutDialog = lazy(() => import('./AboutDialog')) +const ErrorMessageStackTraceDialog = lazy( + () => import('@jbrowse/core/ui/ErrorMessageStackTraceDialog'), +) + +const minDrawerWidth = 128 + +export type ThemeMap = Record + +export interface ReferringNode { + node: IAnyStateTreeNode + key: string +} + +export interface SnackbarMessage { + message: string + level?: NotificationLevel + action?: SnackAction +} + +/** + * #stateModel JBrowseDesktopSessionModel + * + * Consolidated session model for jbrowse-desktop. + * Includes functionality previously split across mixins: + * - BaseSessionModel + * - ReferenceManagementSessionMixin + * - ConnectionManagementSessionMixin + * - DrawerWidgetSessionMixin + * - DialogQueueSessionMixin + * - ThemeManagerSessionMixin + * - TracksManagerSessionMixin + * - MultipleViewsSessionMixin + * - SessionAssembliesMixin + * - TemporaryAssembliesMixin + * - DesktopSessionTrackMenuMixin + * - SnackbarModel + * - AppFocusMixin + */ +export default function sessionModelFactory({ + pluginManager, + assemblyConfigSchema, +}: { + pluginManager: PluginManager + assemblyConfigSchema: BaseAssemblyConfigSchema +}) { + const widgetStateModelType = pluginManager.pluggableMstType( + 'widget', + 'stateModel', + ) + type WidgetStateModel = Instance + + const sessionModel = types + .model('JBrowseDesktopSessionModel', { + // BaseSessionModel props + id: ElementId, + name: types.string, + margin: 0, + + // DrawerWidgetSessionMixin props + drawerPosition: types.optional( + types.string, + () => localStorageGetItem('drawerPosition') || 'right', + ), + drawerWidth: types.optional( + types.refinement(types.integer, width => width >= minDrawerWidth), + 384, + ), + widgets: types.map(widgetStateModelType), + activeWidgets: types.map(types.safeReference(widgetStateModelType)), + minimized: types.optional(types.boolean, false), + + // MultipleViewsSessionMixin props + views: types.array(pluginManager.pluggableMstType('view', 'stateModel')), + stickyViewHeaders: types.optional(types.boolean, () => + localStorageGetBoolean('stickyViewHeaders', true), + ), + + // ConnectionManagementSessionMixin props + connectionInstances: types.array( + pluginManager.pluggableMstType('connection', 'stateModel'), + ), + + // SessionAssembliesMixin props + sessionAssemblies: types.array(assemblyConfigSchema), + + // TemporaryAssembliesMixin props + temporaryAssemblies: types.array(assemblyConfigSchema), + + // AppFocusMixin props + focusedViewId: types.maybe(types.string), + }) + .volatile(() => ({ + // BaseSessionModel volatiles + selection: undefined as unknown, + hovered: undefined as unknown, + + // DialogQueueSessionMixin volatile + queueOfDialogs: [] as [DialogComponentType, unknown][], + + // ThemeManagerSessionMixin volatile + sessionThemeName: localStorageGetItem('themeName') || 'default', + + // SnackbarModel volatile + snackbarMessages: observable.array(), + + // Task volatile + task: undefined, + })) + .views(self => ({ + get root(): DesktopRootModel { + return getParent(self) + }, + get jbrowse() { + return getParent<{ jbrowse: AnyConfigurationModel }>(self).jbrowse + }, + get rpcManager(): RpcManager { + return getParent<{ rpcManager: RpcManager }>(self).rpcManager + }, + get configuration() { + return this.jbrowse.configuration + }, + // Desktop is always admin mode + get adminMode() { + return true + }, + get textSearchManager() { + return getParent<{ textSearchManager: TextSearchManager }>(self) + .textSearchManager + }, + })) + .views(self => ({ + // Tracks views - desktop doesn't have sessionTracks, uses jbrowse.tracks directly + get tracks(): AnyConfigurationModel[] { + return self.jbrowse.tracks + }, + + // Assemblies views + get assemblies(): Instance[] { + return [...self.jbrowse.assemblies, ...self.sessionAssemblies] + }, + + // Connections views + get connections(): BaseConnectionConfigModel[] { + return self.jbrowse.connections + }, + + // DrawerWidgetSessionMixin views + get visibleWidget() { + if (isAlive(self)) { + return [...self.activeWidgets.values()][self.activeWidgets.size - 1] + } + return undefined + }, + + // DialogQueueSessionMixin views + get DialogComponent() { + return self.queueOfDialogs[0]?.[0] + }, + get DialogProps() { + return self.queueOfDialogs[0]?.[1] + }, + + // ThemeManagerSessionMixin views + allThemes(): ThemeMap { + const extraThemes = readConfObject(self.jbrowse, 'extraThemes') + return { ...defaultThemes, ...extraThemes } + }, + get themeName() { + const { sessionThemeName } = self + const all = this.allThemes() + return all[sessionThemeName] ? sessionThemeName : 'default' + }, + get theme() { + const configTheme = readConfObject(self.jbrowse, 'theme') + const all = this.allThemes() + return createJBrowseTheme(configTheme, all, this.themeName) + }, + + // SnackbarModel views + get snackbarMessageSet() { + return new Map(self.snackbarMessages.map(s => [s.message, s])) + }, + + // ReferenceManagementSessionMixin views + getReferring(object: IAnyStateTreeNode) { + const refs: ReferringNode[] = [] + walk(getParent(self), node => { + if (isModelType(getType(node))) { + const members = getMembers(node) + for (const [key, value] of Object.entries(members.properties)) { + if (isReferenceType(value) && node[key] === object) { + refs.push({ node, key }) + } + } + } + }) + return refs + }, + })) + .views(self => ({ + get assemblyNames(): string[] { + return self.assemblies.map(a => readConfObject(a, 'name')) + }, + get version() { + return self.root.version + }, + get history() { + return self.root.history + }, + get menus() { + return self.root.menus + }, + get assemblyManager(): AssemblyManager { + return self.root.assemblyManager + }, + + renderProps() { + return { + theme: self.theme, + highResolutionScaling: getConf(self, 'highResolutionScaling'), + } + }, + })) + .actions(self => ({ + // BaseSessionModel actions + setSelection(thing: unknown) { + self.selection = thing + }, + clearSelection() { + self.selection = undefined + }, + setHovered(thing: unknown) { + self.hovered = thing + }, + + // DialogQueueSessionMixin actions + removeActiveDialog() { + self.queueOfDialogs = self.queueOfDialogs.slice(1) + }, + queueDialog( + cb: (doneCallback: () => void) => [DialogComponentType, unknown], + ) { + const [component, props] = cb(() => { + this.removeActiveDialog() + }) + self.queueOfDialogs = [...self.queueOfDialogs, [component, props]] + }, + + // ThemeManagerSessionMixin actions + setThemeName(name: string) { + self.sessionThemeName = name + }, + + // DrawerWidgetSessionMixin actions + setDrawerPosition(arg: string) { + self.drawerPosition = arg + localStorage.setItem('drawerPosition', arg) + }, + updateDrawerWidth(drawerWidth: number) { + if (drawerWidth === self.drawerWidth) { + return self.drawerWidth + } + let newDrawerWidth = drawerWidth + if (newDrawerWidth < minDrawerWidth) { + newDrawerWidth = minDrawerWidth + } + self.drawerWidth = newDrawerWidth + return newDrawerWidth + }, + resizeDrawer(distance: number) { + if (self.drawerPosition === 'left') { + distance *= -1 + } + const oldDrawerWidth = self.drawerWidth + const newDrawerWidth = this.updateDrawerWidth(oldDrawerWidth - distance) + return oldDrawerWidth - newDrawerWidth + }, + addWidget( + typeName: string, + id: string, + initialState = {}, + conf?: unknown, + ) { + const typeDefinition = pluginManager.getElementType('widget', typeName) + if (!typeDefinition) { + throw new Error(`unknown widget type ${typeName}`) + } + const data = { + ...initialState, + id, + type: typeName, + configuration: conf || { type: typeName }, + } + self.widgets.set(id, data) + return self.widgets.get(id) + }, + showWidget(widget: WidgetStateModel) { + if (self.activeWidgets.has(widget.id)) { + self.activeWidgets.delete(widget.id) + } + self.activeWidgets.set(widget.id, widget) + self.minimized = false + }, + hasWidget(widget: WidgetStateModel) { + return self.activeWidgets.has(widget.id) + }, + hideWidget(widget: WidgetStateModel) { + self.activeWidgets.delete(widget.id) + }, + minimizeWidgetDrawer() { + self.minimized = true + }, + showWidgetDrawer() { + self.minimized = false + }, + hideAllWidgets() { + self.activeWidgets.clear() + }, + editConfiguration(configuration: AnyConfigurationModel) { + if (!isConfigurationModel(configuration)) { + throw new Error( + 'must pass a configuration model to editConfiguration', + ) + } + const editor = this.addWidget( + 'ConfigurationEditorWidget', + 'configEditor', + { target: configuration }, + ) + this.showWidget(editor) + }, + + // MultipleViewsSessionMixin actions + moveViewDown(id: string) { + const idx = self.views.findIndex(v => v.id === id) + if (idx !== -1 && idx < self.views.length - 1) { + self.views.splice(idx, 2, self.views[idx + 1], self.views[idx]) + } + }, + moveViewUp(id: string) { + const idx = self.views.findIndex(view => view.id === id) + if (idx > 0) { + self.views.splice(idx - 1, 2, self.views[idx], self.views[idx - 1]) + } + }, + moveViewToTop(id: string) { + const idx = self.views.findIndex(view => view.id === id) + self.views = cast([ + self.views[idx], + ...self.views.filter(view => view.id !== id), + ]) + }, + moveViewToBottom(id: string) { + const idx = self.views.findIndex(view => view.id === id) + self.views = cast([ + ...self.views.filter(view => view.id !== id), + self.views[idx], + ]) + }, + addView(typeName: string, initialState = {}) { + const typeDefinition = pluginManager.getElementType('view', typeName) + if (!typeDefinition) { + throw new Error(`unknown view type ${typeName}`) + } + const length = self.views.push({ + ...initialState, + type: typeName, + }) + return self.views[length - 1] + }, + removeView(view: IBaseViewModel) { + for (const [, widget] of self.activeWidgets) { + if (widget.view && widget.view.id === view.id) { + this.hideWidget(widget) + } + } + self.views.remove(view) + }, + setStickyViewHeaders(sticky: boolean) { + self.stickyViewHeaders = sticky + }, + + // ReferenceManagementSessionMixin actions + removeReferring( + referring: ReferringNode[], + track: BaseTrackConfig, + callbacks: (() => void)[], + dereferenceTypeCount: Record, + ) { + for (const { node } of referring) { + let dereferenced = false + try { + const type = 'open track(s)' + const view = getContainingView(node) as TrackViewModel + callbacks.push(() => { + view.hideTrack(track.trackId) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } catch { + // ignore + } + + if (isSessionModelWithWidgets(self) && self.widgets.has(node.id)) { + const type = 'configuration editor widget(s)' + callbacks.push(() => { + this.hideWidget(node) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } + if (!dereferenced) { + throw new Error( + `Error when closing this connection, the following node is still referring to a track configuration: ${JSON.stringify( + getSnapshot(node), + )}`, + ) + } + } + }, + + // ConnectionManagementSessionMixin actions + makeConnection( + configuration: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const type = configuration.type as string + if (!type) { + throw new Error('track configuration has no `type` listed') + } + const name = readConfObject(configuration, 'name') + const connectionType = pluginManager.getConnectionType(type) + if (!connectionType) { + throw new Error(`unknown connection type ${type}`) + } + const length = self.connectionInstances.push({ + ...initialSnapshot, + name, + type, + configuration, + }) + return self.connectionInstances[length - 1] + }, + prepareToBreakConnection(configuration: AnyConfigurationModel) { + const callbacksToDeref: (() => void)[] = [] + const derefTypeCount: Record = {} + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + return undefined + } + for (const track of connection.tracks) { + const ref = self.getReferring(track) + this.removeReferring(ref, track, callbacksToDeref, derefTypeCount) + } + return [ + () => { + for (const cb of callbacksToDeref) { + cb() + } + this.breakConnection(configuration) + }, + derefTypeCount, + ] + }, + breakConnection(configuration: AnyConfigurationModel) { + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + throw new Error(`no connection found with name ${name}`) + } + self.connectionInstances.remove(connection) + }, + clearConnections() { + self.connectionInstances.clear() + }, + addConnectionConf(connectionConf: AnyConfigurationModel) { + return self.jbrowse.addConnectionConf(connectionConf) + }, + deleteConnection(configuration: AnyConfigurationModel) { + return self.jbrowse.deleteConnectionConf(configuration) + }, + + // TracksManagerSessionMixin actions (desktop is always admin, so goes to jbrowse) + addTrackConf(trackConf: AnyConfiguration) { + return self.jbrowse.addTrackConf(trackConf) + }, + deleteTrackConf(trackConf: AnyConfigurationModel) { + const callbacksToDereferenceTrack: (() => void)[] = [] + const dereferenceTypeCount: Record = {} + const referring = self.getReferring(trackConf) + this.removeReferring( + referring, + trackConf, + callbacksToDereferenceTrack, + dereferenceTypeCount, + ) + for (const cb of callbacksToDereferenceTrack) { + cb() + } + return self.jbrowse.deleteTrackConf(trackConf) + }, + + // SessionAssembliesMixin actions + addSessionAssembly(conf: AnyConfiguration) { + const asm = self.sessionAssemblies.find(f => f.name === conf.name) + if (asm) { + console.warn(`Assembly ${conf.name} already exists`) + return asm + } + const length = self.sessionAssemblies.push(conf) + return self.sessionAssemblies[length - 1] + }, + addAssembly(conf: AnyConfiguration) { + // Desktop is always admin mode + self.jbrowse.addAssemblyConf(conf) + }, + removeAssembly(name: string) { + // Desktop is always admin mode + self.jbrowse.removeAssemblyConf(name) + }, + removeSessionAssembly(assemblyName: string) { + const elt = self.sessionAssemblies.find(a => a.name === assemblyName) + if (elt) { + self.sessionAssemblies.remove(elt) + } + }, + + // TemporaryAssembliesMixin actions + addTemporaryAssembly(conf: AnyConfiguration) { + const asm = self.temporaryAssemblies.find(f => f.name === conf.name) + if (asm) { + console.warn(`Assembly ${conf.name} was already existing`) + return asm + } + const length = self.temporaryAssemblies.push(conf) + return self.temporaryAssemblies[length - 1] + }, + removeTemporaryAssembly(name: string) { + const elt = self.temporaryAssemblies.find(a => a.name === name) + if (elt) { + self.temporaryAssemblies.remove(elt) + } + }, + + // AppFocusMixin actions + setFocusedViewId(viewId: string) { + self.focusedViewId = viewId + }, + + // SnackbarModel actions + notify(message: string, level?: NotificationLevel, action?: SnackAction) { + this.pushSnackbarMessage(message, level, action) + if (level === 'info' || level === 'success') { + setTimeout(() => { + this.removeSnackbarMessage(message) + }, 5000) + } + }, + notifyError(errorMessage: string, error?: unknown, extra?: unknown) { + this.notify(errorMessage, 'error', { + name: , + onClick: () => { + this.queueDialog((onClose: () => void) => [ + ErrorMessageStackTraceDialog, + { onClose, error, extra }, + ]) + }, + }) + }, + pushSnackbarMessage( + message: string, + level?: NotificationLevel, + action?: SnackAction, + ) { + if (action || !self.snackbarMessageSet.has(message)) { + self.snackbarMessages.push({ message, level, action }) + } + }, + popSnackbarMessage() { + return self.snackbarMessages.pop() + }, + removeSnackbarMessage(message: string) { + const element = self.snackbarMessageSet.get(message) + if (element !== undefined) { + self.snackbarMessages.remove(element) + } + }, + + // Desktop-specific actions + renameCurrentSession(sessionName: string) { + self.root.renameCurrentSession(sessionName) + }, + editTrackConfiguration(configuration: BaseTrackConfig) { + this.editConfiguration(configuration) + }, + + afterAttach() { + addDisposer( + self, + autorun( + function sessionLocalStorageAutorun() { + localStorageSetItem('drawerPosition', self.drawerPosition) + localStorageSetItem('themeName', self.themeName) + localStorageSetBoolean('stickyViewHeaders', self.stickyViewHeaders) + }, + { name: 'SessionLocalStorage' }, + ), + ) + }, + })) + .views(self => ({ + // DesktopSessionTrackMenuMixin views + getTrackActionMenuItems(trackConfig: BaseTrackConfig) { + const trackSnapshot = structuredClone(getSnapshot(trackConfig)) as { + trackId: string + assemblyNames: string[] + name: string + displays: { displayId: string }[] + category?: string + adapter?: { type: string } + textSearching?: { + indexingAttributes?: string[] + indexingFeatureTypesToExclude?: string[] + } + } + return [ + { + label: 'About track', + onClick: () => { + self.queueDialog(doneCallback => [ + AboutDialog, + { config: trackConfig, handleClose: doneCallback }, + ]) + }, + icon: InfoIcon, + }, + { + label: 'Settings', + onClick: () => { + self.editConfiguration(trackConfig) + }, + icon: SettingsIcon, + }, + { + label: 'Delete track', + onClick: () => { + self.deleteTrackConf(trackConfig) + }, + icon: DeleteIcon, + }, + { + label: 'Copy track', + onClick: () => { + const now = Date.now() + trackSnapshot.trackId += `-${now}` + for (const d of trackSnapshot.displays) { + d.displayId += `-${now}` + } + trackSnapshot.name += ' (copy)' + trackSnapshot.category = undefined + self.addTrackConf(trackSnapshot) + }, + icon: CopyIcon, + }, + ...(isSupportedIndexingAdapter(trackSnapshot.adapter?.type) + ? [ + { + label: trackSnapshot.textSearching + ? 'Re-index track' + : 'Index track', + onClick: () => { + const { jobsManager } = self.root + const { trackId, assemblyNames, textSearching, name } = + trackSnapshot + const indexName = `${name}-index` + jobsManager.queueJob({ + indexingParams: { + attributes: textSearching?.indexingAttributes || [ + 'Name', + 'ID', + ], + exclude: textSearching?.indexingFeatureTypesToExclude || [ + 'CDS', + 'exon', + ], + assemblies: assemblyNames, + tracks: [trackId], + indexType: 'perTrack', + timestamp: new Date().toISOString(), + name: indexName, + }, + name: indexName, + }) + }, + icon: Indexing, + }, + ] + : []), + { type: 'divider' }, + ] + }, + })) + + const extendedSessionModel = pluginManager.evaluateExtensionPoint( + 'Core-extendSession', + sessionModel, + ) as typeof sessionModel + + return types.snapshotProcessor(extendedSessionModel, { + // @ts-expect-error + preProcessor(snapshot) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (snapshot) { + // @ts-expect-error + const { connectionInstances, ...rest } = snapshot + // connectionInstances schema changed from object to an array, so any old + // connectionInstances as object is in snapshot, filter it out + // https://github.com/GMOD/jbrowse-components/issues/1903 + if (!Array.isArray(connectionInstances)) { + return rest + } + } + return snapshot + }, + }) +} + +export type DesktopSessionModelType = ReturnType +export type DesktopSessionModel = Instance + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function z(x: Instance): AbstractSessionModel { + // this function's sole purpose is to get typescript to check + // that the session model implements all of AbstractSessionModel + return x +} diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.ts b/products/jbrowse-react-app/src/rootModel/rootModel.ts index 9dadcc6a3f..f5b429f949 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.ts @@ -1,50 +1,30 @@ -import { RootAppMenuMixin, processMutableMenuActions } from '@jbrowse/app-core' +import { + RootAppMenuMixin, + getOpenTrackMenuItem, + getOpenConnectionMenuItem, + getImportSessionMenuItem, + getExportSessionMenuItem, + processMutableMenuActions, +} from '@jbrowse/app-core' +import type { Menu, SessionModelFactory } from '@jbrowse/app-core' import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import assemblyConfigSchemaFactory from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import RpcManager from '@jbrowse/core/rpc/RpcManager' -import { Cable } from '@jbrowse/core/ui/Icons' -import { - addDisposer, - cast, - getSnapshot, - getType, - types, -} from '@jbrowse/mobx-state-tree' +import { addDisposer, getSnapshot, types } from '@jbrowse/mobx-state-tree' import { BaseRootModelFactory, InternetAccountsRootModelMixin, } from '@jbrowse/product-core' import AddIcon from '@mui/icons-material/Add' -import GetAppIcon from '@mui/icons-material/GetApp' -import PublishIcon from '@mui/icons-material/Publish' -import StorageIcon from '@mui/icons-material/Storage' import { autorun } from 'mobx' import jbrowseWebFactory from '../jbrowseModel' -import { filterSessionInPlace } from '../util' import { version } from '../version' import type PluginManager from '@jbrowse/core/PluginManager' -import type { MenuItem } from '@jbrowse/core/ui' -import type { SessionWithWidgets } from '@jbrowse/core/util' -import type { - IAnyStateTreeNode, - IAnyType, - Instance, - SnapshotIn, -} from '@jbrowse/mobx-state-tree' -import type { BaseSessionType } from '@jbrowse/product-core' - -export interface Menu { - label: string - menuItems: MenuItem[] -} +import type { Instance } from '@jbrowse/mobx-state-tree' type AssemblyConfig = ReturnType -type SessionModelFactory = (args: { - pluginManager: PluginManager - assemblyConfigSchema: AssemblyConfig -}) => IAnyType /** * #stateModel JBrowseReactAppRootModel @@ -135,24 +115,6 @@ export default function RootModel({ ), ) }, - /** - * #action - */ - setSession(sessionSnapshot?: SnapshotIn) { - const oldSession = self.session - self.session = cast(sessionSnapshot) - if (self.session) { - // validate all references in the session snapshot - try { - filterSessionInPlace(self.session, getType(self.session)) - } catch (error) { - // throws error if session filtering failed - self.session = oldSession - throw error - } - } - }, - /** * #action */ @@ -164,7 +126,10 @@ export default function RootModel({ */ setDefaultSession() { const { defaultSession } = self.jbrowse - this.setSession({ + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + setSession({ ...defaultSession, name: `${defaultSession.name} ${new Date().toLocaleString()}`, }) @@ -173,9 +138,14 @@ export default function RootModel({ * #action */ renameCurrentSession(sessionName: string) { - if (self.session) { - this.setSession({ - ...getSnapshot(self.session), + const { session } = self + if (session) { + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + const snapshot = getSnapshot(session) as Record + setSession({ + ...snapshot, name: sessionName, }) } @@ -202,78 +172,15 @@ export default function RootModel({ { label: 'New session', icon: AddIcon, - - onClick: (session: any) => { - session.setDefaultSession() + onClick: () => { + self.setDefaultSession() }, }, - { - label: 'Import session…', - icon: PublishIcon, - onClick: (session: SessionWithWidgets) => { - const widget = session.addWidget( - 'ImportSessionWidget', - 'importSessionWidget', - ) - session.showWidget(widget) - }, - }, - { - label: 'Export session', - icon: GetAppIcon, - onClick: async (session: IAnyStateTreeNode) => { - // eslint-disable-next-line @typescript-eslint/no-deprecated - const { saveAs } = await import('file-saver-es') - - saveAs( - new Blob( - [ - JSON.stringify( - { session: getSnapshot(session) }, - null, - 2, - ), - ], - { type: 'text/plain;charset=utf-8' }, - ), - 'session.json', - ) - }, - }, - + getImportSessionMenuItem(), + getExportSessionMenuItem(), { type: 'divider' }, - { - label: 'Open track...', - icon: StorageIcon, - onClick: (session: SessionWithWidgets) => { - if (session.views.length === 0) { - session.notify('Please open a view to add a track first') - } else if (session.views.length > 0) { - const widget = session.addWidget( - 'AddTrackWidget', - 'addTrackWidget', - { view: session.views[0]!.id }, - ) - session.showWidget(widget) - if (session.views.length > 1) { - session.notify( - 'This will add a track to the first view. Note: if you want to open a track in a specific view open the track selector for that view and use the add track (plus icon) in the bottom right', - ) - } - } - }, - }, - { - label: 'Open connection...', - icon: Cable, - onClick: (session: SessionWithWidgets) => { - const widget = session.addWidget( - 'AddConnectionWidget', - 'addConnectionWidget', - ) - session.showWidget(widget) - }, - }, + getOpenTrackMenuItem(), + getOpenConnectionMenuItem(), ], }, { diff --git a/products/jbrowse-react-app/src/util.ts b/products/jbrowse-react-app/src/util.ts index 2ed7b7bf65..8a39ed2fd9 100644 --- a/products/jbrowse-react-app/src/util.ts +++ b/products/jbrowse-react-app/src/util.ts @@ -1,20 +1,3 @@ -import { - getChildType, - getPropertyMembers, - isArrayType, - isMapType, - isModelType, - isReferenceType, - isValidReference, -} from '@jbrowse/mobx-state-tree' - -import type { - IAnyStateTreeNode, - IAnyType, - Instance, - types, -} from '@jbrowse/mobx-state-tree' - /** * Pad the end of a base64 string with "=" to make it valid * @param b64 - unpadded b64 string @@ -70,56 +53,3 @@ export async function toUrlSafeB64(str: string) { ? encoded.slice(0, pos).replaceAll('+', '-').replaceAll('/', '_') : encoded.replaceAll('+', '-').replaceAll('/', '_') } - -type MSTArray = Instance> -type MSTMap = Instance> - -// attempts to remove undefined references from the given MST model. can only -// actually remove them from arrays and maps. throws MST undefined ref error if -// it encounters undefined refs in model properties -export function filterSessionInPlace(node: IAnyStateTreeNode, type: IAnyType) { - // makes it work with session sharing - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (node === undefined) { - return - } - if (isArrayType(type)) { - const array = node as MSTArray - const childType = getChildType(node) - if (isReferenceType(childType)) { - // filter array elements - for (let i = 0; i < array.length; ) { - if (!isValidReference(() => array[i])) { - array.splice(i, 1) - } else { - i += 1 - } - } - } - for (const el of array) { - filterSessionInPlace(el, childType) - } - } else if (isMapType(type)) { - const map = node as MSTMap - const childType = getChildType(map) - if (isReferenceType(childType)) { - // filter the map members - for (const key in map.keys()) { - if (!isValidReference(() => map.get(key))) { - map.delete(key) - } - } - } - for (const child of map) { - filterSessionInPlace(child, childType) - } - } else if (isModelType(type)) { - // iterate over children - const { properties } = getPropertyMembers(node) - - for (const [pname, ptype] of Object.entries(properties)) { - filterSessionInPlace(node[pname], ptype) - } - } -} diff --git a/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.ts b/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.ts deleted file mode 100644 index 20c0cda3ee..0000000000 --- a/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { lazy } from 'react' - -import { getConf } from '@jbrowse/core/configuration' -import SnackbarModel from '@jbrowse/core/ui/SnackbarModel' -import { getParent, types } from '@jbrowse/mobx-state-tree' -import { - BaseSessionModel, - ConnectionManagementSessionMixin, - DialogQueueSessionMixin, - DrawerWidgetSessionMixin, - ReferenceManagementSessionMixin, - TracksManagerSessionMixin, -} from '@jbrowse/product-core' -import InfoIcon from '@mui/icons-material/Info' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { AbstractSessionModel } from '@jbrowse/core/util/types' -import type { Instance } from '@jbrowse/mobx-state-tree' - -const AboutDialog = lazy(() => import('./AboutDialog')) - -/** - * #stateModel JBrowseReactCircularGenomeViewSessionModel - * composed of - * - [BaseSessionModel](../basesessionmodel) - * - [DrawerWidgetSessionMixin](../drawerwidgetsessionmixin) - * - [ConnectionManagementSessionMixin](../connectionmanagementsessionmixin) - * - [DialogQueueSessionMixin](../dialogqueuesessionmixin) - * - [TracksManagerSessionMixin](../tracksmanagersessionmixin) - * - [ReferenceManagementSessionMixin](../referencemanagementsessionmixin) - * - [SnackbarModel](../snackbarmodel) - */ -export default function sessionModelFactory(pluginManager: PluginManager) { - return types - .compose( - 'ReactCircularGenomeViewSession', - BaseSessionModel(pluginManager), - DrawerWidgetSessionMixin(pluginManager), - ConnectionManagementSessionMixin(pluginManager), - DialogQueueSessionMixin(pluginManager), - TracksManagerSessionMixin(pluginManager), - ReferenceManagementSessionMixin(pluginManager), - SnackbarModel(), - ) - .props({ - /** - * #property - */ - view: pluginManager.getViewType('CircularView')!.stateModel, - }) - .volatile((/* self */) => ({ - /** - * this is the current "task" that is being performed in the UI. - * this is usually an object of the form - * `{ taskName: "configure", target: thing_being_configured }` - */ - task: undefined, - })) - .views(self => ({ - /** - * #getter - */ - get version() { - return getParent(self).version - }, - /** - * #getter - */ - get assemblies() { - return [getParent(self).config.assembly] - }, - /** - * #getter - */ - get assemblyNames() { - return [getParent(self).config.assemblyName] - }, - /** - * #getter - */ - get connections() { - return getParent(self).config.connections - }, - /** - * #getter - */ - get assemblyManager() { - return getParent(self).assemblyManager - }, - /** - * #getter - */ - get views() { - return [self.view] - }, - /** - * #method - */ - renderProps() { - return { - theme: getConf(self, 'theme'), - highResolutionScaling: getConf(self, 'highResolutionScaling'), - } - }, - })) - .actions(self => ({ - /** - * #action - * replaces view in this case - */ - addView(typeName: string, initialState = {}) { - const typeDefinition = pluginManager.getElementType('view', typeName) - if (!typeDefinition) { - throw new Error(`unknown view type ${typeName}`) - } - - self.view = { - ...initialState, - type: typeName, - } - return self.view - }, - - /** - * #action - * does nothing - */ - removeView() {}, - })) - .views(self => ({ - /** - * #method - */ - getTrackActionMenuItems(config: any) { - return [ - { - label: 'About track', - onClick: () => { - self.queueDialog(doneCallback => [ - AboutDialog, - { config, handleClose: doneCallback }, - ]) - }, - icon: InfoIcon, - }, - ] - }, - })) -} - -export type SessionStateModel = ReturnType -export type SessionModel = Instance - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function z(x: Instance): AbstractSessionModel { - // this function's sole purpose is to get typescript to check - // that the session model implements all of AbstractSessionModel - return x -} diff --git a/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx b/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx new file mode 100644 index 0000000000..0ab7ca24c0 --- /dev/null +++ b/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx @@ -0,0 +1,550 @@ +import { lazy } from 'react' + +import { + isConfigurationModel, + readConfObject, +} from '@jbrowse/core/configuration' +import { + getContainingView, + isSessionModelWithWidgets, + localStorageGetItem, + localStorageSetItem, +} from '@jbrowse/core/util' +import { ElementId } from '@jbrowse/core/util/types/mst' +import { + addDisposer, + getMembers, + getParent, + getSnapshot, + getType, + isAlive, + isModelType, + isReferenceType, + types, + walk, +} from '@jbrowse/mobx-state-tree' +import { autorun, observable } from 'mobx' +import InfoIcon from '@mui/icons-material/Info' +import Report from '@mui/icons-material/Report' + +import type PluginManager from '@jbrowse/core/PluginManager' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' +import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import type { + AnyConfiguration, + AnyConfigurationModel, +} from '@jbrowse/core/configuration' +import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type { + AssemblyManager, + DialogComponentType, + NotificationLevel, + SnackAction, + TrackViewModel, +} from '@jbrowse/core/util' +import type { AbstractSessionModel } from '@jbrowse/core/util/types' +import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' + +// lazies +const AboutDialog = lazy(() => import('./AboutDialog')) +const ErrorMessageStackTraceDialog = lazy( + () => import('@jbrowse/core/ui/ErrorMessageStackTraceDialog'), +) + +const minDrawerWidth = 128 + +export interface ReferringNode { + node: IAnyStateTreeNode + key: string +} + +export interface SnackbarMessage { + message: string + level?: NotificationLevel + action?: SnackAction +} + +export interface CGVRootModel { + config: { + assembly: AnyConfigurationModel + assemblyName: string + connections: BaseConnectionConfigModel[] + configuration: AnyConfigurationModel + } + version: string + assemblyManager: AssemblyManager + rpcManager: RpcManager + textSearchManager: TextSearchManager + adminMode: boolean + jbrowse: { + tracks: AnyConfigurationModel[] + addTrackConf: (conf: AnyConfiguration) => AnyConfigurationModel + deleteTrackConf: (conf: AnyConfigurationModel) => AnyConfigurationModel + connections: BaseConnectionConfigModel[] + addConnectionConf: (conf: AnyConfigurationModel) => unknown + deleteConnectionConf: (conf: AnyConfigurationModel) => unknown + } +} + +/** + * #stateModel JBrowseReactCircularGenomeViewSessionModel + * + * Consolidated session model for jbrowse-react-circular-genome-view. + */ +export default function sessionModelFactory(pluginManager: PluginManager) { + const widgetStateModelType = pluginManager.pluggableMstType( + 'widget', + 'stateModel', + ) + type WidgetStateModel = Instance + + return types + .model('ReactCircularGenomeViewSession', { + // BaseSessionModel props + id: ElementId, + name: types.string, + margin: 0, + + // DrawerWidgetSessionMixin props + drawerPosition: types.optional( + types.string, + () => localStorageGetItem('drawerPosition') || 'right', + ), + drawerWidth: types.optional( + types.refinement(types.integer, width => width >= minDrawerWidth), + 384, + ), + widgets: types.map(widgetStateModelType), + activeWidgets: types.map(types.safeReference(widgetStateModelType)), + minimized: types.optional(types.boolean, false), + + // ConnectionManagementSessionMixin props + connectionInstances: types.array( + pluginManager.pluggableMstType('connection', 'stateModel'), + ), + + // View prop (single view for CGV) + view: pluginManager.getViewType('CircularView')!.stateModel, + }) + .volatile(() => ({ + // BaseSessionModel volatiles + selection: undefined as unknown, + hovered: undefined as unknown, + + // DialogQueueSessionMixin volatile + queueOfDialogs: [] as [DialogComponentType, unknown][], + + // SnackbarModel volatile + snackbarMessages: observable.array(), + + // Task volatile + task: undefined, + })) + .views(self => ({ + get root(): CGVRootModel { + return getParent(self) + }, + })) + .views(self => ({ + get jbrowse() { + return self.root.jbrowse + }, + get rpcManager() { + return self.root.rpcManager + }, + get configuration() { + return self.root.config.configuration + }, + get adminMode() { + return self.root.adminMode + }, + get textSearchManager() { + return self.root.textSearchManager + }, + get version() { + return self.root.version + }, + get assemblies() { + return [self.root.config.assembly] + }, + get assemblyNames() { + return [self.root.config.assemblyName] + }, + get connections(): BaseConnectionConfigModel[] { + return self.root.config.connections + }, + get assemblyManager() { + return self.root.assemblyManager + }, + + get views() { + return [self.view] + }, + + get visibleWidget() { + if (isAlive(self)) { + return [...self.activeWidgets.values()][self.activeWidgets.size - 1] + } + return undefined + }, + + get DialogComponent() { + return self.queueOfDialogs[0]?.[0] + }, + get DialogProps() { + return self.queueOfDialogs[0]?.[1] + }, + + get snackbarMessageSet() { + return new Map(self.snackbarMessages.map(s => [s.message, s])) + }, + + getReferring(object: IAnyStateTreeNode) { + const refs: ReferringNode[] = [] + walk(getParent(self), node => { + if (isModelType(getType(node))) { + const members = getMembers(node) + for (const [key, value] of Object.entries(members.properties)) { + if (isReferenceType(value) && node[key] === object) { + refs.push({ node, key }) + } + } + } + }) + return refs + }, + })) + .views(self => ({ + get tracks(): AnyConfigurationModel[] { + return self.jbrowse.tracks + }, + + renderProps() { + return { + theme: readConfObject(self.configuration, 'theme'), + highResolutionScaling: readConfObject( + self.configuration, + 'highResolutionScaling', + ), + } + }, + })) + .actions(self => ({ + setSelection(thing: unknown) { + self.selection = thing + }, + clearSelection() { + self.selection = undefined + }, + setHovered(thing: unknown) { + self.hovered = thing + }, + + removeActiveDialog() { + self.queueOfDialogs = self.queueOfDialogs.slice(1) + }, + queueDialog( + cb: (doneCallback: () => void) => [DialogComponentType, unknown], + ) { + const [component, props] = cb(() => { + this.removeActiveDialog() + }) + self.queueOfDialogs = [...self.queueOfDialogs, [component, props]] + }, + + setDrawerPosition(arg: string) { + self.drawerPosition = arg + localStorage.setItem('drawerPosition', arg) + }, + updateDrawerWidth(drawerWidth: number) { + if (drawerWidth === self.drawerWidth) { + return self.drawerWidth + } + let newDrawerWidth = drawerWidth + if (newDrawerWidth < minDrawerWidth) { + newDrawerWidth = minDrawerWidth + } + self.drawerWidth = newDrawerWidth + return newDrawerWidth + }, + resizeDrawer(distance: number) { + if (self.drawerPosition === 'left') { + distance *= -1 + } + const oldDrawerWidth = self.drawerWidth + const newDrawerWidth = this.updateDrawerWidth(oldDrawerWidth - distance) + return oldDrawerWidth - newDrawerWidth + }, + addWidget( + typeName: string, + id: string, + initialState = {}, + conf?: unknown, + ) { + const typeDefinition = pluginManager.getElementType('widget', typeName) + if (!typeDefinition) { + throw new Error(`unknown widget type ${typeName}`) + } + const data = { + ...initialState, + id, + type: typeName, + configuration: conf || { type: typeName }, + } + self.widgets.set(id, data) + return self.widgets.get(id) + }, + showWidget(widget: WidgetStateModel) { + if (self.activeWidgets.has(widget.id)) { + self.activeWidgets.delete(widget.id) + } + self.activeWidgets.set(widget.id, widget) + self.minimized = false + }, + hasWidget(widget: WidgetStateModel) { + return self.activeWidgets.has(widget.id) + }, + hideWidget(widget: WidgetStateModel) { + self.activeWidgets.delete(widget.id) + }, + minimizeWidgetDrawer() { + self.minimized = true + }, + showWidgetDrawer() { + self.minimized = false + }, + hideAllWidgets() { + self.activeWidgets.clear() + }, + editConfiguration(configuration: AnyConfigurationModel) { + if (!isConfigurationModel(configuration)) { + throw new Error( + 'must pass a configuration model to editConfiguration', + ) + } + const editor = this.addWidget( + 'ConfigurationEditorWidget', + 'configEditor', + { target: configuration }, + ) + this.showWidget(editor) + }, + + removeReferring( + referring: ReferringNode[], + track: BaseTrackConfig, + callbacks: (() => void)[], + dereferenceTypeCount: Record, + ) { + for (const { node } of referring) { + let dereferenced = false + try { + const type = 'open track(s)' + const view = getContainingView(node) as TrackViewModel + callbacks.push(() => { + view.hideTrack(track.trackId) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } catch { + // ignore + } + + if (isSessionModelWithWidgets(self) && self.widgets.has(node.id)) { + const type = 'configuration editor widget(s)' + callbacks.push(() => { + this.hideWidget(node) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } + if (!dereferenced) { + throw new Error( + `Error when closing this connection, the following node is still referring to a track configuration: ${JSON.stringify( + getSnapshot(node), + )}`, + ) + } + } + }, + + makeConnection( + configuration: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const type = configuration.type as string + if (!type) { + throw new Error('track configuration has no `type` listed') + } + const name = readConfObject(configuration, 'name') + const connectionType = pluginManager.getConnectionType(type) + if (!connectionType) { + throw new Error(`unknown connection type ${type}`) + } + const length = self.connectionInstances.push({ + ...initialSnapshot, + name, + type, + configuration, + }) + return self.connectionInstances[length - 1] + }, + prepareToBreakConnection(configuration: AnyConfigurationModel) { + const callbacksToDeref: (() => void)[] = [] + const derefTypeCount: Record = {} + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + return undefined + } + for (const track of connection.tracks) { + const ref = self.getReferring(track) + this.removeReferring(ref, track, callbacksToDeref, derefTypeCount) + } + return [ + () => { + for (const cb of callbacksToDeref) { + cb() + } + this.breakConnection(configuration) + }, + derefTypeCount, + ] + }, + breakConnection(configuration: AnyConfigurationModel) { + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + throw new Error(`no connection found with name ${name}`) + } + self.connectionInstances.remove(connection) + }, + deleteConnection(configuration: AnyConfigurationModel) { + return self.jbrowse.deleteConnectionConf(configuration) + }, + addConnectionConf(connectionConf: AnyConfigurationModel) { + return self.jbrowse.addConnectionConf(connectionConf) + }, + clearConnections() { + self.connectionInstances.clear() + }, + + addTrackConf(trackConf: AnyConfiguration) { + return self.jbrowse.addTrackConf(trackConf) + }, + deleteTrackConf(trackConf: AnyConfigurationModel) { + const callbacksToDereferenceTrack: (() => void)[] = [] + const dereferenceTypeCount: Record = {} + const referring = self.getReferring(trackConf) + this.removeReferring( + referring, + trackConf, + callbacksToDereferenceTrack, + dereferenceTypeCount, + ) + for (const cb of callbacksToDereferenceTrack) { + cb() + } + if (self.adminMode) { + return self.jbrowse.deleteTrackConf(trackConf) + } + return undefined + }, + + addView(typeName: string, initialState = {}) { + const typeDefinition = pluginManager.getElementType('view', typeName) + if (!typeDefinition) { + throw new Error(`unknown view type ${typeName}`) + } + self.view = { + ...initialState, + type: typeName, + } + return self.view + }, + removeView() {}, + + notify(message: string, level?: NotificationLevel, action?: SnackAction) { + this.pushSnackbarMessage(message, level, action) + if (level === 'info' || level === 'success') { + setTimeout(() => { + this.removeSnackbarMessage(message) + }, 5000) + } + }, + notifyError(errorMessage: string, error?: unknown, extra?: unknown) { + this.notify(errorMessage, 'error', { + name: , + onClick: () => { + this.queueDialog((onClose: () => void) => [ + ErrorMessageStackTraceDialog, + { onClose, error, extra }, + ]) + }, + }) + }, + pushSnackbarMessage( + message: string, + level?: NotificationLevel, + action?: SnackAction, + ) { + if (action || !self.snackbarMessageSet.has(message)) { + self.snackbarMessages.push({ message, level, action }) + } + }, + popSnackbarMessage() { + return self.snackbarMessages.pop() + }, + removeSnackbarMessage(message: string) { + const element = self.snackbarMessageSet.get(message) + if (element !== undefined) { + self.snackbarMessages.remove(element) + } + }, + + afterAttach() { + addDisposer( + self, + autorun( + function drawerPositionAutorun() { + localStorageSetItem('drawerPosition', self.drawerPosition) + }, + { name: 'DrawerPosition' }, + ), + ) + }, + })) + .views(self => ({ + getTrackActionMenuItems(config: AnyConfigurationModel) { + return [ + { + label: 'About track', + onClick: () => { + self.queueDialog(doneCallback => [ + AboutDialog, + { config, handleClose: doneCallback }, + ]) + }, + icon: InfoIcon, + }, + ] + }, + })) +} + +export type CGVSessionModelType = ReturnType +export type CGVSessionModel = Instance + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function z(x: Instance): AbstractSessionModel { + // this function's sole purpose is to get typescript to check + // that the session model implements all of AbstractSessionModel + return x +} diff --git a/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.ts b/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.ts deleted file mode 100644 index fd078cbb76..0000000000 --- a/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { lazy } from 'react' - -import { getConf } from '@jbrowse/core/configuration' -import SnackbarModel from '@jbrowse/core/ui/SnackbarModel' -import { cast, getParent, types } from '@jbrowse/mobx-state-tree' -import { - BaseSessionModel, - ConnectionManagementSessionMixin, - DialogQueueSessionMixin, - DrawerWidgetSessionMixin, - ReferenceManagementSessionMixin, - SessionTracksManagerSessionMixin, - TracksManagerSessionMixin, -} from '@jbrowse/product-core' -import InfoIcon from '@mui/icons-material/Info' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { AbstractSessionModel } from '@jbrowse/core/util/types' -import type { Instance } from '@jbrowse/mobx-state-tree' -import type { LinearGenomeViewStateModel } from '@jbrowse/plugin-linear-genome-view' - -const AboutDialog = lazy(() => import('./AboutDialog')) - -/** - * #stateModel JBrowseReactLinearGenomeViewSessionModel - * composed of - * - [BaseSessionModel](../basesessionmodel) - * - [DrawerWidgetSessionMixin](../drawerwidgetsessionmixin) - * - [ConnectionManagementSessionMixin](../connectionmanagementsessionmixin) - * - [DialogQueueSessionMixin](../dialogqueuesessionmixin) - * - [TracksManagerSessionMixin](../tracksmanagersessionmixin) - * - [ReferenceManagementSessionMixin](../referencemanagementsessionmixin) - * - [SessionTracksManagerSessionMixin](../sessiontracksmanagersessionmixin) - * - [SnackbarModel](../snackbarmodel) - */ -function x() {} // eslint-disable-line @typescript-eslint/no-unused-vars - -export default function sessionModelFactory(pluginManager: PluginManager) { - return types - .compose( - 'ReactLinearGenomeViewSession', - BaseSessionModel(pluginManager), - DrawerWidgetSessionMixin(pluginManager), - ConnectionManagementSessionMixin(pluginManager), - DialogQueueSessionMixin(pluginManager), - TracksManagerSessionMixin(pluginManager), - ReferenceManagementSessionMixin(pluginManager), - SessionTracksManagerSessionMixin(pluginManager), - SnackbarModel(), - ) - .props({ - /** - * #property - */ - view: pluginManager.getViewType('LinearGenomeView')! - .stateModel as LinearGenomeViewStateModel, - /** - * #property - */ - sessionTracks: types.array( - pluginManager.pluggableConfigSchemaType('track'), - ), - }) - .views(self => ({ - /** - * #getter - */ - get version() { - return getParent(self).version - }, - /** - * #getter - */ - get disableAddTracks() { - return getParent(self).disableAddTracks - }, - /** - * #getter - */ - get assemblies() { - return [getParent(self).config.assembly] - }, - /** - * #getter - */ - get assemblyNames() { - return [getParent(self).config.assemblyName] - }, - /** - * #getter - */ - get connections() { - return getParent(self).config.connections - }, - /** - * #getter - */ - get assemblyManager() { - return getParent(self).assemblyManager - }, - /** - * #getter - */ - get views() { - return [self.view] - }, - /** - * #method - */ - renderProps() { - return { - theme: getConf(self, 'theme'), - highResolutionScaling: getConf(self, 'highResolutionScaling'), - } - }, - })) - .actions(self => ({ - /** - * #action - */ - addView(typeName: string, initialState = {}) { - const typeDefinition = pluginManager.getElementType('view', typeName) - if (!typeDefinition) { - throw new Error(`unknown view type ${typeName}`) - } - - self.view = cast({ - ...initialState, - type: typeName, - }) - return self.view - }, - - removeView() {}, - })) - .views(self => ({ - /** - * #method - */ - getTrackActionMenuItems(config: any) { - return [ - { - label: 'About track', - onClick: () => { - self.queueDialog(doneCallback => [ - AboutDialog, - { config, handleClose: doneCallback }, - ]) - }, - icon: InfoIcon, - }, - ] - }, - })) -} - -export type SessionStateModel = ReturnType -export type SessionModel = Instance - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function z(x: Instance): AbstractSessionModel { - // this function's sole purpose is to get typescript to check - // that the session model implements all of AbstractSessionModel - return x -} diff --git a/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx b/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx new file mode 100644 index 0000000000..97dd0a5855 --- /dev/null +++ b/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx @@ -0,0 +1,613 @@ +import { lazy } from 'react' + +import { isConfigurationModel } from '@jbrowse/core/configuration' +import { + getContainingView, + isSessionModelWithWidgets, + localStorageGetItem, + localStorageSetItem, +} from '@jbrowse/core/util' +import { ElementId } from '@jbrowse/core/util/types/mst' +import { + addDisposer, + cast, + getMembers, + getParent, + getSnapshot, + getType, + isAlive, + isModelType, + isReferenceType, + types, + walk, +} from '@jbrowse/mobx-state-tree' +import { readConfObject } from '@jbrowse/core/configuration' +import { autorun, observable } from 'mobx' +import InfoIcon from '@mui/icons-material/Info' +import Report from '@mui/icons-material/Report' + +import type PluginManager from '@jbrowse/core/PluginManager' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' +import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import type { + AnyConfiguration, + AnyConfigurationModel, +} from '@jbrowse/core/configuration' +import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type { + AssemblyManager, + DialogComponentType, + NotificationLevel, + SnackAction, + TrackViewModel, +} from '@jbrowse/core/util' +import type { AbstractSessionModel } from '@jbrowse/core/util/types' +import type { + IAnyStateTreeNode, + Instance, +} from '@jbrowse/mobx-state-tree' +import type { LinearGenomeViewStateModel } from '@jbrowse/plugin-linear-genome-view' + +// lazies +const AboutDialog = lazy(() => import('./AboutDialog')) +const ErrorMessageStackTraceDialog = lazy( + () => import('@jbrowse/core/ui/ErrorMessageStackTraceDialog'), +) + +const minDrawerWidth = 128 + +export interface ReferringNode { + node: IAnyStateTreeNode + key: string +} + +export interface SnackbarMessage { + message: string + level?: NotificationLevel + action?: SnackAction +} + +export interface LGVRootModel { + config: { + assembly: AnyConfigurationModel + assemblyName: string + connections: BaseConnectionConfigModel[] + configuration: AnyConfigurationModel + } + version: string + disableAddTracks: boolean + assemblyManager: AssemblyManager + rpcManager: RpcManager + textSearchManager: TextSearchManager + adminMode: boolean + jbrowse: { + tracks: AnyConfigurationModel[] + addTrackConf: (conf: AnyConfiguration) => AnyConfigurationModel + deleteTrackConf: (conf: AnyConfigurationModel) => AnyConfigurationModel + connections: BaseConnectionConfigModel[] + addConnectionConf: (conf: AnyConfigurationModel) => unknown + deleteConnectionConf: (conf: AnyConfigurationModel) => unknown + } +} + +/** + * #stateModel JBrowseReactLinearGenomeViewSessionModel + * + * Consolidated session model for jbrowse-react-linear-genome-view. + * Includes functionality previously split across mixins: + * - BaseSessionModel + * - DrawerWidgetSessionMixin + * - ConnectionManagementSessionMixin + * - DialogQueueSessionMixin + * - TracksManagerSessionMixin + * - ReferenceManagementSessionMixin + * - SessionTracksManagerSessionMixin + * - SnackbarModel + */ +export default function sessionModelFactory(pluginManager: PluginManager) { + const widgetStateModelType = pluginManager.pluggableMstType( + 'widget', + 'stateModel', + ) + type WidgetStateModel = Instance + + return types + .model('ReactLinearGenomeViewSession', { + // BaseSessionModel props + id: ElementId, + name: types.string, + margin: 0, + + // DrawerWidgetSessionMixin props + drawerPosition: types.optional( + types.string, + () => localStorageGetItem('drawerPosition') || 'right', + ), + drawerWidth: types.optional( + types.refinement(types.integer, width => width >= minDrawerWidth), + 384, + ), + widgets: types.map(widgetStateModelType), + activeWidgets: types.map(types.safeReference(widgetStateModelType)), + minimized: types.optional(types.boolean, false), + + // ConnectionManagementSessionMixin props + connectionInstances: types.array( + pluginManager.pluggableMstType('connection', 'stateModel'), + ), + + // View prop (single view for LGV) + view: pluginManager.getViewType('LinearGenomeView')! + .stateModel as LinearGenomeViewStateModel, + + // SessionTracksManagerSessionMixin props + sessionTracks: types.array( + pluginManager.pluggableConfigSchemaType('track'), + ), + }) + .volatile(() => ({ + // BaseSessionModel volatiles + selection: undefined as unknown, + hovered: undefined as unknown, + + // DialogQueueSessionMixin volatile + queueOfDialogs: [] as [DialogComponentType, unknown][], + + // SnackbarModel volatile + snackbarMessages: observable.array(), + })) + .views(self => ({ + // Root access + get root(): LGVRootModel { + return getParent(self) + }, + })) + .views(self => ({ + // Properties from root + get jbrowse() { + return self.root.jbrowse + }, + get rpcManager() { + return self.root.rpcManager + }, + get configuration() { + return self.root.config.configuration + }, + get adminMode() { + return self.root.adminMode + }, + get textSearchManager() { + return self.root.textSearchManager + }, + get version() { + return self.root.version + }, + get disableAddTracks() { + return self.root.disableAddTracks + }, + get assemblies() { + return [self.root.config.assembly] + }, + get assemblyNames() { + return [self.root.config.assemblyName] + }, + get connections(): BaseConnectionConfigModel[] { + return self.root.config.connections + }, + get assemblyManager() { + return self.root.assemblyManager + }, + + // View access + get views() { + return [self.view] + }, + + // DrawerWidgetSessionMixin views + get visibleWidget() { + if (isAlive(self)) { + return [...self.activeWidgets.values()][self.activeWidgets.size - 1] + } + return undefined + }, + + // DialogQueueSessionMixin views + get DialogComponent() { + return self.queueOfDialogs[0]?.[0] + }, + get DialogProps() { + return self.queueOfDialogs[0]?.[1] + }, + + // SnackbarModel views + get snackbarMessageSet() { + return new Map(self.snackbarMessages.map(s => [s.message, s])) + }, + + // ReferenceManagementSessionMixin views + getReferring(object: IAnyStateTreeNode) { + const refs: ReferringNode[] = [] + walk(getParent(self), node => { + if (isModelType(getType(node))) { + const members = getMembers(node) + for (const [key, value] of Object.entries(members.properties)) { + if (isReferenceType(value) && node[key] === object) { + refs.push({ node, key }) + } + } + } + }) + return refs + }, + })) + .views(self => ({ + // Tracks views - depends on jbrowse from previous views block + get tracks(): AnyConfigurationModel[] { + return [...self.sessionTracks, ...self.jbrowse.tracks] + }, + + renderProps() { + return { + theme: readConfObject(self.configuration, 'theme'), + highResolutionScaling: readConfObject( + self.configuration, + 'highResolutionScaling', + ), + } + }, + })) + .actions(self => ({ + // BaseSessionModel actions + setSelection(thing: unknown) { + self.selection = thing + }, + clearSelection() { + self.selection = undefined + }, + setHovered(thing: unknown) { + self.hovered = thing + }, + + // DialogQueueSessionMixin actions + removeActiveDialog() { + self.queueOfDialogs = self.queueOfDialogs.slice(1) + }, + queueDialog( + cb: (doneCallback: () => void) => [DialogComponentType, unknown], + ) { + const [component, props] = cb(() => { + this.removeActiveDialog() + }) + self.queueOfDialogs = [...self.queueOfDialogs, [component, props]] + }, + + // DrawerWidgetSessionMixin actions + setDrawerPosition(arg: string) { + self.drawerPosition = arg + localStorage.setItem('drawerPosition', arg) + }, + updateDrawerWidth(drawerWidth: number) { + if (drawerWidth === self.drawerWidth) { + return self.drawerWidth + } + let newDrawerWidth = drawerWidth + if (newDrawerWidth < minDrawerWidth) { + newDrawerWidth = minDrawerWidth + } + self.drawerWidth = newDrawerWidth + return newDrawerWidth + }, + resizeDrawer(distance: number) { + if (self.drawerPosition === 'left') { + distance *= -1 + } + const oldDrawerWidth = self.drawerWidth + const newDrawerWidth = this.updateDrawerWidth(oldDrawerWidth - distance) + return oldDrawerWidth - newDrawerWidth + }, + addWidget( + typeName: string, + id: string, + initialState = {}, + conf?: unknown, + ) { + const typeDefinition = pluginManager.getElementType('widget', typeName) + if (!typeDefinition) { + throw new Error(`unknown widget type ${typeName}`) + } + const data = { + ...initialState, + id, + type: typeName, + configuration: conf || { type: typeName }, + } + self.widgets.set(id, data) + return self.widgets.get(id) + }, + showWidget(widget: WidgetStateModel) { + if (self.activeWidgets.has(widget.id)) { + self.activeWidgets.delete(widget.id) + } + self.activeWidgets.set(widget.id, widget) + self.minimized = false + }, + hasWidget(widget: WidgetStateModel) { + return self.activeWidgets.has(widget.id) + }, + hideWidget(widget: WidgetStateModel) { + self.activeWidgets.delete(widget.id) + }, + minimizeWidgetDrawer() { + self.minimized = true + }, + showWidgetDrawer() { + self.minimized = false + }, + hideAllWidgets() { + self.activeWidgets.clear() + }, + editConfiguration(configuration: AnyConfigurationModel) { + if (!isConfigurationModel(configuration)) { + throw new Error( + 'must pass a configuration model to editConfiguration', + ) + } + const editor = this.addWidget( + 'ConfigurationEditorWidget', + 'configEditor', + { target: configuration }, + ) + this.showWidget(editor) + }, + + // ReferenceManagementSessionMixin actions + removeReferring( + referring: ReferringNode[], + track: BaseTrackConfig, + callbacks: (() => void)[], + dereferenceTypeCount: Record, + ) { + for (const { node } of referring) { + let dereferenced = false + try { + const type = 'open track(s)' + const view = getContainingView(node) as TrackViewModel + callbacks.push(() => { + view.hideTrack(track.trackId) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } catch { + // ignore + } + + if (isSessionModelWithWidgets(self) && self.widgets.has(node.id)) { + const type = 'configuration editor widget(s)' + callbacks.push(() => { + this.hideWidget(node) + }) + dereferenced = true + if (!dereferenceTypeCount[type]) { + dereferenceTypeCount[type] = 0 + } + dereferenceTypeCount[type] += 1 + } + if (!dereferenced) { + throw new Error( + `Error when closing this connection, the following node is still referring to a track configuration: ${JSON.stringify( + getSnapshot(node), + )}`, + ) + } + } + }, + + // ConnectionManagementSessionMixin actions + makeConnection( + configuration: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const type = configuration.type as string + if (!type) { + throw new Error('track configuration has no `type` listed') + } + const name = readConfObject(configuration, 'name') + const connectionType = pluginManager.getConnectionType(type) + if (!connectionType) { + throw new Error(`unknown connection type ${type}`) + } + const length = self.connectionInstances.push({ + ...initialSnapshot, + name, + type, + configuration, + }) + return self.connectionInstances[length - 1] + }, + prepareToBreakConnection(configuration: AnyConfigurationModel) { + const callbacksToDeref: (() => void)[] = [] + const derefTypeCount: Record = {} + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + return undefined + } + for (const track of connection.tracks) { + const ref = self.getReferring(track) + this.removeReferring(ref, track, callbacksToDeref, derefTypeCount) + } + return [ + () => { + for (const cb of callbacksToDeref) { + cb() + } + this.breakConnection(configuration) + }, + derefTypeCount, + ] + }, + breakConnection(configuration: AnyConfigurationModel) { + const name = readConfObject(configuration, 'name') + const connection = self.connectionInstances.find(c => c.name === name) + if (!connection) { + throw new Error(`no connection found with name ${name}`) + } + self.connectionInstances.remove(connection) + }, + deleteConnection(configuration: AnyConfigurationModel) { + return self.jbrowse.deleteConnectionConf(configuration) + }, + addConnectionConf(connectionConf: AnyConfigurationModel) { + return self.jbrowse.addConnectionConf(connectionConf) + }, + clearConnections() { + self.connectionInstances.clear() + }, + + // TracksManagerSessionMixin / SessionTracksManagerSessionMixin actions + addTrackConf(trackConf: AnyConfiguration) { + if (self.adminMode) { + return self.jbrowse.addTrackConf(trackConf) + } + + const { trackId, type } = trackConf as { + type: string + trackId: string + } + if (!type) { + throw new Error(`unknown track type ${type}`) + } + const track = self.sessionTracks.find(t => t.trackId === trackId) + if (track) { + return track + } + const length = self.sessionTracks.push(trackConf) + return self.sessionTracks[length - 1] + }, + deleteTrackConf(trackConf: AnyConfigurationModel) { + const callbacksToDereferenceTrack: (() => void)[] = [] + const dereferenceTypeCount: Record = {} + const referring = self.getReferring(trackConf) + this.removeReferring( + referring, + trackConf, + callbacksToDereferenceTrack, + dereferenceTypeCount, + ) + for (const cb of callbacksToDereferenceTrack) { + cb() + } + + // try to delete from jbrowse config if in admin mode + if (self.adminMode) { + const found = self.jbrowse.deleteTrackConf(trackConf) + if (found) { + return found + } + } + + // try to delete from sessionTracks + const { trackId } = trackConf + const idx = self.sessionTracks.findIndex(t => t.trackId === trackId) + if (idx === -1) { + return undefined + } + return self.sessionTracks.splice(idx, 1) + }, + + // View actions + addView(typeName: string, initialState = {}) { + const typeDefinition = pluginManager.getElementType('view', typeName) + if (!typeDefinition) { + throw new Error(`unknown view type ${typeName}`) + } + self.view = cast({ + ...initialState, + type: typeName, + }) + return self.view + }, + removeView() {}, + + // SnackbarModel actions + notify(message: string, level?: NotificationLevel, action?: SnackAction) { + this.pushSnackbarMessage(message, level, action) + if (level === 'info' || level === 'success') { + setTimeout(() => { + this.removeSnackbarMessage(message) + }, 5000) + } + }, + notifyError(errorMessage: string, error?: unknown, extra?: unknown) { + this.notify(errorMessage, 'error', { + name: , + onClick: () => { + this.queueDialog((onClose: () => void) => [ + ErrorMessageStackTraceDialog, + { onClose, error, extra }, + ]) + }, + }) + }, + pushSnackbarMessage( + message: string, + level?: NotificationLevel, + action?: SnackAction, + ) { + if (action || !self.snackbarMessageSet.has(message)) { + self.snackbarMessages.push({ message, level, action }) + } + }, + popSnackbarMessage() { + return self.snackbarMessages.pop() + }, + removeSnackbarMessage(message: string) { + const element = self.snackbarMessageSet.get(message) + if (element !== undefined) { + self.snackbarMessages.remove(element) + } + }, + + afterAttach() { + addDisposer( + self, + autorun( + function drawerPositionAutorun() { + localStorageSetItem('drawerPosition', self.drawerPosition) + }, + { name: 'DrawerPosition' }, + ), + ) + }, + })) + .views(self => ({ + getTrackActionMenuItems(config: AnyConfigurationModel) { + return [ + { + label: 'About track', + onClick: () => { + self.queueDialog(doneCallback => [ + AboutDialog, + { config, handleClose: doneCallback }, + ]) + }, + icon: InfoIcon, + }, + ] + }, + })) +} + +export type LGVSessionModelType = ReturnType +export type LGVSessionModel = Instance + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function z(x: Instance): AbstractSessionModel { + // this function's sole purpose is to get typescript to check + // that the session model implements all of AbstractSessionModel + return x +} diff --git a/products/jbrowse-web/src/rootModel/rootModel.ts b/products/jbrowse-web/src/rootModel/rootModel.ts index 52d4f7eb79..bf653c21b2 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.ts @@ -3,18 +3,24 @@ import { lazy } from 'react' import { HistoryManagementMixin, RootAppMenuMixin, + getOpenTrackMenuItem, + getOpenConnectionMenuItem, + getUndoMenuItem, + getRedoMenuItem, + getPluginStoreMenuItem, + getImportSessionMenuItem, + getExportSessionMenuItem, processMutableMenuActions, } from '@jbrowse/app-core' +import type { Menu, SessionModelFactory } from '@jbrowse/app-core' import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import assemblyConfigSchemaFactory from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import { readConfObject } from '@jbrowse/core/configuration' import RpcManager from '@jbrowse/core/rpc/RpcManager' -import { Cable, DNA } from '@jbrowse/core/ui/Icons' +import { DNA } from '@jbrowse/core/ui/Icons' import { addDisposer, - cast, getSnapshot, - getType, isAlive, types, } from '@jbrowse/mobx-state-tree' @@ -24,16 +30,10 @@ import { InternetAccountsRootModelMixin, } from '@jbrowse/product-core' import AddIcon from '@mui/icons-material/Add' -import ExtensionIcon from '@mui/icons-material/Extension' import FileCopyIcon from '@mui/icons-material/FileCopy' import FolderOpenIcon from '@mui/icons-material/FolderOpen' -import GetAppIcon from '@mui/icons-material/GetApp' -import PublishIcon from '@mui/icons-material/Publish' -import RedoIcon from '@mui/icons-material/Redo' import SettingsIcon from '@mui/icons-material/Settings' import StarIcon from '@mui/icons-material/Star' -import StorageIcon from '@mui/icons-material/Storage' -import UndoIcon from '@mui/icons-material/Undo' import { formatDistanceToNow } from 'date-fns' import { openDB } from 'idb' import { autorun } from 'mobx' @@ -41,21 +41,14 @@ import { autorun } from 'mobx' import packageJSON from '../../package.json' import jbrowseWebFactory from '../jbrowseModel' import makeWorkerInstance from '../makeWorkerInstance' -import { filterSessionInPlace } from '../util' import type { SessionDB, SessionMetadata } from '../types' -import type { Menu } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' import type { AbstractSessionModel, SessionWithWidgets, } from '@jbrowse/core/util' -import type { - IAnyStateTreeNode, - IAnyType, - Instance, - SnapshotIn, -} from '@jbrowse/mobx-state-tree' +import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' import type { BaseSessionType, SessionWithDialogs } from '@jbrowse/product-core' import type { IDBPDatabase } from 'idb' @@ -64,10 +57,6 @@ const SetDefaultSession = lazy(() => import('../components/SetDefaultSession')) const PreferencesDialog = lazy(() => import('../components/PreferencesDialog')) type AssemblyConfig = ReturnType -type SessionModelFactory = (args: { - pluginManager: PluginManager - assemblyConfigSchema: AssemblyConfig -}) => IAnyType /** * #stateModel JBrowseWebRootModel @@ -313,24 +302,6 @@ export default function RootModel({ ) })() }, - /** - * #action - */ - setSession(sessionSnapshot: SnapshotIn) { - const oldSession = self.session - self.session = cast(sessionSnapshot) - if (self.session) { - // validate all references in the session snapshot - try { - filterSessionInPlace(self.session, getType(self.session)) - } catch (error) { - // throws error if session filtering failed - self.session = oldSession - throw error - } - } - }, - /** * #action */ @@ -353,7 +324,10 @@ export default function RootModel({ */ setDefaultSession() { const { defaultSession } = self.jbrowse - this.setSession({ + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + setSession({ ...defaultSession, name: `${defaultSession.name || 'New session'} ${new Date().toLocaleString()}`, }) @@ -364,7 +338,10 @@ export default function RootModel({ async activateSession(id: string) { const ret = await self.sessionDB?.get('sessions', id) if (ret) { - this.setSession(ret) + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + setSession(ret) } else { self.session.notifyError('Session not found') } @@ -421,8 +398,12 @@ export default function RootModel({ * #action */ renameCurrentSession(sessionName: string) { - this.setSession({ - ...getSnapshot(self.session), + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + const snapshot = getSnapshot(self.session) as Record + setSession({ + ...snapshot, name: sessionName, }) }, @@ -463,40 +444,8 @@ export default function RootModel({ self.setDefaultSession() }, }, - { - label: 'Import session...', - icon: PublishIcon, - onClick: (session: SessionWithWidgets) => { - const widget = session.addWidget( - 'ImportSessionWidget', - 'importSessionWidget', - ) - session.showWidget(widget) - }, - }, - { - label: 'Export session', - icon: GetAppIcon, - onClick: async (session: IAnyStateTreeNode) => { - // eslint-disable-next-line @typescript-eslint/no-deprecated - const { saveAs } = await import('file-saver-es') - - saveAs( - new Blob( - [ - JSON.stringify( - { session: getSnapshot(session) }, - null, - 2, - ), - ], - { type: 'text/plain;charset=utf-8' }, - ), - 'session.json', - { autoBom: false }, - ) - }, - }, + getImportSessionMenuItem(), + getExportSessionMenuItem(), { label: 'Duplicate session', icon: FileCopyIcon, @@ -590,39 +539,8 @@ export default function RootModel({ : [{ label: 'No autosaves found', onClick: () => {} }], }, { type: 'divider' }, - { - label: 'Open track...', - icon: StorageIcon, - onClick: (session: SessionWithWidgets) => { - if (session.views.length === 0) { - session.notify('Please open a view to add a track first') - } else if (session.views.length > 0) { - const widget = session.addWidget( - 'AddTrackWidget', - 'addTrackWidget', - { view: session.views[0]!.id }, - ) - session.showWidget(widget) - if (session.views.length > 1) { - session.notify( - 'This will add a track to the first view. Note: if you want to open a track in a specific view open the track selector for that view and use the add track (plus icon) in the bottom right', - ) - } - } - }, - }, - { - label: 'Open connection...', - icon: Cable, - onClick: (session: SessionWithWidgets) => { - session.showWidget( - session.addWidget( - 'AddConnectionWidget', - 'addConnectionWidget', - ), - ) - }, - }, + getOpenTrackMenuItem(), + getOpenConnectionMenuItem(), ] }, }, @@ -654,39 +572,10 @@ export default function RootModel({ { label: 'Tools', menuItems: [ - { - label: 'Undo', - icon: UndoIcon, - onClick: () => { - if (self.history.canUndo) { - self.history.undo() - } - }, - }, - { - label: 'Redo', - icon: RedoIcon, - onClick: () => { - if (self.history.canRedo) { - self.history.redo() - } - }, - }, + getUndoMenuItem(() => self.history), + getRedoMenuItem(() => self.history), { type: 'divider' }, - { - label: 'Plugin store', - icon: ExtensionIcon, - onClick: () => { - if (self.session) { - self.session.showWidget( - self.session.addWidget( - 'PluginStoreWidget', - 'pluginStoreWidget', - ), - ) - } - }, - }, + getPluginStoreMenuItem(() => self.session), { label: 'Assembly manager', icon: DNA, diff --git a/products/jbrowse-web/src/sessionModel/index.ts b/products/jbrowse-web/src/sessionModel/index.ts index 03f1cf6882..680771fbc1 100644 --- a/products/jbrowse-web/src/sessionModel/index.ts +++ b/products/jbrowse-web/src/sessionModel/index.ts @@ -16,7 +16,10 @@ export default function sessionModelFactory({ pluginManager: PluginManager assemblyConfigSchema: BaseAssemblyConfigSchema }) { - return BaseWebSession({ pluginManager, assemblyConfigSchema }) + return BaseWebSession({ + pluginManager, + assemblyConfigSchema, + }) } export type WebSessionModelType = ReturnType diff --git a/products/jbrowse-web/src/util.ts b/products/jbrowse-web/src/util.ts index 29474a9ba7..75c7a9d6e8 100644 --- a/products/jbrowse-web/src/util.ts +++ b/products/jbrowse-web/src/util.ts @@ -3,23 +3,8 @@ import { isESMPluginDefinition, isUMDPluginDefinition, } from '@jbrowse/core/PluginLoader' -import { - getChildType, - getPropertyMembers, - isArrayType, - isMapType, - isModelType, - isReferenceType, - isValidReference, -} from '@jbrowse/mobx-state-tree' import type { PluginDefinition } from '@jbrowse/core/PluginLoader' -import type { - IAnyStateTreeNode, - IAnyType, - Instance, - types, -} from '@jbrowse/mobx-state-tree' /** * Pad the end of a base64 string with "=" to make it valid @@ -78,62 +63,6 @@ export async function toUrlSafeB64(str: string) { : encoded.replaceAll('+', '-').replaceAll('/', '_') } -type MSTArray = Instance> -type MSTMap = Instance> - -// attempts to remove undefined references from the given MST model. can only -// actually remove them from arrays and maps. throws MST undefined ref error if -// it encounters undefined refs in model properties -export function filterSessionInPlace(node: IAnyStateTreeNode, type: IAnyType) { - // makes it work with session sharing - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (node === undefined) { - return - } - if (isArrayType(type)) { - const array = node as MSTArray - const childType = getChildType(node) - if (isReferenceType(childType)) { - // filter array elements - for (let i = 0; i < array.length; ) { - if (!isValidReference(() => array[i])) { - array.splice(i, 1) - } else { - i += 1 - } - } - } - // eslint-disable-next-line unicorn/no-array-for-each - array.forEach(el => { - filterSessionInPlace(el, childType) - }) - } else if (isMapType(type)) { - const map = node as MSTMap - const childType = getChildType(map) - if (isReferenceType(childType)) { - // filter the map members - for (const key in map.keys()) { - if (!isValidReference(() => map.get(key))) { - map.delete(key) - } - } - } - // eslint-disable-next-line unicorn/no-array-for-each - map.forEach(child => { - filterSessionInPlace(child, childType) - }) - } else if (isModelType(type)) { - // iterate over children - const { properties } = getPropertyMembers(node) - - // eslint-disable-next-line unicorn/no-array-for-each - Object.entries(properties).forEach(([pname, ptype]) => { - filterSessionInPlace(node[pname], ptype) - }) - } -} - export function addRelativeUris( config: Record | null, base: URL, From caea470dade89b132550b90119c9d23b75c608f4 Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 5 Dec 2025 10:53:58 -0500 Subject: [PATCH 2/8] Updatess --- packages/app-core/src/AppFocus/index.ts | 24 - .../src/Assemblies/SessionAssembliesMixin.ts | 81 --- .../Assemblies/TemporaryAssembliesMixin.ts | 46 -- packages/app-core/src/Assemblies/index.ts | 2 - .../app-core/src/HistoryManagement/index.ts | 68 -- packages/app-core/src/RootMenu/index.ts | 147 ----- packages/app-core/src/index.ts | 4 - packages/app-core/src/menuItems.ts | 5 +- .../src/RootModel/InternetAccounts.ts | 139 ---- packages/product-core/src/RootModel/index.ts | 1 - .../product-core/src/Session/Connections.ts | 152 ----- .../product-core/src/Session/DialogQueue.ts | 60 -- .../product-core/src/Session/DrawerWidgets.ts | 219 ------- .../product-core/src/Session/MultipleViews.ts | 145 ---- .../src/Session/ReferenceManagement.ts | 137 ---- .../product-core/src/Session/SessionTracks.ts | 99 --- packages/product-core/src/Session/Themes.ts | 87 --- packages/product-core/src/Session/Tracks.ts | 76 --- packages/product-core/src/Session/index.ts | 10 +- packages/product-core/src/Session/types.ts | 112 ++++ .../web-core/src/BaseWebSession/index.tsx | 23 +- .../jbrowse-desktop/src/indexJobsModel.ts | 2 +- .../jbrowse-desktop/src/rootModel/Sessions.ts | 77 --- .../src/rootModel/rootModel.ts | 307 ++++++++- .../src/sessionModel/sessionModel.tsx | 32 +- .../src/rootModel/rootModel.ts | 197 +++++- .../src/createModel/createSessionModel.tsx | 4 +- .../src/createModel/createSessionModel.tsx | 15 +- .../jbrowse-web/src/rootModel/rootModel.ts | 617 ++++++++++++------ .../jbrowse-web/src/tests/LGVSynteny.test.tsx | 1 - 30 files changed, 1034 insertions(+), 1855 deletions(-) delete mode 100644 packages/app-core/src/AppFocus/index.ts delete mode 100644 packages/app-core/src/Assemblies/SessionAssembliesMixin.ts delete mode 100644 packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts delete mode 100644 packages/app-core/src/Assemblies/index.ts delete mode 100644 packages/app-core/src/HistoryManagement/index.ts delete mode 100644 packages/app-core/src/RootMenu/index.ts delete mode 100644 packages/product-core/src/RootModel/InternetAccounts.ts delete mode 100644 packages/product-core/src/Session/Connections.ts delete mode 100644 packages/product-core/src/Session/DialogQueue.ts delete mode 100644 packages/product-core/src/Session/DrawerWidgets.ts delete mode 100644 packages/product-core/src/Session/MultipleViews.ts delete mode 100644 packages/product-core/src/Session/ReferenceManagement.ts delete mode 100644 packages/product-core/src/Session/SessionTracks.ts delete mode 100644 packages/product-core/src/Session/Themes.ts delete mode 100644 packages/product-core/src/Session/Tracks.ts create mode 100644 packages/product-core/src/Session/types.ts delete mode 100644 products/jbrowse-desktop/src/rootModel/Sessions.ts diff --git a/packages/app-core/src/AppFocus/index.ts b/packages/app-core/src/AppFocus/index.ts deleted file mode 100644 index 39c7c2af07..0000000000 --- a/packages/app-core/src/AppFocus/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' - -/** - * #stateModel AppFocusMixin - * #category session - */ -export function AppFocusMixin() { - return types - .model({ - /** - * #property - * used to keep track of which view is in focus - */ - focusedViewId: types.maybe(types.string), - }) - .actions(self => ({ - /** - * #action - */ - setFocusedViewId(viewId: string) { - self.focusedViewId = viewId - }, - })) -} diff --git a/packages/app-core/src/Assemblies/SessionAssembliesMixin.ts b/packages/app-core/src/Assemblies/SessionAssembliesMixin.ts deleted file mode 100644 index 60274f78ed..0000000000 --- a/packages/app-core/src/Assemblies/SessionAssembliesMixin.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' -import type { AnyConfiguration } from '@jbrowse/core/configuration' - -interface SessionAssembliesMixinJBrowse { - addAssemblyConf: (conf: AnyConfiguration) => void - removeAssemblyConf: (name: string) => void -} - -interface SessionAssembliesMixinContext { - adminMode: boolean - jbrowse: SessionAssembliesMixinJBrowse -} - -/** - * #stateModel SessionAssembliesMixin - * #category root - */ -export function SessionAssembliesMixin( - _pluginManager: PluginManager, - assemblyConfigSchemasType: BaseAssemblyConfigSchema, -) { - return types - .model({ - /** - * #property - */ - sessionAssemblies: types.array(assemblyConfigSchemasType), - }) - .actions(s => { - const self = s as typeof s & SessionAssembliesMixinContext - return { - /** - * #action - */ - addSessionAssembly(conf: AnyConfiguration) { - const asm = self.sessionAssemblies.find(f => f.name === conf.name) - if (asm) { - console.warn(`Assembly ${conf.name} already exists`) - return asm - } - const length = self.sessionAssemblies.push(conf) - return self.sessionAssemblies[length - 1] - }, - - /** - * #action - */ - addAssembly(conf: AnyConfiguration) { - if (self.adminMode) { - self.jbrowse.addAssemblyConf(conf) - } else { - this.addSessionAssembly(conf) - } - }, - - /** - * #action - */ - removeAssembly(name: string) { - if (self.adminMode) { - self.jbrowse.removeAssemblyConf(name) - } else { - this.removeSessionAssembly(name) - } - }, - - /** - * #action - */ - removeSessionAssembly(assemblyName: string) { - const elt = self.sessionAssemblies.find(a => a.name === assemblyName) - if (elt) { - self.sessionAssemblies.remove(elt) - } - }, - } - }) -} diff --git a/packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts b/packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts deleted file mode 100644 index fd5870e2bb..0000000000 --- a/packages/app-core/src/Assemblies/TemporaryAssembliesMixin.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { AnyConfiguration } from '@jbrowse/core/configuration' - -/** - * #stateModel TemporaryAssembliesMixin - * #category root - */ -export function TemporaryAssembliesMixin( - _pluginManager: PluginManager, - assemblyConfigSchemasType = types.frozen(), -) { - return types - .model({ - /** - * #property - */ - temporaryAssemblies: types.array(assemblyConfigSchemasType), - }) - .actions(self => ({ - /** - * #action - * used for read vs ref type assemblies. - */ - addTemporaryAssembly(conf: AnyConfiguration) { - const asm = self.temporaryAssemblies.find(f => f.name === conf.name) - if (asm) { - console.warn(`Assembly ${conf.name} was already existing`) - return asm - } - const length = self.temporaryAssemblies.push(conf) - return self.temporaryAssemblies[length - 1] - }, - - /** - * #action - */ - removeTemporaryAssembly(name: string) { - const elt = self.temporaryAssemblies.find(a => a.name === name) - if (elt) { - self.temporaryAssemblies.remove(elt) - } - }, - })) -} diff --git a/packages/app-core/src/Assemblies/index.ts b/packages/app-core/src/Assemblies/index.ts deleted file mode 100644 index 0b6ff6e43d..0000000000 --- a/packages/app-core/src/Assemblies/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './SessionAssembliesMixin' -export * from './TemporaryAssembliesMixin' diff --git a/packages/app-core/src/HistoryManagement/index.ts b/packages/app-core/src/HistoryManagement/index.ts deleted file mode 100644 index 4c72be2198..0000000000 --- a/packages/app-core/src/HistoryManagement/index.ts +++ /dev/null @@ -1,68 +0,0 @@ -import TimeTraveller from '@jbrowse/core/util/TimeTraveller' -import { addDisposer, types } from '@jbrowse/mobx-state-tree' -import { autorun } from 'mobx' - -import type { BaseRootModel } from '@jbrowse/product-core' - -/** - * #stateModel HistoryManagementMixin - * #category root - */ -export function HistoryManagementMixin() { - return types - .model({ - /** - * #property - * used for undo/redo - */ - history: types.optional(TimeTraveller, { targetPath: '../session' }), - }) - .actions(self => { - const keydownListener = (e: KeyboardEvent) => { - if ( - self.history.canRedo && - // ctrl+shift+z or cmd+shift+z - (((e.ctrlKey || e.metaKey) && e.shiftKey && e.code === 'KeyZ') || - // ctrl+y - (e.ctrlKey && !e.shiftKey && e.code === 'KeyY')) && - document.activeElement?.tagName.toUpperCase() !== 'INPUT' - ) { - self.history.redo() - } - if ( - self.history.canUndo && - // ctrl+z or cmd+z - (e.ctrlKey || e.metaKey) && - !e.shiftKey && - e.code === 'KeyZ' && - document.activeElement?.tagName.toUpperCase() !== 'INPUT' - ) { - self.history.undo() - } - } - return { - afterCreate() { - document.addEventListener('keydown', keydownListener) - addDisposer( - self, - autorun( - function historyInitAutorun() { - const { session } = self as typeof self & BaseRootModel - if (session) { - // we use a specific initialization routine after session is - // created to get it to start tracking itself sort of related - // issue here - // https://github.com/mobxjs/mobx-state-tree/issues/1089#issuecomment-441207911 - self.history.initialize() - } - }, - { name: 'HistoryInit' }, - ), - ) - }, - beforeDestroy() { - document.removeEventListener('keydown', keydownListener) - }, - } - }) -} diff --git a/packages/app-core/src/RootMenu/index.ts b/packages/app-core/src/RootMenu/index.ts deleted file mode 100644 index 46b66e7b94..0000000000 --- a/packages/app-core/src/RootMenu/index.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' - -import type { Menu, MenuAction } from '../menus' -import type { MenuItem } from '@jbrowse/core/ui/Menu' - -/** - * #stateModel RootAppMenuMixin - */ -export function RootAppMenuMixin() { - return types - .model({}) - .volatile(() => ({ - mutableMenuActions: [] as MenuAction[], - })) - .actions(self => ({ - /** - * #action - */ - setMenus(newMenus: Menu[]) { - self.mutableMenuActions = [ - ...self.mutableMenuActions, - { type: 'setMenus', newMenus }, - ] - }, - /** - * #action - * Add a top-level menu - * - * @param menuName - Name of the menu to insert. - * - */ - appendMenu(menuName: string) { - self.mutableMenuActions = [ - ...self.mutableMenuActions, - { type: 'appendMenu', menuName }, - ] - }, - /** - * #action - * Insert a top-level menu - * - * @param menuName - Name of the menu to insert. - * - * @param position - Position to insert menu. If negative, counts from th - * end, e.g. `insertMenu('My Menu', -1)` will insert the menu as the - * second-to-last one. - * - */ - insertMenu(menuName: string, position: number) { - self.mutableMenuActions = [ - ...self.mutableMenuActions, - { - type: 'insertMenu', - menuName, - position, - }, - ] - }, - /** - * #action - * Add a menu item to a top-level menu - * - * @param menuName - Name of the top-level menu to append to. - * - * @param menuItem - Menu item to append. - */ - appendToMenu(menuName: string, menuItem: MenuItem) { - self.mutableMenuActions = [ - ...self.mutableMenuActions, - { - type: 'appendToMenu', - menuName, - menuItem, - }, - ] - }, - /** - * #action - * Insert a menu item into a top-level menu - * - * @param menuName - Name of the top-level menu to insert into - * - * @param menuItem - Menu item to insert - * - * @param position - Position to insert menu item. If negative, counts - * from the end, e.g. `insertMenu('My Menu', -1)` will insert the menu as - * the second-to-last one. - */ - insertInMenu(menuName: string, menuItem: MenuItem, position: number) { - self.mutableMenuActions.push({ - type: 'insertInMenu', - menuName, - menuItem, - position, - }) - }, - /** - * #action - * Add a menu item to a sub-menu - * - * @param menuPath - Path to the sub-menu to add to, starting with the - * top-level menu (e.g. `['File', 'Insert']`). - * - * @param menuItem - Menu item to append. - * - * @returns The new length of the sub-menu - */ - appendToSubMenu(menuPath: string[], menuItem: MenuItem) { - self.mutableMenuActions = [ - ...self.mutableMenuActions, - { - type: 'appendToSubMenu', - menuPath, - menuItem, - }, - ] - }, - /** - * #action - * Insert a menu item into a sub-menu - * - * @param menuPath - Path to the sub-menu to add to, starting with the - * top-level menu (e.g. `['File', 'Insert']`). - * - * @param menuItem - Menu item to insert. - * - * @param position - Position to insert menu item. If negative, counts - * from the end, e.g. `insertMenu('My Menu', -1)` will insert the menu as - * the second-to-last one. - */ - insertInSubMenu( - menuPath: string[], - menuItem: MenuItem, - position: number, - ) { - self.mutableMenuActions = [ - ...self.mutableMenuActions, - { - type: 'insertInSubMenu', - menuPath, - menuItem, - position, - }, - ] - }, - })) -} diff --git a/packages/app-core/src/index.ts b/packages/app-core/src/index.ts index 6d9d5e032e..8dd74e2305 100644 --- a/packages/app-core/src/index.ts +++ b/packages/app-core/src/index.ts @@ -1,10 +1,6 @@ export * from './ui' -export * from './HistoryManagement' -export * from './AppFocus' export * from './JBrowseConfig' export * from './JBrowseModel' -export * from './Assemblies' -export * from './RootMenu' export * from './menus' export * from './menuItems' export * from './types' diff --git a/packages/app-core/src/menuItems.ts b/packages/app-core/src/menuItems.ts index 87e8d98be3..2743e49ba1 100644 --- a/packages/app-core/src/menuItems.ts +++ b/packages/app-core/src/menuItems.ts @@ -1,3 +1,4 @@ +import { Cable } from '@jbrowse/core/ui/Icons' import { getSnapshot } from '@jbrowse/mobx-state-tree' import ExtensionIcon from '@mui/icons-material/Extension' import GetAppIcon from '@mui/icons-material/GetApp' @@ -5,11 +6,9 @@ import PublishIcon from '@mui/icons-material/Publish' import RedoIcon from '@mui/icons-material/Redo' import StorageIcon from '@mui/icons-material/Storage' import UndoIcon from '@mui/icons-material/Undo' -import { Cable } from '@jbrowse/core/ui/Icons' import type { MenuItem } from '@jbrowse/core/ui' -import type { SessionWithWidgets } from '@jbrowse/core/util' -import type { AbstractViewModel } from '@jbrowse/core/util' +import type { AbstractViewModel, SessionWithWidgets } from '@jbrowse/core/util' import type { IAnyStateTreeNode } from '@jbrowse/mobx-state-tree' interface SessionWithViews { diff --git a/packages/product-core/src/RootModel/InternetAccounts.ts b/packages/product-core/src/RootModel/InternetAccounts.ts deleted file mode 100644 index 5c69db2217..0000000000 --- a/packages/product-core/src/RootModel/InternetAccounts.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { addDisposer, types } from '@jbrowse/mobx-state-tree' -import { autorun } from 'mobx' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { AnyConfigurationModel } from '@jbrowse/core/configuration' -import type { UriLocation } from '@jbrowse/core/util' -import type { Instance } from '@jbrowse/mobx-state-tree' - -interface InternetAccountsMixinContext { - jbrowse: { - internetAccounts: AnyConfigurationModel[] - } -} - -/** - * #stateModel InternetAccountsMixin - * #category root - */ -export function InternetAccountsRootModelMixin(pluginManager: PluginManager) { - return types - .model({ - /** - * #property - */ - internetAccounts: types.array( - pluginManager.pluggableMstType('internet account', 'stateModel'), - ), - }) - .actions(self => ({ - /** - * #action - */ - initializeInternetAccount( - internetAccountConfig: AnyConfigurationModel, - initialSnapshot = {}, - ) { - const internetAccountType = pluginManager.getInternetAccountType( - internetAccountConfig.type, - ) - if (!internetAccountType) { - throw new Error( - `unknown internet account type ${internetAccountConfig.type}`, - ) - } - - const length = self.internetAccounts.push({ - ...initialSnapshot, - type: internetAccountConfig.type, - configuration: internetAccountConfig, - }) - return self.internetAccounts[length - 1] - }, - - /** - * #action - */ - createEphemeralInternetAccount( - internetAccountId: string, - initialSnapshot: Record, - url: string, - ) { - let hostUri: string | undefined - - try { - hostUri = new URL(url).origin - } catch (e) { - // ignore - } - const internetAccountSplit = internetAccountId.split('-') - const configuration = { - type: internetAccountSplit[0]!, - internetAccountId: internetAccountId, - name: internetAccountSplit.slice(1).join('-'), - description: '', - domains: hostUri ? [hostUri] : [], - } - const type = pluginManager.getInternetAccountType(configuration.type)! - const internetAccount = type.stateModel.create({ - ...initialSnapshot, - type: configuration.type, - configuration, - }) - self.internetAccounts.push(internetAccount) - return internetAccount - }, - /** - * #action - */ - findAppropriateInternetAccount(location: UriLocation) { - // find the existing account selected from menu - const selectedId = location.internetAccountId - if (selectedId) { - const selectedAccount = self.internetAccounts.find(account => { - return account.internetAccountId === selectedId - }) - if (selectedAccount) { - return selectedAccount - } - } - - // if no existing account or not found, try to find working account - for (const account of self.internetAccounts) { - const handleResult = account.handlesLocation(location) - if (handleResult) { - return account - } - } - - // if still no existing account, create ephemeral config to use - return selectedId - ? this.createEphemeralInternetAccount(selectedId, {}, location.uri) - : null - }, - })) - .actions(self => { - const ctx = self as typeof self & InternetAccountsMixinContext - return { - afterCreate() { - addDisposer( - self, - autorun( - function internetAccountsAutorun() { - for (const internetAccount of ctx.jbrowse.internetAccounts) { - self.initializeInternetAccount(internetAccount) - } - }, - { name: 'InternetAccounts' }, - ), - ) - }, - } - }) -} - -export type RootModelWithInternetAccountsType = ReturnType< - typeof InternetAccountsRootModelMixin -> -export type RootModelWithInternetAccounts = - Instance diff --git a/packages/product-core/src/RootModel/index.ts b/packages/product-core/src/RootModel/index.ts index 1d2163ea5f..21c4956688 100644 --- a/packages/product-core/src/RootModel/index.ts +++ b/packages/product-core/src/RootModel/index.ts @@ -1,5 +1,4 @@ export * from './BaseRootModel' -export * from './InternetAccounts' export * from './FormatAbout' export * from './FormatDetails' export * from './HierarchicalConfig' diff --git a/packages/product-core/src/Session/Connections.ts b/packages/product-core/src/Session/Connections.ts deleted file mode 100644 index 69f2cd920a..0000000000 --- a/packages/product-core/src/Session/Connections.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { readConfObject } from '@jbrowse/core/configuration' -import { types } from '@jbrowse/mobx-state-tree' - -import { isBaseSession } from './BaseSession' - -import type { SessionWithReferenceManagementType } from './ReferenceManagement' -import type PluginManager from '@jbrowse/core/PluginManager' -import type { AnyConfigurationModel } from '@jbrowse/core/configuration' -import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' - -interface ConnectionMixinJBrowse { - connections: BaseConnectionConfigModel[] - deleteConnectionConf: (conf: AnyConfigurationModel) => unknown - addConnectionConf: (conf: AnyConfigurationModel) => unknown -} - -interface ConnectionMixinContext { - jbrowse: ConnectionMixinJBrowse -} - -/** - * #stateModel ConnectionManagementSessionMixin - */ -export function ConnectionManagementSessionMixin(pluginManager: PluginManager) { - return types - .model({ - /** - * #property - */ - connectionInstances: types.array( - pluginManager.pluggableMstType('connection', 'stateModel'), - ), - }) - .views(self => { - const ctx = self as typeof self & ConnectionMixinContext - return { - /** - * #getter - */ - get connections(): BaseConnectionConfigModel[] { - return ctx.jbrowse.connections - }, - } - }) - .actions(self => { - const ctx = self as typeof self & ConnectionMixinContext - return { - /** - * #action - */ - makeConnection( - configuration: AnyConfigurationModel, - initialSnapshot = {}, - ) { - const type = configuration.type as string - if (!type) { - throw new Error('track configuration has no `type` listed') - } - const name = readConfObject(configuration, 'name') - const connectionType = pluginManager.getConnectionType(type) - if (!connectionType) { - throw new Error(`unknown connection type ${type}`) - } - const length = self.connectionInstances.push({ - ...initialSnapshot, - name, - type, - configuration, - }) - return self.connectionInstances[length - 1] - }, - - /** - * #action - */ - prepareToBreakConnection(configuration: AnyConfigurationModel) { - const root = self as typeof self & - Instance - const callbacksToDeref: (() => void)[] = [] - const derefTypeCount: Record = {} - const name = readConfObject(configuration, 'name') - const connection = self.connectionInstances.find(c => c.name === name) - if (!connection) { - return undefined - } - for (const track of connection.tracks) { - const ref = root.getReferring(track) - root.removeReferring(ref, track, callbacksToDeref, derefTypeCount) - } - return [ - () => { - for (const cb of callbacksToDeref) { - cb() - } - this.breakConnection(configuration) - }, - derefTypeCount, - ] - }, - - /** - * #action - */ - breakConnection(configuration: AnyConfigurationModel) { - const name = readConfObject(configuration, 'name') - const connection = self.connectionInstances.find(c => c.name === name) - if (!connection) { - throw new Error(`no connection found with name ${name}`) - } - self.connectionInstances.remove(connection) - }, - - /** - * #action - */ - deleteConnection(configuration: AnyConfigurationModel) { - return ctx.jbrowse.deleteConnectionConf(configuration) - }, - - /** - * #action - */ - addConnectionConf(connectionConf: AnyConfigurationModel) { - return ctx.jbrowse.addConnectionConf(connectionConf) - }, - - /** - * #action - */ - clearConnections() { - self.connectionInstances.clear() - }, - } - }) -} - -/** Session mixin MST type for a session that has connections */ -export type SessionWithConnectionsType = ReturnType< - typeof ConnectionManagementSessionMixin -> - -/** Instance of a session that has connections: `connectionInstances`, - * `makeConnection()`, etc. */ -export type SessionWithConnections = Instance - -/** Type guard for SessionWithConnections */ -export function isSessionWithConnections( - session: IAnyStateTreeNode, -): session is SessionWithConnections { - return isBaseSession(session) && 'connectionInstances' in session -} diff --git a/packages/product-core/src/Session/DialogQueue.ts b/packages/product-core/src/Session/DialogQueue.ts deleted file mode 100644 index f54d5c4e3f..0000000000 --- a/packages/product-core/src/Session/DialogQueue.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' - -import { isBaseSession } from './BaseSession' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { DialogComponentType } from '@jbrowse/core/util' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' - -/** - * #stateModel DialogQueueSessionMixin - */ -export function DialogQueueSessionMixin(_pluginManager: PluginManager) { - return types - .model('DialogQueueSessionMixin', {}) - .volatile(() => ({ - queueOfDialogs: [] as [DialogComponentType, unknown][], - })) - .views(self => ({ - /** - * #getter - */ - get DialogComponent() { - return self.queueOfDialogs[0]?.[0] - }, - /** - * #getter - */ - get DialogProps() { - return self.queueOfDialogs[0]?.[1] - }, - })) - .actions(self => ({ - /** - * #action - */ - removeActiveDialog() { - self.queueOfDialogs = self.queueOfDialogs.slice(1) - }, - /** - * #action - */ - queueDialog( - cb: (doneCallback: () => void) => [DialogComponentType, unknown], - ) { - const [component, props] = cb(() => { - this.removeActiveDialog() - }) - self.queueOfDialogs = [...self.queueOfDialogs, [component, props]] - }, - })) -} - -export type SessionWithDialogsType = ReturnType -export type SessionWithDialogs = Instance - -export function isSessionWithDialogs( - session: IAnyStateTreeNode, -): session is SessionWithDialogs { - return isBaseSession(session) && 'queueOfDialogs' in session -} diff --git a/packages/product-core/src/Session/DrawerWidgets.ts b/packages/product-core/src/Session/DrawerWidgets.ts deleted file mode 100644 index a1f4818f4a..0000000000 --- a/packages/product-core/src/Session/DrawerWidgets.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { isConfigurationModel } from '@jbrowse/core/configuration' -import { localStorageGetItem, localStorageSetItem } from '@jbrowse/core/util' -import { addDisposer, isAlive, types } from '@jbrowse/mobx-state-tree' -import { autorun } from 'mobx' - -import { isBaseSession } from './BaseSession' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { AnyConfigurationModel } from '@jbrowse/core/configuration' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' - -const minDrawerWidth = 128 - -/** - * #stateModel DrawerWidgetSessionMixin - */ -export function DrawerWidgetSessionMixin(pluginManager: PluginManager) { - const widgetStateModelType = pluginManager.pluggableMstType( - 'widget', - 'stateModel', - ) - type WidgetStateModel = Instance - return types - .model({ - /** - * #property - */ - drawerPosition: types.optional( - types.string, - () => localStorageGetItem('drawerPosition') || 'right', - ), - /** - * #property - */ - drawerWidth: types.optional( - types.refinement(types.integer, width => width >= minDrawerWidth), - 384, - ), - /** - * #property - */ - widgets: types.map(widgetStateModelType), - /** - * #property - */ - activeWidgets: types.map(types.safeReference(widgetStateModelType)), - - /** - * #property - */ - minimized: types.optional(types.boolean, false), - }) - .views(self => ({ - /** - * #getter - */ - get visibleWidget() { - if (isAlive(self)) { - // returns most recently added item in active widgets - return [...self.activeWidgets.values()][self.activeWidgets.size - 1] - } - return undefined - }, - })) - .actions(self => ({ - /** - * #action - */ - setDrawerPosition(arg: string) { - self.drawerPosition = arg - localStorage.setItem('drawerPosition', arg) - }, - - /** - * #action - */ - updateDrawerWidth(drawerWidth: number) { - if (drawerWidth === self.drawerWidth) { - return self.drawerWidth - } - let newDrawerWidth = drawerWidth - if (newDrawerWidth < minDrawerWidth) { - newDrawerWidth = minDrawerWidth - } - self.drawerWidth = newDrawerWidth - return newDrawerWidth - }, - - /** - * #action - */ - resizeDrawer(distance: number) { - if (self.drawerPosition === 'left') { - distance *= -1 - } - const oldDrawerWidth = self.drawerWidth - const newDrawerWidth = this.updateDrawerWidth(oldDrawerWidth - distance) - return oldDrawerWidth - newDrawerWidth - }, - - /** - * #action - */ - addWidget( - typeName: string, - id: string, - initialState = {}, - conf?: unknown, - ) { - const typeDefinition = pluginManager.getElementType('widget', typeName) - if (!typeDefinition) { - throw new Error(`unknown widget type ${typeName}`) - } - const data = { - ...initialState, - id, - type: typeName, - configuration: conf || { type: typeName }, - } - self.widgets.set(id, data) - return self.widgets.get(id) - }, - - /** - * #action - */ - showWidget(widget: WidgetStateModel) { - if (self.activeWidgets.has(widget.id)) { - self.activeWidgets.delete(widget.id) - } - self.activeWidgets.set(widget.id, widget) - self.minimized = false - }, - - /** - * #action - */ - hasWidget(widget: WidgetStateModel) { - return self.activeWidgets.has(widget.id) - }, - - /** - * #action - */ - hideWidget(widget: WidgetStateModel) { - self.activeWidgets.delete(widget.id) - }, - - /** - * #action - */ - minimizeWidgetDrawer() { - self.minimized = true - }, - /** - * #action - */ - showWidgetDrawer() { - self.minimized = false - }, - /** - * #action - */ - hideAllWidgets() { - self.activeWidgets.clear() - }, - - /** - * #action - * opens a configuration editor to configure the given thing, - * and sets the current task to be configuring it - * @param configuration - - */ - editConfiguration(configuration: AnyConfigurationModel) { - if (!isConfigurationModel(configuration)) { - throw new Error( - 'must pass a configuration model to editConfiguration', - ) - } - const editor = this.addWidget( - 'ConfigurationEditorWidget', - 'configEditor', - { target: configuration }, - ) - this.showWidget(editor) - }, - - afterAttach() { - addDisposer( - self, - autorun( - function drawerPositionAutorun() { - localStorageSetItem('drawerPosition', self.drawerPosition) - }, - { name: 'DrawerPosition' }, - ), - ) - }, - })) -} - -/** Session mixin MST type for a session that manages drawer widgets */ -export type SessionWithDrawerWidgetsType = ReturnType< - typeof DrawerWidgetSessionMixin -> - -/** Instance of a session that manages drawer widgets */ -export type SessionWithDrawerWidgets = Instance - -/** Type guard for SessionWithDrawerWidgets */ -export function isSessionWithDrawerWidgets( - session: IAnyStateTreeNode, -): session is SessionWithDrawerWidgets { - return ( - isBaseSession(session) && - 'widgets' in session && - 'drawerPosition' in session - ) -} diff --git a/packages/product-core/src/Session/MultipleViews.ts b/packages/product-core/src/Session/MultipleViews.ts deleted file mode 100644 index 8ecf0fba92..0000000000 --- a/packages/product-core/src/Session/MultipleViews.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - localStorageGetBoolean, - localStorageSetBoolean, -} from '@jbrowse/core/util' -import { addDisposer, cast, types } from '@jbrowse/mobx-state-tree' -import { autorun } from 'mobx' - -import { BaseSessionModel, isBaseSession } from './BaseSession' -import { DrawerWidgetSessionMixin } from './DrawerWidgets' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' - -/** - * #stateModel MultipleViewsSessionMixin - * composed of - * - [BaseSessionModel](../basesessionmodel) - * - [DrawerWidgetSessionMixin](../drawerwidgetsessionmixin) - */ -export function MultipleViewsSessionMixin(pluginManager: PluginManager) { - return types - .compose( - BaseSessionModel(pluginManager), - DrawerWidgetSessionMixin(pluginManager), - ) - .props({ - /** - * #property - */ - views: types.array(pluginManager.pluggableMstType('view', 'stateModel')), - /** - * #property - */ - stickyViewHeaders: types.optional(types.boolean, () => - localStorageGetBoolean('stickyViewHeaders', true), - ), - }) - .actions(self => ({ - /** - * #action - */ - moveViewDown(id: string) { - const idx = self.views.findIndex(v => v.id === id) - if (idx !== -1 && idx < self.views.length - 1) { - self.views.splice(idx, 2, self.views[idx + 1], self.views[idx]) - } - }, - /** - * #action - */ - moveViewUp(id: string) { - const idx = self.views.findIndex(view => view.id === id) - if (idx > 0) { - self.views.splice(idx - 1, 2, self.views[idx], self.views[idx - 1]) - } - }, - /** - * #action - */ - moveViewToTop(id: string) { - const idx = self.views.findIndex(view => view.id === id) - self.views = cast([ - self.views[idx], - ...self.views.filter(view => view.id !== id), - ]) - }, - - /** - * #action - */ - moveViewToBottom(id: string) { - const idx = self.views.findIndex(view => view.id === id) - self.views = cast([ - ...self.views.filter(view => view.id !== id), - self.views[idx], - ]) - }, - - /** - * #action - */ - addView(typeName: string, initialState = {}) { - const typeDefinition = pluginManager.getElementType('view', typeName) - if (!typeDefinition) { - throw new Error(`unknown view type ${typeName}`) - } - - const length = self.views.push({ - ...initialState, - type: typeName, - }) - return self.views[length - 1] - }, - - /** - * #action - */ - removeView(view: IBaseViewModel) { - for (const [, widget] of self.activeWidgets) { - if (widget.view && widget.view.id === view.id) { - self.hideWidget(widget) - } - } - self.views.remove(view) - }, - - /** - * #action - */ - setStickyViewHeaders(sticky: boolean) { - self.stickyViewHeaders = sticky - }, - - afterAttach() { - addDisposer( - self, - autorun( - function stickyViewHeadersAutorun() { - localStorageSetBoolean( - 'stickyViewHeaders', - self.stickyViewHeaders, - ) - }, - { name: 'StickyViewHeaders' }, - ), - ) - }, - })) -} - -/** Session mixin MST type for a session that manages multiple views */ -export type SessionWithMultipleViewsType = ReturnType< - typeof MultipleViewsSessionMixin -> - -/** Instance of a session with multiple views */ -export type SessionWithMultipleViews = Instance - -/** Type guard for SessionWithMultipleViews */ -export function isSessionWithMultipleViews( - session: IAnyStateTreeNode, -): session is SessionWithMultipleViews { - return isBaseSession(session) && 'views' in session -} diff --git a/packages/product-core/src/Session/ReferenceManagement.ts b/packages/product-core/src/Session/ReferenceManagement.ts deleted file mode 100644 index 03a22cfcf3..0000000000 --- a/packages/product-core/src/Session/ReferenceManagement.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** MST props, views, actions, etc related to managing connections */ - -import { - getContainingView, - isSessionModelWithWidgets, -} from '@jbrowse/core/util' -import { - getMembers, - getParent, - getSnapshot, - getType, - isModelType, - isReferenceType, - types, - walk, -} from '@jbrowse/mobx-state-tree' - -import { isBaseSession } from './BaseSession' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' -import type { TrackViewModel } from '@jbrowse/core/util' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' - -export interface ReferringNode { - node: IAnyStateTreeNode - key: string -} - -/** - * #stateModel ReferenceManagementSessionMixin - */ -export function ReferenceManagementSessionMixin(_pluginManager: PluginManager) { - return types - .model('ReferenceManagementSessionMixin', {}) - .views(self => ({ - /** - * #method - * See if any MST nodes currently have a types.reference to this object. - * - * @param object - object - * @returns An array where the first element is the node referring - * to the object and the second element is they property name the node is - * using to refer to the object - */ - getReferring(object: IAnyStateTreeNode) { - const refs: ReferringNode[] = [] - walk(getParent(self), node => { - if (isModelType(getType(node))) { - const members = getMembers(node) - for (const [key, value] of Object.entries(members.properties)) { - if (isReferenceType(value) && node[key] === object) { - refs.push({ node, key }) - } - } - } - }) - return refs - }, - })) - .actions(self => ({ - /** - * #action - */ - removeReferring( - referring: ReferringNode[], - track: BaseTrackConfig, - callbacks: ((arg: string) => void)[], - dereferenceTypeCount: Record, - ) { - for (const { node } of referring) { - let dereferenced = false - try { - // If a view is referring to the track config, remove the track - // from the view - const type = 'open track(s)' - const view = getContainingView(node) as TrackViewModel - callbacks.push(() => { - view.hideTrack(track.trackId) - }) - dereferenced = true - if (!dereferenceTypeCount[type]) { - dereferenceTypeCount[type] = 0 - } - dereferenceTypeCount[type] += 1 - } catch (err1) { - // ignore - } - - if (isSessionModelWithWidgets(self) && self.widgets.has(node.id)) { - // If a configuration editor widget has the track config - // open, close the widget - const type = 'configuration editor widget(s)' - if (isSessionModelWithWidgets(self)) { - callbacks.push(() => { - self.hideWidget(node) - }) - } - dereferenced = true - if (!dereferenceTypeCount[type]) { - dereferenceTypeCount[type] = 0 - } - dereferenceTypeCount[type] += 1 - } - if (!dereferenced) { - throw new Error( - `Error when closing this connection, the following node is still referring to a track configuration: ${JSON.stringify( - getSnapshot(node), - )}`, - ) - } - } - }, - })) -} - -/** Session mixin MST type for a session that manages multiple views */ -export type SessionWithReferenceManagementType = ReturnType< - typeof ReferenceManagementSessionMixin -> - -/** Instance of a session with MST reference management (`getReferring()`, `removeReferring()`) */ -export type SessionWithReferenceManagement = - Instance - -/** Type guard for SessionWithReferenceManagement */ -export function isSessionWithReferenceManagement( - thing: IAnyStateTreeNode, -): thing is SessionWithReferenceManagement { - return ( - isBaseSession(thing) && - 'getReferring' in thing && - typeof thing.getReferring === 'function' && - 'removeReferring' in thing && - typeof thing.removeReferring === 'function' - ) -} diff --git a/packages/product-core/src/Session/SessionTracks.ts b/packages/product-core/src/Session/SessionTracks.ts deleted file mode 100644 index 8205af06e0..0000000000 --- a/packages/product-core/src/Session/SessionTracks.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' - -import { isBaseSession } from './BaseSession' -import { TracksManagerSessionMixin } from './Tracks' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { - AnyConfiguration, - AnyConfigurationModel, -} from '@jbrowse/core/configuration' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' - -/** - * #stateModel SessionTracksManagerSessionMixin - */ -export function SessionTracksManagerSessionMixin(pluginManager: PluginManager) { - return TracksManagerSessionMixin(pluginManager) - .named('SessionTracksManagerSessionMixin') - .props({ - /** - * #property - */ - sessionTracks: types.array( - pluginManager.pluggableConfigSchemaType('track'), - ), - }) - .views(self => ({ - /** - * #getter - */ - get tracks(): AnyConfigurationModel[] { - return [...self.sessionTracks, ...self.jbrowse.tracks] - }, - })) - .actions(self => { - const { - addTrackConf: superAddTrackConf, - deleteTrackConf: superDeleteTrackConf, - } = self - return { - /** - * #action - */ - addTrackConf(trackConf: AnyConfiguration) { - if (self.adminMode) { - return superAddTrackConf(trackConf) - } - - const { trackId, type } = trackConf as { - type: string - trackId: string - } - if (!type) { - throw new Error(`unknown track type ${type}`) - } - const track = self.sessionTracks.find(t => t.trackId === trackId) - if (track) { - return track - } - const length = self.sessionTracks.push(trackConf) - return self.sessionTracks[length - 1] - }, - - /** - * #action - */ - deleteTrackConf(trackConf: AnyConfigurationModel) { - // try to delete it in the main config if in admin mode - const found = superDeleteTrackConf(trackConf) - if (found) { - return found - } - // if not found or not in admin mode, try to delete it in the - // sessionTracks - const { trackId } = trackConf - const idx = self.sessionTracks.findIndex(t => t.trackId === trackId) - if (idx === -1) { - return undefined - } - return self.sessionTracks.splice(idx, 1) - }, - } - }) -} - -/** Session mixin MST type for a session that has `sessionTracks` */ -export type SessionWithSessionTracksType = ReturnType< - typeof SessionTracksManagerSessionMixin -> - -/** Instance of a session that has `sessionTracks` */ -export type SessionWithSessionTracks = Instance - -/** Type guard for SessionWithSessionTracks */ -export function isSessionWithSessionTracks( - thing: IAnyStateTreeNode, -): thing is SessionWithSessionTracks { - return isBaseSession(thing) && 'sessionTracks' in thing -} diff --git a/packages/product-core/src/Session/Themes.ts b/packages/product-core/src/Session/Themes.ts deleted file mode 100644 index 2bc1e6a8d1..0000000000 --- a/packages/product-core/src/Session/Themes.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { readConfObject } from '@jbrowse/core/configuration' -import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' -import { localStorageGetItem, localStorageSetItem } from '@jbrowse/core/util' -import { addDisposer, types } from '@jbrowse/mobx-state-tree' -import { autorun } from 'mobx' - -import type { AnyConfigurationModel } from '@jbrowse/core/configuration' -import type PluginManager from '@jbrowse/core/PluginManager' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' -import type { ThemeOptions } from '@mui/material' - -type ThemeMap = Record - -interface ThemeMixinContext { - jbrowse: AnyConfigurationModel -} - -/** - * #stateModel ThemeManagerSessionMixin - */ -export function ThemeManagerSessionMixin(_pluginManager: PluginManager) { - return types - .model({}) - .volatile(() => ({ - sessionThemeName: localStorageGetItem('themeName') || 'default', - })) - .views(s => { - const self = s as typeof s & ThemeMixinContext - return { - /** - * #method - */ - allThemes(): ThemeMap { - const extraThemes = readConfObject(self.jbrowse, 'extraThemes') - return { ...defaultThemes, ...extraThemes } - }, - /** - * #getter - */ - get themeName() { - const { sessionThemeName } = s - const all = this.allThemes() - return all[sessionThemeName] ? sessionThemeName : 'default' - }, - /** - * #getter - */ - get theme() { - const configTheme = readConfObject(self.jbrowse, 'theme') - const all = this.allThemes() - return createJBrowseTheme(configTheme, all, this.themeName) - }, - } - }) - .actions(self => ({ - /** - * #action - */ - setThemeName(name: string) { - self.sessionThemeName = name - }, - afterAttach() { - addDisposer( - self, - autorun( - function themeNameAutorun() { - localStorageSetItem('themeName', self.themeName) - }, - { name: 'ThemeName' }, - ), - ) - }, - })) -} - -/** Session mixin MST type for a session that supports theming */ -export type SessionWithThemesType = ReturnType - -/** Instance of a session that has theming support */ -export type SessionWithThemes = Instance - -/** Type guard for SessionWithThemes */ -export function isSessionWithThemes( - session: IAnyStateTreeNode, -): session is SessionWithThemes { - return 'theme' in session -} diff --git a/packages/product-core/src/Session/Tracks.ts b/packages/product-core/src/Session/Tracks.ts deleted file mode 100644 index b3772dc311..0000000000 --- a/packages/product-core/src/Session/Tracks.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { types } from '@jbrowse/mobx-state-tree' - -import { BaseSessionModel, isBaseSession } from './BaseSession' -import { ReferenceManagementSessionMixin } from './ReferenceManagement' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { - AnyConfiguration, - AnyConfigurationModel, -} from '@jbrowse/core/configuration' -import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' - -/** - * #stateModel TracksManagerSessionMixin - * composed of - * - BaseSessionModel - * - ReferenceManagementSessionMixin - */ -export function TracksManagerSessionMixin(pluginManager: PluginManager) { - return types - .compose( - 'TracksManagerSessionMixin', - BaseSessionModel(pluginManager), - ReferenceManagementSessionMixin(pluginManager), - ) - .views(self => ({ - /** - * #getter - */ - get tracks(): AnyConfigurationModel[] { - return self.jbrowse.tracks - }, - })) - .actions(self => ({ - /** - * #action - */ - addTrackConf(trackConf: AnyConfiguration) { - return self.jbrowse.addTrackConf(trackConf) - }, - - /** - * #action - */ - deleteTrackConf(trackConf: AnyConfigurationModel) { - const callbacksToDereferenceTrack: (() => void)[] = [] - const dereferenceTypeCount: Record = {} - const referring = self.getReferring(trackConf) - self.removeReferring( - referring, - trackConf, - callbacksToDereferenceTrack, - dereferenceTypeCount, - ) - for (const cb of callbacksToDereferenceTrack) { - cb() - } - if (self.adminMode) { - return self.jbrowse.deleteTrackConf(trackConf) - } - }, - })) -} - -/** Session mixin MST type for a session that has tracks */ -export type SessionWithTracksType = ReturnType - -/** Instance of a session that has tracks */ -export type SessionWithTracks = Instance - -/** Type guard for SessionWithTracks */ -export function isSessionWithTracks( - thing: IAnyStateTreeNode, -): thing is SessionWithTracks { - return isBaseSession(thing) && 'tracks' in thing -} diff --git a/packages/product-core/src/Session/index.ts b/packages/product-core/src/Session/index.ts index 2eb19a894f..37ebe786ed 100644 --- a/packages/product-core/src/Session/index.ts +++ b/packages/product-core/src/Session/index.ts @@ -1,10 +1,2 @@ -export * from './ReferenceManagement' -export * from './Connections' -export * from './DrawerWidgets' -export * from './DialogQueue' -export * from './Themes' -export * from './Tracks' -export * from './MultipleViews' -export * from './BaseSession' -export * from './SessionTracks' export * from './BaseSession' +export * from './types' diff --git a/packages/product-core/src/Session/types.ts b/packages/product-core/src/Session/types.ts new file mode 100644 index 0000000000..1d3e3c2cf9 --- /dev/null +++ b/packages/product-core/src/Session/types.ts @@ -0,0 +1,112 @@ +import { isBaseSession } from './BaseSession' + +import type { DialogComponentType } from '@jbrowse/core/util' +import type { IAnyStateTreeNode } from '@jbrowse/mobx-state-tree' + +export interface SessionWithDialogs { + queueOfDialogs: [DialogComponentType, unknown][] + queueDialog( + cb: (doneCallback: () => void) => [DialogComponentType, unknown], + ): void + removeActiveDialog(): void +} + +export function isSessionWithDialogs( + session: IAnyStateTreeNode, +): session is SessionWithDialogs { + return isBaseSession(session) && 'queueOfDialogs' in session +} + +export interface SessionWithConnections { + connectionInstances: unknown[] + makeConnection(configuration: unknown, initialSnapshot?: unknown): unknown + breakConnection(configuration: unknown): void + addConnectionConf(connectionConf: unknown): unknown +} + +export function isSessionWithConnections( + session: IAnyStateTreeNode, +): session is SessionWithConnections { + return isBaseSession(session) && 'connectionInstances' in session +} + +export interface SessionWithMultipleViews { + views: unknown[] + stickyViewHeaders: boolean + addView(typeName: string, initialState?: unknown): unknown + removeView(view: unknown): void +} + +export function isSessionWithMultipleViews( + session: IAnyStateTreeNode, +): session is SessionWithMultipleViews { + return isBaseSession(session) && 'views' in session +} + +export interface SessionWithTracks { + tracks: unknown[] + addTrackConf(configuration: unknown): unknown + deleteTrackConf(configuration: unknown): unknown +} + +export function isSessionWithTracks( + thing: IAnyStateTreeNode, +): thing is SessionWithTracks { + return isBaseSession(thing) && 'tracks' in thing +} + +export interface SessionWithThemes { + themeName: string | undefined + setThemeName(name: string): void +} + +export function isSessionWithThemes( + session: IAnyStateTreeNode, +): session is SessionWithThemes { + return isBaseSession(session) && 'themeName' in session +} + +export interface SessionWithReferenceManagement { + getReferring(target: unknown): unknown[] + removeReferring( + referring: unknown[], + target: unknown, + callbacks: (() => void)[], + derefTypeCount: Record, + ): void +} + +export function isSessionWithReferenceManagement( + thing: IAnyStateTreeNode, +): thing is SessionWithReferenceManagement { + return isBaseSession(thing) && 'getReferring' in thing +} + +export interface SessionWithDrawerWidgets { + widgets: Map + activeWidgets: Map + addWidget( + typeName: string, + id: string, + initialState?: Record, + conf?: unknown, + ): unknown + showWidget(widget: unknown): void + hideWidget(widget: unknown): void +} + +export function isSessionWithDrawerWidgets( + session: IAnyStateTreeNode, +): session is SessionWithDrawerWidgets { + return isBaseSession(session) && 'widgets' in session +} + +export interface SessionWithSessionTracks { + sessionTracks: unknown[] +} + +export function isSessionWithSessionTracks( + session: IAnyStateTreeNode, +): session is SessionWithSessionTracks { + return isBaseSession(session) && 'sessionTracks' in session +} diff --git a/packages/web-core/src/BaseWebSession/index.tsx b/packages/web-core/src/BaseWebSession/index.tsx index 241f159287..2b533fc855 100644 --- a/packages/web-core/src/BaseWebSession/index.tsx +++ b/packages/web-core/src/BaseWebSession/index.tsx @@ -5,6 +5,7 @@ import { isConfigurationModel, readConfObject, } from '@jbrowse/core/configuration' +import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' import { getContainingView, isSessionModelWithWidgets, @@ -27,27 +28,28 @@ import { types, walk, } from '@jbrowse/mobx-state-tree' -import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' -import { autorun, observable } from 'mobx' import DeleteIcon from '@mui/icons-material/Delete' import CopyIcon from '@mui/icons-material/FileCopy' import InfoIcon from '@mui/icons-material/Info' -import SettingsIcon from '@mui/icons-material/Settings' import Report from '@mui/icons-material/Report' +import SettingsIcon from '@mui/icons-material/Settings' +import { autorun, observable } from 'mobx' import type { Menu } from '@jbrowse/app-core' import type { PluginDefinition } from '@jbrowse/core/PluginLoader' import type PluginManager from '@jbrowse/core/PluginManager' -import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' import type { AnyConfiguration, AnyConfigurationModel, } from '@jbrowse/core/configuration' -import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes' -import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import type { + BaseTrackConfig, + IBaseViewModel, +} from '@jbrowse/core/pluggableElementTypes' import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { AssemblyManager, DialogComponentType, @@ -621,9 +623,7 @@ export function BaseWebSession({ const idx = self.sessionConnections.findIndex( c => c.connectionId === connectionId, ) - return idx === -1 - ? undefined - : self.sessionConnections.splice(idx, 1) + return idx === -1 ? undefined : self.sessionConnections.splice(idx, 1) } }, @@ -924,7 +924,10 @@ export function BaseWebSession({ function sessionLocalStorageAutorun() { localStorageSetItem('drawerPosition', self.drawerPosition) localStorageSetItem('themeName', self.themeName) - localStorageSetBoolean('stickyViewHeaders', self.stickyViewHeaders) + localStorageSetBoolean( + 'stickyViewHeaders', + self.stickyViewHeaders, + ) }, { name: 'SessionLocalStorage' }, ), diff --git a/products/jbrowse-desktop/src/indexJobsModel.ts b/products/jbrowse-desktop/src/indexJobsModel.ts index 6ed39c67e5..22012cc601 100644 --- a/products/jbrowse-desktop/src/indexJobsModel.ts +++ b/products/jbrowse-desktop/src/indexJobsModel.ts @@ -15,8 +15,8 @@ import { import { autorun, observable, toJS } from 'mobx' import type PluginManager from '@jbrowse/core/PluginManager' -import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { SessionWithDrawerWidgets } from '@jbrowse/core/util' import type { Instance } from '@jbrowse/mobx-state-tree' import type { JobsListModel } from '@jbrowse/plugin-jobs-management/src/JobsListWidget/model' diff --git a/products/jbrowse-desktop/src/rootModel/Sessions.ts b/products/jbrowse-desktop/src/rootModel/Sessions.ts deleted file mode 100644 index c0f5e38f4a..0000000000 --- a/products/jbrowse-desktop/src/rootModel/Sessions.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { addDisposer, getSnapshot, types } from '@jbrowse/mobx-state-tree' -import { autorun } from 'mobx' - -import type PluginManager from '@jbrowse/core/PluginManager' -import type { SnapshotIn } from '@jbrowse/mobx-state-tree' -import type { BaseRootModel, BaseSession } from '@jbrowse/product-core' - -const { ipcRenderer } = window.require('electron') - -export function getSaveSession(model: BaseRootModel) { - const snap = getSnapshot(model.jbrowse) - return { - ...(snap as Record), - defaultSession: model.session ? getSnapshot(model.session) : {}, - } -} - -/** - * #stateModel DesktopSessionManagementMixin - * #category root - */ -export function DesktopSessionManagementMixin(_pluginManager: PluginManager) { - return types - .model({ - /** - * #property - */ - sessionPath: types.optional(types.string, ''), - }) - .actions(s => { - const self = s as typeof s & BaseRootModel - return { - /** - * #action - */ - async saveSession(val: unknown) { - if (self.sessionPath) { - await ipcRenderer.invoke('saveSession', self.sessionPath, val) - } - }, - - /** - * #action - */ - activateSession(sessionSnapshot: SnapshotIn) { - self.setSession(sessionSnapshot) - }, - } - }) - .actions(s => { - const self = s as typeof s & BaseRootModel - return { - afterCreate() { - addDisposer( - self, - autorun( - async () => { - if (!self.session) { - return - } - try { - await self.saveSession(getSaveSession(self)) - } catch (e) { - console.error(e) - } - }, - { delay: 1000 }, - ), - ) - }, - } - }) -} - -export type SessionManagerType = ReturnType< - typeof DesktopSessionManagementMixin -> diff --git a/products/jbrowse-desktop/src/rootModel/rootModel.ts b/products/jbrowse-desktop/src/rootModel/rootModel.ts index 3e643f129b..49d1de377b 100644 --- a/products/jbrowse-desktop/src/rootModel/rootModel.ts +++ b/products/jbrowse-desktop/src/rootModel/rootModel.ts @@ -1,59 +1,62 @@ import { lazy } from 'react' import { - HistoryManagementMixin, - RootAppMenuMixin, - getOpenTrackMenuItem, getOpenConnectionMenuItem, - getUndoMenuItem, - getRedoMenuItem, + getOpenTrackMenuItem, getPluginStoreMenuItem, + getRedoMenuItem, + getUndoMenuItem, processMutableMenuActions, } from '@jbrowse/app-core' -import type { Menu, SessionModelFactory } from '@jbrowse/app-core' import assemblyConfigSchemaF from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import RpcManager from '@jbrowse/core/rpc/RpcManager' import { DNA } from '@jbrowse/core/ui/Icons' -import { types } from '@jbrowse/mobx-state-tree' +import TimeTraveller from '@jbrowse/core/util/TimeTraveller' +import { addDisposer, getSnapshot, types } from '@jbrowse/mobx-state-tree' import { AssemblyManager } from '@jbrowse/plugin-data-management' -import { - BaseRootModelFactory, - InternetAccountsRootModelMixin, -} from '@jbrowse/product-core' +import { BaseRootModelFactory } from '@jbrowse/product-core' import AppsIcon from '@mui/icons-material/Apps' import OpenIcon from '@mui/icons-material/FolderOpen' import MeetingRoomIcon from '@mui/icons-material/MeetingRoom' import SaveAsIcon from '@mui/icons-material/SaveAs' import SettingsIcon from '@mui/icons-material/Settings' +import { autorun } from 'mobx' -import { DesktopSessionManagementMixin, getSaveSession } from './Sessions' import packageJSON from '../../package.json' import OpenSequenceDialog from '../components/OpenSequenceDialog' import jobsModelFactory from '../indexJobsModel' import JBrowseDesktop from '../jbrowseModel' import makeWorkerInstance from '../makeWorkerInstance' +import type { Menu, MenuAction, SessionModelFactory } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' -import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' -import type { AbstractSessionModel } from '@jbrowse/core/util' -import type { Instance } from '@jbrowse/mobx-state-tree' -import type { SessionWithDialogs } from '@jbrowse/product-core' +import type { MenuItem } from '@jbrowse/core/ui' +import type { AbstractSessionModel, UriLocation } from '@jbrowse/core/util' +import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' +import type { BaseSession, SessionWithDialogs } from '@jbrowse/product-core' // lazies const PreferencesDialog = lazy(() => import('../components/PreferencesDialog')) const { ipcRenderer } = window.require('electron') +export function getSaveSession(model: { + jbrowse: Record + session: Record | undefined +}) { + const snap = getSnapshot(model.jbrowse) + return { + ...(snap as Record), + defaultSession: model.session ? getSnapshot(model.session) : {}, + } +} + /** * #stateModel JBrowseDesktopRootModel * #category root * composed of * - [BaseRootModel](../baserootmodel) - * - [InternetAccountsMixin](../internetaccountsmixin) - * - [DesktopSessionManagementMixin](../desktopsessionmanagementmixin) - * - [HistoryManagementMixin](../historymanagementmixin) - * - [RootAppMenuMixin](../rootappmenumixin) * * note: many properties of the root model are available through the session, * and we generally prefer using the session model (via e.g. getSession) over @@ -82,10 +85,16 @@ export default function rootModelFactory({ sessionModelType, assemblyConfigSchema, }), - InternetAccountsRootModelMixin(pluginManager), - DesktopSessionManagementMixin(pluginManager), - HistoryManagementMixin(), - RootAppMenuMixin(), + types.model({ + // InternetAccountsRootModelMixin property + internetAccounts: types.array( + pluginManager.pluggableMstType('internet account', 'stateModel'), + ), + // HistoryManagementMixin property + history: types.optional(TimeTraveller, { targetPath: '../session' }), + // DesktopSessionManagementMixin property + sessionPath: types.optional(types.string, ''), + }), ) .props({ /** @@ -107,6 +116,8 @@ export default function rootModelFactory({ openNewSessionCallback: async (_path: string) => { console.error('openNewSessionCallback unimplemented') }, + // RootAppMenuMixin volatile + mutableMenuActions: [] as MenuAction[], })) .actions(self => ({ /** @@ -125,7 +136,255 @@ export default function rootModelFactory({ } await root.openNewSessionCallback(root.sessionPath) }, + + // InternetAccountsRootModelMixin actions + /** + * #action + */ + initializeInternetAccount( + internetAccountConfig: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const internetAccountType = pluginManager.getInternetAccountType( + internetAccountConfig.type, + ) + if (!internetAccountType) { + throw new Error( + `unknown internet account type ${internetAccountConfig.type}`, + ) + } + + const length = self.internetAccounts.push({ + ...initialSnapshot, + type: internetAccountConfig.type, + configuration: internetAccountConfig, + }) + return self.internetAccounts[length - 1] + }, + + /** + * #action + */ + createEphemeralInternetAccount( + internetAccountId: string, + initialSnapshot: Record, + url: string, + ) { + let hostUri: string | undefined + + try { + hostUri = new URL(url).origin + } catch { + // ignore + } + const internetAccountSplit = internetAccountId.split('-') + const configuration = { + type: internetAccountSplit[0]!, + internetAccountId: internetAccountId, + name: internetAccountSplit.slice(1).join('-'), + description: '', + domains: hostUri ? [hostUri] : [], + } + const type = pluginManager.getInternetAccountType(configuration.type)! + const internetAccount = type.stateModel.create({ + ...initialSnapshot, + type: configuration.type, + configuration, + }) + self.internetAccounts.push(internetAccount) + return internetAccount + }, + /** + * #action + */ + findAppropriateInternetAccount(location: UriLocation) { + const selectedId = location.internetAccountId + if (selectedId) { + const selectedAccount = self.internetAccounts.find(account => { + return account.internetAccountId === selectedId + }) + if (selectedAccount) { + return selectedAccount + } + } + + for (const account of self.internetAccounts) { + const handleResult = account.handlesLocation(location) + if (handleResult) { + return account + } + } + + return selectedId + ? this.createEphemeralInternetAccount(selectedId, {}, location.uri) + : null + }, + + // DesktopSessionManagementMixin actions + /** + * #action + */ + async saveSession(val: unknown) { + if (self.sessionPath) { + await ipcRenderer.invoke('saveSession', self.sessionPath, val) + } + }, + + /** + * #action + */ + activateSession(sessionSnapshot: SnapshotIn) { + const { setSession } = self as typeof self & { + setSession: (arg: unknown) => void + } + setSession(sessionSnapshot) + }, + + // RootAppMenuMixin actions + /** + * #action + */ + setMenus(newMenus: Menu[]) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'setMenus', newMenus }, + ] + }, + /** + * #action + */ + appendMenu(menuName: string) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendMenu', menuName }, + ] + }, + /** + * #action + */ + insertMenu(menuName: string, position: number) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'insertMenu', menuName, position }, + ] + }, + /** + * #action + */ + appendToMenu(menuName: string, menuItem: MenuItem) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendToMenu', menuName, menuItem }, + ] + }, + /** + * #action + */ + insertInMenu(menuName: string, menuItem: MenuItem, position: number) { + self.mutableMenuActions.push({ + type: 'insertInMenu', + menuName, + menuItem, + position, + }) + }, + /** + * #action + */ + appendToSubMenu(menuPath: string[], menuItem: MenuItem) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendToSubMenu', menuPath, menuItem }, + ] + }, + /** + * #action + */ + insertInSubMenu( + menuPath: string[], + menuItem: MenuItem, + position: number, + ) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'insertInSubMenu', menuPath, menuItem, position }, + ] + }, })) + .actions(self => { + // HistoryManagementMixin keyboard listener + const keydownListener = (e: KeyboardEvent) => { + if ( + self.history.canRedo && + (((e.ctrlKey || e.metaKey) && e.shiftKey && e.code === 'KeyZ') || + (e.ctrlKey && !e.shiftKey && e.code === 'KeyY')) && + document.activeElement?.tagName.toUpperCase() !== 'INPUT' + ) { + self.history.redo() + } + if ( + self.history.canUndo && + (e.ctrlKey || e.metaKey) && + !e.shiftKey && + e.code === 'KeyZ' && + document.activeElement?.tagName.toUpperCase() !== 'INPUT' + ) { + self.history.undo() + } + } + + return { + afterCreate() { + // HistoryManagementMixin setup + document.addEventListener('keydown', keydownListener) + addDisposer( + self, + autorun( + function historyInitAutorun() { + if (self.session) { + self.history.initialize() + } + }, + { name: 'HistoryInit' }, + ), + ) + + // InternetAccountsRootModelMixin setup + addDisposer( + self, + autorun( + function internetAccountsAutorun() { + for (const internetAccount of self.jbrowse.internetAccounts) { + self.initializeInternetAccount(internetAccount) + } + }, + { name: 'InternetAccounts' }, + ), + ) + + // DesktopSessionManagementMixin setup + addDisposer( + self, + autorun( + async () => { + if (!self.session) { + return + } + try { + await self.saveSession(getSaveSession(self)) + } catch (e) { + console.error(e) + } + }, + { delay: 1000 }, + ), + ) + }, + beforeDestroy() { + document.removeEventListener('keydown', keydownListener) + }, + } + }) .views(self => ({ /** * #method diff --git a/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx index b7eeaaddad..e9606819d2 100644 --- a/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx +++ b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx @@ -5,6 +5,8 @@ import { isConfigurationModel, readConfObject, } from '@jbrowse/core/configuration' +import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' +import { Indexing } from '@jbrowse/core/ui/Icons' import { getContainingView, isSessionModelWithWidgets, @@ -15,8 +17,6 @@ import { localStorageSetItem, } from '@jbrowse/core/util' import { ElementId } from '@jbrowse/core/util/types/mst' -import { Indexing } from '@jbrowse/core/ui/Icons' -import { createJBrowseTheme, defaultThemes } from '@jbrowse/core/ui' import { addDisposer, cast, @@ -30,33 +30,35 @@ import { types, walk, } from '@jbrowse/mobx-state-tree' -import { autorun, observable } from 'mobx' import DeleteIcon from '@mui/icons-material/Delete' import CopyIcon from '@mui/icons-material/FileCopy' import InfoIcon from '@mui/icons-material/Info' -import SettingsIcon from '@mui/icons-material/Settings' import Report from '@mui/icons-material/Report' +import SettingsIcon from '@mui/icons-material/Settings' +import { autorun, observable } from 'mobx' import type { DesktopRootModel } from '../rootModel/rootModel' import type PluginManager from '@jbrowse/core/PluginManager' -import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import type { AnyConfiguration, AnyConfigurationModel, } from '@jbrowse/core/configuration' -import type { IBaseViewModel } from '@jbrowse/core/pluggableElementTypes' -import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import type { + BaseTrackConfig, + IBaseViewModel, +} from '@jbrowse/core/pluggableElementTypes' import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { + AbstractSessionModel, AssemblyManager, DialogComponentType, NotificationLevel, SnackAction, TrackViewModel, } from '@jbrowse/core/util' -import type { AbstractSessionModel } from '@jbrowse/core/util' import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' import type { ThemeOptions } from '@mui/material' @@ -672,7 +674,10 @@ export default function sessionModelFactory({ function sessionLocalStorageAutorun() { localStorageSetItem('drawerPosition', self.drawerPosition) localStorageSetItem('themeName', self.themeName) - localStorageSetBoolean('stickyViewHeaders', self.stickyViewHeaders) + localStorageSetBoolean( + 'stickyViewHeaders', + self.stickyViewHeaders, + ) }, { name: 'SessionLocalStorage' }, ), @@ -750,10 +755,11 @@ export default function sessionModelFactory({ 'Name', 'ID', ], - exclude: textSearching?.indexingFeatureTypesToExclude || [ - 'CDS', - 'exon', - ], + exclude: + textSearching?.indexingFeatureTypesToExclude || [ + 'CDS', + 'exon', + ], assemblies: assemblyNames, tracks: [trackId], indexType: 'perTrack', diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.ts b/products/jbrowse-react-app/src/rootModel/rootModel.ts index f5b429f949..01a43a6828 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.ts @@ -1,27 +1,26 @@ import { - RootAppMenuMixin, - getOpenTrackMenuItem, - getOpenConnectionMenuItem, - getImportSessionMenuItem, getExportSessionMenuItem, + getImportSessionMenuItem, + getOpenConnectionMenuItem, + getOpenTrackMenuItem, processMutableMenuActions, } from '@jbrowse/app-core' -import type { Menu, SessionModelFactory } from '@jbrowse/app-core' import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import assemblyConfigSchemaFactory from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import RpcManager from '@jbrowse/core/rpc/RpcManager' import { addDisposer, getSnapshot, types } from '@jbrowse/mobx-state-tree' -import { - BaseRootModelFactory, - InternetAccountsRootModelMixin, -} from '@jbrowse/product-core' +import { BaseRootModelFactory } from '@jbrowse/product-core' import AddIcon from '@mui/icons-material/Add' import { autorun } from 'mobx' import jbrowseWebFactory from '../jbrowseModel' import { version } from '../version' +import type { Menu, MenuAction, SessionModelFactory } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' +import type { AnyConfigurationModel } from '@jbrowse/core/configuration' +import type { MenuItem } from '@jbrowse/core/ui' +import type { UriLocation } from '@jbrowse/core/util' import type { Instance } from '@jbrowse/mobx-state-tree' type AssemblyConfig = ReturnType @@ -31,8 +30,6 @@ type AssemblyConfig = ReturnType * * composed of * - [BaseRootModel](../baserootmodel) - * - [InternetAccountsMixin](../internetaccountsmixin) - * - [RootAppMenuMixin](../rootappmenumixin) * * note: many properties of the root model are available through the session, * and we generally prefer using the session model (via e.g. getSession) over @@ -64,8 +61,12 @@ export default function RootModel({ }), assemblyConfigSchema, }), - InternetAccountsRootModelMixin(pluginManager), - RootAppMenuMixin(), + types.model({ + // InternetAccountsRootModelMixin property + internetAccounts: types.array( + pluginManager.pluggableMstType('internet account', 'stateModel'), + ), + }), ) .volatile(self => ({ @@ -98,10 +99,180 @@ export default function RootModel({ * #volatile */ error: undefined as unknown, + // RootAppMenuMixin volatile + mutableMenuActions: [] as MenuAction[], + })) + .actions(self => ({ + // InternetAccountsRootModelMixin actions + /** + * #action + */ + initializeInternetAccount( + internetAccountConfig: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const internetAccountType = pluginManager.getInternetAccountType( + internetAccountConfig.type, + ) + if (!internetAccountType) { + throw new Error( + `unknown internet account type ${internetAccountConfig.type}`, + ) + } + + const length = self.internetAccounts.push({ + ...initialSnapshot, + type: internetAccountConfig.type, + configuration: internetAccountConfig, + }) + return self.internetAccounts[length - 1] + }, + + /** + * #action + */ + createEphemeralInternetAccount( + internetAccountId: string, + initialSnapshot: Record, + url: string, + ) { + let hostUri: string | undefined + + try { + hostUri = new URL(url).origin + } catch { + // ignore + } + const internetAccountSplit = internetAccountId.split('-') + const configuration = { + type: internetAccountSplit[0]!, + internetAccountId: internetAccountId, + name: internetAccountSplit.slice(1).join('-'), + description: '', + domains: hostUri ? [hostUri] : [], + } + const type = pluginManager.getInternetAccountType(configuration.type)! + const internetAccount = type.stateModel.create({ + ...initialSnapshot, + type: configuration.type, + configuration, + }) + self.internetAccounts.push(internetAccount) + return internetAccount + }, + /** + * #action + */ + findAppropriateInternetAccount(location: UriLocation) { + const selectedId = location.internetAccountId + if (selectedId) { + const selectedAccount = self.internetAccounts.find(account => { + return account.internetAccountId === selectedId + }) + if (selectedAccount) { + return selectedAccount + } + } + + for (const account of self.internetAccounts) { + const handleResult = account.handlesLocation(location) + if (handleResult) { + return account + } + } + + return selectedId + ? this.createEphemeralInternetAccount(selectedId, {}, location.uri) + : null + }, + + // RootAppMenuMixin actions + /** + * #action + */ + setMenus(newMenus: Menu[]) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'setMenus', newMenus }, + ] + }, + /** + * #action + */ + appendMenu(menuName: string) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendMenu', menuName }, + ] + }, + /** + * #action + */ + insertMenu(menuName: string, position: number) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'insertMenu', menuName, position }, + ] + }, + /** + * #action + */ + appendToMenu(menuName: string, menuItem: MenuItem) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendToMenu', menuName, menuItem }, + ] + }, + /** + * #action + */ + insertInMenu(menuName: string, menuItem: MenuItem, position: number) { + self.mutableMenuActions.push({ + type: 'insertInMenu', + menuName, + menuItem, + position, + }) + }, + /** + * #action + */ + appendToSubMenu(menuPath: string[], menuItem: MenuItem) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendToSubMenu', menuPath, menuItem }, + ] + }, + /** + * #action + */ + insertInSubMenu( + menuPath: string[], + menuItem: MenuItem, + position: number, + ) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'insertInSubMenu', menuPath, menuItem, position }, + ] + }, })) .actions(self => { return { afterCreate() { + // InternetAccountsRootModelMixin setup + addDisposer( + self, + autorun( + function internetAccountsAutorun() { + for (const internetAccount of self.jbrowse.internetAccounts) { + self.initializeInternetAccount(internetAccount) + } + }, + { name: 'InternetAccounts' }, + ), + ) + addDisposer( self, autorun( diff --git a/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx b/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx index 0ab7ca24c0..4216c1c15b 100644 --- a/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx +++ b/products/jbrowse-react-circular-genome-view/src/createModel/createSessionModel.tsx @@ -23,12 +23,11 @@ import { types, walk, } from '@jbrowse/mobx-state-tree' -import { autorun, observable } from 'mobx' import InfoIcon from '@mui/icons-material/Info' import Report from '@mui/icons-material/Report' +import { autorun, observable } from 'mobx' import type PluginManager from '@jbrowse/core/PluginManager' -import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import type { AnyConfiguration, @@ -36,6 +35,7 @@ import type { } from '@jbrowse/core/configuration' import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { AssemblyManager, DialogComponentType, diff --git a/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx b/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx index 97dd0a5855..32cfbf746c 100644 --- a/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx +++ b/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx @@ -1,6 +1,9 @@ import { lazy } from 'react' -import { isConfigurationModel } from '@jbrowse/core/configuration' +import { + isConfigurationModel, + readConfObject, +} from '@jbrowse/core/configuration' import { getContainingView, isSessionModelWithWidgets, @@ -21,13 +24,11 @@ import { types, walk, } from '@jbrowse/mobx-state-tree' -import { readConfObject } from '@jbrowse/core/configuration' -import { autorun, observable } from 'mobx' import InfoIcon from '@mui/icons-material/Info' import Report from '@mui/icons-material/Report' +import { autorun, observable } from 'mobx' import type PluginManager from '@jbrowse/core/PluginManager' -import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import type { AnyConfiguration, @@ -35,6 +36,7 @@ import type { } from '@jbrowse/core/configuration' import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' import type { BaseConnectionConfigModel } from '@jbrowse/core/pluggableElementTypes/models/baseConnectionConfig' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { AssemblyManager, DialogComponentType, @@ -43,10 +45,7 @@ import type { TrackViewModel, } from '@jbrowse/core/util' import type { AbstractSessionModel } from '@jbrowse/core/util/types' -import type { - IAnyStateTreeNode, - Instance, -} from '@jbrowse/mobx-state-tree' +import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' import type { LinearGenomeViewStateModel } from '@jbrowse/plugin-linear-genome-view' // lazies diff --git a/products/jbrowse-web/src/rootModel/rootModel.ts b/products/jbrowse-web/src/rootModel/rootModel.ts index bf653c21b2..9c406c01fd 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.ts @@ -1,23 +1,21 @@ import { lazy } from 'react' import { - HistoryManagementMixin, - RootAppMenuMixin, - getOpenTrackMenuItem, + getExportSessionMenuItem, + getImportSessionMenuItem, getOpenConnectionMenuItem, - getUndoMenuItem, - getRedoMenuItem, + getOpenTrackMenuItem, getPluginStoreMenuItem, - getImportSessionMenuItem, - getExportSessionMenuItem, + getRedoMenuItem, + getUndoMenuItem, processMutableMenuActions, } from '@jbrowse/app-core' -import type { Menu, SessionModelFactory } from '@jbrowse/app-core' import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import assemblyConfigSchemaFactory from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import { readConfObject } from '@jbrowse/core/configuration' import RpcManager from '@jbrowse/core/rpc/RpcManager' import { DNA } from '@jbrowse/core/ui/Icons' +import TimeTraveller from '@jbrowse/core/util/TimeTraveller' import { addDisposer, getSnapshot, @@ -25,10 +23,7 @@ import { types, } from '@jbrowse/mobx-state-tree' import { AssemblyManager } from '@jbrowse/plugin-data-management' -import { - BaseRootModelFactory, - InternetAccountsRootModelMixin, -} from '@jbrowse/product-core' +import { BaseRootModelFactory } from '@jbrowse/product-core' import AddIcon from '@mui/icons-material/Add' import FileCopyIcon from '@mui/icons-material/FileCopy' import FolderOpenIcon from '@mui/icons-material/FolderOpen' @@ -43,10 +38,14 @@ import jbrowseWebFactory from '../jbrowseModel' import makeWorkerInstance from '../makeWorkerInstance' import type { SessionDB, SessionMetadata } from '../types' +import type { Menu, MenuAction, SessionModelFactory } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' +import type { AnyConfigurationModel } from '@jbrowse/core/configuration' +import type { MenuItem } from '@jbrowse/core/ui' import type { AbstractSessionModel, SessionWithWidgets, + UriLocation, } from '@jbrowse/core/util' import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' import type { BaseSessionType, SessionWithDialogs } from '@jbrowse/product-core' @@ -63,9 +62,6 @@ type AssemblyConfig = ReturnType * * composed of * - [BaseRootModel](../baserootmodel) - * - [InternetAccountsMixin](../internetaccountsmixin) - * - [HistoryManagementMixin](../historymanagementmixin) - * - [RootAppMenuMixin](../rootappmenumixin) * * note: many properties of the root model are available through the session, * and we generally prefer using the session model (via e.g. getSession) over @@ -97,9 +93,14 @@ export default function RootModel({ sessionModelType, assemblyConfigSchema, }), - InternetAccountsRootModelMixin(pluginManager), - HistoryManagementMixin(), - RootAppMenuMixin(), + types.model({ + // InternetAccountsRootModelMixin property + internetAccounts: types.array( + pluginManager.pluggableMstType('internet account', 'stateModel'), + ), + // HistoryManagementMixin property + history: types.optional(TimeTraveller, { targetPath: '../session' }), + }), ) .props({ /** @@ -156,6 +157,8 @@ export default function RootModel({ ) => { console.error('reloadPluginManagerCallback unimplemented') }, + // RootAppMenuMixin volatile + mutableMenuActions: [] as MenuAction[], })) .actions(self => ({ @@ -185,236 +188,436 @@ export default function RootModel({ setSessionDB(sessionDB: IDBPDatabase) { self.sessionDB = sessionDB }, - })) - .actions(self => ({ - /** - * #aftercreate - */ - afterCreate() { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - ;(async () => { - try { - const sessionDB = await openDB('sessionsDB', 2, { - upgrade(db) { - db.createObjectStore('metadata') - db.createObjectStore('sessions') - }, - }) - self.setSessionDB(sessionDB) - - addDisposer( - self, - autorun( - async () => { - if (self.session) { - try { - // careful not to access self.savedSessionMetadata in - // here, or else it can create an infinite loop - const s = self.session - - // step 1. update the idb data according to whatever - // triggered the autorun - if (self.sessionDB) { - await sessionDB.put('sessions', getSnapshot(s), s.id) - if (!isAlive(self)) { - return - } - - const ret = await self.sessionDB.get('metadata', s.id) - await sessionDB.put( - 'metadata', - { - ...ret, - favorite: ret?.favorite || false, - name: s.name, - id: s.id, - createdAt: new Date(), - configPath: self.configPath || '', - }, - s.id, - ) - } - // step 2. refetch the metadata - await self.fetchSessionMetadata() - } catch (e) { - console.error(e) - self.session?.notifyError(`${e}`, e) - } - } - }, - { delay: 400 }, - ), - ) - } catch (e) { - console.error(e) - self.session?.notifyError(`${e}`, e) - } - - let savingFailed = false - addDisposer( - self, - autorun( - () => { - if (self.session) { - const s = self.session as AbstractSessionModel - const sessionSnap = getSnapshot(s) - try { - sessionStorage.setItem( - 'current', - JSON.stringify({ - session: sessionSnap, - createdAt: new Date(), - }), - ) - if (savingFailed) { - savingFailed = false - s.notify('Auto-saving restored', 'info') - } - // this check is not able to be modularized into it's own - // autorun at current time because it depends on session - // storage snapshot being set above - if (self.pluginsUpdated) { - self.reloadPluginManagerCallback( - structuredClone(getSnapshot(self.jbrowse)), - structuredClone(sessionSnap), - ) - } - } catch (e) { - console.error(e) - const msg = `${e}` - if (!savingFailed) { - savingFailed = true - if (msg.includes('quota')) { - s.notifyError( - 'Unable to auto-save session, exceeded sessionStorage quota. This may be because a very large feature was stored in session', - e, - ) - } else { - s.notifyError(msg, e) - } - } - } - } - }, - { delay: 400 }, - ), - ) - })() - }, + // InternetAccountsRootModelMixin actions /** * #action */ - setPluginsUpdated(flag: boolean) { - self.pluginsUpdated = flag + initializeInternetAccount( + internetAccountConfig: AnyConfigurationModel, + initialSnapshot = {}, + ) { + const internetAccountType = pluginManager.getInternetAccountType( + internetAccountConfig.type, + ) + if (!internetAccountType) { + throw new Error( + `unknown internet account type ${internetAccountConfig.type}`, + ) + } + + const length = self.internetAccounts.push({ + ...initialSnapshot, + type: internetAccountConfig.type, + configuration: internetAccountConfig, + }) + return self.internetAccounts[length - 1] }, + /** * #action */ - setReloadPluginManagerCallback( - callback: ( - configSnapshot: Record, - sessionSnapshot: Record, - ) => void, + createEphemeralInternetAccount( + internetAccountId: string, + initialSnapshot: Record, + url: string, ) { - self.reloadPluginManagerCallback = callback + let hostUri: string | undefined + + try { + hostUri = new URL(url).origin + } catch { + // ignore + } + const internetAccountSplit = internetAccountId.split('-') + const configuration = { + type: internetAccountSplit[0]!, + internetAccountId: internetAccountId, + name: internetAccountSplit.slice(1).join('-'), + description: '', + domains: hostUri ? [hostUri] : [], + } + const type = pluginManager.getInternetAccountType(configuration.type)! + const internetAccount = type.stateModel.create({ + ...initialSnapshot, + type: configuration.type, + configuration, + }) + self.internetAccounts.push(internetAccount) + return internetAccount }, /** * #action */ - setDefaultSession() { - const { defaultSession } = self.jbrowse - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void + findAppropriateInternetAccount(location: UriLocation) { + const selectedId = location.internetAccountId + if (selectedId) { + const selectedAccount = self.internetAccounts.find(account => { + return account.internetAccountId === selectedId + }) + if (selectedAccount) { + return selectedAccount + } } - setSession({ - ...defaultSession, - name: `${defaultSession.name || 'New session'} ${new Date().toLocaleString()}`, - }) + + for (const account of self.internetAccounts) { + const handleResult = account.handlesLocation(location) + if (handleResult) { + return account + } + } + + return selectedId + ? this.createEphemeralInternetAccount(selectedId, {}, location.uri) + : null }, + + // RootAppMenuMixin actions /** * #action */ - async activateSession(id: string) { - const ret = await self.sessionDB?.get('sessions', id) - if (ret) { - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void - } - setSession(ret) - } else { - self.session.notifyError('Session not found') - } + setMenus(newMenus: Menu[]) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'setMenus', newMenus }, + ] }, /** * #action */ - async favoriteSavedSession(id: string) { - if (self.sessionDB) { - const ret = self.savedSessionMetadata!.find(f => f.id === id) - if (ret) { - await self.sessionDB.put( - 'metadata', - { - ...ret, - favorite: true, - }, - ret.id, - ) - await self.fetchSessionMetadata() - } - } + appendMenu(menuName: string) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendMenu', menuName }, + ] }, /** * #action */ - async unfavoriteSavedSession(id: string) { - if (self.sessionDB) { - const ret = self.savedSessionMetadata!.find(f => f.id === id) - if (ret) { - await self.sessionDB.put( - 'metadata', - { - ...ret, - favorite: false, - }, - ret.id, - ) - } - await self.fetchSessionMetadata() - } + insertMenu(menuName: string, position: number) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'insertMenu', menuName, position }, + ] }, /** * #action */ - async deleteSavedSession(id: string) { - if (self.sessionDB) { - await self.sessionDB.delete('metadata', id) - await self.sessionDB.delete('sessions', id) - await self.fetchSessionMetadata() - } + appendToMenu(menuName: string, menuItem: MenuItem) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendToMenu', menuName, menuItem }, + ] }, /** * #action */ - renameCurrentSession(sessionName: string) { - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void - } - const snapshot = getSnapshot(self.session) as Record - setSession({ - ...snapshot, - name: sessionName, + insertInMenu(menuName: string, menuItem: MenuItem, position: number) { + self.mutableMenuActions.push({ + type: 'insertInMenu', + menuName, + menuItem, + position, }) }, - /** * #action */ - setError(error?: unknown) { - self.error = error + appendToSubMenu(menuPath: string[], menuItem: MenuItem) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'appendToSubMenu', menuPath, menuItem }, + ] + }, + /** + * #action + */ + insertInSubMenu( + menuPath: string[], + menuItem: MenuItem, + position: number, + ) { + self.mutableMenuActions = [ + ...self.mutableMenuActions, + { type: 'insertInSubMenu', menuPath, menuItem, position }, + ] }, })) + .actions(self => { + // HistoryManagementMixin keyboard listener + const keydownListener = (e: KeyboardEvent) => { + if ( + self.history.canRedo && + (((e.ctrlKey || e.metaKey) && e.shiftKey && e.code === 'KeyZ') || + (e.ctrlKey && !e.shiftKey && e.code === 'KeyY')) && + document.activeElement?.tagName.toUpperCase() !== 'INPUT' + ) { + self.history.redo() + } + if ( + self.history.canUndo && + (e.ctrlKey || e.metaKey) && + !e.shiftKey && + e.code === 'KeyZ' && + document.activeElement?.tagName.toUpperCase() !== 'INPUT' + ) { + self.history.undo() + } + } + + return { + /** + * #aftercreate + */ + afterCreate() { + // HistoryManagementMixin setup + document.addEventListener('keydown', keydownListener) + addDisposer( + self, + autorun( + function historyInitAutorun() { + if (self.session) { + self.history.initialize() + } + }, + { name: 'HistoryInit' }, + ), + ) + + // InternetAccountsRootModelMixin setup + addDisposer( + self, + autorun( + function internetAccountsAutorun() { + for (const internetAccount of self.jbrowse.internetAccounts) { + self.initializeInternetAccount(internetAccount) + } + }, + { name: 'InternetAccounts' }, + ), + ) + + // Session storage setup + // eslint-disable-next-line @typescript-eslint/no-floating-promises + ;(async () => { + try { + const sessionDB = await openDB('sessionsDB', 2, { + upgrade(db) { + db.createObjectStore('metadata') + db.createObjectStore('sessions') + }, + }) + self.setSessionDB(sessionDB) + + addDisposer( + self, + autorun( + async () => { + if (self.session) { + try { + const s = self.session + + if (self.sessionDB) { + await sessionDB.put('sessions', getSnapshot(s), s.id) + if (!isAlive(self)) { + return + } + + const ret = await self.sessionDB.get('metadata', s.id) + await sessionDB.put( + 'metadata', + { + ...ret, + favorite: ret?.favorite || false, + name: s.name, + id: s.id, + createdAt: new Date(), + configPath: self.configPath || '', + }, + s.id, + ) + } + await self.fetchSessionMetadata() + } catch (e) { + console.error(e) + self.session?.notifyError(`${e}`, e) + } + } + }, + { delay: 400 }, + ), + ) + } catch (e) { + console.error(e) + self.session?.notifyError(`${e}`, e) + } + + let savingFailed = false + addDisposer( + self, + autorun( + () => { + if (self.session) { + const s = self.session as AbstractSessionModel + const sessionSnap = getSnapshot(s) + try { + sessionStorage.setItem( + 'current', + JSON.stringify({ + session: sessionSnap, + createdAt: new Date(), + }), + ) + if (savingFailed) { + savingFailed = false + s.notify('Auto-saving restored', 'info') + } + + if (self.pluginsUpdated) { + self.reloadPluginManagerCallback( + structuredClone(getSnapshot(self.jbrowse)), + structuredClone(sessionSnap), + ) + } + } catch (e) { + console.error(e) + const msg = `${e}` + if (!savingFailed) { + savingFailed = true + if (msg.includes('quota')) { + s.notifyError( + 'Unable to auto-save session, exceeded sessionStorage quota. This may be because a very large feature was stored in session', + e, + ) + } else { + s.notifyError(msg, e) + } + } + } + } + }, + { delay: 400 }, + ), + ) + })() + }, + beforeDestroy() { + document.removeEventListener('keydown', keydownListener) + }, + /** + * #action + */ + setPluginsUpdated(flag: boolean) { + self.pluginsUpdated = flag + }, + /** + * #action + */ + setReloadPluginManagerCallback( + callback: ( + configSnapshot: Record, + sessionSnapshot: Record, + ) => void, + ) { + self.reloadPluginManagerCallback = callback + }, + /** + * #action + */ + setDefaultSession() { + const { defaultSession } = self.jbrowse + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + setSession({ + ...defaultSession, + name: `${defaultSession.name || 'New session'} ${new Date().toLocaleString()}`, + }) + }, + /** + * #action + */ + async activateSession(id: string) { + const ret = await self.sessionDB?.get('sessions', id) + if (ret) { + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + setSession(ret) + } else { + self.session.notifyError('Session not found') + } + }, + /** + * #action + */ + async favoriteSavedSession(id: string) { + if (self.sessionDB) { + const ret = self.savedSessionMetadata!.find(f => f.id === id) + if (ret) { + await self.sessionDB.put( + 'metadata', + { + ...ret, + favorite: true, + }, + ret.id, + ) + await self.fetchSessionMetadata() + } + } + }, + /** + * #action + */ + async unfavoriteSavedSession(id: string) { + if (self.sessionDB) { + const ret = self.savedSessionMetadata!.find(f => f.id === id) + if (ret) { + await self.sessionDB.put( + 'metadata', + { + ...ret, + favorite: false, + }, + ret.id, + ) + } + await self.fetchSessionMetadata() + } + }, + /** + * #action + */ + async deleteSavedSession(id: string) { + if (self.sessionDB) { + await self.sessionDB.delete('metadata', id) + await self.sessionDB.delete('sessions', id) + await self.fetchSessionMetadata() + } + }, + /** + * #action + */ + renameCurrentSession(sessionName: string) { + const { setSession } = self as unknown as { + setSession: (arg: unknown) => void + } + const snapshot = getSnapshot(self.session) + setSession({ + ...snapshot, + name: sessionName, + }) + }, + + /** + * #action + */ + setError(error?: unknown) { + self.error = error + }, + } + }) .views(self => ({ /** * #method diff --git a/products/jbrowse-web/src/tests/LGVSynteny.test.tsx b/products/jbrowse-web/src/tests/LGVSynteny.test.tsx index fb647396d2..64a1c03f10 100644 --- a/products/jbrowse-web/src/tests/LGVSynteny.test.tsx +++ b/products/jbrowse-web/src/tests/LGVSynteny.test.tsx @@ -94,7 +94,6 @@ test('nav to synteny from right click, with launch connection plugin', async () assemblyNames: [assemblyName], connectionId: jb2asm, } - // @ts-expect-error s.addConnectionConf(conf) // @ts-expect-error s.makeConnection(conf) From 69977ac1797715cc7cae4f5e8d28ad97a434ee21 Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 5 Dec 2025 10:55:45 -0500 Subject: [PATCH 3/8] Improve typing --- packages/app-core/src/ui/App/ViewHeader.tsx | 7 +++---- products/jbrowse-web/src/tests/LGVSynteny.test.tsx | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/app-core/src/ui/App/ViewHeader.tsx b/packages/app-core/src/ui/App/ViewHeader.tsx index 2a1f56a47b..c1793f010d 100644 --- a/packages/app-core/src/ui/App/ViewHeader.tsx +++ b/packages/app-core/src/ui/App/ViewHeader.tsx @@ -76,10 +76,9 @@ const ViewHeader = observer(function ({ const { classes } = useStyles() const scrollRef = useRef(null) const session = getSession(view) - let stickyViewHeaders = false - if (isSessionWithMultipleViews(session)) { - ;({ stickyViewHeaders } = session) - } + const stickyViewHeaders = isSessionWithMultipleViews(session) + ? session.stickyViewHeaders + : false // scroll the view into view when first mounted. note: this effect will run // only once, because of the empty array second param diff --git a/products/jbrowse-web/src/tests/LGVSynteny.test.tsx b/products/jbrowse-web/src/tests/LGVSynteny.test.tsx index 64a1c03f10..2b29e3c0f2 100644 --- a/products/jbrowse-web/src/tests/LGVSynteny.test.tsx +++ b/products/jbrowse-web/src/tests/LGVSynteny.test.tsx @@ -95,7 +95,6 @@ test('nav to synteny from right click, with launch connection plugin', async () connectionId: jb2asm, } s.addConnectionConf(conf) - // @ts-expect-error s.makeConnection(conf) } }, From a56631ba2e334a8d729ae02e2e5b2d43cd94a781 Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 5 Dec 2025 14:22:59 -0500 Subject: [PATCH 4/8] K1 --- packages/app-core/src/menuItems.ts | 6 +----- packages/product-core/src/RootModel/BaseRootModel.ts | 10 ++-------- products/jbrowse-react-app/src/rootModel/rootModel.ts | 2 -- .../src/createModel/createSessionModel.tsx | 4 +++- products/jbrowse-web/src/rootModel/rootModel.ts | 6 ++---- 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/packages/app-core/src/menuItems.ts b/packages/app-core/src/menuItems.ts index 2743e49ba1..9cd340cf66 100644 --- a/packages/app-core/src/menuItems.ts +++ b/packages/app-core/src/menuItems.ts @@ -8,13 +8,9 @@ import StorageIcon from '@mui/icons-material/Storage' import UndoIcon from '@mui/icons-material/Undo' import type { MenuItem } from '@jbrowse/core/ui' -import type { AbstractViewModel, SessionWithWidgets } from '@jbrowse/core/util' +import type { SessionWithWidgets } from '@jbrowse/core/util' import type { IAnyStateTreeNode } from '@jbrowse/mobx-state-tree' -interface SessionWithViews { - views: AbstractViewModel[] -} - interface HistoryManager { canUndo: boolean canRedo: boolean diff --git a/packages/product-core/src/RootModel/BaseRootModel.ts b/packages/product-core/src/RootModel/BaseRootModel.ts index f39f437d7f..ae2fea8526 100644 --- a/packages/product-core/src/RootModel/BaseRootModel.ts +++ b/packages/product-core/src/RootModel/BaseRootModel.ts @@ -12,13 +12,7 @@ import { import type PluginManager from '@jbrowse/core/PluginManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' -import type { IAnyType, Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' - -/** Minimal session snapshot shape for type checking */ -export interface BaseSessionSnapshot { - name: string - id?: string -} +import type { IAnyType, Instance } from '@jbrowse/mobx-state-tree' /** * #stateModel BaseRootModel @@ -103,7 +97,7 @@ export function BaseRootModelFactory({ /** * #action */ - setSession(sessionSnapshot?: SnapshotIn & BaseSessionSnapshot) { + setSession(sessionSnapshot?: Record) { const oldSession = self.session self.session = cast(sessionSnapshot) if (self.session) { diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.ts b/products/jbrowse-react-app/src/rootModel/rootModel.ts index 01a43a6828..5d3c9ce7cf 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.ts @@ -23,8 +23,6 @@ import type { MenuItem } from '@jbrowse/core/ui' import type { UriLocation } from '@jbrowse/core/util' import type { Instance } from '@jbrowse/mobx-state-tree' -type AssemblyConfig = ReturnType - /** * #stateModel JBrowseReactAppRootModel * diff --git a/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx b/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx index 32cfbf746c..ec68a19991 100644 --- a/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx +++ b/products/jbrowse-react-linear-genome-view/src/createModel/createSessionModel.tsx @@ -83,7 +83,9 @@ export interface LGVRootModel { jbrowse: { tracks: AnyConfigurationModel[] addTrackConf: (conf: AnyConfiguration) => AnyConfigurationModel - deleteTrackConf: (conf: AnyConfigurationModel) => AnyConfigurationModel + deleteTrackConf: ( + conf: AnyConfigurationModel, + ) => AnyConfigurationModel | undefined connections: BaseConnectionConfigModel[] addConnectionConf: (conf: AnyConfigurationModel) => unknown deleteConnectionConf: (conf: AnyConfigurationModel) => unknown diff --git a/products/jbrowse-web/src/rootModel/rootModel.ts b/products/jbrowse-web/src/rootModel/rootModel.ts index 9c406c01fd..d3945a36a6 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.ts @@ -47,16 +47,14 @@ import type { SessionWithWidgets, UriLocation, } from '@jbrowse/core/util' -import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' -import type { BaseSessionType, SessionWithDialogs } from '@jbrowse/product-core' +import type { Instance } from '@jbrowse/mobx-state-tree' +import type { SessionWithDialogs } from '@jbrowse/product-core' import type { IDBPDatabase } from 'idb' // lazies const SetDefaultSession = lazy(() => import('../components/SetDefaultSession')) const PreferencesDialog = lazy(() => import('../components/PreferencesDialog')) -type AssemblyConfig = ReturnType - /** * #stateModel JBrowseWebRootModel * From 48d2656b15b58ae75f5b12ce88d2a0454660a27a Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 5 Dec 2025 15:13:16 -0500 Subject: [PATCH 5/8] [skip ci] Inlines --- packages/app-core/src/JBrowseModel/index.ts | 4 +- .../src/RootModel/BaseRootModel.ts | 163 ++---------- .../product-core/src/Session/BaseSession.ts | 8 +- .../src/rootModel/rootModel.test.ts | 2 +- .../src/rootModel/rootModel.ts | 207 +++++++++++----- .../src/rootModel/rootModel.test.ts | 2 +- .../src/rootModel/rootModel.ts | 234 ++++++++++-------- .../src/rootModel/rootModel.test.ts | 2 +- .../jbrowse-web/src/rootModel/rootModel.ts | 121 +++++---- 9 files changed, 365 insertions(+), 378 deletions(-) diff --git a/packages/app-core/src/JBrowseModel/index.ts b/packages/app-core/src/JBrowseModel/index.ts index 081eefc896..b0d370c834 100644 --- a/packages/app-core/src/JBrowseModel/index.ts +++ b/packages/app-core/src/JBrowseModel/index.ts @@ -79,7 +79,7 @@ export function JBrowseModelF({ /** * #action */ - addTrackConf(trackConf: AnyConfigurationModel) { + addTrackConf(trackConf: Record) { const { type } = trackConf if (!type) { throw new Error(`unknown track type ${type}`) @@ -90,7 +90,7 @@ export function JBrowseModelF({ /** * #action */ - addConnectionConf(connectionConf: AnyConfigurationModel) { + addConnectionConf(connectionConf: Record) { const { type } = connectionConf if (!type) { throw new Error(`unknown connection type ${type}`) diff --git a/packages/product-core/src/RootModel/BaseRootModel.ts b/packages/product-core/src/RootModel/BaseRootModel.ts index ae2fea8526..0b1655e22c 100644 --- a/packages/product-core/src/RootModel/BaseRootModel.ts +++ b/packages/product-core/src/RootModel/BaseRootModel.ts @@ -1,151 +1,22 @@ -import { filterSessionInPlace } from '@jbrowse/app-core' -import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' -import assemblyManagerFactory from '@jbrowse/core/assemblyManager' -import RpcManager from '@jbrowse/core/rpc/RpcManager' -import { - cast, - getSnapshot, - getType, - isStateTreeNode, - types, -} from '@jbrowse/mobx-state-tree' +import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import type assemblyManagerFactory from '@jbrowse/core/assemblyManager' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' +import type { AnyConfigurationModel } from '@jbrowse/core/configuration' +import type { Instance } from '@jbrowse/mobx-state-tree' -import type PluginManager from '@jbrowse/core/PluginManager' -import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager' -import type { IAnyType, Instance } from '@jbrowse/mobx-state-tree' +type AssemblyManager = Instance> /** - * #stateModel BaseRootModel - * #category root - * factory function for the Base-level root model shared by all products + * Base interface for root model properties that sessions expect to exist */ -export function BaseRootModelFactory({ - pluginManager, - jbrowseModelType, - sessionModelType, - assemblyConfigSchema, -}: { - pluginManager: PluginManager - jbrowseModelType: IAnyType - sessionModelType: IAnyType - assemblyConfigSchema: BaseAssemblyConfigSchema -}) { - return types - .model('BaseRootModel', { - /** - * #property - * `jbrowse` is a mapping of the config.json into the in-memory state - * tree - */ - jbrowse: jbrowseModelType, - - /** - * #property - * `session` encompasses the currently active state of the app, including - * views open, tracks open in those views, etc. - */ - session: types.maybe(sessionModelType), - /** - * #property - */ - sessionPath: types.optional(types.string, ''), - - /** - * #property - */ - assemblyManager: types.optional( - assemblyManagerFactory(assemblyConfigSchema, pluginManager), - {}, - ), - }) - .volatile(self => ({ - /** - * #volatile - */ - rpcManager: new RpcManager( - pluginManager, - self.jbrowse.configuration.rpc, - { - MainThreadRpcDriver: {}, - }, - ), - - /** - * #volatile - */ - adminMode: false, - /** - * #volatile - */ - error: undefined as unknown, - /** - * #volatile - */ - textSearchManager: new TextSearchManager(pluginManager), - /** - * #volatile - */ - pluginManager, - })) - .actions(self => ({ - /** - * #action - */ - setError(error: unknown) { - self.error = error - }, - /** - * #action - */ - setSession(sessionSnapshot?: Record) { - const oldSession = self.session - self.session = cast(sessionSnapshot) - if (self.session) { - // validate all references in the session snapshot - try { - filterSessionInPlace(self.session, getType(self.session)) - } catch (error) { - // throws error if session filtering failed - self.session = oldSession - throw error - } - } - }, - /** - * #action - */ - setDefaultSession() { - this.setSession(self.jbrowse.defaultSession) - }, - /** - * #action - */ - setSessionPath(path: string) { - self.sessionPath = path - }, - /** - * #action - */ - renameCurrentSession(newName: string) { - if (self.session) { - this.setSession({ - ...getSnapshot(self.session), - name: newName, - }) - } - }, - })) -} - -export type BaseRootModelType = ReturnType -export type BaseRootModel = Instance - -/** Type guard for checking if something is a JB root model */ -export function isRootModel(thing: unknown): thing is BaseRootModelType { - return ( - isStateTreeNode(thing) && - 'session' in thing && - 'jbrowse' in thing && - 'assemblyManager' in thing - ) +export interface BaseRootModelType { + jbrowse: { + assemblies: AnyConfigurationModel[] + configuration: AnyConfigurationModel + } + session: unknown + assemblyManager: AssemblyManager + rpcManager: RpcManager + adminMode: boolean + textSearchManager: TextSearchManager } diff --git a/packages/product-core/src/Session/BaseSession.ts b/packages/product-core/src/Session/BaseSession.ts index a4c1350564..777ecf955b 100644 --- a/packages/product-core/src/Session/BaseSession.ts +++ b/packages/product-core/src/Session/BaseSession.ts @@ -72,8 +72,8 @@ export function BaseSessionModel< /** * #getter */ - get configuration(): Instance { - return this.jbrowse.configuration + get configuration() { + return this.jbrowse.configuration as Instance }, /** * #getter @@ -93,8 +93,8 @@ export function BaseSessionModel< /** * #getter */ - get assemblies(): Instance[] { - return self.jbrowse.assemblies + get assemblies() { + return self.jbrowse.assemblies as Instance[] }, })) .actions(self => ({ diff --git a/products/jbrowse-desktop/src/rootModel/rootModel.test.ts b/products/jbrowse-desktop/src/rootModel/rootModel.test.ts index 86e957abb3..1ceb7dd7b9 100644 --- a/products/jbrowse-desktop/src/rootModel/rootModel.test.ts +++ b/products/jbrowse-desktop/src/rootModel/rootModel.test.ts @@ -98,7 +98,7 @@ test('adds track and connection configs to an assembly', () => { }, }) expect(root.jbrowse.assemblies.length).toBe(1) - expect(getSnapshot(root.jbrowse.assemblies[0])).toMatchSnapshot() + expect(getSnapshot(root.jbrowse.assemblies[0]!)).toMatchSnapshot() const newTrackConf = root.jbrowse.addTrackConf({ type: 'FeatureTrack', trackId: 'trackId0', diff --git a/products/jbrowse-desktop/src/rootModel/rootModel.ts b/products/jbrowse-desktop/src/rootModel/rootModel.ts index 49d1de377b..22f160ded1 100644 --- a/products/jbrowse-desktop/src/rootModel/rootModel.ts +++ b/products/jbrowse-desktop/src/rootModel/rootModel.ts @@ -1,6 +1,7 @@ import { lazy } from 'react' import { + filterSessionInPlace, getOpenConnectionMenuItem, getOpenTrackMenuItem, getPluginStoreMenuItem, @@ -8,13 +9,20 @@ import { getUndoMenuItem, processMutableMenuActions, } from '@jbrowse/app-core' +import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import assemblyManagerFactory from '@jbrowse/core/assemblyManager' import assemblyConfigSchemaF from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import RpcManager from '@jbrowse/core/rpc/RpcManager' import { DNA } from '@jbrowse/core/ui/Icons' import TimeTraveller from '@jbrowse/core/util/TimeTraveller' -import { addDisposer, getSnapshot, types } from '@jbrowse/mobx-state-tree' +import { + addDisposer, + cast, + getSnapshot, + getType, + types, +} from '@jbrowse/mobx-state-tree' import { AssemblyManager } from '@jbrowse/plugin-data-management' -import { BaseRootModelFactory } from '@jbrowse/product-core' import AppsIcon from '@mui/icons-material/Apps' import OpenIcon from '@mui/icons-material/FolderOpen' import MeetingRoomIcon from '@mui/icons-material/MeetingRoom' @@ -33,30 +41,25 @@ import type PluginManager from '@jbrowse/core/PluginManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { MenuItem } from '@jbrowse/core/ui' import type { AbstractSessionModel, UriLocation } from '@jbrowse/core/util' -import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' -import type { BaseSession, SessionWithDialogs } from '@jbrowse/product-core' +import type { Instance } from '@jbrowse/mobx-state-tree' +import type { SessionWithDialogs } from '@jbrowse/product-core' // lazies const PreferencesDialog = lazy(() => import('../components/PreferencesDialog')) const { ipcRenderer } = window.require('electron') -export function getSaveSession(model: { - jbrowse: Record - session: Record | undefined -}) { - const snap = getSnapshot(model.jbrowse) +export function getSaveSession(model: { jbrowse: unknown; session: unknown }) { + const snap = getSnapshot(model.jbrowse as object) return { ...(snap as Record), - defaultSession: model.session ? getSnapshot(model.session) : {}, + defaultSession: model.session ? getSnapshot(model.session as object) : {}, } } /** * #stateModel JBrowseDesktopRootModel * #category root - * composed of - * - [BaseRootModel](../baserootmodel) * * note: many properties of the root model are available through the session, * and we generally prefer using the session model (via e.g. getSession) over @@ -77,34 +80,57 @@ export default function rootModelFactory({ const jbrowseModelType = JBrowseDesktop(pluginManager, assemblyConfigSchema) const JobsManager = jobsModelFactory(pluginManager) return types - .compose( - 'JBrowseDesktopRootModel', - BaseRootModelFactory({ - pluginManager, - jbrowseModelType, - sessionModelType, - assemblyConfigSchema, - }), - types.model({ - // InternetAccountsRootModelMixin property - internetAccounts: types.array( - pluginManager.pluggableMstType('internet account', 'stateModel'), - ), - // HistoryManagementMixin property - history: types.optional(TimeTraveller, { targetPath: '../session' }), - // DesktopSessionManagementMixin property - sessionPath: types.optional(types.string, ''), - }), - ) - .props({ + .model('JBrowseDesktopRootModel', { + /** + * #property + */ + jbrowse: jbrowseModelType, + /** + * #property + */ + session: types.maybe(sessionModelType), + /** + * #property + */ + sessionPath: types.optional(types.string, ''), + /** + * #property + */ + assemblyManager: types.optional( + assemblyManagerFactory(assemblyConfigSchema, pluginManager), + {}, + ), + /** + * #property + */ + internetAccounts: types.array( + pluginManager.pluggableMstType('internet account', 'stateModel'), + ), + /** + * #property + */ + history: types.optional(TimeTraveller, { targetPath: '../session' }), /** * #property */ jobsManager: types.optional(JobsManager, {}), }) .volatile(self => ({ + /** + * #volatile + */ + pluginManager, + /** + * #volatile + */ version: packageJSON.version, + /** + * #volatile + */ adminMode: true, + /** + * #volatile + */ rpcManager: new RpcManager( pluginManager, self.jbrowse.configuration.rpc, @@ -113,13 +139,53 @@ export default function rootModelFactory({ MainThreadRpcDriver: {}, }, ), + /** + * #volatile + */ + textSearchManager: new TextSearchManager(pluginManager), + /** + * #volatile + */ + error: undefined as unknown, + /** + * #volatile + */ openNewSessionCallback: async (_path: string) => { console.error('openNewSessionCallback unimplemented') }, - // RootAppMenuMixin volatile + /** + * #volatile + */ mutableMenuActions: [] as MenuAction[], })) .actions(self => ({ + /** + * #action + */ + setSession(sessionSnapshot?: Record) { + const oldSession = self.session + self.session = cast(sessionSnapshot) + if (self.session) { + try { + filterSessionInPlace(self.session, getType(self.session)) + } catch (error) { + self.session = oldSession + throw error + } + } + }, + /** + * #action + */ + setSessionPath(path: string) { + self.sessionPath = path + }, + /** + * #action + */ + setError(error: unknown) { + self.error = error + }, /** * #action */ @@ -129,15 +195,11 @@ export default function rootModelFactory({ /** * #action */ - async setPluginsUpdated() { - const root = self as DesktopRootModel - if (root.session) { - await root.saveSession(getSaveSession(root)) + async saveSession(val: unknown) { + if (self.sessionPath) { + await ipcRenderer.invoke('saveSession', self.sessionPath, val) } - await root.openNewSessionCallback(root.sessionPath) }, - - // InternetAccountsRootModelMixin actions /** * #action */ @@ -161,7 +223,6 @@ export default function rootModelFactory({ }) return self.internetAccounts[length - 1] }, - /** * #action */ @@ -219,28 +280,6 @@ export default function rootModelFactory({ ? this.createEphemeralInternetAccount(selectedId, {}, location.uri) : null }, - - // DesktopSessionManagementMixin actions - /** - * #action - */ - async saveSession(val: unknown) { - if (self.sessionPath) { - await ipcRenderer.invoke('saveSession', self.sessionPath, val) - } - }, - - /** - * #action - */ - activateSession(sessionSnapshot: SnapshotIn) { - const { setSession } = self as typeof self & { - setSession: (arg: unknown) => void - } - setSession(sessionSnapshot) - }, - - // RootAppMenuMixin actions /** * #action */ @@ -312,7 +351,6 @@ export default function rootModelFactory({ }, })) .actions(self => { - // HistoryManagementMixin keyboard listener const keydownListener = (e: KeyboardEvent) => { if ( self.history.canRedo && @@ -335,7 +373,6 @@ export default function rootModelFactory({ return { afterCreate() { - // HistoryManagementMixin setup document.addEventListener('keydown', keydownListener) addDisposer( self, @@ -349,7 +386,6 @@ export default function rootModelFactory({ ), ) - // InternetAccountsRootModelMixin setup addDisposer( self, autorun( @@ -362,7 +398,6 @@ export default function rootModelFactory({ ), ) - // DesktopSessionManagementMixin setup addDisposer( self, autorun( @@ -383,6 +418,44 @@ export default function rootModelFactory({ beforeDestroy() { document.removeEventListener('keydown', keydownListener) }, + /** + * #action + */ + async setPluginsUpdated() { + const root = self as DesktopRootModel + if (root.session) { + await root.saveSession(getSaveSession(root)) + } + await root.openNewSessionCallback(root.sessionPath) + }, + /** + * #action + */ + activateSession(sessionSnapshot: Record) { + self.setSession(sessionSnapshot) + }, + /** + * #action + */ + setDefaultSession() { + const { defaultSession } = self.jbrowse + self.setSession({ + ...defaultSession, + name: `${defaultSession.name || 'New session'} ${new Date().toLocaleString()}`, + }) + }, + /** + * #action + */ + renameCurrentSession(sessionName: string) { + if (self.session) { + const snapshot = getSnapshot(self.session) as Record + self.setSession({ + ...snapshot, + name: sessionName, + }) + } + }, } }) .views(self => ({ diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.test.ts b/products/jbrowse-react-app/src/rootModel/rootModel.test.ts index 98e973d00a..c95c0a7437 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.test.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.test.ts @@ -94,7 +94,7 @@ test('adds track and connection configs to an assembly', () => { }, }) expect(root.jbrowse.assemblies.length).toBe(1) - expect(getSnapshot(root.jbrowse.assemblies[0])).toMatchSnapshot() + expect(getSnapshot(root.jbrowse.assemblies[0]!)).toMatchSnapshot() const newTrackConf = root.jbrowse.addTrackConf({ type: 'FeatureTrack', trackId: 'trackId0', diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.ts b/products/jbrowse-react-app/src/rootModel/rootModel.ts index 5d3c9ce7cf..dab2ffacf8 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.ts @@ -1,4 +1,5 @@ import { + filterSessionInPlace, getExportSessionMenuItem, getImportSessionMenuItem, getOpenConnectionMenuItem, @@ -6,10 +7,16 @@ import { processMutableMenuActions, } from '@jbrowse/app-core' import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import assemblyManagerFactory from '@jbrowse/core/assemblyManager' import assemblyConfigSchemaFactory from '@jbrowse/core/assemblyManager/assemblyConfigSchema' import RpcManager from '@jbrowse/core/rpc/RpcManager' -import { addDisposer, getSnapshot, types } from '@jbrowse/mobx-state-tree' -import { BaseRootModelFactory } from '@jbrowse/product-core' +import { + addDisposer, + cast, + getSnapshot, + getType, + types, +} from '@jbrowse/mobx-state-tree' import AddIcon from '@mui/icons-material/Add' import { autorun } from 'mobx' @@ -26,9 +33,6 @@ import type { Instance } from '@jbrowse/mobx-state-tree' /** * #stateModel JBrowseReactAppRootModel * - * composed of - * - [BaseRootModel](../baserootmodel) - * * note: many properties of the root model are available through the session, * and we generally prefer using the session model (via e.g. getSession) over * the root model (via e.g. getRoot) in plugin code @@ -45,29 +49,47 @@ export default function RootModel({ makeWorkerInstance?: () => Worker }) { const assemblyConfigSchema = assemblyConfigSchemaFactory(pluginManager) + const jbrowseModelType = jbrowseWebFactory({ + pluginManager, + assemblyConfigSchema, + }) + const sessionModelType = sessionModelFactory({ + pluginManager, + assemblyConfigSchema, + }) return types - .compose( - BaseRootModelFactory({ - pluginManager, - jbrowseModelType: jbrowseWebFactory({ - pluginManager, - assemblyConfigSchema, - }), - sessionModelType: sessionModelFactory({ - pluginManager, - assemblyConfigSchema, - }), - assemblyConfigSchema, - }), - types.model({ - // InternetAccountsRootModelMixin property - internetAccounts: types.array( - pluginManager.pluggableMstType('internet account', 'stateModel'), - ), - }), - ) - + .model('JBrowseReactAppRootModel', { + /** + * #property + */ + jbrowse: jbrowseModelType, + /** + * #property + */ + session: types.maybe(sessionModelType), + /** + * #property + */ + sessionPath: types.optional(types.string, ''), + /** + * #property + */ + assemblyManager: types.optional( + assemblyManagerFactory(assemblyConfigSchema, pluginManager), + {}, + ), + /** + * #property + */ + internetAccounts: types.array( + pluginManager.pluggableMstType('internet account', 'stateModel'), + ), + }) .volatile(self => ({ + /** + * #volatile + */ + pluginManager, /** * #volatile */ @@ -97,11 +119,33 @@ export default function RootModel({ * #volatile */ error: undefined as unknown, - // RootAppMenuMixin volatile + /** + * #volatile + */ mutableMenuActions: [] as MenuAction[], })) .actions(self => ({ - // InternetAccountsRootModelMixin actions + /** + * #action + */ + setSession(sessionSnapshot?: Record) { + const oldSession = self.session + self.session = cast(sessionSnapshot) + if (self.session) { + try { + filterSessionInPlace(self.session, getType(self.session)) + } catch (error) { + self.session = oldSession + throw error + } + } + }, + /** + * #action + */ + setSessionPath(path: string) { + self.sessionPath = path + }, /** * #action */ @@ -125,7 +169,6 @@ export default function RootModel({ }) return self.internetAccounts[length - 1] }, - /** * #action */ @@ -183,8 +226,6 @@ export default function RootModel({ ? this.createEphemeralInternetAccount(selectedId, {}, location.uri) : null }, - - // RootAppMenuMixin actions /** * #action */ @@ -255,79 +296,68 @@ export default function RootModel({ ] }, })) - .actions(self => { - return { - afterCreate() { - // InternetAccountsRootModelMixin setup - addDisposer( - self, - autorun( - function internetAccountsAutorun() { - for (const internetAccount of self.jbrowse.internetAccounts) { - self.initializeInternetAccount(internetAccount) - } - }, - { name: 'InternetAccounts' }, - ), - ) + .actions(self => ({ + afterCreate() { + addDisposer( + self, + autorun( + function internetAccountsAutorun() { + for (const internetAccount of self.jbrowse.internetAccounts) { + self.initializeInternetAccount(internetAccount) + } + }, + { name: 'InternetAccounts' }, + ), + ) - addDisposer( - self, - autorun( - function pluginsUpdatedAutorun() { - if (self.pluginsUpdated) { - // reload app to get a fresh plugin manager - window.location.reload() - } - }, - { name: 'PluginsUpdated' }, - ), - ) - }, - /** - * #action - */ - setPluginsUpdated(flag: boolean) { - self.pluginsUpdated = flag - }, - /** - * #action - */ - setDefaultSession() { - const { defaultSession } = self.jbrowse - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void - } - setSession({ - ...defaultSession, - name: `${defaultSession.name} ${new Date().toLocaleString()}`, + addDisposer( + self, + autorun( + function pluginsUpdatedAutorun() { + if (self.pluginsUpdated) { + window.location.reload() + } + }, + { name: 'PluginsUpdated' }, + ), + ) + }, + /** + * #action + */ + setPluginsUpdated(flag: boolean) { + self.pluginsUpdated = flag + }, + /** + * #action + */ + setDefaultSession() { + const { defaultSession } = self.jbrowse + self.setSession({ + ...defaultSession, + name: `${defaultSession.name} ${new Date().toLocaleString()}`, + }) + }, + /** + * #action + */ + renameCurrentSession(sessionName: string) { + const { session } = self + if (session) { + const snapshot = getSnapshot(session) as Record + self.setSession({ + ...snapshot, + name: sessionName, }) - }, - /** - * #action - */ - renameCurrentSession(sessionName: string) { - const { session } = self - if (session) { - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void - } - const snapshot = getSnapshot(session) as Record - setSession({ - ...snapshot, - name: sessionName, - }) - } - }, - - /** - * #action - */ - setError(error?: unknown) { - self.error = error - }, - } - }) + } + }, + /** + * #action + */ + setError(error?: unknown) { + self.error = error + }, + })) .views(self => ({ /** * #method diff --git a/products/jbrowse-web/src/rootModel/rootModel.test.ts b/products/jbrowse-web/src/rootModel/rootModel.test.ts index 98e973d00a..c95c0a7437 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.test.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.test.ts @@ -94,7 +94,7 @@ test('adds track and connection configs to an assembly', () => { }, }) expect(root.jbrowse.assemblies.length).toBe(1) - expect(getSnapshot(root.jbrowse.assemblies[0])).toMatchSnapshot() + expect(getSnapshot(root.jbrowse.assemblies[0]!)).toMatchSnapshot() const newTrackConf = root.jbrowse.addTrackConf({ type: 'FeatureTrack', trackId: 'trackId0', diff --git a/products/jbrowse-web/src/rootModel/rootModel.ts b/products/jbrowse-web/src/rootModel/rootModel.ts index d3945a36a6..4a3cd062a1 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.ts @@ -1,6 +1,7 @@ import { lazy } from 'react' import { + filterSessionInPlace, getExportSessionMenuItem, getImportSessionMenuItem, getOpenConnectionMenuItem, @@ -11,19 +12,20 @@ import { processMutableMenuActions, } from '@jbrowse/app-core' import TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' +import assemblyManagerFactory from '@jbrowse/core/assemblyManager' import assemblyConfigSchemaFactory from '@jbrowse/core/assemblyManager/assemblyConfigSchema' -import { readConfObject } from '@jbrowse/core/configuration' import RpcManager from '@jbrowse/core/rpc/RpcManager' import { DNA } from '@jbrowse/core/ui/Icons' import TimeTraveller from '@jbrowse/core/util/TimeTraveller' import { addDisposer, + cast, getSnapshot, + getType, isAlive, types, } from '@jbrowse/mobx-state-tree' import { AssemblyManager } from '@jbrowse/plugin-data-management' -import { BaseRootModelFactory } from '@jbrowse/product-core' import AddIcon from '@mui/icons-material/Add' import FileCopyIcon from '@mui/icons-material/FileCopy' import FolderOpenIcon from '@mui/icons-material/FolderOpen' @@ -58,9 +60,6 @@ const PreferencesDialog = lazy(() => import('../components/PreferencesDialog')) /** * #stateModel JBrowseWebRootModel * - * composed of - * - [BaseRootModel](../baserootmodel) - * * note: many properties of the root model are available through the session, * and we generally prefer using the session model (via e.g. getSession) over * the root model (via e.g. getRoot) in plugin code @@ -84,29 +83,46 @@ export default function RootModel({ assemblyConfigSchema, }) return types - .compose( - BaseRootModelFactory({ - pluginManager, - jbrowseModelType, - sessionModelType, - assemblyConfigSchema, - }), - types.model({ - // InternetAccountsRootModelMixin property - internetAccounts: types.array( - pluginManager.pluggableMstType('internet account', 'stateModel'), - ), - // HistoryManagementMixin property - history: types.optional(TimeTraveller, { targetPath: '../session' }), - }), - ) - .props({ + .model('JBrowseWebRootModel', { + /** + * #property + */ + jbrowse: jbrowseModelType, + /** + * #property + */ + session: types.maybe(sessionModelType), + /** + * #property + */ + sessionPath: types.optional(types.string, ''), + /** + * #property + */ + assemblyManager: types.optional( + assemblyManagerFactory(assemblyConfigSchema, pluginManager), + {}, + ), + /** + * #property + */ + internetAccounts: types.array( + pluginManager.pluggableMstType('internet account', 'stateModel'), + ), + /** + * #property + */ + history: types.optional(TimeTraveller, { targetPath: '../session' }), /** * #property */ configPath: types.maybe(types.string), }) .volatile(self => ({ + /** + * #volatile + */ + pluginManager, /** * #volatile */ @@ -155,18 +171,40 @@ export default function RootModel({ ) => { console.error('reloadPluginManagerCallback unimplemented') }, - // RootAppMenuMixin volatile + /** + * #volatile + */ mutableMenuActions: [] as MenuAction[], })) .actions(self => ({ + /** + * #action + */ + setSession(sessionSnapshot?: Record) { + const oldSession = self.session + self.session = cast(sessionSnapshot) + if (self.session) { + try { + filterSessionInPlace(self.session, getType(self.session)) + } catch (error) { + self.session = oldSession + throw error + } + } + }, + /** + * #action + */ + setSessionPath(path: string) { + self.sessionPath = path + }, /** * #action */ setSavedSessionMetadata(sessions: SessionMetadata[]) { self.savedSessionMetadata = sessions }, - /** * #action */ @@ -186,8 +224,6 @@ export default function RootModel({ setSessionDB(sessionDB: IDBPDatabase) { self.sessionDB = sessionDB }, - - // InternetAccountsRootModelMixin actions /** * #action */ @@ -211,7 +247,6 @@ export default function RootModel({ }) return self.internetAccounts[length - 1] }, - /** * #action */ @@ -269,8 +304,6 @@ export default function RootModel({ ? this.createEphemeralInternetAccount(selectedId, {}, location.uri) : null }, - - // RootAppMenuMixin actions /** * #action */ @@ -342,7 +375,6 @@ export default function RootModel({ }, })) .actions(self => { - // HistoryManagementMixin keyboard listener const keydownListener = (e: KeyboardEvent) => { if ( self.history.canRedo && @@ -364,11 +396,7 @@ export default function RootModel({ } return { - /** - * #aftercreate - */ afterCreate() { - // HistoryManagementMixin setup document.addEventListener('keydown', keydownListener) addDisposer( self, @@ -382,7 +410,6 @@ export default function RootModel({ ), ) - // InternetAccountsRootModelMixin setup addDisposer( self, autorun( @@ -395,7 +422,6 @@ export default function RootModel({ ), ) - // Session storage setup // eslint-disable-next-line @typescript-eslint/no-floating-promises ;(async () => { try { @@ -524,10 +550,7 @@ export default function RootModel({ */ setDefaultSession() { const { defaultSession } = self.jbrowse - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void - } - setSession({ + self.setSession({ ...defaultSession, name: `${defaultSession.name || 'New session'} ${new Date().toLocaleString()}`, }) @@ -538,10 +561,7 @@ export default function RootModel({ async activateSession(id: string) { const ret = await self.sessionDB?.get('sessions', id) if (ret) { - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void - } - setSession(ret) + self.setSession(ret) } else { self.session.notifyError('Session not found') } @@ -598,16 +618,12 @@ export default function RootModel({ * #action */ renameCurrentSession(sessionName: string) { - const { setSession } = self as unknown as { - setSession: (arg: unknown) => void - } - const snapshot = getSnapshot(self.session) - setSession({ + const snapshot = getSnapshot(self.session) as Record + self.setSession({ ...snapshot, name: sessionName, }) }, - /** * #action */ @@ -621,10 +637,7 @@ export default function RootModel({ * #method */ menus() { - const preConfiguredSessions = readConfObject( - self.jbrowse, - 'preConfiguredSessions', - ) + const { preConfiguredSessions } = self.jbrowse const ret = [ { From 53347441592563fbd60a81c0e537e45ea67c9e59 Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 5 Dec 2025 21:37:02 -0500 Subject: [PATCH 6/8] Round 2 --- packages/core/util/types/index.ts | 12 +++++- .../src/RootModel/BaseRootModel.ts | 2 +- .../components/PluginStoreWidget.test.tsx | 11 +++--- .../src/components/JBrowse.test.tsx | 6 +-- .../src/components/StartScreen/util.tsx | 6 +-- .../src/rootModel/rootModel.test.ts | 8 ++-- .../src/rootModel/rootModel.ts | 37 +++++++++---------- .../src/sessionModel/sessionModel.tsx | 16 ++++++-- .../src/rootModel/rootModel.ts | 1 + .../JBrowseCircularGenomeView.tsx | 1 - .../JBrowseLinearGenomeView.tsx | 1 - .../jbrowse-web/src/components/JBrowse.tsx | 1 - .../jbrowse-web/src/rootModel/rootModel.ts | 3 +- 13 files changed, 56 insertions(+), 49 deletions(-) diff --git a/packages/core/util/types/index.ts b/packages/core/util/types/index.ts index ac52a23ea0..131b93e4e3 100644 --- a/packages/core/util/types/index.ts +++ b/packages/core/util/types/index.ts @@ -190,12 +190,20 @@ export interface Widget { id: string } +interface WidgetMap { + size: number + values(): Iterable + entries(): Iterable<[string, Widget]> + get(key: string): Widget | undefined + has(key: string): boolean +} + /** abstract interface for a session that manages widgets */ export interface SessionWithWidgets extends AbstractSessionModel { minimized: boolean visibleWidget?: Widget - widgets: Map - activeWidgets: Map + widgets: WidgetMap + activeWidgets: WidgetMap hideAllWidgets: () => void addWidget( typeName: string, diff --git a/packages/product-core/src/RootModel/BaseRootModel.ts b/packages/product-core/src/RootModel/BaseRootModel.ts index 0b1655e22c..50227eafa7 100644 --- a/packages/product-core/src/RootModel/BaseRootModel.ts +++ b/packages/product-core/src/RootModel/BaseRootModel.ts @@ -1,7 +1,7 @@ import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import type assemblyManagerFactory from '@jbrowse/core/assemblyManager' -import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' +import type RpcManager from '@jbrowse/core/rpc/RpcManager' import type { Instance } from '@jbrowse/mobx-state-tree' type AssemblyManager = Instance> diff --git a/plugins/data-management/src/PluginStoreWidget/components/PluginStoreWidget.test.tsx b/plugins/data-management/src/PluginStoreWidget/components/PluginStoreWidget.test.tsx index c444bffd97..a18feac744 100644 --- a/plugins/data-management/src/PluginStoreWidget/components/PluginStoreWidget.test.tsx +++ b/plugins/data-management/src/PluginStoreWidget/components/PluginStoreWidget.test.tsx @@ -42,14 +42,18 @@ function setup(sessionSnapshot?: Record, adminMode?: boolean) { const reloadPluginManagerMock = jest.fn() // @ts-expect-error root.setReloadPluginManagerCallback(reloadPluginManagerMock) - return { model, session, user, reloadPluginManagerMock } + return { + model, + session, + user, + reloadPluginManagerMock, + } } test('renders with the available plugins', async () => { const { model, session } = setup() const { container, findByText } = render( - {/* @ts-expect-error */} , @@ -62,7 +66,6 @@ test('Installs a session plugin', async () => { const { user, session, model, reloadPluginManagerMock } = setup() const { findByText } = render( - {/* @ts-expect-error */} , @@ -78,7 +81,6 @@ test('plugin store admin - adds a custom plugin correctly', async () => { const { user, session, model, reloadPluginManagerMock } = setup({}, true) const { findByText, findByLabelText } = render( - {/* @ts-expect-error */} , @@ -105,7 +107,6 @@ test('plugin store admin - removes a custom plugin correctly', async () => { session.jbrowse.addPlugin(plugins.plugins[0]) const { findByText, findByTestId } = render( - {/* @ts-expect-error */} , diff --git a/products/jbrowse-desktop/src/components/JBrowse.test.tsx b/products/jbrowse-desktop/src/components/JBrowse.test.tsx index d7bc53cfc9..e2129412aa 100644 --- a/products/jbrowse-desktop/src/components/JBrowse.test.tsx +++ b/products/jbrowse-desktop/src/components/JBrowse.test.tsx @@ -11,7 +11,6 @@ import { ipcMain, ipcRenderer } from '../../../../packages/__mocks__/electron' import configSnapshot from '../../test_data/volvox/config.json' import corePlugins from '../corePlugins' import JBrowseRootModelFactory from '../rootModel/rootModel' -import sessionModelFactory from '../sessionModel/sessionModel' import type { SnapshotIn } from '@jbrowse/mobx-state-tree' @@ -24,10 +23,7 @@ function getPluginManager(initialState?: SnapshotIn) { corePlugins.map(P => new P()), ).createPluggableElements() - const rootModel = JBrowseRootModelFactory({ - pluginManager, - sessionModelFactory, - }).create( + const rootModel = JBrowseRootModelFactory(pluginManager).create( { jbrowse: initialState || { ...configSnapshot, diff --git a/products/jbrowse-desktop/src/components/StartScreen/util.tsx b/products/jbrowse-desktop/src/components/StartScreen/util.tsx index 4f408da39f..9e83494179 100644 --- a/products/jbrowse-desktop/src/components/StartScreen/util.tsx +++ b/products/jbrowse-desktop/src/components/StartScreen/util.tsx @@ -10,7 +10,6 @@ import deepmerge from 'deepmerge' import corePlugins from '../../corePlugins' import JBrowseRootModelFactory from '../../rootModel/rootModel' -import sessionModelFactory from '../../sessionModel/sessionModel' import { fetchCJS } from '../../util' import type { JBrowseConfig } from './types' @@ -59,10 +58,7 @@ export async function createPluginManager( ]) pluginManager.createPluggableElements() - const JBrowseRootModel = JBrowseRootModelFactory({ - pluginManager, - sessionModelFactory, - }) + const JBrowseRootModel = JBrowseRootModelFactory(pluginManager) const jbrowse = deepmerge(configSnapshot, { internetAccounts: [ diff --git a/products/jbrowse-desktop/src/rootModel/rootModel.test.ts b/products/jbrowse-desktop/src/rootModel/rootModel.test.ts index 1ceb7dd7b9..8669966a28 100644 --- a/products/jbrowse-desktop/src/rootModel/rootModel.test.ts +++ b/products/jbrowse-desktop/src/rootModel/rootModel.test.ts @@ -8,7 +8,6 @@ import { getSnapshot } from '@jbrowse/mobx-state-tree' import corePlugins from '../corePlugins' import rootModelFactory from './rootModel' -import sessionModelFactory from '../sessionModel/sessionModel' jest.mock('../makeWorkerInstance', () => () => {}) @@ -16,10 +15,7 @@ function getRootModel() { const pluginManager = new PluginManager(corePlugins.map(P => new P())) pluginManager.createPluggableElements() pluginManager.configure() - return rootModelFactory({ - pluginManager, - sessionModelFactory, - }) + return rootModelFactory(pluginManager) } afterEach(() => { localStorage.clear() @@ -117,6 +113,7 @@ test('throws if session is invalid', () => { expect(() => { getRootModel().create({ ...mainThreadConfig, + // @ts-expect-error intentionally invalid session: {}, }) }).toThrow() @@ -125,6 +122,7 @@ test('throws if session is invalid', () => { test('throws if session snapshot is invalid', () => { const root = getRootModel().create(mainThreadConfig) expect(() => { + // @ts-expect-error intentionally invalid root.setSession({}) }).toThrow() }) diff --git a/products/jbrowse-desktop/src/rootModel/rootModel.ts b/products/jbrowse-desktop/src/rootModel/rootModel.ts index 22f160ded1..751e839cf5 100644 --- a/products/jbrowse-desktop/src/rootModel/rootModel.ts +++ b/products/jbrowse-desktop/src/rootModel/rootModel.ts @@ -35,13 +35,18 @@ import OpenSequenceDialog from '../components/OpenSequenceDialog' import jobsModelFactory from '../indexJobsModel' import JBrowseDesktop from '../jbrowseModel' import makeWorkerInstance from '../makeWorkerInstance' +import sessionModelFactory from '../sessionModel/sessionModel' -import type { Menu, MenuAction, SessionModelFactory } from '@jbrowse/app-core' +import type { + DesktopSessionModel, + DesktopSessionModelType, +} from '../sessionModel/sessionModel' +import type { Menu, MenuAction } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { MenuItem } from '@jbrowse/core/ui' -import type { AbstractSessionModel, UriLocation } from '@jbrowse/core/util' -import type { Instance } from '@jbrowse/mobx-state-tree' +import type { UriLocation } from '@jbrowse/core/util' +import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' import type { SessionWithDialogs } from '@jbrowse/product-core' // lazies @@ -65,15 +70,9 @@ export function getSaveSession(model: { jbrowse: unknown; session: unknown }) { * and we generally prefer using the session model (via e.g. getSession) over * the root model (via e.g. getRoot) in plugin code */ -export default function rootModelFactory({ - pluginManager, - sessionModelFactory, -}: { - pluginManager: PluginManager - sessionModelFactory: SessionModelFactory -}) { +export default function rootModelFactory(pluginManager: PluginManager) { const assemblyConfigSchema = assemblyConfigSchemaF(pluginManager) - const sessionModelType = sessionModelFactory({ + const sessionModelType: DesktopSessionModelType = sessionModelFactory({ pluginManager, assemblyConfigSchema, }) @@ -162,7 +161,7 @@ export default function rootModelFactory({ /** * #action */ - setSession(sessionSnapshot?: Record) { + setSession(sessionSnapshot?: SnapshotIn) { const oldSession = self.session self.session = cast(sessionSnapshot) if (self.session) { @@ -431,7 +430,7 @@ export default function rootModelFactory({ /** * #action */ - activateSession(sessionSnapshot: Record) { + activateSession(sessionSnapshot: SnapshotIn) { self.setSession(sessionSnapshot) }, /** @@ -449,7 +448,7 @@ export default function rootModelFactory({ */ renameCurrentSession(sessionName: string) { if (self.session) { - const snapshot = getSnapshot(self.session) as Record + const snapshot = getSnapshot(self.session) self.setSession({ ...snapshot, name: sessionName, @@ -581,15 +580,15 @@ export default function rootModelFactory({ label: 'Open assembly manager', icon: DNA, onClick: () => { - ;(self.session as AbstractSessionModel).queueDialog( - handleClose => [ + if (self.session) { + self.session.queueDialog(handleClose => [ AssemblyManager, { - session: self.session, + session: self.session as DesktopSessionModel, onClose: handleClose, }, - ], - ) + ]) + } }, }, ], diff --git a/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx index e9606819d2..9db1dd53e8 100644 --- a/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx +++ b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx @@ -37,7 +37,6 @@ import Report from '@mui/icons-material/Report' import SettingsIcon from '@mui/icons-material/Settings' import { autorun, observable } from 'mobx' -import type { DesktopRootModel } from '../rootModel/rootModel' import type PluginManager from '@jbrowse/core/PluginManager' import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' @@ -59,8 +58,19 @@ import type { SnackAction, TrackViewModel, } from '@jbrowse/core/util' +import type { Menu } from '@jbrowse/app-core' import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' import type { ThemeOptions } from '@mui/material' +import type { JobsStateModel } from '../indexJobsModel' + +export interface DesktopRootModelShape { + version: string + history: { canUndo: boolean; canRedo: boolean; undo: () => void; redo: () => void } + menus: () => Menu[] + assemblyManager: AssemblyManager + jobsManager: JobsStateModel + renameCurrentSession: (name: string) => void +} // lazies const AboutDialog = lazy(() => import('./AboutDialog')) @@ -173,8 +183,8 @@ export default function sessionModelFactory({ task: undefined, })) .views(self => ({ - get root(): DesktopRootModel { - return getParent(self) + get root(): DesktopRootModelShape { + return getParent(self) }, get jbrowse() { return getParent<{ jbrowse: AnyConfigurationModel }>(self).jbrowse diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.ts b/products/jbrowse-react-app/src/rootModel/rootModel.ts index dab2ffacf8..10816cce3c 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.ts @@ -344,6 +344,7 @@ export default function RootModel({ renameCurrentSession(sessionName: string) { const { session } = self if (session) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const snapshot = getSnapshot(session) as Record self.setSession({ ...snapshot, diff --git a/products/jbrowse-react-circular-genome-view/src/JBrowseCircularGenomeView/JBrowseCircularGenomeView.tsx b/products/jbrowse-react-circular-genome-view/src/JBrowseCircularGenomeView/JBrowseCircularGenomeView.tsx index 4a552a5e5a..e7d6ed0ad1 100644 --- a/products/jbrowse-react-circular-genome-view/src/JBrowseCircularGenomeView/JBrowseCircularGenomeView.tsx +++ b/products/jbrowse-react-circular-genome-view/src/JBrowseCircularGenomeView/JBrowseCircularGenomeView.tsx @@ -33,7 +33,6 @@ const JBrowseCircularGenomeView = observer(function ({ - {/* @ts-expect-error see comments on interface for AbstractSessionModel */} ) diff --git a/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/JBrowseLinearGenomeView.tsx b/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/JBrowseLinearGenomeView.tsx index 8542b8a5d6..5cec983131 100644 --- a/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/JBrowseLinearGenomeView.tsx +++ b/products/jbrowse-react-linear-genome-view/src/JBrowseLinearGenomeView/JBrowseLinearGenomeView.tsx @@ -29,7 +29,6 @@ const JBrowseLinearGenomeView = observer(function ({ - {/* @ts-expect-error see comments on interface for AbstractSessionModel */} ) diff --git a/products/jbrowse-web/src/components/JBrowse.tsx b/products/jbrowse-web/src/components/JBrowse.tsx index e9877364de..2116aaa4e7 100644 --- a/products/jbrowse-web/src/components/JBrowse.tsx +++ b/products/jbrowse-web/src/components/JBrowse.tsx @@ -67,7 +67,6 @@ const JBrowse = observer(function ({ } /> diff --git a/products/jbrowse-web/src/rootModel/rootModel.ts b/products/jbrowse-web/src/rootModel/rootModel.ts index 4a3cd062a1..3a604535ad 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.ts @@ -618,6 +618,7 @@ export default function RootModel({ * #action */ renameCurrentSession(sessionName: string) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const snapshot = getSnapshot(self.session) as Record self.setSession({ ...snapshot, @@ -669,7 +670,7 @@ export default function RootModel({ self.setSession(rest) }, }, - ...(preConfiguredSessions?.length + ...(preConfiguredSessions.length ? [ { label: 'Pre-configured sessions...', From fecc9a9161c42b925df3497e54cd9269fe778ca3 Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 5 Dec 2025 22:10:35 -0500 Subject: [PATCH 7/8] K2 --- packages/app-core/src/types.ts | 10 +-- .../src/JBrowseApp/JBrowseApp.tsx | 4 +- products/jbrowse-react-app/src/createModel.ts | 2 - .../src/rootModel/rootModel.test.ts | 8 +-- .../src/rootModel/rootModel.ts | 16 +++-- .../stories/examples/BasicExample.tsx | 2 +- .../stories/examples/DarkTheme.tsx | 2 +- .../stories/examples/WithImportConfigJson.tsx | 2 +- .../jbrowse-web/src/createPluginManager.ts | 7 ++- .../src/rootModel/rootModel.test.ts | 8 +-- .../jbrowse-web/src/rootModel/rootModel.ts | 61 +++++++++++-------- .../jbrowse-web/src/rootModel/test_util.ts | 2 - .../jbrowse-web/src/tests/JBrowse.test.tsx | 2 - products/jbrowse-web/src/tests/util.tsx | 2 - 14 files changed, 61 insertions(+), 67 deletions(-) diff --git a/packages/app-core/src/types.ts b/packages/app-core/src/types.ts index 11d1bd0327..dc420810e9 100644 --- a/packages/app-core/src/types.ts +++ b/packages/app-core/src/types.ts @@ -1,8 +1,2 @@ -import type PluginManager from '@jbrowse/core/PluginManager' -import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' -import type { IAnyType } from '@jbrowse/mobx-state-tree' - -export type SessionModelFactory = (args: { - pluginManager: PluginManager - assemblyConfigSchema: BaseAssemblyConfigSchema -}) => IAnyType +// SessionModelFactory type was removed as products now directly import their session model factories +export {} diff --git a/products/jbrowse-react-app/src/JBrowseApp/JBrowseApp.tsx b/products/jbrowse-react-app/src/JBrowseApp/JBrowseApp.tsx index 81a847de30..b62518b5fc 100644 --- a/products/jbrowse-react-app/src/JBrowseApp/JBrowseApp.tsx +++ b/products/jbrowse-react-app/src/JBrowseApp/JBrowseApp.tsx @@ -20,7 +20,7 @@ const useStyles = makeStyles()({ const JBrowseApp = observer(function ({ viewState }: { viewState: ViewModel }) { const { classes } = useStyles() - const session = viewState.session + const { session } = viewState const theme = createJBrowseTheme(getConf(viewState.jbrowse, 'theme')) return ( @@ -28,7 +28,7 @@ const JBrowseApp = observer(function ({ viewState }: { viewState: ViewModel }) {
}> - + {session ? : null}
diff --git a/products/jbrowse-react-app/src/createModel.ts b/products/jbrowse-react-app/src/createModel.ts index f6d7f238e3..dcee8d2784 100644 --- a/products/jbrowse-react-app/src/createModel.ts +++ b/products/jbrowse-react-app/src/createModel.ts @@ -2,7 +2,6 @@ import PluginManager from '@jbrowse/core/PluginManager' import corePlugins from './corePlugins' import createRootModel from './rootModel/rootModel' -import sessionModelFactory from './sessionModel' import type { PluginConstructor } from '@jbrowse/core/Plugin' import type { Instance } from '@jbrowse/mobx-state-tree' @@ -24,7 +23,6 @@ export default function createModel({ return { model: createRootModel({ pluginManager, - sessionModelFactory, makeWorkerInstance, }), pluginManager, diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.test.ts b/products/jbrowse-react-app/src/rootModel/rootModel.test.ts index c95c0a7437..2c3cdbb344 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.test.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.test.ts @@ -4,7 +4,6 @@ import { getSnapshot } from '@jbrowse/mobx-state-tree' import corePlugins from '../corePlugins' import rootModelFactory from './rootModel' -import sessionModelFactory from '../sessionModel' jest.mock('../makeWorkerInstance', () => () => {}) @@ -12,10 +11,7 @@ function getRootModel() { const pluginManager = new PluginManager(corePlugins.map(P => new P())) pluginManager.createPluggableElements() pluginManager.configure() - return rootModelFactory({ - pluginManager, - sessionModelFactory, - }) + return rootModelFactory({ pluginManager }) } afterEach(() => { localStorage.clear() @@ -113,6 +109,7 @@ test('throws if session is invalid', () => { expect(() => { getRootModel().create({ ...mainThreadConfig, + // @ts-expect-error intentionally invalid session: {}, }) }).toThrow() @@ -121,6 +118,7 @@ test('throws if session is invalid', () => { test('throws if session snapshot is invalid', () => { const root = getRootModel().create(mainThreadConfig) expect(() => { + // @ts-expect-error intentionally invalid root.setSession({}) }).toThrow() }) diff --git a/products/jbrowse-react-app/src/rootModel/rootModel.ts b/products/jbrowse-react-app/src/rootModel/rootModel.ts index 10816cce3c..d01e5d10b7 100644 --- a/products/jbrowse-react-app/src/rootModel/rootModel.ts +++ b/products/jbrowse-react-app/src/rootModel/rootModel.ts @@ -21,14 +21,16 @@ import AddIcon from '@mui/icons-material/Add' import { autorun } from 'mobx' import jbrowseWebFactory from '../jbrowseModel' +import sessionModelFactory from '../sessionModel' import { version } from '../version' -import type { Menu, MenuAction, SessionModelFactory } from '@jbrowse/app-core' +import type { WebSessionModelType } from '../sessionModel' +import type { Menu, MenuAction } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { MenuItem } from '@jbrowse/core/ui' import type { UriLocation } from '@jbrowse/core/util' -import type { Instance } from '@jbrowse/mobx-state-tree' +import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' /** * #stateModel JBrowseReactAppRootModel @@ -39,13 +41,11 @@ import type { Instance } from '@jbrowse/mobx-state-tree' */ export default function RootModel({ pluginManager, - sessionModelFactory, makeWorkerInstance = () => { throw new Error('no makeWorkerInstance supplied') }, }: { pluginManager: PluginManager - sessionModelFactory: SessionModelFactory makeWorkerInstance?: () => Worker }) { const assemblyConfigSchema = assemblyConfigSchemaFactory(pluginManager) @@ -53,7 +53,7 @@ export default function RootModel({ pluginManager, assemblyConfigSchema, }) - const sessionModelType = sessionModelFactory({ + const sessionModelType: WebSessionModelType = sessionModelFactory({ pluginManager, assemblyConfigSchema, }) @@ -128,7 +128,7 @@ export default function RootModel({ /** * #action */ - setSession(sessionSnapshot?: Record) { + setSession(sessionSnapshot?: SnapshotIn) { const oldSession = self.session self.session = cast(sessionSnapshot) if (self.session) { @@ -344,10 +344,8 @@ export default function RootModel({ renameCurrentSession(sessionName: string) { const { session } = self if (session) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const snapshot = getSnapshot(session) as Record self.setSession({ - ...snapshot, + ...getSnapshot(session), name: sessionName, }) } diff --git a/products/jbrowse-react-app/stories/examples/BasicExample.tsx b/products/jbrowse-react-app/stories/examples/BasicExample.tsx index 5861a2912a..4dc72be76d 100644 --- a/products/jbrowse-react-app/stories/examples/BasicExample.tsx +++ b/products/jbrowse-react-app/stories/examples/BasicExample.tsx @@ -77,7 +77,7 @@ export const BasicExample = () => { ], }, }) - state.session.views[0]?.showTrack('volvox_cram') + state.session!.views[0]?.showTrack('volvox_cram') return (
diff --git a/products/jbrowse-react-app/stories/examples/DarkTheme.tsx b/products/jbrowse-react-app/stories/examples/DarkTheme.tsx index 2c13276417..581af77701 100644 --- a/products/jbrowse-react-app/stories/examples/DarkTheme.tsx +++ b/products/jbrowse-react-app/stories/examples/DarkTheme.tsx @@ -84,7 +84,7 @@ export const DarkTheme = () => { ], }, }) - state.session.views[0]?.showTrack('volvox_cram') + state.session!.views[0]?.showTrack('volvox_cram') return (
diff --git a/products/jbrowse-react-app/stories/examples/WithImportConfigJson.tsx b/products/jbrowse-react-app/stories/examples/WithImportConfigJson.tsx index c90c150f0e..a6ee6c0f3e 100644 --- a/products/jbrowse-react-app/stories/examples/WithImportConfigJson.tsx +++ b/products/jbrowse-react-app/stories/examples/WithImportConfigJson.tsx @@ -11,7 +11,7 @@ export const WithImportConfigJson = () => { const state = createViewState({ config, }) - state.session.views[0]?.showTrack('volvox_cram') + state.session!.views[0]?.showTrack('volvox_cram') return (
diff --git a/products/jbrowse-web/src/createPluginManager.ts b/products/jbrowse-web/src/createPluginManager.ts index 20859fbe1f..af3e059f19 100644 --- a/products/jbrowse-web/src/createPluginManager.ts +++ b/products/jbrowse-web/src/createPluginManager.ts @@ -5,7 +5,6 @@ import corePlugins from './corePlugins' import { loadHubSpec } from './loadHubSpec' import { loadSessionSpec } from './loadSessionSpec' import JBrowseRootModelFactory from './rootModel/rootModel' -import sessionModelFactory from './sessionModel' import type { SessionLoaderModel } from './SessionLoader' @@ -45,7 +44,6 @@ export function createPluginManager( const RootModel = JBrowseRootModelFactory({ pluginManager, - sessionModelFactory, adminMode: !!model.adminKey, }) @@ -77,7 +75,10 @@ export function createPluginManager( // eslint-disable-next-line @typescript-eslint/only-throw-error throw sessionError } else if (sessionSnapshot) { - rootModel.setSession(sessionSnapshot) + rootModel.setSession({ + name: sessionSnapshot.name as string, + ...sessionSnapshot, + }) } else if (hubSpec) { // @ts-expect-error afterInitializedCb = () => loadHubSpec(hubSpec, pluginManager) diff --git a/products/jbrowse-web/src/rootModel/rootModel.test.ts b/products/jbrowse-web/src/rootModel/rootModel.test.ts index c95c0a7437..2c3cdbb344 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.test.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.test.ts @@ -4,7 +4,6 @@ import { getSnapshot } from '@jbrowse/mobx-state-tree' import corePlugins from '../corePlugins' import rootModelFactory from './rootModel' -import sessionModelFactory from '../sessionModel' jest.mock('../makeWorkerInstance', () => () => {}) @@ -12,10 +11,7 @@ function getRootModel() { const pluginManager = new PluginManager(corePlugins.map(P => new P())) pluginManager.createPluggableElements() pluginManager.configure() - return rootModelFactory({ - pluginManager, - sessionModelFactory, - }) + return rootModelFactory({ pluginManager }) } afterEach(() => { localStorage.clear() @@ -113,6 +109,7 @@ test('throws if session is invalid', () => { expect(() => { getRootModel().create({ ...mainThreadConfig, + // @ts-expect-error intentionally invalid session: {}, }) }).toThrow() @@ -121,6 +118,7 @@ test('throws if session is invalid', () => { test('throws if session snapshot is invalid', () => { const root = getRootModel().create(mainThreadConfig) expect(() => { + // @ts-expect-error intentionally invalid root.setSession({}) }).toThrow() }) diff --git a/products/jbrowse-web/src/rootModel/rootModel.ts b/products/jbrowse-web/src/rootModel/rootModel.ts index 3a604535ad..b8608c54d7 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.ts @@ -38,9 +38,14 @@ import { autorun } from 'mobx' import packageJSON from '../../package.json' import jbrowseWebFactory from '../jbrowseModel' import makeWorkerInstance from '../makeWorkerInstance' +import sessionModelFactory from '../sessionModel' +import type { + WebSessionModel, + WebSessionModelType, +} from '../sessionModel' import type { SessionDB, SessionMetadata } from '../types' -import type { Menu, MenuAction, SessionModelFactory } from '@jbrowse/app-core' +import type { Menu, MenuAction } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' import type { AnyConfigurationModel } from '@jbrowse/core/configuration' import type { MenuItem } from '@jbrowse/core/ui' @@ -49,7 +54,7 @@ import type { SessionWithWidgets, UriLocation, } from '@jbrowse/core/util' -import type { Instance } from '@jbrowse/mobx-state-tree' +import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' import type { SessionWithDialogs } from '@jbrowse/product-core' import type { IDBPDatabase } from 'idb' @@ -66,11 +71,9 @@ const PreferencesDialog = lazy(() => import('../components/PreferencesDialog')) */ export default function RootModel({ pluginManager, - sessionModelFactory, adminMode = false, }: { pluginManager: PluginManager - sessionModelFactory: SessionModelFactory adminMode?: boolean }) { const assemblyConfigSchema = assemblyConfigSchemaFactory(pluginManager) @@ -78,7 +81,7 @@ export default function RootModel({ pluginManager, assemblyConfigSchema, }) - const sessionModelType = sessionModelFactory({ + const sessionModelType: WebSessionModelType = sessionModelFactory({ pluginManager, assemblyConfigSchema, }) @@ -181,7 +184,7 @@ export default function RootModel({ /** * #action */ - setSession(sessionSnapshot?: Record) { + setSession(sessionSnapshot?: SnapshotIn) { const oldSession = self.session self.session = cast(sessionSnapshot) if (self.session) { @@ -442,7 +445,12 @@ export default function RootModel({ const s = self.session if (self.sessionDB) { - await sessionDB.put('sessions', getSnapshot(s), s.id) + const snap = getSnapshot(s)! + await sessionDB.put( + 'sessions', + { ...snap, name: snap.name, id: snap.id }, + s.id, + ) if (!isAlive(self)) { return } @@ -499,8 +507,13 @@ export default function RootModel({ if (self.pluginsUpdated) { self.reloadPluginManagerCallback( - structuredClone(getSnapshot(self.jbrowse)), - structuredClone(sessionSnap), + structuredClone( + getSnapshot(self.jbrowse), + ) as Record, + structuredClone(sessionSnap) as Record< + string, + unknown + >, ) } } catch (e) { @@ -563,7 +576,7 @@ export default function RootModel({ if (ret) { self.setSession(ret) } else { - self.session.notifyError('Session not found') + self.session?.notifyError('Session not found') } }, /** @@ -618,12 +631,12 @@ export default function RootModel({ * #action */ renameCurrentSession(sessionName: string) { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const snapshot = getSnapshot(self.session) as Record - self.setSession({ - ...snapshot, - name: sessionName, - }) + if (self.session) { + self.setSession({ + ...getSnapshot(self.session), + name: sessionName, + }) + } }, /** * #action @@ -691,8 +704,8 @@ export default function RootModel({ label: 'Favorite sessions...', subMenu: [ ...favs.slice(0, 5).map(r => ({ - label: `${r.name} (${r.id === self.session.id ? 'current' : formatDistanceToNow(r.createdAt, { addSuffix: true })})`, - disabled: r.id === self.session.id, + label: `${r.name} (${r.id === self.session?.id ? 'current' : formatDistanceToNow(r.createdAt, { addSuffix: true })})`, + disabled: r.id === self.session?.id, icon: StarIcon, onClick: () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises @@ -700,7 +713,7 @@ export default function RootModel({ try { await self.activateSession(r.id) } catch (e) { - self.session.notifyError(`${e}`, e) + self.session?.notifyError(`${e}`, e) } })() }, @@ -726,15 +739,15 @@ export default function RootModel({ subMenu: rest?.length ? [ ...rest.map(r => ({ - label: `${r.name} (${r.id === self.session.id ? 'current' : formatDistanceToNow(r.createdAt, { addSuffix: true })})`, - disabled: r.id === self.session.id, + label: `${r.name} (${r.id === self.session?.id ? 'current' : formatDistanceToNow(r.createdAt, { addSuffix: true })})`, + disabled: r.id === self.session?.id, onClick: () => { // eslint-disable-next-line @typescript-eslint/no-floating-promises ;(async () => { try { await self.activateSession(r.id) } catch (e) { - self.session.notifyError(`${e}`, e) + self.session?.notifyError(`${e}`, e) } })() }, @@ -767,7 +780,7 @@ export default function RootModel({ { label: 'Set default session', onClick: () => { - self.session.queueDialog((onClose: () => void) => [ + self.session?.queueDialog((onClose: () => void) => [ SetDefaultSession, { rootModel: self, @@ -795,7 +808,7 @@ export default function RootModel({ label: 'Assembly manager', icon: DNA, onClick: () => { - self.session.queueDialog((onClose: () => void) => [ + self.session?.queueDialog((onClose: () => void) => [ AssemblyManager, { onClose, diff --git a/products/jbrowse-web/src/rootModel/test_util.ts b/products/jbrowse-web/src/rootModel/test_util.ts index 414599f5cd..654415e37d 100644 --- a/products/jbrowse-web/src/rootModel/test_util.ts +++ b/products/jbrowse-web/src/rootModel/test_util.ts @@ -2,7 +2,6 @@ import PluginManager from '@jbrowse/core/PluginManager' import corePlugins from '../corePlugins' import RootModel from './rootModel' -import sessionModelFactory from '../sessionModel' import type { WebSessionModel } from '../sessionModel' @@ -22,7 +21,6 @@ export function createTestSession(args?: { const root = RootModel({ pluginManager, - sessionModelFactory, adminMode, }).create( { diff --git a/products/jbrowse-web/src/tests/JBrowse.test.tsx b/products/jbrowse-web/src/tests/JBrowse.test.tsx index 9e9bba4148..0b6c1772aa 100644 --- a/products/jbrowse-web/src/tests/JBrowse.test.tsx +++ b/products/jbrowse-web/src/tests/JBrowse.test.tsx @@ -7,7 +7,6 @@ import { fireEvent } from '@testing-library/react' import volvoxConfigSnapshot from '../../test_data/volvox/config.json' import corePlugins from '../corePlugins' import JBrowseRootModelFactory from '../rootModel/rootModel' -import sessionModelFactory from '../sessionModel' import * as sessionSharing from '../sessionSharing' import TestPlugin from './TestPlugin' import { createView, doBeforeEach, expectCanvasMatch, hts, setup } from './util' @@ -32,7 +31,6 @@ test('toplevel configuration', () => { const pluginManager = new PluginManager(plugins).createPluggableElements() const rootModel = JBrowseRootModelFactory({ pluginManager, - sessionModelFactory, adminMode: true, }).create( { diff --git a/products/jbrowse-web/src/tests/util.tsx b/products/jbrowse-web/src/tests/util.tsx index 1c933495c8..9303f3b0b7 100644 --- a/products/jbrowse-web/src/tests/util.tsx +++ b/products/jbrowse-web/src/tests/util.tsx @@ -15,7 +15,6 @@ import { generateReadBuffer } from './generateReadBuffer' import configSnapshot from '../../test_data/volvox/config.json' import corePlugins from '../corePlugins' import JBrowseRootModelFactory from '../rootModel/rootModel' -import sessionModelFactory from '../sessionModel' import JBrowse from './TestingJBrowse' import type { AbstractSessionModel, AppRootModel } from '@jbrowse/core/util' @@ -40,7 +39,6 @@ export function getPluginManager( const rootModel = JBrowseRootModelFactory({ pluginManager, - sessionModelFactory, adminMode, }).create( { From c899aa65e853b19217d32e8d0d6120ae27a70126 Mon Sep 17 00:00:00 2001 From: Colin Date: Fri, 5 Dec 2025 22:19:13 -0500 Subject: [PATCH 8/8] [skip ci] LGV and CGV --- packages/app-core/src/index.ts | 1 - packages/app-core/src/types.ts | 2 -- .../src/sessionModel/sessionModel.tsx | 11 +++++-- products/jbrowse-react-app/src/index.ts | 5 +++ .../src/createModel/createModel.ts | 3 +- .../src/index.ts | 5 +++ .../src/createModel/createModel.ts | 3 +- .../src/index.ts | 4 +++ .../jbrowse-web/src/rootModel/rootModel.ts | 33 +++++++------------ products/jbrowse-web/src/tests/util.tsx | 2 +- 10 files changed, 39 insertions(+), 30 deletions(-) delete mode 100644 packages/app-core/src/types.ts diff --git a/packages/app-core/src/index.ts b/packages/app-core/src/index.ts index 8dd74e2305..702febbcc7 100644 --- a/packages/app-core/src/index.ts +++ b/packages/app-core/src/index.ts @@ -3,5 +3,4 @@ export * from './JBrowseConfig' export * from './JBrowseModel' export * from './menus' export * from './menuItems' -export * from './types' export * from './util' diff --git a/packages/app-core/src/types.ts b/packages/app-core/src/types.ts deleted file mode 100644 index dc420810e9..0000000000 --- a/packages/app-core/src/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -// SessionModelFactory type was removed as products now directly import their session model factories -export {} diff --git a/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx index 9db1dd53e8..407849b883 100644 --- a/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx +++ b/products/jbrowse-desktop/src/sessionModel/sessionModel.tsx @@ -37,6 +37,8 @@ import Report from '@mui/icons-material/Report' import SettingsIcon from '@mui/icons-material/Settings' import { autorun, observable } from 'mobx' +import type { JobsStateModel } from '../indexJobsModel' +import type { Menu } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' import type TextSearchManager from '@jbrowse/core/TextSearch/TextSearchManager' import type { BaseAssemblyConfigSchema } from '@jbrowse/core/assemblyManager/assemblyConfigSchema' @@ -58,14 +60,17 @@ import type { SnackAction, TrackViewModel, } from '@jbrowse/core/util' -import type { Menu } from '@jbrowse/app-core' import type { IAnyStateTreeNode, Instance } from '@jbrowse/mobx-state-tree' import type { ThemeOptions } from '@mui/material' -import type { JobsStateModel } from '../indexJobsModel' export interface DesktopRootModelShape { version: string - history: { canUndo: boolean; canRedo: boolean; undo: () => void; redo: () => void } + history: { + canUndo: boolean + canRedo: boolean + undo: () => void + redo: () => void + } menus: () => Menu[] assemblyManager: AssemblyManager jobsManager: JobsStateModel diff --git a/products/jbrowse-react-app/src/index.ts b/products/jbrowse-react-app/src/index.ts index 3caf964092..ec6866d9a1 100644 --- a/products/jbrowse-react-app/src/index.ts +++ b/products/jbrowse-react-app/src/index.ts @@ -2,3 +2,8 @@ export { default as JBrowseApp } from './JBrowseApp' export { default as createModel } from './createModel' export { default as createViewState } from './createViewState' export { default as loadPlugins } from './loadPlugins' +export type { ViewModel } from './createModel' +export type { + WebSessionModel, + WebSessionModelType, +} from './sessionModel' diff --git a/products/jbrowse-react-circular-genome-view/src/createModel/createModel.ts b/products/jbrowse-react-circular-genome-view/src/createModel/createModel.ts index 9eb9505791..0d94978276 100644 --- a/products/jbrowse-react-circular-genome-view/src/createModel/createModel.ts +++ b/products/jbrowse-react-circular-genome-view/src/createModel/createModel.ts @@ -11,6 +11,7 @@ import createConfigModel from './createConfigModel' import createSessionModel from './createSessionModel' import { version } from '../version' +import type { CGVSessionModelType } from './createSessionModel' import type { PluginConstructor } from '@jbrowse/core/Plugin' import type { UriLocation } from '@jbrowse/core/util' import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' @@ -28,7 +29,7 @@ export default function createModel( [...corePlugins, ...runtimePlugins].map(P => new P()), ) pluginManager.createPluggableElements() - const Session = createSessionModel(pluginManager) + const Session: CGVSessionModelType = createSessionModel(pluginManager) const assemblyConfigSchema = assemblyConfigSchemaFactory(pluginManager) const assemblyManagerType = assemblyManagerFactory( assemblyConfigSchema, diff --git a/products/jbrowse-react-circular-genome-view/src/index.ts b/products/jbrowse-react-circular-genome-view/src/index.ts index 5def9d1bc0..ac83d4d71d 100644 --- a/products/jbrowse-react-circular-genome-view/src/index.ts +++ b/products/jbrowse-react-circular-genome-view/src/index.ts @@ -2,3 +2,8 @@ export { default as JBrowseCircularGenomeView } from './JBrowseCircularGenomeVie export { default as createModel } from './createModel' export { default as createViewState } from './createViewState' export { default as loadPlugins } from './loadPlugins' +export type { ViewModel } from './createModel/createModel' +export type { + CGVSessionModel, + CGVSessionModelType, +} from './createModel/createSessionModel' diff --git a/products/jbrowse-react-linear-genome-view/src/createModel/createModel.ts b/products/jbrowse-react-linear-genome-view/src/createModel/createModel.ts index 798b6b1ed7..2848cadf86 100644 --- a/products/jbrowse-react-linear-genome-view/src/createModel/createModel.ts +++ b/products/jbrowse-react-linear-genome-view/src/createModel/createModel.ts @@ -11,6 +11,7 @@ import createConfigModel from './createConfigModel' import createSessionModel from './createSessionModel' import { version } from '../version' +import type { LGVSessionModelType } from './createSessionModel' import type { PluginConstructor } from '@jbrowse/core/Plugin' import type { UriLocation } from '@jbrowse/core/util' import type { Instance, SnapshotIn } from '@jbrowse/mobx-state-tree' @@ -27,7 +28,7 @@ export default function createModel( const pluginManager = new PluginManager( [...corePlugins, ...runtimePlugins].map(P => new P()), ).createPluggableElements() - const Session = createSessionModel(pluginManager) + const Session: LGVSessionModelType = createSessionModel(pluginManager) const assemblyConfig = assemblyConfigSchemaFactory(pluginManager) const AssemblyManager = assemblyManagerFactory(assemblyConfig, pluginManager) const rootModel = types diff --git a/products/jbrowse-react-linear-genome-view/src/index.ts b/products/jbrowse-react-linear-genome-view/src/index.ts index bf03734f9e..69ec219a1a 100644 --- a/products/jbrowse-react-linear-genome-view/src/index.ts +++ b/products/jbrowse-react-linear-genome-view/src/index.ts @@ -3,3 +3,7 @@ export { default as createModel } from './createModel' export { default as createViewState } from './createViewState' export { default as loadPlugins } from './loadPlugins' export type { ViewModel } from './createModel/createModel' +export type { + LGVSessionModel, + LGVSessionModelType, +} from './createModel/createSessionModel' diff --git a/products/jbrowse-web/src/rootModel/rootModel.ts b/products/jbrowse-web/src/rootModel/rootModel.ts index b8608c54d7..e2d91156d7 100644 --- a/products/jbrowse-web/src/rootModel/rootModel.ts +++ b/products/jbrowse-web/src/rootModel/rootModel.ts @@ -40,10 +40,7 @@ import jbrowseWebFactory from '../jbrowseModel' import makeWorkerInstance from '../makeWorkerInstance' import sessionModelFactory from '../sessionModel' -import type { - WebSessionModel, - WebSessionModelType, -} from '../sessionModel' +import type { WebSessionModelType } from '../sessionModel' import type { SessionDB, SessionMetadata } from '../types' import type { Menu, MenuAction } from '@jbrowse/app-core' import type PluginManager from '@jbrowse/core/PluginManager' @@ -490,7 +487,7 @@ export default function RootModel({ autorun( () => { if (self.session) { - const s = self.session as AbstractSessionModel + const s = self.session const sessionSnap = getSnapshot(s) try { sessionStorage.setItem( @@ -507,13 +504,9 @@ export default function RootModel({ if (self.pluginsUpdated) { self.reloadPluginManagerCallback( - structuredClone( - getSnapshot(self.jbrowse), - ) as Record, - structuredClone(sessionSnap) as Record< - string, - unknown - >, + structuredClone(getSnapshot(self.jbrowse)), + // @ts-expect-error + structuredClone(sessionSnap), ) } } catch (e) { @@ -824,15 +817,13 @@ export default function RootModel({ icon: SettingsIcon, onClick: () => { if (self.session) { - ;(self.session as SessionWithDialogs).queueDialog( - handleClose => [ - PreferencesDialog, - { - session: self.session, - handleClose, - }, - ], - ) + self.session.queueDialog(handleClose => [ + PreferencesDialog, + { + session: self.session, + handleClose, + }, + ]) } }, }, diff --git a/products/jbrowse-web/src/tests/util.tsx b/products/jbrowse-web/src/tests/util.tsx index 9303f3b0b7..bb9472a30d 100644 --- a/products/jbrowse-web/src/tests/util.tsx +++ b/products/jbrowse-web/src/tests/util.tsx @@ -14,8 +14,8 @@ import { toMatchImageSnapshot } from 'jest-image-snapshot' import { generateReadBuffer } from './generateReadBuffer' import configSnapshot from '../../test_data/volvox/config.json' import corePlugins from '../corePlugins' -import JBrowseRootModelFactory from '../rootModel/rootModel' import JBrowse from './TestingJBrowse' +import JBrowseRootModelFactory from '../rootModel/rootModel' import type { AbstractSessionModel, AppRootModel } from '@jbrowse/core/util' import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view'