@jupyterlab/filebrowser 4.3.0-alpha.2 → 4.3.0-beta.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
@@ -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,21 @@ 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
+ return this.renderer.updateItemSize(
925
+ node,
926
+ item,
927
+ this._modifiedStyle,
928
+ this._columnSizes
929
+ );
930
+ }
852
931
  const ft = this._manager.registry.getFileTypeForModel(item);
853
932
  this.renderer.updateItemNode(
854
933
  node,
@@ -857,7 +936,8 @@ export class DirListing extends Widget {
857
936
  this.translator,
858
937
  this._hiddenColumns,
859
938
  this.selection[item.path],
860
- this._modifiedStyle
939
+ this._modifiedStyle,
940
+ this._columnSizes
861
941
  );
862
942
  if (
863
943
  this.selection[item.path] &&
@@ -925,7 +1005,10 @@ export class DirListing extends Widget {
925
1005
 
926
1006
  // Add any missing item nodes.
927
1007
  while (nodes.length < items.length) {
928
- const node = renderer.createItemNode(this._hiddenColumns);
1008
+ const node = renderer.createItemNode(
1009
+ this._hiddenColumns,
1010
+ this._columnSizes
1011
+ );
929
1012
  node.classList.add(ITEM_CLASS);
930
1013
  nodes.push(node);
931
1014
  content.appendChild(node);
@@ -984,15 +1067,8 @@ export class DirListing extends Widget {
984
1067
  onResize(msg: Widget.ResizeMessage): void {
985
1068
  const { width } =
986
1069
  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
- }
1070
+ this._width = width;
1071
+ this._updateColumnSizes();
996
1072
  }
997
1073
 
998
1074
  setColumnVisibility(
@@ -1009,8 +1085,114 @@ export class DirListing extends Widget {
1009
1085
  this._renderer.populateHeaderNode(
1010
1086
  this.headerNode,
1011
1087
  this.translator,
1012
- this._hiddenColumns
1088
+ this._hiddenColumns,
1089
+ this._columnSizes
1013
1090
  );
1091
+
1092
+ this._updateColumnSizes();
1093
+ }
1094
+
1095
+ private _updateColumnSizes() {
1096
+ // adjust column sizes so that they add up to the total width available, preserving ratios
1097
+ // and removing width from the last column so that it fills the width available;
1098
+ const visibleColumns = this._visibleColumns.map(column => ({
1099
+ ...column,
1100
+ element: DOMUtils.findElement(this.node, column.className)
1101
+ }));
1102
+
1103
+ // read from DOM
1104
+ let total = 0;
1105
+ for (const column of visibleColumns) {
1106
+ let size = this._columnSizes[column.id];
1107
+ if (size === null) {
1108
+ size = column.element.getBoundingClientRect().width;
1109
+ }
1110
+ // restrict the minimum and maximum width
1111
+ size = Math.max(size, column.minWidth);
1112
+ if (this._width) {
1113
+ let reservedForOtherColums = 0;
1114
+ for (const other of visibleColumns) {
1115
+ if (other.id === column.id) {
1116
+ continue;
1117
+ }
1118
+ reservedForOtherColums += other.minWidth;
1119
+ }
1120
+ size = Math.min(size, this._width - reservedForOtherColums);
1121
+ }
1122
+ this._columnSizes[column.id] = size;
1123
+ total += size;
1124
+ }
1125
+
1126
+ // Ensure that total fits
1127
+ if (this._width && total > this._width) {
1128
+ for (const column of visibleColumns) {
1129
+ this._columnSizes[column.id] =
1130
+ (this._columnSizes[column.id]! / total) * this._width;
1131
+ }
1132
+ }
1133
+
1134
+ // Write to DOM
1135
+ for (const column of visibleColumns) {
1136
+ const size = this._columnSizes[column.id];
1137
+ column.element.style.width = size === null ? '' : size + 'px';
1138
+ }
1139
+ this._updateModifiedStyleAndSize();
1140
+
1141
+ // Refresh sizes on the per item widths
1142
+ if (this.isVisible) {
1143
+ const items = this._items;
1144
+ if (items.length !== 0) {
1145
+ this.updateNodes(this._sortedItems, this._items, true);
1146
+ }
1147
+ }
1148
+
1149
+ if (this._state && this._stateColumnsKey) {
1150
+ void this._state.save(this._stateColumnsKey, {
1151
+ sizes: this._columnSizes
1152
+ });
1153
+ }
1154
+ }
1155
+
1156
+ private get _visibleColumns() {
1157
+ return DirListing.columns.filter(
1158
+ column => column.id === 'name' || !this._hiddenColumns?.has(column.id)
1159
+ );
1160
+ }
1161
+
1162
+ private _setColumnSize(
1163
+ name: DirListing.ResizableColumn,
1164
+ size: number | null
1165
+ ): void {
1166
+ const previousSize = this._columnSizes[name];
1167
+ if (previousSize && size && size > previousSize) {
1168
+ // check if we can resize up
1169
+ let total = 0;
1170
+ let before = true;
1171
+ for (const column of this._visibleColumns) {
1172
+ if (column.id === name) {
1173
+ // add proposed size for the current columns
1174
+ total += size;
1175
+ before = false;
1176
+ continue;
1177
+ }
1178
+ if (before) {
1179
+ // add size as-is for columns before
1180
+ const element = DOMUtils.findElement(this.node, column.className);
1181
+ total +=
1182
+ this._columnSizes[column.id] ??
1183
+ element.getBoundingClientRect().width;
1184
+ } else {
1185
+ // add minimum acceptable size for columns after
1186
+ total += column.minWidth;
1187
+ }
1188
+ }
1189
+ if (this._width && total > this._width) {
1190
+ // up sizing is no longer possible
1191
+ return;
1192
+ }
1193
+ }
1194
+ this._columnSizes[name] = size;
1195
+ this._updateColumnSizes();
1014
1196
  }
1015
1197
 
1016
1198
  /**
@@ -1025,6 +1207,14 @@ export class DirListing extends Widget {
1025
1207
  }
1026
1208
  }
1027
1209
 
1210
+ /**
1211
+ * Update the setting to allow single click navigation.
1212
+ * This enables opening files/directories with a single click.
1213
+ */
1214
+ setAllowSingleClickNavigation(isEnabled: boolean) {
1215
+ this._allowSingleClick = isEnabled;
1216
+ }
1217
+
1028
1218
  /**
1029
1219
  * Would this click (or other event type) hit the checkbox by default?
1030
1220
  */
@@ -1113,6 +1303,43 @@ export class DirListing extends Widget {
1113
1303
  let index = Private.hitTestNodes(this._items, event);
1114
1304
 
1115
1305
  if (index === -1) {
1306
+ // Left mouse press for drag or resize start.
1307
+ if (event.button === 0) {
1308
+ const resizeHandle = event.target;
1309
+ if (
1310
+ resizeHandle instanceof HTMLElement &&
1311
+ resizeHandle.classList.contains(RESIZE_HANDLE_CLASS)
1312
+ ) {
1313
+ const columnId = resizeHandle.dataset.column as
1314
+ | DirListing.ResizableColumn
1315
+ | undefined;
1316
+ if (!columnId) {
1317
+ throw Error(
1318
+ 'Column resize handle is missing data-column attribute'
1319
+ );
1320
+ }
1321
+ const column = DirListing.columns.find(c => c.id === columnId);
1322
+ if (!column) {
1323
+ throw Error(`Column with identifier ${columnId} not found`);
1324
+ }
1325
+ const element = DOMUtils.findElement(this.node, column.className);
1326
+ resizeHandle.classList.add(ACTIVE_CLASS);
1327
+ const cursorOverride = Drag.overrideCursor('col-resize');
1328
+
1329
+ this._resizeData = {
1330
+ pressX: event.clientX,
1331
+ column: columnId,
1332
+ initialSize: element.getBoundingClientRect().width,
1333
+ overrides: new DisposableDelegate(() => {
1334
+ cursorOverride.dispose();
1335
+ resizeHandle.classList.remove(ACTIVE_CLASS);
1336
+ })
1337
+ };
1338
+ document.addEventListener('mouseup', this, true);
1339
+ document.addEventListener('mousemove', this, true);
1340
+ return;
1341
+ }
1342
+ }
1116
1343
  return;
1117
1344
  }
1118
1345
 
@@ -1128,7 +1355,7 @@ export class DirListing extends Widget {
1128
1355
  return;
1129
1356
  }
1130
1357
 
1131
- // Left mouse press for drag start.
1358
+ // Left mouse press for drag or resize start.
1132
1359
  if (event.button === 0) {
1133
1360
  this._dragData = {
1134
1361
  pressX: event.clientX,
@@ -1138,6 +1365,10 @@ export class DirListing extends Widget {
1138
1365
  document.addEventListener('mouseup', this, true);
1139
1366
  document.addEventListener('mousemove', this, true);
1140
1367
  }
1368
+
1369
+ if (this._allowSingleClick) {
1370
+ this.evtDblClick(event as MouseEvent);
1371
+ }
1141
1372
  }
1142
1373
 
1143
1374
  /**
@@ -1162,12 +1393,21 @@ export class DirListing extends Widget {
1162
1393
  this._focusItem(this._focusIndex);
1163
1394
  }
1164
1395
 
1396
+ // Remove the resize listeners if necessary.
1397
+ if (this._resizeData) {
1398
+ this._resizeData.overrides.dispose();
1399
+ document.removeEventListener('mousemove', this, true);
1400
+ document.removeEventListener('mouseup', this, true);
1401
+ return;
1402
+ }
1403
+
1165
1404
  // Remove the drag listeners if necessary.
1166
1405
  if (event.button !== 0 || !this._drag) {
1167
1406
  document.removeEventListener('mousemove', this, true);
1168
1407
  document.removeEventListener('mouseup', this, true);
1169
1408
  return;
1170
1409
  }
1410
+
1171
1411
  event.preventDefault();
1172
1412
  event.stopPropagation();
1173
1413
  }
@@ -1179,6 +1419,12 @@ export class DirListing extends Widget {
1179
1419
  event.preventDefault();
1180
1420
  event.stopPropagation();
1181
1421
 
1422
+ if (this._resizeData) {
1423
+ const { initialSize, column, pressX } = this._resizeData;
1424
+ this._setColumnSize(column, initialSize + event.clientX - pressX);
1425
+ return;
1426
+ }
1427
+
1182
1428
  // Bail if we are the one dragging.
1183
1429
  if (this._drag || !this._dragData) {
1184
1430
  return;
@@ -1468,31 +1714,70 @@ export class DirListing extends Widget {
1468
1714
  * Handle the `drop` event for the widget.
1469
1715
  */
1470
1716
  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) {
1717
+ // Prevent navigation
1718
+ event.preventDefault();
1719
+
1720
+ const items = event.dataTransfer?.items;
1721
+ if (!items) {
1722
+ // Fallback to simple upload of files (if any)
1723
+ const files = event.dataTransfer?.files;
1724
+ if (!files || files.length === 0) {
1725
+ return;
1726
+ }
1727
+ const promises = [];
1728
+ for (const file of files) {
1729
+ const promise = this._model.upload(file);
1730
+ promises.push(promise);
1731
+ }
1732
+ Promise.all(promises)
1733
+ .then(() => this._allUploaded.emit())
1734
+ .catch(err => {
1735
+ console.error('Error while uploading files: ', err);
1736
+ });
1477
1737
  return;
1478
1738
  }
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
- });
1739
+
1740
+ const uploadEntry = async (entry: FileSystemEntry, path: string) => {
1741
+ if (Private.isDirectoryEntry(entry)) {
1742
+ const dirPath = await Private.createDirectory(
1743
+ this._model.manager,
1744
+ path,
1745
+ entry.name
1746
+ );
1747
+ const directoryReader = entry.createReader();
1748
+
1749
+ const allEntries = await Private.collectEntries(directoryReader);
1750
+ for (const childEntry of allEntries) {
1751
+ await uploadEntry(childEntry, dirPath);
1752
+ }
1753
+ } else if (Private.isFileEntry(entry)) {
1754
+ const file = await Private.readFile(entry);
1755
+ await this._model.upload(file, path);
1490
1756
  }
1757
+ };
1758
+
1759
+ const promises = [];
1760
+ for (const item of items) {
1761
+ const entry = Private.defensiveGetAsEntry(item);
1762
+
1763
+ if (!entry) {
1764
+ continue;
1765
+ }
1766
+ const promise = uploadEntry(entry, this._model.path ?? '/');
1767
+ promises.push(promise);
1491
1768
  }
1492
- event.preventDefault();
1493
- for (let i = 0; i < files.length; i++) {
1494
- void this._model.upload(files[i]);
1495
- }
1769
+ Promise.all(promises)
1770
+ .then(() => this._allUploaded.emit())
1771
+ .catch(err => {
1772
+ console.error('Error while uploading files: ', err);
1773
+ });
1774
+ }
1775
+
1776
+ /**
1777
+ * Signal emitted on when all files were uploaded after native drag.
1778
+ */
1779
+ protected get allUploaded(): ISignal<DirListing, void> {
1780
+ return this._allUploaded;
1496
1781
  }
1497
1782
 
1498
1783
  /**
@@ -2130,6 +2415,24 @@ export class DirListing extends Widget {
2130
2415
  pressY: number;
2131
2416
  index: number;
2132
2417
  } | null = null;
2418
+ private _resizeData: {
2419
+ /**
2420
+ * Cursor position when the resize started.
2421
+ */
2422
+ pressX: number;
2423
+ /**
2424
+ * Identifier of the column being resized.
2425
+ */
2426
+ column: DirListing.ResizableColumn;
2427
+ /**
2428
+ * Size of the column when the cursor grabbed the resize handle.
2429
+ */
2430
+ initialSize: number;
2431
+ /**
2432
+ * The disposable to clear the cursor override and resize handle.
2433
+ */
2434
+ readonly overrides: IDisposable;
2435
+ } | null = null;
2133
2436
  private _selectTimer = -1;
2134
2437
  private _isCut = false;
2135
2438
  private _prevPath = '';
@@ -2143,12 +2446,22 @@ export class DirListing extends Widget {
2143
2446
  private _inRename = false;
2144
2447
  private _isDirty = false;
2145
2448
  private _hiddenColumns = new Set<DirListing.ToggleableColumn>();
2449
+ private _columnSizes: Record<DirListing.IColumn['id'], number | null> = {
2450
+ name: null,
2451
+ file_size: null,
2452
+ is_selected: null,
2453
+ last_modified: null
2454
+ };
2146
2455
  private _sortNotebooksFirst = false;
2456
+ private _allowSingleClick = false;
2147
2457
  // _focusIndex should never be set outside the range [0, this._items.length - 1]
2148
2458
  private _focusIndex = 0;
2149
2459
  // Width of the "last modified" column for an individual file
2150
2460
  private _modifiedWidth: number;
2151
2461
  private _modifiedStyle: Time.HumanStyle;
2462
+ private _allUploaded = new Signal<DirListing, void>(this);
2463
+ private _width: number | null = null;
2464
+ private _state: IStateDB | null = null;
2152
2465
  }
2153
2466
 
2154
2467
  /**
@@ -2175,6 +2488,12 @@ export namespace DirListing {
2175
2488
  * A language translator.
2176
2489
  */
2177
2490
  translator?: ITranslator;
2491
+
2492
+ /**
2493
+ * An optional state database. If provided, the widget will restore
2494
+ * the columns sizes
2495
+ */
2496
+ state?: IStateDB;
2178
2497
  }
2179
2498
 
2180
2499
  /**
@@ -2189,7 +2508,7 @@ export namespace DirListing {
2189
2508
  /**
2190
2509
  * The sort key.
2191
2510
  */
2192
- key: 'name' | 'last_modified' | 'file_size';
2511
+ key: SortableColumn;
2193
2512
  }
2194
2513
 
2195
2514
  /**
@@ -2197,6 +2516,16 @@ export namespace DirListing {
2197
2516
  */
2198
2517
  export type ToggleableColumn = 'last_modified' | 'is_selected' | 'file_size';
2199
2518
 
2519
+ /**
2520
+ * Resizable columns.
2521
+ */
2522
+ export type ResizableColumn = 'name' | 'last_modified' | 'file_size';
2523
+
2524
+ /**
2525
+ * Sortable columns.
2526
+ */
2527
+ export type SortableColumn = 'name' | 'last_modified' | 'file_size';
2528
+
2200
2529
  /**
2201
2530
  * A file contents model thunk.
2202
2531
  *
@@ -2233,7 +2562,8 @@ export namespace DirListing {
2233
2562
  populateHeaderNode(
2234
2563
  node: HTMLElement,
2235
2564
  translator?: ITranslator,
2236
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2565
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2566
+ columnsSizes?: Record<IColumn['id'], number | null>
2237
2567
  ): void;
2238
2568
 
2239
2569
  /**
@@ -2253,7 +2583,8 @@ export namespace DirListing {
2253
2583
  * @returns A new DOM node to use as a content item.
2254
2584
  */
2255
2585
  createItemNode(
2256
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2586
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2587
+ columnsSizes?: Record<IColumn['id'], number | null>
2257
2588
  ): HTMLElement;
2258
2589
 
2259
2590
  /**
@@ -2289,7 +2620,18 @@ export namespace DirListing {
2289
2620
  translator?: ITranslator,
2290
2621
  hiddenColumns?: Set<DirListing.ToggleableColumn>,
2291
2622
  selected?: boolean,
2292
- modifiedStyle?: Time.HumanStyle
2623
+ modifiedStyle?: Time.HumanStyle,
2624
+ columnsSizes?: Record<IColumn['id'], number | null>
2625
+ ): void;
2626
+
2627
+ /**
2628
+ * Update size of item nodes, assuming that model has not changed.
2629
+ */
2630
+ updateItemSize?(
2631
+ node: HTMLElement,
2632
+ model: Contents.IModel,
2633
+ modifiedStyle?: Time.HumanStyle,
2634
+ columnsSizes?: Record<IColumn['id'], number | null>
2293
2635
  ): void;
2294
2636
 
2295
2637
  /**
@@ -2334,6 +2676,91 @@ export namespace DirListing {
2334
2676
  ): HTMLElement;
2335
2677
  }
2336
2678
 
2679
+ interface IBaseColumn {
2680
+ /**
2681
+ * Name of the header class, must be unique among other columns.
2682
+ */
2683
+ className: string;
2684
+ /**
2685
+ * Name of the item class, must be unique among other columns.
2686
+ */
2687
+ itemClassName: string;
2688
+ /**
2689
+ * Minimum size the column should occupy.
2690
+ */
2691
+ minWidth: number;
2692
+ }
2693
+ interface IFixedColumn extends IBaseColumn {
2694
+ id: 'is_selected';
2695
+ resizable: false;
2696
+ sortable: false;
2697
+ }
2698
+ /**
2699
+ * Sortable column.
2700
+ */
2701
+ export interface ISortableColumn extends IBaseColumn {
2702
+ id: SortableColumn;
2703
+ sortable: true;
2704
+ caretSide: 'left' | 'right';
2705
+ }
2706
+ /**
2707
+ * Resizable column.
2708
+ */
2709
+ export interface IResizableColumn extends IBaseColumn {
2710
+ id: ResizableColumn;
2711
+ resizable: true;
2712
+ }
2713
+
2714
+ /**
2715
+ * Columns types supported by DirListing.
2716
+ */
2717
+ export type IColumn =
2718
+ | IFixedColumn
2719
+ | ISortableColumn
2720
+ | IResizableColumn
2721
+ | (ISortableColumn & IResizableColumn);
2722
+
2723
+ /**
2724
+ * Column definitions.
2725
+ */
2726
+ export const columns: IColumn[] = [
2727
+ {
2728
+ id: 'is_selected' as const,
2729
+ className: CHECKBOX_WRAPPER_CLASS,
2730
+ itemClassName: CHECKBOX_WRAPPER_CLASS,
2731
+ minWidth: 18,
2732
+ resizable: false,
2733
+ sortable: false
2734
+ },
2735
+ {
2736
+ id: 'name' as const,
2737
+ className: NAME_ID_CLASS,
2738
+ itemClassName: ITEM_NAME_COLUMN_CLASS,
2739
+ minWidth: 60,
2740
+ resizable: true,
2741
+ sortable: true,
2742
+ caretSide: 'right'
2743
+ },
2744
+ {
2745
+ id: 'last_modified' as const,
2746
+ className: MODIFIED_ID_CLASS,
2747
+ itemClassName: ITEM_MODIFIED_CLASS,
2748
+ minWidth: 60,
2749
+ resizable: true,
2750
+ sortable: true,
2751
+ caretSide: 'left'
2752
+ },
2753
+ {
2754
+ id: 'file_size' as const,
2755
+ className: FILE_SIZE_ID_CLASS,
2756
+ itemClassName: ITEM_FILE_SIZE_CLASS,
2757
+ minWidth: 60,
2758
+ resizable: true,
2759
+ sortable: true,
2760
+ caretSide: 'left'
2761
+ }
2762
+ ];
2763
+
2337
2764
  /**
2338
2765
  * The default implementation of an `IRenderer`.
2339
2766
  */
@@ -2364,47 +2791,57 @@ export namespace DirListing {
2364
2791
  populateHeaderNode(
2365
2792
  node: HTMLElement,
2366
2793
  translator?: ITranslator,
2367
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2794
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2795
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
2368
2796
  ): void {
2369
2797
  translator = translator || nullTranslator;
2370
2798
  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
2799
 
2396
- if (hiddenColumns?.has('last_modified')) {
2397
- modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2398
- } else {
2399
- modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
2400
- }
2800
+ const elementCreators = {
2801
+ name: () => this.createHeaderItemNode(trans.__('Name')),
2802
+ last_modified: () =>
2803
+ this._createHeaderItemNodeWithSizes({
2804
+ small: trans.__('Modified'),
2805
+ large: trans.__('Last Modified')
2806
+ }),
2807
+ file_size: () => this.createHeaderItemNode(trans.__('File Size')),
2808
+ is_selected: () =>
2809
+ this.createCheckboxWrapperNode({
2810
+ alwaysVisible: true,
2811
+ headerNode: true
2812
+ })
2813
+ };
2401
2814
 
2402
- if (hiddenColumns?.has('file_size')) {
2403
- fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2404
- } else {
2405
- fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
2815
+ const visibleColumns = columns.filter(
2816
+ column => column.id === 'name' || !hiddenColumns?.has(column.id)
2817
+ );
2818
+
2819
+ for (const column of visibleColumns) {
2820
+ const createElement = elementCreators[column.id];
2821
+ const element = createElement();
2822
+ element.classList.add(column.className);
2823
+ const isLastVisible =
2824
+ column.id === visibleColumns[visibleColumns.length - 1].id;
2825
+
2826
+ if (columnsSizes) {
2827
+ const size = columnsSizes[column.id];
2828
+ if (!isLastVisible) {
2829
+ element.style.width = size + 'px';
2830
+ }
2831
+ }
2832
+ node.appendChild(element);
2833
+
2834
+ if (Private.isResizable(column) && !isLastVisible) {
2835
+ const resizer = document.createElement('div');
2836
+ resizer.classList.add(RESIZE_HANDLE_CLASS);
2837
+ resizer.dataset.column = column.id;
2838
+ node.appendChild(resizer);
2839
+ }
2406
2840
  }
2407
2841
 
2842
+ const name = DOMUtils.findElement(node, NAME_ID_CLASS);
2843
+ name.classList.add(SELECTED_CLASS);
2844
+
2408
2845
  // set the initial caret icon
2409
2846
  Private.updateCaret(
2410
2847
  DOMUtils.findElement(name, HEADER_ITEM_ICON_CLASS),
@@ -2423,92 +2860,57 @@ export namespace DirListing {
2423
2860
  * @returns The sort state of the header after the click event.
2424
2861
  */
2425
2862
  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
2863
  const state: ISortState = { direction: 'ascending', key: 'name' };
2430
2864
  const target = event.target as HTMLElement;
2431
2865
 
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');
2866
+ const sortableColumns = DirListing.columns.filter(Private.isSortable);
2867
+ console.log(sortableColumns);
2868
+
2869
+ for (const column of sortableColumns) {
2870
+ const header = node.querySelector(`.${column.className}`);
2871
+ if (!header) {
2872
+ // skip if the column is hidden
2873
+ continue;
2455
2874
  }
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');
2875
+ if (header.contains(target)) {
2876
+ state.key = column.id;
2877
+ const headerIcon = DOMUtils.findElement(
2878
+ header as HTMLElement,
2879
+ HEADER_ITEM_ICON_CLASS
2880
+ );
2881
+ if (header.classList.contains(SELECTED_CLASS)) {
2882
+ if (!header.classList.contains(DESCENDING_CLASS)) {
2883
+ state.direction = 'descending';
2884
+ header.classList.add(DESCENDING_CLASS);
2885
+ Private.updateCaret(headerIcon, column.caretSide, 'down');
2886
+ } else {
2887
+ header.classList.remove(DESCENDING_CLASS);
2888
+ Private.updateCaret(headerIcon, column.caretSide, 'up');
2889
+ }
2472
2890
  } else {
2473
- modified.classList.remove(DESCENDING_CLASS);
2474
- Private.updateCaret(modifiedIcon, 'left', 'up');
2891
+ header.classList.remove(DESCENDING_CLASS);
2892
+ Private.updateCaret(headerIcon, column.caretSide, 'up');
2475
2893
  }
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');
2894
+ header.classList.add(SELECTED_CLASS);
2895
+ for (const otherColumn of sortableColumns) {
2896
+ if (otherColumn.id === column.id) {
2897
+ continue;
2898
+ }
2899
+ const otherHeader = node.querySelector(`.${otherColumn.className}`);
2900
+ if (!otherHeader) {
2901
+ // skip if hidden
2902
+ continue;
2903
+ }
2904
+ otherHeader.classList.remove(SELECTED_CLASS);
2905
+ otherHeader.classList.remove(DESCENDING_CLASS);
2906
+ const otherHeaderIcon = DOMUtils.findElement(
2907
+ otherHeader as HTMLElement,
2908
+ HEADER_ITEM_ICON_CLASS
2909
+ );
2910
+ Private.updateCaret(otherHeaderIcon, otherColumn.caretSide);
2499
2911
  }
2500
- } else {
2501
- fileSize.classList.remove(DESCENDING_CLASS);
2502
- Private.updateCaret(fileSizeIcon, 'left', 'up');
2912
+ return state;
2503
2913
  }
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
2914
  }
2513
2915
  return state;
2514
2916
  }
@@ -2519,36 +2921,23 @@ export namespace DirListing {
2519
2921
  * @returns A new DOM node to use as a content item.
2520
2922
  */
2521
2923
  createItemNode(
2522
- hiddenColumns?: Set<DirListing.ToggleableColumn>
2924
+ hiddenColumns?: Set<DirListing.ToggleableColumn>,
2925
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
2523
2926
  ): HTMLElement {
2524
2927
  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
2928
 
2542
- if (hiddenColumns?.has('last_modified')) {
2543
- modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2544
- } else {
2545
- modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
2546
- }
2929
+ for (const column of columns) {
2930
+ if (column.id != 'name' && hiddenColumns?.has(column.id)) {
2931
+ continue;
2932
+ }
2933
+ const createElement = this.itemFactories[column.id];
2934
+ const element = createElement();
2935
+ node.appendChild(element);
2547
2936
 
2548
- if (hiddenColumns?.has('file_size')) {
2549
- fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2550
- } else {
2551
- fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
2937
+ if (columnsSizes) {
2938
+ const size = columnsSizes[column.id];
2939
+ element.style.width = size + 'px';
2940
+ }
2552
2941
  }
2553
2942
 
2554
2943
  return node;
@@ -2614,16 +3003,29 @@ export namespace DirListing {
2614
3003
  modifiedDate: string,
2615
3004
  modifiedStyle: Time.HumanStyle
2616
3005
  ): void {
2617
- let modText = '';
2618
- let modTitle = '';
3006
+ // Formatting dates is expensive (0.1-0.2ms per call,
3007
+ // so over 150 files can easily already choke the renderer),
3008
+ // let's do the bare minimum check of comparing if an update
3009
+ // is needed using a last update cache:
3010
+ const previousUpdate = this._modifiedColumnLastUpdate.get(modified);
3011
+ if (
3012
+ previousUpdate?.date === modifiedDate &&
3013
+ previousUpdate?.style === modifiedStyle
3014
+ ) {
3015
+ return;
3016
+ }
2619
3017
 
2620
3018
  const parsedDate = new Date(modifiedDate);
2621
3019
  // 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);
3020
+ const modText = Time.formatHuman(parsedDate, modifiedStyle);
3021
+ const modTitle = Time.format(parsedDate);
2624
3022
 
2625
3023
  modified.textContent = modText;
2626
3024
  modified.title = modTitle;
3025
+ this._modifiedColumnLastUpdate.set(modified, {
3026
+ date: modifiedDate,
3027
+ style: modifiedStyle
3028
+ });
2627
3029
  }
2628
3030
 
2629
3031
  /**
@@ -2643,7 +3045,8 @@ export namespace DirListing {
2643
3045
  translator?: ITranslator,
2644
3046
  hiddenColumns?: Set<DirListing.ToggleableColumn>,
2645
3047
  selected?: boolean,
2646
- modifiedStyle?: Time.HumanStyle
3048
+ modifiedStyle?: Time.HumanStyle,
3049
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
2647
3050
  ): void {
2648
3051
  if (selected) {
2649
3052
  node.classList.add(SELECTED_CLASS);
@@ -2657,8 +3060,13 @@ export namespace DirListing {
2657
3060
 
2658
3061
  const iconContainer = DOMUtils.findElement(node, ITEM_ICON_CLASS);
2659
3062
  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);
3063
+ const nameColumn = DOMUtils.findElement(node, ITEM_NAME_COLUMN_CLASS);
3064
+ let modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS) as
3065
+ | HTMLElement
3066
+ | undefined;
3067
+ let fileSize = DOMUtils.findElement(node, ITEM_FILE_SIZE_CLASS) as
3068
+ | HTMLElement
3069
+ | undefined;
2662
3070
  const checkboxWrapper = DOMUtils.findElement(
2663
3071
  node,
2664
3072
  CHECKBOX_WRAPPER_CLASS
@@ -2669,19 +3077,23 @@ export namespace DirListing {
2669
3077
  node.removeChild(checkboxWrapper);
2670
3078
  } else if (showFileCheckboxes && !checkboxWrapper) {
2671
3079
  const checkboxWrapper = this.createCheckboxWrapperNode();
2672
- node.insertBefore(checkboxWrapper, iconContainer);
3080
+ nameColumn.insertAdjacentElement('beforebegin', checkboxWrapper);
2673
3081
  }
2674
3082
 
2675
- if (hiddenColumns?.has('last_modified')) {
2676
- modified.classList.add(MODIFIED_COLUMN_HIDDEN);
2677
- } else {
2678
- modified.classList.remove(MODIFIED_COLUMN_HIDDEN);
3083
+ const showModified = !hiddenColumns?.has('last_modified');
3084
+ if (modified && !showModified) {
3085
+ node.removeChild(modified);
3086
+ } else if (showModified && !modified) {
3087
+ modified = this.itemFactories.last_modified();
3088
+ nameColumn.insertAdjacentElement('afterend', modified);
2679
3089
  }
2680
3090
 
2681
- if (hiddenColumns?.has('file_size')) {
2682
- fileSize.classList.add(FILE_SIZE_COLUMN_HIDDEN);
2683
- } else {
2684
- fileSize.classList.remove(FILE_SIZE_COLUMN_HIDDEN);
3091
+ const showFileSize = !hiddenColumns?.has('file_size');
3092
+ if (fileSize && !showFileSize) {
3093
+ node.removeChild(fileSize);
3094
+ } else if (showFileSize && !fileSize) {
3095
+ fileSize = this.itemFactories.file_size();
3096
+ (modified ?? nameColumn).insertAdjacentElement('afterend', fileSize);
2685
3097
  }
2686
3098
 
2687
3099
  // render the file item's icon
@@ -2698,12 +3110,14 @@ export namespace DirListing {
2698
3110
  // add file size to pop up if its available
2699
3111
  if (model.size !== null && model.size !== undefined) {
2700
3112
  const fileSizeText = Private.formatFileSize(model.size, 1, 1024);
2701
- fileSize.textContent = fileSizeText;
3113
+ if (fileSize) {
3114
+ fileSize.textContent = fileSizeText;
3115
+ }
2702
3116
  hoverText += trans.__(
2703
3117
  '\nSize: %1',
2704
3118
  Private.formatFileSize(model.size, 1, 1024)
2705
3119
  );
2706
- } else {
3120
+ } else if (fileSize) {
2707
3121
  fileSize.textContent = '';
2708
3122
  }
2709
3123
  if (model.path) {
@@ -2746,7 +3160,7 @@ export namespace DirListing {
2746
3160
  // Adds an aria-label to the checkbox element.
2747
3161
  const checkbox = checkboxWrapper?.querySelector(
2748
3162
  'input[type="checkbox"]'
2749
- ) as HTMLInputElement;
3163
+ ) as HTMLInputElement | undefined;
2750
3164
 
2751
3165
  if (checkbox) {
2752
3166
  let ariaLabel: string;
@@ -2763,7 +3177,36 @@ export namespace DirListing {
2763
3177
  checkbox.checked = selected ?? false;
2764
3178
  }
2765
3179
 
2766
- if (model.last_modified) {
3180
+ this.updateItemSize(node, model, modifiedStyle, columnsSizes);
3181
+ }
3182
+
3183
+ /**
3184
+ * Update size of item nodes, assuming that model has not changed.
3185
+ */
3186
+ updateItemSize(
3187
+ node: HTMLElement,
3188
+ model: Contents.IModel,
3189
+ modifiedStyle?: Time.HumanStyle,
3190
+ columnsSizes?: Record<DirListing.IColumn['id'], number | null>
3191
+ ): void {
3192
+ if (columnsSizes) {
3193
+ for (const column of columns) {
3194
+ const element = DOMUtils.findElement(node, column.itemClassName);
3195
+ if (!element) {
3196
+ continue;
3197
+ }
3198
+ const sizeSpec = columnsSizes[column.id];
3199
+ const newWidth = sizeSpec === null ? '' : sizeSpec + 'px';
3200
+ if (newWidth !== element.style.width) {
3201
+ element.style.width = newWidth;
3202
+ }
3203
+ }
3204
+ }
3205
+ let modified = DOMUtils.findElement(node, ITEM_MODIFIED_CLASS) as
3206
+ | HTMLElement
3207
+ | undefined;
3208
+
3209
+ if (model.last_modified && modified) {
2767
3210
  this.updateItemModified(
2768
3211
  modified,
2769
3212
  model.last_modified,
@@ -2835,6 +3278,34 @@ export namespace DirListing {
2835
3278
  return dragImage;
2836
3279
  }
2837
3280
 
3281
+ /**
3282
+ * Factories for individual parts of the item.
3283
+ */
3284
+ protected itemFactories = {
3285
+ name: () => {
3286
+ const name = document.createElement('span');
3287
+ const icon = document.createElement('span');
3288
+ const text = document.createElement('span');
3289
+ icon.className = ITEM_ICON_CLASS;
3290
+ text.className = ITEM_TEXT_CLASS;
3291
+ name.className = ITEM_NAME_COLUMN_CLASS;
3292
+ name.appendChild(icon);
3293
+ name.appendChild(text);
3294
+ return name;
3295
+ },
3296
+ last_modified: () => {
3297
+ const modified = document.createElement('span');
3298
+ modified.className = ITEM_MODIFIED_CLASS;
3299
+ return modified;
3300
+ },
3301
+ file_size: () => {
3302
+ const fileSize = document.createElement('span');
3303
+ fileSize.className = ITEM_FILE_SIZE_CLASS;
3304
+ return fileSize;
3305
+ },
3306
+ is_selected: () => this.createCheckboxWrapperNode()
3307
+ };
3308
+
2838
3309
  /**
2839
3310
  * Create a node for a header item.
2840
3311
  */
@@ -2873,6 +3344,14 @@ export namespace DirListing {
2873
3344
  node.appendChild(icon);
2874
3345
  return node;
2875
3346
  }
3347
+
3348
+ /**
3349
+ * Register of most recent arguments for last modified column update.
3350
+ */
3351
+ private _modifiedColumnLastUpdate = new WeakMap<
3352
+ HTMLElement,
3353
+ { date: string; style: Time.HumanStyle }
3354
+ >();
2876
3355
  }
2877
3356
 
2878
3357
  /**
@@ -3017,6 +3496,24 @@ namespace Private {
3017
3496
  return copy;
3018
3497
  }
3019
3498
 
3499
+ /**
3500
+ * Check if the column is resizable.
3501
+ */
3502
+ export const isResizable = (
3503
+ column: DirListing.IColumn
3504
+ ): column is DirListing.IResizableColumn => {
3505
+ return 'resizable' in column && column.resizable;
3506
+ };
3507
+
3508
+ /**
3509
+ * Check if the column is sortable.
3510
+ */
3511
+ export const isSortable = (
3512
+ column: DirListing.IColumn
3513
+ ): column is DirListing.ISortableColumn => {
3514
+ return 'sortable' in column && column.sortable;
3515
+ };
3516
+
3020
3517
  /**
3021
3518
  * Get the index of the node at a client position, or `-1`.
3022
3519
  */
@@ -3066,13 +3563,87 @@ namespace Private {
3066
3563
  (state === 'down' ? caretDownIcon : caretUpIcon).element({
3067
3564
  container,
3068
3565
  tag: 'span',
3069
- stylesheet: 'listingHeaderItem',
3070
-
3071
- float
3566
+ stylesheet: 'listingHeaderItem'
3072
3567
  });
3568
+ if (float === 'left') {
3569
+ container.style.order = '-1';
3570
+ } else {
3571
+ container.style.order = '';
3572
+ }
3073
3573
  } else {
3074
3574
  LabIcon.remove(container);
3075
3575
  container.className = HEADER_ITEM_ICON_CLASS;
3076
3576
  }
3077
3577
  }
3578
+
3579
+ export async function createDirectory(
3580
+ manager: IDocumentManager,
3581
+ path: string,
3582
+ name: string
3583
+ ): Promise<string> {
3584
+ const model = await manager.newUntitled({
3585
+ path: path,
3586
+ type: 'directory'
3587
+ });
3588
+ const tmpDirPath = PathExt.join(path, model.name);
3589
+ const dirPath = PathExt.join(path, name);
3590
+ try {
3591
+ await manager.rename(tmpDirPath, dirPath);
3592
+ } catch (e) {
3593
+ // The `dirPath` already exists, remove the temporary new directory
3594
+ await manager.deleteFile(tmpDirPath);
3595
+ }
3596
+ return dirPath;
3597
+ }
3598
+
3599
+ export function isDirectoryEntry(
3600
+ entry: FileSystemEntry
3601
+ ): entry is FileSystemDirectoryEntry {
3602
+ return entry.isDirectory;
3603
+ }
3604
+ export function isFileEntry(
3605
+ entry: FileSystemEntry
3606
+ ): entry is FileSystemFileEntry {
3607
+ return entry.isFile;
3608
+ }
3609
+
3610
+ export function defensiveGetAsEntry(
3611
+ item: DataTransferItem
3612
+ ): FileSystemEntry | null {
3613
+ if (item.webkitGetAsEntry) {
3614
+ return item.webkitGetAsEntry();
3615
+ }
3616
+ if ('getAsEntry' in item) {
3617
+ // See https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
3618
+ return (item['getAsEntry'] as () => FileSystemEntry | null)();
3619
+ }
3620
+ return null;
3621
+ }
3622
+
3623
+ function readEntries(reader: FileSystemDirectoryReader) {
3624
+ return new Promise<FileSystemEntry[]>((resolve, reject) =>
3625
+ reader.readEntries(resolve, reject)
3626
+ );
3627
+ }
3628
+
3629
+ export function readFile(entry: FileSystemFileEntry) {
3630
+ return new Promise<File>((resolve, reject) => entry.file(resolve, reject));
3631
+ }
3632
+
3633
+ export async function collectEntries(reader: FileSystemDirectoryReader) {
3634
+ // Spec requires calling `readEntries` until these are exhausted;
3635
+ // in practice this is only required in Chromium-based browsers for >100 files.
3636
+ // https://issues.chromium.org/issues/41110876
3637
+ const allEntries: FileSystemEntry[] = [];
3638
+ let done = false;
3639
+ while (!done) {
3640
+ const entries = await readEntries(reader);
3641
+ if (entries.length === 0) {
3642
+ done = true;
3643
+ } else {
3644
+ allEntries.push(...entries);
3645
+ }
3646
+ }
3647
+ return allEntries;
3648
+ }
3078
3649
  }