listing.ts 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. Dialog, DOMUtils, showDialog
  5. } from '@jupyterlab/apputils';
  6. import {
  7. PathExt, Time
  8. } from '@jupyterlab/coreutils';
  9. import {
  10. IDocumentManager, renameFileDialog
  11. } from '@jupyterlab/docmanager';
  12. import {
  13. Contents
  14. } from '@jupyterlab/services';
  15. import {
  16. ArrayExt, ArrayIterator, IIterator, each, filter, find, map, toArray
  17. } from '@phosphor/algorithm';
  18. import {
  19. MimeData
  20. } from '@phosphor/coreutils';
  21. import {
  22. Drag, IDragEvent
  23. } from '@phosphor/dragdrop';
  24. import {
  25. ElementExt
  26. } from '@phosphor/domutils';
  27. import {
  28. Message, MessageLoop
  29. } from '@phosphor/messaging';
  30. import {
  31. Widget
  32. } from '@phosphor/widgets';
  33. import {
  34. FileBrowserModel
  35. } from './model';
  36. import * as utils
  37. from './utils';
  38. import {
  39. showErrorMessage
  40. } from './utils';
  41. /**
  42. * The class name added to DirListing widget.
  43. */
  44. const DIR_LISTING_CLASS = 'jp-DirListing';
  45. /**
  46. * The class name added to a dir listing header node.
  47. */
  48. const HEADER_CLASS = 'jp-DirListing-header';
  49. /**
  50. * The class name added to a dir listing list header cell.
  51. */
  52. const HEADER_ITEM_CLASS = 'jp-DirListing-headerItem';
  53. /**
  54. * The class name added to a header cell text node.
  55. */
  56. const HEADER_ITEM_TEXT_CLASS = 'jp-DirListing-headerItemText';
  57. /**
  58. * The class name added to a header cell icon node.
  59. */
  60. const HEADER_ITEM_ICON_CLASS = 'jp-DirListing-headerItemIcon';
  61. /**
  62. * The class name added to the dir listing content node.
  63. */
  64. const CONTENT_CLASS = 'jp-DirListing-content';
  65. /**
  66. * The class name added to dir listing content item.
  67. */
  68. const ITEM_CLASS = 'jp-DirListing-item';
  69. /**
  70. * The class name added to the listing item text cell.
  71. */
  72. const ITEM_TEXT_CLASS = 'jp-DirListing-itemText';
  73. /**
  74. * The class name added to the listing item icon cell.
  75. */
  76. const ITEM_ICON_CLASS = 'jp-DirListing-itemIcon';
  77. /**
  78. * The class name added to the listing item modified cell.
  79. */
  80. const ITEM_MODIFIED_CLASS = 'jp-DirListing-itemModified';
  81. /**
  82. * The class name added to the dir listing editor node.
  83. */
  84. const EDITOR_CLASS = 'jp-DirListing-editor';
  85. /**
  86. * The class name added to the name column header cell.
  87. */
  88. const NAME_ID_CLASS = 'jp-id-name';
  89. /**
  90. * The class name added to the modified column header cell.
  91. */
  92. const MODIFIED_ID_CLASS = 'jp-id-modified';
  93. /**
  94. * The class name added to a file type content item.
  95. */
  96. const FILE_TYPE_CLASS = 'jp-FileIcon';
  97. /**
  98. * The mime type for a contents drag object.
  99. */
  100. const CONTENTS_MIME = 'application/x-jupyter-icontents';
  101. /**
  102. * The class name added to drop targets.
  103. */
  104. const DROP_TARGET_CLASS = 'jp-mod-dropTarget';
  105. /**
  106. * The class name added to selected rows.
  107. */
  108. const SELECTED_CLASS = 'jp-mod-selected';
  109. /**
  110. * The class name added to a material icon content item.
  111. */
  112. const FOLDER_MATERIAL_ICON_CLASS = 'jp-OpenFolderIcon';
  113. const NOTEBOOK_MATERIAL_ICON_CLASS = 'jp-NotebookIcon';
  114. const MATERIAL_ICON_CLASS = 'jp-MaterialIcon';
  115. /**
  116. * The class name added to drag state icons to add space between the icon and the file name
  117. */
  118. const DRAG_ICON_CLASS = 'jp-DragIcon';
  119. /**
  120. * The class name added to the widget when there are items on the clipboard.
  121. */
  122. const CLIPBOARD_CLASS = 'jp-mod-clipboard';
  123. /**
  124. * The class name added to cut rows.
  125. */
  126. const CUT_CLASS = 'jp-mod-cut';
  127. /**
  128. * The class name added when there are more than one selected rows.
  129. */
  130. const MULTI_SELECTED_CLASS = 'jp-mod-multiSelected';
  131. /**
  132. * The class name added to indicate running notebook.
  133. */
  134. const RUNNING_CLASS = 'jp-mod-running';
  135. /**
  136. * The class name added for a decending sort.
  137. */
  138. const DESCENDING_CLASS = 'jp-mod-descending';
  139. /**
  140. * The minimum duration for a rename select in ms.
  141. */
  142. const RENAME_DURATION = 1000;
  143. /**
  144. * The maximum duration between two key presses when selecting files by prefix.
  145. */
  146. const PREFIX_APPEND_DURATION = 1000;
  147. /**
  148. * The threshold in pixels to start a drag event.
  149. */
  150. const DRAG_THRESHOLD = 5;
  151. /**
  152. * A boolean indicating whether the platform is Mac.
  153. */
  154. const IS_MAC = !!navigator.platform.match(/Mac/i);
  155. /**
  156. * The factory MIME type supported by phosphor dock panels.
  157. */
  158. const FACTORY_MIME = 'application/vnd.phosphor.widget-factory';
  159. /**
  160. * A widget which hosts a file list area.
  161. */
  162. export
  163. class DirListing extends Widget {
  164. /**
  165. * Construct a new file browser directory listing widget.
  166. *
  167. * @param model - The file browser view model.
  168. */
  169. constructor(options: DirListing.IOptions) {
  170. super({
  171. node: (options.renderer || DirListing.defaultRenderer).createNode()
  172. });
  173. this.addClass(DIR_LISTING_CLASS);
  174. this._model = options.model;
  175. this._model.fileChanged.connect(this._onFileChanged, this);
  176. this._model.refreshed.connect(this._onModelRefreshed, this);
  177. this._model.pathChanged.connect(this._onPathChanged, this);
  178. this._editNode = document.createElement('input');
  179. this._editNode.className = EDITOR_CLASS;
  180. this._manager = this._model.manager;
  181. this._renderer = options.renderer || DirListing.defaultRenderer;
  182. const headerNode = DOMUtils.findElement(this.node, HEADER_CLASS);
  183. this._renderer.populateHeaderNode(headerNode);
  184. this._manager.activateRequested.connect(this._onActivateRequested, this);
  185. }
  186. /**
  187. * Dispose of the resources held by the directory listing.
  188. */
  189. dispose(): void {
  190. this._model = null;
  191. this._items = null;
  192. this._editNode = null;
  193. this._drag = null;
  194. this._dragData = null;
  195. this._manager = null;
  196. super.dispose();
  197. }
  198. /**
  199. * Get the model used by the listing.
  200. */
  201. get model(): FileBrowserModel {
  202. return this._model;
  203. }
  204. /**
  205. * Get the dir listing header node.
  206. *
  207. * #### Notes
  208. * This is the node which holds the header cells.
  209. *
  210. * Modifying this node directly can lead to undefined behavior.
  211. */
  212. get headerNode(): HTMLElement {
  213. return DOMUtils.findElement(this.node, HEADER_CLASS);
  214. }
  215. /**
  216. * Get the dir listing content node.
  217. *
  218. * #### Notes
  219. * This is the node which holds the item nodes.
  220. *
  221. * Modifying this node directly can lead to undefined behavior.
  222. */
  223. get contentNode(): HTMLElement {
  224. return DOMUtils.findElement(this.node, CONTENT_CLASS);
  225. }
  226. /**
  227. * The renderer instance used by the directory listing.
  228. */
  229. get renderer(): DirListing.IRenderer {
  230. return this._renderer;
  231. }
  232. /**
  233. * The current sort state.
  234. */
  235. get sortState(): DirListing.ISortState {
  236. return this._sortState;
  237. }
  238. /**
  239. * Create an iterator over the listing's sorted items.
  240. *
  241. * @returns A new iterator over the listing's sorted items.
  242. */
  243. sortedItems(): IIterator<Contents.IModel> {
  244. return new ArrayIterator(this._sortedItems);
  245. }
  246. /**
  247. * Sort the items using a sort condition.
  248. */
  249. sort(state: DirListing.ISortState): void {
  250. this._sortedItems = Private.sort(this.model.items(), state);
  251. this._sortState = state;
  252. this.update();
  253. }
  254. /**
  255. * Rename the first currently selected item.
  256. *
  257. * @returns A promise that resolves with the new name of the item.
  258. */
  259. rename(): Promise<string> {
  260. return this._doRename();
  261. }
  262. /**
  263. * Cut the selected items.
  264. */
  265. cut(): void {
  266. this._isCut = true;
  267. this._copy();
  268. }
  269. /**
  270. * Copy the selected items.
  271. */
  272. copy(): void {
  273. this._copy();
  274. }
  275. /**
  276. * Paste the items from the clipboard.
  277. *
  278. * @returns A promise that resolves when the operation is complete.
  279. */
  280. paste(): Promise<void> {
  281. if (!this._clipboard.length) {
  282. this._isCut = false;
  283. return;
  284. }
  285. const basePath = this._model.path;
  286. let promises: Promise<Contents.IModel>[] = [];
  287. each(this._clipboard, path => {
  288. if (this._isCut) {
  289. const parts = path.split('/');
  290. const name = parts[parts.length - 1];
  291. promises.push(this._model.manager.rename(path, name, basePath));
  292. } else {
  293. promises.push(this._model.manager.copy(path, '.', basePath));
  294. }
  295. });
  296. // Remove any cut modifiers.
  297. each(this._items, item => {
  298. item.classList.remove(CUT_CLASS);
  299. });
  300. this._clipboard.length = 0;
  301. this._isCut = false;
  302. this.removeClass(CLIPBOARD_CLASS);
  303. return Promise.all(promises).catch(error => {
  304. utils.showErrorMessage('Paste Error', error);
  305. });
  306. }
  307. /**
  308. * Delete the currently selected item(s).
  309. *
  310. * @returns A promise that resolves when the operation is complete.
  311. */
  312. delete(): Promise<void> {
  313. let names: string[] = [];
  314. each(this._sortedItems, item => {
  315. if (this._selection[item.name]) {
  316. names.push(item.name);
  317. }
  318. });
  319. let message = `Are you sure you want to permanently delete the ${names.length} files/folders selected?`;
  320. if (names.length === 1) {
  321. message = `Are you sure you want to permanently delete: ${names[0]}?`;
  322. }
  323. if (names.length) {
  324. return showDialog({
  325. title: 'Delete',
  326. body: message,
  327. buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'DELETE'})]
  328. }).then(result => {
  329. if (!this.isDisposed && result.accept) {
  330. return this._delete(names);
  331. }
  332. });
  333. }
  334. return Promise.resolve(void 0);
  335. }
  336. /**
  337. * Duplicate the currently selected item(s).
  338. *
  339. * @returns A promise that resolves when the operation is complete.
  340. */
  341. duplicate(): Promise<void> {
  342. const basePath = this._model.path;
  343. let promises: Promise<Contents.IModel>[] = [];
  344. for (let item of this._getSelectedItems()) {
  345. if (item.type !== 'directory') {
  346. promises.push(this._model.manager.copy(item.name, '.', basePath));
  347. }
  348. }
  349. return Promise.all(promises).catch(error => {
  350. utils.showErrorMessage('Duplicate file', error);
  351. });
  352. }
  353. /**
  354. * Download the currently selected item(s).
  355. */
  356. download(): void {
  357. for (let item of this._getSelectedItems()) {
  358. if (item.type !== 'directory') {
  359. this._model.download(item.path);
  360. }
  361. }
  362. }
  363. /**
  364. * Shut down kernels on the applicable currently selected items.
  365. *
  366. * @returns A promise that resolves when the operation is complete.
  367. */
  368. shutdownKernels(): Promise<void> {
  369. const model = this._model;
  370. const promises: Promise<void>[] = [];
  371. const items = this._sortedItems;
  372. const paths = items.map(item => item.path);
  373. each(this._model.sessions(), session => {
  374. let index = ArrayExt.firstIndexOf(paths, session.path);
  375. if (this._selection[items[index].name]) {
  376. promises.push(model.manager.services.sessions.shutdown(session.id));
  377. }
  378. });
  379. return Promise.all(promises).catch(error => {
  380. utils.showErrorMessage('Shutdown kernel', error);
  381. });
  382. }
  383. /**
  384. * Select next item.
  385. *
  386. * @param keepExisting - Whether to keep the current selection and add to it.
  387. */
  388. selectNext(keepExisting = false): void {
  389. let index = -1;
  390. let selected = Object.keys(this._selection);
  391. let items = this._sortedItems;
  392. if (selected.length === 1 || keepExisting) {
  393. // Select the next item.
  394. let name = selected[selected.length - 1];
  395. index = ArrayExt.findFirstIndex(items, value => value.name === name);
  396. index += 1;
  397. if (index === this._items.length) {
  398. index = 0;
  399. }
  400. } else if (selected.length === 0) {
  401. // Select the first item.
  402. index = 0;
  403. } else {
  404. // Select the last selected item.
  405. let name = selected[selected.length - 1];
  406. index = ArrayExt.findFirstIndex(items, value => value.name === name);
  407. }
  408. if (index !== -1) {
  409. this._selectItem(index, keepExisting);
  410. ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
  411. }
  412. }
  413. /**
  414. * Select previous item.
  415. *
  416. * @param keepExisting - Whether to keep the current selection and add to it.
  417. */
  418. selectPrevious(keepExisting = false): void {
  419. let index = -1;
  420. let selected = Object.keys(this._selection);
  421. let items = this._sortedItems;
  422. if (selected.length === 1 || keepExisting) {
  423. // Select the previous item.
  424. let name = selected[0];
  425. index = ArrayExt.findFirstIndex(items, value => value.name === name);
  426. index -= 1;
  427. if (index === -1) {
  428. index = this._items.length - 1;
  429. }
  430. } else if (selected.length === 0) {
  431. // Select the last item.
  432. index = this._items.length - 1;
  433. } else {
  434. // Select the first selected item.
  435. let name = selected[0];
  436. index = ArrayExt.findFirstIndex(items, value => value.name === name);
  437. }
  438. if (index !== -1) {
  439. this._selectItem(index, keepExisting);
  440. ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
  441. }
  442. }
  443. /**
  444. * Select the first item that starts with prefix being typed.
  445. */
  446. selectByPrefix(): void {
  447. const prefix = this._searchPrefix;
  448. let items = this._sortedItems;
  449. let index = ArrayExt.findFirstIndex(items, value => {
  450. return value.name.toLowerCase().substr(0, prefix.length) === prefix
  451. });
  452. if (index !== -1) {
  453. this._selectItem(index, false);
  454. ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
  455. }
  456. }
  457. /**
  458. * Get whether an item is selected by name.
  459. *
  460. * @param name - The name of of the item.
  461. *
  462. * @returns Whether the item is selected.
  463. */
  464. isSelected(name: string): boolean {
  465. return this._selection[name] === true;
  466. }
  467. /**
  468. * Find a path given a click.
  469. *
  470. * @param event - The mouse event.
  471. *
  472. * @returns The path to the selected file.
  473. */
  474. pathForClick(event: MouseEvent): string {
  475. let items = this._sortedItems;
  476. let index = Private.hitTestNodes(this._items, event.clientX, event.clientY);
  477. if (index !== -1) {
  478. return items[index].path;
  479. }
  480. }
  481. /**
  482. * Handle the DOM events for the directory listing.
  483. *
  484. * @param event - The DOM event sent to the widget.
  485. *
  486. * #### Notes
  487. * This method implements the DOM `EventListener` interface and is
  488. * called in response to events on the panel's DOM node. It should
  489. * not be called directly by user code.
  490. */
  491. handleEvent(event: Event): void {
  492. switch (event.type) {
  493. case 'mousedown':
  494. this._evtMousedown(event as MouseEvent);
  495. break;
  496. case 'mouseup':
  497. this._evtMouseup(event as MouseEvent);
  498. break;
  499. case 'mousemove':
  500. this._evtMousemove(event as MouseEvent);
  501. break;
  502. case 'keydown':
  503. this._evtKeydown(event as KeyboardEvent);
  504. break;
  505. case 'click':
  506. this._evtClick(event as MouseEvent);
  507. break;
  508. case 'dblclick':
  509. this._evtDblClick(event as MouseEvent);
  510. break;
  511. case 'contextmenu':
  512. this._evtContextMenu(event as MouseEvent);
  513. break;
  514. case 'scroll':
  515. this._evtScroll(event as MouseEvent);
  516. break;
  517. case 'p-dragenter':
  518. this._evtDragEnter(event as IDragEvent);
  519. break;
  520. case 'p-dragleave':
  521. this._evtDragLeave(event as IDragEvent);
  522. break;
  523. case 'p-dragover':
  524. this._evtDragOver(event as IDragEvent);
  525. break;
  526. case 'p-drop':
  527. this._evtDrop(event as IDragEvent);
  528. break;
  529. default:
  530. break;
  531. }
  532. }
  533. /**
  534. * A message handler invoked on an `'after-attach'` message.
  535. */
  536. protected onAfterAttach(msg: Message): void {
  537. super.onAfterAttach(msg);
  538. let node = this.node;
  539. let content = DOMUtils.findElement(node, CONTENT_CLASS);
  540. node.addEventListener('mousedown', this);
  541. node.addEventListener('keydown', this);
  542. node.addEventListener('click', this);
  543. node.addEventListener('dblclick', this);
  544. node.addEventListener('contextmenu', this);
  545. content.addEventListener('scroll', this);
  546. content.addEventListener('p-dragenter', this);
  547. content.addEventListener('p-dragleave', this);
  548. content.addEventListener('p-dragover', this);
  549. content.addEventListener('p-drop', this);
  550. }
  551. /**
  552. * A message handler invoked on a `'before-detach'` message.
  553. */
  554. protected onBeforeDetach(msg: Message): void {
  555. super.onBeforeDetach(msg);
  556. let node = this.node;
  557. let content = DOMUtils.findElement(node, CONTENT_CLASS);
  558. node.removeEventListener('mousedown', this);
  559. node.removeEventListener('keydown', this);
  560. node.removeEventListener('click', this);
  561. node.removeEventListener('dblclick', this);
  562. node.removeEventListener('contextmenu', this);
  563. content.removeEventListener('scroll', this);
  564. content.removeEventListener('p-dragenter', this);
  565. content.removeEventListener('p-dragleave', this);
  566. content.removeEventListener('p-dragover', this);
  567. content.removeEventListener('p-drop', this);
  568. document.removeEventListener('mousemove', this, true);
  569. document.removeEventListener('mouseup', this, true);
  570. }
  571. /**
  572. * A handler invoked on an `'update-request'` message.
  573. */
  574. protected onUpdateRequest(msg: Message): void {
  575. // Fetch common variables.
  576. let items = this._sortedItems;
  577. let nodes = this._items;
  578. let content = DOMUtils.findElement(this.node, CONTENT_CLASS);
  579. let renderer = this._renderer;
  580. this.removeClass(MULTI_SELECTED_CLASS);
  581. this.removeClass(SELECTED_CLASS);
  582. // Remove any excess item nodes.
  583. while (nodes.length > items.length) {
  584. let node = nodes.pop();
  585. content.removeChild(node);
  586. }
  587. // Add any missing item nodes.
  588. while (nodes.length < items.length) {
  589. let node = renderer.createItemNode();
  590. node.classList.add(ITEM_CLASS);
  591. nodes.push(node);
  592. content.appendChild(node);
  593. }
  594. // Remove extra classes from the nodes.
  595. each(nodes, item => {
  596. item.classList.remove(SELECTED_CLASS);
  597. item.classList.remove(RUNNING_CLASS);
  598. item.classList.remove(CUT_CLASS);
  599. });
  600. // Add extra classes to item nodes based on widget state.
  601. for (let i = 0, n = items.length; i < n; ++i) {
  602. let node = nodes[i];
  603. let item = items[i];
  604. renderer.updateItemNode(node, item);
  605. if (this._selection[item.name]) {
  606. node.classList.add(SELECTED_CLASS);
  607. if (this._isCut && this._model.path === this._prevPath) {
  608. node.classList.add(CUT_CLASS);
  609. }
  610. }
  611. }
  612. // Handle the selectors on the widget node.
  613. let selectedNames = Object.keys(this._selection);
  614. if (selectedNames.length > 1) {
  615. this.addClass(MULTI_SELECTED_CLASS);
  616. }
  617. if (selectedNames.length) {
  618. this.addClass(SELECTED_CLASS);
  619. }
  620. // Handle file session statuses.
  621. let paths = toArray(map(items, item => item.path));
  622. each(this._model.sessions(), session => {
  623. let index = ArrayExt.firstIndexOf(paths, session.path);
  624. let node = nodes[index];
  625. node.classList.add(RUNNING_CLASS);
  626. let name = session.kernel.name;
  627. let specs = this._model.specs;
  628. if (specs) {
  629. name = specs.kernelspecs[name].display_name;
  630. }
  631. node.title = name;
  632. });
  633. this._prevPath = this._model.path;
  634. }
  635. /**
  636. * Handle the `'click'` event for the widget.
  637. */
  638. private _evtClick(event: MouseEvent) {
  639. let target = event.target as HTMLElement;
  640. let header = this.headerNode;
  641. if (header.contains(target)) {
  642. let state = this.renderer.handleHeaderClick(header, event);
  643. if (state) {
  644. this.sort(state);
  645. }
  646. return;
  647. }
  648. }
  649. /**
  650. * Handle the `'scroll'` event for the widget.
  651. */
  652. private _evtScroll(event: MouseEvent): void {
  653. this.headerNode.scrollLeft = this.contentNode.scrollLeft;
  654. }
  655. /**
  656. * Handle the `'contextmenu'` event for the widget.
  657. */
  658. private _evtContextMenu(event: MouseEvent): void {
  659. this._inContext = true;
  660. }
  661. /**
  662. * Handle the `'mousedown'` event for the widget.
  663. */
  664. private _evtMousedown(event: MouseEvent): void {
  665. // Bail if clicking within the edit node
  666. if (event.target === this._editNode) {
  667. return;
  668. }
  669. // Blur the edit node if necessary.
  670. if (this._editNode.parentNode) {
  671. if (this._editNode !== event.target as HTMLElement) {
  672. this._editNode.focus();
  673. this._editNode.blur();
  674. clearTimeout(this._selectTimer);
  675. } else {
  676. return;
  677. }
  678. }
  679. // Check for clearing a context menu.
  680. let newContext = (IS_MAC && event.ctrlKey) || (event.button === 2);
  681. if (this._inContext && !newContext) {
  682. this._inContext = false;
  683. return;
  684. }
  685. this._inContext = false;
  686. let index = Private.hitTestNodes(this._items, event.clientX, event.clientY);
  687. if (index === -1) {
  688. return;
  689. }
  690. this._handleFileSelect(event);
  691. // Left mouse press for drag start.
  692. if (event.button === 0) {
  693. this._dragData = { pressX: event.clientX, pressY: event.clientY,
  694. index: index };
  695. document.addEventListener('mouseup', this, true);
  696. document.addEventListener('mousemove', this, true);
  697. }
  698. if (event.button !== 0) {
  699. clearTimeout(this._selectTimer);
  700. }
  701. }
  702. /**
  703. * Handle the `'mouseup'` event for the widget.
  704. */
  705. private _evtMouseup(event: MouseEvent): void {
  706. // Handle any soft selection from the previous mouse down.
  707. if (this._softSelection) {
  708. let altered = event.metaKey || event.shiftKey || event.ctrlKey;
  709. // See if we need to clear the other selection.
  710. if (!altered && event.button === 0) {
  711. this._selection = Object.create(null);
  712. this._selection[this._softSelection] = true;
  713. this.update();
  714. }
  715. this._softSelection = '';
  716. }
  717. // Remove the drag listeners if necessary.
  718. if (event.button !== 0 || !this._drag) {
  719. document.removeEventListener('mousemove', this, true);
  720. document.removeEventListener('mouseup', this, true);
  721. return;
  722. }
  723. event.preventDefault();
  724. event.stopPropagation();
  725. }
  726. /**
  727. * Handle the `'mousemove'` event for the widget.
  728. */
  729. private _evtMousemove(event: MouseEvent): void {
  730. event.preventDefault();
  731. event.stopPropagation();
  732. // Bail if we are the one dragging.
  733. if (this._drag) {
  734. return;
  735. }
  736. // Check for a drag initialization.
  737. let data = this._dragData;
  738. let dx = Math.abs(event.clientX - data.pressX);
  739. let dy = Math.abs(event.clientY - data.pressY);
  740. if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
  741. return;
  742. }
  743. this._startDrag(data.index, event.clientX, event.clientY);
  744. }
  745. /**
  746. * Handle the `'keydown'` event for the widget.
  747. */
  748. private _evtKeydown(event: KeyboardEvent): void {
  749. switch (event.keyCode) {
  750. case 13: // Enter
  751. // Do nothing if any modifier keys are pressed.
  752. if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
  753. return;
  754. }
  755. event.preventDefault();
  756. event.stopPropagation();
  757. let selected = Object.keys(this._selection);
  758. let name = selected[0];
  759. let items = this._sortedItems;
  760. let i = ArrayExt.findFirstIndex(items, value => value.name === name);
  761. if (i === -1) {
  762. return;
  763. }
  764. let model = this._model;
  765. let item = this._sortedItems[i];
  766. if (item.type === 'directory') {
  767. model.cd(item.name).catch(error =>
  768. showErrorMessage('Open directory', error)
  769. );
  770. } else {
  771. let path = item.path;
  772. this._manager.openOrReveal(path);
  773. }
  774. break;
  775. case 38: // Up arrow
  776. this.selectPrevious(event.shiftKey);
  777. event.stopPropagation();
  778. event.preventDefault();
  779. break;
  780. case 40: // Down arrow
  781. this.selectNext(event.shiftKey);
  782. event.stopPropagation();
  783. event.preventDefault();
  784. break;
  785. default:
  786. break;
  787. }
  788. // Detects printable characters typed by the user.
  789. // Not all browsers support .key, but it discharges us from reconstructing
  790. // characters from key codes.
  791. if (!this._inRename && event.key !== undefined && event.key.length === 1) {
  792. this._searchPrefix += event.key;
  793. clearTimeout(this._searchPrefixTimer);
  794. this._searchPrefixTimer = window.setTimeout(() => {
  795. this._searchPrefix = '';
  796. }, PREFIX_APPEND_DURATION);
  797. this.selectByPrefix();
  798. event.stopPropagation();
  799. event.preventDefault();
  800. }
  801. }
  802. /**
  803. * Handle the `'dblclick'` event for the widget.
  804. */
  805. private _evtDblClick(event: MouseEvent): void {
  806. // Do nothing if it's not a left mouse press.
  807. if (event.button !== 0) {
  808. return;
  809. }
  810. // Do nothing if any modifier keys are pressed.
  811. if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
  812. return;
  813. }
  814. // Stop the event propagation.
  815. event.preventDefault();
  816. event.stopPropagation();
  817. clearTimeout(this._selectTimer);
  818. this._noSelectTimer = window.setTimeout(() => {
  819. this._noSelectTimer = -1;
  820. }, RENAME_DURATION);
  821. this._editNode.blur();
  822. // Find a valid double click target.
  823. let target = event.target as HTMLElement;
  824. let i = ArrayExt.findFirstIndex(this._items, node => node.contains(target));
  825. if (i === -1) {
  826. return;
  827. }
  828. let model = this._model;
  829. let item = this._sortedItems[i];
  830. if (item.type === 'directory') {
  831. model.cd(item.name).catch(error =>
  832. showErrorMessage('Open directory', error)
  833. );
  834. } else {
  835. let path = item.path;
  836. this._manager.openOrReveal(path);
  837. }
  838. }
  839. /**
  840. * Handle the `'p-dragenter'` event for the widget.
  841. */
  842. private _evtDragEnter(event: IDragEvent): void {
  843. if (event.mimeData.hasData(CONTENTS_MIME)) {
  844. let index = Private.hitTestNodes(this._items, event.clientX, event.clientY);
  845. if (index === -1) {
  846. return;
  847. }
  848. let item = this._sortedItems[index];
  849. if (item.type !== 'directory' || this._selection[item.name]) {
  850. return;
  851. }
  852. let target = event.target as HTMLElement;
  853. target.classList.add(DROP_TARGET_CLASS);
  854. event.preventDefault();
  855. event.stopPropagation();
  856. }
  857. }
  858. /**
  859. * Handle the `'p-dragleave'` event for the widget.
  860. */
  861. private _evtDragLeave(event: IDragEvent): void {
  862. event.preventDefault();
  863. event.stopPropagation();
  864. let dropTarget = DOMUtils.findElement(this.node, DROP_TARGET_CLASS);
  865. if (dropTarget) {
  866. dropTarget.classList.remove(DROP_TARGET_CLASS);
  867. }
  868. }
  869. /**
  870. * Handle the `'p-dragover'` event for the widget.
  871. */
  872. private _evtDragOver(event: IDragEvent): void {
  873. event.preventDefault();
  874. event.stopPropagation();
  875. event.dropAction = event.proposedAction;
  876. let dropTarget = DOMUtils.findElement(this.node, DROP_TARGET_CLASS);
  877. if (dropTarget) {
  878. dropTarget.classList.remove(DROP_TARGET_CLASS);
  879. }
  880. let index = Private.hitTestNodes(this._items, event.clientX, event.clientY);
  881. this._items[index].classList.add(DROP_TARGET_CLASS);
  882. }
  883. /**
  884. * Handle the `'p-drop'` event for the widget.
  885. */
  886. private _evtDrop(event: IDragEvent): void {
  887. event.preventDefault();
  888. event.stopPropagation();
  889. clearTimeout(this._selectTimer);
  890. if (event.proposedAction === 'none') {
  891. event.dropAction = 'none';
  892. return;
  893. }
  894. if (!event.mimeData.hasData(CONTENTS_MIME)) {
  895. return;
  896. }
  897. event.dropAction = event.proposedAction;
  898. let target = event.target as HTMLElement;
  899. while (target && target.parentElement) {
  900. if (target.classList.contains(DROP_TARGET_CLASS)) {
  901. target.classList.remove(DROP_TARGET_CLASS);
  902. break;
  903. }
  904. target = target.parentElement;
  905. }
  906. // Get the path based on the target node.
  907. const index = ArrayExt.firstIndexOf(this._items, target);
  908. const items = this._sortedItems;
  909. const path = items[index].name + '/';
  910. const manager = this._manager;
  911. // Move all of the items.
  912. const promises: Promise<Contents.IModel>[] = [];
  913. const names = event.mimeData.getData(CONTENTS_MIME) as string[];
  914. for (let name of names) {
  915. let newPath = path + name;
  916. promises.push(renameFileDialog(manager, name, newPath, this._model.path));
  917. }
  918. Promise.all(promises).catch(error => {
  919. utils.showErrorMessage('Move Error', error);
  920. });
  921. }
  922. /**
  923. * Start a drag event.
  924. */
  925. private _startDrag(index: number, clientX: number, clientY: number): void {
  926. let selectedNames = Object.keys(this._selection);
  927. let source = this._items[index];
  928. let items = this._sortedItems;
  929. let item: Contents.IModel = null;
  930. // If the source node is not selected, use just that node.
  931. if (!source.classList.contains(SELECTED_CLASS)) {
  932. item = items[index];
  933. selectedNames = [item.name];
  934. } else if (selectedNames.length === 1) {
  935. let name = selectedNames[0];
  936. item = find(items, value => value.name === name);
  937. }
  938. // Create the drag image.
  939. let dragImage = this.renderer.createDragImage(source, selectedNames.length, item);
  940. // Set up the drag event.
  941. this._drag = new Drag({
  942. dragImage,
  943. mimeData: new MimeData(),
  944. supportedActions: 'move',
  945. proposedAction: 'move'
  946. });
  947. this._drag.mimeData.setData(CONTENTS_MIME, selectedNames);
  948. if (item && item.type !== 'directory') {
  949. this._drag.mimeData.setData(FACTORY_MIME, () => {
  950. let path = item.path;
  951. let widget = this._manager.findWidget(path);
  952. if (!widget) {
  953. widget = this._manager.open(item.path);
  954. }
  955. return widget;
  956. });
  957. }
  958. // Start the drag and remove the mousemove and mouseup listeners.
  959. document.removeEventListener('mousemove', this, true);
  960. document.removeEventListener('mouseup', this, true);
  961. clearTimeout(this._selectTimer);
  962. this._drag.start(clientX, clientY).then(action => {
  963. this._drag = null;
  964. clearTimeout(this._selectTimer);
  965. });
  966. }
  967. /**
  968. * Handle selection on a file node.
  969. */
  970. private _handleFileSelect(event: MouseEvent): void {
  971. // Fetch common variables.
  972. let items = this._sortedItems;
  973. let index = Private.hitTestNodes(this._items, event.clientX, event.clientY);
  974. let target = event.target as HTMLElement;
  975. let inText = target.classList.contains(ITEM_TEXT_CLASS);
  976. clearTimeout(this._selectTimer);
  977. if (index === -1) {
  978. return;
  979. }
  980. // Clear any existing soft selection.
  981. this._softSelection = '';
  982. let name = items[index].name;
  983. let selected = Object.keys(this._selection);
  984. // Handle toggling.
  985. if ((IS_MAC && event.metaKey) || (!IS_MAC && event.ctrlKey)) {
  986. if (this._selection[name]) {
  987. delete this._selection[name];
  988. } else {
  989. this._selection[name] = true;
  990. }
  991. // Handle multiple select.
  992. } else if (event.shiftKey) {
  993. this._handleMultiSelect(selected, index);
  994. // Handle a 'soft' selection
  995. } else if (name in this._selection && selected.length > 1) {
  996. this._softSelection = name;
  997. // Default to selecting the only the item.
  998. } else {
  999. // Handle a rename.
  1000. if (inText && selected.length === 1 && selected[0] === name) {
  1001. this._selectTimer = window.setTimeout(() => {
  1002. if (this._noSelectTimer === -1) {
  1003. this._doRename();
  1004. }
  1005. }, RENAME_DURATION);
  1006. }
  1007. // Select only the given item.
  1008. this._selection = Object.create(null);
  1009. this._selection[name] = true;
  1010. }
  1011. this.update();
  1012. }
  1013. /**
  1014. * Select an item by name.
  1015. *
  1016. * @parem name - The name of the item to select.
  1017. *
  1018. * @returns A promise that resolves when the name is selected.
  1019. */
  1020. private _selectItemByName(name: string): Promise<void> {
  1021. // Make sure the file is available.
  1022. return this.model.refresh().then(() => {
  1023. if (this.isDisposed) {
  1024. return;
  1025. }
  1026. let items = this._sortedItems;
  1027. let index = ArrayExt.findFirstIndex(items, value => value.name === name);
  1028. if (index === -1) {
  1029. return;
  1030. }
  1031. this._selectItem(index, false);
  1032. MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);
  1033. ElementExt.scrollIntoViewIfNeeded(this.contentNode, this._items[index]);
  1034. });
  1035. }
  1036. /**
  1037. * Handle a multiple select on a file item node.
  1038. */
  1039. private _handleMultiSelect(selected: string[], index: number): void {
  1040. // Find the "nearest selected".
  1041. let items = this._sortedItems;
  1042. let nearestIndex = -1;
  1043. for (let i = 0; i < this._items.length; i++) {
  1044. if (i === index) {
  1045. continue;
  1046. }
  1047. let name = items[i].name;
  1048. if (selected.indexOf(name) !== -1) {
  1049. if (nearestIndex === -1) {
  1050. nearestIndex = i;
  1051. } else {
  1052. if (Math.abs(index - i) < Math.abs(nearestIndex - i)) {
  1053. nearestIndex = i;
  1054. }
  1055. }
  1056. }
  1057. }
  1058. // Default to the first element (and fill down).
  1059. if (nearestIndex === -1) {
  1060. nearestIndex = 0;
  1061. }
  1062. // Select the rows between the current and the nearest selected.
  1063. for (let i = 0; i < this._items.length; i++) {
  1064. if (nearestIndex >= i && index <= i ||
  1065. nearestIndex <= i && index >= i) {
  1066. this._selection[items[i].name] = true;
  1067. }
  1068. }
  1069. }
  1070. /**
  1071. * Get the currently selected items.
  1072. */
  1073. private _getSelectedItems(): Contents.IModel[] {
  1074. let items = this._sortedItems;
  1075. return toArray(filter(items, item => this._selection[item.name]));
  1076. }
  1077. /**
  1078. * Copy the selected items, and optionally cut as well.
  1079. */
  1080. private _copy(): void {
  1081. this._clipboard.length = 0;
  1082. for (let item of this._getSelectedItems()) {
  1083. if (item.type !== 'directory') {
  1084. // Store the absolute path of the item.
  1085. this._clipboard.push('/' + item.path);
  1086. }
  1087. }
  1088. this.update();
  1089. }
  1090. /**
  1091. * Delete the files with the given names.
  1092. */
  1093. private _delete(names: string[]): Promise<void> {
  1094. const promises: Promise<void>[] = [];
  1095. const basePath = this._model.path;
  1096. for (let name of names) {
  1097. let promise = this._model.manager.deleteFile(name, basePath).catch(err => {
  1098. utils.showErrorMessage('Delete Failed', err);
  1099. });
  1100. promises.push(promise);
  1101. }
  1102. return Promise.all(promises).then(() => undefined);
  1103. }
  1104. /**
  1105. * Allow the user to rename item on a given row.
  1106. */
  1107. private _doRename(): Promise<string> {
  1108. this._inRename = true;
  1109. let items = this._sortedItems;
  1110. let name = Object.keys(this._selection)[0];
  1111. let index = ArrayExt.findFirstIndex(items, value => value.name === name);
  1112. let row = this._items[index];
  1113. let item = items[index];
  1114. let nameNode = this.renderer.getNameNode(row);
  1115. let original = item.name;
  1116. this._editNode.value = original;
  1117. this._selectItem(index, false);
  1118. return Private.doRename(nameNode, this._editNode).then(newName => {
  1119. if (newName === original) {
  1120. this._inRename = false;
  1121. return original;
  1122. }
  1123. if (this.isDisposed) {
  1124. this._inRename = false;
  1125. return;
  1126. }
  1127. const manager = this._manager;
  1128. const basePath = this._model.path;
  1129. const promise = renameFileDialog(manager, original, newName, basePath);
  1130. return promise.catch(error => {
  1131. utils.showErrorMessage('Rename Error', error);
  1132. this._inRename = false;
  1133. return original;
  1134. }).then(() => {
  1135. if (this.isDisposed) {
  1136. this._inRename = false;
  1137. return;
  1138. }
  1139. this._selectItemByName(newName);
  1140. this._inRename = false;
  1141. return newName;
  1142. });
  1143. });
  1144. }
  1145. /**
  1146. * Select a given item.
  1147. */
  1148. private _selectItem(index: number, keepExisting: boolean) {
  1149. // Selected the given row(s)
  1150. let items = this._sortedItems;
  1151. if (!keepExisting) {
  1152. this._selection = Object.create(null);
  1153. }
  1154. let name = items[index].name;
  1155. this._selection[name] = true;
  1156. this.update();
  1157. }
  1158. /**
  1159. * Handle the `refreshed` signal from the model.
  1160. */
  1161. private _onModelRefreshed(): void {
  1162. // Update the selection.
  1163. let existing = Object.keys(this._selection);
  1164. this._selection = Object.create(null);
  1165. each(this._model.items(), item => {
  1166. let name = item.name;
  1167. if (existing.indexOf(name) !== -1) {
  1168. this._selection[name] = true;
  1169. }
  1170. });
  1171. // Update the sorted items.
  1172. this.sort(this.sortState);
  1173. }
  1174. /**
  1175. * Handle a `pathChanged` signal from the model.
  1176. */
  1177. private _onPathChanged(): void {
  1178. // Reset the selection.
  1179. this._selection = Object.create(null);
  1180. // Update the sorted items.
  1181. this.sort(this.sortState);
  1182. }
  1183. /**
  1184. * Handle a `fileChanged` signal from the model.
  1185. */
  1186. private _onFileChanged(sender: FileBrowserModel, args: Contents.IChangedArgs) {
  1187. if (args.type === 'new') {
  1188. this._selectItemByName(args.newValue.name).then(() => {
  1189. if (!this.isDisposed && args.newValue === 'directory') {
  1190. this._doRename();
  1191. }
  1192. });
  1193. }
  1194. }
  1195. /**
  1196. * Handle an `activateRequested` signal from the manager.
  1197. */
  1198. private _onActivateRequested(sender: IDocumentManager, args: string): void {
  1199. let dirname = PathExt.dirname(args);
  1200. if (dirname !== this._model.path) {
  1201. return;
  1202. }
  1203. let basename = PathExt.basename(args);
  1204. this._selectItemByName(basename);
  1205. }
  1206. private _model: FileBrowserModel = null;
  1207. private _editNode: HTMLInputElement = null;
  1208. private _items: HTMLElement[] = [];
  1209. private _sortedItems: Contents.IModel[] = [];
  1210. private _sortState: DirListing.ISortState = { direction: 'ascending', key: 'name' };
  1211. private _drag: Drag = null;
  1212. private _dragData: { pressX: number, pressY: number, index: number } = null;
  1213. private _selectTimer = -1;
  1214. private _noSelectTimer = -1;
  1215. private _isCut = false;
  1216. private _prevPath = '';
  1217. private _clipboard: string[] = [];
  1218. private _manager: IDocumentManager = null;
  1219. private _softSelection = '';
  1220. private _inContext = false;
  1221. private _selection: { [key: string]: boolean; } = Object.create(null);
  1222. private _renderer: DirListing.IRenderer = null;
  1223. private _searchPrefix: string = '';
  1224. private _searchPrefixTimer = -1;
  1225. private _inRename = false;
  1226. }
  1227. /**
  1228. * The namespace for the `DirListing` class statics.
  1229. */
  1230. export
  1231. namespace DirListing {
  1232. /**
  1233. * An options object for initializing a file browser directory listing.
  1234. */
  1235. export
  1236. interface IOptions {
  1237. /**
  1238. * A file browser model instance.
  1239. */
  1240. model: FileBrowserModel;
  1241. /**
  1242. * A renderer for file items.
  1243. *
  1244. * The default is a shared `Renderer` instance.
  1245. */
  1246. renderer?: IRenderer;
  1247. }
  1248. /**
  1249. * A sort state.
  1250. */
  1251. export
  1252. interface ISortState {
  1253. /**
  1254. * The direction of sort.
  1255. */
  1256. direction: 'ascending' | 'descending';
  1257. /**
  1258. * The sort key.
  1259. */
  1260. key: 'name' | 'last_modified';
  1261. }
  1262. /**
  1263. * The render interface for file browser listing options.
  1264. */
  1265. export
  1266. interface IRenderer {
  1267. /**
  1268. * Create the DOM node for a dir listing.
  1269. */
  1270. createNode(): HTMLElement;
  1271. /**
  1272. * Populate and empty header node for a dir listing.
  1273. *
  1274. * @param node - The header node to populate.
  1275. */
  1276. populateHeaderNode(node: HTMLElement): void;
  1277. /**
  1278. * Handle a header click.
  1279. *
  1280. * @param node - A node populated by [[populateHeaderNode]].
  1281. *
  1282. * @param event - A click event on the node.
  1283. *
  1284. * @returns The sort state of the header after the click event.
  1285. */
  1286. handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState;
  1287. /**
  1288. * Create a new item node for a dir listing.
  1289. *
  1290. * @returns A new DOM node to use as a content item.
  1291. */
  1292. createItemNode(): HTMLElement;
  1293. /**
  1294. * Update an item node to reflect the current state of a model.
  1295. *
  1296. * @param node - A node created by [[createItemNode]].
  1297. *
  1298. * @param model - The model object to use for the item state.
  1299. */
  1300. updateItemNode(node: HTMLElement, model: Contents.IModel): void;
  1301. /**
  1302. * Get the node containing the file name.
  1303. *
  1304. * @param node - A node created by [[createItemNode]].
  1305. *
  1306. * @returns The node containing the file name.
  1307. */
  1308. getNameNode(node: HTMLElement): HTMLElement;
  1309. /**
  1310. * Create an appropriate drag image for an item.
  1311. *
  1312. * @param node - A node created by [[createItemNode]].
  1313. *
  1314. * @param count - The number of items being dragged.
  1315. *
  1316. * @returns An element to use as the drag image.
  1317. */
  1318. createDragImage(node: HTMLElement, count: number, model: Contents.IModel): HTMLElement;
  1319. }
  1320. /**
  1321. * The default implementation of an `IRenderer`.
  1322. */
  1323. export
  1324. class Renderer implements IRenderer {
  1325. /**
  1326. * Create the DOM node for a dir listing.
  1327. */
  1328. createNode(): HTMLElement {
  1329. let node = document.createElement('div');
  1330. let header = document.createElement('div');
  1331. let content = document.createElement('ul');
  1332. content.className = CONTENT_CLASS;
  1333. header.className = HEADER_CLASS;
  1334. node.appendChild(header);
  1335. node.appendChild(content);
  1336. node.tabIndex = 1;
  1337. return node;
  1338. }
  1339. /**
  1340. * Populate and empty header node for a dir listing.
  1341. *
  1342. * @param node - The header node to populate.
  1343. */
  1344. populateHeaderNode(node: HTMLElement): void {
  1345. let name = this._createHeaderItemNode('Name');
  1346. let modified = this._createHeaderItemNode('Last Modified');
  1347. name.classList.add(NAME_ID_CLASS);
  1348. name.classList.add(SELECTED_CLASS);
  1349. modified.classList.add(MODIFIED_ID_CLASS);
  1350. node.appendChild(name);
  1351. node.appendChild(modified);
  1352. }
  1353. /**
  1354. * Handle a header click.
  1355. *
  1356. * @param node - A node populated by [[populateHeaderNode]].
  1357. *
  1358. * @param event - A click event on the node.
  1359. *
  1360. * @returns The sort state of the header after the click event.
  1361. */
  1362. handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState {
  1363. let name = DOMUtils.findElement(node, NAME_ID_CLASS);
  1364. let modified = DOMUtils.findElement(node, MODIFIED_ID_CLASS);
  1365. let state: ISortState = { direction: 'ascending', key: 'name' };
  1366. let target = event.target as HTMLElement;
  1367. if (name.contains(target)) {
  1368. if (name.classList.contains(SELECTED_CLASS)) {
  1369. if (!name.classList.contains(DESCENDING_CLASS)) {
  1370. state.direction = 'descending';
  1371. name.classList.add(DESCENDING_CLASS);
  1372. } else {
  1373. name.classList.remove(DESCENDING_CLASS);
  1374. }
  1375. } else {
  1376. name.classList.remove(DESCENDING_CLASS);
  1377. }
  1378. name.classList.add(SELECTED_CLASS);
  1379. modified.classList.remove(SELECTED_CLASS);
  1380. modified.classList.remove(DESCENDING_CLASS);
  1381. return state;
  1382. }
  1383. if (modified.contains(target)) {
  1384. state.key = 'last_modified';
  1385. if (modified.classList.contains(SELECTED_CLASS)) {
  1386. if (!modified.classList.contains(DESCENDING_CLASS)) {
  1387. state.direction = 'descending';
  1388. modified.classList.add(DESCENDING_CLASS);
  1389. } else {
  1390. modified.classList.remove(DESCENDING_CLASS);
  1391. }
  1392. } else {
  1393. modified.classList.remove(DESCENDING_CLASS);
  1394. }
  1395. modified.classList.add(SELECTED_CLASS);
  1396. name.classList.remove(SELECTED_CLASS);
  1397. name.classList.remove(DESCENDING_CLASS);
  1398. return state;
  1399. }
  1400. return void 0;
  1401. }
  1402. /**
  1403. * Create a new item node for a dir listing.
  1404. *
  1405. * @returns A new DOM node to use as a content item.
  1406. */
  1407. createItemNode(): HTMLElement {
  1408. let node = document.createElement('li');
  1409. let icon = document.createElement('span');
  1410. let text = document.createElement('span');
  1411. let modified = document.createElement('span');
  1412. icon.className = ITEM_ICON_CLASS;
  1413. text.className = ITEM_TEXT_CLASS;
  1414. modified.className = ITEM_MODIFIED_CLASS;
  1415. node.appendChild(icon);
  1416. node.appendChild(text);
  1417. node.appendChild(modified);
  1418. return node;
  1419. }
  1420. /**
  1421. * Update an item node to reflect the current state of a model.
  1422. *
  1423. * @param node - A node created by [[createItemNode]].
  1424. *
  1425. * @param model - The model object to use for the item state.
  1426. */
  1427. updateItemNode(node: HTMLElement, model: Contents.IModel): void {
  1428. let icon = DOMUtils.findElement(node, ITEM_ICON_CLASS);
  1429. let text = DOMUtils.findElement(node, ITEM_TEXT_CLASS);
  1430. let modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS);
  1431. icon.className = ITEM_ICON_CLASS + ' ' + MATERIAL_ICON_CLASS;
  1432. switch (model.type) {
  1433. case 'directory':
  1434. icon.classList.add(FOLDER_MATERIAL_ICON_CLASS);
  1435. break;
  1436. case 'notebook':
  1437. icon.classList.add(NOTEBOOK_MATERIAL_ICON_CLASS);
  1438. break;
  1439. default:
  1440. icon.classList.add(MATERIAL_ICON_CLASS);
  1441. icon.classList.add(FILE_TYPE_CLASS);
  1442. break;
  1443. }
  1444. let modText = '';
  1445. let modTitle = '';
  1446. if (model.last_modified) {
  1447. modText = Time.formatHuman(model.last_modified);
  1448. modTitle = Time.format(model.last_modified);
  1449. }
  1450. // If an item is being edited currently, its text node is unavailable.
  1451. if (text) {
  1452. text.textContent = model.name;
  1453. }
  1454. modified.textContent = modText;
  1455. modified.title = modTitle;
  1456. }
  1457. /**
  1458. * Get the node containing the file name.
  1459. *
  1460. * @param node - A node created by [[createItemNode]].
  1461. *
  1462. * @returns The node containing the file name.
  1463. */
  1464. getNameNode(node: HTMLElement): HTMLElement {
  1465. return DOMUtils.findElement(node, ITEM_TEXT_CLASS);
  1466. }
  1467. /**
  1468. * Create a drag image for an item.
  1469. *
  1470. * @param node - A node created by [[createItemNode]].
  1471. *
  1472. * @param count - The number of items being dragged.
  1473. *
  1474. * @returns An element to use as the drag image.
  1475. */
  1476. createDragImage(node: HTMLElement, count: number, model: Contents.IModel): HTMLElement {
  1477. let dragImage = node.cloneNode(true) as HTMLElement;
  1478. let modified = DOMUtils.findElement(dragImage, ITEM_MODIFIED_CLASS);
  1479. let iconNode = DOMUtils.findElement(dragImage, ITEM_ICON_CLASS);
  1480. dragImage.removeChild(modified as HTMLElement);
  1481. if (model) {
  1482. switch (model.type) {
  1483. case 'directory':
  1484. iconNode.className = `${MATERIAL_ICON_CLASS} ${FOLDER_MATERIAL_ICON_CLASS} ${DRAG_ICON_CLASS}`;
  1485. break;
  1486. case 'notebook':
  1487. iconNode.className = `${MATERIAL_ICON_CLASS} ${NOTEBOOK_MATERIAL_ICON_CLASS} ${DRAG_ICON_CLASS}`;
  1488. break;
  1489. default:
  1490. iconNode.className = `${MATERIAL_ICON_CLASS} ${FILE_TYPE_CLASS} ${DRAG_ICON_CLASS}`;
  1491. break;
  1492. }
  1493. }
  1494. if (count > 1) {
  1495. let nameNode = DOMUtils.findElement(dragImage, ITEM_TEXT_CLASS);
  1496. nameNode.textContent = count + ' Items';
  1497. }
  1498. return dragImage;
  1499. }
  1500. /**
  1501. * Create a node for a header item.
  1502. */
  1503. private _createHeaderItemNode(label: string): HTMLElement {
  1504. let node = document.createElement('div');
  1505. let text = document.createElement('span');
  1506. let icon = document.createElement('span');
  1507. node.className = HEADER_ITEM_CLASS;
  1508. text.className = HEADER_ITEM_TEXT_CLASS;
  1509. icon.className = HEADER_ITEM_ICON_CLASS;
  1510. text.textContent = label;
  1511. node.appendChild(text);
  1512. node.appendChild(icon);
  1513. return node;
  1514. }
  1515. }
  1516. /**
  1517. * The default `IRenderer` instance.
  1518. */
  1519. export
  1520. const defaultRenderer = new Renderer();
  1521. }
  1522. /**
  1523. * The namespace for the listing private data.
  1524. */
  1525. namespace Private {
  1526. /**
  1527. * Handle editing text on a node.
  1528. *
  1529. * @returns Boolean indicating whether the name changed.
  1530. */
  1531. export
  1532. function doRename(text: HTMLElement, edit: HTMLInputElement): Promise<string> {
  1533. let changed = true;
  1534. let parent = text.parentElement as HTMLElement;
  1535. parent.replaceChild(edit, text);
  1536. edit.focus();
  1537. let index = edit.value.lastIndexOf('.');
  1538. if (index === -1) {
  1539. edit.setSelectionRange(0, edit.value.length);
  1540. } else {
  1541. edit.setSelectionRange(0, index);
  1542. }
  1543. return new Promise<string>((resolve, reject) => {
  1544. edit.onblur = () => {
  1545. parent.replaceChild(text, edit);
  1546. resolve(edit.value);
  1547. };
  1548. edit.onkeydown = (event: KeyboardEvent) => {
  1549. switch (event.keyCode) {
  1550. case 13: // Enter
  1551. event.stopPropagation();
  1552. event.preventDefault();
  1553. edit.blur();
  1554. break;
  1555. case 27: // Escape
  1556. event.stopPropagation();
  1557. event.preventDefault();
  1558. changed = false;
  1559. edit.blur();
  1560. break;
  1561. case 38: // Up arrow
  1562. event.stopPropagation();
  1563. event.preventDefault();
  1564. if (edit.selectionStart !== edit.selectionEnd) {
  1565. edit.selectionStart = edit.selectionEnd = 0;
  1566. }
  1567. break;
  1568. case 40: // Down arrow
  1569. event.stopPropagation();
  1570. event.preventDefault();
  1571. if (edit.selectionStart !== edit.selectionEnd) {
  1572. edit.selectionStart = edit.selectionEnd = edit.value.length;
  1573. }
  1574. break;
  1575. default:
  1576. break;
  1577. }
  1578. };
  1579. });
  1580. }
  1581. /**
  1582. * Sort a list of items by sort state as a new array.
  1583. */
  1584. export
  1585. function sort(items: IIterator<Contents.IModel>, state: DirListing.ISortState) : Contents.IModel[] {
  1586. // Shortcut for unmodified.
  1587. if (state.key !== 'last_modified' && state.direction === 'ascending') {
  1588. return toArray(items);
  1589. }
  1590. let copy = toArray(items);
  1591. if (state.key === 'last_modified') {
  1592. copy.sort((a, b) => {
  1593. let valA = new Date(a.last_modified).getTime();
  1594. let valB = new Date(b.last_modified).getTime();
  1595. return valB - valA;
  1596. });
  1597. }
  1598. // Reverse the order if descending.
  1599. if (state.direction === 'descending') {
  1600. copy.reverse();
  1601. }
  1602. return copy;
  1603. }
  1604. /**
  1605. * Get the index of the node at a client position, or `-1`.
  1606. */
  1607. export
  1608. function hitTestNodes(nodes: HTMLElement[], x: number, y: number): number {
  1609. return ArrayExt.findFirstIndex(nodes, node => ElementExt.hitTest(node, x, y));
  1610. }
  1611. }