pinokiod 8.0.46 → 8.0.48

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.
@@ -1,5 +1,11 @@
1
+ const CANDIDATE_SIZE_BASE = process.platform === "win32" ? 1024 : 1000
2
+ const CANDIDATE_SIZE_OPTIONS = [1, 10, 50, 100, 500]
3
+ .map((value) => value * CANDIDATE_SIZE_BASE ** 2)
4
+ .concat(CANDIDATE_SIZE_BASE ** 3)
5
+
1
6
  module.exports = {
2
- SIZE_THRESHOLD: 100 * 1024 * 1024,
7
+ SIZE_THRESHOLD: CANDIDATE_SIZE_OPTIONS[3],
8
+ CANDIDATE_SIZE_OPTIONS,
3
9
  TMP_SUFFIX: ".pinokio-dedup-tmp",
4
10
  SHA256_RE: /^[0-9a-f]{64}$/,
5
11
  ENTRY_BATCH_SIZE: 256,
@@ -7,8 +7,8 @@ const Sweeper = require('./sweeper')
7
7
  const { walkBatches, statMany } = require('./walker')
8
8
  const { fileSnapshot, sameSnapshot, sameContentState } = require('./snapshot')
9
9
  const {
10
- SIZE_THRESHOLD, TMP_SUFFIX, SHA256_RE, DIR_CONCURRENCY, STAT_CONCURRENCY,
11
- HASH_INACTIVITY_MS
10
+ SIZE_THRESHOLD, CANDIDATE_SIZE_OPTIONS, TMP_SUFFIX, SHA256_RE,
11
+ DIR_CONCURRENCY, STAT_CONCURRENCY, HASH_INACTIVITY_MS
12
12
  } = require('./constants')
13
13
 
14
14
  // Shared model store engine (spec/requirements/shared-model-store.md).
@@ -171,9 +171,10 @@ class Vault {
171
171
  if (this.fileActionProgress === progress) this.fileActionProgress = null
172
172
  }
173
173
  }
174
- startScan(scopeId = null) {
174
+ startScan(scopeId = null, sizeThreshold = this.sizeThreshold) {
175
175
  if (!this.enabled || !this.sweeper) return { started: false, disabled: true }
176
176
  if (this.scanPromise) return { started: false, already_running: true }
177
+ this.sizeThreshold = sizeThreshold
177
178
  this.scanError = null
178
179
  this.scanScopeId = scopeId
179
180
  this.scanPromise = this.runExclusive(() => this.sweeper.scan(scopeId))
@@ -204,11 +205,19 @@ class Vault {
204
205
  }
205
206
  }
206
207
  }
207
- case "scan":
208
+ case "scan": {
208
209
  if (payload.scope_id && !this.scanSource(payload.scope_id)) {
209
210
  return { error: "That scan location is no longer available." }
210
211
  }
211
- return this.startScan(payload.scope_id || null)
212
+ let sizeThreshold = this.sizeThreshold
213
+ if (payload.candidate_size != null) {
214
+ if (!CANDIDATE_SIZE_OPTIONS.includes(payload.candidate_size)) {
215
+ return { error: "Choose a valid minimum file size." }
216
+ }
217
+ sizeThreshold = payload.candidate_size
218
+ }
219
+ return this.startScan(payload.scope_id || null, sizeThreshold)
220
+ }
212
221
  case "deduplicate":
213
222
  if (typeof payload.path === "string" && payload.path) {
214
223
  return this.runMutation(() => this.runFileAction({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.46",
3
+ "version": "8.0.48",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -29,7 +29,10 @@ body.dark.vault-page {
29
29
  min-height: 0;
30
30
  overflow: hidden;
31
31
  }
32
- .vault-embed-main .vault-shell { width: 100%; }
32
+ .vault-embed-main .vault-shell {
33
+ width: 100%;
34
+ height: 100%;
35
+ }
33
36
  body.vault-page .task-container {
34
37
  display: flex;
35
38
  min-height: 0;
@@ -909,10 +912,17 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
909
912
  .vault-empty p { margin: 0; color: var(--task-muted); font-size: 12px; line-height: 1.55; }
910
913
  .vault-empty .vault-button { margin-top: 10px; }
911
914
  .vault-pane-footer {
915
+ display: flex;
916
+ align-items: center;
917
+ justify-content: space-between;
918
+ gap: 12px;
912
919
  padding: 9px var(--vault-inline) 11px;
913
920
  color: var(--task-muted);
914
921
  font-size: 10.5px;
915
922
  }
923
+ .vault-pagination { display: inline-flex; align-items: center; gap: 10px; white-space: nowrap; }
924
+ .vault-pagination .vault-text-button:disabled { cursor: default; }
925
+ .vault-page-range { font-variant-numeric: tabular-nums; }
916
926
  .vault-event-kind { font-weight: 570; }
917
927
  .vault-event-time { color: var(--task-muted); font-size: 10.5px; }
918
928
  .vault-unavailable { color: var(--task-muted); font-size: 11px; }
@@ -924,6 +934,8 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
924
934
  }
925
935
  @media (max-width: 820px) {
926
936
  body.vault-page .task-container { display: block; overflow-y: auto; }
937
+ .vault-embed-main { overflow-y: auto; }
938
+ .vault-embed-main .vault-shell { height: auto; }
927
939
  .vault-shell,
928
940
  .vault-shell .task-shell-body,
929
941
  .vault-body { height: auto; overflow: visible; }
@@ -938,6 +950,7 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
938
950
  .vault-result-message { grid-template-columns: 16px minmax(0, 1fr); padding: 9px 0; }
939
951
  .vault-result-heading { align-items: flex-start; flex-direction: column; gap: 2px; }
940
952
  .vault-result-actions { grid-column: 2; }
953
+ .vault-pane-footer { align-items: flex-start; flex-direction: column; }
941
954
  .vault-result.incomplete .vault-button { margin: 0; }
942
955
  }
943
956
  @media (pointer: coarse) {
@@ -42,6 +42,7 @@ const COPY = {
42
42
  scan_app: "Scan this app",
43
43
  scan_again: "Scan again",
44
44
  scanning: "Scanning…",
45
+ minimum_file_size: "Minimum file size to scan",
45
46
  scanning_elsewhere: "Another location is being scanned",
46
47
  scanning_elsewhere_hint: "This app can be scanned when the current scan finishes.",
47
48
  vault_options: "Save space options",
@@ -102,6 +103,9 @@ const COPY = {
102
103
  display_mode: "Display mode",
103
104
  sorted_largest: "sorted largest first",
104
105
  sorted_smallest: "sorted smallest first",
106
+ previous: "Previous",
107
+ next: "Next",
108
+ file_pages: "File pages",
105
109
  status: "Deduplication status",
106
110
  matches: "Matches",
107
111
  can_save: "Can save",
@@ -168,7 +172,7 @@ const COPY = {
168
172
  no_activity_hint: "Scans and actions will be recorded here.",
169
173
  view_all: "View all files",
170
174
  show_all_locations: "Show all locations",
171
- tracked_note: "Only files 100 MB and larger appear here. Files keep their current locations.",
175
+ tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
172
176
  duplicate_note: "Only files waiting for review are shown.",
173
177
  reclaimable_note: "These copies are no longer used by any configured location. Deleting them frees disk space.",
174
178
  converted: "Deduplicated",
@@ -197,6 +201,12 @@ const statusUrl = (progress = false) => {
197
201
  return `/info/dedup${suffix ? `?${suffix}` : ""}`
198
202
  }
199
203
  const reviewedScanKey = `pinokio:vault:reviewed-scan:${SCOPE_ID || "global"}`
204
+ const candidateSizeKey = "pinokio:vault:candidate-size"
205
+ const candidateSizeBase = document.body.dataset.platform === "win32" ? 1024 : 1000
206
+ const candidateSizeOptions = [1, 10, 50, 100, 500]
207
+ .map((value) => value * candidateSizeBase ** 2)
208
+ .concat(candidateSizeBase ** 3)
209
+ const PAGE_SIZE = 500
200
210
 
201
211
  const state = {
202
212
  data: null,
@@ -215,7 +225,9 @@ const state = {
215
225
  scanProblemsOpen: false,
216
226
  feedback: null,
217
227
  actionProgress: null,
218
- actionRequest: false
228
+ actionRequest: false,
229
+ page: 0,
230
+ pageContext: null
219
231
  }
220
232
 
221
233
  const el = (id) => document.getElementById(id)
@@ -224,6 +236,10 @@ const esc = (value) => String(value == null ? "" : value).replace(/[&<>"']/g, (c
224
236
  }[char]))
225
237
  const attr = esc
226
238
  const fmt = window.PinokioFormatStorageSize
239
+ const candidateSize = () => {
240
+ const value = Number(el("vault-candidate-size").value)
241
+ return candidateSizeOptions.includes(value) ? value : candidateSizeOptions[3]
242
+ }
227
243
  const countLabel = (count, singular = COPY.file, plural = COPY.files) => `${count} ${count === 1 ? singular : plural}`
228
244
  const basename = (value) => String(value || "").split(/[\\/]/).filter(Boolean).pop() || ""
229
245
  const dirname = (value) => {
@@ -696,8 +712,9 @@ const renderDuplicateGroups = (items) => {
696
712
  return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
697
713
  const source = sourceById(sourceId)
698
714
  const bytes = group.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
699
- const action = source && source.shareable && group.some((item) => item.shareable)
700
- ? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(group.filter((item) => item.shareable).length)}</button>`
715
+ const allShareable = scopeDuplicates(sourceId).filter((item) => item.shareable !== false)
716
+ const action = source && source.shareable && allShareable.length
717
+ ? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(allShareable.length)}</button>`
701
718
  : `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
702
719
  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("")}`
703
720
  }).join("")
@@ -741,10 +758,9 @@ const emptyState = (view) => {
741
758
  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>` : view === "all" && !activeScan ? `<button class="vault-button" type="button" id="btn-empty-scan">${esc(scanLabel)}</button>` : ""}</div></div>`
742
759
  }
743
760
 
744
- const renderReclaimable = () => {
745
- const blobs = state.data.blobs.filter((blob) => blob.orphan)
761
+ const renderReclaimable = (blobs) => {
746
762
  if (!blobs.length) return emptyState("reclaimable")
747
- return [...blobs].sort((a, b) => compareRows(a, b, (blob) => blob.names[0] ? blob.names[0].path : blob.hash)).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.names[0] ? basename(blob.names[0].path) : `${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)}">${esc(COPY.reclaim)}</button></span></div>`).join("")
763
+ 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.names[0] ? basename(blob.names[0].path) : `${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)}">${esc(COPY.reclaim)}</button></span></div>`).join("")
748
764
  }
749
765
 
750
766
  const renderActivity = () => {
@@ -784,7 +800,7 @@ const renderTable = (items) => {
784
800
  } else if (state.view === "reclaimable") {
785
801
  tableClass = "reclaimable"
786
802
  headers = [COPY.name, COPY.size, COPY.can_free, ""]
787
- body = renderReclaimable()
803
+ body = renderReclaimable(items)
788
804
  } else if (state.view === "activity") {
789
805
  tableClass = "activity"
790
806
  headers = [COPY.name, COPY.size, COPY.last_scanned, ""]
@@ -805,6 +821,73 @@ const renderTable = (items) => {
805
821
  el("vault-table-wrap").innerHTML = `<div class="vault-table ${tableClass}"><div class="vault-columns">${headerMarkup}</div>${body}</div>`
806
822
  }
807
823
 
824
+ const orderedItems = (items) => {
825
+ if (state.view === "activity") return items
826
+ if (state.view === "reclaimable") {
827
+ return [...items].sort((a, b) => compareRows(a, b,
828
+ (blob) => blob.names[0] ? blob.names[0].path : blob.hash))
829
+ }
830
+ if (state.displayMode === "files" && supportsDisplayMode()) {
831
+ return [...items].sort((a, b) => compareRows(a, b, flatLocation))
832
+ }
833
+ return [...items].sort((a, b) => {
834
+ const sourceOrder = state.sourceId ? 0 : sourceSort([a.source_id], [b.source_id])
835
+ return sourceOrder || compareRows(a, b, (item) => item.relative_path || item.path)
836
+ })
837
+ }
838
+
839
+ const pagedItems = (items) => {
840
+ if (state.view === "activity") {
841
+ return { items, start: 0, end: items.length, total: items.length, pages: 1 }
842
+ }
843
+ const context = JSON.stringify([
844
+ state.view, state.sourceId, state.query, state.statusFilter,
845
+ state.displayMode, state.sizeSort
846
+ ])
847
+ if (context !== state.pageContext) {
848
+ state.page = 0
849
+ state.pageContext = context
850
+ }
851
+ const ordered = orderedItems(items)
852
+ const pages = Math.max(1, Math.ceil(ordered.length / PAGE_SIZE))
853
+ state.page = Math.max(0, Math.min(state.page, pages - 1))
854
+ const start = state.page * PAGE_SIZE
855
+ const end = Math.min(start + PAGE_SIZE, ordered.length)
856
+ return { items: ordered.slice(start, end), start, end, total: ordered.length, pages }
857
+ }
858
+
859
+ const paneFooterText = (items) => {
860
+ if (state.displayMode === "files" && supportsDisplayMode()) {
861
+ const count = state.view === "shared"
862
+ ? countLabel(items.length, "deduplicated file", "deduplicated files")
863
+ : state.view === "independent"
864
+ ? countLabel(items.length, "file kept separate", "files kept separate")
865
+ : countLabel(items.length)
866
+ const order = state.sizeSort === "desc"
867
+ ? COPY.sorted_largest
868
+ : state.sizeSort === "asc" ? COPY.sorted_smallest : ""
869
+ return `${count}${order ? ` · ${order}` : ""}`
870
+ }
871
+ if (state.view === "duplicates") return COPY.duplicate_note
872
+ if (state.view === "reclaimable") return COPY.reclaimable_note
873
+ return COPY.tracked_note.replace("{size}", fmt(candidateSize()))
874
+ }
875
+
876
+ const renderPaneFooter = (items, page) => {
877
+ const footer = el("vault-pane-footer")
878
+ const message = paneFooterText(items)
879
+ if (page.pages === 1) {
880
+ footer.textContent = message
881
+ return
882
+ }
883
+ footer.innerHTML = `<span>${esc(message)}</span>
884
+ <span class="vault-pagination" role="navigation" aria-label="${attr(COPY.file_pages)}">
885
+ <button class="vault-text-button" type="button" data-page="previous" ${state.page === 0 ? "disabled" : ""}>${esc(COPY.previous)}</button>
886
+ <span class="vault-page-range">${page.start + 1}–${page.end} of ${page.total}</span>
887
+ <button class="vault-text-button" type="button" data-page="next" ${state.page === page.pages - 1 ? "disabled" : ""}>${esc(COPY.next)}</button>
888
+ </span>`
889
+ }
890
+
808
891
  const renderOverview = () => {
809
892
  const data = state.data
810
893
  const last = data.last_scan
@@ -875,6 +958,7 @@ const renderOverview = () => {
875
958
  ? `<i class="fa-solid fa-circle-notch fa-spin"></i>${esc(COPY.scanning)}`
876
959
  : `<i class="fa-solid fa-rotate"></i>${esc(last ? COPY.scan_again : (IS_APP_MODE ? COPY.scan_app : COPY.scan))}`
877
960
  el("btn-scan").disabled = activeScan
961
+ el("vault-candidate-size").disabled = activeScan
878
962
  const optionsButton = el("btn-vault-options")
879
963
  const repairTitle = el("vault-repair-title")
880
964
  const repairDescription = el("vault-repair-description")
@@ -1114,27 +1198,16 @@ const render = () => {
1114
1198
  el("vault-explorer").style.display = "none"
1115
1199
  return
1116
1200
  }
1117
- el("vault-explorer").style.display = "grid"
1201
+ el("vault-explorer").style.display = ""
1118
1202
  const items = buildItems()
1119
1203
  renderViews(items)
1120
1204
  renderLocations(items)
1121
1205
  const visible = activeItems(items)
1122
1206
  renderToolbar(visible, items)
1123
- renderTable(visible)
1207
+ const page = pagedItems(visible)
1208
+ renderTable(page.items)
1124
1209
  renderActionProgress()
1125
- if (state.displayMode === "files" && supportsDisplayMode()) {
1126
- const count = state.view === "shared"
1127
- ? countLabel(visible.length, "deduplicated file", "deduplicated files")
1128
- : state.view === "independent"
1129
- ? countLabel(visible.length, "file kept separate", "files kept separate")
1130
- : countLabel(visible.length)
1131
- const order = state.sizeSort === "desc" ? COPY.sorted_largest : state.sizeSort === "asc" ? COPY.sorted_smallest : ""
1132
- el("vault-pane-footer").textContent = `${count}${order ? ` · ${order}` : ""}`
1133
- } else {
1134
- el("vault-pane-footer").textContent = state.view === "duplicates"
1135
- ? COPY.duplicate_note
1136
- : state.view === "reclaimable" ? COPY.reclaimable_note : COPY.tracked_note
1137
- }
1210
+ renderPaneFooter(visible, page)
1138
1211
  }
1139
1212
 
1140
1213
  const scanActive = (scan) => !!(scan && (scan.pending || scan.active || scan.queued > 0))
@@ -1392,6 +1465,10 @@ document.addEventListener("click", async (event) => {
1392
1465
  if (target.id === "btn-dismiss-cloud") {
1393
1466
  try { localStorage.setItem(target.dataset.warningKey, "1") } catch (error) {}
1394
1467
  renderCloudWarning()
1468
+ } else if (target.dataset.page) {
1469
+ state.page += target.dataset.page === "next" ? 1 : -1
1470
+ render()
1471
+ el("vault-table-wrap").scrollTop = 0
1395
1472
  } else if (target.hasAttribute("data-sort-size")) {
1396
1473
  state.sizeSort = state.sizeSort === "desc" ? "asc" : "desc"
1397
1474
  render()
@@ -1468,7 +1545,11 @@ document.addEventListener("click", async (event) => {
1468
1545
  state.scanProblemsOpen = false
1469
1546
  state.feedback = null
1470
1547
  try {
1471
- const result = await post({ action: "scan", scope_id: SCOPE_ID })
1548
+ const result = await post({
1549
+ action: "scan",
1550
+ scope_id: SCOPE_ID,
1551
+ candidate_size: candidateSize()
1552
+ })
1472
1553
  if (result.error) throw new Error(result.error)
1473
1554
  await refresh()
1474
1555
  } catch (error) {
@@ -1580,16 +1661,35 @@ document.addEventListener("input", (event) => {
1580
1661
  state.query = event.target.value
1581
1662
  const items = activeItems(buildItems())
1582
1663
  updateToolbarSummary(items)
1583
- renderTable(items)
1664
+ const page = pagedItems(items)
1665
+ renderTable(page.items)
1666
+ renderPaneFooter(items, page)
1584
1667
  renderActionProgress()
1585
1668
  })
1586
1669
  document.addEventListener("change", (event) => {
1670
+ if (event.target.id === "vault-candidate-size") {
1671
+ try { localStorage.setItem(candidateSizeKey, String(candidateSize())) } catch (error) {}
1672
+ render()
1673
+ return
1674
+ }
1587
1675
  if (event.target.id !== "vault-status-filter") return
1588
1676
  state.statusFilter = event.target.value
1589
1677
  render()
1590
1678
  })
1591
1679
 
1592
1680
  el("btn-scan").textContent = COPY.scan
1681
+ const candidateSizeSelect = el("vault-candidate-size")
1682
+ candidateSizeSelect.setAttribute("aria-label", COPY.minimum_file_size)
1683
+ candidateSizeSelect.innerHTML = candidateSizeOptions
1684
+ .map((size) => `<option value="${size}">${fmt(size)}+</option>`)
1685
+ .join("")
1686
+ candidateSizeSelect.value = String(candidateSizeOptions[3])
1687
+ try {
1688
+ const storedCandidateSize = Number(localStorage.getItem(candidateSizeKey))
1689
+ if (candidateSizeOptions.includes(storedCandidateSize)) {
1690
+ candidateSizeSelect.value = String(storedCandidateSize)
1691
+ }
1692
+ } catch (error) {}
1593
1693
  const addSourceButton = el("btn-add-source")
1594
1694
  if (addSourceButton) {
1595
1695
  addSourceButton.setAttribute("aria-label", COPY.add_external_folder)
@@ -4,6 +4,7 @@
4
4
  <section class='vault-overview'>
5
5
  <div class='vault-metrics' id='vault-metrics'></div>
6
6
  <div class='vault-overview-actions'>
7
+ <select class='vault-select' id='vault-candidate-size'></select>
7
8
  <button class='vault-button' id='btn-scan' type='button'></button>
8
9
  <% if (!appMode) { %>
9
10
  <details class='vault-advanced' id='vault-advanced'>
@@ -7,6 +7,7 @@ const crypto = require('crypto')
7
7
  const ejs = require('ejs')
8
8
  const { JSDOM } = require('jsdom')
9
9
  const Vault = require('../kernel/vault')
10
+ const { CANDIDATE_SIZE_OPTIONS } = require('../kernel/vault/constants')
10
11
 
11
12
  const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
12
13
 
@@ -66,6 +67,29 @@ describe('vault dashboard backend (phase 4)', () => {
66
67
  return { content, hash, a, b }
67
68
  }
68
69
 
70
+ test('scan requests accept only supported minimum file sizes', async () => {
71
+ const { vault } = await makeEnv()
72
+ let startedWith = null
73
+ vault.startScan = (scopeId, sizeThreshold) => {
74
+ startedWith = { scopeId, sizeThreshold }
75
+ return { started: true }
76
+ }
77
+
78
+ const accepted = await vault.perform('scan', {
79
+ candidate_size: CANDIDATE_SIZE_OPTIONS[0]
80
+ })
81
+ assert.deepStrictEqual(accepted, { started: true })
82
+ assert.deepStrictEqual(startedWith, {
83
+ scopeId: null,
84
+ sizeThreshold: CANDIDATE_SIZE_OPTIONS[0]
85
+ })
86
+
87
+ startedWith = null
88
+ const rejected = await vault.perform('scan', { candidate_size: 1 })
89
+ assert.deepStrictEqual(rejected, { error: 'Choose a valid minimum file size.' })
90
+ assert.strictEqual(startedWith, null)
91
+ })
92
+
69
93
  test('item 15: undo of a conversion batch restores independent files', async () => {
70
94
  const { home, vault } = await makeEnv()
71
95
  const { content, a, b } = await makeSharedPair(home, vault)
@@ -1090,6 +1114,9 @@ describe('vault dashboard backend (phase 4)', () => {
1090
1114
  assert.match(vaultPage, /\.vault-button\.primary \{[\s\S]*?background:\s*var\(--task-accent-contrast\)[\s\S]*?color:\s*#101828/)
1091
1115
  assert.match(vaultPage, /id="btn-review-result"/)
1092
1116
  assert.match(vaultPage, /class='vault-button' id='btn-scan'/)
1117
+ assert.match(vaultPage, /id='vault-candidate-size'/)
1118
+ assert.match(vaultPage, /candidate_size:\s*candidateSize\(\)/)
1119
+ assert.match(vaultPage, /localStorage\.setItem\(candidateSizeKey/)
1093
1120
  assert.match(vaultPage, /id='vault-storage-details'/)
1094
1121
  assert.match(vaultPage, /fmt\(data\.lifetime_bytes_saved\)/)
1095
1122
  assert.match(vaultPage, /repair_index:\s*"Repair index"/)
@@ -1201,6 +1228,18 @@ describe('vault dashboard backend (phase 4)', () => {
1201
1228
  assert.ok(appTemplate.indexOf("id='save-space-tab'") < appTemplate.indexOf('class="app-autolaunch"'))
1202
1229
  assert.match(globalTemplate, /include\('partials\/vault_workspace', \{ appMode: false \}\)/)
1203
1230
  assert.match(embeddedTemplate, /include\('partials\/vault_workspace', \{ appMode: true \}\)/)
1231
+ const vaultStyles = await fs.promises.readFile(
1232
+ path.resolve(__dirname, '..', 'server', 'public', 'vault.css'), 'utf8')
1233
+ assert.match(vaultStyles, /\.vault-embed-main \.vault-shell \{[\s\S]*?height:\s*100%;[\s\S]*?\}/)
1234
+ const compactStart = vaultStyles.indexOf('@media (max-width: 820px)')
1235
+ const compactEnd = vaultStyles.indexOf('@media (pointer: coarse)', compactStart)
1236
+ assert.ok(compactStart >= 0 && compactEnd > compactStart)
1237
+ const compactStyles = vaultStyles.slice(compactStart, compactEnd)
1238
+ assert.match(compactStyles, /\.vault-embed-main \{ overflow-y: auto; \}/)
1239
+ assert.match(compactStyles, /\.vault-embed-main \.vault-shell \{ height: auto; \}/)
1240
+ const vaultScript = await fs.promises.readFile(
1241
+ path.resolve(__dirname, '..', 'server', 'public', 'vault.js'), 'utf8')
1242
+ assert.match(vaultScript, /el\("vault-explorer"\)\.style\.display = ""/)
1204
1243
 
1205
1244
  const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1206
1245
  theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
@@ -1282,6 +1321,15 @@ describe('vault dashboard backend (phase 4)', () => {
1282
1321
 
1283
1322
  assert.deepStrictEqual(requests, ['/info/dedup?scope_id=app%3AappB'])
1284
1323
  assert.match(dom.window.document.getElementById('btn-scan').textContent, /Scan this app/)
1324
+ const candidateSize = dom.window.document.getElementById('vault-candidate-size')
1325
+ assert.deepStrictEqual([...candidateSize.options].map((option) => option.textContent),
1326
+ ['1 MB+', '10 MB+', '50 MB+', '100 MB+', '500 MB+', '1 GB+'])
1327
+ assert.strictEqual(candidateSize.value, '100000000')
1328
+ candidateSize.value = '50000000'
1329
+ candidateSize.dispatchEvent(new dom.window.Event('change', { bubbles: true }))
1330
+ assert.strictEqual(dom.window.localStorage.getItem('pinokio:vault:candidate-size'), '50000000')
1331
+ assert.match(dom.window.document.getElementById('vault-pane-footer').textContent,
1332
+ /Only files 50 MB and larger/)
1285
1333
  assert.strictEqual(dom.window.document.querySelector('[data-source="app:appB"]').getAttribute('aria-current'), 'page')
1286
1334
  assert.strictEqual(dom.window.document.getElementById('btn-add-source'), null)
1287
1335
  assert.strictEqual(dom.window.document.getElementById('btn-repair'), null)
@@ -1805,6 +1853,94 @@ describe('vault dashboard backend (phase 4)', () => {
1805
1853
  }
1806
1854
  })
1807
1855
 
1856
+ test('large file lists render bounded pages without narrowing Deduplicate all', async () => {
1857
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1858
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
1859
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
1860
+ })
1861
+ const dom = new JSDOM(html, {
1862
+ url: 'http://localhost/vault/app/appB',
1863
+ runScripts: 'dangerously'
1864
+ })
1865
+ const actions = []
1866
+ const duplicates = Array.from({ length: 501 }, (_, index) => {
1867
+ const name = `file-${String(index).padStart(4, '0')}.bin`
1868
+ return {
1869
+ path: `/pinokio/api/appB/${name}`,
1870
+ relative_path: name,
1871
+ source_id: 'app:appB',
1872
+ source_label: 'appB',
1873
+ size: 1024 + index,
1874
+ shareable: true,
1875
+ match: { path: `/pinokio/api/appA/${name}` }
1876
+ }
1877
+ })
1878
+ const status = {
1879
+ enabled: true,
1880
+ mode: 'link',
1881
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
1882
+ last_scan: { ts: Date.now(), bytes_total: 1024 * 501 },
1883
+ tracked_bytes: 1024 * 501,
1884
+ effective_bytes: 1024 * 501,
1885
+ shared_bytes: 0,
1886
+ pending_bytes: duplicates.reduce((sum, item) => sum + item.size, 0),
1887
+ activity_error: null,
1888
+ cloud_sync_warning: null,
1889
+ sources: [{
1890
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
1891
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
1892
+ }],
1893
+ blobs: [],
1894
+ duplicates,
1895
+ excluded: [],
1896
+ events: [],
1897
+ undo_batches: []
1898
+ }
1899
+ dom.window.fetch = async (url, options = {}) => {
1900
+ if (options.method === 'POST') {
1901
+ actions.push(JSON.parse(options.body))
1902
+ return { ok: true, json: async () => ({ converted: 501, bytes_saved: status.pending_bytes }) }
1903
+ }
1904
+ return { ok: true, json: async () => status }
1905
+ }
1906
+ await runVaultScript(dom)
1907
+ await new Promise((resolve) => setTimeout(resolve, 25))
1908
+
1909
+ dom.window.document.querySelector('[data-view="duplicates"]').click()
1910
+ const rows = () => [...dom.window.document.querySelectorAll('.vault-table.matches .vault-file-row')]
1911
+ let bulk = dom.window.document.querySelector('[data-deduplicate-all="duplicates"]')
1912
+ assert.strictEqual(rows().length, 500)
1913
+ assert.strictEqual(bulk.textContent, 'Deduplicate 501 files')
1914
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 501')
1915
+ assert.strictEqual(dom.window.document.querySelector('[data-page="previous"]').disabled, true)
1916
+ assert.strictEqual(dom.window.document.querySelector('[data-page="next"]').disabled, false)
1917
+
1918
+ dom.window.document.querySelector('[data-page="next"]').click()
1919
+ assert.strictEqual(rows().length, 1)
1920
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'file-0500.bin')
1921
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–501 of 501')
1922
+ assert.strictEqual(dom.window.document.querySelector('[data-page="previous"]').disabled, false)
1923
+ assert.strictEqual(dom.window.document.querySelector('[data-page="next"]').disabled, true)
1924
+
1925
+ bulk = dom.window.document.querySelector('[data-deduplicate-all="duplicates"]')
1926
+ assert.strictEqual(bulk.textContent, 'Deduplicate 501 files')
1927
+ bulk.click()
1928
+ await new Promise((resolve) => setTimeout(resolve, 25))
1929
+ assert.deepStrictEqual(actions[0], {
1930
+ action: 'deduplicate',
1931
+ selection: 'duplicates',
1932
+ scope_id: 'app:appB'
1933
+ })
1934
+
1935
+ const search = dom.window.document.getElementById('vault-search')
1936
+ search.value = 'file-0001.bin'
1937
+ search.dispatchEvent(new dom.window.Event('input', { bubbles: true }))
1938
+ assert.strictEqual(rows().length, 1)
1939
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'file-0001.bin')
1940
+ assert.strictEqual(dom.window.document.querySelector('.vault-pagination'), null)
1941
+ dom.window.close()
1942
+ })
1943
+
1808
1944
  test('app inventory uses explicit sharing actions without ambiguous switches', async () => {
1809
1945
  const views = path.resolve(__dirname, '..', 'server', 'views')
1810
1946
  const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {