model.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. Contents, Kernel, IServiceManager, Session
  5. } from 'jupyter-js-services';
  6. import {
  7. deepEqual
  8. } from 'phosphor/lib/algorithm/json';
  9. import {
  10. IDisposable
  11. } from 'phosphor/lib/core/disposable';
  12. import {
  13. clearSignalData, defineSignal, ISignal
  14. } from 'phosphor/lib/core/signaling';
  15. import {
  16. IChangedArgs
  17. } from '../common/interfaces';
  18. /**
  19. * An implementation of a file browser model.
  20. *
  21. * #### Notes
  22. * All paths parameters without a leading `'/'` are interpreted as relative to
  23. * the current directory. Supports `'../'` syntax.
  24. */
  25. export
  26. class FileBrowserModel implements IDisposable {
  27. /**
  28. * Construct a new file browser model.
  29. */
  30. constructor(options: FileBrowserModel.IOptions) {
  31. this._manager = options.manager;
  32. this._model = { path: '', name: '/', type: 'directory', content: [] };
  33. this.cd();
  34. this._manager.sessions.runningChanged.connect(this._onRunningChanged, this);
  35. }
  36. /**
  37. * A signal emitted when the path changes.
  38. */
  39. pathChanged: ISignal<FileBrowserModel, IChangedArgs<string>>;
  40. /**
  41. * Get the refreshed signal.
  42. */
  43. refreshed: ISignal<FileBrowserModel, void>;
  44. /**
  45. * Get the file path changed signal.
  46. */
  47. fileChanged: ISignal<FileBrowserModel, IChangedArgs<Contents.IModel>>;
  48. /**
  49. * Get the current path.
  50. *
  51. * #### Notes
  52. * This is a read-only property.
  53. */
  54. get path(): string {
  55. return this._model.path;
  56. }
  57. /**
  58. * Get a read-only list of the items in the current path.
  59. */
  60. get items(): Contents.IModel[] {
  61. return this._model.content ? this._model.content.slice() : [];
  62. }
  63. /**
  64. * Get whether the view model is disposed.
  65. */
  66. get isDisposed(): boolean {
  67. return this._model === null;
  68. }
  69. /**
  70. * Get the session models for active notebooks.
  71. *
  72. * #### Notes
  73. * This is a read-only property.
  74. */
  75. get sessions(): Session.IModel[] {
  76. return this._sessions.slice();
  77. }
  78. /**
  79. * Get the kernel specs.
  80. */
  81. get kernelspecs(): Kernel.ISpecModels {
  82. return this._manager.kernelspecs;
  83. }
  84. /**
  85. * Dispose of the resources held by the view model.
  86. */
  87. dispose(): void {
  88. this._model = null;
  89. this._manager = null;
  90. clearSignalData(this);
  91. }
  92. /**
  93. * Change directory.
  94. *
  95. * @param path - The path to the file or directory.
  96. *
  97. * @returns A promise with the contents of the directory.
  98. */
  99. cd(newValue = '.'): Promise<void> {
  100. if (newValue !== '.') {
  101. newValue = Private.normalizePath(this._model.path, newValue);
  102. }
  103. // Collapse requests to the same directory.
  104. if (newValue === this._pendingPath) {
  105. return Promise.resolve(void 0);
  106. }
  107. let oldValue = this.path;
  108. let options: Contents.IFetchOptions = { content: true };
  109. this._pendingPath = newValue;
  110. if (newValue === '.') {
  111. newValue = this.path;
  112. }
  113. if (oldValue !== newValue) {
  114. this._sessions = [];
  115. }
  116. this._pending = this._manager.contents.get(newValue, options).then(contents => {
  117. this._model = contents;
  118. return this._manager.sessions.listRunning();
  119. }).then(models => {
  120. this._onRunningChanged(this._manager.sessions, models);
  121. if (oldValue !== newValue) {
  122. this.pathChanged.emit({
  123. name: 'path',
  124. oldValue,
  125. newValue
  126. });
  127. }
  128. this.refreshed.emit(void 0);
  129. this._pendingPath = null;
  130. });
  131. return this._pending;
  132. }
  133. /**
  134. * Refresh the current directory.
  135. */
  136. refresh(): Promise<void> {
  137. return this.cd('.').catch(error => {
  138. console.error(error);
  139. let msg = 'Unable to refresh the directory listing due to ';
  140. msg += 'lost server connection.';
  141. error.message = msg;
  142. throw error;
  143. });
  144. }
  145. /**
  146. * Copy a file.
  147. *
  148. * @param fromFile - The path of the original file.
  149. *
  150. * @param toDir - The path to the target directory.
  151. *
  152. * @returns A promise which resolves to the contents of the file.
  153. */
  154. copy(fromFile: string, toDir: string): Promise<Contents.IModel> {
  155. let normalizePath = Private.normalizePath;
  156. fromFile = normalizePath(this._model.path, fromFile);
  157. toDir = normalizePath(this._model.path, toDir);
  158. return this._manager.contents.copy(fromFile, toDir).then(contents => {
  159. this.fileChanged.emit({
  160. name: 'file',
  161. oldValue: void 0,
  162. newValue: contents
  163. });
  164. return contents;
  165. });
  166. }
  167. /**
  168. * Delete a file.
  169. *
  170. * @param: path - The path to the file to be deleted.
  171. *
  172. * @returns A promise which resolves when the file is deleted.
  173. */
  174. deleteFile(path: string): Promise<void> {
  175. let normalizePath = Private.normalizePath;
  176. path = normalizePath(this._model.path, path);
  177. return this._manager.contents.delete(path).then(() => {
  178. this.fileChanged.emit({
  179. name: 'file',
  180. oldValue: {path: path},
  181. newValue: void 0
  182. });
  183. });
  184. }
  185. /**
  186. * Download a file.
  187. *
  188. * @param - path - The path of the file to be downloaded.
  189. */
  190. download(path: string): void {
  191. let url = this._manager.contents.getDownloadUrl(path);
  192. let element = document.createElement('a');
  193. element.setAttribute('href', url);
  194. element.setAttribute('download', '');
  195. element.click();
  196. }
  197. /**
  198. * Create a new untitled file or directory in the current directory.
  199. *
  200. * @param type - The type of file object to create. One of
  201. * `['file', 'notebook', 'directory']`.
  202. *
  203. * @param ext - Optional extension for `'file'` types (defaults to `'.txt'`).
  204. *
  205. * @returns A promise containing the new file contents model.
  206. */
  207. newUntitled(options: Contents.ICreateOptions): Promise<Contents.IModel> {
  208. if (options.type === 'file') {
  209. options.ext = options.ext || '.txt';
  210. }
  211. options.path = options.path || this._model.path;
  212. return this._manager.contents.newUntitled(options).then(contents => {
  213. this.fileChanged.emit({
  214. name: 'file',
  215. oldValue: void 0,
  216. newValue: contents
  217. });
  218. return contents;
  219. });
  220. }
  221. /**
  222. * Rename a file or directory.
  223. *
  224. * @param path - The path to the original file.
  225. *
  226. * @param newPath - The path to the new file.
  227. *
  228. * @returns A promise containing the new file contents model.
  229. */
  230. rename(path: string, newPath: string): Promise<Contents.IModel> {
  231. // Handle relative paths.
  232. let normalizePath = Private.normalizePath;
  233. path = normalizePath(this._model.path, path);
  234. newPath = normalizePath(this._model.path, newPath);
  235. return this._manager.contents.rename(path, newPath).then(contents => {
  236. this.fileChanged.emit({
  237. name: 'file',
  238. oldValue: {type: contents.type , path: path},
  239. newValue: contents
  240. });
  241. return contents;
  242. });
  243. }
  244. /**
  245. * Upload a `File` object.
  246. *
  247. * @param file - The `File` object to upload.
  248. *
  249. * @param overwrite - Whether to overwrite an existing file.
  250. *
  251. * @returns A promise containing the new file contents model.
  252. *
  253. * #### Notes
  254. * This will fail to upload files that are too big to be sent in one
  255. * request to the server.
  256. */
  257. upload(file: File, overwrite?: boolean): Promise<Contents.IModel> {
  258. // Skip large files with a warning.
  259. if (file.size > this._maxUploadSizeMb * 1024 * 1024) {
  260. let msg = `Cannot upload file (>${this._maxUploadSizeMb} MB) `;
  261. msg += `"${file.name}"`;
  262. console.warn(msg);
  263. return Promise.reject<Contents.IModel>(new Error(msg));
  264. }
  265. if (overwrite) {
  266. return this._upload(file);
  267. }
  268. let path = this._model.path;
  269. path = path ? path + '/' + file.name : file.name;
  270. return this._manager.contents.get(path, {}).then(() => {
  271. return Private.typedThrow<Contents.IModel>(`"${file.name}" already exists`);
  272. }, () => {
  273. return this._upload(file);
  274. });
  275. }
  276. /**
  277. * Shut down a session by session id.
  278. */
  279. shutdown(id: string): Promise<void> {
  280. return this._manager.sessions.shutdown(id);
  281. }
  282. /**
  283. * Perform the actual upload.
  284. */
  285. private _upload(file: File): Promise<Contents.IModel> {
  286. // Gather the file model parameters.
  287. let path = this._model.path;
  288. path = path ? path + '/' + file.name : file.name;
  289. let name = file.name;
  290. let isNotebook = file.name.indexOf('.ipynb') !== -1;
  291. let type: Contents.FileType = isNotebook ? 'notebook' : 'file';
  292. let format: Contents.FileFormat = isNotebook ? 'json' : 'base64';
  293. // Get the file content.
  294. let reader = new FileReader();
  295. if (isNotebook) {
  296. reader.readAsText(file);
  297. } else {
  298. reader.readAsArrayBuffer(file);
  299. }
  300. return new Promise<Contents.IModel>((resolve, reject) => {
  301. reader.onload = (event: Event) => {
  302. let model: Contents.IModel = {
  303. type: type,
  304. format,
  305. name,
  306. content: Private.getContent(reader)
  307. };
  308. this._manager.contents.save(path, model).then(contents => {
  309. this.fileChanged.emit({
  310. name: 'file',
  311. oldValue: void 0,
  312. newValue: contents
  313. });
  314. resolve(contents);
  315. });
  316. };
  317. reader.onerror = (event: Event) => {
  318. reject(Error(`Failed to upload "${file.name}":` + event));
  319. };
  320. });
  321. }
  322. /**
  323. * Handle a change to the running sessions.
  324. */
  325. private _onRunningChanged(sender: Session.IManager, models: Session.IModel[]): void {
  326. if (deepEqual(models, this._sessions)) {
  327. return;
  328. }
  329. this._sessions = [];
  330. if (!models.length) {
  331. this.refreshed.emit(void 0);
  332. return;
  333. }
  334. let paths = this._model.content.map((contents: Contents.IModel) => {
  335. return contents.path;
  336. });
  337. for (let model of models) {
  338. let index = paths.indexOf(model.notebook.path);
  339. if (index !== -1) {
  340. this._sessions.push(model);
  341. }
  342. }
  343. this.refreshed.emit(void 0);
  344. }
  345. private _maxUploadSizeMb = 15;
  346. private _manager: IServiceManager = null;
  347. private _sessions: Session.IModel[] = [];
  348. private _model: Contents.IModel;
  349. private _pendingPath: string = null;
  350. private _pending: Promise<void> = null;
  351. }
  352. // Define the signals for the `FileBrowserModel` class.
  353. defineSignal(FileBrowserModel.prototype, 'pathChanged');
  354. defineSignal(FileBrowserModel.prototype, 'refreshed');
  355. defineSignal(FileBrowserModel.prototype, 'fileChanged');
  356. /**
  357. * The namespace for the `FileBrowserModel` class statics.
  358. */
  359. export
  360. namespace FileBrowserModel {
  361. /**
  362. * An options object for initializing a file browser.
  363. */
  364. export
  365. interface IOptions {
  366. /**
  367. * A service manager instance.
  368. */
  369. manager: IServiceManager;
  370. }
  371. }
  372. /**
  373. * The namespace for the file browser model private data.
  374. */
  375. namespace Private {
  376. /**
  377. * Parse the content of a `FileReader`.
  378. *
  379. * If the result is an `ArrayBuffer`, return a Base64-encoded string.
  380. * Otherwise, return the JSON parsed result.
  381. */
  382. export
  383. function getContent(reader: FileReader): any {
  384. if (reader.result instanceof ArrayBuffer) {
  385. // Base64-encode binary file data.
  386. let bytes = '';
  387. let buf = new Uint8Array(reader.result);
  388. let nbytes = buf.byteLength;
  389. for (let i = 0; i < nbytes; i++) {
  390. bytes += String.fromCharCode(buf[i]);
  391. }
  392. return btoa(bytes);
  393. } else {
  394. return JSON.parse(reader.result);
  395. }
  396. }
  397. /**
  398. * Normalize a path based on a root directory, accounting for relative paths.
  399. */
  400. export
  401. function normalizePath(root: string, path: string): string {
  402. // Current directory
  403. if (path === '.') {
  404. return root;
  405. }
  406. // Root path.
  407. if (path.indexOf('/') === 0) {
  408. path = path.slice(1, path.length);
  409. root = '';
  410. // Current directory.
  411. } else if (path.indexOf('./') === 0) {
  412. path = path.slice(2, path.length);
  413. // Grandparent directory.
  414. } else if (path.indexOf('../../') === 0) {
  415. let parts = root.split('/');
  416. root = parts.splice(0, parts.length - 2).join('/');
  417. path = path.slice(6, path.length);
  418. // Parent directory.
  419. } else if (path.indexOf('../') === 0) {
  420. let parts = root.split('/');
  421. root = parts.splice(0, parts.length - 1).join('/');
  422. path = path.slice(3, path.length);
  423. } else {
  424. // Current directory.
  425. }
  426. if (path[path.length - 1] === '/') {
  427. path = path.slice(0, path.length - 1);
  428. }
  429. // Combine the root and the path if necessary.
  430. if (root && path) {
  431. path = root + '/' + path;
  432. } else if (root) {
  433. path = root;
  434. }
  435. return path;
  436. }
  437. /**
  438. * Work around TS 1.8 type inferencing in promises which only throw.
  439. */
  440. export
  441. function typedThrow<T>(msg: string): T {
  442. throw new Error(msg);
  443. }
  444. }