snapshots.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. /**
  2. * Copyright (c) 2018 mol* contributors, licensed under MIT, See LICENSE file for more info.
  3. *
  4. * @author David Sehnal <david.sehnal@gmail.com>
  5. */
  6. import { OrderedMap } from 'immutable';
  7. import * as React from 'react';
  8. import { PluginCommands } from '../../mol-plugin/commands';
  9. import { PluginConfig } from '../../mol-plugin/config';
  10. import { PluginState } from '../../mol-plugin/state';
  11. import { shallowEqualObjects } from '../../mol-util';
  12. import { formatTimespan } from '../../mol-util/now';
  13. import { ParamDefinition as PD } from '../../mol-util/param-definition';
  14. import { urlCombine } from '../../mol-util/url';
  15. import { PluginUIComponent, PurePluginUIComponent } from '../base';
  16. import { Button, ExpandGroup, IconButton, SectionHeader } from '../controls/common';
  17. import { Icon, SaveOutlinedSvg, GetAppSvg, OpenInBrowserSvg, WarningSvg, DeleteOutlinedSvg, AddSvg, ArrowUpwardSvg, SwapHorizSvg, ArrowDownwardSvg, RefreshSvg, CloudUploadSvg } from '../controls/icons';
  18. import { ParameterControls } from '../controls/parameters';
  19. export class StateSnapshots extends PluginUIComponent<{}> {
  20. render() {
  21. return <div>
  22. <SectionHeader icon={SaveOutlinedSvg} title='Plugin State' />
  23. <div style={{ marginBottom: '10px' }}>
  24. <ExpandGroup header='Save Options' initiallyExpanded={false}>
  25. <LocalStateSnapshotParams />
  26. </ExpandGroup>
  27. </div>
  28. <LocalStateSnapshots />
  29. <LocalStateSnapshotList />
  30. <SectionHeader title='Save as File' accent='blue' />
  31. <StateExportImportControls />
  32. {this.plugin.spec.components?.remoteState !== 'none' && <RemoteStateSnapshots />}
  33. </div>;
  34. }
  35. }
  36. export class StateExportImportControls extends PluginUIComponent<{ onAction?: () => void }> {
  37. downloadToFileJson = () => {
  38. this.props.onAction?.();
  39. PluginCommands.State.Snapshots.DownloadToFile(this.plugin, { type: 'json' });
  40. }
  41. downloadToFileZip = () => {
  42. this.props.onAction?.();
  43. PluginCommands.State.Snapshots.DownloadToFile(this.plugin, { type: 'zip' });
  44. }
  45. open = (e: React.ChangeEvent<HTMLInputElement>) => {
  46. if (!e.target.files || !e.target.files[0]) {
  47. this.plugin.log.error('No state file selected');
  48. return;
  49. }
  50. this.props.onAction?.();
  51. PluginCommands.State.Snapshots.OpenFile(this.plugin, { file: e.target.files[0] });
  52. }
  53. render() {
  54. return <>
  55. <div className='msp-flex-row'>
  56. <Button icon={GetAppSvg} onClick={this.downloadToFileJson} title='Save the state description. Input data are loaded using the provided sources. Does not work if local files are used as input.'>
  57. State
  58. </Button>
  59. <Button icon={GetAppSvg} onClick={this.downloadToFileZip} title='Save the state including the input data.'>
  60. Session
  61. </Button>
  62. <div className='msp-btn msp-btn-block msp-btn-action msp-loader-msp-btn-file'>
  63. <Icon svg={OpenInBrowserSvg} inline /> Open <input onChange={this.open} type='file' multiple={false} accept='.molx,.molj' />
  64. </div>
  65. </div>
  66. <div className='msp-help-text' style={{ padding: '10px'}}>
  67. <Icon svg={WarningSvg} /> This is an experimental feature and stored states/sessions might not be openable in a future version.
  68. </div>
  69. </>;
  70. }
  71. }
  72. export class LocalStateSnapshotParams extends PluginUIComponent {
  73. componentDidMount() {
  74. this.subscribe(this.plugin.state.snapshotParams, () => this.forceUpdate());
  75. }
  76. render() {
  77. return <ParameterControls params={PluginState.SnapshotParams} values={this.plugin.state.snapshotParams.value} onChangeValues={this.plugin.state.setSnapshotParams} />;
  78. }
  79. }
  80. export class LocalStateSnapshots extends PluginUIComponent<
  81. {},
  82. { params: PD.Values<typeof LocalStateSnapshots.Params> }> {
  83. state = { params: PD.getDefaultValues(LocalStateSnapshots.Params) };
  84. static Params = {
  85. name: PD.Text(),
  86. description: PD.Text()
  87. };
  88. add = () => {
  89. PluginCommands.State.Snapshots.Add(this.plugin, {
  90. name: this.state.params.name,
  91. description: this.state.params.description
  92. });
  93. }
  94. updateParams = (params: PD.Values<typeof LocalStateSnapshots.Params>) => this.setState({ params });
  95. clear = () => {
  96. PluginCommands.State.Snapshots.Clear(this.plugin, {});
  97. }
  98. shouldComponentUpdate(nextProps: any, nextState: any) {
  99. return !shallowEqualObjects(this.props, nextProps) || !shallowEqualObjects(this.state, nextState);
  100. }
  101. render() {
  102. return <div>
  103. <ParameterControls params={LocalStateSnapshots.Params} values={this.state.params} onEnter={this.add} onChangeValues={this.updateParams} />
  104. <div className='msp-flex-row'>
  105. <IconButton onClick={this.clear} svg={DeleteOutlinedSvg} title='Remove All' />
  106. <Button onClick={this.add} icon={AddSvg} style={{ textAlign: 'right' }} commit>Add</Button>
  107. </div>
  108. </div>;
  109. }
  110. }
  111. export class LocalStateSnapshotList extends PluginUIComponent<{}, {}> {
  112. componentDidMount() {
  113. this.subscribe(this.plugin.managers.snapshot.events.changed, () => this.forceUpdate());
  114. }
  115. apply = (e: React.MouseEvent<HTMLElement>) => {
  116. const id = e.currentTarget.getAttribute('data-id');
  117. if (!id) return;
  118. PluginCommands.State.Snapshots.Apply(this.plugin, { id });
  119. }
  120. remove = (e: React.MouseEvent<HTMLElement>) => {
  121. const id = e.currentTarget.getAttribute('data-id');
  122. if (!id) return;
  123. PluginCommands.State.Snapshots.Remove(this.plugin, { id });
  124. }
  125. moveUp = (e: React.MouseEvent<HTMLElement>) => {
  126. const id = e.currentTarget.getAttribute('data-id');
  127. if (!id) return;
  128. PluginCommands.State.Snapshots.Move(this.plugin, { id, dir: -1 });
  129. }
  130. moveDown = (e: React.MouseEvent<HTMLElement>) => {
  131. const id = e.currentTarget.getAttribute('data-id');
  132. if (!id) return;
  133. PluginCommands.State.Snapshots.Move(this.plugin, { id, dir: 1 });
  134. }
  135. replace = (e: React.MouseEvent<HTMLElement>) => {
  136. const id = e.currentTarget.getAttribute('data-id');
  137. if (!id) return;
  138. PluginCommands.State.Snapshots.Replace(this.plugin, { id });
  139. }
  140. render() {
  141. const current = this.plugin.managers.snapshot.state.current;
  142. return <ul style={{ listStyle: 'none', marginTop: '10px' }} className='msp-state-list'>
  143. {this.plugin.managers.snapshot.state.entries.map(e => <li key={e!.snapshot.id} className='msp-flex-row'>
  144. <Button data-id={e!.snapshot.id} onClick={this.apply} className='msp-no-overflow'>
  145. <span style={{ fontWeight: e!.snapshot.id === current ? 'bold' : void 0 }}>
  146. {e!.name || new Date(e!.timestamp).toLocaleString()}</span> <small>
  147. {`${e!.snapshot.durationInMs ? formatTimespan(e!.snapshot.durationInMs, false) + `${e!.description ? ', ' : ''}` : ''}${e!.description ? e!.description : ''}`}
  148. </small>
  149. </Button>
  150. <IconButton svg={ArrowUpwardSvg} data-id={e!.snapshot.id} title='Move Up' onClick={this.moveUp} flex='20px' />
  151. <IconButton svg={ArrowDownwardSvg} data-id={e!.snapshot.id} title='Move Down' onClick={this.moveDown} flex='20px' />
  152. <IconButton svg={SwapHorizSvg} data-id={e!.snapshot.id} title='Replace' onClick={this.replace} flex='20px' />
  153. <IconButton svg={DeleteOutlinedSvg} data-id={e!.snapshot.id} title='Remove' onClick={this.remove} flex='20px' />
  154. </li>)}
  155. </ul>;
  156. }
  157. }
  158. export type RemoteEntry = { url: string, removeUrl: string, timestamp: number, id: string, name: string, description: string, isSticky?: boolean }
  159. export class RemoteStateSnapshots extends PluginUIComponent<
  160. { listOnly?: boolean },
  161. { params: PD.Values<RemoteStateSnapshots['Params']>, entries: OrderedMap<string, RemoteEntry>, isBusy: boolean }> {
  162. Params = {
  163. name: PD.Text(),
  164. options: PD.Group({
  165. description: PD.Text(),
  166. playOnLoad: PD.Boolean(false),
  167. serverUrl: PD.Text(this.plugin.config.get(PluginConfig.State.CurrentServer))
  168. })
  169. };
  170. state = { params: PD.getDefaultValues(this.Params), entries: OrderedMap<string, RemoteEntry>(), isBusy: false };
  171. ListOnlyParams = {
  172. options: PD.Group({
  173. serverUrl: PD.Text(this.plugin.config.get(PluginConfig.State.CurrentServer))
  174. }, { isFlat: true })
  175. };
  176. private _mounted = false;
  177. componentDidMount() {
  178. this.refresh();
  179. // TODO: solve this by using "PluginComponent" with behaviors intead
  180. this._mounted = true;
  181. // this.subscribe(UploadedEvent, this.refresh);
  182. }
  183. componentWillUnmount() {
  184. this._mounted = false;
  185. }
  186. serverUrl(q?: string) {
  187. if (!q) return this.state.params.options.serverUrl;
  188. return urlCombine(this.state.params.options.serverUrl, q);
  189. }
  190. refresh = async () => {
  191. try {
  192. this.setState({ isBusy: true });
  193. this.plugin.config.set(PluginConfig.State.CurrentServer, this.state.params.options.serverUrl);
  194. const json = (await this.plugin.runTask<RemoteEntry[]>(this.plugin.fetch({ url: this.serverUrl('list'), type: 'json' }))) || [];
  195. json.sort((a, b) => {
  196. if (a.isSticky === b.isSticky) return a.timestamp - b.timestamp;
  197. return a.isSticky ? -1 : 1;
  198. });
  199. const entries = OrderedMap<string, RemoteEntry>().asMutable();
  200. for (const e of json) {
  201. entries.set(e.id, {
  202. ...e,
  203. url: this.serverUrl(`get/${e.id}`),
  204. removeUrl: this.serverUrl(`remove/${e.id}`)
  205. });
  206. }
  207. if (this._mounted) this.setState({ entries: entries.asImmutable(), isBusy: false });
  208. } catch (e) {
  209. this.plugin.log.error('Fetching Remote Snapshots: ' + e);
  210. if (this._mounted) this.setState({ entries: OrderedMap(), isBusy: false });
  211. }
  212. }
  213. upload = async () => {
  214. this.setState({ isBusy: true });
  215. this.plugin.config.set(PluginConfig.State.CurrentServer, this.state.params.options.serverUrl);
  216. await PluginCommands.State.Snapshots.Upload(this.plugin, {
  217. name: this.state.params.name,
  218. description: this.state.params.options.description,
  219. playOnLoad: this.state.params.options.playOnLoad,
  220. serverUrl: this.state.params.options.serverUrl
  221. });
  222. this.plugin.log.message('Snapshot uploaded.');
  223. if (this._mounted) {
  224. this.setState({ isBusy: false });
  225. this.refresh();
  226. }
  227. }
  228. fetch = async (e: React.MouseEvent<HTMLElement>) => {
  229. const id = e.currentTarget.getAttribute('data-id');
  230. if (!id) return;
  231. const entry = this.state.entries.get(id);
  232. if (!entry) return;
  233. this.setState({ isBusy: true });
  234. try {
  235. await PluginCommands.State.Snapshots.Fetch(this.plugin, { url: entry.url });
  236. } finally {
  237. if (this._mounted) this.setState({ isBusy: false });
  238. }
  239. }
  240. remove = async (e: React.MouseEvent<HTMLElement>) => {
  241. const id = e.currentTarget.getAttribute('data-id');
  242. if (!id) return;
  243. const entry = this.state.entries.get(id);
  244. if (!entry) return;
  245. this.setState({ entries: this.state.entries.remove(id) });
  246. try {
  247. await fetch(entry.removeUrl);
  248. } catch { }
  249. }
  250. render() {
  251. return <>
  252. <SectionHeader title='Remote States' accent='blue' />
  253. {!this.props.listOnly && <>
  254. <ParameterControls params={this.Params} values={this.state.params} onEnter={this.upload} onChange={p => {
  255. this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any);
  256. }} isDisabled={this.state.isBusy} />
  257. <div className='msp-flex-row'>
  258. <IconButton onClick={this.refresh} disabled={this.state.isBusy} svg={RefreshSvg} />
  259. <Button icon={CloudUploadSvg} onClick={this.upload} disabled={this.state.isBusy} commit>Upload</Button>
  260. </div>
  261. </>}
  262. <RemoteStateSnapshotList entries={this.state.entries} isBusy={this.state.isBusy} serverUrl={this.state.params.options.serverUrl}
  263. fetch={this.fetch} remove={this.props.listOnly ? void 0 : this.remove} />
  264. {this.props.listOnly && <div style={{ marginTop: '10px' }}>
  265. <ParameterControls params={this.ListOnlyParams} values={this.state.params} onEnter={this.upload} onChange={p => {
  266. this.setState({ params: { ...this.state.params, [p.name]: p.value } } as any);
  267. }} isDisabled={this.state.isBusy} />
  268. <div className='msp-flex-row'>
  269. <Button onClick={this.refresh} disabled={this.state.isBusy} icon={RefreshSvg}>Refresh</Button>
  270. </div>
  271. </div>}
  272. </>;
  273. }
  274. }
  275. class RemoteStateSnapshotList extends PurePluginUIComponent<
  276. { entries: OrderedMap<string, RemoteEntry>, serverUrl: string, isBusy: boolean, fetch: (e: React.MouseEvent<HTMLElement>) => void, remove?: (e: React.MouseEvent<HTMLElement>) => void },
  277. {}> {
  278. open = async (e: React.MouseEvent<HTMLElement>) => {
  279. const id = e.currentTarget.getAttribute('data-id');
  280. if (!id) return;
  281. const entry = this.props.entries.get(id);
  282. if (!entry) return;
  283. e.preventDefault();
  284. let url = `${window.location}`, qi = url.indexOf('?');
  285. if (qi > 0) url = url.substr(0, qi);
  286. window.open(`${url}?snapshot-url=${encodeURIComponent(entry.url)}`, '_blank');
  287. }
  288. render() {
  289. return <ul style={{ listStyle: 'none', marginTop: '10px' }} className='msp-state-list'>
  290. {this.props.entries.valueSeq().map(e => <li key={e!.id} className='msp-flex-row'>
  291. <Button data-id={e!.id} onClick={this.props.fetch}
  292. disabled={this.props.isBusy} onContextMenu={this.open} title='Click to download, right-click to open in a new tab.'>
  293. {e!.name || new Date(e!.timestamp).toLocaleString()} <small>{e!.description}</small>
  294. </Button>
  295. {!e!.isSticky && this.props.remove && <IconButton svg={DeleteOutlinedSvg} data-id={e!.id} title='Remove' onClick={this.props.remove} disabled={this.props.isBusy} small />}
  296. </li>)}
  297. </ul>;
  298. }
  299. }