pinokiod 8.0.114 → 8.0.117
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 +57 -28
- package/package.json +1 -1
- package/server/public/vault.js +75 -72
- package/test/vault-engine.test.js +21 -2
- package/test/vault-ui.test.js +106 -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
|
|
@@ -5383,11 +5414,12 @@ class RegistryCore {
|
|
|
5383
5414
|
where.push(clause.sql)
|
|
5384
5415
|
values.push(...clause.values)
|
|
5385
5416
|
}
|
|
5417
|
+
const folded = "pinokio_path_name(name)"
|
|
5386
5418
|
const order = sizeSort === "asc"
|
|
5387
|
-
?
|
|
5419
|
+
? `bytes ASC, ${folded} ASC`
|
|
5388
5420
|
: sizeSort === "desc"
|
|
5389
|
-
?
|
|
5390
|
-
: nameSort === "desc" ?
|
|
5421
|
+
? `bytes DESC, ${folded} ASC`
|
|
5422
|
+
: nameSort === "desc" ? `${folded} DESC` : `${folded} ASC`
|
|
5391
5423
|
// Values bind in the order the placeholders appear: the CTE's substr, then
|
|
5392
5424
|
// its WHERE, then the two separators in the outer select and filter.
|
|
5393
5425
|
return this.database.prepare(`
|
|
@@ -5565,9 +5597,13 @@ class RegistryCore {
|
|
|
5565
5597
|
parentSeparator
|
|
5566
5598
|
} = options
|
|
5567
5599
|
// Size and name sorting are one server-side order, so only one can win.
|
|
5600
|
+
// Name order is only ever asked for, never the default: it costs a scan,
|
|
5601
|
+
// and the natural order of an inventory is its path order.
|
|
5568
5602
|
const sort = sizeSort === "asc" || sizeSort === "desc"
|
|
5569
5603
|
? sizeSort
|
|
5570
|
-
: nameSort === "
|
|
5604
|
+
: nameSort === "asc"
|
|
5605
|
+
? "name"
|
|
5606
|
+
: nameSort === "desc" ? "name_desc" : "path"
|
|
5571
5607
|
const rows = this.boundedFileRows({
|
|
5572
5608
|
sourceIds,
|
|
5573
5609
|
unrestricted,
|
|
@@ -5585,18 +5621,11 @@ class RegistryCore {
|
|
|
5585
5621
|
if (hasMore) rows.pop()
|
|
5586
5622
|
const last = rows[rows.length - 1]
|
|
5587
5623
|
const nextCursor = hasMore && last
|
|
5588
|
-
? this.encodeCursor(
|
|
5589
|
-
sort,
|
|
5590
|
-
size: last.size,
|
|
5591
|
-
path: last.path
|
|
5592
|
-
})
|
|
5624
|
+
? this.encodeCursor(this.cursorFor(sort, last))
|
|
5593
5625
|
: null
|
|
5594
5626
|
|
|
5595
|
-
const rowCursors = rows.map((row) =>
|
|
5596
|
-
sort,
|
|
5597
|
-
size: row.size,
|
|
5598
|
-
path: row.path
|
|
5599
|
-
}))
|
|
5627
|
+
const rowCursors = rows.map((row) =>
|
|
5628
|
+
this.encodeCursor(this.cursorFor(sort, row)))
|
|
5600
5629
|
const hashes = [...new Set(rows
|
|
5601
5630
|
.filter((row) => row.status !== "linked")
|
|
5602
5631
|
.map((row) => row.hash)
|
package/package.json
CHANGED
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,14 @@ 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 name a row shows in its Name column. Every list orders by this, so the
|
|
2102
|
+
// order matches the column the user clicked; the path below it is context, not
|
|
2103
|
+
// the sort key.
|
|
2104
|
+
const rowName = (item) => basename(item.relative_path || item.path)
|
|
2096
2105
|
const flatLocation = (item) => externalLocation(item) ||
|
|
2097
2106
|
[groupTitle(sourceById(item.source_id)), item.relative_path].filter(Boolean).join(" / ")
|
|
2098
2107
|
const renderFlatFiles = (items) => [...items]
|
|
2099
|
-
.sort((a, b) => compareRows(a, b,
|
|
2108
|
+
.sort((a, b) => compareRows(a, b, rowName))
|
|
2100
2109
|
.map((item) => {
|
|
2101
2110
|
const expandable = Number(item.location_count) > 1
|
|
2102
2111
|
return `<div class="vault-file-row">
|
|
@@ -2197,7 +2206,7 @@ const renderDuplicateGroups = (items) => {
|
|
|
2197
2206
|
const action = source && source.shareable && allShareable
|
|
2198
2207
|
? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(allShareable)}</button>`
|
|
2199
2208
|
: `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
|
|
2200
|
-
return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${countLabel(group.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${bytes ? fmt(bytes) : COPY.unavailable}</div></div><div class="vault-group-action">${action}</div></div>${[...group].sort((a, b) => compareRows(a, b,
|
|
2209
|
+
return `<div class="vault-group-row"><div class="vault-group-main"><div class="vault-group-title"><i class="fa-regular fa-folder"></i><span>${esc(groupTitle(source))}</span></div><div class="vault-group-meta">${countLabel(group.length, COPY.duplicate.toLowerCase(), COPY.duplicates.toLowerCase())} · ${bytes ? fmt(bytes) : COPY.unavailable}</div></div><div class="vault-group-action">${action}</div></div>${[...group].sort((a, b) => compareRows(a, b, rowName)).map((item) => renderFileRow(item, 0, true)).join("")}`
|
|
2201
2210
|
}).join("")
|
|
2202
2211
|
}
|
|
2203
2212
|
|
|
@@ -2218,8 +2227,10 @@ const emptyState = (view) => {
|
|
|
2218
2227
|
return `<div class="vault-empty"><div class="vault-empty-inner"><i class="${content[2]}"></i><h3>${esc(content[0])}</h3><p>${esc(content[1])}</p>${view === "duplicates" ? `<button class="vault-button" type="button" data-view="all">${esc(COPY.view_all)}</button>` : ""}</div></div>`
|
|
2219
2228
|
}
|
|
2220
2229
|
|
|
2221
|
-
const renderReclaimable = (
|
|
2222
|
-
if (!
|
|
2230
|
+
const renderReclaimable = (rows) => {
|
|
2231
|
+
if (!rows.length) return emptyState("reclaimable")
|
|
2232
|
+
const blobs = [...rows]
|
|
2233
|
+
.sort((left, right) => compareRows(left, right, (blob) => blob.hash))
|
|
2223
2234
|
return blobs.map((blob) => `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-regular fa-file vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name">${esc(`${blob.hash.slice(0, 12)}…`)}</span></span></div><span class="vault-size">${fmt(blob.size)}</span><span class="vault-space">${fmt(blob.size)}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-reclaim="${attr(blob.hash)}" data-reclaim-store="${attr(blob.store_id)}">${esc(COPY.reclaim)}</button></span></div>`).join("")
|
|
2224
2235
|
}
|
|
2225
2236
|
|
|
@@ -2387,10 +2398,10 @@ const renderTable = (items) => {
|
|
|
2387
2398
|
const sortableName = tableClass === "flat" ||
|
|
2388
2399
|
tableClass === "inventory" ||
|
|
2389
2400
|
tableClass === "matches"
|
|
2390
|
-
const nameSortLabel = state.nameSort === "
|
|
2401
|
+
const nameSortLabel = state.nameSort === "asc" ? COPY.sort_z_a : COPY.sort_a_z
|
|
2391
2402
|
const nameSortIcon = state.nameSort === "desc"
|
|
2392
2403
|
? "fa-arrow-down-z-a"
|
|
2393
|
-
: "fa-arrow-down-a-z"
|
|
2404
|
+
: state.nameSort === "asc" ? "fa-arrow-down-a-z" : "fa-sort"
|
|
2394
2405
|
const separatePagePaths = selectablePagePaths(items)
|
|
2395
2406
|
const duplicatePagePaths = state.view === "duplicates"
|
|
2396
2407
|
? selectableDuplicatePagePaths(items)
|
|
@@ -2427,10 +2438,10 @@ const renderTable = (items) => {
|
|
|
2427
2438
|
allPageSelected = separatePagePaths.every((filePath) =>
|
|
2428
2439
|
state.selectedSeparateFiles.has(filePath))
|
|
2429
2440
|
}
|
|
2430
|
-
const nameSortButton = `<button class="vault-sort-button ${state.
|
|
2441
|
+
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
2442
|
const nameHeader = (header) => {
|
|
2432
2443
|
const inner = sortableName ? nameSortButton : `<span>${esc(header)}</span>`
|
|
2433
|
-
const sorted = !sortableName || state.
|
|
2444
|
+
const sorted = !sortableName || !state.nameSort
|
|
2434
2445
|
? "none"
|
|
2435
2446
|
: state.nameSort === "desc" ? "descending" : "ascending"
|
|
2436
2447
|
if (pageSelectionAttribute) {
|
|
@@ -2453,22 +2464,6 @@ const renderTable = (items) => {
|
|
|
2453
2464
|
syncDuplicateGroupPageSelectionCheckbox()
|
|
2454
2465
|
}
|
|
2455
2466
|
|
|
2456
|
-
const orderedItems = (items) => {
|
|
2457
|
-
if (state.view === "activity") return items
|
|
2458
|
-
if (items.some((item) => item.kind === "duplicate_group")) return items
|
|
2459
|
-
if (state.view === "reclaimable") {
|
|
2460
|
-
return [...items].sort((a, b) => compareRows(a, b,
|
|
2461
|
-
(blob) => blob.hash))
|
|
2462
|
-
}
|
|
2463
|
-
if (state.displayMode === "files" && supportsDisplayMode()) {
|
|
2464
|
-
return [...items].sort((a, b) => compareRows(a, b, flatLocation))
|
|
2465
|
-
}
|
|
2466
|
-
return [...items].sort((a, b) => {
|
|
2467
|
-
const sourceOrder = state.sourceId ? 0 : sourceSort([a.source_id], [b.source_id])
|
|
2468
|
-
return sourceOrder || compareRows(a, b, (item) => item.relative_path || item.path)
|
|
2469
|
-
})
|
|
2470
|
-
}
|
|
2471
|
-
|
|
2472
2467
|
const pagedItems = (items) => {
|
|
2473
2468
|
const inventory = state.data.inventory
|
|
2474
2469
|
state.page = Number(inventory.page) || 0
|
|
@@ -2485,7 +2480,7 @@ const pagedItems = (items) => {
|
|
|
2485
2480
|
}
|
|
2486
2481
|
}
|
|
2487
2482
|
return {
|
|
2488
|
-
items
|
|
2483
|
+
items,
|
|
2489
2484
|
start: Number(inventory.start) || 0,
|
|
2490
2485
|
end: Number(inventory.end) || 0,
|
|
2491
2486
|
total: Number(inventory.total) || 0,
|
|
@@ -2522,7 +2517,9 @@ const paneFooterText = () => {
|
|
|
2522
2517
|
? COPY.sorted_largest
|
|
2523
2518
|
: state.sizeSort === "asc"
|
|
2524
2519
|
? COPY.sorted_smallest
|
|
2525
|
-
: state.nameSort === "desc"
|
|
2520
|
+
: state.nameSort === "desc"
|
|
2521
|
+
? COPY.sorted_z_a
|
|
2522
|
+
: state.nameSort === "asc" ? COPY.sorted_a_z : ""
|
|
2526
2523
|
return `${count}${order ? ` · ${order}` : ""}`
|
|
2527
2524
|
}
|
|
2528
2525
|
if (state.view === "duplicates") return COPY.duplicate_note
|
|
@@ -4021,17 +4018,23 @@ document.addEventListener("click", async (event) => {
|
|
|
4021
4018
|
} else render()
|
|
4022
4019
|
}
|
|
4023
4020
|
return
|
|
4024
|
-
} else if (target.dataset.
|
|
4025
|
-
|
|
4026
|
-
|
|
4021
|
+
} else if (target.dataset.treePage) {
|
|
4022
|
+
const locationId = target.dataset.pageLocation || null
|
|
4023
|
+
const parent = target.dataset.pagePath
|
|
4024
|
+
const level = treeLevel(locationId, parent)
|
|
4025
|
+
const page = (Number(level && level.page) || 0) +
|
|
4026
|
+
(target.dataset.treePage === "next" ? 1 : -1)
|
|
4027
|
+
if (page < 0) return
|
|
4028
|
+
await loadTreeLevel(locationId, parent, page)
|
|
4029
|
+
// Only the root level owns the top of the table; paging a folder deeper in
|
|
4030
|
+
// should leave the reader where they were looking.
|
|
4031
|
+
if (!parent && !locationId) el("vault-table-wrap").scrollTop = 0
|
|
4027
4032
|
return
|
|
4028
4033
|
} else if (target.hasAttribute("data-sort-name")) {
|
|
4029
4034
|
clearFileSelections()
|
|
4030
4035
|
// Size ordering wins over name ordering server-side, so sorting by name
|
|
4031
4036
|
// has to drop it rather than sit underneath it doing nothing visible.
|
|
4032
|
-
state.nameSort =
|
|
4033
|
-
? "desc"
|
|
4034
|
-
: "asc"
|
|
4037
|
+
state.nameSort = state.nameSort === "asc" ? "desc" : "asc"
|
|
4035
4038
|
state.sizeSort = null
|
|
4036
4039
|
resetPage()
|
|
4037
4040
|
await refresh(true)
|
|
@@ -4039,7 +4042,7 @@ document.addEventListener("click", async (event) => {
|
|
|
4039
4042
|
clearFileSelections()
|
|
4040
4043
|
state.displayMode = target.dataset.displayMode === "files" ? "files" : "folders"
|
|
4041
4044
|
state.sizeSort = state.displayMode === "files" ? "desc" : null
|
|
4042
|
-
state.nameSort =
|
|
4045
|
+
state.nameSort = null
|
|
4043
4046
|
resetPage()
|
|
4044
4047
|
await refresh(true)
|
|
4045
4048
|
} else if (target.dataset.view || target.id === "btn-review-cleanup") {
|
|
@@ -4050,7 +4053,7 @@ document.addEventListener("click", async (event) => {
|
|
|
4050
4053
|
state.statusFilter = "all"
|
|
4051
4054
|
state.displayMode = state.view === "shared" ? "files" : "folders"
|
|
4052
4055
|
state.sizeSort = state.view === "shared" ? "desc" : null
|
|
4053
|
-
state.nameSort =
|
|
4056
|
+
state.nameSort = null
|
|
4054
4057
|
if (state.view === "duplicates") {
|
|
4055
4058
|
markScanReviewed()
|
|
4056
4059
|
state.scanResult = null
|
|
@@ -394,7 +394,9 @@ describe("Save Space engine", () => {
|
|
|
394
394
|
[`${root}\\app\\models\\big.bin`, 4000],
|
|
395
395
|
[`${root}\\app\\models\\small.bin`, 10],
|
|
396
396
|
[`${root}\\cache\\only\\deep\\x.bin`, 50],
|
|
397
|
-
[`${root}\\loose.bin`, 700]
|
|
397
|
+
[`${root}\\loose.bin`, 700],
|
|
398
|
+
[`${root}\\Zebra\\z.bin`, 5],
|
|
399
|
+
[`${root}\\apple\\a.bin`, 5]
|
|
398
400
|
].forEach(([filePath, size], index) =>
|
|
399
401
|
insert.run(filePath, `h${index}`, size, index + 1))
|
|
400
402
|
|
|
@@ -409,7 +411,24 @@ describe("Save Space engine", () => {
|
|
|
409
411
|
limit: 50,
|
|
410
412
|
offset: 0
|
|
411
413
|
})
|
|
412
|
-
assert.deepEqual(directories.map((row) => row.name),
|
|
414
|
+
assert.deepEqual(directories.map((row) => row.name).slice(0, 2),
|
|
415
|
+
["app", "cache"])
|
|
416
|
+
// Folder names fold case the same way file names do, so a capitalised
|
|
417
|
+
// folder does not jump ahead of every lowercase one.
|
|
418
|
+
const cased = core.treeDirectoryRows({
|
|
419
|
+
sourceId: "app:demo",
|
|
420
|
+
prefix: `${root}\\`,
|
|
421
|
+
separator: "\\",
|
|
422
|
+
statuses: [],
|
|
423
|
+
query: "",
|
|
424
|
+
nameOnly: false,
|
|
425
|
+
sizeSort: null,
|
|
426
|
+
nameSort: "asc",
|
|
427
|
+
limit: 50,
|
|
428
|
+
offset: 0
|
|
429
|
+
})
|
|
430
|
+
assert.deepEqual(cased.map((row) => row.name),
|
|
431
|
+
["app", "apple", "cache", "Zebra"])
|
|
413
432
|
assert.equal(directories[0].bytes, 4010)
|
|
414
433
|
// The collapse reads these, so they have to carry the whole subpath.
|
|
415
434
|
assert.equal(directories[1].lo, "cache\\only\\deep\\x.bin")
|
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,92 @@ 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
|
+
|
|
1018
|
+
test("Duplicates in Folders orders by name, not by the path below it", async () => {
|
|
1019
|
+
// Names and paths disagree on purpose: ordering by path gives decord,
|
|
1020
|
+
// numpy.libs, torch, triton -- which is what the screen used to show.
|
|
1021
|
+
const files = [
|
|
1022
|
+
["zebra.dll", "app/decord/zebra.dll"],
|
|
1023
|
+
["mango.dll", "app/numpy.libs/mango.dll"],
|
|
1024
|
+
["apple.dll", "app/torch/lib/apple.dll"]
|
|
1025
|
+
].map(([name, relative]) => item({
|
|
1026
|
+
path: `/pinokio/api/app/${relative}`,
|
|
1027
|
+
relative_path: relative,
|
|
1028
|
+
status: "duplicate",
|
|
1029
|
+
shareable: true,
|
|
1030
|
+
location_count: 2
|
|
1031
|
+
}))
|
|
1032
|
+
const base = fixture(files)
|
|
1033
|
+
base.inventory.source_counts.duplicates["app:app"] = files.length
|
|
1034
|
+
base.inventory.shareable_by_source["app:app"] = files.length
|
|
1035
|
+
const { dom } = await makePage(base)
|
|
1036
|
+
const document = dom.window.document
|
|
1037
|
+
|
|
1038
|
+
document.querySelector('[data-view="duplicates"]').click()
|
|
1039
|
+
await waitFor(() => document.querySelector(
|
|
1040
|
+
'[data-view="duplicates"].selected'))
|
|
1041
|
+
const names = () => [...document.querySelectorAll(
|
|
1042
|
+
"#vault-table-wrap .vault-file-name")].map((node) => node.textContent)
|
|
1043
|
+
|
|
1044
|
+
document.querySelector("[data-sort-name]").click()
|
|
1045
|
+
await waitFor(() => names()[0] === "apple.dll")
|
|
1046
|
+
assert.deepEqual(names(), ["apple.dll", "mango.dll", "zebra.dll"])
|
|
1047
|
+
|
|
1048
|
+
document.querySelector("[data-sort-name]").click()
|
|
1049
|
+
await waitFor(() => names()[0] === "zebra.dll")
|
|
1050
|
+
assert.deepEqual(names(), ["zebra.dll", "mango.dll", "apple.dll"])
|
|
1051
|
+
await settle()
|
|
1052
|
+
dom.window.close()
|
|
1053
|
+
})
|
|
1054
|
+
|
|
959
1055
|
test("name sorting is offered wherever the Name column holds a name", async () => {
|
|
960
1056
|
const duplicate = item({
|
|
961
1057
|
path: "/pinokio/api/app/models/duplicate.bin",
|