pinokiod 8.0.113 → 8.0.116
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/kernel/vault/index.js +7 -2
- package/kernel/vault/registry_core.js +53 -25
- package/package.json +1 -1
- package/server/public/vault.css +13 -5
- package/server/public/vault.js +69 -53
- package/test/vault-ui.test.js +69 -10
package/kernel/vault/index.js
CHANGED
|
@@ -3349,7 +3349,10 @@ class Vault {
|
|
|
3349
3349
|
const sizeSort = options.size_sort === "asc" || options.size_sort === "desc"
|
|
3350
3350
|
? options.size_sort
|
|
3351
3351
|
: null
|
|
3352
|
-
const nameSort = options.name_sort === "
|
|
3352
|
+
const nameSort = options.name_sort === "asc" ||
|
|
3353
|
+
options.name_sort === "desc"
|
|
3354
|
+
? options.name_sort
|
|
3355
|
+
: null
|
|
3353
3356
|
// The authorised set, not the requested id: a scoped page must not be able
|
|
3354
3357
|
// to read another location by naming it, and a rail entry standing for a
|
|
3355
3358
|
// group of locations -- Pinokio, Apps -- owns no files of its own, so it
|
|
@@ -3683,7 +3686,9 @@ class Vault {
|
|
|
3683
3686
|
: options.size_sort,
|
|
3684
3687
|
groupDuplicates,
|
|
3685
3688
|
nameOnly: filesMode,
|
|
3686
|
-
nameSort: options.name_sort === "
|
|
3689
|
+
nameSort: options.name_sort === "asc" || options.name_sort === "desc"
|
|
3690
|
+
? options.name_sort
|
|
3691
|
+
: null,
|
|
3687
3692
|
cursor
|
|
3688
3693
|
})
|
|
3689
3694
|
let items
|
|
@@ -5045,6 +5045,15 @@ class RegistryCore {
|
|
|
5045
5045
|
}
|
|
5046
5046
|
}
|
|
5047
5047
|
|
|
5048
|
+
// A cursor carries only what its order resumes from.
|
|
5049
|
+
cursorFor(sort, row) {
|
|
5050
|
+
const cursor = { sort, size: row.size, path: row.path }
|
|
5051
|
+
if (sort === "name" || sort === "name_desc") {
|
|
5052
|
+
cursor.name = pathNameKey(row.path)
|
|
5053
|
+
}
|
|
5054
|
+
return cursor
|
|
5055
|
+
}
|
|
5056
|
+
|
|
5048
5057
|
encodeCursor(value) {
|
|
5049
5058
|
return Buffer.from(JSON.stringify(value)).toString("base64url")
|
|
5050
5059
|
}
|
|
@@ -5160,11 +5169,18 @@ class RegistryCore {
|
|
|
5160
5169
|
if (sort === "desc" && left.size !== right.size) {
|
|
5161
5170
|
return left.size > right.size ? -1 : 1
|
|
5162
5171
|
}
|
|
5172
|
+
if (sort === "name" || sort === "name_desc") {
|
|
5173
|
+
const nameOrder = Buffer.compare(
|
|
5174
|
+
Buffer.from(pathNameKey(left.path)),
|
|
5175
|
+
Buffer.from(pathNameKey(right.path))
|
|
5176
|
+
)
|
|
5177
|
+
if (nameOrder) return sort === "name_desc" ? -nameOrder : nameOrder
|
|
5178
|
+
}
|
|
5163
5179
|
const pathOrder = Buffer.compare(
|
|
5164
5180
|
Buffer.from(left.path),
|
|
5165
5181
|
Buffer.from(right.path)
|
|
5166
5182
|
)
|
|
5167
|
-
return sort === "desc"
|
|
5183
|
+
return sort === "desc" ? -pathOrder : pathOrder
|
|
5168
5184
|
}
|
|
5169
5185
|
|
|
5170
5186
|
fileStreamRows(options) {
|
|
@@ -5215,21 +5231,33 @@ class RegistryCore {
|
|
|
5215
5231
|
} else if (sort === "desc" && Number.isFinite(cursor.size)) {
|
|
5216
5232
|
where.push("(size < ? OR (size = ? AND path < ?))")
|
|
5217
5233
|
values.push(cursor.size, cursor.size, cursor.path)
|
|
5218
|
-
} else if (sort === "
|
|
5219
|
-
where.push(
|
|
5220
|
-
|
|
5234
|
+
} else if (sort === "name_desc" && typeof cursor.name === "string") {
|
|
5235
|
+
where.push(
|
|
5236
|
+
"(pinokio_path_name(path) < ? OR " +
|
|
5237
|
+
"(pinokio_path_name(path) = ? AND path < ?))")
|
|
5238
|
+
values.push(cursor.name, cursor.name, cursor.path)
|
|
5239
|
+
} else if (sort === "name" && typeof cursor.name === "string") {
|
|
5240
|
+
where.push(
|
|
5241
|
+
"(pinokio_path_name(path) > ? OR " +
|
|
5242
|
+
"(pinokio_path_name(path) = ? AND path > ?))")
|
|
5243
|
+
values.push(cursor.name, cursor.name, cursor.path)
|
|
5221
5244
|
} else if (sort === "path") {
|
|
5222
5245
|
where.push("path > ?")
|
|
5223
5246
|
values.push(cursor.path)
|
|
5224
5247
|
}
|
|
5225
5248
|
}
|
|
5249
|
+
// Ordering by name means the name the row shows. It costs a scan, because
|
|
5250
|
+
// no index covers a derived value, so it is only asked for when the user
|
|
5251
|
+
// sorts by the Name column.
|
|
5226
5252
|
const order = sort === "asc"
|
|
5227
5253
|
? "size ASC, path ASC"
|
|
5228
5254
|
: sort === "desc"
|
|
5229
5255
|
? "size DESC, path DESC"
|
|
5230
|
-
: sort === "
|
|
5231
|
-
? "path
|
|
5232
|
-
:
|
|
5256
|
+
: sort === "name"
|
|
5257
|
+
? "pinokio_path_name(path) ASC, path ASC"
|
|
5258
|
+
: sort === "name_desc"
|
|
5259
|
+
? "pinokio_path_name(path) DESC, path DESC"
|
|
5260
|
+
: "path ASC"
|
|
5233
5261
|
return this.database.prepare(`
|
|
5234
5262
|
SELECT * FROM files
|
|
5235
5263
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
@@ -5293,14 +5321,14 @@ class RegistryCore {
|
|
|
5293
5321
|
if (rows.length < limit) stream.exhausted = true
|
|
5294
5322
|
const last = rows[rows.length - 1]
|
|
5295
5323
|
if (last) {
|
|
5296
|
-
stream.cursor =
|
|
5297
|
-
sort,
|
|
5298
|
-
size: last.size,
|
|
5299
|
-
path: last.path
|
|
5300
|
-
}
|
|
5324
|
+
stream.cursor = this.cursorFor(sort, last)
|
|
5301
5325
|
}
|
|
5302
5326
|
}
|
|
5303
|
-
|
|
5327
|
+
// One stream needs no merging, so it is filled in a single query. Several
|
|
5328
|
+
// are probed a row at a time because most will not contribute much.
|
|
5329
|
+
for (const stream of streams) {
|
|
5330
|
+
refill(stream, streams.length === 1 ? pageSize + 1 : 1)
|
|
5331
|
+
}
|
|
5304
5332
|
const result = []
|
|
5305
5333
|
while (result.length < pageSize + 1) {
|
|
5306
5334
|
let selected = null
|
|
@@ -5316,7 +5344,10 @@ class RegistryCore {
|
|
|
5316
5344
|
if (!selected) break
|
|
5317
5345
|
result.push(selected.rows.shift())
|
|
5318
5346
|
if (!selected.rows.length && !selected.exhausted) {
|
|
5319
|
-
|
|
5347
|
+
// Ask for what the page still needs. Refilling a fixed handful meant
|
|
5348
|
+
// dozens of queries per page, which is invisible while an index serves
|
|
5349
|
+
// them and ruinous for an order that has to scan.
|
|
5350
|
+
refill(selected, Math.max(16, pageSize + 1 - result.length))
|
|
5320
5351
|
}
|
|
5321
5352
|
}
|
|
5322
5353
|
return result
|
|
@@ -5565,9 +5596,13 @@ class RegistryCore {
|
|
|
5565
5596
|
parentSeparator
|
|
5566
5597
|
} = options
|
|
5567
5598
|
// Size and name sorting are one server-side order, so only one can win.
|
|
5599
|
+
// Name order is only ever asked for, never the default: it costs a scan,
|
|
5600
|
+
// and the natural order of an inventory is its path order.
|
|
5568
5601
|
const sort = sizeSort === "asc" || sizeSort === "desc"
|
|
5569
5602
|
? sizeSort
|
|
5570
|
-
: nameSort === "
|
|
5603
|
+
: nameSort === "asc"
|
|
5604
|
+
? "name"
|
|
5605
|
+
: nameSort === "desc" ? "name_desc" : "path"
|
|
5571
5606
|
const rows = this.boundedFileRows({
|
|
5572
5607
|
sourceIds,
|
|
5573
5608
|
unrestricted,
|
|
@@ -5585,18 +5620,11 @@ class RegistryCore {
|
|
|
5585
5620
|
if (hasMore) rows.pop()
|
|
5586
5621
|
const last = rows[rows.length - 1]
|
|
5587
5622
|
const nextCursor = hasMore && last
|
|
5588
|
-
? this.encodeCursor(
|
|
5589
|
-
sort,
|
|
5590
|
-
size: last.size,
|
|
5591
|
-
path: last.path
|
|
5592
|
-
})
|
|
5623
|
+
? this.encodeCursor(this.cursorFor(sort, last))
|
|
5593
5624
|
: null
|
|
5594
5625
|
|
|
5595
|
-
const rowCursors = rows.map((row) =>
|
|
5596
|
-
sort,
|
|
5597
|
-
size: row.size,
|
|
5598
|
-
path: row.path
|
|
5599
|
-
}))
|
|
5626
|
+
const rowCursors = rows.map((row) =>
|
|
5627
|
+
this.encodeCursor(this.cursorFor(sort, row)))
|
|
5600
5628
|
const hashes = [...new Set(rows
|
|
5601
5629
|
.filter((row) => row.status !== "linked")
|
|
5602
5630
|
.map((row) => row.hash)
|
package/package.json
CHANGED
package/server/public/vault.css
CHANGED
|
@@ -1647,16 +1647,20 @@ body[data-vault-mode="global"] .vault-explorer {
|
|
|
1647
1647
|
}
|
|
1648
1648
|
.vault-text-button:hover { color: var(--task-text); }
|
|
1649
1649
|
.vault-text-button:disabled { cursor: progress; opacity: .55; }
|
|
1650
|
+
/* The list is the only part that grows and scrolls; every other row keeps its
|
|
1651
|
+
own height. Laying this out as fixed grid rows tied the footer's position to
|
|
1652
|
+
how many children happened to be in flow, and a status row appearing or a
|
|
1653
|
+
mode declaring a different count pushed the footer out of the pane, where no
|
|
1654
|
+
amount of scrolling could reach it. */
|
|
1650
1655
|
.vault-pane {
|
|
1651
|
-
display:
|
|
1656
|
+
display: flex;
|
|
1652
1657
|
min-width: 0;
|
|
1653
1658
|
min-height: 0;
|
|
1654
|
-
|
|
1659
|
+
flex-direction: column;
|
|
1655
1660
|
overflow: hidden;
|
|
1656
1661
|
}
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
}
|
|
1662
|
+
.vault-pane > * { flex: 0 0 auto; }
|
|
1663
|
+
.vault-pane > .vault-table-wrap { min-height: 0; flex: 1 1 auto; }
|
|
1660
1664
|
body.vault-page .vault-view-tabs {
|
|
1661
1665
|
display: block;
|
|
1662
1666
|
min-width: 0;
|
|
@@ -2192,6 +2196,10 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
|
|
|
2192
2196
|
align-items: center;
|
|
2193
2197
|
justify-content: space-between;
|
|
2194
2198
|
gap: 12px;
|
|
2199
|
+
/* Now that it holds its own place at the bottom, it needs an edge: a short
|
|
2200
|
+
list leaves empty space above it, and without a rule it reads as a stray
|
|
2201
|
+
line of text rather than the foot of the table. */
|
|
2202
|
+
border-top: 1px solid var(--task-border);
|
|
2195
2203
|
padding: 9px var(--vault-inline) 11px;
|
|
2196
2204
|
color: var(--task-muted);
|
|
2197
2205
|
font-size: 10.5px;
|
package/server/public/vault.js
CHANGED
|
@@ -168,7 +168,7 @@ const COPY = {
|
|
|
168
168
|
size: "Size",
|
|
169
169
|
sort_largest: "Sort by size, largest first",
|
|
170
170
|
sort_smallest: "Sort by size, smallest first",
|
|
171
|
-
|
|
171
|
+
page_number: "Page {page}",
|
|
172
172
|
sort_a_z: "Sort by name, A to Z",
|
|
173
173
|
sort_z_a: "Sort by name, Z to A",
|
|
174
174
|
folders: "Folders",
|
|
@@ -332,7 +332,7 @@ const statusUrl = (progress = false) => {
|
|
|
332
332
|
if (state.query) query.set("q", state.query)
|
|
333
333
|
if (state.statusFilter !== "all") query.set("status_filter", state.statusFilter)
|
|
334
334
|
if (state.sizeSort) query.set("size_sort", state.sizeSort)
|
|
335
|
-
if (state.nameSort
|
|
335
|
+
if (state.nameSort) query.set("name_sort", state.nameSort)
|
|
336
336
|
// The server groups duplicates only for this view, but every view needs to
|
|
337
337
|
// know the mode: the flat list matches the search against file names.
|
|
338
338
|
if (state.displayMode === "files" && supportsDisplayMode()) {
|
|
@@ -365,7 +365,7 @@ const treeLevelUrl = (locationId, parent, cursor = null, directoryOffset = 0) =>
|
|
|
365
365
|
if (state.statusFilter !== "all") query.set("status_filter", state.statusFilter)
|
|
366
366
|
if (state.query) query.set("q", state.query)
|
|
367
367
|
if (state.sizeSort) query.set("size_sort", state.sizeSort)
|
|
368
|
-
if (state.nameSort
|
|
368
|
+
if (state.nameSort) query.set("name_sort", state.nameSort)
|
|
369
369
|
if (cursor) query.set("cursor", cursor)
|
|
370
370
|
if (directoryOffset) query.set("directory_offset", String(directoryOffset))
|
|
371
371
|
query.set("page_size", String(PAGE_SIZE))
|
|
@@ -460,7 +460,7 @@ const state = {
|
|
|
460
460
|
statusFilter: "all",
|
|
461
461
|
displayMode: "folders",
|
|
462
462
|
sizeSort: null,
|
|
463
|
-
nameSort:
|
|
463
|
+
nameSort: null,
|
|
464
464
|
collapsedSources: new Set(),
|
|
465
465
|
// One entry per folder that has been opened, keyed by its path inside the
|
|
466
466
|
// location. Folders start closed, so only what the user opens is fetched.
|
|
@@ -1924,32 +1924,34 @@ const invalidateTreeLevels = () => {
|
|
|
1924
1924
|
state.treeLevels.clear()
|
|
1925
1925
|
state.treeGeneration += 1
|
|
1926
1926
|
}
|
|
1927
|
-
|
|
1927
|
+
// A level is a list, so it pages like one. Each page's starting position is
|
|
1928
|
+
// kept so Previous can return to it, the same way the flat file list keeps its
|
|
1929
|
+
// cursors.
|
|
1930
|
+
const loadTreeLevel = async (locationId, parent, page = 0) => {
|
|
1928
1931
|
const key = levelKey(locationId, parent)
|
|
1929
1932
|
const existing = state.treeLevels.get(key)
|
|
1930
1933
|
if (existing && existing.loading) return
|
|
1931
|
-
// A level continues from two places at once: how far down the folder list it
|
|
1932
|
-
// got, and which file it stopped on.
|
|
1933
1934
|
// Whatever the level is showing now was asked for under this generation. A
|
|
1934
1935
|
// reply from an older one describes a search, filter or order that is gone.
|
|
1935
1936
|
const generation = state.treeGeneration
|
|
1936
|
-
const
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1937
|
+
const positions = existing && existing.positions
|
|
1938
|
+
? existing.positions
|
|
1939
|
+
: [{ cursor: null, directoryOffset: 0 }]
|
|
1940
|
+
const wanted = Math.max(0, Math.min(page, positions.length - 1))
|
|
1941
|
+
const from = positions[wanted]
|
|
1940
1942
|
state.treeLevels.set(key, {
|
|
1941
|
-
items: existing
|
|
1943
|
+
items: existing ? existing.items : [],
|
|
1942
1944
|
loading: true,
|
|
1943
|
-
loaded: !!
|
|
1945
|
+
loaded: !!existing,
|
|
1944
1946
|
error: null,
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1947
|
+
page: wanted,
|
|
1948
|
+
positions,
|
|
1949
|
+
hasNext: existing ? existing.hasNext : false
|
|
1948
1950
|
})
|
|
1949
1951
|
render()
|
|
1950
1952
|
try {
|
|
1951
|
-
const data = await fetchJson(
|
|
1952
|
-
|
|
1953
|
+
const data = await fetchJson(treeLevelUrl(
|
|
1954
|
+
locationId, parent, from.cursor, from.directoryOffset))
|
|
1953
1955
|
if (generation !== state.treeGeneration) return
|
|
1954
1956
|
// A scope holding one location has nothing to choose between, so it opens
|
|
1955
1957
|
// rather than making the user click through a list of one.
|
|
@@ -1960,21 +1962,23 @@ const loadTreeLevel = async (locationId, parent, append = false) => {
|
|
|
1960
1962
|
if (only) {
|
|
1961
1963
|
const childKey = levelKey(only.location_id, "")
|
|
1962
1964
|
state.expandedDirs.add(childKey)
|
|
1963
|
-
if (!state.treeLevels.has(childKey))
|
|
1964
|
-
|
|
1965
|
-
|
|
1965
|
+
if (!state.treeLevels.has(childKey)) loadTreeLevel(only.location_id, "")
|
|
1966
|
+
}
|
|
1967
|
+
const next = positions.slice(0, wanted + 1)
|
|
1968
|
+
if (data.has_next) {
|
|
1969
|
+
next.push({
|
|
1970
|
+
cursor: data.next_cursor || null,
|
|
1971
|
+
directoryOffset: Number(data.next_directory_offset) || 0
|
|
1972
|
+
})
|
|
1966
1973
|
}
|
|
1967
|
-
const previous = state.treeLevels.get(key)
|
|
1968
1974
|
state.treeLevels.set(key, {
|
|
1969
|
-
items:
|
|
1970
|
-
? [...previous.items, ...(data.items || [])]
|
|
1971
|
-
: (data.items || []),
|
|
1975
|
+
items: data.items || [],
|
|
1972
1976
|
loading: false,
|
|
1973
1977
|
loaded: true,
|
|
1974
1978
|
error: null,
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1979
|
+
page: wanted,
|
|
1980
|
+
positions: next,
|
|
1981
|
+
hasNext: !!data.has_next
|
|
1978
1982
|
})
|
|
1979
1983
|
} catch (error) {
|
|
1980
1984
|
if (generation !== state.treeGeneration) return
|
|
@@ -1983,9 +1987,9 @@ const loadTreeLevel = async (locationId, parent, append = false) => {
|
|
|
1983
1987
|
loading: false,
|
|
1984
1988
|
loaded: true,
|
|
1985
1989
|
error: error && error.message ? error.message : String(error),
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1990
|
+
page: 0,
|
|
1991
|
+
positions: [{ cursor: null, directoryOffset: 0 }],
|
|
1992
|
+
hasNext: false
|
|
1989
1993
|
})
|
|
1990
1994
|
}
|
|
1991
1995
|
render()
|
|
@@ -2011,10 +2015,10 @@ const walkToReveal = async () => {
|
|
|
2011
2015
|
// The folder on the way down can sit past the first page of a busy level,
|
|
2012
2016
|
// so keep asking for more of that level until it appears or runs out.
|
|
2013
2017
|
while (!next && level.hasNext) {
|
|
2014
|
-
await loadTreeLevel(locationId, parent,
|
|
2015
|
-
const
|
|
2016
|
-
if (!
|
|
2017
|
-
level =
|
|
2018
|
+
await loadTreeLevel(locationId, parent, (Number(level.page) || 0) + 1)
|
|
2019
|
+
const turned = treeLevel(locationId, parent)
|
|
2020
|
+
if (!turned || turned.error || turned.page === level.page) break
|
|
2021
|
+
level = turned
|
|
2018
2022
|
next = match()
|
|
2019
2023
|
}
|
|
2020
2024
|
if (!next) break
|
|
@@ -2072,10 +2076,11 @@ const renderTreeLevel = (locationId, parent, depth) => {
|
|
|
2072
2076
|
}
|
|
2073
2077
|
return renderFileRow(entry, depth)
|
|
2074
2078
|
}).join("")
|
|
2075
|
-
const
|
|
2076
|
-
|
|
2079
|
+
const page = Number(level.page) || 0
|
|
2080
|
+
const paging = level.hasNext || page > 0
|
|
2081
|
+
? treePlaceholderRow(depth, `<span class="vault-pagination" role="navigation" aria-label="${attr(COPY.file_pages)}"><button class="vault-text-button" type="button" data-tree-page="previous" data-page-location="${attr(locationId || "")}" data-page-path="${attr(parent)}" ${page > 0 && !level.loading ? "" : "disabled"}>${esc(COPY.previous)}</button><span class="vault-page-range">${esc(COPY.page_number.replace("{page}", page + 1))}</span><button class="vault-text-button" type="button" data-tree-page="next" data-page-location="${attr(locationId || "")}" data-page-path="${attr(parent)}" ${level.hasNext && !level.loading ? "" : "disabled"}>${esc(COPY.next)}</button></span>`)
|
|
2077
2082
|
: ""
|
|
2078
|
-
return rows +
|
|
2083
|
+
return rows + paging
|
|
2079
2084
|
}
|
|
2080
2085
|
const groupTitle = (source) => {
|
|
2081
2086
|
if (!source) return COPY.unknown_location
|
|
@@ -2093,10 +2098,13 @@ const sourceSort = (a, b) => {
|
|
|
2093
2098
|
const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
|
|
2094
2099
|
return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
|
|
2095
2100
|
}
|
|
2101
|
+
// The flat list is ordered by the name its Name column shows, matching the
|
|
2102
|
+
// order the server paged in; the location is only the tie-break.
|
|
2103
|
+
const flatName = (item) => basename(item.relative_path)
|
|
2096
2104
|
const flatLocation = (item) => externalLocation(item) ||
|
|
2097
2105
|
[groupTitle(sourceById(item.source_id)), item.relative_path].filter(Boolean).join(" / ")
|
|
2098
2106
|
const renderFlatFiles = (items) => [...items]
|
|
2099
|
-
.sort((a, b) => compareRows(a, b,
|
|
2107
|
+
.sort((a, b) => compareRows(a, b, flatName))
|
|
2100
2108
|
.map((item) => {
|
|
2101
2109
|
const expandable = Number(item.location_count) > 1
|
|
2102
2110
|
return `<div class="vault-file-row">
|
|
@@ -2387,10 +2395,10 @@ const renderTable = (items) => {
|
|
|
2387
2395
|
const sortableName = tableClass === "flat" ||
|
|
2388
2396
|
tableClass === "inventory" ||
|
|
2389
2397
|
tableClass === "matches"
|
|
2390
|
-
const nameSortLabel = state.nameSort === "
|
|
2398
|
+
const nameSortLabel = state.nameSort === "asc" ? COPY.sort_z_a : COPY.sort_a_z
|
|
2391
2399
|
const nameSortIcon = state.nameSort === "desc"
|
|
2392
2400
|
? "fa-arrow-down-z-a"
|
|
2393
|
-
: "fa-arrow-down-a-z"
|
|
2401
|
+
: state.nameSort === "asc" ? "fa-arrow-down-a-z" : "fa-sort"
|
|
2394
2402
|
const separatePagePaths = selectablePagePaths(items)
|
|
2395
2403
|
const duplicatePagePaths = state.view === "duplicates"
|
|
2396
2404
|
? selectableDuplicatePagePaths(items)
|
|
@@ -2427,10 +2435,10 @@ const renderTable = (items) => {
|
|
|
2427
2435
|
allPageSelected = separatePagePaths.every((filePath) =>
|
|
2428
2436
|
state.selectedSeparateFiles.has(filePath))
|
|
2429
2437
|
}
|
|
2430
|
-
const nameSortButton = `<button class="vault-sort-button ${state.
|
|
2438
|
+
const nameSortButton = `<button class="vault-sort-button ${state.nameSort ? "active" : ""}" type="button" data-sort-name aria-label="${attr(nameSortLabel)}">${esc(COPY.name)}<i class="fa-solid ${nameSortIcon}" aria-hidden="true"></i></button>`
|
|
2431
2439
|
const nameHeader = (header) => {
|
|
2432
2440
|
const inner = sortableName ? nameSortButton : `<span>${esc(header)}</span>`
|
|
2433
|
-
const sorted = !sortableName || state.
|
|
2441
|
+
const sorted = !sortableName || !state.nameSort
|
|
2434
2442
|
? "none"
|
|
2435
2443
|
: state.nameSort === "desc" ? "descending" : "ascending"
|
|
2436
2444
|
if (pageSelectionAttribute) {
|
|
@@ -2461,7 +2469,7 @@ const orderedItems = (items) => {
|
|
|
2461
2469
|
(blob) => blob.hash))
|
|
2462
2470
|
}
|
|
2463
2471
|
if (state.displayMode === "files" && supportsDisplayMode()) {
|
|
2464
|
-
return [...items].sort((a, b) => compareRows(a, b,
|
|
2472
|
+
return [...items].sort((a, b) => compareRows(a, b, flatName))
|
|
2465
2473
|
}
|
|
2466
2474
|
return [...items].sort((a, b) => {
|
|
2467
2475
|
const sourceOrder = state.sourceId ? 0 : sourceSort([a.source_id], [b.source_id])
|
|
@@ -2522,7 +2530,9 @@ const paneFooterText = () => {
|
|
|
2522
2530
|
? COPY.sorted_largest
|
|
2523
2531
|
: state.sizeSort === "asc"
|
|
2524
2532
|
? COPY.sorted_smallest
|
|
2525
|
-
: state.nameSort === "desc"
|
|
2533
|
+
: state.nameSort === "desc"
|
|
2534
|
+
? COPY.sorted_z_a
|
|
2535
|
+
: state.nameSort === "asc" ? COPY.sorted_a_z : ""
|
|
2526
2536
|
return `${count}${order ? ` · ${order}` : ""}`
|
|
2527
2537
|
}
|
|
2528
2538
|
if (state.view === "duplicates") return COPY.duplicate_note
|
|
@@ -4021,17 +4031,23 @@ document.addEventListener("click", async (event) => {
|
|
|
4021
4031
|
} else render()
|
|
4022
4032
|
}
|
|
4023
4033
|
return
|
|
4024
|
-
} else if (target.dataset.
|
|
4025
|
-
|
|
4026
|
-
|
|
4034
|
+
} else if (target.dataset.treePage) {
|
|
4035
|
+
const locationId = target.dataset.pageLocation || null
|
|
4036
|
+
const parent = target.dataset.pagePath
|
|
4037
|
+
const level = treeLevel(locationId, parent)
|
|
4038
|
+
const page = (Number(level && level.page) || 0) +
|
|
4039
|
+
(target.dataset.treePage === "next" ? 1 : -1)
|
|
4040
|
+
if (page < 0) return
|
|
4041
|
+
await loadTreeLevel(locationId, parent, page)
|
|
4042
|
+
// Only the root level owns the top of the table; paging a folder deeper in
|
|
4043
|
+
// should leave the reader where they were looking.
|
|
4044
|
+
if (!parent && !locationId) el("vault-table-wrap").scrollTop = 0
|
|
4027
4045
|
return
|
|
4028
4046
|
} else if (target.hasAttribute("data-sort-name")) {
|
|
4029
4047
|
clearFileSelections()
|
|
4030
4048
|
// Size ordering wins over name ordering server-side, so sorting by name
|
|
4031
4049
|
// has to drop it rather than sit underneath it doing nothing visible.
|
|
4032
|
-
state.nameSort =
|
|
4033
|
-
? "desc"
|
|
4034
|
-
: "asc"
|
|
4050
|
+
state.nameSort = state.nameSort === "asc" ? "desc" : "asc"
|
|
4035
4051
|
state.sizeSort = null
|
|
4036
4052
|
resetPage()
|
|
4037
4053
|
await refresh(true)
|
|
@@ -4039,7 +4055,7 @@ document.addEventListener("click", async (event) => {
|
|
|
4039
4055
|
clearFileSelections()
|
|
4040
4056
|
state.displayMode = target.dataset.displayMode === "files" ? "files" : "folders"
|
|
4041
4057
|
state.sizeSort = state.displayMode === "files" ? "desc" : null
|
|
4042
|
-
state.nameSort =
|
|
4058
|
+
state.nameSort = null
|
|
4043
4059
|
resetPage()
|
|
4044
4060
|
await refresh(true)
|
|
4045
4061
|
} else if (target.dataset.view || target.id === "btn-review-cleanup") {
|
|
@@ -4050,7 +4066,7 @@ document.addEventListener("click", async (event) => {
|
|
|
4050
4066
|
state.statusFilter = "all"
|
|
4051
4067
|
state.displayMode = state.view === "shared" ? "files" : "folders"
|
|
4052
4068
|
state.sizeSort = state.view === "shared" ? "desc" : null
|
|
4053
|
-
state.nameSort =
|
|
4069
|
+
state.nameSort = null
|
|
4054
4070
|
if (state.view === "duplicates") {
|
|
4055
4071
|
markScanReviewed()
|
|
4056
4072
|
state.scanResult = null
|
package/test/vault-ui.test.js
CHANGED
|
@@ -485,22 +485,32 @@ const makePage = async (status, options = {}) => {
|
|
|
485
485
|
directories.set(name,
|
|
486
486
|
tally(directories.get(name) || empty(), entry))
|
|
487
487
|
}
|
|
488
|
+
const merged = [
|
|
489
|
+
...[...directories].map(([name, totals]) => Object.assign({
|
|
490
|
+
kind: "directory",
|
|
491
|
+
name,
|
|
492
|
+
label: name,
|
|
493
|
+
relative_path: `${prefix}${name}`,
|
|
494
|
+
source_id: locationId
|
|
495
|
+
}, totals)),
|
|
496
|
+
...files
|
|
497
|
+
]
|
|
498
|
+
// Honour the page size the client asks for, so a level that does not fit
|
|
499
|
+
// one page exercises the same continuation the server drives.
|
|
500
|
+
const limit = Math.max(1,
|
|
501
|
+
Number(parsed.searchParams.get("page_size")) || 500)
|
|
502
|
+
const from = Math.max(0,
|
|
503
|
+
Number(parsed.searchParams.get("directory_offset")) || 0)
|
|
504
|
+
const page = merged.slice(from, from + limit)
|
|
488
505
|
return {
|
|
489
506
|
ok: true,
|
|
490
507
|
status: 200,
|
|
491
508
|
json: async () => ({
|
|
492
509
|
enabled: true,
|
|
493
510
|
parent: treeParent,
|
|
494
|
-
items:
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
name,
|
|
498
|
-
label: name,
|
|
499
|
-
relative_path: `${prefix}${name}`,
|
|
500
|
-
source_id: locationId
|
|
501
|
-
}, totals)),
|
|
502
|
-
...files
|
|
503
|
-
]
|
|
511
|
+
items: page,
|
|
512
|
+
has_next: from + page.length < merged.length,
|
|
513
|
+
next_directory_offset: from + page.length
|
|
504
514
|
})
|
|
505
515
|
}
|
|
506
516
|
}
|
|
@@ -956,6 +966,55 @@ describe("Save Space interface", () => {
|
|
|
956
966
|
dom.window.close()
|
|
957
967
|
})
|
|
958
968
|
|
|
969
|
+
test("a folder level pages like the file list, one page at a time", async () => {
|
|
970
|
+
// More files in one folder than a page holds.
|
|
971
|
+
const many = Array.from({ length: 1200 }, (unused, index) => item({
|
|
972
|
+
path: `/pinokio/api/app/models/file-${String(index).padStart(4, "0")}.bin`,
|
|
973
|
+
relative_path: `models/file-${String(index).padStart(4, "0")}.bin`,
|
|
974
|
+
status: "tracked"
|
|
975
|
+
}))
|
|
976
|
+
const { dom, getRequests } = await makePage(fixture(many))
|
|
977
|
+
const document = dom.window.document
|
|
978
|
+
|
|
979
|
+
await openTree(document, waitFor)
|
|
980
|
+
const rowsNow = () => document.querySelectorAll(
|
|
981
|
+
"#vault-table-wrap .vault-file-row").length
|
|
982
|
+
// Wait for the folder's own level, not just the rows above it.
|
|
983
|
+
await waitFor(() => rowsNow() > 100)
|
|
984
|
+
const first = rowsNow()
|
|
985
|
+
// The level stops well short of the 1200 files it contains.
|
|
986
|
+
assert.ok(first < 1200, `expected a bounded level, rendered ${first}`)
|
|
987
|
+
// Every level request carries a page size; none asks for everything.
|
|
988
|
+
const sizes = getRequests
|
|
989
|
+
.map((url) => new URL(url, "http://localhost"))
|
|
990
|
+
.filter((url) => url.searchParams.get("tree_parent") !== null)
|
|
991
|
+
.map((url) => Number(url.searchParams.get("page_size")))
|
|
992
|
+
assert.ok(sizes.length > 0)
|
|
993
|
+
assert.ok(sizes.every((size) => size > 0 && size <= 500))
|
|
994
|
+
|
|
995
|
+
// The rest is a page away, and the level pages both ways.
|
|
996
|
+
const next = document.querySelector('[data-tree-page="next"]')
|
|
997
|
+
assert.ok(next, "expected the level to offer a next page")
|
|
998
|
+
assert.equal(document.querySelector('[data-tree-page="previous"]').disabled,
|
|
999
|
+
true)
|
|
1000
|
+
// Read the first file on the page, not the folder rows above it.
|
|
1001
|
+
const firstFile = () => [...document.querySelectorAll(
|
|
1002
|
+
"#vault-table-wrap .vault-file-name")]
|
|
1003
|
+
.map((node) => node.textContent)
|
|
1004
|
+
.find((text) => /^file-\d+\.bin$/.test(text))
|
|
1005
|
+
const opening = firstFile()
|
|
1006
|
+
assert.ok(opening)
|
|
1007
|
+
next.click()
|
|
1008
|
+
await waitFor(() => firstFile() && firstFile() !== opening)
|
|
1009
|
+
assert.match(document.querySelector(".vault-page-range").textContent,
|
|
1010
|
+
/Page 2/)
|
|
1011
|
+
// And back again lands on the page it came from.
|
|
1012
|
+
document.querySelector('[data-tree-page="previous"]').click()
|
|
1013
|
+
await waitFor(() => firstFile() === opening)
|
|
1014
|
+
await settle()
|
|
1015
|
+
dom.window.close()
|
|
1016
|
+
})
|
|
1017
|
+
|
|
959
1018
|
test("name sorting is offered wherever the Name column holds a name", async () => {
|
|
960
1019
|
const duplicate = item({
|
|
961
1020
|
path: "/pinokio/api/app/models/duplicate.bin",
|