@jupyterlab/filebrowser 4.3.0-alpha.2 → 4.3.0-beta.1

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
@@ -15,6 +15,7 @@ import {
15
15
  } from '@jupyterlab/docmanager';
16
16
  import { DocumentRegistry } from '@jupyterlab/docregistry';
17
17
  import { Contents } from '@jupyterlab/services';
18
+ import { IStateDB } from '@jupyterlab/statedb';
18
19
  import {
19
20
  ITranslator,
20
21
  nullTranslator,
@@ -27,8 +28,13 @@ import {
27
28
  LabIcon
28
29
  } from '@jupyterlab/ui-components';
29
30
  import { ArrayExt, filter, StringExt } from '@lumino/algorithm';
30
- import { MimeData, PromiseDelegate } from '@lumino/coreutils';
31
+ import {
32
+ MimeData,
33
+ PromiseDelegate,
34
+ ReadonlyJSONObject
35
+ } from '@lumino/coreutils';
31
36
  import { ElementExt } from '@lumino/domutils';
37
+ import { DisposableDelegate, IDisposable } from '@lumino/disposable';
32
38
  import { Drag } from '@lumino/dragdrop';
33
39
  import { Message, MessageLoop } from '@lumino/messaging';
34
40
  import { ISignal, Signal } from '@lumino/signaling';
@@ -76,6 +82,11 @@ const ITEM_CLASS = 'jp-DirListing-item';
76
82
  */
77
83
  const ITEM_TEXT_CLASS = 'jp-DirListing-itemText';
78
84
 
85
+ /**
86
+ * The class name added to the listing item text cell.
87
+ */
88
+ const ITEM_NAME_COLUMN_CLASS = 'jp-DirListing-itemName';
89
+
79
90
  /**
80
91
  * The class name added to the listing item icon cell.
81
92
  */
@@ -117,21 +128,6 @@ const MODIFIED_ID_CLASS = 'jp-id-modified';
117
128
  */
118
129
  const FILE_SIZE_ID_CLASS = 'jp-id-filesize';
119
130
 
120
- /**
121
- * The class name added to the narrow column header cell.
122
- */
123
- const NARROW_ID_CLASS = 'jp-id-narrow';
124
-
125
- /**
126
- * The class name added to the modified column header cell and modified item cell when hidden.
127
- */
128
- const MODIFIED_COLUMN_HIDDEN = 'jp-LastModified-hidden';
129
-
130
- /**
131
- * The class name added to the size column header cell and size item cell when hidden.
132
- */
133
- const FILE_SIZE_COLUMN_HIDDEN = 'jp-FileSize-hidden';
134
-
135
131
  /**
136
132
  * The mime type for a contents drag object.
137
133
  */
@@ -157,6 +153,11 @@ const SELECTED_CLASS = 'jp-mod-selected';
157
153
  */
158
154
  const DRAG_ICON_CLASS = 'jp-DragIcon';
159
155
 
156
+ /**
157
+ * The class name added to column resize handle.
158
+ */
159
+ const RESIZE_HANDLE_CLASS = 'jp-DirListing-resizeHandle';
160
+
160
161
  /**
161
162
  * The class name added to the widget when there are items on the clipboard.
162
163
  */
@@ -177,6 +178,11 @@ const MULTI_SELECTED_CLASS = 'jp-mod-multiSelected';
177
178
  */
178
179
  const RUNNING_CLASS = 'jp-mod-running';
179
180
 
181
+ /**
182
+ * The class name added to indicate the active element.
183
+ */
184
+ const ACTIVE_CLASS = 'jp-mod-active';
185
+
180
186
  /**
181
187
  * The class name added for a descending sort.
182
188
  */
@@ -226,6 +232,7 @@ export class DirListing extends Widget {
226
232
  this._editNode.className = EDITOR_CLASS;
227
233
  this._manager = this._model.manager;
228
234
  this._renderer = options.renderer || DirListing.defaultRenderer;
235
+ this._state = options.state || null;
229
236
 
230
237
  // Get the width of the "modified" column
231
238
  this._updateModifiedSize(this.node);
@@ -236,7 +243,8 @@ export class DirListing extends Widget {
236
243
  this._renderer.populateHeaderNode(
237
244
  headerNode,
238
245
  this.translator,
239
- this._hiddenColumns
246
+ this._hiddenColumns,
247
+ this._columnSizes
240
248
  );
241
249
  this._manager.activateRequested.connect(this._onActivateRequested, this);
242
250
  }
@@ -493,6 +501,45 @@ export class DirListing extends Widget {
493
501
  );
494
502
  }
495
503
 
504
+ /**
505
+ * Restore the state of the file browser listing.
506
+ *
507
+ * @param id - The unique ID that is used to construct a state database key.
508
+ *
509
+ */
510
+ async restore(id: string): Promise<void> {
511
+ const key = `file-browser-${id}:columns`;
512
+ const state = this._state;
513
+ this._stateColumnsKey = key;
514
+
515
+ if (!state) {
516
+ return;
517
+ }
518
+
519
+ try {
520
+ const columns = await state.fetch(key);
521
+
522
+ if (!columns) {
523
+ return;
524
+ }
525
+
526
+ const sizes = (columns as ReadonlyJSONObject)['sizes'] as
527
+ | Record<DirListing.IColumn['id'], number | null>
528
+ | undefined;
529
+
530
+ if (!sizes) {
531
+ return;
532
+ }
533
+ for (const [key, size] of Object.entries(sizes)) {
534
+ this._columnSizes[key as DirListing.IColumn['id']] = size;
535
+ }
536
+ this._updateColumnSizes();
537
+ } catch (error) {
538
+ await state.remove(key);
539
+ }
540
+ }
541
+ private _stateColumnsKey: string;
542
+
496
543
  /**
497
544
  * Shut down kernels on the applicable currently selected items.
498
545
  *
@@ -756,6 +803,7 @@ export class DirListing extends Widget {
756
803
  protected onAfterAttach(msg: Message): void {
757
804
  super.onAfterAttach(msg);
758
805
  const node = this.node;
806
+ this._width = this.node.getBoundingClientRect().width;
759
807
  const content = DOMUtils.findElement(node, CONTENT_CLASS);
760
808
  node.addEventListener('mousedown', this);
761
809
  node.addEventListener('keydown', this);
@@ -809,11 +857,16 @@ export class DirListing extends Widget {
809
857
  }
810
858
  }
811
859
 
812
- // Update the modified column's size
860
+ /**
861
+ * Update the modified column's size
862
+ */
813
863
  private _updateModifiedSize(node: HTMLElement) {
814
864
  // Look for the modified column's header
815
865
  const modified = DOMUtils.findElement(node, MODIFIED_ID_CLASS);
816
- this._modifiedWidth = modified?.getBoundingClientRect().width ?? 83;
866
+ this._modifiedWidth =
867
+ this._columnSizes['last_modified'] ??
868
+ modified?.getBoundingClientRect().width ??
869
+ 83;
817
870
  this._modifiedStyle =
818
871
  this._modifiedWidth < 100
819
872
  ? 'narrow'
@@ -822,7 +875,21 @@ export class DirListing extends Widget {
822
875
  : 'short';
823
876
  }
824
877
 
825
- // Update only the modified dates.
878
+ /**
879
+ * Rerender item nodes' modified dates, if the modified style has changed.
880
+ */
881
+ private _updateModifiedStyleAndSize() {
882
+ const oldModifiedStyle = this._modifiedStyle;
883
+ // Update both size and style
884
+ this._updateModifiedSize(this.node);
885
+ if (oldModifiedStyle !== this._modifiedStyle) {
886
+ this.updateModified(this._sortedItems, this._items);
887
+ }
888
+ }
889
+
890
+ /**
891
+ * Update only the modified dates.
892
+ */
826
893
  protected updateModified(items: Contents.IModel[], nodes: HTMLElement[]) {
827
894
  items.forEach((item, i) => {
828
895
  const node = nodes[i];
@@ -846,9 +913,25 @@ export class DirListing extends Widget {
846
913
  }
847
914
 
848
915
  // Update item nodes based on widget state.
849
- protected updateNodes(items: Contents.IModel[], nodes: HTMLElement[]) {
916
+ protected updateNodes(
917
+ items: Contents.IModel[],
918
+ nodes: HTMLElement[],
919
+ sizeOnly = false
920
+ ) {
850
921
  items.forEach((item, i) => {
851
922
  const node = nodes[i];
923
+ if (sizeOnly && this.renderer.updateItemSize) {
924
+ if (!node) {
925
+ // short-circuit in case if node is not yet ready
926
+ return;
927
+ }
928
+ return this.renderer.updateItemSize(
929
+ node,
930
+ item,
931
+ this._modifiedStyle,
932
+ this._columnSizes
933
+ );
934
+ }
852
935
  const ft = this._manager.registry.getFileTypeForModel(item);
853
936
  this.renderer.updateItemNode(
854
937
  node,
@@ -857,7 +940,8 @@ export class DirListing extends Widget {
857
940
  this.translator,
858
941
  this._hiddenColumns,
859
942
  this.selection[item.path],
860
- this._modifiedStyle
943
+ this._modifiedStyle,
944
+ this._columnSizes
861
945
  );
862
946
  if (
863
947
  this.selection[item.path] &&
@@ -925,7 +1009,10 @@ export class DirListing extends Widget {
925
1009
 
926
1010
  // Add any missing item nodes.
927
1011
  while (nodes.length < items.length) {
928
- const node = renderer.createItemNode(this._hiddenColumns);
1012
+ const node = renderer.createItemNode(
1013
+ this._hiddenColumns,
1014
+ this._columnSizes
1015
+ );
929
1016
  node.classList.add(ITEM_CLASS);
930
1017
  nodes.push(node);
931
1018
  content.appendChild(node);
@@ -984,15 +1071,8 @@ export class DirListing extends Widget {
984
1071
  onResize(msg: Widget.ResizeMessage): void {
985
1072
  const { width } =
986
1073
  msg.width === -1 ? this.node.getBoundingClientRect() : msg;
987
- this.toggleClass('jp-DirListing-narrow', width < 250);
988
-
989
- // Rerender item nodes' modified dates, if the modified style has changed.
990
- const oldModifiedStyle = this._modifiedStyle;
991
- // Update both size and style
992
- this._updateModifiedSize(this.node);
993
- if (oldModifiedStyle !== this._modifiedStyle) {
994
- this.updateModified(this._sortedItems, this._items);
995
- }
1074
+ this._width = width;
1075
+ this._updateColumnSizes();
996
1076
  }
997
1077
 
998
1078
  setColumnVisibility(
@@ -1009,10 +1089,116 @@ export class DirListing extends Widget {
1009
1089
  this._renderer.populateHeaderNode(
1010
1090
  this.headerNode,
1011
1091
  this.translator,
1012
- this._hiddenColumns
1092
+ this._hiddenColumns,
1093
+ this._columnSizes
1094
+ );
1095
+
1096
+ this._updateColumnSizes();
1097
+ }
1098
+
1099
+ private _updateColumnSizes() {
1100
+ // adjust column sizes so that they add up to the total width available, preserving ratios
1101
+ // and removing width from the last column so that it fills the width available;
1102
+ const visibleColumns = this._visibleColumns.map(column => ({
1103
+ ...column,
1104
+ element: DOMUtils.findElement(this.node, column.className)
1105
+ }));
1106
+
1107
+ // read from DOM
1108
+ let total = 0;
1109
+ for (const column of visibleColumns) {
1110
+ let size = this._columnSizes[column.id];
1111
+ if (size === null) {
1112
+ size = column.element.getBoundingClientRect().width;
1113
+ }
1114
+ // restrict the minimum and maximum width
1115
+ size = Math.max(size, column.minWidth);
1116
+ if (this._width) {
1117
+ let reservedForOtherColums = 0;
1118
+ for (const other of visibleColumns) {
1119
+ if (other.id === column.id) {
1120
+ continue;
1121
+ }
1122
+ reservedForOtherColums += other.minWidth;
1123
+ }
1124
+ size = Math.min(size, this._width - reservedForOtherColums);
1125
+ }
1126
+ this._columnSizes[column.id] = size;
1127
+ total += size;
1128
+ }
1129
+
1130
+ // Ensure that total fits
1131
+ if (this._width && total > this._width) {
1132
+ for (const column of visibleColumns) {
1133
+ this._columnSizes[column.id] =
1134
+ (this._columnSizes[column.id]! / total) * this._width;
1135
+ }
1136
+ }
1137
+
1138
+ // Write to DOM
1139
+ for (const column of visibleColumns) {
1140
+ const size = this._columnSizes[column.id];
1141
+ column.element.style.width = size === null ? '' : size + 'px';
1142
+ }
1143
+ this._updateModifiedStyleAndSize();
1144
+
1145
+ // Refresh sizes on the per item widths
1146
+ if (this.isVisible) {
1147
+ const items = this._items;
1148
+ if (items.length !== 0) {
1149
+ this.updateNodes(this._sortedItems, this._items, true);
1150
+ }
1151
+ }
1152
+
1153
+ if (this._state && this._stateColumnsKey) {
1154
+ void this._state.save(this._stateColumnsKey, {
1155
+ sizes: this._columnSizes
1156
+ });
1157
+ }
1158
+ }
1159
+
1160
+ private get _visibleColumns() {
1161
+ return DirListing.columns.filter(
1162
+ column => column.id === 'name' || !this._hiddenColumns?.has(column.id)
1013
1163
  );
1014
1164
  }
1015
1165
 
1166
+ private _setColumnSize(
1167
+ name: DirListing.ResizableColumn,
1168
+ size: number | null
1169
+ ): void {
1170
+ const previousSize = this._columnSizes[name];
1171
+ if (previousSize && size && size > previousSize) {
1172
+ // check if we can resize up
1173
+ let total = 0;
1174
+ let before = true;
1175
+ for (const column of this._visibleColumns) {
1176
+ if (column.id === name) {
1177
+ // add proposed size for the current columns
1178
+ total += size;
1179
+ before = false;
1180
+ continue;
1181
+ }
1182
+ if (before) {
1183
+ // add size as-is for columns before
1184
+ const element = DOMUtils.findElement(this.node, column.className);
1185
+ total +=
1186
+ this._columnSizes[column.id] ??
1187
+ element.getBoundingClientRect().width;
1188
+ } else {
1189
+ // add minimum acceptable size for columns after
1190
+ total += column.minWidth;
1191
+ }
1192
+ }
1193
+ if (this._width && total > this._width) {
1194
+ // up sizing is no longer possible
1195
+ return;
1196
+ }
1197
+ }
1198
+ this._columnSizes[name] = size;
1199
+ this._updateColumnSizes();
1200
+ }
1201
+
1016
1202
  /**
1017
1203
  * Update the setting to sort notebooks above files.
1018
1204
  * This sorts the items again if the internal value is modified.
@@ -1025,6 +1211,14 @@ export class DirListing extends Widget {
1025
1211
  }
1026
1212
  }
1027
1213
 
1214
+ /**
1215
+ * Update the setting to allow single click navigation.
1216
+ * This enables opening files/directories with a single click.
1217
+ */
1218
+ setAllowSingleClickNavigation(isEnabled: boolean) {
1219
+ this._allowSingleClick = isEnabled;
1220
+ }
1221
+
1028
1222
  /**
1029
1223
  * Would this click (or other event type) hit the checkbox by default?
1030
1224
  */
@@ -1113,6 +1307,43 @@ export class DirListing extends Widget {
1113
1307
  let index = Private.hitTestNodes(this._items, event);
1114
1308
 
1115
1309
  if (index === -1) {
1310
+ // Left mouse press for drag or resize start.
1311
+ if (event.button === 0) {
1312
+ const resizeHandle = event.target;
1313
+ if (
1314
+ resizeHandle instanceof HTMLElement &&
1315
+ resizeHandle.classList.contains(RESIZE_HANDLE_CLASS)
1316
+ ) {
1317
+ const columnId = resizeHandle.dataset.column as
1318
+ | DirListing.ResizableColumn
1319
+ | undefined;
1320
+ if (!columnId) {
1321
+ throw Error(
1322
+ 'Column resize handle is missing data-column attribute'
1323
+ );
1324
+ }
1325
+ const column = DirListing.columns.find(c => c.id === columnId);
1326
+ if (!column) {
1327
+ throw Error(`Column with identifier ${columnId} not found`);
1328
+ }
1329
+ const element = DOMUtils.findElement(this.node, column.className);
1330
+ resizeHandle.classList.add(ACTIVE_CLASS);
1331
+ const cursorOverride = Drag.overrideCursor('col-resize');
1332
+
1333
+ this._resizeData = {
1334
+ pressX: event.clientX,
1335
+ column: columnId,
1336
+ initialSize: element.getBoundingClientRect().width,
1337
+ overrides: new DisposableDelegate(() => {
1338
+ cursorOverride.dispose();
1339
+ resizeHandle.classList.remove(ACTIVE_CLASS);
1340
+ })
1341
+ };
1342
+ document.addEventListener('mouseup', this, true);
1343
+ document.addEventListener('mousemove', this, true);
1344
+ return;
1345
+ }
1346
+ }
1116
1347
  return;
1117
1348
  }
1118
1349
 
@@ -1128,7 +1359,7 @@ export class DirListing extends Widget {
1128
1359
  return;
1129
1360
  }
1130
1361
 
1131
- // Left mouse press for drag start.
1362
+ // Left mouse press for drag or resize start.
1132
1363
  if (event.button === 0) {
1133
1364
  this._dragData = {
1134
1365
  pressX: event.clientX,
@@ -1138,6 +1369,10 @@ export class DirListing extends Widget {
1138
1369
  document.addEventListener('mouseup', this, true);
1139
1370
  document.addEventListener('mousemove', this, true);
1140
1371
  }
1372
+
1373
+ if (this._allowSingleClick) {
1374
+ this.evtDblClick(event as MouseEvent);
1375
+ }
1141
1376
  }
1142
1377
 
1143
1378
  /**
@@ -1162,12 +1397,21 @@ export class DirListing extends Widget {
1162
1397
  this._focusItem(this._focusIndex);
1163
1398
  }
1164
1399
 
1400
+ // Remove the resize listeners if necessary.
1401
+ if (this._resizeData) {
1402
+ this._resizeData.overrides.dispose();
1403
+ document.removeEventListener('mousemove', this, true);
1404
+ document.removeEventListener('mouseup', this, true);
1405
+ return;
1406
+ }
1407
+
1165
1408
  // Remove the drag listeners if necessary.
1166
1409
  if (event.button !== 0 || !this._drag) {
1167
1410
  document.removeEventListener('mousemove', this, true);
1168
1411
  document.removeEventListener('mouseup', this, true);
1169
1412
  return;
1170
1413
  }
1414
+
1171
1415
  event.preventDefault();
1172
1416
  event.stopPropagation();
1173
1417
  }
@@ -1179,6 +1423,12 @@ export class DirListing extends Widget {
1179
1423
  event.preventDefault();
1180
1424
  event.stopPropagation();
1181
1425
 
1426
+ if (this._resizeData) {
1427
+ const { initialSize, column, pressX } = this._resizeData;
1428
+ this._setColumnSize(column, initialSize + event.clientX - pressX);
1429
+ return;
1430
+ }
1431
+
1182
1432
  // Bail if we are the one dragging.
1183
1433
  if (this._drag || !this._dragData) {
1184
1434
  return;
@@ -1468,31 +1718,70 @@ export class DirListing extends Widget {
1468
1718
  * Handle the `drop` event for the widget.
1469
1719
  */
1470
1720
  protected evtNativeDrop(event: DragEvent): void {
1471
- const files = event.dataTransfer?.files;
1472
- if (!files || files.length === 0) {
1473
- return;
1474
- }
1475
- const length = event.dataTransfer?.items.length;
1476
- if (!length) {
1721
+ // Prevent navigation
1722
+ event.preventDefault();
1723
+
1724
+ const items = event.dataTransfer?.items;
1725
+ if (!items) {
1726
+ // Fallback to simple upload of files (if any)
1727
+ const files = event.dataTransfer?.files;
1728
+ if (!files || files.length === 0) {
1729
+ return;
1730
+ }
1731
+ const promises = [];
1732
+ for (const file of files) {
1733
+ const promise = this._model.upload(file);
1734
+ promises.push(promise);
1735
+ }
1736
+ Promise.all(promises)
1737
+ .then(() => this._allUploaded.emit())
1738
+ .catch(err => {
1739
+ console.error('Error while uploading files: ', err);
1740
+ });
1477
1741
  return;
1478
1742
  }
1479
- for (let i = 0; i < length; i++) {
1480
- let entry = event.dataTransfer?.items[i].webkitGetAsEntry();
1481
- if (entry?.isDirectory) {
1482
- console.log('currently not supporting drag + drop for folders');
1483
- void showDialog({
1484
- title: this._trans.__('Error Uploading Folder'),
1485
- body: this._trans.__(
1486
- 'Drag and Drop is currently not supported for folders'
1487
- ),
1488
- buttons: [Dialog.cancelButton({ label: this._trans.__('Close') })]
1489
- });
1743
+
1744
+ const uploadEntry = async (entry: FileSystemEntry, path: string) => {
1745
+ if (Private.isDirectoryEntry(entry)) {
1746
+ const dirPath = await Private.createDirectory(
1747
+ this._model.manager,
1748
+ path,
1749
+ entry.name
1750
+ );
1751
+ const directoryReader = entry.createReader();
1752
+
1753
+ const allEntries = await Private.collectEntries(directoryReader);
1754
+ for (const childEntry of allEntries) {
1755
+ await uploadEntry(childEntry, dirPath);
1756
+ }
1757
+ } else if (Private.isFileEntry(entry)) {
1758
+ const file = await Private.readFile(entry);
1759
+ await this._model.upload(file, path);
1490
1760
  }
1761
+ };
1762
+
1763
+ const promises = [];
1764
+ for (const item of items) {
1765
+ const entry = Private.defensiveGetAsEntry(item);
1766
+
1767
+ if (!entry) {
1768
+ continue;
1769
+ }
1770
+ const promise = uploadEntry(entry, this._model.path ?? '/');
1771
+ promises.push(promise);
1491
1772
  }
1492
- event.preventDefault();
1493
- for (let i = 0; i < files.length; i++) {
1494
- void this._model.upload(files[i]);
1495
- }
1773
+ Promise.all(promises)
1774
+ .then(() => this._allUploaded.emit())
1775
+ .catch(err => {
1776
+ console.error('Error while uploading files: ', err);
1777
+ });
1778
+ }
1779
+
1780
+ /**
1781
+ * Signal emitted on when all files were uploaded after native drag.
1782
+ */
1783
+ protected get allUploaded(): ISignal<DirListing, void> {
1784
+ return this._allUploaded;
1496
1785
  }
1497
1786
 
1498
1787
  /**
@@ -2130,6 +2419,24 @@ export class DirListing extends Widget {
2130
2419
  pressY: number;
2131
2420
  index: number;
2132
2421
  } | null = null;
2422
+ private _resizeData: {
2423
+ /**
2424
+ * Cursor position when the resize started.
2425
+ */
2426
+ pressX: number;
2427
+ /**
2428
+ * Identifier of the column being resized.
2429
+ */
2430
+ column: DirListing.ResizableColumn;
2431
+ /**
2432
+ * Size of the column when the cursor grabbed the resize handle.
2433
+ */
2434
+ initialSize: number;
2435
+ /**
2436
+ * The disposable to clear the cursor override and resize handle.
2437
+ */
2438
+ readonly overrides: IDisposable;
2439
+ } | null = null;
2133
2440
  private _selectTimer = -1;
2134
2441
  private _isCut = false;
2135
2442
  private _prevPath = '';
@@ -2143,12 +2450,22 @@ export class DirListing extends Widget {
2143
2450
  private _inRename = false;
2144
2451
  private _isDirty = false;
2145
2452
  private _hiddenColumns = new Set<DirListing.ToggleableColumn>();
2453
+ private _columnSizes: Record<DirListing.IColumn['id'], number | null> = {
2454
+ name: null,
2455
+ file_size: null,
2456
+ is_selected: null,
2457
+ last_modified: null
2458
+ };
2146
2459
  private _sortNotebooksFirst = false;
2460
+ private _allowSingleClick = false;
2147
2461
  // _focusIndex should never be set outside the range [0, this._items.length - 1]
2148
2462
  private _focusIndex = 0;
2149
2463
  // Width of the "last modified" column for an individual file
2150
2464
  private _modifiedWidth: number;
2151
2465
  private _modifiedStyle: Time.HumanStyle;
2466
+ private _allUploaded = new Signal<DirListing, void>(this);
2467
+ private _width: number | null = null;
2468
+ private _state: IStateDB | null = null;
2152
2469
  }
2153
2470
 
2154
2471
  /**
@@ -2175,6 +2492,12 @@ export namespace DirListing {
2175
2492
  * A language translator.
2176
2493
  */
2177
2494
  translator?: ITranslator;
2495
+
2496
+ /**
2497
+ * An optional state database. If provided, the widget will restore
2498
+ * the columns sizes
2499
+ */
2500
+ state?: IStateDB;
2178
2501
  }
2179
2502
 
2180
2503
  /**
@@ -2189,7 +2512,7 @@ export namespace DirListing {
2189
2512
  /**
2190
2513
  * The sort key.
2191
2514
  */
2192
- key: 'name' | 'last_modified' | 'file_size';
2515
+ key: SortableColumn;
2193
2516
  }
2194
2517
 
2195
2518
  /**
@@ -2197,6 +2520,16 @@ export namespace DirListing {
2197
2520
  */
2198
2521
  export type ToggleableColumn = 'last_modified' | 'is_selected' | 'file_size';
2199
2522
 
2523
+ /**
2524
+ * Resizable columns.
2525
+ */
2526
+ export type ResizableColumn = 'name' | 'last_modified' | 'file_size';
2527
+
2528
+ /**
2529
+ * Sortable columns.
2530
+ */
2531
+ export type SortableColumn = 'name' | 'last_modified' | 'file_size';
2532
+
2200
2533
  /**
2201
2534
  * A file contents model thunk.
2202
2535
  *
@@ -2233,7 +2566,8 @@ export namespace DirListing {
2233
2566
  populateHeaderNode(
2234
2567
  node: HTMLElement,
2235
2568
  translator?: ITranslator,
2236
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2569
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2570
+ columnsSizes?: Record<IColumn['id'], number | null>
2237
2571
  ): void;
2238
2572
 
2239
2573
  /**
@@ -2253,7 +2587,8 @@ export namespace DirListing {
2253
2587
  * @returns A new DOM node to use as a content item.
2254
2588
  */
2255
2589
  createItemNode(
2256
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2590
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2591
+ columnsSizes?: Record<IColumn['id'], number | null>
2257
2592
  ): HTMLElement;
2258
2593
 
2259
2594
  /**
@@ -2289,7 +2624,18 @@ export namespace DirListing {
2289
2624
  translator?: ITranslator,
2290
2625
  hiddenColumns?: Set<DirListing.ToggleableColumn>,
2291
2626
  selected?: boolean,
2292
- modifiedStyle?: Time.HumanStyle
2627
+ modifiedStyle?: Time.HumanStyle,
2628
+ columnsSizes?: Record<IColumn['id'], number | null>
2629
+ ): void;
2630
+
2631
+ /**
2632
+ * Update size of item nodes, assuming that model has not changed.
2633
+ */
2634
+ updateItemSize?(
2635
+ node: HTMLElement,
2636
+ model: Contents.IModel,
2637
+ modifiedStyle?: Time.HumanStyle,
2638
+ columnsSizes?: Record<IColumn['id'], number | null>
2293
2639
  ): void;
2294
2640
 
2295
2641
  /**
@@ -2334,6 +2680,91 @@ export namespace DirListing {
2334
2680
  ): HTMLElement;
2335
2681
  }
2336
2682
 
2683
+ interface IBaseColumn {
2684
+ /**
2685
+ * Name of the header class, must be unique among other columns.
2686
+ */
2687
+ className: string;
2688
+ /**
2689
+ * Name of the item class, must be unique among other columns.
2690
+ */
2691
+ itemClassName: string;
2692
+ /**
2693
+ * Minimum size the column should occupy.
2694
+ */
2695
+ minWidth: number;
2696
+ }
2697
+ interface IFixedColumn extends IBaseColumn {
2698
+ id: 'is_selected';
2699
+ resizable: false;
2700
+ sortable: false;
2701
+ }
2702
+ /**
2703
+ * Sortable column.
2704
+ */
2705
+ export interface ISortableColumn extends IBaseColumn {
2706
+ id: SortableColumn;
2707
+ sortable: true;
2708
+ caretSide: 'left' | 'right';
2709
+ }
2710
+ /**
2711
+ * Resizable column.
2712
+ */
2713
+ export interface IResizableColumn extends IBaseColumn {
2714
+ id: ResizableColumn;
2715
+ resizable: true;
2716
+ }
2717
+
2718
+ /**
2719
+ * Columns types supported by DirListing.
2720
+ */
2721
+ export type IColumn =
2722
+ | IFixedColumn
2723
+ | ISortableColumn
2724
+ | IResizableColumn
2725
+ | (ISortableColumn & IResizableColumn);
2726
+
2727
+ /**
2728
+ * Column definitions.
2729
+ */
2730
+ export const columns: IColumn[] = [
2731
+ {
2732
+ id: 'is_selected' as const,
2733
+ className: CHECKBOX_WRAPPER_CLASS,
2734
+ itemClassName: CHECKBOX_WRAPPER_CLASS,
2735
+ minWidth: 18,
2736
+ resizable: false,
2737
+ sortable: false
2738
+ },
2739
+ {
2740
+ id: 'name' as const,
2741
+ className: NAME_ID_CLASS,
2742
+ itemClassName: ITEM_NAME_COLUMN_CLASS,
2743
+ minWidth: 60,
2744
+ resizable: true,
2745
+ sortable: true,
2746
+ caretSide: 'right'
2747
+ },
2748
+ {
2749
+ id: 'last_modified' as const,
2750
+ className: MODIFIED_ID_CLASS,
2751
+ itemClassName: ITEM_MODIFIED_CLASS,
2752
+ minWidth: 60,
2753
+ resizable: true,
2754
+ sortable: true,
2755
+ caretSide: 'left'
2756
+ },
2757
+ {
2758
+ id: 'file_size' as const,
2759
+ className: FILE_SIZE_ID_CLASS,
2760
+ itemClassName: ITEM_FILE_SIZE_CLASS,
2761
+ minWidth: 60,
2762
+ resizable: true,
2763
+ sortable: true,
2764
+ caretSide: 'left'
2765
+ }
2766
+ ];
2767
+
2337
2768
  /**
2338
2769
  * The default implementation of an `IRenderer`.
2339
2770
  */
@@ -2364,47 +2795,57 @@ export namespace DirListing {
2364
2795
  populateHeaderNode(
2365
2796
  node: HTMLElement,
2366
2797
  translator?: ITranslator,
2367
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2798
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2799
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
2368
2800
  ): void {
2369
2801
  translator = translator || nullTranslator;
2370
2802
  const trans = translator.load('jupyterlab');
2371
- const name = this.createHeaderItemNode(trans.__('Name'));
2372
- const narrow = document.createElement('div');
2373
- const modified = this._createHeaderItemNodeWithSizes({
2374
- small: trans.__('Modified'),
2375
- large: trans.__('Last Modified')
2376
- });
2377
- const fileSize = this.createHeaderItemNode(trans.__('File Size'));
2378
- name.classList.add(NAME_ID_CLASS);
2379
- name.classList.add(SELECTED_CLASS);
2380
- modified.classList.add(MODIFIED_ID_CLASS);
2381
- fileSize.classList.add(FILE_SIZE_ID_CLASS);
2382
- narrow.classList.add(NARROW_ID_CLASS);
2383
- narrow.textContent = '...';
2384
- if (!hiddenColumns?.has('is_selected')) {
2385
- const checkboxWrapper = this.createCheckboxWrapperNode({
2386
- alwaysVisible: true,
2387
- headerNode: true
2388
- });
2389
- node.appendChild(checkboxWrapper);
2390
- }
2391
- node.appendChild(name);
2392
- node.appendChild(narrow);
2393
- node.appendChild(modified);
2394
- node.appendChild(fileSize);
2395
2803
 
2396
- if (hiddenColumns?.has('last_modified')) {
2397
- modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2398
- } else {
2399
- modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
2400
- }
2804
+ const elementCreators = {
2805
+ name: () => this.createHeaderItemNode(trans.__('Name')),
2806
+ last_modified: () =>
2807
+ this._createHeaderItemNodeWithSizes({
2808
+ small: trans.__('Modified'),
2809
+ large: trans.__('Last Modified')
2810
+ }),
2811
+ file_size: () => this.createHeaderItemNode(trans.__('File Size')),
2812
+ is_selected: () =>
2813
+ this.createCheckboxWrapperNode({
2814
+ alwaysVisible: true,
2815
+ headerNode: true
2816
+ })
2817
+ };
2401
2818
 
2402
- if (hiddenColumns?.has('file_size')) {
2403
- fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2404
- } else {
2405
- fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
2819
+ const visibleColumns = columns.filter(
2820
+ column => column.id === 'name' || !hiddenColumns?.has(column.id)
2821
+ );
2822
+
2823
+ for (const column of visibleColumns) {
2824
+ const createElement = elementCreators[column.id];
2825
+ const element = createElement();
2826
+ element.classList.add(column.className);
2827
+ const isLastVisible =
2828
+ column.id === visibleColumns[visibleColumns.length - 1].id;
2829
+
2830
+ if (columnsSizes) {
2831
+ const size = columnsSizes[column.id];
2832
+ if (!isLastVisible) {
2833
+ element.style.width = size + 'px';
2834
+ }
2835
+ }
2836
+ node.appendChild(element);
2837
+
2838
+ if (Private.isResizable(column) && !isLastVisible) {
2839
+ const resizer = document.createElement('div');
2840
+ resizer.classList.add(RESIZE_HANDLE_CLASS);
2841
+ resizer.dataset.column = column.id;
2842
+ node.appendChild(resizer);
2843
+ }
2406
2844
  }
2407
2845
 
2846
+ const name = DOMUtils.findElement(node, NAME_ID_CLASS);
2847
+ name.classList.add(SELECTED_CLASS);
2848
+
2408
2849
  // set the initial caret icon
2409
2850
  Private.updateCaret(
2410
2851
  DOMUtils.findElement(name, HEADER_ITEM_ICON_CLASS),
@@ -2423,92 +2864,56 @@ export namespace DirListing {
2423
2864
  * @returns The sort state of the header after the click event.
2424
2865
  */
2425
2866
  handleHeaderClick(node: HTMLElement, event: MouseEvent): ISortState | null {
2426
- const name = DOMUtils.findElement(node, NAME_ID_CLASS);
2427
- const modified = DOMUtils.findElement(node, MODIFIED_ID_CLASS);
2428
- const fileSize = DOMUtils.findElement(node, FILE_SIZE_ID_CLASS);
2429
2867
  const state: ISortState = { direction: 'ascending', key: 'name' };
2430
2868
  const target = event.target as HTMLElement;
2431
2869
 
2432
- const modifiedIcon = DOMUtils.findElement(
2433
- modified,
2434
- HEADER_ITEM_ICON_CLASS
2435
- );
2436
- const fileSizeIcon = DOMUtils.findElement(
2437
- fileSize,
2438
- HEADER_ITEM_ICON_CLASS
2439
- );
2440
- const nameIcon = DOMUtils.findElement(name, HEADER_ITEM_ICON_CLASS);
2441
-
2442
- if (name.contains(target)) {
2443
- if (name.classList.contains(SELECTED_CLASS)) {
2444
- if (!name.classList.contains(DESCENDING_CLASS)) {
2445
- state.direction = 'descending';
2446
- name.classList.add(DESCENDING_CLASS);
2447
- Private.updateCaret(nameIcon, 'right', 'down');
2448
- } else {
2449
- name.classList.remove(DESCENDING_CLASS);
2450
- Private.updateCaret(nameIcon, 'right', 'up');
2451
- }
2452
- } else {
2453
- name.classList.remove(DESCENDING_CLASS);
2454
- Private.updateCaret(nameIcon, 'right', 'up');
2870
+ const sortableColumns = DirListing.columns.filter(Private.isSortable);
2871
+
2872
+ for (const column of sortableColumns) {
2873
+ const header = node.querySelector(`.${column.className}`);
2874
+ if (!header) {
2875
+ // skip if the column is hidden
2876
+ continue;
2455
2877
  }
2456
- name.classList.add(SELECTED_CLASS);
2457
- modified.classList.remove(SELECTED_CLASS);
2458
- modified.classList.remove(DESCENDING_CLASS);
2459
- fileSize.classList.remove(SELECTED_CLASS);
2460
- fileSize.classList.remove(DESCENDING_CLASS);
2461
- Private.updateCaret(modifiedIcon, 'left');
2462
- Private.updateCaret(fileSizeIcon, 'left');
2463
- return state;
2464
- }
2465
- if (modified.contains(target)) {
2466
- state.key = 'last_modified';
2467
- if (modified.classList.contains(SELECTED_CLASS)) {
2468
- if (!modified.classList.contains(DESCENDING_CLASS)) {
2469
- state.direction = 'descending';
2470
- modified.classList.add(DESCENDING_CLASS);
2471
- Private.updateCaret(modifiedIcon, 'left', 'down');
2878
+ if (header.contains(target)) {
2879
+ state.key = column.id;
2880
+ const headerIcon = DOMUtils.findElement(
2881
+ header as HTMLElement,
2882
+ HEADER_ITEM_ICON_CLASS
2883
+ );
2884
+ if (header.classList.contains(SELECTED_CLASS)) {
2885
+ if (!header.classList.contains(DESCENDING_CLASS)) {
2886
+ state.direction = 'descending';
2887
+ header.classList.add(DESCENDING_CLASS);
2888
+ Private.updateCaret(headerIcon, column.caretSide, 'down');
2889
+ } else {
2890
+ header.classList.remove(DESCENDING_CLASS);
2891
+ Private.updateCaret(headerIcon, column.caretSide, 'up');
2892
+ }
2472
2893
  } else {
2473
- modified.classList.remove(DESCENDING_CLASS);
2474
- Private.updateCaret(modifiedIcon, 'left', 'up');
2894
+ header.classList.remove(DESCENDING_CLASS);
2895
+ Private.updateCaret(headerIcon, column.caretSide, 'up');
2475
2896
  }
2476
- } else {
2477
- modified.classList.remove(DESCENDING_CLASS);
2478
- Private.updateCaret(modifiedIcon, 'left', 'up');
2479
- }
2480
- modified.classList.add(SELECTED_CLASS);
2481
- name.classList.remove(SELECTED_CLASS);
2482
- name.classList.remove(DESCENDING_CLASS);
2483
- fileSize.classList.remove(SELECTED_CLASS);
2484
- fileSize.classList.remove(DESCENDING_CLASS);
2485
- Private.updateCaret(nameIcon, 'right');
2486
- Private.updateCaret(fileSizeIcon, 'left');
2487
- return state;
2488
- }
2489
- if (fileSize.contains(target)) {
2490
- state.key = 'file_size';
2491
- if (fileSize.classList.contains(SELECTED_CLASS)) {
2492
- if (!fileSize.classList.contains(DESCENDING_CLASS)) {
2493
- state.direction = 'descending';
2494
- fileSize.classList.add(DESCENDING_CLASS);
2495
- Private.updateCaret(fileSizeIcon, 'left', 'down');
2496
- } else {
2497
- fileSize.classList.remove(DESCENDING_CLASS);
2498
- Private.updateCaret(fileSizeIcon, 'left', 'up');
2897
+ header.classList.add(SELECTED_CLASS);
2898
+ for (const otherColumn of sortableColumns) {
2899
+ if (otherColumn.id === column.id) {
2900
+ continue;
2901
+ }
2902
+ const otherHeader = node.querySelector(`.${otherColumn.className}`);
2903
+ if (!otherHeader) {
2904
+ // skip if hidden
2905
+ continue;
2906
+ }
2907
+ otherHeader.classList.remove(SELECTED_CLASS);
2908
+ otherHeader.classList.remove(DESCENDING_CLASS);
2909
+ const otherHeaderIcon = DOMUtils.findElement(
2910
+ otherHeader as HTMLElement,
2911
+ HEADER_ITEM_ICON_CLASS
2912
+ );
2913
+ Private.updateCaret(otherHeaderIcon, otherColumn.caretSide);
2499
2914
  }
2500
- } else {
2501
- fileSize.classList.remove(DESCENDING_CLASS);
2502
- Private.updateCaret(fileSizeIcon, 'left', 'up');
2915
+ return state;
2503
2916
  }
2504
- fileSize.classList.add(SELECTED_CLASS);
2505
- name.classList.remove(SELECTED_CLASS);
2506
- name.classList.remove(DESCENDING_CLASS);
2507
- modified.classList.remove(SELECTED_CLASS);
2508
- modified.classList.remove(DESCENDING_CLASS);
2509
- Private.updateCaret(nameIcon, 'right');
2510
- Private.updateCaret(modifiedIcon, 'left');
2511
- return state;
2512
2917
  }
2513
2918
  return state;
2514
2919
  }
@@ -2519,36 +2924,23 @@ export namespace DirListing {
2519
2924
  * @returns A new DOM node to use as a content item.
2520
2925
  */
2521
2926
  createItemNode(
2522
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2927
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2928
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
2523
2929
  ): HTMLElement {
2524
2930
  const node = document.createElement('li');
2525
- const icon = document.createElement('span');
2526
- const text = document.createElement('span');
2527
- const modified = document.createElement('span');
2528
- const fileSize = document.createElement('span');
2529
- icon.className = ITEM_ICON_CLASS;
2530
- text.className = ITEM_TEXT_CLASS;
2531
- modified.className = ITEM_MODIFIED_CLASS;
2532
- fileSize.className = ITEM_FILE_SIZE_CLASS;
2533
- if (!hiddenColumns?.has('is_selected')) {
2534
- const checkboxWrapper = this.createCheckboxWrapperNode();
2535
- node.appendChild(checkboxWrapper);
2536
- }
2537
- node.appendChild(icon);
2538
- node.appendChild(text);
2539
- node.appendChild(modified);
2540
- node.appendChild(fileSize);
2541
2931
 
2542
- if (hiddenColumns?.has('last_modified')) {
2543
- modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2544
- } else {
2545
- modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
2546
- }
2932
+ for (const column of columns) {
2933
+ if (column.id != 'name' && hiddenColumns?.has(column.id)) {
2934
+ continue;
2935
+ }
2936
+ const createElement = this.itemFactories[column.id];
2937
+ const element = createElement();
2938
+ node.appendChild(element);
2547
2939
 
2548
- if (hiddenColumns?.has('file_size')) {
2549
- fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2550
- } else {
2551
- fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
2940
+ if (columnsSizes) {
2941
+ const size = columnsSizes[column.id];
2942
+ element.style.width = size + 'px';
2943
+ }
2552
2944
  }
2553
2945
 
2554
2946
  return node;
@@ -2614,16 +3006,29 @@ export namespace DirListing {
2614
3006
  modifiedDate: string,
2615
3007
  modifiedStyle: Time.HumanStyle
2616
3008
  ): void {
2617
- let modText = '';
2618
- let modTitle = '';
3009
+ // Formatting dates is expensive (0.1-0.2ms per call,
3010
+ // so over 150 files can easily already choke the renderer),
3011
+ // let's do the bare minimum check of comparing if an update
3012
+ // is needed using a last update cache:
3013
+ const previousUpdate = this._modifiedColumnLastUpdate.get(modified);
3014
+ if (
3015
+ previousUpdate?.date === modifiedDate &&
3016
+ previousUpdate?.style === modifiedStyle
3017
+ ) {
3018
+ return;
3019
+ }
2619
3020
 
2620
3021
  const parsedDate = new Date(modifiedDate);
2621
3022
  // Render the date in one of multiple formats, depending on the container's size
2622
- modText = Time.formatHuman(parsedDate, modifiedStyle);
2623
- modTitle = Time.format(parsedDate);
3023
+ const modText = Time.formatHuman(parsedDate, modifiedStyle);
3024
+ const modTitle = Time.format(parsedDate);
2624
3025
 
2625
3026
  modified.textContent = modText;
2626
3027
  modified.title = modTitle;
3028
+ this._modifiedColumnLastUpdate.set(modified, {
3029
+ date: modifiedDate,
3030
+ style: modifiedStyle
3031
+ });
2627
3032
  }
2628
3033
 
2629
3034
  /**
@@ -2643,7 +3048,8 @@ export namespace DirListing {
2643
3048
  translator?: ITranslator,
2644
3049
  hiddenColumns?: Set<DirListing.ToggleableColumn>,
2645
3050
  selected?: boolean,
2646
- modifiedStyle?: Time.HumanStyle
3051
+ modifiedStyle?: Time.HumanStyle,
3052
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
2647
3053
  ): void {
2648
3054
  if (selected) {
2649
3055
  node.classList.add(SELECTED_CLASS);
@@ -2657,8 +3063,13 @@ export namespace DirListing {
2657
3063
 
2658
3064
  const iconContainer = DOMUtils.findElement(node, ITEM_ICON_CLASS);
2659
3065
  const text = DOMUtils.findElement(node, ITEM_TEXT_CLASS);
2660
- const modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS);
2661
- const fileSize = DOMUtils.findElement(node, ITEM_FILE_SIZE_CLASS);
3066
+ const nameColumn = DOMUtils.findElement(node, ITEM_NAME_COLUMN_CLASS);
3067
+ let modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS) as
3068
+ | HTMLElement
3069
+ | undefined;
3070
+ let fileSize = DOMUtils.findElement(node, ITEM_FILE_SIZE_CLASS) as
3071
+ | HTMLElement
3072
+ | undefined;
2662
3073
  const checkboxWrapper = DOMUtils.findElement(
2663
3074
  node,
2664
3075
  CHECKBOX_WRAPPER_CLASS
@@ -2669,19 +3080,23 @@ export namespace DirListing {
2669
3080
  node.removeChild(checkboxWrapper);
2670
3081
  } else if (showFileCheckboxes && !checkboxWrapper) {
2671
3082
  const checkboxWrapper = this.createCheckboxWrapperNode();
2672
- node.insertBefore(checkboxWrapper, iconContainer);
3083
+ nameColumn.insertAdjacentElement('beforebegin', checkboxWrapper);
2673
3084
  }
2674
3085
 
2675
- if (hiddenColumns?.has('last_modified')) {
2676
- modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2677
- } else {
2678
- modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
3086
+ const showModified = !hiddenColumns?.has('last_modified');
3087
+ if (modified && !showModified) {
3088
+ node.removeChild(modified);
3089
+ } else if (showModified && !modified) {
3090
+ modified = this.itemFactories.last_modified();
3091
+ nameColumn.insertAdjacentElement('afterend', modified);
2679
3092
  }
2680
3093
 
2681
- if (hiddenColumns?.has('file_size')) {
2682
- fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2683
- } else {
2684
- fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
3094
+ const showFileSize = !hiddenColumns?.has('file_size');
3095
+ if (fileSize && !showFileSize) {
3096
+ node.removeChild(fileSize);
3097
+ } else if (showFileSize && !fileSize) {
3098
+ fileSize = this.itemFactories.file_size();
3099
+ (modified ?? nameColumn).insertAdjacentElement('afterend', fileSize);
2685
3100
  }
2686
3101
 
2687
3102
  // render the file item's icon
@@ -2698,12 +3113,14 @@ export namespace DirListing {
2698
3113
  // add file size to pop up if its available
2699
3114
  if (model.size !== null && model.size !== undefined) {
2700
3115
  const fileSizeText = Private.formatFileSize(model.size, 1, 1024);
2701
- fileSize.textContent = fileSizeText;
3116
+ if (fileSize) {
3117
+ fileSize.textContent = fileSizeText;
3118
+ }
2702
3119
  hoverText += trans.__(
2703
3120
  '\nSize: %1',
2704
3121
  Private.formatFileSize(model.size, 1, 1024)
2705
3122
  );
2706
- } else {
3123
+ } else if (fileSize) {
2707
3124
  fileSize.textContent = '';
2708
3125
  }
2709
3126
  if (model.path) {
@@ -2746,7 +3163,7 @@ export namespace DirListing {
2746
3163
  // Adds an aria-label to the checkbox element.
2747
3164
  const checkbox = checkboxWrapper?.querySelector(
2748
3165
  'input[type="checkbox"]'
2749
- ) as HTMLInputElement;
3166
+ ) as HTMLInputElement | undefined;
2750
3167
 
2751
3168
  if (checkbox) {
2752
3169
  let ariaLabel: string;
@@ -2763,7 +3180,36 @@ export namespace DirListing {
2763
3180
  checkbox.checked = selected ?? false;
2764
3181
  }
2765
3182
 
2766
- if (model.last_modified) {
3183
+ this.updateItemSize(node, model, modifiedStyle, columnsSizes);
3184
+ }
3185
+
3186
+ /**
3187
+ * Update size of item nodes, assuming that model has not changed.
3188
+ */
3189
+ updateItemSize(
3190
+ node: HTMLElement,
3191
+ model: Contents.IModel,
3192
+ modifiedStyle?: Time.HumanStyle,
3193
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
3194
+ ): void {
3195
+ if (columnsSizes) {
3196
+ for (const column of columns) {
3197
+ const element = DOMUtils.findElement(node, column.itemClassName);
3198
+ if (!element) {
3199
+ continue;
3200
+ }
3201
+ const sizeSpec = columnsSizes[column.id];
3202
+ const newWidth = sizeSpec === null ? '' : sizeSpec + 'px';
3203
+ if (newWidth !== element.style.width) {
3204
+ element.style.width = newWidth;
3205
+ }
3206
+ }
3207
+ }
3208
+ let modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS) as
3209
+ | HTMLElement
3210
+ | undefined;
3211
+
3212
+ if (model.last_modified && modified) {
2767
3213
  this.updateItemModified(
2768
3214
  modified,
2769
3215
  model.last_modified,
@@ -2835,6 +3281,34 @@ export namespace DirListing {
2835
3281
  return dragImage;
2836
3282
  }
2837
3283
 
3284
+ /**
3285
+ * Factories for individual parts of the item.
3286
+ */
3287
+ protected itemFactories = {
3288
+ name: () => {
3289
+ const name = document.createElement('span');
3290
+ const icon = document.createElement('span');
3291
+ const text = document.createElement('span');
3292
+ icon.className = ITEM_ICON_CLASS;
3293
+ text.className = ITEM_TEXT_CLASS;
3294
+ name.className = ITEM_NAME_COLUMN_CLASS;
3295
+ name.appendChild(icon);
3296
+ name.appendChild(text);
3297
+ return name;
3298
+ },
3299
+ last_modified: () => {
3300
+ const modified = document.createElement('span');
3301
+ modified.className = ITEM_MODIFIED_CLASS;
3302
+ return modified;
3303
+ },
3304
+ file_size: () => {
3305
+ const fileSize = document.createElement('span');
3306
+ fileSize.className = ITEM_FILE_SIZE_CLASS;
3307
+ return fileSize;
3308
+ },
3309
+ is_selected: () => this.createCheckboxWrapperNode()
3310
+ };
3311
+
2838
3312
  /**
2839
3313
  * Create a node for a header item.
2840
3314
  */
@@ -2873,6 +3347,14 @@ export namespace DirListing {
2873
3347
  node.appendChild(icon);
2874
3348
  return node;
2875
3349
  }
3350
+
3351
+ /**
3352
+ * Register of most recent arguments for last modified column update.
3353
+ */
3354
+ private _modifiedColumnLastUpdate = new WeakMap<
3355
+ HTMLElement,
3356
+ { date: string; style: Time.HumanStyle }
3357
+ >();
2876
3358
  }
2877
3359
 
2878
3360
  /**
@@ -3017,6 +3499,24 @@ namespace Private {
3017
3499
  return copy;
3018
3500
  }
3019
3501
 
3502
+ /**
3503
+ * Check if the column is resizable.
3504
+ */
3505
+ export const isResizable = (
3506
+ column: DirListing.IColumn
3507
+ ): column is DirListing.IResizableColumn => {
3508
+ return 'resizable' in column && column.resizable;
3509
+ };
3510
+
3511
+ /**
3512
+ * Check if the column is sortable.
3513
+ */
3514
+ export const isSortable = (
3515
+ column: DirListing.IColumn
3516
+ ): column is DirListing.ISortableColumn => {
3517
+ return 'sortable' in column && column.sortable;
3518
+ };
3519
+
3020
3520
  /**
3021
3521
  * Get the index of the node at a client position, or `-1`.
3022
3522
  */
@@ -3066,13 +3566,87 @@ namespace Private {
3066
3566
  (state === 'down' ? caretDownIcon : caretUpIcon).element({
3067
3567
  container,
3068
3568
  tag: 'span',
3069
- stylesheet: 'listingHeaderItem',
3070
-
3071
- float
3569
+ stylesheet: 'listingHeaderItem'
3072
3570
  });
3571
+ if (float === 'left') {
3572
+ container.style.order = '-1';
3573
+ } else {
3574
+ container.style.order = '';
3575
+ }
3073
3576
  } else {
3074
3577
  LabIcon.remove(container);
3075
3578
  container.className = HEADER_ITEM_ICON_CLASS;
3076
3579
  }
3077
3580
  }
3581
+
3582
+ export async function createDirectory(
3583
+ manager: IDocumentManager,
3584
+ path: string,
3585
+ name: string
3586
+ ): Promise<string> {
3587
+ const model = await manager.newUntitled({
3588
+ path: path,
3589
+ type: 'directory'
3590
+ });
3591
+ const tmpDirPath = PathExt.join(path, model.name);
3592
+ const dirPath = PathExt.join(path, name);
3593
+ try {
3594
+ await manager.rename(tmpDirPath, dirPath);
3595
+ } catch (e) {
3596
+ // The `dirPath` already exists, remove the temporary new directory
3597
+ await manager.deleteFile(tmpDirPath);
3598
+ }
3599
+ return dirPath;
3600
+ }
3601
+
3602
+ export function isDirectoryEntry(
3603
+ entry: FileSystemEntry
3604
+ ): entry is FileSystemDirectoryEntry {
3605
+ return entry.isDirectory;
3606
+ }
3607
+ export function isFileEntry(
3608
+ entry: FileSystemEntry
3609
+ ): entry is FileSystemFileEntry {
3610
+ return entry.isFile;
3611
+ }
3612
+
3613
+ export function defensiveGetAsEntry(
3614
+ item: DataTransferItem
3615
+ ): FileSystemEntry | null {
3616
+ if (item.webkitGetAsEntry) {
3617
+ return item.webkitGetAsEntry();
3618
+ }
3619
+ if ('getAsEntry' in item) {
3620
+ // See https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
3621
+ return (item['getAsEntry'] as () => FileSystemEntry | null)();
3622
+ }
3623
+ return null;
3624
+ }
3625
+
3626
+ function readEntries(reader: FileSystemDirectoryReader) {
3627
+ return new Promise<FileSystemEntry[]>((resolve, reject) =>
3628
+ reader.readEntries(resolve, reject)
3629
+ );
3630
+ }
3631
+
3632
+ export function readFile(entry: FileSystemFileEntry) {
3633
+ return new Promise<File>((resolve, reject) => entry.file(resolve, reject));
3634
+ }
3635
+
3636
+ export async function collectEntries(reader: FileSystemDirectoryReader) {
3637
+ // Spec requires calling `readEntries` until these are exhausted;
3638
+ // in practice this is only required in Chromium-based browsers for >100 files.
3639
+ // https://issues.chromium.org/issues/41110876
3640
+ const allEntries: FileSystemEntry[] = [];
3641
+ let done = false;
3642
+ while (!done) {
3643
+ const entries = await readEntries(reader);
3644
+ if (entries.length === 0) {
3645
+ done = true;
3646
+ } else {
3647
+ allEntries.push(...entries);
3648
+ }
3649
+ }
3650
+ return allEntries;
3651
+ }
3078
3652
  }