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.
@@ -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
@@ -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 === "desc" ? "path_desc" : "path"
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) => this.encodeCursor({
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.113",
3
+ "version": "8.0.116",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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: grid;
1656
+ display: flex;
1652
1657
  min-width: 0;
1653
1658
  min-height: 0;
1654
- grid-template-rows: auto auto minmax(0, 1fr) auto;
1659
+ flex-direction: column;
1655
1660
  overflow: hidden;
1656
1661
  }
1657
- body[data-vault-mode="global"] .vault-pane {
1658
- grid-template-rows: auto auto auto minmax(0, 1fr) auto;
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;
@@ -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,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, flatLocation))
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 === "desc" ? COPY.sort_a_z : COPY.sort_z_a
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.sizeSort ? "" : "active"}" type="button" data-sort-name aria-label="${attr(nameSortLabel)}">${esc(COPY.name)}<i class="fa-solid ${nameSortIcon}" aria-hidden="true"></i></button>`
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.sizeSort
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, flatLocation))
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" ? COPY.sorted_z_a : COPY.sorted_a_z
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.morePath !== undefined) {
4025
- await loadTreeLevel(
4026
- target.dataset.moreLocation || null, target.dataset.morePath, true)
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 = !state.sizeSort && state.nameSort === "asc"
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 = "asc"
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 = "asc"
4069
+ state.nameSort = null
4054
4070
  if (state.view === "duplicates") {
4055
4071
  markScanReviewed()
4056
4072
  state.scanResult = null
@@ -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,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",