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.
@@ -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 === "desc" ? "desc" : "asc"
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 === "desc" ? "desc" : "asc",
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" || sort === "path_desc" ? -pathOrder : pathOrder
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 === "path_desc") {
5219
- where.push("path < ?")
5220
- values.push(cursor.path)
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 === "path_desc"
5231
- ? "path DESC"
5232
- : "path ASC"
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
- for (const stream of streams) refill(stream, 1)
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
- refill(selected, 16)
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
- ? "bytes ASC, name ASC"
5419
+ ? `bytes ASC, ${folded} ASC`
5388
5420
  : sizeSort === "desc"
5389
- ? "bytes DESC, name ASC"
5390
- : nameSort === "desc" ? "name DESC" : "name ASC"
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 === "desc" ? "path_desc" : "path"
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) => this.encodeCursor({
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.114",
3
+ "version": "8.0.117",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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
- show_more: "Show more",
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 === "desc") query.set("name_sort", "desc")
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 === "desc") query.set("name_sort", "desc")
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: "asc",
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
- const loadTreeLevel = async (locationId, parent, append = false) => {
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 cursor = append && existing ? existing.nextCursor : null
1937
- const directoryOffset = append && existing
1938
- ? existing.nextDirectoryOffset || 0
1939
- : 0
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 && append ? existing.items : [],
1943
+ items: existing ? existing.items : [],
1942
1944
  loading: true,
1943
- loaded: !!(existing && append),
1945
+ loaded: !!existing,
1944
1946
  error: null,
1945
- hasNext: existing ? existing.hasNext : false,
1946
- nextCursor: existing ? existing.nextCursor : null,
1947
- nextDirectoryOffset: existing ? existing.nextDirectoryOffset : 0
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
- treeLevelUrl(locationId, parent, cursor, directoryOffset))
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
- loadTreeLevel(only.location_id, "")
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: append && previous
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
- hasNext: !!data.has_next,
1976
- nextCursor: data.next_cursor || null,
1977
- nextDirectoryOffset: Number(data.next_directory_offset) || 0
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
- hasNext: false,
1987
- nextCursor: null,
1988
- nextDirectoryOffset: 0
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, true)
2015
- const grown = treeLevel(locationId, parent)
2016
- if (!grown || grown.error || grown.items.length === level.items.length) break
2017
- level = grown
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 more = level.hasNext
2076
- ? treePlaceholderRow(depth, `<button class="vault-text-button" type="button" data-more-location="${attr(locationId || "")}" data-more-path="${attr(parent)}" ${level.loading ? "disabled" : ""}>${esc(COPY.show_more)}</button>`)
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 + more
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, flatLocation))
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, (item) => item.relative_path)).map((item) => renderFileRow(item, 0, true)).join("")}`
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 = (blobs) => {
2222
- if (!blobs.length) return emptyState("reclaimable")
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 === "desc" ? COPY.sort_a_z : COPY.sort_z_a
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.sizeSort ? "" : "active"}" type="button" data-sort-name aria-label="${attr(nameSortLabel)}">${esc(COPY.name)}<i class="fa-solid ${nameSortIcon}" aria-hidden="true"></i></button>`
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.sizeSort
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: orderedItems(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" ? COPY.sorted_z_a : COPY.sorted_a_z
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.morePath !== undefined) {
4025
- await loadTreeLevel(
4026
- target.dataset.moreLocation || null, target.dataset.morePath, true)
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 = !state.sizeSort && state.nameSort === "asc"
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 = "asc"
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 = "asc"
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), ["app", "cache"])
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")
@@ -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
- ...[...directories].map(([name, totals]) => Object.assign({
496
- kind: "directory",
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",