listing.ts 57 KB

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