@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/lib/browser.d.ts +12 -0
- package/lib/browser.js +20 -1
- package/lib/browser.js.map +1 -1
- package/lib/listing.d.ts +129 -15
- package/lib/listing.js +577 -208
- package/lib/listing.js.map +1 -1
- package/lib/model.d.ts +2 -1
- package/lib/model.js +5 -4
- package/lib/model.js.map +1 -1
- package/package.json +11 -11
- package/src/browser.ts +28 -1
- package/src/listing.ts +794 -220
- package/src/model.ts +7 -4
- package/style/base.css +68 -39
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1480
|
-
|
|
1481
|
-
if (entry
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
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
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
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:
|
|
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
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
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
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
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
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
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
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
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
|
-
|
|
2474
|
-
Private.updateCaret(
|
|
2894
|
+
header.classList.remove(DESCENDING_CLASS);
|
|
2895
|
+
Private.updateCaret(headerIcon, column.caretSide, 'up');
|
|
2475
2896
|
}
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
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
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
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
|
-
|
|
2618
|
-
|
|
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
|
|
2661
|
-
|
|
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
|
-
|
|
3083
|
+
nameColumn.insertAdjacentElement('beforebegin', checkboxWrapper);
|
|
2673
3084
|
}
|
|
2674
3085
|
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
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
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|