snapshots.tsx 15 KB

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