context.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. Contents, ServiceManager, ServerConnection
  5. } from '@jupyterlab/services';
  6. import {
  7. JSONValue, PromiseDelegate
  8. } from '@phosphor/coreutils';
  9. import {
  10. IDisposable, DisposableDelegate
  11. } from '@phosphor/disposable';
  12. import {
  13. ISignal, Signal
  14. } from '@phosphor/signaling';
  15. import {
  16. Widget
  17. } from '@phosphor/widgets';
  18. import {
  19. showDialog, ClientSession, Dialog, IClientSession
  20. } from '@jupyterlab/apputils';
  21. import {
  22. PathExt
  23. } from '@jupyterlab/coreutils';
  24. import {
  25. IModelDB, ModelDB
  26. } from '@jupyterlab/observables';
  27. import {
  28. RenderMimeRegistry
  29. } from '@jupyterlab/rendermime';
  30. import {
  31. IRenderMime
  32. } from '@jupyterlab/rendermime-interfaces';
  33. import {
  34. DocumentRegistry
  35. } from './registry';
  36. /**
  37. * An implementation of a document context.
  38. *
  39. * This class is typically instantiated by the document manger.
  40. */
  41. export
  42. class Context<T extends DocumentRegistry.IModel> implements DocumentRegistry.IContext<T> {
  43. /**
  44. * Construct a new document context.
  45. */
  46. constructor(options: Context.IOptions<T>) {
  47. let manager = this._manager = options.manager;
  48. this._factory = options.factory;
  49. this._opener = options.opener || Private.noOp;
  50. this._path = options.path;
  51. const localPath = this._manager.contents.localPath(this._path);
  52. let lang = this._factory.preferredLanguage(PathExt.basename(localPath));
  53. let dbFactory = options.modelDBFactory;
  54. if (dbFactory) {
  55. const localPath = manager.contents.localPath(this._path);
  56. this._modelDB = dbFactory.createNew(localPath);
  57. this._model = this._factory.createNew(lang, this._modelDB);
  58. } else {
  59. this._model = this._factory.createNew(lang);
  60. }
  61. this._readyPromise = manager.ready.then(() => {
  62. return this._populatedPromise.promise;
  63. });
  64. let ext = PathExt.extname(this._path);
  65. this.session = new ClientSession({
  66. manager: manager.sessions,
  67. path: this._path,
  68. type: ext === '.ipynb' ? 'notebook' : 'file',
  69. name: PathExt.basename(localPath),
  70. kernelPreference: options.kernelPreference || { shouldStart: false }
  71. });
  72. this.session.propertyChanged.connect(this._onSessionChanged, this);
  73. manager.contents.fileChanged.connect(this._onFileChanged, this);
  74. this.urlResolver = new RenderMimeRegistry.UrlResolver({
  75. session: this.session,
  76. contents: manager.contents
  77. });
  78. }
  79. /**
  80. * A signal emitted when the path changes.
  81. */
  82. get pathChanged(): ISignal<this, string> {
  83. return this._pathChanged;
  84. }
  85. /**
  86. * A signal emitted when the model is saved or reverted.
  87. */
  88. get fileChanged(): ISignal<this, Contents.IModel> {
  89. return this._fileChanged;
  90. }
  91. /**
  92. * A signal emitted when the context is disposed.
  93. */
  94. get disposed(): ISignal<this, void> {
  95. return this._disposed;
  96. }
  97. /**
  98. * Get the model associated with the document.
  99. */
  100. get model(): T {
  101. return this._model;
  102. }
  103. /**
  104. * The client session object associated with the context.
  105. */
  106. readonly session: ClientSession;
  107. /**
  108. * The current path associated with the document.
  109. */
  110. get path(): string {
  111. return this._path;
  112. }
  113. /**
  114. * The current local path associated with the document.
  115. * If the document is in the default notebook file browser,
  116. * this is the same as the path.
  117. */
  118. get localPath(): string {
  119. return this._manager.contents.localPath(this._path);
  120. }
  121. /**
  122. * The current contents model associated with the document.
  123. *
  124. * #### Notes
  125. * The contents model will be null until the context is populated.
  126. * It will have an empty `contents` field.
  127. */
  128. get contentsModel(): Contents.IModel | null {
  129. return this._contentsModel;
  130. }
  131. /**
  132. * Get the model factory name.
  133. *
  134. * #### Notes
  135. * This is not part of the `IContext` API.
  136. */
  137. get factoryName(): string {
  138. return this.isDisposed ? '' : this._factory.name;
  139. }
  140. /**
  141. * Test whether the context is disposed.
  142. */
  143. get isDisposed(): boolean {
  144. return this._isDisposed;
  145. }
  146. /**
  147. * Dispose of the resources held by the context.
  148. */
  149. dispose(): void {
  150. if (this.isDisposed) {
  151. return;
  152. }
  153. this._isDisposed = true;
  154. this.session.dispose();
  155. if (this._modelDB) {
  156. this._modelDB.dispose();
  157. }
  158. this._model.dispose();
  159. this._disposed.emit(void 0);
  160. Signal.clearData(this);
  161. }
  162. /**
  163. * Whether the context is ready.
  164. */
  165. get isReady(): boolean {
  166. return this._isReady;
  167. }
  168. /**
  169. * A promise that is fulfilled when the context is ready.
  170. */
  171. get ready(): Promise<void> {
  172. return this._readyPromise;
  173. }
  174. /**
  175. * The url resolver for the context.
  176. */
  177. readonly urlResolver: IRenderMime.IResolver;
  178. /**
  179. * Populate the contents of the model, either from
  180. * disk or from the modelDB backend.
  181. *
  182. * @returns a promise that resolves upon model population.
  183. */
  184. fromStore(): Promise<void> {
  185. if (this._modelDB) {
  186. return this._modelDB.connected.then(() => {
  187. if (this._modelDB.isPrepopulated) {
  188. this.save();
  189. return void 0;
  190. } else {
  191. return this.revert();
  192. }
  193. });
  194. } else {
  195. return this.revert();
  196. }
  197. }
  198. /**
  199. * Save the document contents to disk.
  200. */
  201. save(): Promise<void> {
  202. let model = this._model;
  203. let content: JSONValue;
  204. if (this._factory.fileFormat === 'json') {
  205. content = model.toJSON();
  206. } else {
  207. content = model.toString();
  208. }
  209. let options = {
  210. type: this._factory.contentType,
  211. format: this._factory.fileFormat,
  212. content
  213. };
  214. return this._manager.ready.then(() => {
  215. if (!model.modelDB.isCollaborative) {
  216. return this._maybeSave(options);
  217. }
  218. return this._manager.contents.save(this._path, options);
  219. }).then(value => {
  220. if (this.isDisposed) {
  221. return;
  222. }
  223. model.dirty = false;
  224. this._updateContentsModel(value);
  225. if (!this._isPopulated) {
  226. return this._populate();
  227. }
  228. }).catch(err => {
  229. // If the save has been canceled by the user,
  230. // throw the error so that whoever called save()
  231. // can decide what to do.
  232. if (err.message === 'Cancel') {
  233. throw err;
  234. }
  235. // Otherwise show an error message and throw the error.
  236. const localPath = this._manager.contents.localPath(this._path);
  237. const name = PathExt.basename(localPath);
  238. this._handleError(err, `File Save Error for ${name}`);
  239. throw err;
  240. });
  241. }
  242. /**
  243. * Save the document to a different path chosen by the user.
  244. */
  245. saveAs(): Promise<void> {
  246. return Private.getSavePath(this._path).then(newPath => {
  247. if (this.isDisposed || !newPath) {
  248. return;
  249. }
  250. if (newPath === this._path) {
  251. return this.save();
  252. }
  253. // Make sure the path does not exist.
  254. return this._manager.ready.then(() => {
  255. return this._manager.contents.get(newPath);
  256. }).then(() => {
  257. return this._maybeOverWrite(newPath);
  258. }).catch(err => {
  259. if (!err.response || err.response.status !== 404) {
  260. throw err;
  261. }
  262. return this._finishSaveAs(newPath);
  263. });
  264. });
  265. }
  266. /**
  267. * Revert the document contents to disk contents.
  268. */
  269. revert(): Promise<void> {
  270. let opts: Contents.IFetchOptions = {
  271. format: this._factory.fileFormat,
  272. type: this._factory.contentType,
  273. content: true
  274. };
  275. let path = this._path;
  276. let model = this._model;
  277. return this._manager.ready.then(() => {
  278. return this._manager.contents.get(path, opts);
  279. }).then(contents => {
  280. if (this.isDisposed) {
  281. return;
  282. }
  283. let dirty = false;
  284. if (contents.format === 'json') {
  285. model.fromJSON(contents.content);
  286. } else {
  287. let content = contents.content;
  288. // Convert line endings if necessary, marking the file
  289. // as dirty.
  290. if (content.indexOf('\r') !== -1) {
  291. dirty = true;
  292. content = content.replace(/\r\n|\r/g, '\n');
  293. }
  294. model.fromString(content);
  295. }
  296. this._updateContentsModel(contents);
  297. model.dirty = dirty;
  298. if (!this._isPopulated) {
  299. return this._populate();
  300. }
  301. }).catch(err => {
  302. const localPath = this._manager.contents.localPath(this._path);
  303. const name = PathExt.basename(localPath);
  304. this._handleError(err, `File Load Error for ${name}`);
  305. throw err;
  306. });
  307. }
  308. /**
  309. * Create a checkpoint for the file.
  310. */
  311. createCheckpoint(): Promise<Contents.ICheckpointModel> {
  312. let contents = this._manager.contents;
  313. return this._manager.ready.then(() => {
  314. return contents.createCheckpoint(this._path);
  315. });
  316. }
  317. /**
  318. * Delete a checkpoint for the file.
  319. */
  320. deleteCheckpoint(checkpointId: string): Promise<void> {
  321. let contents = this._manager.contents;
  322. return this._manager.ready.then(() => {
  323. return contents.deleteCheckpoint(this._path, checkpointId);
  324. });
  325. }
  326. /**
  327. * Restore the file to a known checkpoint state.
  328. */
  329. restoreCheckpoint(checkpointId?: string): Promise<void> {
  330. let contents = this._manager.contents;
  331. let path = this._path;
  332. return this._manager.ready.then(() => {
  333. if (checkpointId) {
  334. return contents.restoreCheckpoint(path, checkpointId);
  335. }
  336. return this.listCheckpoints().then(checkpoints => {
  337. if (this.isDisposed || !checkpoints.length) {
  338. return;
  339. }
  340. checkpointId = checkpoints[checkpoints.length - 1].id;
  341. return contents.restoreCheckpoint(path, checkpointId);
  342. });
  343. });
  344. }
  345. /**
  346. * List available checkpoints for a file.
  347. */
  348. listCheckpoints(): Promise<Contents.ICheckpointModel[]> {
  349. let contents = this._manager.contents;
  350. return this._manager.ready.then(() => {
  351. return contents.listCheckpoints(this._path);
  352. });
  353. }
  354. /**
  355. * Add a sibling widget to the document manager.
  356. *
  357. * @param widget - The widget to add to the document manager.
  358. *
  359. * @param options - The desired options for adding the sibling.
  360. *
  361. * @returns A disposable used to remove the sibling if desired.
  362. *
  363. * #### Notes
  364. * It is assumed that the widget has the same model and context
  365. * as the original widget.
  366. */
  367. addSibling(widget: Widget, options: DocumentRegistry.IOpenOptions = {}): IDisposable {
  368. let opener = this._opener;
  369. if (opener) {
  370. opener(widget, options);
  371. }
  372. return new DisposableDelegate(() => {
  373. widget.close();
  374. });
  375. }
  376. /**
  377. * Handle a change on the contents manager.
  378. */
  379. private _onFileChanged(sender: Contents.IManager, change: Contents.IChangedArgs): void {
  380. if (change.type !== 'rename') {
  381. return;
  382. }
  383. let oldPath = change.oldValue && change.oldValue.path;
  384. let newPath = change.newValue && change.newValue.path;
  385. if (newPath && oldPath === this._path) {
  386. this.session.setPath(newPath);
  387. const localPath = this._manager.contents.localPath(newPath);
  388. this.session.setName(PathExt.basename(localPath));
  389. this._path = newPath;
  390. this._updateContentsModel(change.newValue as Contents.IModel);
  391. this._pathChanged.emit(this._path);
  392. }
  393. }
  394. /**
  395. * Handle a change to a session property.
  396. */
  397. private _onSessionChanged(sender: IClientSession, type: string): void {
  398. if (type !== 'path') {
  399. return;
  400. }
  401. let path = this.session.path;
  402. if (path !== this._path) {
  403. this._path = path;
  404. this._pathChanged.emit(path);
  405. }
  406. }
  407. /**
  408. * Update our contents model, without the content.
  409. */
  410. private _updateContentsModel(model: Contents.IModel): void {
  411. let newModel: Contents.IModel = {
  412. path: model.path,
  413. name: model.name,
  414. type: model.type,
  415. content: undefined,
  416. writable: model.writable,
  417. created: model.created,
  418. last_modified: model.last_modified,
  419. mimetype: model.mimetype,
  420. format: model.format
  421. };
  422. let mod = this._contentsModel ? this._contentsModel.last_modified : null;
  423. this._contentsModel = newModel;
  424. if (!mod || newModel.last_modified !== mod) {
  425. this._fileChanged.emit(newModel);
  426. }
  427. }
  428. /**
  429. * Handle an initial population.
  430. */
  431. private _populate(): Promise<void> {
  432. this._isPopulated = true;
  433. // Add a checkpoint if none exists and the file is writable.
  434. return this._maybeCheckpoint(false).then(() => {
  435. if (this.isDisposed) {
  436. return;
  437. }
  438. // Update the kernel preference.
  439. let name = (
  440. this._model.defaultKernelName || this.session.kernelPreference.name
  441. );
  442. this.session.kernelPreference = {
  443. ...this.session.kernelPreference,
  444. name,
  445. language: this._model.defaultKernelLanguage,
  446. };
  447. return this.session.initialize();
  448. }).then(() => {
  449. this._isReady = true;
  450. this._populatedPromise.resolve(void 0);
  451. });
  452. }
  453. /**
  454. * Save a file, dealing with conflicts.
  455. */
  456. private _maybeSave(options: Partial<Contents.IModel>): Promise<Contents.IModel> {
  457. let path = this._path;
  458. // Make sure the file has not changed on disk.
  459. let promise = this._manager.contents.get(path, { content: false });
  460. return promise.then(model => {
  461. if (this.isDisposed) {
  462. return Promise.reject(new Error('Disposed'));
  463. }
  464. // We want to check last_modified (disk) > last_modified (client)
  465. // (our last save)
  466. // In some cases the filesystem reports an inconsistent time,
  467. // so we allow 0.5 seconds difference before complaining.
  468. let modified = this.contentsModel && this.contentsModel.last_modified;
  469. let tClient = new Date(modified);
  470. let tDisk = new Date(model.last_modified);
  471. if (modified && (tDisk.getTime() - tClient.getTime()) > 500) { // 500 ms
  472. return this._timeConflict(tClient, model, options);
  473. }
  474. return this._manager.contents.save(path, options);
  475. }, (err) => {
  476. if (err.response && err.response.status === 404) {
  477. return this._manager.contents.save(path, options);
  478. }
  479. throw err;
  480. });
  481. }
  482. /**
  483. * Handle a save/load error with a dialog.
  484. */
  485. private _handleError(err: Error | ServerConnection.ResponseError, title: string): void {
  486. let buttons = [Dialog.okButton()];
  487. // Check for a more specific error message.
  488. if (err instanceof ServerConnection.ResponseError) {
  489. err.response.text().then(text => {
  490. let body = '';
  491. try {
  492. body = JSON.parse(text).message;
  493. } catch (e) {
  494. body = text;
  495. }
  496. body = body || err.message;
  497. showDialog({ title, body, buttons });
  498. });
  499. } else {
  500. let body = err.message;
  501. showDialog({ title, body, buttons });
  502. }
  503. }
  504. /**
  505. * Add a checkpoint the file is writable.
  506. */
  507. private _maybeCheckpoint(force: boolean): Promise<void> {
  508. let writable = this._contentsModel && this._contentsModel.writable;
  509. let promise = Promise.resolve(void 0);
  510. if (!writable) {
  511. return promise;
  512. }
  513. if (force) {
  514. promise = this.createCheckpoint();
  515. } else {
  516. promise = this.listCheckpoints().then(checkpoints => {
  517. writable = this._contentsModel && this._contentsModel.writable;
  518. if (!this.isDisposed && !checkpoints.length && writable) {
  519. return this.createCheckpoint().then(() => { /* no-op */ });
  520. }
  521. });
  522. }
  523. return promise.catch(err => {
  524. // Handle a read-only folder.
  525. if (!err.response || err.response.status !== 403) {
  526. throw err;
  527. }
  528. });
  529. }
  530. /**
  531. * Handle a time conflict.
  532. */
  533. private _timeConflict(tClient: Date, model: Contents.IModel, options: Partial<Contents.IModel>): Promise<Contents.IModel> {
  534. let tDisk = new Date(model.last_modified);
  535. console.warn(`Last saving peformed ${tClient} ` +
  536. `while the current file seems to have been saved ` +
  537. `${tDisk}`);
  538. let body = `The file has changed on disk since the last time it ` +
  539. `ws opened or saved. ` +
  540. `Do you want to overwrite the file on disk with the version ` +
  541. ` open here, or load the version on disk (revert)?`;
  542. let revertBtn = Dialog.okButton({ label: 'REVERT' });
  543. let overwriteBtn = Dialog.warnButton({ label: 'OVERWRITE' });
  544. return showDialog({
  545. title: 'File Changed', body,
  546. buttons: [Dialog.cancelButton(), revertBtn, overwriteBtn]
  547. }).then(result => {
  548. if (this.isDisposed) {
  549. return Promise.reject(new Error('Disposed'));
  550. }
  551. if (result.button.label === 'OVERWRITE') {
  552. return this._manager.contents.save(this._path, options);
  553. }
  554. if (result.button.label === 'REVERT') {
  555. return this.revert().then(() => { return model; });
  556. }
  557. return Promise.reject(new Error('Cancel')); // Otherwise cancel the save.
  558. });
  559. }
  560. /**
  561. * Handle a time conflict.
  562. */
  563. private _maybeOverWrite(path: string): Promise<void> {
  564. let body = `"${path}" already exists. Do you want to replace it?`;
  565. let overwriteBtn = Dialog.warnButton({ label: 'OVERWRITE' });
  566. return showDialog({
  567. title: 'File Overwrite?', body,
  568. buttons: [Dialog.cancelButton(), overwriteBtn]
  569. }).then(result => {
  570. if (this.isDisposed) {
  571. return Promise.reject(new Error('Disposed'));
  572. }
  573. if (result.button.label === 'OVERWRITE') {
  574. return this._manager.contents.delete(path).then(() => {
  575. this._finishSaveAs(path);
  576. });
  577. }
  578. });
  579. }
  580. /**
  581. * Finish a saveAs operation given a new path.
  582. */
  583. private _finishSaveAs(newPath: string): Promise<void> {
  584. this._path = newPath;
  585. return this.session.setPath(newPath).then(() => {
  586. this.session.setName(newPath.split('/').pop()!);
  587. return this.save();
  588. }).then(() => {
  589. this._pathChanged.emit(this._path);
  590. return this._maybeCheckpoint(true);
  591. });
  592. }
  593. private _manager: ServiceManager.IManager;
  594. private _opener: (widget: Widget, options?: DocumentRegistry.IOpenOptions) => void;
  595. private _model: T;
  596. private _modelDB: IModelDB;
  597. private _path = '';
  598. private _factory: DocumentRegistry.IModelFactory<T>;
  599. private _contentsModel: Contents.IModel | null = null;
  600. private _readyPromise: Promise<void>;
  601. private _populatedPromise = new PromiseDelegate<void>();
  602. private _isPopulated = false;
  603. private _isReady = false;
  604. private _isDisposed = false;
  605. private _pathChanged = new Signal<this, string>(this);
  606. private _fileChanged = new Signal<this, Contents.IModel>(this);
  607. private _disposed = new Signal<this, void>(this);
  608. }
  609. /**
  610. * A namespace for `Context` statics.
  611. */
  612. export namespace Context {
  613. /**
  614. * The options used to initialize a context.
  615. */
  616. export
  617. interface IOptions<T extends DocumentRegistry.IModel> {
  618. /**
  619. * A service manager instance.
  620. */
  621. manager: ServiceManager.IManager;
  622. /**
  623. * The model factory used to create the model.
  624. */
  625. factory: DocumentRegistry.IModelFactory<T>;
  626. /**
  627. * The initial path of the file.
  628. */
  629. path: string;
  630. /**
  631. * The kernel preference associated with the context.
  632. */
  633. kernelPreference?: IClientSession.IKernelPreference;
  634. /**
  635. * An IModelDB factory method which may be used for the document.
  636. */
  637. modelDBFactory?: ModelDB.IFactory;
  638. /**
  639. * An optional callback for opening sibling widgets.
  640. */
  641. opener?: (widget: Widget) => void;
  642. }
  643. }
  644. /**
  645. * A namespace for private data.
  646. */
  647. namespace Private {
  648. /**
  649. * Get a new file path from the user.
  650. */
  651. export
  652. function getSavePath(path: string): Promise<string | undefined> {
  653. let saveBtn = Dialog.okButton({ label: 'SAVE' });
  654. return showDialog({
  655. title: 'Save File As..',
  656. body: new SaveWidget(path),
  657. buttons: [Dialog.cancelButton(), saveBtn]
  658. }).then(result => {
  659. if (result.button.label === 'SAVE') {
  660. return result.value;
  661. }
  662. return;
  663. });
  664. }
  665. /**
  666. * A no-op function.
  667. */
  668. export
  669. function noOp() { /* no-op */ }
  670. /*
  671. * A widget that gets a file path from a user.
  672. */
  673. class SaveWidget extends Widget {
  674. /**
  675. * Construct a new save widget.
  676. */
  677. constructor(path: string) {
  678. super({ node: createSaveNode(path) });
  679. }
  680. /**
  681. * Get the value for the widget.
  682. */
  683. getValue(): string {
  684. return (this.node as HTMLInputElement).value;
  685. }
  686. }
  687. /**
  688. * Create the node for a save widget.
  689. */
  690. function createSaveNode(path: string): HTMLElement {
  691. let input = document.createElement('input');
  692. input.value = path;
  693. return input;
  694. }
  695. }