jbrowse-plugin-msaview 3.4.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +4 -6
  2. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
  3. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts +1 -0
  5. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  6. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +2 -1
  7. package/dist/LaunchMsaViewExtensionPoint/index.js +11 -6
  8. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  9. package/dist/LaunchMsaViewExtensionPoint/index.test.js +43 -0
  10. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +15 -0
  11. package/dist/MsaViewPanel/afterCreateAutoruns.js +93 -70
  12. package/dist/MsaViewPanel/components/LaunchProgress.js +11 -2
  13. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +19 -0
  14. package/dist/MsaViewPanel/doLaunchBlast.d.ts +3 -1
  15. package/dist/MsaViewPanel/doLaunchBlast.js +10 -12
  16. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  17. package/dist/MsaViewPanel/doLaunchOrthologs.js +6 -5
  18. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +38 -24
  19. package/dist/MsaViewPanel/model.d.ts +160 -10
  20. package/dist/MsaViewPanel/model.js +51 -1
  21. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  22. package/dist/MsaViewPanel/msaDataStore.js +16 -7
  23. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  24. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  25. package/dist/MsaViewPanel/runLaunch.d.ts +38 -0
  26. package/dist/MsaViewPanel/runLaunch.js +65 -0
  27. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  28. package/dist/MsaViewPanel/runLaunch.test.js +129 -0
  29. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  30. package/dist/MsaViewPanel/storedData.test.js +160 -0
  31. package/dist/jbrowse-plugin-msaview.umd.production.min.js +35 -35
  32. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  33. package/dist/utils/blastCache.js +32 -0
  34. package/dist/utils/blastCache.test.d.ts +1 -0
  35. package/dist/utils/blastCache.test.js +72 -0
  36. package/dist/utils/ebiBlast.d.ts +4 -2
  37. package/dist/utils/ebiBlast.js +6 -4
  38. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  39. package/dist/utils/ebiJobDispatcher.js +13 -6
  40. package/dist/utils/fetch.d.ts +8 -1
  41. package/dist/utils/fetch.js +28 -2
  42. package/dist/utils/msa.d.ts +4 -2
  43. package/dist/utils/msa.js +9 -5
  44. package/dist/utils/ncbiDomains.d.ts +2 -2
  45. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  46. package/dist/utils/ncbiOrthologs.js +26 -7
  47. package/dist/utils/ncbiOrthologs.test.js +23 -1
  48. package/dist/utils/pantherOrthologs.js +2 -10
  49. package/dist/utils/phmmer.d.ts +2 -1
  50. package/dist/utils/phmmer.js +4 -2
  51. package/dist/utils/poll.d.ts +6 -1
  52. package/dist/utils/poll.js +7 -2
  53. package/dist/version.d.ts +1 -1
  54. package/dist/version.js +1 -1
  55. package/package.json +14 -14
  56. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +7 -11
  57. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  58. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  59. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +2 -2
  60. package/src/LaunchMsaViewExtensionPoint/index.test.ts +51 -0
  61. package/src/LaunchMsaViewExtensionPoint/index.ts +21 -6
  62. package/src/MsaViewPanel/afterCreateAutoruns.ts +100 -66
  63. package/src/MsaViewPanel/components/LaunchProgress.tsx +20 -2
  64. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +21 -0
  65. package/src/MsaViewPanel/doLaunchBlast.ts +14 -10
  66. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +41 -26
  67. package/src/MsaViewPanel/doLaunchOrthologs.ts +8 -4
  68. package/src/MsaViewPanel/model.ts +54 -1
  69. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  70. package/src/MsaViewPanel/msaDataStore.ts +22 -13
  71. package/src/MsaViewPanel/runLaunch.test.ts +154 -0
  72. package/src/MsaViewPanel/runLaunch.ts +102 -0
  73. package/src/MsaViewPanel/storedData.test.ts +196 -0
  74. package/src/utils/blastCache.test.ts +86 -0
  75. package/src/utils/blastCache.ts +34 -1
  76. package/src/utils/ebiBlast.ts +8 -2
  77. package/src/utils/ebiJobDispatcher.ts +18 -3
  78. package/src/utils/fetch.ts +29 -2
  79. package/src/utils/msa.ts +11 -3
  80. package/src/utils/ncbiDomains.ts +4 -2
  81. package/src/utils/ncbiOrthologs.test.ts +25 -0
  82. package/src/utils/ncbiOrthologs.ts +27 -7
  83. package/src/utils/pantherOrthologs.ts +6 -11
  84. package/src/utils/phmmer.ts +5 -1
  85. package/src/utils/poll.ts +8 -1
  86. package/src/version.ts +1 -1
@@ -12,7 +12,8 @@ import { useTranscriptSelection } from '../useTranscriptSelection';
12
12
  import CachedBlastResults from './CachedBlastResults';
13
13
  import MsaAlgorithmSelect from './MsaAlgorithmSelect';
14
14
  import { blastLaunchView } from './blastLaunchView';
15
- import { databaseOptionsFor, defaultBlastDatabase, defaultSearchFor, searchPrograms, } from './consts';
15
+ import { databaseOptionsFor, defaultSearchFor, searchPrograms } from './consts';
16
+ import { useStoredMsaAlgorithm, useStoredSearchChoice, } from './searchChoiceStorage';
16
17
  import { useCachedBlastResults } from './useCachedBlastResults';
17
18
  const useStyles = makeStyles()({
18
19
  selectField: {
@@ -38,11 +39,8 @@ const BlastAutomaticPanel = observer(function ({ handleClose, feature, model, ch
38
39
  // one piece of state, not two: a program and a database that program does not
39
40
  // have is a 400 from EBI minutes after Submit, and holding them apart is what
40
41
  // would let them drift into that
41
- const [search, setSearch] = useState({
42
- program: 'blastp',
43
- database: defaultBlastDatabase,
44
- });
45
- const [selectedMsaAlgorithm, setSelectedMsaAlgorithm] = useState('clustalo');
42
+ const [search, setSearch] = useStoredSearchChoice();
43
+ const [selectedMsaAlgorithm, setSelectedMsaAlgorithm] = useStoredMsaAlgorithm();
46
44
  const isPhmmer = search.program === 'phmmer';
47
45
  const geneIds = useMemo(() => getGeneIdentifiers(feature), [feature]);
48
46
  const { results: cachedResults, error: cachedResultsError } = useCachedBlastResults(geneIds);
@@ -0,0 +1,7 @@
1
+ import type { MsaAlgorithm, SearchChoice } from './consts';
2
+ export declare const SEARCH_CHOICE_STORAGE_KEY = "msaView-blastSearch";
3
+ export declare const MSA_ALGORITHM_STORAGE_KEY = "msaView-msaAlgorithm";
4
+ export declare function validSearchChoice(stored: unknown): SearchChoice;
5
+ export declare function validMsaAlgorithm(stored: unknown): MsaAlgorithm;
6
+ export declare function useStoredSearchChoice(): readonly [SearchChoice, (choice: SearchChoice) => void];
7
+ export declare function useStoredMsaAlgorithm(): readonly ["clustalo" | "muscle" | "kalign" | "mafft", (algorithm: MsaAlgorithm) => void];
@@ -0,0 +1,42 @@
1
+ import { useLocalStorage } from '../../../utils/useLocalStorage';
2
+ import { databaseOptionsFor, defaultSearchFor, msaAlgorithms, searchPrograms, } from './consts';
3
+ // The panel reopens on the last search run rather than on the defaults, the way
4
+ // the Orthologs tab remembers its source.
5
+ //
6
+ // The program and its database stay ONE stored value for the reason consts.ts
7
+ // keeps them one state: neither service knows the other's database names. What
8
+ // comes back is checked against the current options before it is used — an
9
+ // option this plugin has since dropped would otherwise reach EBI and come back
10
+ // a 400, minutes after the user pressed Submit.
11
+ export const SEARCH_CHOICE_STORAGE_KEY = 'msaView-blastSearch';
12
+ export const MSA_ALGORITHM_STORAGE_KEY = 'msaView-msaAlgorithm';
13
+ const defaultSearch = defaultSearchFor('blastp');
14
+ const defaultMsaAlgorithm = 'clustalo';
15
+ export function validSearchChoice(stored) {
16
+ const { program, database } = (stored ?? {});
17
+ const known = searchPrograms.find(p => p === program);
18
+ return known && databaseOptionsFor(known).some(d => d === database)
19
+ ? { program: known, database }
20
+ : defaultSearch;
21
+ }
22
+ export function validMsaAlgorithm(stored) {
23
+ return msaAlgorithms.find(a => a === stored) ?? defaultMsaAlgorithm;
24
+ }
25
+ export function useStoredSearchChoice() {
26
+ const [stored, setStored] = useLocalStorage(SEARCH_CHOICE_STORAGE_KEY, defaultSearch);
27
+ return [
28
+ validSearchChoice(stored),
29
+ (choice) => {
30
+ setStored(choice);
31
+ },
32
+ ];
33
+ }
34
+ export function useStoredMsaAlgorithm() {
35
+ const [stored, setStored] = useLocalStorage(MSA_ALGORITHM_STORAGE_KEY, defaultMsaAlgorithm);
36
+ return [
37
+ validMsaAlgorithm(stored),
38
+ (algorithm) => {
39
+ setStored(algorithm);
40
+ },
41
+ ];
42
+ }
@@ -0,0 +1,32 @@
1
+ import { expect, test } from 'vitest';
2
+ import { validMsaAlgorithm, validSearchChoice } from './searchChoiceStorage';
3
+ test('a stored pair both services recognise comes back as it went in', () => {
4
+ expect(validSearchChoice({ program: 'phmmer', database: 'swissprot' })).toEqual({
5
+ program: 'phmmer',
6
+ database: 'swissprot',
7
+ });
8
+ });
9
+ test('a database the stored program does not have falls back to the defaults', () => {
10
+ // uniprotkb_swissprot is blastp's name for it; sending it to phmmer is a 400
11
+ // from EBI minutes after Submit
12
+ expect(validSearchChoice({ program: 'phmmer', database: 'uniprotkb_swissprot' })).toEqual({ program: 'blastp', database: 'uniprotkb_swissprot' });
13
+ // uniprotkb_reference_proteomes shipped as a menu entry EBI rejects, and 3.0.0
14
+ // is old enough that someone's storage still holds it
15
+ expect(validSearchChoice({
16
+ program: 'blastp',
17
+ database: 'uniprotkb_reference_proteomes',
18
+ })).toEqual({ program: 'blastp', database: 'uniprotkb_swissprot' });
19
+ });
20
+ test('storage holding something else entirely falls back rather than throwing', () => {
21
+ for (const stored of [null, undefined, 'blastp', 42, {}, []]) {
22
+ expect(validSearchChoice(stored)).toEqual({
23
+ program: 'blastp',
24
+ database: 'uniprotkb_swissprot',
25
+ });
26
+ }
27
+ });
28
+ test('an unknown algorithm falls back to clustalo', () => {
29
+ expect(validMsaAlgorithm('mafft')).toBe('mafft');
30
+ expect(validMsaAlgorithm('t_coffee')).toBe('clustalo');
31
+ expect(validMsaAlgorithm(null)).toBe('clustalo');
32
+ });
@@ -7,6 +7,7 @@ import { defaultMaxSpecies } from '../../../utils/ncbiOrthologs';
7
7
  import { useLocalStorage } from '../../../utils/useLocalStorage';
8
8
  import { getGeneDisplayName, getGeneIdentifiers, getLinearGenomeView, getTranscriptDisplayName, } from '../../util';
9
9
  import MsaAlgorithmSelect from '../BlastQuery/MsaAlgorithmSelect';
10
+ import { useStoredMsaAlgorithm } from '../BlastQuery/searchChoiceStorage';
10
11
  import LaunchPanelContent from '../LaunchPanelContent';
11
12
  import SubmitCancelActions from '../SubmitCancelActions';
12
13
  import TranscriptSelector from '../TranscriptSelector';
@@ -25,7 +26,7 @@ const OrthologPanel = observer(function ({ handleClose, feature, model, }) {
25
26
  const [launchViewError, setLaunchViewError] = useState();
26
27
  const [taxId, setTaxId] = useState(9606);
27
28
  const [source, setSource] = useLocalStorage(ORTHOLOG_SOURCE_STORAGE_KEY, 'ncbi');
28
- const [msaAlgorithm, setMsaAlgorithm] = useState('clustalo');
29
+ const [msaAlgorithm, setMsaAlgorithm] = useStoredMsaAlgorithm();
29
30
  const [maxSpecies, setMaxSpecies] = useState(String(defaultMaxSpecies));
30
31
  const geneCandidates = useMemo(() => getGeneIdentifiers(feature), [feature]);
31
32
  const transcriptSelection = useTranscriptSelection({ feature, view });
@@ -15,6 +15,16 @@ export default function LaunchMsaViewExtensionPointF(pluginManager) {
15
15
  // directly, and so is orthologParams (the model's own autorun picks it up).
16
16
  // Only sources needing launch-time resolution go through `init`: msaUrl
17
17
  // (AlphaFold sniff) and the name-indexed bgzip block (no native loader).
18
+ //
19
+ // An init whose every field is undefined is still a truthy object, and
20
+ // MsaViewPanel reads any init as a launch in flight, so an inline-data
21
+ // launch flashed "Loading alignment" until processInit cleared it.
22
+ const init = {
23
+ msaUrl: msaFileLocation?.uri,
24
+ msaIndexedLocation,
25
+ msaName,
26
+ querySeqName,
27
+ };
18
28
  launchMsaView(session, {
19
29
  ...rest,
20
30
  data,
@@ -26,12 +36,7 @@ export default function LaunchMsaViewExtensionPointF(pluginManager) {
26
36
  },
27
37
  }
28
38
  : {}),
29
- init: {
30
- msaUrl: msaFileLocation?.uri,
31
- msaIndexedLocation,
32
- msaName,
33
- querySeqName,
34
- },
39
+ ...(Object.values(init).some(v => v !== undefined) ? { init } : {}),
35
40
  });
36
41
  return args;
37
42
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { expect, test } from 'vitest';
2
+ import LaunchMsaViewExtensionPointF from './index';
3
+ function launch(args) {
4
+ let run;
5
+ LaunchMsaViewExtensionPointF({
6
+ addToExtensionPoint(_name, cb) {
7
+ run = cb;
8
+ },
9
+ });
10
+ const added = [];
11
+ const session = {
12
+ addView(_type, snapshot) {
13
+ added.push(snapshot);
14
+ return { id: 'view-1' };
15
+ },
16
+ };
17
+ run({ session, ...args });
18
+ return added[0];
19
+ }
20
+ test('inline data carries no init, which the panel would read as a launch in flight', () => {
21
+ const snapshot = launch({ data: { msa: '>a\nMEEP' } });
22
+ expect('init' in snapshot).toBe(false);
23
+ });
24
+ test('a file location travels through init', () => {
25
+ const snapshot = launch({
26
+ msaFileLocation: { uri: 'http://example.com/a.fa' },
27
+ });
28
+ expect(snapshot.init).toEqual({
29
+ msaUrl: 'http://example.com/a.fa',
30
+ msaIndexedLocation: undefined,
31
+ msaName: undefined,
32
+ querySeqName: undefined,
33
+ });
34
+ });
35
+ test('one field set is enough to need init', () => {
36
+ const snapshot = launch({ data: { msa: '>a\nMEEP' }, querySeqName: 'QUERY' });
37
+ expect(snapshot.init).toEqual({
38
+ msaUrl: undefined,
39
+ msaIndexedLocation: undefined,
40
+ msaName: undefined,
41
+ querySeqName: 'QUERY',
42
+ });
43
+ });
@@ -1,5 +1,20 @@
1
1
  import type { JBrowsePluginMsaViewModel } from './model';
2
2
  export declare function loadStoredData(self: JBrowsePluginMsaViewModel): void;
3
+ /**
4
+ * Keep IndexedDB holding what the view holds. The first run writes a new row and
5
+ * records its id in the session snapshot; later runs update that row, because
6
+ * the alignment keeps changing after it arrives — react-msaview's
7
+ * "calculate neighbor-joining tree" replaces `data.tree` — and a session
8
+ * reopened against a stale row restores the wrong picture.
9
+ *
10
+ * `lastStoredData` is what makes that safe to run on every data change: it is
11
+ * the only thing separating "this is new" from "this is what we just wrote", and
12
+ * it is recorded whether or not the write succeeded, so a browser refusing
13
+ * IndexedDB (private mode) fails once rather than in a loop.
14
+ *
15
+ * A view whose data comes from a filehandle stores nothing at all: the file is
16
+ * the source of truth and react-msaview refetches it at startup.
17
+ */
3
18
  export declare function storeDataToIndexedDB(self: JBrowsePluginMsaViewModel): void;
4
19
  /**
5
20
  * Same shape as launchBlastIfNeeded, for the ortholog path: the params ARE the
@@ -1,12 +1,15 @@
1
1
  import { getSession } from '@jbrowse/core/util';
2
+ import { transaction } from 'mobx';
2
3
  import { doLaunchBlast } from './doLaunchBlast';
3
4
  import { doLaunchOrthologs } from './doLaunchOrthologs';
4
5
  import { fetchIndexedMsa } from './fetchIndexedMsa';
5
6
  import { genomeToMSA } from './genomeToMSA';
6
7
  import { loadProteinDomains } from './loadProteinDomains';
7
8
  import { cleanupOldData, generateDataStoreId, retrieveMsaData, storeMsaData, } from './msaDataStore';
9
+ import { runLaunch } from './runLaunch';
8
10
  import { getProteinViews } from './structureConnection';
9
11
  import { getUniprotIdFromAlphaFoldUrl, hasQueryRow } from './util';
12
+ const EXPIRED_MESSAGE = "This view's alignment is no longer in browser storage. Stored alignments are kept for 7 days after they were last used, and are lost when site data is cleared. Relaunch the alignment to rebuild it.";
10
13
  export function loadStoredData(self) {
11
14
  const { dataStoreId, rows } = self;
12
15
  if (dataStoreId && rows.length === 0) {
@@ -15,15 +18,28 @@ export function loadStoredData(self) {
15
18
  self.setLoadingStoredData(true);
16
19
  const storedData = await retrieveMsaData(dataStoreId);
17
20
  if (storedData) {
18
- if (storedData.msa) {
19
- self.setMSA(storedData.msa);
20
- }
21
- if (storedData.tree) {
22
- self.setTree(storedData.tree);
23
- }
24
- if (storedData.treeMetadata) {
25
- self.setTreeMetadata(storedData.treeMetadata);
26
- }
21
+ // one transaction, so storeDataToIndexedDB sees the restored data and
22
+ // the record of it together and does not write it straight back
23
+ transaction(() => {
24
+ if (storedData.msa) {
25
+ self.setMSA(storedData.msa);
26
+ }
27
+ if (storedData.tree) {
28
+ self.setTree(storedData.tree);
29
+ }
30
+ if (storedData.treeMetadata) {
31
+ self.setTreeMetadata(storedData.treeMetadata);
32
+ }
33
+ self.setLastStoredData(currentData(self));
34
+ });
35
+ }
36
+ else {
37
+ // the id names nothing, so clearing it is what lets react-msaview's
38
+ // "Return to import form" actually return instead of landing back here
39
+ transaction(() => {
40
+ self.setDataStoreId(undefined);
41
+ self.setError(new Error(EXPIRED_MESSAGE));
42
+ });
27
43
  }
28
44
  }
29
45
  catch (e) {
@@ -35,40 +51,63 @@ export function loadStoredData(self) {
35
51
  })();
36
52
  }
37
53
  }
54
+ function currentData(self) {
55
+ const { msa, tree, treeMetadata } = self.data;
56
+ return { msa, tree, treeMetadata };
57
+ }
58
+ function sameData(a, b) {
59
+ return (!!a &&
60
+ a.msa === b.msa &&
61
+ a.tree === b.tree &&
62
+ a.treeMetadata === b.treeMetadata);
63
+ }
64
+ /**
65
+ * Keep IndexedDB holding what the view holds. The first run writes a new row and
66
+ * records its id in the session snapshot; later runs update that row, because
67
+ * the alignment keeps changing after it arrives — react-msaview's
68
+ * "calculate neighbor-joining tree" replaces `data.tree` — and a session
69
+ * reopened against a stale row restores the wrong picture.
70
+ *
71
+ * `lastStoredData` is what makes that safe to run on every data change: it is
72
+ * the only thing separating "this is new" from "this is what we just wrote", and
73
+ * it is recorded whether or not the write succeeded, so a browser refusing
74
+ * IndexedDB (private mode) fails once rather than in a loop.
75
+ *
76
+ * A view whose data comes from a filehandle stores nothing at all: the file is
77
+ * the source of truth and react-msaview refetches it at startup.
78
+ */
38
79
  export function storeDataToIndexedDB(self) {
39
- const { rows, dataStoreId, isStoringData } = self;
40
- if (rows.length > 0 && !dataStoreId && !isStoringData) {
41
- if (self.msaFilehandle || self.treeFilehandle) {
42
- return;
80
+ const { rows, dataStoreId, isStoringData, lastStoredData } = self;
81
+ const data = currentData(self);
82
+ if (rows.length === 0 ||
83
+ isStoringData ||
84
+ self.msaFilehandle ||
85
+ self.treeFilehandle ||
86
+ !(data.msa || data.tree) ||
87
+ sameData(lastStoredData, data)) {
88
+ return;
89
+ }
90
+ // mark as storing synchronously so re-runs of this autorun (e.g. when data
91
+ // observables change while the write is pending) don't kick off a duplicate
92
+ // write and leave an orphan IndexedDB entry
93
+ self.setIsStoringData(true);
94
+ void (async () => {
95
+ try {
96
+ const id = dataStoreId ?? generateDataStoreId();
97
+ if (await storeMsaData(id, data)) {
98
+ self.setDataStoreId(id);
99
+ }
43
100
  }
44
- const msaData = self.data.msa;
45
- const treeData = self.data.tree;
46
- if (msaData || treeData) {
47
- // mark as storing synchronously so re-runs of this autorun (e.g. when
48
- // data observables change while the write is pending) don't kick off a
49
- // duplicate write and leave an orphan IndexedDB entry
50
- self.setIsStoringData(true);
51
- void (async () => {
52
- try {
53
- const newId = generateDataStoreId();
54
- const success = await storeMsaData(newId, {
55
- msa: msaData,
56
- tree: treeData,
57
- treeMetadata: self.data.treeMetadata,
58
- });
59
- if (success) {
60
- self.setDataStoreId(newId);
61
- }
62
- }
63
- catch (e) {
64
- console.error('Failed to store MSA data to IndexedDB:', e);
65
- }
66
- finally {
67
- self.setIsStoringData(false);
68
- }
69
- })();
101
+ catch (e) {
102
+ console.error('Failed to store MSA data to IndexedDB:', e);
70
103
  }
71
- }
104
+ finally {
105
+ transaction(() => {
106
+ self.setLastStoredData(data);
107
+ self.setIsStoringData(false);
108
+ });
109
+ }
110
+ })();
72
111
  }
73
112
  /**
74
113
  * Same shape as launchBlastIfNeeded, for the ortholog path: the params ARE the
@@ -79,42 +118,26 @@ export function storeDataToIndexedDB(self) {
79
118
  */
80
119
  export function launchOrthologsIfNeeded(self) {
81
120
  if (self.orthologParams) {
82
- void (async () => {
83
- try {
84
- self.setProgress('Resolving orthologs');
85
- self.setError(undefined);
86
- const data = await doLaunchOrthologs({ self });
87
- self.setData(data);
121
+ runLaunch({
122
+ self,
123
+ message: 'Resolving orthologs',
124
+ launch: scope => doLaunchOrthologs({ self, scope }),
125
+ onLaunched: () => {
88
126
  self.setOrthologParams(undefined);
89
- }
90
- catch (e) {
91
- self.setError(e);
92
- console.error(e);
93
- }
94
- finally {
95
- self.setProgress('');
96
- }
97
- })();
127
+ },
128
+ });
98
129
  }
99
130
  }
100
131
  export function launchBlastIfNeeded(self) {
101
132
  if (self.blastParams) {
102
- void (async () => {
103
- try {
104
- self.setProgress('Submitting query');
105
- self.setError(undefined);
106
- const data = await doLaunchBlast({ self });
107
- self.setData(data);
133
+ runLaunch({
134
+ self,
135
+ message: 'Submitting query',
136
+ launch: scope => doLaunchBlast({ self, scope }),
137
+ onLaunched: () => {
108
138
  self.setBlastParams(undefined);
109
- }
110
- catch (e) {
111
- self.setError(e);
112
- console.error(e);
113
- }
114
- finally {
115
- self.setProgress('');
116
- }
117
- })();
139
+ },
140
+ });
118
141
  }
119
142
  }
120
143
  /**
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { ErrorMessage, LoadingEllipses } from '@jbrowse/core/ui';
3
- import { Typography } from '@mui/material';
3
+ import { Button, Typography } from '@mui/material';
4
4
  import { observer } from 'mobx-react';
5
5
  import { makeStyles } from 'tss-react/mui';
6
6
  import JobLink from './JobLink';
@@ -8,6 +8,11 @@ const useStyles = makeStyles()({
8
8
  margin: {
9
9
  padding: 20,
10
10
  },
11
+ progressRow: {
12
+ display: 'flex',
13
+ alignItems: 'center',
14
+ gap: 12,
15
+ },
11
16
  });
12
17
  /**
13
18
  * What a view shows while it is still building its alignment, and what it shows
@@ -36,6 +41,10 @@ const LaunchProgress = observer(function LaunchProgress2({ model, }) {
36
41
  React.createElement(ErrorMessage, { error: error }))) : (React.createElement(React.Fragment, null,
37
42
  React.createElement(LoadingEllipses, { message: message, variant: "h5" }),
38
43
  rid ? React.createElement(JobLink, { jobId: rid }) : null,
39
- React.createElement(Typography, null, progress || 'Initializing')))));
44
+ React.createElement("div", { className: classes.progressRow },
45
+ React.createElement(Typography, null, progress || 'Initializing'),
46
+ React.createElement(Button, { variant: "outlined", size: "small", onClick: () => {
47
+ model.cancelLaunch();
48
+ } }, "Cancel"))))));
40
49
  });
41
50
  export default LaunchProgress;
@@ -57,6 +57,25 @@ test('a failed init shows why', () => {
57
57
  expect(screen.queryByText('the alignment')).toBeNull();
58
58
  expect(screen.getByText(/No alignment named ENST00000288602/)).toBeTruthy();
59
59
  });
60
+ // a launch runs for 10+ minutes, so leaving with no way out means watching it
61
+ test('a running launch offers a way out, and a failed one does not', () => {
62
+ const cancelLaunch = vi.fn();
63
+ panel({
64
+ blastParams: { proteinSequence: 'MKV' },
65
+ progress: 'Re-checking BLAST status in... 7',
66
+ cancelLaunch,
67
+ });
68
+ screen.getByRole('button', { name: 'Cancel' }).click();
69
+ expect(cancelLaunch).toHaveBeenCalled();
70
+ cleanup();
71
+ panel({
72
+ blastParams: { proteinSequence: 'MKV' },
73
+ progress: '',
74
+ error: new Error('No hits found'),
75
+ cancelLaunch,
76
+ });
77
+ expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
78
+ });
60
79
  test('a running job links out to it', () => {
61
80
  panel({
62
81
  blastParams: { proteinSequence: 'MKV' },
@@ -1,6 +1,8 @@
1
1
  import type { JBrowsePluginMsaViewModel } from './model';
2
- export declare function doLaunchBlast({ self, }: {
2
+ import type { LaunchScope } from './runLaunch';
3
+ export declare function doLaunchBlast({ self, scope, }: {
3
4
  self: JBrowsePluginMsaViewModel;
5
+ scope: LaunchScope;
4
6
  }): Promise<{
5
7
  msa: string;
6
8
  tree: string;
@@ -6,26 +6,20 @@ import { launchMSA, launchTree } from '../utils/msa';
6
6
  import { buildPhmmerMsa, buildRowMetadata } from '../utils/msaRows';
7
7
  import { queryPhmmer } from '../utils/phmmer';
8
8
  import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
9
- export async function doLaunchBlast({ self, }) {
9
+ export async function doLaunchBlast({ self, scope, }) {
10
10
  // kept whole rather than destructured: the database's type depends on
11
11
  // searchProgram, and pulling the two apart loses the link between them
12
12
  const params = self.blastParams;
13
13
  const { selectedTranscript } = params;
14
14
  const cleanedSeq = cleanProteinSequence(params.proteinSequence);
15
- const onProgress = (arg) => {
16
- self.setProgress(arg);
17
- };
18
- // publish the job id before the first poll so the view can link out while the
19
- // job is still running
20
- const onRid = (r) => {
21
- self.setRid(r);
22
- };
15
+ const { onProgress, onRid, signal } = scope;
23
16
  const { msa, tree, treeMetadata, rid } = params.searchProgram === 'phmmer'
24
17
  ? await runPhmmer({
25
18
  query: cleanedSeq,
26
19
  database: params.blastDatabase,
27
20
  onProgress,
28
21
  onRid,
22
+ signal,
29
23
  })
30
24
  : await runBlast({
31
25
  query: cleanedSeq,
@@ -33,6 +27,7 @@ export async function doLaunchBlast({ self, }) {
33
27
  msaAlgorithm: params.msaAlgorithm,
34
28
  onProgress,
35
29
  onRid,
30
+ signal,
36
31
  });
37
32
  const treeMetadataJson = JSON.stringify(treeMetadata);
38
33
  await saveBlastResult({
@@ -57,12 +52,13 @@ export async function doLaunchBlast({ self, }) {
57
52
  * at a time, so the alignments are stripped back off and every hit is realigned
58
53
  * together by a dedicated aligner.
59
54
  */
60
- async function runBlast({ query, blastDatabase, msaAlgorithm, onProgress, onRid, }) {
55
+ async function runBlast({ query, blastDatabase, msaAlgorithm, onProgress, onRid, signal, }) {
61
56
  const { hits, rid } = await queryEbiBlast({
62
57
  query,
63
58
  blastDatabase,
64
59
  onProgress,
65
60
  onRid,
61
+ signal,
66
62
  });
67
63
  onProgress('Fetching species taxonomy info...');
68
64
  const taxonomyInfo = await fetchTaxonomyInfo(hits
@@ -83,6 +79,7 @@ async function runBlast({ query, blastDatabase, msaAlgorithm, onProgress, onRid,
83
79
  algorithm: msaAlgorithm,
84
80
  sequence: [`>QUERY\n${query}`, ...sequences].join('\n'),
85
81
  onProgress,
82
+ signal,
86
83
  });
87
84
  return { ...result, treeMetadata, rid };
88
85
  }
@@ -93,12 +90,13 @@ async function runBlast({ query, blastDatabase, msaAlgorithm, onProgress, onRid,
93
90
  * match columns rather than being aligned back in afterwards. That leaves no
94
91
  * aligner run to take a tree from, so the tree is built from this alignment.
95
92
  */
96
- async function runPhmmer({ query, database, onProgress, onRid, }) {
93
+ async function runPhmmer({ query, database, onProgress, onRid, signal, }) {
97
94
  const { rows, queryRow, rid } = await queryPhmmer({
98
95
  query,
99
96
  database,
100
97
  onProgress,
101
98
  onRid,
99
+ signal,
102
100
  });
103
101
  onProgress('Fetching species taxonomy info...');
104
102
  const taxonomyInfo = await fetchTaxonomyInfo(rows.map(r => r.taxid).filter((t) => t !== undefined));
@@ -109,7 +107,7 @@ async function runPhmmer({ query, database, onProgress, onRid, }) {
109
107
  });
110
108
  return {
111
109
  msa,
112
- tree: await launchTree({ alignment: msa, onProgress }),
110
+ tree: await launchTree({ alignment: msa, onProgress, signal }),
113
111
  treeMetadata,
114
112
  rid,
115
113
  };
@@ -1,4 +1,5 @@
1
1
  import type { JBrowsePluginMsaViewModel } from './model';
2
+ import type { LaunchScope } from './runLaunch';
2
3
  /**
3
4
  * The no-search-job alternative to doLaunchBlast.
4
5
  *
@@ -16,8 +17,9 @@ import type { JBrowsePluginMsaViewModel } from './model';
16
17
  * would silently break the genome<->MSA linkage. The query species is therefore
17
18
  * excluded from the ortholog set rather than appearing twice.
18
19
  */
19
- export declare function doLaunchOrthologs({ self, }: {
20
+ export declare function doLaunchOrthologs({ self, scope, }: {
20
21
  self: JBrowsePluginMsaViewModel;
22
+ scope: LaunchScope;
21
23
  }): Promise<{
22
24
  treeMetadata: string;
23
25
  msa: string;
@@ -20,11 +20,9 @@ import { fetchTaxonomyInfo } from '../utils/taxonomyNames';
20
20
  * would silently break the genome<->MSA linkage. The query species is therefore
21
21
  * excluded from the ortholog set rather than appearing twice.
22
22
  */
23
- export async function doLaunchOrthologs({ self, }) {
23
+ export async function doLaunchOrthologs({ self, scope, }) {
24
24
  const { taxId, taxa, maxSpecies, geneCandidates, msaAlgorithm, proteinSequence, source = 'ncbi', } = self.orthologParams;
25
- const onProgress = (arg) => {
26
- self.setProgress(arg);
27
- };
25
+ const { onProgress, act, signal } = scope;
28
26
  const request = {
29
27
  taxId,
30
28
  geneCandidates,
@@ -62,7 +60,9 @@ export async function doLaunchOrthologs({ self, }) {
62
60
  // token, and a collision would silently point the coordinate mapping at
63
61
  // another animal's row.
64
62
  const queryLabel = await queryRowLabel(taxId, rows);
65
- self.setQuerySeqName(queryLabel);
63
+ act(() => {
64
+ self.setQuerySeqName(queryLabel);
65
+ });
66
66
  const treeMetadata = {
67
67
  [queryLabel]: buildQueryMetadata(self, geneId, cleanedSeq, representative),
68
68
  };
@@ -76,6 +76,7 @@ export async function doLaunchOrthologs({ self, }) {
76
76
  ...rows.map(r => `>${r.label}\n${r.sequence}`),
77
77
  ].join('\n'),
78
78
  onProgress,
79
+ signal,
79
80
  });
80
81
  return {
81
82
  ...result,