pinokiod 8.0.47 → 8.0.49

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.
@@ -10,8 +10,9 @@ const COPY = {
10
10
  all_description: "Every scanned file and its current deduplication status.",
11
11
  duplicates_description: "Identical files waiting to be deduplicated or kept separate.",
12
12
  shared_description: "Files that share disk storage across multiple locations.",
13
+ tracked_description: "Files with no duplicate action required.",
13
14
  independent_description: "Duplicate files that remain as separate copies.",
14
- reclaimable_description: "Stored copies no longer used by any configured location.",
15
+ reclaimable_description: "Private links no longer used by any linked file.",
15
16
  activity_description: "A history of scans and changes made by Save space.",
16
17
  add_external_folder: "Add external folder",
17
18
  files_region: "Files",
@@ -24,7 +25,7 @@ const COPY = {
24
25
  disk_space_saved: "of disk space saved",
25
26
  saved_for_app: "{size} saved for this app",
26
27
  before: "Before",
27
- before_help: "Estimated space if every app stored its own copy.",
28
+ before_help: "Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.",
28
29
  after: "After",
29
30
  effective_help: "For deduplicated files, disk usage is divided evenly among every location using them.",
30
31
  nothing_more_to_save: "Nothing else to save",
@@ -86,6 +87,7 @@ const COPY = {
86
87
  search_all: "Search files",
87
88
  search_duplicates: "Search duplicates",
88
89
  search_shared: "Search deduplicated files",
90
+ search_tracked: "Search files with no action needed",
89
91
  search_skipped: "Search files kept separate",
90
92
  search_activity: "Search activity",
91
93
  search_in: "Search in {location}",
@@ -103,6 +105,9 @@ const COPY = {
103
105
  display_mode: "Display mode",
104
106
  sorted_largest: "sorted largest first",
105
107
  sorted_smallest: "sorted smallest first",
108
+ previous: "Previous",
109
+ next: "Next",
110
+ file_pages: "File pages",
106
111
  status: "Deduplication status",
107
112
  matches: "Matches",
108
113
  can_save: "Can save",
@@ -149,8 +154,13 @@ const COPY = {
149
154
  keep_separate: "Keep separate",
150
155
  kept_separate: "Kept separate",
151
156
  make_separate: "Make separate",
152
- reclaim: "Delete",
153
- reclaim_all: "Delete all",
157
+ reclaim: "Clean up",
158
+ reclaim_all: "Clean up all",
159
+ cleanup_ready: "{size} ready to clean up",
160
+ cleanup_ready_detail: "{count} left after their linked files were deleted.",
161
+ review_cleanup: "Review cleanup",
162
+ private_link: "private link",
163
+ private_links: "private links",
154
164
  undo: "Undo",
155
165
  identical_contents_at: "Identical contents at",
156
166
  no_files: "No files found",
@@ -161,26 +171,29 @@ const COPY = {
161
171
  no_duplicates_hint: "There are no files waiting for your review.",
162
172
  no_shared: "Nothing deduplicated yet",
163
173
  no_shared_hint: "Deduplicated files will appear here after you review duplicates.",
174
+ no_tracked: "No files with no action needed",
175
+ no_tracked_hint: "Files without a duplicate action will appear here after a scan.",
164
176
  no_skipped: "Nothing kept separate",
165
177
  no_skipped_hint: "Files you keep separate will appear here.",
166
- no_reclaimable: "No unused files",
167
- no_reclaimable_hint: "Copies no longer used by any configured location will appear here.",
178
+ no_reclaimable: "No cleanup needed",
179
+ no_reclaimable_hint: "Private links with no remaining linked files will appear here.",
168
180
  no_activity: "No activity yet",
169
181
  no_activity_hint: "Scans and actions will be recorded here.",
170
182
  view_all: "View all files",
171
183
  show_all_locations: "Show all locations",
172
184
  tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
173
185
  duplicate_note: "Only files waiting for review are shown.",
174
- reclaimable_note: "These copies are no longer used by any configured location. Deleting them frees disk space.",
186
+ reclaimable_note: "These private links have no remaining linked files. Cleaning them up frees disk space.",
187
+ activity_note: "Recent scans and Save space actions.",
175
188
  converted: "Deduplicated",
176
189
  files_left_separate: "{count} remained separate",
177
190
  skipped_action: "Kept separate",
178
191
  separated: "Separated",
179
- reclaimed: "Deleted",
192
+ reclaimed: "Cleaned up",
180
193
  event_convert: "Deduplicated",
181
194
  event_found: "Duplicate found",
182
195
  event_adopt: "Added",
183
- event_reclaim: "Deleted unused file",
196
+ event_reclaim: "Cleaned up unused link",
184
197
  event_undo: "Undid deduplication",
185
198
  event_diverged: "Changed by an app — no longer deduplicated",
186
199
  event_detach: "Separated",
@@ -200,9 +213,10 @@ const statusUrl = (progress = false) => {
200
213
  const reviewedScanKey = `pinokio:vault:reviewed-scan:${SCOPE_ID || "global"}`
201
214
  const candidateSizeKey = "pinokio:vault:candidate-size"
202
215
  const candidateSizeBase = document.body.dataset.platform === "win32" ? 1024 : 1000
203
- const candidateSizeOptions = [10, 50, 100, 500]
216
+ const candidateSizeOptions = [1, 10, 50, 100, 500]
204
217
  .map((value) => value * candidateSizeBase ** 2)
205
218
  .concat(candidateSizeBase ** 3)
219
+ const PAGE_SIZE = 500
206
220
 
207
221
  const state = {
208
222
  data: null,
@@ -221,7 +235,9 @@ const state = {
221
235
  scanProblemsOpen: false,
222
236
  feedback: null,
223
237
  actionProgress: null,
224
- actionRequest: false
238
+ actionRequest: false,
239
+ page: 0,
240
+ pageContext: null
225
241
  }
226
242
 
227
243
  const el = (id) => document.getElementById(id)
@@ -232,7 +248,7 @@ const attr = esc
232
248
  const fmt = window.PinokioFormatStorageSize
233
249
  const candidateSize = () => {
234
250
  const value = Number(el("vault-candidate-size").value)
235
- return candidateSizeOptions.includes(value) ? value : candidateSizeOptions[2]
251
+ return candidateSizeOptions.includes(value) ? value : candidateSizeOptions[3]
236
252
  }
237
253
  const countLabel = (count, singular = COPY.file, plural = COPY.files) => `${count} ${count === 1 ? singular : plural}`
238
254
  const basename = (value) => String(value || "").split(/[\\/]/).filter(Boolean).pop() || ""
@@ -274,6 +290,16 @@ const sourcePath = (source) => {
274
290
  if (source.kind === "app") return IS_APP_MODE ? (source.display_path || source.root || "") : ""
275
291
  return source.display_path || source.root || ""
276
292
  }
293
+ const externalLocation = (item) => {
294
+ const source = sourceById(item.source_id)
295
+ if (!source || source.kind !== "external") return ""
296
+ const base = sourcePath(source).replace(/[\\/]+$/, "")
297
+ const relative = String(item.relative_path || "").replace(/^[\\/]+/, "")
298
+ if (!base) return relative
299
+ if (!relative) return base
300
+ const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/"
301
+ return `${base}${separator}${separator === "\\" ? relative.replace(/\//g, "\\") : relative}`
302
+ }
277
303
  const isDescendantSource = (candidateId, parentId) => {
278
304
  if (!parentId) return true
279
305
  if (candidateId === parentId) return true
@@ -331,9 +357,10 @@ const getCounts = (items) => ({
331
357
  all: items.length,
332
358
  duplicates: items.filter((item) => item.status === "duplicate").length,
333
359
  shared: items.filter((item) => item.status === "shared").length,
360
+ tracked: items.filter((item) => item.status === "tracked").length,
334
361
  independent: items.filter((item) => item.status === "independent").length,
335
362
  reclaimable: state.data.blobs.filter((blob) => blob.orphan).length,
336
- activity: state.data.events.length
363
+ activity: activityItems("").length
337
364
  })
338
365
 
339
366
  const sourceCounts = (items) => {
@@ -356,6 +383,7 @@ const activeItems = (items) => {
356
383
  let result = items
357
384
  if (state.view === "duplicates") result = result.filter((item) => item.status === "duplicate")
358
385
  if (state.view === "shared") result = result.filter((item) => item.status === "shared")
386
+ if (state.view === "tracked") result = result.filter((item) => item.status === "tracked")
359
387
  if (state.view === "independent") result = result.filter((item) => item.status === "independent")
360
388
  if (state.sourceId) result = result.filter((item) => isDescendantSource(item.source_id, state.sourceId))
361
389
  if (state.view === "all" && state.statusFilter !== "all") result = result.filter((item) => item.status === state.statusFilter)
@@ -368,6 +396,7 @@ const viewIcon = {
368
396
  all: "fa-regular fa-file-lines",
369
397
  duplicates: "fa-regular fa-copy",
370
398
  shared: "fa-solid fa-link",
399
+ tracked: "fa-regular fa-circle-check",
371
400
  independent: "fa-solid fa-circle-minus",
372
401
  reclaimable: "fa-regular fa-trash-can",
373
402
  activity: "fa-solid fa-wave-square"
@@ -376,6 +405,7 @@ const viewLabel = {
376
405
  all: COPY.all,
377
406
  duplicates: COPY.duplicates,
378
407
  shared: COPY.shared,
408
+ tracked: COPY.tracked,
379
409
  independent: COPY.skipped,
380
410
  reclaimable: COPY.reclaimable,
381
411
  activity: COPY.activity
@@ -391,18 +421,30 @@ const eventLabels = {
391
421
  skip: COPY.event_skip,
392
422
  reshare: COPY.event_reshare
393
423
  }
394
- const activityItems = () => {
395
- const query = state.query.toLowerCase()
396
- return state.data.events.filter((event) => !query ||
424
+ const activityItems = (queryText = state.query) => {
425
+ const query = queryText.trim().toLowerCase()
426
+ const events = state.data.events.filter((event) => !query ||
397
427
  `${eventLabels[event.kind] || event.kind} ${event.path || ""}`.toLowerCase().includes(query))
428
+ const seenBatches = new Set()
429
+ const eventItems = events.map((event) => {
430
+ const showUndo = event.kind === "convert" && event.batch_id &&
431
+ event.undoable !== false && !seenBatches.has(event.batch_id)
432
+ if (showUndo) seenBatches.add(event.batch_id)
433
+ return Object.assign({}, event, { activity_type: "event", show_undo: showUndo })
434
+ })
435
+ const batchItems = (Array.isArray(state.data.undo_batches) ? state.data.undo_batches : [])
436
+ .filter((batch) => !seenBatches.has(batch.batch_id) && (!query ||
437
+ `${COPY.event_convert} ${batch.files || 0} ${COPY.files}`.toLowerCase().includes(query)))
438
+ .map((batch) => Object.assign({}, batch, { activity_type: "batch" }))
439
+ return batchItems.concat(eventItems)
398
440
  }
399
441
 
400
442
  const renderViews = (items) => {
401
443
  const counts = getCounts(items)
402
444
  el("views-label").textContent = COPY.views
403
445
  const views = IS_APP_MODE
404
- ? ["all", "duplicates", "shared", "independent", "activity"]
405
- : ["all", "duplicates", "shared", "independent", "reclaimable", "activity"]
446
+ ? ["all", "duplicates", "shared", "tracked", "independent", "activity"]
447
+ : ["all", "duplicates", "shared", "tracked", "independent", "reclaimable", "activity"]
406
448
  el("vault-views").innerHTML = views.map((view) => `
407
449
  <button class="vault-nav-row ${state.view === view ? "selected" : ""}" type="button" data-view="${view}" ${state.view === view ? 'aria-current="page"' : ""}>
408
450
  <i class="${viewIcon[view]}"></i>
@@ -510,12 +552,14 @@ const updateToolbarSummary = (visibleItems) => {
510
552
  const searchPlaceholder = () => {
511
553
  if (state.view === "duplicates") return COPY.search_duplicates
512
554
  if (state.view === "shared") return COPY.search_shared
555
+ if (state.view === "tracked") return COPY.search_tracked
513
556
  if (state.view === "independent") return COPY.search_skipped
514
557
  if (state.view === "activity") return COPY.search_activity
515
558
  const source = selectedSource()
516
559
  return source && source.kind === "app" ? COPY.search_in.replace("{location}", source.label) : COPY.search_all
517
560
  }
518
- const supportsDisplayMode = () => state.view === "all" || state.view === "shared" || state.view === "independent"
561
+ const supportsDisplayMode = () => state.view === "all" || state.view === "shared" ||
562
+ state.view === "tracked" || state.view === "independent"
519
563
  const displayModeControl = () => supportsDisplayMode() ? `<div class="vault-display-mode" role="group" aria-label="${attr(COPY.display_mode)}">
520
564
  <button type="button" data-display-mode="folders" aria-pressed="${state.displayMode === "folders"}" class="${state.displayMode === "folders" ? "selected" : ""}">${esc(COPY.folders)}</button>
521
565
  <button type="button" data-display-mode="files" aria-pressed="${state.displayMode === "files"}" class="${state.displayMode === "files" ? "selected" : ""}">${esc(COPY.files_mode)}</button>
@@ -583,7 +627,7 @@ const sharingControl = (item) => {
583
627
  const fileDetail = (item) => {
584
628
  if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
585
629
  return `<div class="vault-detail"><div class="vault-detail-label">${esc(COPY.identical_contents_at)} ${countLabel(item.locations.length, COPY.location, COPY.locations_lower)}</div>${item.locations.map((location) => `
586
- <div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(location.source_label || "")}${location.relative_path ? ` / ${esc(location.relative_path)}` : ""}</span></div>`).join("")}</div>`
630
+ <div class="vault-location-detail"><i class="fa-regular fa-file"></i><span>${esc(externalLocation(location) || [location.source_label, location.relative_path].filter(Boolean).join(" / "))}</span></div>`).join("")}</div>`
587
631
  }
588
632
 
589
633
  const renderFileRow = (item, depth = 0, showMatch = false) => {
@@ -680,7 +724,8 @@ const sourceSort = (a, b) => {
680
724
  const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
681
725
  return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
682
726
  }
683
- const flatLocation = (item) => [groupTitle(sourceById(item.source_id)), item.relative_path].filter(Boolean).join(" / ")
727
+ const flatLocation = (item) => externalLocation(item) ||
728
+ [groupTitle(sourceById(item.source_id)), item.relative_path].filter(Boolean).join(" / ")
684
729
  const renderFlatFiles = (items) => [...items]
685
730
  .sort((a, b) => compareRows(a, b, flatLocation))
686
731
  .map((item) => {
@@ -706,8 +751,9 @@ const renderDuplicateGroups = (items) => {
706
751
  return [...groups.entries()].sort(sourceSort).map(([sourceId, group]) => {
707
752
  const source = sourceById(sourceId)
708
753
  const bytes = group.filter((item) => item.shareable).reduce((sum, item) => sum + item.size, 0)
709
- const action = source && source.shareable && group.some((item) => item.shareable)
710
- ? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(group.filter((item) => item.shareable).length)}</button>`
754
+ const allShareable = scopeDuplicates(sourceId).filter((item) => item.shareable !== false)
755
+ const action = source && source.shareable && allShareable.length
756
+ ? `<button class="vault-button" type="button" data-deduplicate-scope="${attr(sourceId)}">${esc(COPY.deduplicate)} ${countLabel(allShareable.length)}</button>`
711
757
  : `<span class="vault-unavailable">${esc(COPY.sharing_unavailable)}</span>`
712
758
  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("")}`
713
759
  }).join("")
@@ -743,6 +789,7 @@ const emptyState = (view) => {
743
789
  : [COPY.no_files, COPY.no_files_hint, "fa-regular fa-folder-open"],
744
790
  duplicates: [COPY.no_duplicates, COPY.no_duplicates_hint, "fa-regular fa-circle-check"],
745
791
  shared: [COPY.no_shared, COPY.no_shared_hint, "fa-solid fa-link"],
792
+ tracked: [COPY.no_tracked, COPY.no_tracked_hint, "fa-regular fa-circle-check"],
746
793
  independent: [COPY.no_skipped, COPY.no_skipped_hint, "fa-solid fa-circle-minus"],
747
794
  reclaimable: [COPY.no_reclaimable, COPY.no_reclaimable_hint, "fa-regular fa-circle-check"],
748
795
  activity: [COPY.no_activity, COPY.no_activity_hint, "fa-solid fa-wave-square"]
@@ -751,36 +798,26 @@ const emptyState = (view) => {
751
798
  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>`
752
799
  }
753
800
 
754
- const renderReclaimable = () => {
755
- const blobs = state.data.blobs.filter((blob) => blob.orphan)
801
+ const renderReclaimable = (blobs) => {
756
802
  if (!blobs.length) return emptyState("reclaimable")
757
- 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("")
803
+ 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("")
758
804
  }
759
805
 
760
- const renderActivity = () => {
761
- const events = activityItems()
762
- const query = state.query.trim().toLowerCase()
763
- const undoBatches = Array.isArray(state.data.undo_batches)
764
- ? state.data.undo_batches.filter((batch) => !query ||
765
- `${COPY.event_convert} ${batch.files || 0} ${COPY.files}`.toLowerCase().includes(query))
766
- : []
767
- if (!events.length && !undoBatches.length) return emptyState("activity")
768
- const seenBatches = new Set()
769
- const eventRows = events.map((event) => {
770
- let undo = ""
771
- if (event.kind === "convert" && event.batch_id && event.undoable !== false && !seenBatches.has(event.batch_id)) {
772
- seenBatches.add(event.batch_id)
773
- undo = `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
806
+ const renderActivity = (items) => {
807
+ if (!items.length) return emptyState("activity")
808
+ return items.map((item) => {
809
+ if (item.activity_type === "batch") {
810
+ return `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-solid fa-wave-square vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name vault-event-kind">${esc(COPY.event_convert)}</span><span class="vault-file-path">${esc(countLabel(item.files || 0))}</span></span></div><span class="vault-size">${fmt(item.bytes || 0)}</span><span class="vault-event-time">${item.ts ? esc(new Date(item.ts).toLocaleString()) : "—"}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-undo="${attr(item.batch_id)}">${esc(COPY.undo)}</button></span></div>`
774
811
  }
812
+ const event = item
813
+ const undo = event.show_undo
814
+ ? `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
815
+ : ""
775
816
  const eventPath = event.path
776
817
  ? `${event.source_label ? `${event.source_label} / ` : ""}${event.relative_path || event.path}`
777
818
  : (event.hash || "").slice(0, 12)
778
819
  return `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-solid fa-wave-square vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name vault-event-kind">${esc(eventLabels[event.kind] || event.kind)}</span><span class="vault-file-path">${esc(eventPath)}</span></span></div><span class="vault-size">${event.bytes_saved ? fmt(event.bytes_saved) : event.size ? fmt(event.size) : "—"}</span><span class="vault-event-time">${esc(new Date(event.ts).toLocaleString())}</span><span class="vault-row-action">${undo}</span></div>`
779
820
  }).join("")
780
- const batchRows = undoBatches.filter((batch) => !seenBatches.has(batch.batch_id)).map((batch) =>
781
- `<div class="vault-file-row"><div class="vault-name-cell"><span class="vault-disclosure"></span><i class="fa-solid fa-wave-square vault-name-icon"></i><span class="vault-name-copy"><span class="vault-file-name vault-event-kind">${esc(COPY.event_convert)}</span><span class="vault-file-path">${esc(countLabel(batch.files || 0))}</span></span></div><span class="vault-size">${fmt(batch.bytes || 0)}</span><span class="vault-event-time">${batch.ts ? esc(new Date(batch.ts).toLocaleString()) : "—"}</span><span class="vault-row-action"><button class="vault-text-button" type="button" data-undo="${attr(batch.batch_id)}">${esc(COPY.undo)}</button></span></div>`
782
- ).join("")
783
- return batchRows + eventRows
784
821
  }
785
822
 
786
823
  const renderTable = (items) => {
@@ -794,11 +831,11 @@ const renderTable = (items) => {
794
831
  } else if (state.view === "reclaimable") {
795
832
  tableClass = "reclaimable"
796
833
  headers = [COPY.name, COPY.size, COPY.can_free, ""]
797
- body = renderReclaimable()
834
+ body = renderReclaimable(items)
798
835
  } else if (state.view === "activity") {
799
836
  tableClass = "activity"
800
837
  headers = [COPY.name, COPY.size, COPY.last_scanned, ""]
801
- body = renderActivity()
838
+ body = renderActivity(items)
802
839
  } else if (state.displayMode === "files" && supportsDisplayMode()) {
803
840
  tableClass = "flat"
804
841
  headers = [COPY.name, COPY.location_column, COPY.size, COPY.status]
@@ -815,6 +852,71 @@ const renderTable = (items) => {
815
852
  el("vault-table-wrap").innerHTML = `<div class="vault-table ${tableClass}"><div class="vault-columns">${headerMarkup}</div>${body}</div>`
816
853
  }
817
854
 
855
+ const orderedItems = (items) => {
856
+ if (state.view === "activity") return items
857
+ if (state.view === "reclaimable") {
858
+ return [...items].sort((a, b) => compareRows(a, b,
859
+ (blob) => blob.names[0] ? blob.names[0].path : blob.hash))
860
+ }
861
+ if (state.displayMode === "files" && supportsDisplayMode()) {
862
+ return [...items].sort((a, b) => compareRows(a, b, flatLocation))
863
+ }
864
+ return [...items].sort((a, b) => {
865
+ const sourceOrder = state.sourceId ? 0 : sourceSort([a.source_id], [b.source_id])
866
+ return sourceOrder || compareRows(a, b, (item) => item.relative_path || item.path)
867
+ })
868
+ }
869
+
870
+ const pagedItems = (items) => {
871
+ const context = JSON.stringify([
872
+ state.view, state.sourceId, state.query, state.statusFilter,
873
+ state.displayMode, state.sizeSort
874
+ ])
875
+ if (context !== state.pageContext) {
876
+ state.page = 0
877
+ state.pageContext = context
878
+ }
879
+ const ordered = orderedItems(items)
880
+ const pages = Math.max(1, Math.ceil(ordered.length / PAGE_SIZE))
881
+ state.page = Math.max(0, Math.min(state.page, pages - 1))
882
+ const start = state.page * PAGE_SIZE
883
+ const end = Math.min(start + PAGE_SIZE, ordered.length)
884
+ return { items: ordered.slice(start, end), start, end, total: ordered.length, pages }
885
+ }
886
+
887
+ const paneFooterText = (items) => {
888
+ if (state.displayMode === "files" && supportsDisplayMode()) {
889
+ const count = state.view === "shared"
890
+ ? countLabel(items.length, "deduplicated file", "deduplicated files")
891
+ : state.view === "independent"
892
+ ? countLabel(items.length, "file kept separate", "files kept separate")
893
+ : countLabel(items.length)
894
+ const order = state.sizeSort === "desc"
895
+ ? COPY.sorted_largest
896
+ : state.sizeSort === "asc" ? COPY.sorted_smallest : ""
897
+ return `${count}${order ? ` · ${order}` : ""}`
898
+ }
899
+ if (state.view === "duplicates") return COPY.duplicate_note
900
+ if (state.view === "reclaimable") return COPY.reclaimable_note
901
+ if (state.view === "activity") return COPY.activity_note
902
+ return COPY.tracked_note.replace("{size}", fmt(candidateSize()))
903
+ }
904
+
905
+ const renderPaneFooter = (items, page) => {
906
+ const footer = el("vault-pane-footer")
907
+ const message = paneFooterText(items)
908
+ if (page.pages === 1) {
909
+ footer.textContent = message
910
+ return
911
+ }
912
+ footer.innerHTML = `<span>${esc(message)}</span>
913
+ <span class="vault-pagination" role="navigation" aria-label="${attr(COPY.file_pages)}">
914
+ <button class="vault-text-button" type="button" data-page="previous" ${state.page === 0 ? "disabled" : ""}>${esc(COPY.previous)}</button>
915
+ <span class="vault-page-range">${page.start + 1}–${page.end} of ${page.total}</span>
916
+ <button class="vault-text-button" type="button" data-page="next" ${state.page === page.pages - 1 ? "disabled" : ""}>${esc(COPY.next)}</button>
917
+ </span>`
918
+ }
919
+
818
920
  const renderOverview = () => {
819
921
  const data = state.data
820
922
  const last = data.last_scan
@@ -1115,12 +1217,34 @@ const renderCloudWarning = () => {
1115
1217
  warning.innerHTML = `<i class="fa-solid fa-triangle-exclamation"></i><span>${esc(message)}</span><button class="vault-text-button" id="btn-dismiss-cloud" type="button" data-warning-key="${attr(key)}">${esc(COPY.dismiss)}</button>`
1116
1218
  }
1117
1219
 
1220
+ const renderCleanupNotice = () => {
1221
+ const notice = el("vault-cleanup-notice")
1222
+ const blobs = state.data && Array.isArray(state.data.blobs) ? state.data.blobs : []
1223
+ const unused = !IS_APP_MODE && state.data && state.data.enabled
1224
+ ? blobs.filter((blob) => blob.orphan)
1225
+ : []
1226
+ if (!unused.length || state.view === "reclaimable") {
1227
+ notice.className = "vault-cleanup-notice"
1228
+ notice.innerHTML = ""
1229
+ return
1230
+ }
1231
+ const bytes = unused.reduce((sum, blob) => sum + (Number(blob.size) || 0), 0)
1232
+ const title = COPY.cleanup_ready.replace("{size}", fmt(bytes))
1233
+ const detail = COPY.cleanup_ready_detail.replace(
1234
+ "{count}",
1235
+ countLabel(unused.length, COPY.private_link, COPY.private_links)
1236
+ )
1237
+ notice.className = "vault-cleanup-notice show"
1238
+ notice.innerHTML = `<i class="fa-solid fa-broom" aria-hidden="true"></i><strong>${esc(title)}</strong><span class="vault-cleanup-notice-detail">${esc(detail)}</span><button class="vault-button" id="btn-review-cleanup" type="button">${esc(COPY.review_cleanup)}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
1239
+ }
1240
+
1118
1241
  const render = () => {
1119
1242
  if (!state.data) return
1120
1243
  renderOverview()
1121
1244
  renderResult()
1122
1245
  renderFeedback()
1123
1246
  renderCloudWarning()
1247
+ renderCleanupNotice()
1124
1248
  if (!state.data.enabled) {
1125
1249
  el("vault-explorer").style.display = "none"
1126
1250
  return
@@ -1131,23 +1255,10 @@ const render = () => {
1131
1255
  renderLocations(items)
1132
1256
  const visible = activeItems(items)
1133
1257
  renderToolbar(visible, items)
1134
- renderTable(visible)
1258
+ const page = pagedItems(visible)
1259
+ renderTable(page.items)
1135
1260
  renderActionProgress()
1136
- if (state.displayMode === "files" && supportsDisplayMode()) {
1137
- const count = state.view === "shared"
1138
- ? countLabel(visible.length, "deduplicated file", "deduplicated files")
1139
- : state.view === "independent"
1140
- ? countLabel(visible.length, "file kept separate", "files kept separate")
1141
- : countLabel(visible.length)
1142
- const order = state.sizeSort === "desc" ? COPY.sorted_largest : state.sizeSort === "asc" ? COPY.sorted_smallest : ""
1143
- el("vault-pane-footer").textContent = `${count}${order ? ` · ${order}` : ""}`
1144
- } else {
1145
- el("vault-pane-footer").textContent = state.view === "duplicates"
1146
- ? COPY.duplicate_note
1147
- : state.view === "reclaimable"
1148
- ? COPY.reclaimable_note
1149
- : COPY.tracked_note.replace("{size}", fmt(candidateSize()))
1150
- }
1261
+ renderPaneFooter(visible, page)
1151
1262
  }
1152
1263
 
1153
1264
  const scanActive = (scan) => !!(scan && (scan.pending || scan.active || scan.queued > 0))
@@ -1203,13 +1314,16 @@ const applyFullData = (data) => {
1203
1314
  render()
1204
1315
  return scanning || !!fileAction
1205
1316
  }
1317
+ let refreshSequence = 0
1206
1318
  const refresh = async (forceFull = false) => {
1319
+ const sequence = ++refreshSequence
1207
1320
  let delay = null
1208
1321
  try {
1209
1322
  const progressOnly = !forceFull &&
1210
1323
  !!(state.data && (scanActive(state.data.scan) || state.actionProgress))
1211
1324
  if (progressOnly) {
1212
1325
  const progress = await fetchJson(statusUrl(true))
1326
+ if (sequence !== refreshSequence) return
1213
1327
  state.data.scan = progress.scan
1214
1328
  state.data.last_scan = progress.last_scan
1215
1329
  const fileAction = serverFileAction(progress.file_action)
@@ -1221,16 +1335,22 @@ const refresh = async (forceFull = false) => {
1221
1335
  renderActionProgress()
1222
1336
  renderFeedback()
1223
1337
  } else {
1224
- if (applyFullData(await fetchJson(statusUrl())) || state.scanRequested) delay = 1500
1338
+ const data = await fetchJson(statusUrl())
1339
+ if (sequence !== refreshSequence) return
1340
+ if (applyFullData(data) || state.scanRequested) delay = 1500
1225
1341
  }
1226
1342
  } else {
1227
- if (applyFullData(await fetchJson(statusUrl())) || state.scanRequested) delay = 1500
1343
+ const data = await fetchJson(statusUrl())
1344
+ if (sequence !== refreshSequence) return
1345
+ if (applyFullData(data) || state.scanRequested) delay = 1500
1228
1346
  }
1229
1347
  } catch (error) {
1348
+ if (sequence !== refreshSequence) return
1230
1349
  state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1231
1350
  renderFeedback()
1232
1351
  delay = 5000
1233
1352
  } finally {
1353
+ if (sequence !== refreshSequence) return
1234
1354
  clearTimeout(window.__vaultRefresh)
1235
1355
  window.__vaultRefresh = delay == null ? null : setTimeout(refresh, delay)
1236
1356
  }
@@ -1405,6 +1525,10 @@ document.addEventListener("click", async (event) => {
1405
1525
  if (target.id === "btn-dismiss-cloud") {
1406
1526
  try { localStorage.setItem(target.dataset.warningKey, "1") } catch (error) {}
1407
1527
  renderCloudWarning()
1528
+ } else if (target.dataset.page) {
1529
+ state.page += target.dataset.page === "next" ? 1 : -1
1530
+ render()
1531
+ el("vault-table-wrap").scrollTop = 0
1408
1532
  } else if (target.hasAttribute("data-sort-size")) {
1409
1533
  state.sizeSort = state.sizeSort === "desc" ? "asc" : "desc"
1410
1534
  render()
@@ -1412,8 +1536,8 @@ document.addEventListener("click", async (event) => {
1412
1536
  state.displayMode = target.dataset.displayMode === "files" ? "files" : "folders"
1413
1537
  state.sizeSort = state.displayMode === "files" ? "desc" : null
1414
1538
  render()
1415
- } else if (target.dataset.view) {
1416
- state.view = target.dataset.view
1539
+ } else if (target.dataset.view || target.id === "btn-review-cleanup") {
1540
+ state.view = target.dataset.view || "reclaimable"
1417
1541
  state.sourceId = SCOPE_ID
1418
1542
  state.query = ""
1419
1543
  state.statusFilter = "all"
@@ -1597,7 +1721,9 @@ document.addEventListener("input", (event) => {
1597
1721
  state.query = event.target.value
1598
1722
  const items = activeItems(buildItems())
1599
1723
  updateToolbarSummary(items)
1600
- renderTable(items)
1724
+ const page = pagedItems(items)
1725
+ renderTable(page.items)
1726
+ renderPaneFooter(items, page)
1601
1727
  renderActionProgress()
1602
1728
  })
1603
1729
  document.addEventListener("change", (event) => {
@@ -1617,7 +1743,7 @@ candidateSizeSelect.setAttribute("aria-label", COPY.minimum_file_size)
1617
1743
  candidateSizeSelect.innerHTML = candidateSizeOptions
1618
1744
  .map((size) => `<option value="${size}">${fmt(size)}+</option>`)
1619
1745
  .join("")
1620
- candidateSizeSelect.value = String(candidateSizeOptions[2])
1746
+ candidateSizeSelect.value = String(candidateSizeOptions[3])
1621
1747
  try {
1622
1748
  const storedCandidateSize = Number(localStorage.getItem(candidateSizeKey))
1623
1749
  if (candidateSizeOptions.includes(storedCandidateSize)) {
@@ -25,6 +25,7 @@
25
25
  <div class='vault-result' id='vault-result' role='status' aria-live='polite'></div>
26
26
  <div class='vault-feedback' id='vault-feedback' role='status' aria-live='polite'></div>
27
27
  <div class='vault-cloud-warning' id='vault-cloud-warning' role='status'></div>
28
+ <div class='vault-cleanup-notice' id='vault-cleanup-notice' role='status'></div>
28
29
  <section class='vault-explorer' id='vault-explorer'>
29
30
  <aside class='vault-rail'>
30
31
  <div class='vault-rail-section'>
@@ -97,6 +97,27 @@ describe('vault engine (phase 1)', () => {
97
97
  assert.strictEqual(fs.existsSync(path.resolve(h, 'vault', 'registry.json')), true)
98
98
  })
99
99
 
100
+ test('startup verification reports unused private links without deleting them', async () => {
101
+ const h = await home()
102
+ const vault = await makeVault(h)
103
+ const content = crypto.randomBytes(4096)
104
+ const hash = sha256(content)
105
+ const file = await writeFile(path.resolve(h, 'api', 'appA', 'unused.bin'), content)
106
+ await vault.adopt(file, hash, { app: 'appA' })
107
+ await vault.registry.flush()
108
+ await fs.promises.unlink(file)
109
+
110
+ const fresh = new Vault(fakeKernel(h))
111
+ await fresh.init({ existingOnly: true })
112
+ fresh.verificationPending = true
113
+ await fresh.ensureInitialized()
114
+
115
+ assert.strictEqual(fs.existsSync(fresh.storePathFor(hash)), true)
116
+ assert.strictEqual(fresh.registry.links.has(file), false)
117
+ assert.strictEqual(fresh.registry.blobs.get(hash).orphan, true)
118
+ assert.strictEqual((await fresh.status()).reclaimable, content.length)
119
+ })
120
+
100
121
  test('adopt: metadata-only, store name shares the inode', async () => {
101
122
  const h = await home()
102
123
  const vault = await makeVault(h)