@jupyterlab/filebrowser 4.0.0-beta.1 → 4.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/listing.ts CHANGED
@@ -437,6 +437,16 @@ export class DirListing extends Widget {
437
437
  if (!this.isDisposed && result.button.accept) {
438
438
  await this._delete(items.map(item => item.path));
439
439
  }
440
+
441
+ // Re-focus
442
+ let focusIndex = this._focusIndex;
443
+ const lastIndexAfterDelete = this._sortedItems.length - items.length - 1;
444
+ if (focusIndex > lastIndexAfterDelete) {
445
+ // If the focus index after deleting items is out of bounds, set it to the
446
+ // last item.
447
+ focusIndex = Math.max(0, lastIndexAfterDelete);
448
+ }
449
+ this._focusItem(focusIndex);
440
450
  }
441
451
 
442
452
  /**
@@ -628,7 +638,7 @@ export class DirListing extends Widget {
628
638
  * Select an item by name.
629
639
  *
630
640
  * @param name - The name of the item to select.
631
- * @param focus - Whether to move focus the selected item.
641
+ * @param focus - Whether to move focus to the selected item.
632
642
  *
633
643
  * @returns A promise that resolves when the name is selected.
634
644
  */
@@ -799,16 +809,25 @@ export class DirListing extends Widget {
799
809
  content.appendChild(node);
800
810
  }
801
811
 
802
- // Remove extra classes from the nodes.
803
- nodes.forEach(node => {
812
+ nodes.forEach((node, i) => {
813
+ // Remove extra classes from the nodes.
804
814
  node.classList.remove(SELECTED_CLASS);
805
815
  node.classList.remove(RUNNING_CLASS);
806
816
  node.classList.remove(CUT_CLASS);
817
+
818
+ // Uncheck each file checkbox
807
819
  const checkbox = renderer.getCheckboxNode(node);
808
820
  if (checkbox) {
809
- // Uncheck each file checkbox
810
821
  checkbox.checked = false;
811
822
  }
823
+
824
+ // Handle `tabIndex`
825
+ const nameNode = renderer.getNameNode(node);
826
+ if (nameNode) {
827
+ // Must check if the name node is there because it gets replaced by the
828
+ // edit node when editing the name of the file or directory.
829
+ nameNode.tabIndex = i === this._focusIndex ? 0 : -1;
830
+ }
812
831
  });
813
832
 
814
833
  // Put the check-all checkbox in the header into the correct state
@@ -835,7 +854,7 @@ export class DirListing extends Widget {
835
854
  );
836
855
  }
837
856
 
838
- // Add extra classes to item nodes based on widget state.
857
+ // Update item nodes based on widget state.
839
858
  items.forEach((item, i) => {
840
859
  const node = nodes[i];
841
860
  const ft = this._manager.registry.getFileTypeForModel(item);
@@ -966,6 +985,12 @@ export class DirListing extends Widget {
966
985
  }
967
986
  }
968
987
  return;
988
+ } else {
989
+ // Focus the selected file on click to ensure a couple of things:
990
+ // 1. If a user clicks on the item node, its name node will receive focus.
991
+ // 2. If a user clicks on blank space in the directory listing, the
992
+ // previously focussed item will be focussed.
993
+ this._focusItem(this._focusIndex);
969
994
  }
970
995
  }
971
996
 
@@ -1041,11 +1066,11 @@ export class DirListing extends Widget {
1041
1066
  }
1042
1067
  this._softSelection = '';
1043
1068
  }
1044
- // Re-focus the selected file. This is needed because nodes corresponding
1045
- // to files selected in mousedown handler will not retain the focus
1046
- // as mousedown event is always followed by a blur/focus event.
1069
+ // Re-focus. This is needed because nodes corresponding to files selected in
1070
+ // mousedown handler will not retain the focus as mousedown event is always
1071
+ // followed by a blur/focus event.
1047
1072
  if (event.button === 0) {
1048
- this._focusSelectedFile();
1073
+ this._focusItem(this._focusIndex);
1049
1074
  }
1050
1075
 
1051
1076
  // Remove the drag listeners if necessary.
@@ -1102,10 +1127,116 @@ export class DirListing extends Widget {
1102
1127
  }
1103
1128
  }
1104
1129
 
1130
+ /**
1131
+ * Calculate the next focus index, given the current focus index and a
1132
+ * direction, keeping within the bounds of the directory listing.
1133
+ *
1134
+ * @param index Current focus index
1135
+ * @param direction -1 (up) or 1 (down)
1136
+ * @returns The next focus index, which could be the same as the current focus
1137
+ * index if at the boundary.
1138
+ */
1139
+ private _getNextFocusIndex(index: number, direction: number): number {
1140
+ const nextIndex = index + direction;
1141
+ if (nextIndex === -1 || nextIndex === this._items.length) {
1142
+ // keep focus index within bounds
1143
+ return index;
1144
+ } else {
1145
+ return nextIndex;
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Handle the up or down arrow key.
1151
+ *
1152
+ * @param event The keyboard event
1153
+ * @param direction -1 (up) or 1 (down)
1154
+ */
1155
+ private _handleArrowY(event: KeyboardEvent, direction: number) {
1156
+ // We only handle the `ctrl` and `shift` modifiers. If other modifiers are
1157
+ // present, then do nothing.
1158
+ if (event.altKey || event.metaKey) {
1159
+ return;
1160
+ }
1161
+
1162
+ // If folder is empty, there's nothing to do with the up/down key.
1163
+ if (!this._items.length) {
1164
+ return;
1165
+ }
1166
+
1167
+ // Don't handle the arrow key press if it's not on directory item. This
1168
+ // avoids a confusing user experience that can result from when the user
1169
+ // moves the selection and focus index apart (via ctrl + up/down). The last
1170
+ // selected item remains highlighted but the last focussed item loses its
1171
+ // focus ring if it's not actively focussed. This forces the user to
1172
+ // visibly reveal the last focussed item before moving the focus.
1173
+ if (!(event.target as HTMLElement).classList.contains(ITEM_TEXT_CLASS)) {
1174
+ return;
1175
+ }
1176
+
1177
+ event.stopPropagation();
1178
+ event.preventDefault();
1179
+
1180
+ const focusIndex = this._focusIndex;
1181
+ let nextFocusIndex = this._getNextFocusIndex(focusIndex, direction);
1182
+
1183
+ // The following if-block allows the first press of the down arrow to select
1184
+ // the first (rather than the second) file/directory in the list. This is
1185
+ // the situation when the page first loads or when a user changes directory.
1186
+ if (
1187
+ direction > 0 &&
1188
+ focusIndex === 0 &&
1189
+ !event.ctrlKey &&
1190
+ Object.keys(this.selection).length === 0
1191
+ ) {
1192
+ nextFocusIndex = 0;
1193
+ }
1194
+
1195
+ // Shift key indicates multi-selection. Either the user is trying to grow
1196
+ // the selection, or shrink it.
1197
+ if (event.shiftKey) {
1198
+ this._handleMultiSelect(nextFocusIndex);
1199
+ } else if (!event.ctrlKey) {
1200
+ // If neither the shift nor ctrl keys were used with the up/down arrow,
1201
+ // then we treat it as a normal, unmodified key press and select the
1202
+ // next item.
1203
+ this._selectItem(
1204
+ nextFocusIndex,
1205
+ event.shiftKey,
1206
+ false /* focus = false because we call focus method directly following this */
1207
+ );
1208
+ }
1209
+
1210
+ this._focusItem(nextFocusIndex);
1211
+ this.update();
1212
+ }
1213
+
1214
+ /**
1215
+ * cd ..
1216
+ *
1217
+ * Go up one level in the directory tree.
1218
+ */
1219
+ async goUp() {
1220
+ const model = this.model;
1221
+ if (model.path === model.rootPath) {
1222
+ return;
1223
+ }
1224
+ try {
1225
+ await model.cd('..');
1226
+ } catch (reason) {
1227
+ console.warn(`Failed to go to parent directory of ${model.path}`, reason);
1228
+ }
1229
+ }
1230
+
1105
1231
  /**
1106
1232
  * Handle the `'keydown'` event for the widget.
1107
1233
  */
1108
1234
  protected evtKeydown(event: KeyboardEvent): void {
1235
+ // Do not handle any keydown events here if in the middle of a file rename.
1236
+ if (this._inRename) {
1237
+ return;
1238
+ }
1239
+
1109
1240
  switch (event.keyCode) {
1110
1241
  case 13: {
1111
1242
  // Enter
@@ -1115,37 +1246,78 @@ export class DirListing extends Widget {
1115
1246
  }
1116
1247
  event.preventDefault();
1117
1248
  event.stopPropagation();
1249
+ for (const item of this.selectedItems()) {
1250
+ this.handleOpen(item);
1251
+ }
1252
+ return;
1253
+ }
1254
+ case 38:
1255
+ // Up arrow
1256
+ this._handleArrowY(event, -1);
1257
+ return;
1258
+ case 40:
1259
+ // Down arrow
1260
+ this._handleArrowY(event, 1);
1261
+ return;
1262
+ case 32: {
1263
+ // Space
1264
+ if (event.ctrlKey) {
1265
+ // Follow the Windows and Ubuntu convention: you must press `ctrl` +
1266
+ // `space` in order to toggle whether an item is selected.
1267
+
1268
+ // However, do not handle if any other modifiers were pressed.
1269
+ if (event.metaKey || event.shiftKey || event.altKey) {
1270
+ return;
1271
+ }
1272
+
1273
+ // Make sure the ctrl+space key stroke was on a valid, focussed target.
1274
+ const node = this._items[this._focusIndex];
1275
+ if (
1276
+ !(
1277
+ // Event must have occurred within a node whose item can be toggled.
1278
+ (
1279
+ node.contains(event.target as HTMLElement) &&
1280
+ // That node must also contain the currently focussed element.
1281
+ node.contains(document.activeElement)
1282
+ )
1283
+ )
1284
+ ) {
1285
+ return;
1286
+ }
1287
+
1288
+ event.stopPropagation();
1289
+ // Prevent default, otherwise the container will scroll.
1290
+ event.preventDefault();
1118
1291
 
1119
- const selected = Object.keys(this.selection);
1120
- const path = selected[0];
1121
- const items = this._sortedItems;
1122
- const i = ArrayExt.findFirstIndex(items, value => value.path === path);
1123
- if (i === -1) {
1292
+ // Toggle item selected
1293
+ const { path } = this._sortedItems[this._focusIndex];
1294
+ if (this.selection[path]) {
1295
+ delete this.selection[path];
1296
+ } else {
1297
+ this.selection[path] = true;
1298
+ }
1299
+
1300
+ this.update();
1301
+ // Key was handled, so return.
1124
1302
  return;
1125
1303
  }
1126
-
1127
- const item = this._sortedItems[i];
1128
- this.handleOpen(item);
1129
1304
  break;
1130
1305
  }
1131
- case 38: // Up arrow
1132
- this.selectPrevious(event.shiftKey);
1133
- event.stopPropagation();
1134
- event.preventDefault();
1135
- break;
1136
- case 40: // Down arrow
1137
- this.selectNext(event.shiftKey);
1138
- event.stopPropagation();
1139
- event.preventDefault();
1140
- break;
1141
- default:
1142
- break;
1143
1306
  }
1144
1307
 
1145
1308
  // Detects printable characters typed by the user.
1146
1309
  // Not all browsers support .key, but it discharges us from reconstructing
1147
1310
  // characters from key codes.
1148
- if (!this._inRename && event.key !== undefined && event.key.length === 1) {
1311
+ if (
1312
+ event.key !== undefined &&
1313
+ event.key.length === 1 &&
1314
+ // Don't gobble up the space key on the check-all checkbox (which the
1315
+ // browser treats as a click event).
1316
+ !(
1317
+ (event.key === ' ' || event.keyCode === 32) &&
1318
+ (event.target as HTMLInputElement).type === 'checkbox'
1319
+ )
1320
+ ) {
1149
1321
  if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
1150
1322
  return;
1151
1323
  }
@@ -1486,11 +1658,11 @@ export class DirListing extends Widget {
1486
1658
  } else {
1487
1659
  this.selection[path] = true;
1488
1660
  }
1489
-
1661
+ this._focusItem(index);
1490
1662
  // Handle multiple select.
1491
1663
  } else if (event.shiftKey) {
1492
- this._handleMultiSelect(selected, index);
1493
-
1664
+ this._handleMultiSelect(index);
1665
+ this._focusItem(index);
1494
1666
  // Handle a 'soft' selection
1495
1667
  } else if (path in this.selection && selected.length > 1) {
1496
1668
  this._softSelection = path;
@@ -1498,75 +1670,125 @@ export class DirListing extends Widget {
1498
1670
  // Default to selecting the only the item.
1499
1671
  } else {
1500
1672
  // Select only the given item.
1501
- return this._selectItem(index, false);
1673
+ return this._selectItem(index, false, true);
1502
1674
  }
1503
1675
  this.update();
1504
1676
  }
1505
1677
 
1506
1678
  /**
1507
- * (Re-)focus on the selected file.
1679
+ * (Re-)focus an item in the directory listing.
1508
1680
  *
1509
- * If index is not given, it will be inferred from the current selection;
1510
- * providing index saves on the iteration time.
1511
- */
1512
- private _focusSelectedFile(index?: number): void {
1513
- if (typeof index === 'undefined') {
1514
- const selected = Object.keys(this.selection);
1515
- if (selected.length > 1) {
1516
- // Multiselect - do not focus on any single file
1517
- return;
1518
- }
1519
- index = ArrayExt.findFirstIndex(
1520
- this._sortedItems,
1521
- value => value.path === selected[0]
1522
- );
1523
- }
1524
- if (index === -1) {
1681
+ * @param index The index of the item node to focus
1682
+ */
1683
+ private _focusItem(index: number): void {
1684
+ const items = this._items;
1685
+ if (items.length === 0) {
1686
+ // Focus the top node if the folder is empty and therefore there are no
1687
+ // items inside the folder to focus.
1688
+ this._focusIndex = 0;
1689
+ this.node.focus();
1525
1690
  return;
1526
1691
  }
1527
- // Focus on text to make shortcuts works
1528
- const node = this._items[index];
1529
- const text = DOMUtils.findElement(node, ITEM_TEXT_CLASS);
1530
- if (text) {
1531
- text.focus();
1692
+ this._focusIndex = index;
1693
+ const node = items[index];
1694
+ const nameNode = this.renderer.getNameNode(node);
1695
+ if (nameNode) {
1696
+ // Make the filename text node focusable so that it receives keyboard
1697
+ // events; text node was specifically chosen to receive shortcuts because
1698
+ // it gets substituted with input element during file name edits which
1699
+ // conveniently deactivates irrelevant shortcuts.
1700
+ nameNode.tabIndex = 0;
1701
+ nameNode.focus();
1702
+ }
1703
+ }
1704
+
1705
+ /**
1706
+ * Are all of the items between two provided indices selected?
1707
+ *
1708
+ * The items at the indices are not considered.
1709
+ *
1710
+ * @param j Index of one item.
1711
+ * @param k Index of another item. Note: may be less or greater than first
1712
+ * index.
1713
+ * @returns True if and only if all items between the j and k are selected.
1714
+ * Returns undefined if j and k are the same.
1715
+ */
1716
+ private _allSelectedBetween(j: number, k: number): boolean | void {
1717
+ if (j === k) {
1718
+ return;
1532
1719
  }
1720
+ const [start, end] = j < k ? [j + 1, k] : [k + 1, j];
1721
+ return this._sortedItems
1722
+ .slice(start, end)
1723
+ .reduce((result, item) => result && this.selection[item.path], true);
1533
1724
  }
1534
1725
 
1535
1726
  /**
1536
1727
  * Handle a multiple select on a file item node.
1537
1728
  */
1538
- private _handleMultiSelect(selected: string[], index: number): void {
1539
- // Find the "nearest selected".
1729
+ private _handleMultiSelect(index: number): void {
1540
1730
  const items = this._sortedItems;
1541
- let nearestIndex = -1;
1542
- for (let i = 0; i < this._items.length; i++) {
1543
- if (i === index) {
1544
- continue;
1545
- }
1546
- const path = items[i].path;
1547
- if (selected.indexOf(path) !== -1) {
1548
- if (nearestIndex === -1) {
1549
- nearestIndex = i;
1550
- } else {
1551
- if (Math.abs(index - i) < Math.abs(nearestIndex - i)) {
1552
- nearestIndex = i;
1553
- }
1554
- }
1555
- }
1731
+ const fromIndex = this._focusIndex;
1732
+ const target = items[index];
1733
+ let shouldAdd = true;
1734
+
1735
+ if (index === fromIndex) {
1736
+ // This follows the convention in Ubuntu and Windows, which is to allow
1737
+ // the focussed item to gain but not lose selected status on shift-click.
1738
+ // (MacOS is irrelevant here because MacOS Finder has no notion of a
1739
+ // focused-but-not-selected state.)
1740
+ this.selection[target.path] = true;
1741
+ return;
1556
1742
  }
1557
1743
 
1558
- // Default to the first element (and fill down).
1559
- if (nearestIndex === -1) {
1560
- nearestIndex = 0;
1744
+ // If the target and all items in-between are selected, then we assume that
1745
+ // the user is trying to shrink rather than grow the group of selected
1746
+ // items.
1747
+ if (this.selection[target.path]) {
1748
+ // However, there is a special case when the distance between the from-
1749
+ // and to- index is just one (for example, when the user is pressing the
1750
+ // shift key plus arrow-up/down). If and only if the situation looks like
1751
+ // the following when going down (or reverse when going up) ...
1752
+ //
1753
+ // - [ante-anchor / previous item] unselected (or boundary)
1754
+ // - [anchor / currently focussed item / item at from-index] selected
1755
+ // - [target / next item / item at to-index] selected
1756
+ //
1757
+ // ... then we shrink the selection / unselect the currently focussed
1758
+ // item.
1759
+ if (Math.abs(index - fromIndex) === 1) {
1760
+ const anchor = items[fromIndex];
1761
+ const anteAnchor = items[fromIndex + (index < fromIndex ? 1 : -1)];
1762
+ if (
1763
+ // Currently focussed item is selected
1764
+ this.selection[anchor.path] &&
1765
+ // Item on other side of focussed item (away from target) is either a
1766
+ // boundary or unselected
1767
+ (!anteAnchor || !this.selection[anteAnchor.path])
1768
+ ) {
1769
+ delete this.selection[anchor.path];
1770
+ }
1771
+ } else if (this._allSelectedBetween(fromIndex, index)) {
1772
+ shouldAdd = false;
1773
+ }
1561
1774
  }
1562
1775
 
1563
- // Select the rows between the current and the nearest selected.
1564
- for (let i = 0; i < this._items.length; i++) {
1565
- if (
1566
- (nearestIndex >= i && index <= i) ||
1567
- (nearestIndex <= i && index >= i)
1568
- ) {
1776
+ // Select (or unselect) the rows between chosen index (target) and the last
1777
+ // focussed.
1778
+ const step = fromIndex < index ? 1 : -1;
1779
+ for (let i = fromIndex; i !== index + step; i += step) {
1780
+ if (shouldAdd) {
1781
+ if (i === fromIndex) {
1782
+ // Do not change the selection state of the starting (fromIndex) item.
1783
+ continue;
1784
+ }
1569
1785
  this.selection[items[i].path] = true;
1786
+ } else {
1787
+ if (i === index) {
1788
+ // Do not unselect the target item.
1789
+ continue;
1790
+ }
1791
+ delete this.selection[items[i].path];
1570
1792
  }
1571
1793
  }
1572
1794
  }
@@ -1600,74 +1822,112 @@ export class DirListing extends Widget {
1600
1822
  /**
1601
1823
  * Allow the user to rename item on a given row.
1602
1824
  */
1603
- private _doRename(): Promise<string> {
1825
+ private async _doRename(): Promise<string> {
1604
1826
  this._inRename = true;
1827
+
1828
+ const selectedPaths = Object.keys(this.selection);
1829
+
1830
+ // Bail out if nothing has been selected.
1831
+ if (selectedPaths.length === 0) {
1832
+ this._inRename = false;
1833
+ return Promise.resolve('');
1834
+ }
1835
+
1836
+ // Figure out which selected path to use for the rename.
1605
1837
  const items = this._sortedItems;
1606
- const path = Object.keys(this.selection)[0];
1838
+ let { path } = items[this._focusIndex];
1839
+ if (!this.selection[path]) {
1840
+ // If the currently focused item is not selected, then choose the last
1841
+ // selected item.
1842
+ path = selectedPaths.slice(-1)[0];
1843
+ }
1844
+
1845
+ // Get the corresponding model, nodes, and file name.
1607
1846
  const index = ArrayExt.findFirstIndex(items, value => value.path === path);
1608
1847
  const row = this._items[index];
1609
1848
  const item = items[index];
1610
1849
  const nameNode = this.renderer.getNameNode(row);
1611
1850
  const original = item.name;
1851
+
1852
+ // Seed the text input with current file name, and select and focus it.
1612
1853
  this._editNode.value = original;
1613
- this._selectItem(index, false);
1614
-
1615
- return Private.doRename(nameNode, this._editNode, original).then(
1616
- newName => {
1617
- this.node.focus();
1618
- if (!newName || newName === original) {
1619
- this._inRename = false;
1620
- return original;
1621
- }
1622
- if (!isValidFileName(newName)) {
1854
+ this._selectItem(index, false, true);
1855
+
1856
+ // Wait for user input
1857
+ const newName = await Private.userInputForRename(
1858
+ nameNode,
1859
+ this._editNode,
1860
+ original
1861
+ );
1862
+
1863
+ // Check if the widget was disposed during the `await`.
1864
+ if (this.isDisposed) {
1865
+ this._inRename = false;
1866
+ throw new Error('File browser is disposed.');
1867
+ }
1868
+
1869
+ let finalFilename = newName;
1870
+
1871
+ if (!newName || newName === original) {
1872
+ finalFilename = original;
1873
+ } else if (!isValidFileName(newName)) {
1874
+ void showErrorMessage(
1875
+ this._trans.__('Rename Error'),
1876
+ Error(
1877
+ this._trans._p(
1878
+ 'showErrorMessage',
1879
+ '"%1" is not a valid name for a file. Names must have nonzero length, and cannot include "/", "\\", or ":"',
1880
+ newName
1881
+ )
1882
+ )
1883
+ );
1884
+ finalFilename = original;
1885
+ } else {
1886
+ // Attempt rename at the file system level.
1887
+
1888
+ const manager = this._manager;
1889
+ const oldPath = PathExt.join(this._model.path, original);
1890
+ const newPath = PathExt.join(this._model.path, newName);
1891
+ try {
1892
+ await renameFile(manager, oldPath, newPath);
1893
+ } catch (error) {
1894
+ if (error !== 'File not renamed') {
1623
1895
  void showErrorMessage(
1624
- this._trans.__('Rename Error'),
1625
- Error(
1626
- this._trans._p(
1627
- 'showErrorMessage',
1628
- '"%1" is not a valid name for a file. Names must have nonzero length, and cannot include "/", "\\", or ":"',
1629
- newName
1630
- )
1631
- )
1896
+ this._trans._p('showErrorMessage', 'Rename Error'),
1897
+ error
1632
1898
  );
1633
- this._inRename = false;
1634
- return original;
1635
1899
  }
1900
+ finalFilename = original;
1901
+ }
1636
1902
 
1637
- if (this.isDisposed) {
1638
- this._inRename = false;
1639
- throw new Error('File browser is disposed.');
1640
- }
1903
+ // Check if the widget was disposed during the `await`.
1904
+ if (this.isDisposed) {
1905
+ this._inRename = false;
1906
+ throw new Error('File browser is disposed.');
1907
+ }
1908
+ }
1641
1909
 
1642
- const manager = this._manager;
1643
- const oldPath = PathExt.join(this._model.path, original);
1644
- const newPath = PathExt.join(this._model.path, newName);
1645
- const promise = renameFile(manager, oldPath, newPath);
1646
- return promise
1647
- .catch(error => {
1648
- if (error !== 'File not renamed') {
1649
- void showErrorMessage(
1650
- this._trans._p('showErrorMessage', 'Rename Error'),
1651
- error
1652
- );
1653
- }
1654
- this._inRename = false;
1655
- return original;
1656
- })
1657
- .then(() => {
1658
- if (this.isDisposed) {
1659
- this._inRename = false;
1660
- throw new Error('File browser is disposed.');
1661
- }
1662
- if (this._inRename) {
1663
- // No need to catch because `newName` will always exit.
1664
- void this.selectItemByName(newName);
1665
- }
1666
- this._inRename = false;
1667
- return newName;
1668
- });
1910
+ // If nothing else has been selected, then select the renamed file. In
1911
+ // other words, don't select the renamed file if the user has clicked
1912
+ // away to some other file.
1913
+ if (
1914
+ !this.isDisposed &&
1915
+ Object.keys(this.selection).length === 1 &&
1916
+ // We haven't updated the instance yet to reflect the rename, so unless
1917
+ // the user or something else has updated the selection, the original file
1918
+ // path and not the new file path will be in `this.selection`.
1919
+ this.selection[item.path]
1920
+ ) {
1921
+ try {
1922
+ await this.selectItemByName(finalFilename, true);
1923
+ } catch {
1924
+ // do nothing
1925
+ console.warn('After rename, failed to select file', finalFilename);
1669
1926
  }
1670
- );
1927
+ }
1928
+
1929
+ this._inRename = false;
1930
+ return finalFilename;
1671
1931
  }
1672
1932
 
1673
1933
  /**
@@ -1686,8 +1946,8 @@ export class DirListing extends Widget {
1686
1946
  const path = items[index].path;
1687
1947
  this.selection[path] = true;
1688
1948
 
1689
- if (!keepExisting && focus) {
1690
- this._focusSelectedFile(index);
1949
+ if (focus) {
1950
+ this._focusItem(index);
1691
1951
  }
1692
1952
  this.update();
1693
1953
  }
@@ -1721,6 +1981,11 @@ export class DirListing extends Widget {
1721
1981
  this.clearSelectedItems();
1722
1982
  // Update the sorted items.
1723
1983
  this.sort(this.sortState);
1984
+ // Reset focus. But wait until the DOM has been updated (hence
1985
+ // `requestAnimationFrame`).
1986
+ requestAnimationFrame(() => {
1987
+ this._focusItem(0);
1988
+ });
1724
1989
  }
1725
1990
 
1726
1991
  /**
@@ -1789,6 +2054,8 @@ export class DirListing extends Widget {
1789
2054
  private _inRename = false;
1790
2055
  private _isDirty = false;
1791
2056
  private _hiddenColumns = new Set<DirListing.ToggleableColumn>();
2057
+ // _focusIndex should never be set outside the range [0, this._items.length - 1]
2058
+ private _focusIndex = 0;
1792
2059
  }
1793
2060
 
1794
2061
  /**
@@ -1971,7 +2238,8 @@ export namespace DirListing {
1971
2238
  header.className = HEADER_CLASS;
1972
2239
  node.appendChild(header);
1973
2240
  node.appendChild(content);
1974
- node.tabIndex = 0;
2241
+ // Set to -1 to allow calling this.node.focus().
2242
+ node.tabIndex = -1;
1975
2243
  return node;
1976
2244
  }
1977
2245
 
@@ -2154,12 +2422,6 @@ export namespace DirListing {
2154
2422
  node.appendChild(modified);
2155
2423
  node.appendChild(fileSize);
2156
2424
 
2157
- // Make the text note focusable so that it receives keyboard events;
2158
- // text node was specifically chosen to receive shortcuts because
2159
- // text element gets substituted with input area during file name edits
2160
- // which conveniently deactivate irrelevant shortcuts.
2161
- text.tabIndex = 0;
2162
-
2163
2425
  if (hiddenColumns?.has('last_modified')) {
2164
2426
  modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2165
2427
  } else {
@@ -2195,11 +2457,7 @@ export namespace DirListing {
2195
2457
  // Wrap the checkbox in a label element in order to increase its hit area.
2196
2458
  const labelWrapper = document.createElement('label');
2197
2459
  labelWrapper.classList.add(CHECKBOX_WRAPPER_CLASS);
2198
- // The individual file checkboxes are visible on hover, but the header
2199
- // check-all checkbox is always visible.
2200
- if (options?.alwaysVisible) {
2201
- labelWrapper.classList.add('jp-mod-visible');
2202
- }
2460
+
2203
2461
  const checkbox = document.createElement('input');
2204
2462
  checkbox.type = 'checkbox';
2205
2463
  // Prevent the user from clicking (via mouse, keyboard, or touch) the
@@ -2208,6 +2466,16 @@ export namespace DirListing {
2208
2466
  checkbox.addEventListener('click', event => {
2209
2467
  event.preventDefault();
2210
2468
  });
2469
+
2470
+ // The individual file checkboxes are visible on hover, but the header
2471
+ // check-all checkbox is always visible.
2472
+ if (options?.alwaysVisible) {
2473
+ labelWrapper.classList.add('jp-mod-visible');
2474
+ } else {
2475
+ // Disable tabbing to all other checkboxes.
2476
+ checkbox.tabIndex = -1;
2477
+ }
2478
+
2211
2479
  labelWrapper.appendChild(checkbox);
2212
2480
  return labelWrapper;
2213
2481
  }
@@ -2453,7 +2721,7 @@ namespace Private {
2453
2721
  *
2454
2722
  * @returns Boolean indicating whether the name changed.
2455
2723
  */
2456
- export function doRename(
2724
+ export function userInputForRename(
2457
2725
  text: HTMLElement,
2458
2726
  edit: HTMLInputElement,
2459
2727
  original: string
@@ -2468,7 +2736,7 @@ namespace Private {
2468
2736
  edit.setSelectionRange(0, index);
2469
2737
  }
2470
2738
 
2471
- return new Promise<string>((resolve, reject) => {
2739
+ return new Promise<string>(resolve => {
2472
2740
  edit.onblur = () => {
2473
2741
  parent.replaceChild(text, edit);
2474
2742
  resolve(edit.value);
@@ -2485,20 +2753,10 @@ namespace Private {
2485
2753
  event.preventDefault();
2486
2754
  edit.value = original;
2487
2755
  edit.blur();
2488
- break;
2489
- case 38: // Up arrow
2490
- event.stopPropagation();
2491
- event.preventDefault();
2492
- if (edit.selectionStart !== edit.selectionEnd) {
2493
- edit.selectionStart = edit.selectionEnd = 0;
2494
- }
2495
- break;
2496
- case 40: // Down arrow
2497
- event.stopPropagation();
2498
- event.preventDefault();
2499
- if (edit.selectionStart !== edit.selectionEnd) {
2500
- edit.selectionStart = edit.selectionEnd = edit.value.length;
2501
- }
2756
+ // Put focus back on the text node. That way the user can, for
2757
+ // example, press the keyboard shortcut to go back into edit mode,
2758
+ // and it will work.
2759
+ text.focus();
2502
2760
  break;
2503
2761
  default:
2504
2762
  break;