pinokiod 8.0.48 → 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.
@@ -1712,6 +1712,7 @@ class Vault {
1712
1712
  )
1713
1713
  let bytesOnDisk = 0
1714
1714
  let wouldBe = 0
1715
+ let trackedLogicalBytes = 0
1715
1716
  for (let index = 0; index < blobEntries.length; index++) {
1716
1717
  const [hash, blob] = blobEntries[index]
1717
1718
  const names = namesByHash.get(hash) || []
@@ -1723,6 +1724,7 @@ class Vault {
1723
1724
  const linkedCopy = storeStat || names.some((name) => name.mode === "link") ? 1 : 0
1724
1725
  const physicalCopies = linkedCopy + names.filter((name) => name.mode === "copy").length
1725
1726
  const pendingBytes = pendingBytesByHash.get(hash) || 0
1727
+ trackedLogicalBytes += (size * names.length) + pendingBytes
1726
1728
  bytesOnDisk += (size * physicalCopies) + pendingBytes
1727
1729
  wouldBe += (size * Math.max(physicalCopies, names.length)) + pendingBytes
1728
1730
  const orphan = !!(storeStat && storeStat.nlink === 1)
@@ -1733,6 +1735,22 @@ class Vault {
1733
1735
  blobs.push(publicBlob)
1734
1736
  blobByHash.set(hash, publicBlob)
1735
1737
  }
1738
+ if (!scopeId) {
1739
+ // The scan total covers every regular file, including files below the
1740
+ // candidate threshold and files the user kept separate. The registry
1741
+ // figures above cover only content tracked by Save space. Add the
1742
+ // remainder to both sides so the global comparison represents the
1743
+ // complete scanned locations instead of silently omitting small files.
1744
+ const lastScan = registry.scanFor()
1745
+ const scannedBytes = lastScan && Number.isFinite(lastScan.bytes_total)
1746
+ ? lastScan.bytes_total
1747
+ : null
1748
+ const untrackedScannedBytes = scannedBytes === null
1749
+ ? 0
1750
+ : Math.max(0, scannedBytes - trackedLogicalBytes)
1751
+ bytesOnDisk += untrackedScannedBytes
1752
+ wouldBe += untrackedScannedBytes
1753
+ }
1736
1754
  const duplicates = []
1737
1755
  for (const [p, entry] of registry.duplicates) {
1738
1756
  if (scopeId && entry.source_id !== scopeId) continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.48",
3
+ "version": "8.0.49",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -300,7 +300,8 @@ body[data-vault-mode="app"] .vault-overview {
300
300
  .vault-action-state,
301
301
  .vault-result,
302
302
  .vault-feedback,
303
- .vault-cloud-warning {
303
+ .vault-cloud-warning,
304
+ .vault-cleanup-notice {
304
305
  display: none;
305
306
  align-items: center;
306
307
  min-height: 38px;
@@ -319,7 +320,8 @@ body[data-vault-mode="app"] .vault-overview {
319
320
  .vault-action-state.show,
320
321
  .vault-result.show,
321
322
  .vault-feedback.show,
322
- .vault-cloud-warning.show { display: flex; }
323
+ .vault-cloud-warning.show,
324
+ .vault-cleanup-notice.show { display: flex; }
323
325
  .vault-scan-state i,
324
326
  .vault-action-state i,
325
327
  .vault-result > i { color: var(--task-accent); }
@@ -469,6 +471,18 @@ body[data-vault-mode="app"] .vault-overview {
469
471
  .vault-cloud-warning { min-height: 34px; background: var(--vault-warning-soft); }
470
472
  .vault-cloud-warning > i { color: var(--vault-warning); }
471
473
  .vault-cloud-warning .vault-text-button { margin-left: auto; }
474
+ .vault-cleanup-notice {
475
+ flex: 0 0 auto;
476
+ background: color-mix(in srgb, var(--task-accent) 5%, var(--task-panel));
477
+ }
478
+ .vault-cleanup-notice > i { color: var(--task-accent); }
479
+ .vault-cleanup-notice strong { flex: 0 0 auto; font-weight: 650; }
480
+ .vault-cleanup-notice-detail {
481
+ min-width: 0;
482
+ flex: 1 1 auto;
483
+ color: var(--task-muted);
484
+ }
485
+ .vault-cleanup-notice .vault-button { flex: 0 0 auto; margin-left: auto; }
472
486
  .vault-explorer {
473
487
  display: grid;
474
488
  min-height: 0;
@@ -952,6 +966,15 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
952
966
  .vault-result-actions { grid-column: 2; }
953
967
  .vault-pane-footer { align-items: flex-start; flex-direction: column; }
954
968
  .vault-result.incomplete .vault-button { margin: 0; }
969
+ .vault-cleanup-notice.show {
970
+ display: grid;
971
+ grid-template-columns: 16px minmax(0, 1fr) auto;
972
+ align-items: center;
973
+ }
974
+ .vault-cleanup-notice > i { grid-area: 1 / 1; }
975
+ .vault-cleanup-notice strong { grid-area: 1 / 2; }
976
+ .vault-cleanup-notice-detail { grid-area: 2 / 2 / 3 / 4; }
977
+ .vault-cleanup-notice .vault-button { grid-area: 1 / 3; margin-left: 0; }
955
978
  }
956
979
  @media (pointer: coarse) {
957
980
  .vault-page {
@@ -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}",
@@ -152,8 +154,13 @@ const COPY = {
152
154
  keep_separate: "Keep separate",
153
155
  kept_separate: "Kept separate",
154
156
  make_separate: "Make separate",
155
- reclaim: "Delete",
156
- 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",
157
164
  undo: "Undo",
158
165
  identical_contents_at: "Identical contents at",
159
166
  no_files: "No files found",
@@ -164,26 +171,29 @@ const COPY = {
164
171
  no_duplicates_hint: "There are no files waiting for your review.",
165
172
  no_shared: "Nothing deduplicated yet",
166
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.",
167
176
  no_skipped: "Nothing kept separate",
168
177
  no_skipped_hint: "Files you keep separate will appear here.",
169
- no_reclaimable: "No unused files",
170
- 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.",
171
180
  no_activity: "No activity yet",
172
181
  no_activity_hint: "Scans and actions will be recorded here.",
173
182
  view_all: "View all files",
174
183
  show_all_locations: "Show all locations",
175
184
  tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
176
185
  duplicate_note: "Only files waiting for review are shown.",
177
- 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.",
178
188
  converted: "Deduplicated",
179
189
  files_left_separate: "{count} remained separate",
180
190
  skipped_action: "Kept separate",
181
191
  separated: "Separated",
182
- reclaimed: "Deleted",
192
+ reclaimed: "Cleaned up",
183
193
  event_convert: "Deduplicated",
184
194
  event_found: "Duplicate found",
185
195
  event_adopt: "Added",
186
- event_reclaim: "Deleted unused file",
196
+ event_reclaim: "Cleaned up unused link",
187
197
  event_undo: "Undid deduplication",
188
198
  event_diverged: "Changed by an app — no longer deduplicated",
189
199
  event_detach: "Separated",
@@ -280,6 +290,16 @@ const sourcePath = (source) => {
280
290
  if (source.kind === "app") return IS_APP_MODE ? (source.display_path || source.root || "") : ""
281
291
  return source.display_path || source.root || ""
282
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
+ }
283
303
  const isDescendantSource = (candidateId, parentId) => {
284
304
  if (!parentId) return true
285
305
  if (candidateId === parentId) return true
@@ -337,9 +357,10 @@ const getCounts = (items) => ({
337
357
  all: items.length,
338
358
  duplicates: items.filter((item) => item.status === "duplicate").length,
339
359
  shared: items.filter((item) => item.status === "shared").length,
360
+ tracked: items.filter((item) => item.status === "tracked").length,
340
361
  independent: items.filter((item) => item.status === "independent").length,
341
362
  reclaimable: state.data.blobs.filter((blob) => blob.orphan).length,
342
- activity: state.data.events.length
363
+ activity: activityItems("").length
343
364
  })
344
365
 
345
366
  const sourceCounts = (items) => {
@@ -362,6 +383,7 @@ const activeItems = (items) => {
362
383
  let result = items
363
384
  if (state.view === "duplicates") result = result.filter((item) => item.status === "duplicate")
364
385
  if (state.view === "shared") result = result.filter((item) => item.status === "shared")
386
+ if (state.view === "tracked") result = result.filter((item) => item.status === "tracked")
365
387
  if (state.view === "independent") result = result.filter((item) => item.status === "independent")
366
388
  if (state.sourceId) result = result.filter((item) => isDescendantSource(item.source_id, state.sourceId))
367
389
  if (state.view === "all" && state.statusFilter !== "all") result = result.filter((item) => item.status === state.statusFilter)
@@ -374,6 +396,7 @@ const viewIcon = {
374
396
  all: "fa-regular fa-file-lines",
375
397
  duplicates: "fa-regular fa-copy",
376
398
  shared: "fa-solid fa-link",
399
+ tracked: "fa-regular fa-circle-check",
377
400
  independent: "fa-solid fa-circle-minus",
378
401
  reclaimable: "fa-regular fa-trash-can",
379
402
  activity: "fa-solid fa-wave-square"
@@ -382,6 +405,7 @@ const viewLabel = {
382
405
  all: COPY.all,
383
406
  duplicates: COPY.duplicates,
384
407
  shared: COPY.shared,
408
+ tracked: COPY.tracked,
385
409
  independent: COPY.skipped,
386
410
  reclaimable: COPY.reclaimable,
387
411
  activity: COPY.activity
@@ -397,18 +421,30 @@ const eventLabels = {
397
421
  skip: COPY.event_skip,
398
422
  reshare: COPY.event_reshare
399
423
  }
400
- const activityItems = () => {
401
- const query = state.query.toLowerCase()
402
- 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 ||
403
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)
404
440
  }
405
441
 
406
442
  const renderViews = (items) => {
407
443
  const counts = getCounts(items)
408
444
  el("views-label").textContent = COPY.views
409
445
  const views = IS_APP_MODE
410
- ? ["all", "duplicates", "shared", "independent", "activity"]
411
- : ["all", "duplicates", "shared", "independent", "reclaimable", "activity"]
446
+ ? ["all", "duplicates", "shared", "tracked", "independent", "activity"]
447
+ : ["all", "duplicates", "shared", "tracked", "independent", "reclaimable", "activity"]
412
448
  el("vault-views").innerHTML = views.map((view) => `
413
449
  <button class="vault-nav-row ${state.view === view ? "selected" : ""}" type="button" data-view="${view}" ${state.view === view ? 'aria-current="page"' : ""}>
414
450
  <i class="${viewIcon[view]}"></i>
@@ -516,12 +552,14 @@ const updateToolbarSummary = (visibleItems) => {
516
552
  const searchPlaceholder = () => {
517
553
  if (state.view === "duplicates") return COPY.search_duplicates
518
554
  if (state.view === "shared") return COPY.search_shared
555
+ if (state.view === "tracked") return COPY.search_tracked
519
556
  if (state.view === "independent") return COPY.search_skipped
520
557
  if (state.view === "activity") return COPY.search_activity
521
558
  const source = selectedSource()
522
559
  return source && source.kind === "app" ? COPY.search_in.replace("{location}", source.label) : COPY.search_all
523
560
  }
524
- 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"
525
563
  const displayModeControl = () => supportsDisplayMode() ? `<div class="vault-display-mode" role="group" aria-label="${attr(COPY.display_mode)}">
526
564
  <button type="button" data-display-mode="folders" aria-pressed="${state.displayMode === "folders"}" class="${state.displayMode === "folders" ? "selected" : ""}">${esc(COPY.folders)}</button>
527
565
  <button type="button" data-display-mode="files" aria-pressed="${state.displayMode === "files"}" class="${state.displayMode === "files" ? "selected" : ""}">${esc(COPY.files_mode)}</button>
@@ -589,7 +627,7 @@ const sharingControl = (item) => {
589
627
  const fileDetail = (item) => {
590
628
  if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
591
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) => `
592
- <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>`
593
631
  }
594
632
 
595
633
  const renderFileRow = (item, depth = 0, showMatch = false) => {
@@ -686,7 +724,8 @@ const sourceSort = (a, b) => {
686
724
  const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
687
725
  return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
688
726
  }
689
- 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(" / ")
690
729
  const renderFlatFiles = (items) => [...items]
691
730
  .sort((a, b) => compareRows(a, b, flatLocation))
692
731
  .map((item) => {
@@ -750,6 +789,7 @@ const emptyState = (view) => {
750
789
  : [COPY.no_files, COPY.no_files_hint, "fa-regular fa-folder-open"],
751
790
  duplicates: [COPY.no_duplicates, COPY.no_duplicates_hint, "fa-regular fa-circle-check"],
752
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"],
753
793
  independent: [COPY.no_skipped, COPY.no_skipped_hint, "fa-solid fa-circle-minus"],
754
794
  reclaimable: [COPY.no_reclaimable, COPY.no_reclaimable_hint, "fa-regular fa-circle-check"],
755
795
  activity: [COPY.no_activity, COPY.no_activity_hint, "fa-solid fa-wave-square"]
@@ -763,30 +803,21 @@ const renderReclaimable = (blobs) => {
763
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("")
764
804
  }
765
805
 
766
- const renderActivity = () => {
767
- const events = activityItems()
768
- const query = state.query.trim().toLowerCase()
769
- const undoBatches = Array.isArray(state.data.undo_batches)
770
- ? state.data.undo_batches.filter((batch) => !query ||
771
- `${COPY.event_convert} ${batch.files || 0} ${COPY.files}`.toLowerCase().includes(query))
772
- : []
773
- if (!events.length && !undoBatches.length) return emptyState("activity")
774
- const seenBatches = new Set()
775
- const eventRows = events.map((event) => {
776
- let undo = ""
777
- if (event.kind === "convert" && event.batch_id && event.undoable !== false && !seenBatches.has(event.batch_id)) {
778
- seenBatches.add(event.batch_id)
779
- 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>`
780
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
+ : ""
781
816
  const eventPath = event.path
782
817
  ? `${event.source_label ? `${event.source_label} / ` : ""}${event.relative_path || event.path}`
783
818
  : (event.hash || "").slice(0, 12)
784
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>`
785
820
  }).join("")
786
- const batchRows = undoBatches.filter((batch) => !seenBatches.has(batch.batch_id)).map((batch) =>
787
- `<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>`
788
- ).join("")
789
- return batchRows + eventRows
790
821
  }
791
822
 
792
823
  const renderTable = (items) => {
@@ -804,7 +835,7 @@ const renderTable = (items) => {
804
835
  } else if (state.view === "activity") {
805
836
  tableClass = "activity"
806
837
  headers = [COPY.name, COPY.size, COPY.last_scanned, ""]
807
- body = renderActivity()
838
+ body = renderActivity(items)
808
839
  } else if (state.displayMode === "files" && supportsDisplayMode()) {
809
840
  tableClass = "flat"
810
841
  headers = [COPY.name, COPY.location_column, COPY.size, COPY.status]
@@ -837,9 +868,6 @@ const orderedItems = (items) => {
837
868
  }
838
869
 
839
870
  const pagedItems = (items) => {
840
- if (state.view === "activity") {
841
- return { items, start: 0, end: items.length, total: items.length, pages: 1 }
842
- }
843
871
  const context = JSON.stringify([
844
872
  state.view, state.sourceId, state.query, state.statusFilter,
845
873
  state.displayMode, state.sizeSort
@@ -870,6 +898,7 @@ const paneFooterText = (items) => {
870
898
  }
871
899
  if (state.view === "duplicates") return COPY.duplicate_note
872
900
  if (state.view === "reclaimable") return COPY.reclaimable_note
901
+ if (state.view === "activity") return COPY.activity_note
873
902
  return COPY.tracked_note.replace("{size}", fmt(candidateSize()))
874
903
  }
875
904
 
@@ -1188,12 +1217,34 @@ const renderCloudWarning = () => {
1188
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>`
1189
1218
  }
1190
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
+
1191
1241
  const render = () => {
1192
1242
  if (!state.data) return
1193
1243
  renderOverview()
1194
1244
  renderResult()
1195
1245
  renderFeedback()
1196
1246
  renderCloudWarning()
1247
+ renderCleanupNotice()
1197
1248
  if (!state.data.enabled) {
1198
1249
  el("vault-explorer").style.display = "none"
1199
1250
  return
@@ -1263,13 +1314,16 @@ const applyFullData = (data) => {
1263
1314
  render()
1264
1315
  return scanning || !!fileAction
1265
1316
  }
1317
+ let refreshSequence = 0
1266
1318
  const refresh = async (forceFull = false) => {
1319
+ const sequence = ++refreshSequence
1267
1320
  let delay = null
1268
1321
  try {
1269
1322
  const progressOnly = !forceFull &&
1270
1323
  !!(state.data && (scanActive(state.data.scan) || state.actionProgress))
1271
1324
  if (progressOnly) {
1272
1325
  const progress = await fetchJson(statusUrl(true))
1326
+ if (sequence !== refreshSequence) return
1273
1327
  state.data.scan = progress.scan
1274
1328
  state.data.last_scan = progress.last_scan
1275
1329
  const fileAction = serverFileAction(progress.file_action)
@@ -1281,16 +1335,22 @@ const refresh = async (forceFull = false) => {
1281
1335
  renderActionProgress()
1282
1336
  renderFeedback()
1283
1337
  } else {
1284
- 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
1285
1341
  }
1286
1342
  } else {
1287
- 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
1288
1346
  }
1289
1347
  } catch (error) {
1348
+ if (sequence !== refreshSequence) return
1290
1349
  state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
1291
1350
  renderFeedback()
1292
1351
  delay = 5000
1293
1352
  } finally {
1353
+ if (sequence !== refreshSequence) return
1294
1354
  clearTimeout(window.__vaultRefresh)
1295
1355
  window.__vaultRefresh = delay == null ? null : setTimeout(refresh, delay)
1296
1356
  }
@@ -1476,8 +1536,8 @@ document.addEventListener("click", async (event) => {
1476
1536
  state.displayMode = target.dataset.displayMode === "files" ? "files" : "folders"
1477
1537
  state.sizeSort = state.displayMode === "files" ? "desc" : null
1478
1538
  render()
1479
- } else if (target.dataset.view) {
1480
- state.view = target.dataset.view
1539
+ } else if (target.dataset.view || target.id === "btn-review-cleanup") {
1540
+ state.view = target.dataset.view || "reclaimable"
1481
1541
  state.sourceId = SCOPE_ID
1482
1542
  state.query = ""
1483
1543
  state.statusFilter = "all"
@@ -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)
@@ -674,7 +674,8 @@ describe('vault dashboard backend (phase 4)', () => {
674
674
  await vault.sweeper.scan()
675
675
  const status = await vault.status()
676
676
  assert.strictEqual(status.enabled, true)
677
- assert.strictEqual(status.bytes_on_disk, content.length)
677
+ assert.strictEqual(status.bytes_on_disk, content.length * 2)
678
+ assert.strictEqual(status.bytes_without_sharing, content.length * 2)
678
679
  assert.ok(status.last_scan && status.last_scan.files > 0)
679
680
  assert.strictEqual(status.excluded.length, 1)
680
681
  assert.strictEqual(status.excluded[0].size, content.length)
@@ -705,6 +706,79 @@ describe('vault dashboard backend (phase 4)', () => {
705
706
  assert.strictEqual(status.saved_by_sharing, 0)
706
707
  })
707
708
 
709
+ test('global storage metrics include scanned files below the candidate threshold', async () => {
710
+ const { home, vault } = await makeEnv()
711
+ const { content } = await makeSharedPair(home, vault)
712
+ const small = crypto.randomBytes(512)
713
+ await writeFile(path.resolve(home, 'api', 'appA', 'config.json'), small)
714
+ await vault.sweeper.scan()
715
+
716
+ const status = await vault.status()
717
+ assert.strictEqual(status.last_scan.bytes_total, (content.length * 2) + small.length)
718
+ assert.strictEqual(status.bytes_on_disk, content.length + small.length)
719
+ assert.strictEqual(status.bytes_without_sharing, (content.length * 2) + small.length)
720
+ assert.strictEqual(status.saved_by_sharing, content.length)
721
+ })
722
+
723
+ test('global storage metrics do not disappear when every scanned file is below the candidate threshold', async () => {
724
+ const { home, vault } = await makeEnv()
725
+ const first = crypto.randomBytes(511)
726
+ const second = crypto.randomBytes(257)
727
+ await writeFile(path.resolve(home, 'api', 'appA', 'config.json'), first)
728
+ await writeFile(path.resolve(home, 'api', 'appB', 'notes.txt'), second)
729
+ await vault.sweeper.scan()
730
+
731
+ const status = await vault.status()
732
+ const total = first.length + second.length
733
+ assert.strictEqual(status.last_scan.bytes_total, total)
734
+ assert.strictEqual(status.bytes_on_disk, total)
735
+ assert.strictEqual(status.bytes_without_sharing, total)
736
+ assert.strictEqual(status.saved_by_sharing, 0)
737
+ })
738
+
739
+ test('global storage metrics include below-threshold files from external locations', async (t) => {
740
+ const { home, vault } = await makeEnv()
741
+ const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-size-external-'))
742
+ homes.push(external)
743
+ const local = crypto.randomBytes(401)
744
+ const imported = crypto.randomBytes(607)
745
+ await writeFile(path.resolve(home, 'api', 'appA', 'local.json'), local)
746
+ await writeFile(path.resolve(external, 'external.json'), imported)
747
+ try {
748
+ await vault.addExternalSource(external)
749
+ } catch (error) {
750
+ t.skip(`directory links unavailable: ${error.message}`)
751
+ return
752
+ }
753
+ await vault.sweeper.scan()
754
+
755
+ const status = await vault.status()
756
+ const total = local.length + imported.length
757
+ assert.strictEqual(status.last_scan.home_bytes_total, local.length)
758
+ assert.strictEqual(status.last_scan.bytes_total, total)
759
+ assert.strictEqual(status.bytes_on_disk, total)
760
+ assert.strictEqual(status.bytes_without_sharing, total)
761
+ assert.strictEqual(status.saved_by_sharing, 0)
762
+ })
763
+
764
+ test('global storage metrics add unused managed files without double-counting scanned files', async () => {
765
+ const { home, vault } = await makeEnv()
766
+ const managed = crypto.randomBytes(4096)
767
+ const small = crypto.randomBytes(379)
768
+ const managedPath = await writeFile(path.resolve(home, 'api', 'appA', 'unused.bin'), managed)
769
+ await vault.adopt(managedPath, sha256(managed), { app: 'appA' })
770
+ await fs.promises.unlink(managedPath)
771
+ await writeFile(path.resolve(home, 'api', 'appA', 'settings.json'), small)
772
+ await vault.sweeper.scan()
773
+
774
+ const status = await vault.status()
775
+ assert.strictEqual(status.last_scan.bytes_total, small.length)
776
+ assert.strictEqual(status.reclaimable, managed.length)
777
+ assert.strictEqual(status.bytes_on_disk, managed.length + small.length)
778
+ assert.strictEqual(status.bytes_without_sharing, managed.length + small.length)
779
+ assert.strictEqual(status.saved_by_sharing, 0)
780
+ })
781
+
708
782
  test('app status exposes only that app while retaining its matching locations', async () => {
709
783
  const { home, vault } = await makeEnv()
710
784
  const content = crypto.randomBytes(4096)
@@ -1061,6 +1135,81 @@ describe('vault dashboard backend (phase 4)', () => {
1061
1135
  }
1062
1136
  })
1063
1137
 
1138
+ test('an older status response cannot replace newer scan progress', async () => {
1139
+ const views = path.resolve(__dirname, '..', 'server', 'views')
1140
+ const publicRoot = path.resolve(__dirname, '..', 'server', 'public')
1141
+ const workspace = await ejs.renderFile(
1142
+ path.resolve(views, 'partials', 'vault_workspace.ejs'),
1143
+ { appMode: false }
1144
+ )
1145
+ const baseStatus = {
1146
+ enabled: true,
1147
+ mode: 'link',
1148
+ last_scan: null,
1149
+ bytes_on_disk: 0,
1150
+ bytes_without_sharing: 0,
1151
+ saved_by_sharing: 0,
1152
+ lifetime_bytes_saved: 0,
1153
+ pending_bytes: 0,
1154
+ reclaimable: 0,
1155
+ file_action: null,
1156
+ activity_error: null,
1157
+ cloud_sync_warning: null,
1158
+ sources: [{
1159
+ id: 'pinokio', kind: 'pinokio', label: 'Pinokio', root: '/pinokio',
1160
+ display_path: '/pinokio', parent_id: null, available: true, shareable: true
1161
+ }],
1162
+ blobs: [], duplicates: [], excluded: [], events: [], undo_batches: []
1163
+ }
1164
+ const scan = (queued, currentFile) => ({
1165
+ active: true, pending: false, phase: 'analyzing', scope_id: null,
1166
+ dirs: 4, files: 8, total_files: 8, bytes_total: 8000,
1167
+ hash_total: 937, queued, current_file: currentFile,
1168
+ current_file_bytes: 0, current_file_size: 1000,
1169
+ started: 123
1170
+ })
1171
+ const response = (data) => ({ ok: true, json: async () => data })
1172
+ let requestCount = 0
1173
+ let resolveOlder
1174
+ const olderResponse = new Promise((resolve) => { resolveOlder = resolve })
1175
+ const dom = new JSDOM(
1176
+ `<body data-platform="darwin" data-vault-mode="global">${workspace}</body>`,
1177
+ { url: 'http://localhost/vault', runScripts: 'dangerously' }
1178
+ )
1179
+ dom.window.fetch = async () => {
1180
+ requestCount += 1
1181
+ if (requestCount === 1) {
1182
+ return response(Object.assign({}, baseStatus, {
1183
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null }
1184
+ }))
1185
+ }
1186
+ if (requestCount === 2) return olderResponse
1187
+ return response(Object.assign({}, baseStatus, { scan: scan(115, 'newer.bin') }))
1188
+ }
1189
+ try {
1190
+ const formatter = await fs.promises.readFile(path.resolve(publicRoot, 'storage-size.js'), 'utf8')
1191
+ const source = await fs.promises.readFile(path.resolve(publicRoot, 'vault.js'), 'utf8')
1192
+ dom.window.eval(formatter)
1193
+ dom.window.eval(source.replace(/\nrefresh\(\)\s*$/, '\nwindow.__testVaultRefresh = refresh\nrefresh()'))
1194
+ await new Promise((resolve) => setTimeout(resolve, 25))
1195
+
1196
+ const olderRefresh = dom.window.__testVaultRefresh()
1197
+ await new Promise((resolve) => setTimeout(resolve, 0))
1198
+ const newerRefresh = dom.window.__testVaultRefresh()
1199
+ await newerRefresh
1200
+ assert.strictEqual(dom.window.document.querySelector('.vault-scan-percent').textContent, '87.7%')
1201
+ assert.match(dom.window.document.querySelector('.vault-scan-detail').textContent, /822 of 937 large files checked/)
1202
+
1203
+ resolveOlder(response(Object.assign({}, baseStatus, { scan: scan(204, 'older.bin') })))
1204
+ await olderRefresh
1205
+ assert.strictEqual(dom.window.document.querySelector('.vault-scan-percent').textContent, '87.7%')
1206
+ assert.match(dom.window.document.querySelector('.vault-scan-detail').textContent, /822 of 937 large files checked/)
1207
+ assert.doesNotMatch(dom.window.document.querySelector('.vault-scan-detail').textContent, /older\.bin/)
1208
+ } finally {
1209
+ dom.window.close()
1210
+ }
1211
+ })
1212
+
1064
1213
  test('item 16: vocabulary lint — vault page copy avoids forbidden terms', async () => {
1065
1214
  const forbidden = /hard.?link|junction|symlink|inode|\bblob\b|\bstore\b|\bdedupe\b|\bvault\b/i
1066
1215
  const vaultPage = await vaultPageSource()
@@ -1086,24 +1235,33 @@ describe('vault dashboard backend (phase 4)', () => {
1086
1235
  test('copy-mode tracking never claims physical sharing savings', async () => {
1087
1236
  const { home, vault } = await makeEnv()
1088
1237
  const content = crypto.randomBytes(4096)
1238
+ const small = crypto.randomBytes(383)
1089
1239
  const hash = sha256(content)
1090
1240
  const first = await writeFile(path.resolve(home, 'api', 'appA', 'm.bin'), content)
1091
1241
  const second = await writeFile(path.resolve(home, 'api', 'appB', 'm.bin'), content)
1242
+ await writeFile(path.resolve(home, 'api', 'appA', 'config.json'), small)
1092
1243
  const firstStat = await fs.promises.stat(first)
1093
1244
  const secondStat = await fs.promises.stat(second)
1094
1245
  vault.registry.addBlob(hash, { size: content.length })
1095
1246
  vault.registry.addLink(first, { hash, app: 'appA', dev: firstStat.dev, ino: firstStat.ino, mode: 'copy' })
1096
1247
  vault.registry.addLink(second, { hash, app: 'appB', dev: secondStat.dev, ino: secondStat.ino, mode: 'copy' })
1248
+ vault.registry.setLastScan({
1249
+ ts: Date.now(),
1250
+ files: 3,
1251
+ bytes_total: (content.length * 2) + small.length,
1252
+ home_bytes_total: (content.length * 2) + small.length
1253
+ })
1097
1254
 
1098
1255
  const status = await vault.status()
1099
- assert.strictEqual(status.bytes_on_disk, content.length * 2)
1256
+ assert.strictEqual(status.bytes_on_disk, (content.length * 2) + small.length)
1257
+ assert.strictEqual(status.bytes_without_sharing, (content.length * 2) + small.length)
1100
1258
  assert.strictEqual(status.saved_by_sharing, 0)
1101
1259
  })
1102
1260
 
1103
1261
  test('repair is advanced, metrics distinguish detected and explicit savings, and refresh retries', async () => {
1104
1262
  const vaultPage = await vaultPageSource()
1105
1263
  assert.match(vaultPage, /disk_space_saved:\s*"of disk space saved"/)
1106
- assert.match(vaultPage, /before_help:\s*"Estimated space if every app stored its own copy\."/)
1264
+ assert.match(vaultPage, /before_help:\s*"Estimated size of every scanned location if each app stored its own copy\. File Explorer may count deduplicated files differently\."/)
1107
1265
  assert.match(vaultPage, /nothing_more_to_save:\s*"Nothing else to save"/)
1108
1266
  assert.match(vaultPage, /more_can_be_saved:\s*"\{size\} more can be saved"/)
1109
1267
  assert.match(vaultPage, /fmt\(data\.saved_by_sharing\)/)
@@ -1186,7 +1344,8 @@ describe('vault dashboard backend (phase 4)', () => {
1186
1344
  assert.match(vaultPage, /pinokio:vault:reviewed-scan/)
1187
1345
  assert.match(vaultPage, /completed \|\| incomplete \|\| \(!state\.scanResult && unreviewed\)/)
1188
1346
  assert.match(vaultPage, /state\.data\.undo_batches/)
1189
- assert.match(vaultPage, /data-undo="\$\{attr\(batch\.batch_id\)\}"/)
1347
+ assert.match(vaultPage, /data-undo="\$\{attr\(item\.batch_id\)\}"/)
1348
+ assert.match(vaultPage, /data-undo="\$\{attr\(event\.batch_id\)\}"/)
1190
1349
  assert.match(vaultPage, /last_scan && data\.last_scan\.hash_failures/)
1191
1350
  assert.match(vaultPage, /scan_not_analyzed:\s*"could not be analyzed"/)
1192
1351
  assert.match(vaultPage, /\.vault-progress-bar\.determinate \{[\s\S]*?transform:\s*scaleX/)
@@ -1387,11 +1546,14 @@ describe('vault dashboard backend (phase 4)', () => {
1387
1546
  const formatter = await fs.promises.readFile(
1388
1547
  path.resolve(__dirname, '..', 'server', 'public', 'storage-size.js'), 'utf8')
1389
1548
  dom.window.eval(formatter)
1549
+ const screenshotFolderBytes = 2_002_022_055_452
1390
1550
  assert.strictEqual(dom.window.PinokioFormatStorageSize(7.05e9), '7.05 GB')
1391
1551
  assert.strictEqual(dom.window.PinokioFormatStorageSize(5.3e9), '5.3 GB')
1392
1552
  assert.strictEqual(dom.window.PinokioFormatStorageSize(1024 ** 3), '1.07 GB')
1553
+ assert.strictEqual(dom.window.PinokioFormatStorageSize(screenshotFolderBytes), '2 TB')
1393
1554
  dom.window.document.body.dataset.platform = 'win32'
1394
1555
  assert.strictEqual(dom.window.PinokioFormatStorageSize(1024 ** 3), '1 GB')
1556
+ assert.strictEqual(dom.window.PinokioFormatStorageSize(screenshotFolderBytes), '1.82 TB')
1395
1557
 
1396
1558
  const appView = await fs.promises.readFile(
1397
1559
  path.resolve(__dirname, '..', 'server', 'views', 'app.ejs'), 'utf8')
@@ -1443,7 +1605,8 @@ describe('vault dashboard backend (phase 4)', () => {
1443
1605
  assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['Before', 'After'])
1444
1606
  assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['661.75 GB', '404.8 GB'])
1445
1607
  assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:61\.17%/)
1446
- assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent, 'Estimated space if every app stored its own copy.')
1608
+ assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent,
1609
+ 'Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.')
1447
1610
  assert.strictEqual(dom.window.document.querySelector('.vault-compare-info').getAttribute('tabindex'), '0')
1448
1611
  assert.match(dom.window.document.querySelector('.vault-summary-side').textContent, /13\.31 GB more can be saved/)
1449
1612
  assert.strictEqual(dom.window.document.getElementById('btn-review-metric').textContent, 'Review files')
@@ -1451,12 +1614,21 @@ describe('vault dashboard backend (phase 4)', () => {
1451
1614
  assert.strictEqual(dom.window.document.getElementById('btn-scan').classList.contains('primary'), false)
1452
1615
  assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Pinokio folder\s*166\.22 GB/)
1453
1616
  assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Saved by your actions\s*258\.77 GB/)
1617
+ const cleanupNotice = dom.window.document.getElementById('vault-cleanup-notice')
1618
+ assert.strictEqual(cleanupNotice.classList.contains('show'), true)
1619
+ assert.match(cleanupNotice.textContent, /ready to clean up/)
1620
+ assert.match(cleanupNotice.textContent, /1 private link left/)
1621
+ dom.window.document.getElementById('btn-review-cleanup').click()
1622
+ assert.strictEqual(dom.window.document.querySelector('[data-view="reclaimable"]').classList.contains('selected'), true)
1623
+ assert.strictEqual(cleanupNotice.classList.contains('show'), false)
1624
+ dom.window.document.querySelector('[data-view="all"]').click()
1454
1625
  const viewDescriptions = {
1455
1626
  all: 'Every scanned file and its current deduplication status.',
1456
1627
  duplicates: 'Identical files waiting to be deduplicated or kept separate.',
1457
1628
  shared: 'Files that share disk storage across multiple locations.',
1629
+ tracked: 'Files with no duplicate action required.',
1458
1630
  independent: 'Duplicate files that remain as separate copies.',
1459
- reclaimable: 'Stored copies no longer used by any configured location.',
1631
+ reclaimable: 'Private links no longer used by any linked file.',
1460
1632
  activity: 'A history of scans and changes made by Save space.'
1461
1633
  }
1462
1634
  for (const [view, description] of Object.entries(viewDescriptions)) {
@@ -1472,9 +1644,10 @@ describe('vault dashboard backend (phase 4)', () => {
1472
1644
  const unusedView = dom.window.document.querySelector('[data-view="reclaimable"]')
1473
1645
  assert.strictEqual(unusedView.querySelector('.vault-nav-name').textContent, 'Unused files')
1474
1646
  unusedView.click()
1475
- assert.strictEqual(dom.window.document.getElementById('btn-reclaim-all').textContent, 'Delete all')
1476
- assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, 'Delete')
1477
- assert.match(dom.window.document.getElementById('vault-pane-footer').textContent, /Deleting them frees disk space/)
1647
+ assert.strictEqual(dom.window.document.getElementById('btn-reclaim-all').textContent, 'Clean up all')
1648
+ assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, 'Clean up')
1649
+ assert.match(dom.window.document.getElementById('vault-pane-footer').textContent,
1650
+ /Cleaning them up frees disk space/)
1478
1651
  dom.window.close()
1479
1652
  })
1480
1653
 
@@ -1517,6 +1690,7 @@ describe('vault dashboard backend (phase 4)', () => {
1517
1690
  ['10 GB', '7 GB'])
1518
1691
  assert.match(dom.window.document.querySelector('.vault-summary-side').textContent,
1519
1692
  /1 GB more can be saved.*Not scanned yet/)
1693
+ assert.strictEqual(dom.window.document.getElementById('vault-cleanup-notice').classList.contains('show'), false)
1520
1694
  dom.window.close()
1521
1695
  })
1522
1696
 
@@ -1549,8 +1723,8 @@ describe('vault dashboard backend (phase 4)', () => {
1549
1723
  assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.can_free, ""\]/)
1550
1724
  assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.last_scanned, ""\]/)
1551
1725
  assert.match(source, /reclaimable:\s*"Unused files"/)
1552
- assert.match(source, /reclaim:\s*"Delete"/)
1553
- assert.match(source, /reclaim_all:\s*"Delete all"/)
1726
+ assert.match(source, /reclaim:\s*"Clean up"/)
1727
+ assert.match(source, /reclaim_all:\s*"Clean up all"/)
1554
1728
  assert.match(source, /data-sort-size/)
1555
1729
  assert.match(source, /data-display-mode="folders"/)
1556
1730
  assert.match(source, /data-display-mode="files"/)
@@ -1853,6 +2027,156 @@ describe('vault dashboard backend (phase 4)', () => {
1853
2027
  }
1854
2028
  })
1855
2029
 
2030
+ test('No action needed is a counted, filtered, paginated file view', async () => {
2031
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2032
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2033
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
2034
+ })
2035
+ const dom = new JSDOM(html, {
2036
+ url: 'http://localhost/vault/app/appB',
2037
+ runScripts: 'dangerously'
2038
+ })
2039
+ const blobs = Array.from({ length: 501 }, (_, index) => {
2040
+ const name = `tracked-${String(index).padStart(4, '0')}.bin`
2041
+ return {
2042
+ hash: index.toString(16).padStart(64, '0'),
2043
+ size: 1024 + index,
2044
+ orphan: false,
2045
+ nlink: 2,
2046
+ names: [{
2047
+ path: `/pinokio/api/appB/${name}`,
2048
+ relative_path: name,
2049
+ source_id: 'app:appB',
2050
+ source_label: 'appB',
2051
+ mode: 'link'
2052
+ }]
2053
+ }
2054
+ })
2055
+ const status = {
2056
+ enabled: true,
2057
+ mode: 'link',
2058
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2059
+ last_scan: { ts: Date.now(), bytes_total: 1024 * 502 },
2060
+ tracked_bytes: 1024 * 502,
2061
+ effective_bytes: 1024 * 502,
2062
+ shared_bytes: 0,
2063
+ pending_bytes: 1024,
2064
+ activity_error: null,
2065
+ cloud_sync_warning: null,
2066
+ sources: [{
2067
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
2068
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
2069
+ }],
2070
+ blobs,
2071
+ duplicates: [{
2072
+ path: '/pinokio/api/appB/duplicate.bin',
2073
+ relative_path: 'duplicate.bin',
2074
+ source_id: 'app:appB',
2075
+ source_label: 'appB',
2076
+ size: 1024,
2077
+ shareable: true,
2078
+ match: { path: '/pinokio/api/appA/duplicate.bin' }
2079
+ }],
2080
+ excluded: [],
2081
+ events: [],
2082
+ undo_batches: []
2083
+ }
2084
+ dom.window.fetch = async () => ({ ok: true, json: async () => status })
2085
+ await runVaultScript(dom)
2086
+ await new Promise((resolve) => setTimeout(resolve, 25))
2087
+
2088
+ const trackedView = dom.window.document.querySelector('[data-view="tracked"]')
2089
+ assert.strictEqual(trackedView.querySelector('.vault-nav-name').textContent, 'No action needed')
2090
+ assert.strictEqual(trackedView.querySelector('.vault-nav-count').textContent, '501')
2091
+ trackedView.click()
2092
+
2093
+ const rows = () => [...dom.window.document.querySelectorAll('.vault-table .vault-file-row')]
2094
+ assert.strictEqual(rows().length, 500)
2095
+ assert.strictEqual(dom.window.document.getElementById('vault-search').placeholder,
2096
+ 'Search files with no action needed')
2097
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 501')
2098
+ assert.doesNotMatch(dom.window.document.getElementById('vault-table-wrap').textContent, /duplicate\.bin/)
2099
+
2100
+ dom.window.document.querySelector('[data-page="next"]').click()
2101
+ assert.strictEqual(rows().length, 1)
2102
+ assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'tracked-0500.bin')
2103
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–501 of 501')
2104
+ dom.window.close()
2105
+ })
2106
+
2107
+ test('Activity is paginated without repeating a batch undo action', async () => {
2108
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2109
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2110
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
2111
+ })
2112
+ const dom = new JSDOM(html, {
2113
+ url: 'http://localhost/vault/app/appB',
2114
+ runScripts: 'dangerously'
2115
+ })
2116
+ const events = Array.from({ length: 501 }, (_, index) => ({
2117
+ kind: 'convert',
2118
+ path: `/pinokio/api/appB/event-${String(index).padStart(4, '0')}.bin`,
2119
+ relative_path: `event-${String(index).padStart(4, '0')}.bin`,
2120
+ source_id: 'app:appB',
2121
+ source_label: 'appB',
2122
+ batch_id: 'batch-large',
2123
+ undoable: index !== 0,
2124
+ bytes_saved: 1024 + index,
2125
+ ts: Date.now() - index
2126
+ }))
2127
+ const status = {
2128
+ enabled: true,
2129
+ mode: 'link',
2130
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2131
+ last_scan: { ts: Date.now(), bytes_total: 1024 * 501 },
2132
+ tracked_bytes: 0,
2133
+ effective_bytes: 0,
2134
+ shared_bytes: 0,
2135
+ pending_bytes: 0,
2136
+ activity_error: null,
2137
+ cloud_sync_warning: null,
2138
+ sources: [{
2139
+ id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
2140
+ display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
2141
+ }],
2142
+ blobs: [],
2143
+ duplicates: [],
2144
+ excluded: [],
2145
+ events,
2146
+ undo_batches: [{
2147
+ batch_id: 'batch-large',
2148
+ files: events.length,
2149
+ bytes: events.reduce((sum, event) => sum + event.bytes_saved, 0),
2150
+ ts: events[0].ts
2151
+ }, {
2152
+ batch_id: 'batch-compacted',
2153
+ files: 2,
2154
+ bytes: 4096,
2155
+ ts: events[events.length - 1].ts - 1
2156
+ }]
2157
+ }
2158
+ dom.window.fetch = async () => ({ ok: true, json: async () => status })
2159
+ await runVaultScript(dom)
2160
+ await new Promise((resolve) => setTimeout(resolve, 25))
2161
+
2162
+ const activityView = dom.window.document.querySelector('[data-view="activity"]')
2163
+ assert.strictEqual(activityView.querySelector('.vault-nav-count').textContent, '502')
2164
+ activityView.click()
2165
+
2166
+ const rows = () => [...dom.window.document.querySelectorAll('.vault-table.activity .vault-file-row')]
2167
+ assert.strictEqual(rows().length, 500)
2168
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-large"]').length, 1)
2169
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-compacted"]').length, 1)
2170
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 502')
2171
+
2172
+ dom.window.document.querySelector('[data-page="next"]').click()
2173
+ assert.strictEqual(rows().length, 2)
2174
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-large"]').length, 0)
2175
+ assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-compacted"]').length, 0)
2176
+ assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–502 of 502')
2177
+ dom.window.close()
2178
+ })
2179
+
1856
2180
  test('large file lists render bounded pages without narrowing Deduplicate all', async () => {
1857
2181
  const views = path.resolve(__dirname, '..', 'server', 'views')
1858
2182
  const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
@@ -2148,6 +2472,64 @@ describe('vault dashboard backend (phase 4)', () => {
2148
2472
  dom.window.close()
2149
2473
  })
2150
2474
 
2475
+ test('external file rows and expanded matches show the full target path', async () => {
2476
+ const views = path.resolve(__dirname, '..', 'server', 'views')
2477
+ const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
2478
+ theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'external:api'
2479
+ })
2480
+ const dom = new JSDOM(html, { url: 'http://localhost/vault/app/external%3Aapi', runScripts: 'dangerously' })
2481
+ const targetRoot = '/Volumes/Models/api'
2482
+ const relativePath = 'ideogram/app/comfy_models/model.safetensors'
2483
+ const externalPath = `${targetRoot}/${relativePath}`
2484
+ const status = {
2485
+ enabled: true,
2486
+ mode: 'link',
2487
+ scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
2488
+ last_scan: { ts: Date.now(), bytes_total: 4096 },
2489
+ tracked_bytes: 4096,
2490
+ effective_bytes: 2048,
2491
+ shared_bytes: 4096,
2492
+ pending_bytes: 0,
2493
+ activity_error: null,
2494
+ cloud_sync_warning: null,
2495
+ sources: [{
2496
+ id: 'external:api', kind: 'external', label: 'api', root: targetRoot,
2497
+ display_path: '/pinokio/vault/sources/api', target_path: targetRoot,
2498
+ parent_id: null, available: true, shareable: true
2499
+ }],
2500
+ blobs: [{
2501
+ hash: 'a'.repeat(64), size: 4096, orphan: false, nlink: 3,
2502
+ names: [
2503
+ {
2504
+ path: externalPath, relative_path: relativePath,
2505
+ source_id: 'external:api', source_label: 'api', mode: 'link'
2506
+ },
2507
+ {
2508
+ path: `/pinokio/api/appA/${relativePath}`, relative_path: relativePath,
2509
+ source_id: 'app:appA', source_label: 'appA', mode: 'link'
2510
+ }
2511
+ ]
2512
+ }],
2513
+ duplicates: [], excluded: [], events: [], undo_batches: []
2514
+ }
2515
+ dom.window.fetch = async () => ({ ok: true, json: async () => status })
2516
+ await runVaultScript(dom)
2517
+ await new Promise((resolve) => setTimeout(resolve, 25))
2518
+
2519
+ dom.window.document.querySelector('[data-view="shared"]').click()
2520
+ assert.strictEqual(
2521
+ dom.window.document.querySelector('.vault-flat-location').textContent,
2522
+ externalPath
2523
+ )
2524
+ dom.window.document.querySelector('[data-expand-file]').click()
2525
+ assert.deepStrictEqual(
2526
+ [...dom.window.document.querySelectorAll('.vault-location-detail span')]
2527
+ .map((node) => node.textContent),
2528
+ [externalPath, `appA / ${relativePath}`]
2529
+ )
2530
+ dom.window.close()
2531
+ })
2532
+
2151
2533
  test('vault route provisions the dev requirements needed by its folder picker', async () => {
2152
2534
  const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
2153
2535
  const routeStart = serverSource.indexOf('this.app.get("/vault"')