listing.ts 53 KB

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