model.ts 19 KB

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