model.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. IChangedArgs,
  5. IStateDB,
  6. PathExt,
  7. PageConfig,
  8. Poll
  9. } from '@jupyterlab/coreutils';
  10. import { IDocumentManager, shouldOverwrite } from '@jupyterlab/docmanager';
  11. import { Contents, Kernel, Session } from '@jupyterlab/services';
  12. import {
  13. ArrayIterator,
  14. each,
  15. find,
  16. IIterator,
  17. IterableOrArrayLike,
  18. ArrayExt
  19. } from '@phosphor/algorithm';
  20. import { PromiseDelegate, ReadonlyJSONObject } from '@phosphor/coreutils';
  21. import { IDisposable } from '@phosphor/disposable';
  22. import { ISignal, Signal } from '@phosphor/signaling';
  23. import { showDialog, Dialog } from '@jupyterlab/apputils';
  24. /**
  25. * The default duration of the auto-refresh in ms
  26. */
  27. const DEFAULT_REFRESH_INTERVAL = 10000;
  28. /**
  29. * The maximum upload size (in bytes) for notebook version < 5.1.0
  30. */
  31. export const LARGE_FILE_SIZE = 15 * 1024 * 1024;
  32. /**
  33. * The size (in bytes) of the biggest chunk we should upload at once.
  34. */
  35. export const CHUNK_SIZE = 1024 * 1024;
  36. /**
  37. * An upload progress event for a file at `path`.
  38. */
  39. export interface IUploadModel {
  40. path: string;
  41. /**
  42. * % uploaded [0, 1)
  43. */
  44. progress: number;
  45. }
  46. /**
  47. * An implementation of a file browser model.
  48. *
  49. * #### Notes
  50. * All paths parameters without a leading `'/'` are interpreted as relative to
  51. * the current directory. Supports `'../'` syntax.
  52. */
  53. export class FileBrowserModel implements IDisposable {
  54. /**
  55. * Construct a new file browser model.
  56. */
  57. constructor(options: FileBrowserModel.IOptions) {
  58. this.manager = options.manager;
  59. this._driveName = options.driveName || '';
  60. let rootPath = this._driveName ? this._driveName + ':' : '';
  61. this._model = {
  62. path: rootPath,
  63. name: PathExt.basename(rootPath),
  64. type: 'directory',
  65. content: undefined,
  66. writable: false,
  67. created: 'unknown',
  68. last_modified: 'unknown',
  69. mimetype: 'text/plain',
  70. format: 'text'
  71. };
  72. this._state = options.state || null;
  73. const refreshInterval = options.refreshInterval || DEFAULT_REFRESH_INTERVAL;
  74. const { services } = options.manager;
  75. services.contents.fileChanged.connect(this._onFileChanged, this);
  76. services.sessions.runningChanged.connect(this._onRunningChanged, this);
  77. this._unloadEventListener = (e: Event) => {
  78. if (this._uploads.length > 0) {
  79. const confirmationMessage = 'Files still uploading';
  80. (e as any).returnValue = confirmationMessage;
  81. return confirmationMessage;
  82. }
  83. };
  84. window.addEventListener('beforeunload', this._unloadEventListener);
  85. this._poll = new Poll({
  86. factory: () => this.cd('.'),
  87. frequency: {
  88. interval: refreshInterval,
  89. backoff: true,
  90. max: 300 * 1000
  91. },
  92. standby: 'when-hidden'
  93. });
  94. }
  95. /**
  96. * The document manager instance used by the file browser model.
  97. */
  98. readonly manager: IDocumentManager;
  99. /**
  100. * A signal emitted when the file browser model loses connection.
  101. */
  102. get connectionFailure(): ISignal<this, Error> {
  103. return this._connectionFailure;
  104. }
  105. /**
  106. * The drive name that gets prepended to the path.
  107. */
  108. get driveName(): string {
  109. return this._driveName;
  110. }
  111. /**
  112. * A promise that resolves when the model is first restored.
  113. */
  114. get restored(): Promise<void> {
  115. return this._restored.promise;
  116. }
  117. /**
  118. * Get the file path changed signal.
  119. */
  120. get fileChanged(): ISignal<this, Contents.IChangedArgs> {
  121. return this._fileChanged;
  122. }
  123. /**
  124. * Get the current path.
  125. */
  126. get path(): string {
  127. return this._model ? this._model.path : '';
  128. }
  129. /**
  130. * A signal emitted when the path changes.
  131. */
  132. get pathChanged(): ISignal<this, IChangedArgs<string>> {
  133. return this._pathChanged;
  134. }
  135. /**
  136. * A signal emitted when the directory listing is refreshed.
  137. */
  138. get refreshed(): ISignal<this, void> {
  139. return this._refreshed;
  140. }
  141. /**
  142. * Get the kernel spec models.
  143. */
  144. get specs(): Kernel.ISpecModels | null {
  145. return this.manager.services.sessions.specs;
  146. }
  147. /**
  148. * Get whether the model is disposed.
  149. */
  150. get isDisposed(): boolean {
  151. return this._isDisposed;
  152. }
  153. /**
  154. * A signal emitted when an upload progresses.
  155. */
  156. get uploadChanged(): ISignal<this, IChangedArgs<IUploadModel>> {
  157. return this._uploadChanged;
  158. }
  159. /**
  160. * Create an iterator over the status of all in progress uploads.
  161. */
  162. uploads(): IIterator<IUploadModel> {
  163. return new ArrayIterator(this._uploads);
  164. }
  165. /**
  166. * Dispose of the resources held by the model.
  167. */
  168. dispose(): void {
  169. if (this.isDisposed) {
  170. return;
  171. }
  172. window.removeEventListener('beforeunload', this._unloadEventListener);
  173. this._isDisposed = true;
  174. this._poll.dispose();
  175. this._sessions.length = 0;
  176. this._items.length = 0;
  177. Signal.clearData(this);
  178. }
  179. /**
  180. * Create an iterator over the model's items.
  181. *
  182. * @returns A new iterator over the model's items.
  183. */
  184. items(): IIterator<Contents.IModel> {
  185. return new ArrayIterator(this._items);
  186. }
  187. /**
  188. * Create an iterator over the active sessions in the directory.
  189. *
  190. * @returns A new iterator over the model's active sessions.
  191. */
  192. sessions(): IIterator<Session.IModel> {
  193. return new ArrayIterator(this._sessions);
  194. }
  195. /**
  196. * Force a refresh of the directory contents.
  197. */
  198. async refresh(): Promise<void> {
  199. await this._poll.refresh();
  200. await this._poll.tick;
  201. }
  202. /**
  203. * Change directory.
  204. *
  205. * @param path - The path to the file or directory.
  206. *
  207. * @returns A promise with the contents of the directory.
  208. */
  209. async cd(newValue = '.'): Promise<void> {
  210. if (newValue !== '.') {
  211. newValue = Private.normalizePath(
  212. this.manager.services.contents,
  213. this._model.path,
  214. newValue
  215. );
  216. } else {
  217. newValue = this._pendingPath || this._model.path;
  218. }
  219. if (this._pending) {
  220. // Collapse requests to the same directory.
  221. if (newValue === this._pendingPath) {
  222. return this._pending;
  223. }
  224. // Otherwise wait for the pending request to complete before continuing.
  225. await this._pending;
  226. }
  227. let oldValue = this.path;
  228. let options: Contents.IFetchOptions = { content: true };
  229. this._pendingPath = newValue;
  230. if (oldValue !== newValue) {
  231. this._sessions.length = 0;
  232. }
  233. let services = this.manager.services;
  234. this._pending = services.contents
  235. .get(newValue, options)
  236. .then(contents => {
  237. if (this.isDisposed) {
  238. return;
  239. }
  240. this._handleContents(contents);
  241. this._pendingPath = null;
  242. this._pending = null;
  243. if (oldValue !== newValue) {
  244. // If there is a state database and a unique key, save the new path.
  245. // We don't need to wait on the save to continue.
  246. if (this._state && this._key) {
  247. void this._state.save(this._key, { path: newValue });
  248. }
  249. this._pathChanged.emit({
  250. name: 'path',
  251. oldValue,
  252. newValue
  253. });
  254. }
  255. this._onRunningChanged(services.sessions, services.sessions.running());
  256. this._refreshed.emit(void 0);
  257. })
  258. .catch(error => {
  259. this._pendingPath = null;
  260. if (error.message === 'Not Found') {
  261. error.message = `Directory not found: "${this._model.path}"`;
  262. console.error(error);
  263. this._connectionFailure.emit(error);
  264. return this.cd('/');
  265. } else {
  266. this._connectionFailure.emit(error);
  267. }
  268. });
  269. return this._pending;
  270. }
  271. /**
  272. * Download a file.
  273. *
  274. * @param path - The path of the file to be downloaded.
  275. *
  276. * @returns A promise which resolves when the file has begun
  277. * downloading.
  278. */
  279. download(path: string): Promise<void> {
  280. return this.manager.services.contents.getDownloadUrl(path).then(url => {
  281. let element = document.createElement('a');
  282. document.body.appendChild(element);
  283. element.setAttribute('href', url);
  284. element.setAttribute('target', '_blank');
  285. element.click();
  286. document.body.removeChild(element);
  287. return void 0;
  288. });
  289. }
  290. /**
  291. * Restore the state of the file browser.
  292. *
  293. * @param id - The unique ID that is used to construct a state database key.
  294. *
  295. * @returns A promise when restoration is complete.
  296. *
  297. * #### Notes
  298. * This function will only restore the model *once*. If it is called multiple
  299. * times, all subsequent invocations are no-ops.
  300. */
  301. restore(id: string): Promise<void> {
  302. const state = this._state;
  303. const restored = !!this._key;
  304. if (!state || restored) {
  305. return Promise.resolve(void 0);
  306. }
  307. const manager = this.manager;
  308. const key = `file-browser-${id}:cwd`;
  309. const ready = manager.services.ready;
  310. return Promise.all([state.fetch(key), ready])
  311. .then(([value]) => {
  312. if (!value) {
  313. this._restored.resolve(undefined);
  314. return;
  315. }
  316. const path = (value as ReadonlyJSONObject)['path'] as string;
  317. const localPath = manager.services.contents.localPath(path);
  318. return manager.services.contents
  319. .get(path)
  320. .then(() => this.cd(localPath))
  321. .catch(() => state.remove(key));
  322. })
  323. .catch(() => state.remove(key))
  324. .then(() => {
  325. this._key = key;
  326. this._restored.resolve(undefined);
  327. }); // Set key after restoration is done.
  328. }
  329. /**
  330. * Upload a `File` object.
  331. *
  332. * @param file - The `File` object to upload.
  333. *
  334. * @returns A promise containing the new file contents model.
  335. *
  336. * #### Notes
  337. * On Notebook version < 5.1.0, this will fail to upload files that are too
  338. * big to be sent in one request to the server. On newer versions, it will
  339. * ask for confirmation then upload the file in 1 MB chunks.
  340. */
  341. async upload(file: File): Promise<Contents.IModel> {
  342. const supportsChunked = PageConfig.getNotebookVersion() >= [5, 1, 0];
  343. const largeFile = file.size > LARGE_FILE_SIZE;
  344. if (largeFile && !supportsChunked) {
  345. let msg = `Cannot upload file (>${LARGE_FILE_SIZE / (1024 * 1024)} MB). ${
  346. file.name
  347. }`;
  348. console.warn(msg);
  349. throw msg;
  350. }
  351. const err = 'File not uploaded';
  352. if (largeFile && !(await this._shouldUploadLarge(file))) {
  353. throw 'Cancelled large file upload';
  354. }
  355. await this._uploadCheckDisposed();
  356. await this.refresh();
  357. await this._uploadCheckDisposed();
  358. if (
  359. find(this._items, i => i.name === file.name) &&
  360. !(await shouldOverwrite(file.name))
  361. ) {
  362. throw err;
  363. }
  364. await this._uploadCheckDisposed();
  365. const chunkedUpload = supportsChunked && file.size > CHUNK_SIZE;
  366. return await this._upload(file, chunkedUpload);
  367. }
  368. private async _shouldUploadLarge(file: File): Promise<boolean> {
  369. const { button } = await showDialog({
  370. title: 'Large file size warning',
  371. body: `The file size is ${Math.round(
  372. file.size / (1024 * 1024)
  373. )} MB. Do you still want to upload it?`,
  374. buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'UPLOAD' })]
  375. });
  376. return button.accept;
  377. }
  378. /**
  379. * Perform the actual upload.
  380. */
  381. private async _upload(
  382. file: File,
  383. chunked: boolean
  384. ): Promise<Contents.IModel> {
  385. // Gather the file model parameters.
  386. let path = this._model.path;
  387. path = path ? path + '/' + file.name : file.name;
  388. let name = file.name;
  389. let type: Contents.ContentType = 'file';
  390. let format: Contents.FileFormat = 'base64';
  391. const uploadInner = async (
  392. blob: Blob,
  393. chunk?: number
  394. ): Promise<Contents.IModel> => {
  395. await this._uploadCheckDisposed();
  396. let reader = new FileReader();
  397. reader.readAsDataURL(blob);
  398. await new Promise((resolve, reject) => {
  399. reader.onload = resolve;
  400. reader.onerror = event =>
  401. reject(`Failed to upload "${file.name}":` + event);
  402. });
  403. await this._uploadCheckDisposed();
  404. // remove header https://stackoverflow.com/a/24289420/907060
  405. const content = (reader.result as string).split(',')[1];
  406. let model: Partial<Contents.IModel> = {
  407. type,
  408. format,
  409. name,
  410. chunk,
  411. content
  412. };
  413. return await this.manager.services.contents.save(path, model);
  414. };
  415. if (!chunked) {
  416. try {
  417. return await uploadInner(file);
  418. } catch (err) {
  419. ArrayExt.removeFirstWhere(this._uploads, uploadIndex => {
  420. return file.name === uploadIndex.path;
  421. });
  422. throw err;
  423. }
  424. }
  425. let finalModel: Contents.IModel;
  426. let upload = { path, progress: 0 };
  427. this._uploadChanged.emit({
  428. name: 'start',
  429. newValue: upload,
  430. oldValue: null
  431. });
  432. for (let start = 0; !finalModel; start += CHUNK_SIZE) {
  433. const end = start + CHUNK_SIZE;
  434. const lastChunk = end >= file.size;
  435. const chunk = lastChunk ? -1 : end / CHUNK_SIZE;
  436. const newUpload = { path, progress: start / file.size };
  437. this._uploads.splice(this._uploads.indexOf(upload));
  438. this._uploads.push(newUpload);
  439. this._uploadChanged.emit({
  440. name: 'update',
  441. newValue: newUpload,
  442. oldValue: upload
  443. });
  444. upload = newUpload;
  445. let currentModel: Contents.IModel;
  446. try {
  447. currentModel = await uploadInner(file.slice(start, end), chunk);
  448. } catch (err) {
  449. ArrayExt.removeFirstWhere(this._uploads, uploadIndex => {
  450. return file.name === uploadIndex.path;
  451. });
  452. this._uploadChanged.emit({
  453. name: 'failure',
  454. newValue: upload,
  455. oldValue: null
  456. });
  457. throw err;
  458. }
  459. if (lastChunk) {
  460. finalModel = currentModel;
  461. }
  462. }
  463. this._uploads.splice(this._uploads.indexOf(upload));
  464. this._uploadChanged.emit({
  465. name: 'finish',
  466. newValue: null,
  467. oldValue: upload
  468. });
  469. return finalModel;
  470. }
  471. private _uploadCheckDisposed(): Promise<void> {
  472. if (this.isDisposed) {
  473. return Promise.reject('Filemanager disposed. File upload canceled');
  474. }
  475. return Promise.resolve();
  476. }
  477. /**
  478. * Handle an updated contents model.
  479. */
  480. private _handleContents(contents: Contents.IModel): void {
  481. // Update our internal data.
  482. this._model = {
  483. name: contents.name,
  484. path: contents.path,
  485. type: contents.type,
  486. content: undefined,
  487. writable: contents.writable,
  488. created: contents.created,
  489. last_modified: contents.last_modified,
  490. mimetype: contents.mimetype,
  491. format: contents.format
  492. };
  493. this._items = contents.content;
  494. this._paths.clear();
  495. contents.content.forEach((model: Contents.IModel) => {
  496. this._paths.add(model.path);
  497. });
  498. }
  499. /**
  500. * Handle a change to the running sessions.
  501. */
  502. private _onRunningChanged(
  503. sender: Session.IManager,
  504. models: IterableOrArrayLike<Session.IModel>
  505. ): void {
  506. this._populateSessions(models);
  507. this._refreshed.emit(void 0);
  508. }
  509. /**
  510. * Handle a change on the contents manager.
  511. */
  512. private _onFileChanged(
  513. sender: Contents.IManager,
  514. change: Contents.IChangedArgs
  515. ): void {
  516. let path = this._model.path;
  517. let { sessions } = this.manager.services;
  518. let { oldValue, newValue } = change;
  519. let value =
  520. oldValue && oldValue.path && PathExt.dirname(oldValue.path) === path
  521. ? oldValue
  522. : newValue && newValue.path && PathExt.dirname(newValue.path) === path
  523. ? newValue
  524. : undefined;
  525. // If either the old value or the new value is in the current path, update.
  526. if (value) {
  527. void this._poll.refresh();
  528. this._populateSessions(sessions.running());
  529. this._fileChanged.emit(change);
  530. return;
  531. }
  532. }
  533. /**
  534. * Populate the model's sessions collection.
  535. */
  536. private _populateSessions(models: IterableOrArrayLike<Session.IModel>): void {
  537. this._sessions.length = 0;
  538. each(models, model => {
  539. if (this._paths.has(model.path)) {
  540. this._sessions.push(model);
  541. }
  542. });
  543. }
  544. private _connectionFailure = new Signal<this, Error>(this);
  545. private _fileChanged = new Signal<this, Contents.IChangedArgs>(this);
  546. private _items: Contents.IModel[] = [];
  547. private _key: string = '';
  548. private _model: Contents.IModel;
  549. private _pathChanged = new Signal<this, IChangedArgs<string>>(this);
  550. private _paths = new Set<string>();
  551. private _pending: Promise<void> | null = null;
  552. private _pendingPath: string | null = null;
  553. private _refreshed = new Signal<this, void>(this);
  554. private _sessions: Session.IModel[] = [];
  555. private _state: IStateDB | null = null;
  556. private _driveName: string;
  557. private _isDisposed = false;
  558. private _restored = new PromiseDelegate<void>();
  559. private _uploads: IUploadModel[] = [];
  560. private _uploadChanged = new Signal<this, IChangedArgs<IUploadModel>>(this);
  561. private _unloadEventListener: (e: Event) => string;
  562. private _poll: Poll;
  563. }
  564. /**
  565. * The namespace for the `FileBrowserModel` class statics.
  566. */
  567. export namespace FileBrowserModel {
  568. /**
  569. * An options object for initializing a file browser.
  570. */
  571. export interface IOptions {
  572. /**
  573. * A document manager instance.
  574. */
  575. manager: IDocumentManager;
  576. /**
  577. * An optional `Contents.IDrive` name for the model.
  578. * If given, the model will prepend `driveName:` to
  579. * all paths used in file operations.
  580. */
  581. driveName?: string;
  582. /**
  583. * An optional state database. If provided, the model will restore which
  584. * folder was last opened when it is restored.
  585. */
  586. state?: IStateDB;
  587. /**
  588. * The time interval for browser refreshing, in ms.
  589. */
  590. refreshInterval?: number;
  591. }
  592. }
  593. /**
  594. * The namespace for the file browser model private data.
  595. */
  596. namespace Private {
  597. /**
  598. * Normalize a path based on a root directory, accounting for relative paths.
  599. */
  600. export function normalizePath(
  601. contents: Contents.IManager,
  602. root: string,
  603. path: string
  604. ): string {
  605. const driveName = contents.driveName(root);
  606. const localPath = contents.localPath(root);
  607. const resolved = PathExt.resolve(localPath, path);
  608. return driveName ? `${driveName}:${resolved}` : resolved;
  609. }
  610. }