pinokiod 8.0.48 → 8.0.50
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.
- package/kernel/vault/index.js +18 -0
- package/package.json +1 -1
- package/server/public/vault.css +35 -2
- package/server/public/vault.js +130 -56
- package/server/views/partials/vault_workspace.ejs +1 -0
- package/test/vault-engine.test.js +21 -0
- package/test/vault-ui.test.js +405 -19
package/kernel/vault/index.js
CHANGED
|
@@ -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
package/server/public/vault.css
CHANGED
|
@@ -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
|
|
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,28 @@ 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
|
+
border-bottom-color: color-mix(in srgb, var(--vault-warning) 45%, var(--task-border));
|
|
477
|
+
background: color-mix(in srgb, var(--vault-warning) 16%, var(--task-panel));
|
|
478
|
+
}
|
|
479
|
+
.vault-cleanup-notice > i { color: var(--vault-warning); }
|
|
480
|
+
.vault-cleanup-notice strong { flex: 0 0 auto; font-weight: 650; }
|
|
481
|
+
.vault-cleanup-notice-detail {
|
|
482
|
+
min-width: 0;
|
|
483
|
+
flex: 1 1 auto;
|
|
484
|
+
color: color-mix(in srgb, var(--task-text) 78%, var(--vault-warning));
|
|
485
|
+
}
|
|
486
|
+
.vault-cleanup-notice .vault-button {
|
|
487
|
+
flex: 0 0 auto;
|
|
488
|
+
margin-left: auto;
|
|
489
|
+
border-color: color-mix(in srgb, var(--vault-warning) 70%, var(--task-border-strong));
|
|
490
|
+
background: var(--task-panel);
|
|
491
|
+
font-weight: 650;
|
|
492
|
+
}
|
|
493
|
+
.vault-cleanup-notice .vault-button:hover {
|
|
494
|
+
background: color-mix(in srgb, var(--vault-warning) 12%, var(--task-panel));
|
|
495
|
+
}
|
|
472
496
|
.vault-explorer {
|
|
473
497
|
display: grid;
|
|
474
498
|
min-height: 0;
|
|
@@ -952,6 +976,15 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
|
|
|
952
976
|
.vault-result-actions { grid-column: 2; }
|
|
953
977
|
.vault-pane-footer { align-items: flex-start; flex-direction: column; }
|
|
954
978
|
.vault-result.incomplete .vault-button { margin: 0; }
|
|
979
|
+
.vault-cleanup-notice.show {
|
|
980
|
+
display: grid;
|
|
981
|
+
grid-template-columns: 16px minmax(0, 1fr) auto;
|
|
982
|
+
align-items: center;
|
|
983
|
+
}
|
|
984
|
+
.vault-cleanup-notice > i { grid-area: 1 / 1; }
|
|
985
|
+
.vault-cleanup-notice strong { grid-area: 1 / 2; }
|
|
986
|
+
.vault-cleanup-notice-detail { grid-area: 2 / 2 / 3 / 4; }
|
|
987
|
+
.vault-cleanup-notice .vault-button { grid-area: 1 / 3; margin-left: 0; }
|
|
955
988
|
}
|
|
956
989
|
@media (pointer: coarse) {
|
|
957
990
|
.vault-page {
|
package/server/public/vault.js
CHANGED
|
@@ -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: "
|
|
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",
|
|
@@ -21,11 +22,12 @@ const COPY = {
|
|
|
21
22
|
external_other_disk: "Added to Locations. It can be scanned, but files on this disk cannot be deduplicated with files in your Pinokio folder.",
|
|
22
23
|
pinokio_folder: "Pinokio folder",
|
|
23
24
|
save_space: "Save space",
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
disk_space_freed: "of disk space freed",
|
|
26
|
+
sharing_saves_for_app: "Sharing saves {size} for this app",
|
|
27
|
+
without_sharing: "Without sharing",
|
|
28
|
+
without_sharing_help: "Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.",
|
|
29
|
+
on_disk: "On disk",
|
|
30
|
+
app_share: "This app’s share",
|
|
29
31
|
effective_help: "For deduplicated files, disk usage is divided evenly among every location using them.",
|
|
30
32
|
nothing_more_to_save: "Nothing else to save",
|
|
31
33
|
more_can_be_saved: "{size} more can be saved",
|
|
@@ -35,7 +37,8 @@ const COPY = {
|
|
|
35
37
|
find_savings: "Scan to find duplicate files and save disk space",
|
|
36
38
|
find_app_savings: "Scan this app to find duplicate files and save disk space",
|
|
37
39
|
storage_details: "Storage details",
|
|
38
|
-
|
|
40
|
+
kept_deduplicated: "Kept deduplicated",
|
|
41
|
+
already_shared: "Already shared before Save space",
|
|
39
42
|
last_scanned: "Last scanned",
|
|
40
43
|
never: "Never",
|
|
41
44
|
scan: "Scan now",
|
|
@@ -86,6 +89,7 @@ const COPY = {
|
|
|
86
89
|
search_all: "Search files",
|
|
87
90
|
search_duplicates: "Search duplicates",
|
|
88
91
|
search_shared: "Search deduplicated files",
|
|
92
|
+
search_tracked: "Search files with no action needed",
|
|
89
93
|
search_skipped: "Search files kept separate",
|
|
90
94
|
search_activity: "Search activity",
|
|
91
95
|
search_in: "Search in {location}",
|
|
@@ -152,8 +156,13 @@ const COPY = {
|
|
|
152
156
|
keep_separate: "Keep separate",
|
|
153
157
|
kept_separate: "Kept separate",
|
|
154
158
|
make_separate: "Make separate",
|
|
155
|
-
reclaim: "
|
|
156
|
-
reclaim_all: "
|
|
159
|
+
reclaim: "Clean up",
|
|
160
|
+
reclaim_all: "Clean up all",
|
|
161
|
+
cleanup_ready: "{size} ready to clean up",
|
|
162
|
+
cleanup_ready_detail: "{count} left after their linked files were deleted.",
|
|
163
|
+
review_cleanup: "Review cleanup",
|
|
164
|
+
private_link: "private link",
|
|
165
|
+
private_links: "private links",
|
|
157
166
|
undo: "Undo",
|
|
158
167
|
identical_contents_at: "Identical contents at",
|
|
159
168
|
no_files: "No files found",
|
|
@@ -164,26 +173,29 @@ const COPY = {
|
|
|
164
173
|
no_duplicates_hint: "There are no files waiting for your review.",
|
|
165
174
|
no_shared: "Nothing deduplicated yet",
|
|
166
175
|
no_shared_hint: "Deduplicated files will appear here after you review duplicates.",
|
|
176
|
+
no_tracked: "No files with no action needed",
|
|
177
|
+
no_tracked_hint: "Files without a duplicate action will appear here after a scan.",
|
|
167
178
|
no_skipped: "Nothing kept separate",
|
|
168
179
|
no_skipped_hint: "Files you keep separate will appear here.",
|
|
169
|
-
no_reclaimable: "No
|
|
170
|
-
no_reclaimable_hint: "
|
|
180
|
+
no_reclaimable: "No cleanup needed",
|
|
181
|
+
no_reclaimable_hint: "Private links with no remaining linked files will appear here.",
|
|
171
182
|
no_activity: "No activity yet",
|
|
172
183
|
no_activity_hint: "Scans and actions will be recorded here.",
|
|
173
184
|
view_all: "View all files",
|
|
174
185
|
show_all_locations: "Show all locations",
|
|
175
186
|
tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
|
|
176
187
|
duplicate_note: "Only files waiting for review are shown.",
|
|
177
|
-
reclaimable_note: "These
|
|
188
|
+
reclaimable_note: "These private links have no remaining linked files. Cleaning them up frees disk space.",
|
|
189
|
+
activity_note: "Recent scans and Save space actions.",
|
|
178
190
|
converted: "Deduplicated",
|
|
179
191
|
files_left_separate: "{count} remained separate",
|
|
180
192
|
skipped_action: "Kept separate",
|
|
181
193
|
separated: "Separated",
|
|
182
|
-
reclaimed: "
|
|
194
|
+
reclaimed: "Cleaned up",
|
|
183
195
|
event_convert: "Deduplicated",
|
|
184
196
|
event_found: "Duplicate found",
|
|
185
197
|
event_adopt: "Added",
|
|
186
|
-
event_reclaim: "
|
|
198
|
+
event_reclaim: "Cleaned up unused link",
|
|
187
199
|
event_undo: "Undid deduplication",
|
|
188
200
|
event_diverged: "Changed by an app — no longer deduplicated",
|
|
189
201
|
event_detach: "Separated",
|
|
@@ -280,6 +292,16 @@ const sourcePath = (source) => {
|
|
|
280
292
|
if (source.kind === "app") return IS_APP_MODE ? (source.display_path || source.root || "") : ""
|
|
281
293
|
return source.display_path || source.root || ""
|
|
282
294
|
}
|
|
295
|
+
const externalLocation = (item) => {
|
|
296
|
+
const source = sourceById(item.source_id)
|
|
297
|
+
if (!source || source.kind !== "external") return ""
|
|
298
|
+
const base = sourcePath(source).replace(/[\\/]+$/, "")
|
|
299
|
+
const relative = String(item.relative_path || "").replace(/^[\\/]+/, "")
|
|
300
|
+
if (!base) return relative
|
|
301
|
+
if (!relative) return base
|
|
302
|
+
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/"
|
|
303
|
+
return `${base}${separator}${separator === "\\" ? relative.replace(/\//g, "\\") : relative}`
|
|
304
|
+
}
|
|
283
305
|
const isDescendantSource = (candidateId, parentId) => {
|
|
284
306
|
if (!parentId) return true
|
|
285
307
|
if (candidateId === parentId) return true
|
|
@@ -337,9 +359,10 @@ const getCounts = (items) => ({
|
|
|
337
359
|
all: items.length,
|
|
338
360
|
duplicates: items.filter((item) => item.status === "duplicate").length,
|
|
339
361
|
shared: items.filter((item) => item.status === "shared").length,
|
|
362
|
+
tracked: items.filter((item) => item.status === "tracked").length,
|
|
340
363
|
independent: items.filter((item) => item.status === "independent").length,
|
|
341
364
|
reclaimable: state.data.blobs.filter((blob) => blob.orphan).length,
|
|
342
|
-
activity:
|
|
365
|
+
activity: activityItems("").length
|
|
343
366
|
})
|
|
344
367
|
|
|
345
368
|
const sourceCounts = (items) => {
|
|
@@ -362,6 +385,7 @@ const activeItems = (items) => {
|
|
|
362
385
|
let result = items
|
|
363
386
|
if (state.view === "duplicates") result = result.filter((item) => item.status === "duplicate")
|
|
364
387
|
if (state.view === "shared") result = result.filter((item) => item.status === "shared")
|
|
388
|
+
if (state.view === "tracked") result = result.filter((item) => item.status === "tracked")
|
|
365
389
|
if (state.view === "independent") result = result.filter((item) => item.status === "independent")
|
|
366
390
|
if (state.sourceId) result = result.filter((item) => isDescendantSource(item.source_id, state.sourceId))
|
|
367
391
|
if (state.view === "all" && state.statusFilter !== "all") result = result.filter((item) => item.status === state.statusFilter)
|
|
@@ -374,6 +398,7 @@ const viewIcon = {
|
|
|
374
398
|
all: "fa-regular fa-file-lines",
|
|
375
399
|
duplicates: "fa-regular fa-copy",
|
|
376
400
|
shared: "fa-solid fa-link",
|
|
401
|
+
tracked: "fa-regular fa-circle-check",
|
|
377
402
|
independent: "fa-solid fa-circle-minus",
|
|
378
403
|
reclaimable: "fa-regular fa-trash-can",
|
|
379
404
|
activity: "fa-solid fa-wave-square"
|
|
@@ -382,6 +407,7 @@ const viewLabel = {
|
|
|
382
407
|
all: COPY.all,
|
|
383
408
|
duplicates: COPY.duplicates,
|
|
384
409
|
shared: COPY.shared,
|
|
410
|
+
tracked: COPY.tracked,
|
|
385
411
|
independent: COPY.skipped,
|
|
386
412
|
reclaimable: COPY.reclaimable,
|
|
387
413
|
activity: COPY.activity
|
|
@@ -397,18 +423,30 @@ const eventLabels = {
|
|
|
397
423
|
skip: COPY.event_skip,
|
|
398
424
|
reshare: COPY.event_reshare
|
|
399
425
|
}
|
|
400
|
-
const activityItems = () => {
|
|
401
|
-
const query =
|
|
402
|
-
|
|
426
|
+
const activityItems = (queryText = state.query) => {
|
|
427
|
+
const query = queryText.trim().toLowerCase()
|
|
428
|
+
const events = state.data.events.filter((event) => !query ||
|
|
403
429
|
`${eventLabels[event.kind] || event.kind} ${event.path || ""}`.toLowerCase().includes(query))
|
|
430
|
+
const seenBatches = new Set()
|
|
431
|
+
const eventItems = events.map((event) => {
|
|
432
|
+
const showUndo = event.kind === "convert" && event.batch_id &&
|
|
433
|
+
event.undoable !== false && !seenBatches.has(event.batch_id)
|
|
434
|
+
if (showUndo) seenBatches.add(event.batch_id)
|
|
435
|
+
return Object.assign({}, event, { activity_type: "event", show_undo: showUndo })
|
|
436
|
+
})
|
|
437
|
+
const batchItems = (Array.isArray(state.data.undo_batches) ? state.data.undo_batches : [])
|
|
438
|
+
.filter((batch) => !seenBatches.has(batch.batch_id) && (!query ||
|
|
439
|
+
`${COPY.event_convert} ${batch.files || 0} ${COPY.files}`.toLowerCase().includes(query)))
|
|
440
|
+
.map((batch) => Object.assign({}, batch, { activity_type: "batch" }))
|
|
441
|
+
return batchItems.concat(eventItems)
|
|
404
442
|
}
|
|
405
443
|
|
|
406
444
|
const renderViews = (items) => {
|
|
407
445
|
const counts = getCounts(items)
|
|
408
446
|
el("views-label").textContent = COPY.views
|
|
409
447
|
const views = IS_APP_MODE
|
|
410
|
-
? ["all", "duplicates", "shared", "independent", "activity"]
|
|
411
|
-
: ["all", "duplicates", "shared", "independent", "reclaimable", "activity"]
|
|
448
|
+
? ["all", "duplicates", "shared", "tracked", "independent", "activity"]
|
|
449
|
+
: ["all", "duplicates", "shared", "tracked", "independent", "reclaimable", "activity"]
|
|
412
450
|
el("vault-views").innerHTML = views.map((view) => `
|
|
413
451
|
<button class="vault-nav-row ${state.view === view ? "selected" : ""}" type="button" data-view="${view}" ${state.view === view ? 'aria-current="page"' : ""}>
|
|
414
452
|
<i class="${viewIcon[view]}"></i>
|
|
@@ -516,12 +554,14 @@ const updateToolbarSummary = (visibleItems) => {
|
|
|
516
554
|
const searchPlaceholder = () => {
|
|
517
555
|
if (state.view === "duplicates") return COPY.search_duplicates
|
|
518
556
|
if (state.view === "shared") return COPY.search_shared
|
|
557
|
+
if (state.view === "tracked") return COPY.search_tracked
|
|
519
558
|
if (state.view === "independent") return COPY.search_skipped
|
|
520
559
|
if (state.view === "activity") return COPY.search_activity
|
|
521
560
|
const source = selectedSource()
|
|
522
561
|
return source && source.kind === "app" ? COPY.search_in.replace("{location}", source.label) : COPY.search_all
|
|
523
562
|
}
|
|
524
|
-
const supportsDisplayMode = () => state.view === "all" || state.view === "shared" ||
|
|
563
|
+
const supportsDisplayMode = () => state.view === "all" || state.view === "shared" ||
|
|
564
|
+
state.view === "tracked" || state.view === "independent"
|
|
525
565
|
const displayModeControl = () => supportsDisplayMode() ? `<div class="vault-display-mode" role="group" aria-label="${attr(COPY.display_mode)}">
|
|
526
566
|
<button type="button" data-display-mode="folders" aria-pressed="${state.displayMode === "folders"}" class="${state.displayMode === "folders" ? "selected" : ""}">${esc(COPY.folders)}</button>
|
|
527
567
|
<button type="button" data-display-mode="files" aria-pressed="${state.displayMode === "files"}" class="${state.displayMode === "files" ? "selected" : ""}">${esc(COPY.files_mode)}</button>
|
|
@@ -589,7 +629,7 @@ const sharingControl = (item) => {
|
|
|
589
629
|
const fileDetail = (item) => {
|
|
590
630
|
if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
|
|
591
631
|
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
|
|
632
|
+
<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
633
|
}
|
|
594
634
|
|
|
595
635
|
const renderFileRow = (item, depth = 0, showMatch = false) => {
|
|
@@ -686,7 +726,8 @@ const sourceSort = (a, b) => {
|
|
|
686
726
|
const rank = (source) => source && source.kind === "external" ? 1 : source ? 0 : 2
|
|
687
727
|
return rank(first) - rank(second) || groupTitle(first).localeCompare(groupTitle(second))
|
|
688
728
|
}
|
|
689
|
-
const flatLocation = (item) =>
|
|
729
|
+
const flatLocation = (item) => externalLocation(item) ||
|
|
730
|
+
[groupTitle(sourceById(item.source_id)), item.relative_path].filter(Boolean).join(" / ")
|
|
690
731
|
const renderFlatFiles = (items) => [...items]
|
|
691
732
|
.sort((a, b) => compareRows(a, b, flatLocation))
|
|
692
733
|
.map((item) => {
|
|
@@ -750,6 +791,7 @@ const emptyState = (view) => {
|
|
|
750
791
|
: [COPY.no_files, COPY.no_files_hint, "fa-regular fa-folder-open"],
|
|
751
792
|
duplicates: [COPY.no_duplicates, COPY.no_duplicates_hint, "fa-regular fa-circle-check"],
|
|
752
793
|
shared: [COPY.no_shared, COPY.no_shared_hint, "fa-solid fa-link"],
|
|
794
|
+
tracked: [COPY.no_tracked, COPY.no_tracked_hint, "fa-regular fa-circle-check"],
|
|
753
795
|
independent: [COPY.no_skipped, COPY.no_skipped_hint, "fa-solid fa-circle-minus"],
|
|
754
796
|
reclaimable: [COPY.no_reclaimable, COPY.no_reclaimable_hint, "fa-regular fa-circle-check"],
|
|
755
797
|
activity: [COPY.no_activity, COPY.no_activity_hint, "fa-solid fa-wave-square"]
|
|
@@ -763,30 +805,21 @@ const renderReclaimable = (blobs) => {
|
|
|
763
805
|
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
806
|
}
|
|
765
807
|
|
|
766
|
-
const renderActivity = () => {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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>`
|
|
808
|
+
const renderActivity = (items) => {
|
|
809
|
+
if (!items.length) return emptyState("activity")
|
|
810
|
+
return items.map((item) => {
|
|
811
|
+
if (item.activity_type === "batch") {
|
|
812
|
+
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
813
|
}
|
|
814
|
+
const event = item
|
|
815
|
+
const undo = event.show_undo
|
|
816
|
+
? `<button class="vault-text-button" type="button" data-undo="${attr(event.batch_id)}">${esc(COPY.undo)}</button>`
|
|
817
|
+
: ""
|
|
781
818
|
const eventPath = event.path
|
|
782
819
|
? `${event.source_label ? `${event.source_label} / ` : ""}${event.relative_path || event.path}`
|
|
783
820
|
: (event.hash || "").slice(0, 12)
|
|
784
821
|
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
822
|
}).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
823
|
}
|
|
791
824
|
|
|
792
825
|
const renderTable = (items) => {
|
|
@@ -804,7 +837,7 @@ const renderTable = (items) => {
|
|
|
804
837
|
} else if (state.view === "activity") {
|
|
805
838
|
tableClass = "activity"
|
|
806
839
|
headers = [COPY.name, COPY.size, COPY.last_scanned, ""]
|
|
807
|
-
body = renderActivity()
|
|
840
|
+
body = renderActivity(items)
|
|
808
841
|
} else if (state.displayMode === "files" && supportsDisplayMode()) {
|
|
809
842
|
tableClass = "flat"
|
|
810
843
|
headers = [COPY.name, COPY.location_column, COPY.size, COPY.status]
|
|
@@ -837,9 +870,6 @@ const orderedItems = (items) => {
|
|
|
837
870
|
}
|
|
838
871
|
|
|
839
872
|
const pagedItems = (items) => {
|
|
840
|
-
if (state.view === "activity") {
|
|
841
|
-
return { items, start: 0, end: items.length, total: items.length, pages: 1 }
|
|
842
|
-
}
|
|
843
873
|
const context = JSON.stringify([
|
|
844
874
|
state.view, state.sourceId, state.query, state.statusFilter,
|
|
845
875
|
state.displayMode, state.sizeSort
|
|
@@ -870,6 +900,7 @@ const paneFooterText = (items) => {
|
|
|
870
900
|
}
|
|
871
901
|
if (state.view === "duplicates") return COPY.duplicate_note
|
|
872
902
|
if (state.view === "reclaimable") return COPY.reclaimable_note
|
|
903
|
+
if (state.view === "activity") return COPY.activity_note
|
|
873
904
|
return COPY.tracked_note.replace("{size}", fmt(candidateSize()))
|
|
874
905
|
}
|
|
875
906
|
|
|
@@ -910,23 +941,31 @@ const renderOverview = () => {
|
|
|
910
941
|
(!!last || beforeBytes > 0 || afterBytes > 0 || Number(data.pending_bytes) > 0)
|
|
911
942
|
const afterRatio = beforeBytes ? Math.min(100, (afterBytes / beforeBytes) * 100) : 0
|
|
912
943
|
const pendingBytes = Math.max(0, Number(data.pending_bytes) || 0)
|
|
944
|
+
// The headline reports bytes actually freed by user actions, so it always
|
|
945
|
+
// survives a comparison against the OS file manager. Sharing that already
|
|
946
|
+
// existed when a scan adopted it (package-manager hardlinks, earlier runs)
|
|
947
|
+
// never changed free space and stays out of this number.
|
|
948
|
+
const freedBytes = Math.max(0, Number(data.lifetime_bytes_saved) || 0)
|
|
949
|
+
const sharedNow = Math.max(0, Number(data.saved_by_sharing) || 0)
|
|
913
950
|
const headline = hasComparison
|
|
914
951
|
? (IS_APP_MODE
|
|
915
|
-
? COPY.
|
|
916
|
-
: `${fmt(
|
|
952
|
+
? COPY.sharing_saves_for_app.replace("{size}", fmt(Math.max(0, beforeBytes - afterBytes)))
|
|
953
|
+
: `${fmt(freedBytes)} ${COPY.disk_space_freed}`)
|
|
917
954
|
: (IS_APP_MODE ? COPY.find_app_savings : COPY.find_savings)
|
|
918
|
-
const
|
|
955
|
+
const beforeLabel = COPY.without_sharing
|
|
956
|
+
const afterLabel = IS_APP_MODE ? COPY.app_share : COPY.on_disk
|
|
957
|
+
const help = IS_APP_MODE ? COPY.effective_help : COPY.without_sharing_help
|
|
919
958
|
const helpId = IS_APP_MODE ? "vault-after-help" : "vault-before-help"
|
|
920
959
|
const helpMarkup = `<span class="vault-compare-info" tabindex="0" aria-describedby="${helpId}"><i class="fa-regular fa-circle-question" aria-hidden="true"></i><span class="vault-compare-tooltip" id="${helpId}" role="tooltip">${esc(help)}</span></span>`
|
|
921
960
|
const comparison = hasComparison ? `
|
|
922
|
-
<div class="vault-comparison" aria-label="${attr(`${
|
|
961
|
+
<div class="vault-comparison" aria-label="${attr(`${beforeLabel}: ${fmt(beforeBytes)}. ${afterLabel}: ${fmt(afterBytes)}.`)}">
|
|
923
962
|
<div class="vault-compare-row">
|
|
924
|
-
<span class="vault-compare-label">${esc(
|
|
963
|
+
<span class="vault-compare-label">${esc(beforeLabel)}${IS_APP_MODE ? "" : helpMarkup}</span>
|
|
925
964
|
<span class="vault-compare-track"><span class="vault-compare-fill before"></span></span>
|
|
926
965
|
<span class="vault-compare-value">${fmt(beforeBytes)}</span>
|
|
927
966
|
</div>
|
|
928
967
|
<div class="vault-compare-row">
|
|
929
|
-
<span class="vault-compare-label">${esc(
|
|
968
|
+
<span class="vault-compare-label">${esc(afterLabel)}${IS_APP_MODE ? helpMarkup : ""}</span>
|
|
930
969
|
<span class="vault-compare-track"><span class="vault-compare-fill after" style="--vault-after-ratio:${afterRatio.toFixed(2)}%"></span></span>
|
|
931
970
|
<span class="vault-compare-value">${fmt(afterBytes)}</span>
|
|
932
971
|
</div>
|
|
@@ -947,10 +986,14 @@ const renderOverview = () => {
|
|
|
947
986
|
const storageDetails = !IS_APP_MODE && el("vault-storage-details")
|
|
948
987
|
if (storageDetails) {
|
|
949
988
|
const homeBytes = last ? (last.home_bytes_total == null ? last.bytes_total : last.home_bytes_total) : null
|
|
989
|
+
// Clamped because deleting linked files later shrinks the live sharing
|
|
990
|
+
// total without touching the lifetime counter.
|
|
991
|
+
const alreadyShared = Math.max(0, sharedNow - freedBytes)
|
|
950
992
|
storageDetails.innerHTML = `<div class="vault-advanced-title">${esc(COPY.storage_details)}</div>
|
|
951
993
|
<dl class="vault-storage-list">
|
|
952
994
|
<div><dt>${esc(COPY.pinokio_folder)}</dt><dd>${homeBytes == null ? "—" : fmt(homeBytes)}</dd></div>
|
|
953
|
-
<div><dt>${esc(COPY.
|
|
995
|
+
<div><dt>${esc(COPY.kept_deduplicated)}</dt><dd>${fmt(sharedNow)}</dd></div>
|
|
996
|
+
<div><dt>${esc(COPY.already_shared)}</dt><dd>${fmt(alreadyShared)}</dd></div>
|
|
954
997
|
</dl>`
|
|
955
998
|
}
|
|
956
999
|
}
|
|
@@ -1188,12 +1231,34 @@ const renderCloudWarning = () => {
|
|
|
1188
1231
|
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
1232
|
}
|
|
1190
1233
|
|
|
1234
|
+
const renderCleanupNotice = () => {
|
|
1235
|
+
const notice = el("vault-cleanup-notice")
|
|
1236
|
+
const blobs = state.data && Array.isArray(state.data.blobs) ? state.data.blobs : []
|
|
1237
|
+
const unused = !IS_APP_MODE && state.data && state.data.enabled
|
|
1238
|
+
? blobs.filter((blob) => blob.orphan)
|
|
1239
|
+
: []
|
|
1240
|
+
if (!unused.length || state.view === "reclaimable") {
|
|
1241
|
+
notice.className = "vault-cleanup-notice"
|
|
1242
|
+
notice.innerHTML = ""
|
|
1243
|
+
return
|
|
1244
|
+
}
|
|
1245
|
+
const bytes = unused.reduce((sum, blob) => sum + (Number(blob.size) || 0), 0)
|
|
1246
|
+
const title = COPY.cleanup_ready.replace("{size}", fmt(bytes))
|
|
1247
|
+
const detail = COPY.cleanup_ready_detail.replace(
|
|
1248
|
+
"{count}",
|
|
1249
|
+
countLabel(unused.length, COPY.private_link, COPY.private_links)
|
|
1250
|
+
)
|
|
1251
|
+
notice.className = "vault-cleanup-notice show"
|
|
1252
|
+
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>`
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1191
1255
|
const render = () => {
|
|
1192
1256
|
if (!state.data) return
|
|
1193
1257
|
renderOverview()
|
|
1194
1258
|
renderResult()
|
|
1195
1259
|
renderFeedback()
|
|
1196
1260
|
renderCloudWarning()
|
|
1261
|
+
renderCleanupNotice()
|
|
1197
1262
|
if (!state.data.enabled) {
|
|
1198
1263
|
el("vault-explorer").style.display = "none"
|
|
1199
1264
|
return
|
|
@@ -1263,13 +1328,16 @@ const applyFullData = (data) => {
|
|
|
1263
1328
|
render()
|
|
1264
1329
|
return scanning || !!fileAction
|
|
1265
1330
|
}
|
|
1331
|
+
let refreshSequence = 0
|
|
1266
1332
|
const refresh = async (forceFull = false) => {
|
|
1333
|
+
const sequence = ++refreshSequence
|
|
1267
1334
|
let delay = null
|
|
1268
1335
|
try {
|
|
1269
1336
|
const progressOnly = !forceFull &&
|
|
1270
1337
|
!!(state.data && (scanActive(state.data.scan) || state.actionProgress))
|
|
1271
1338
|
if (progressOnly) {
|
|
1272
1339
|
const progress = await fetchJson(statusUrl(true))
|
|
1340
|
+
if (sequence !== refreshSequence) return
|
|
1273
1341
|
state.data.scan = progress.scan
|
|
1274
1342
|
state.data.last_scan = progress.last_scan
|
|
1275
1343
|
const fileAction = serverFileAction(progress.file_action)
|
|
@@ -1281,16 +1349,22 @@ const refresh = async (forceFull = false) => {
|
|
|
1281
1349
|
renderActionProgress()
|
|
1282
1350
|
renderFeedback()
|
|
1283
1351
|
} else {
|
|
1284
|
-
|
|
1352
|
+
const data = await fetchJson(statusUrl())
|
|
1353
|
+
if (sequence !== refreshSequence) return
|
|
1354
|
+
if (applyFullData(data) || state.scanRequested) delay = 1500
|
|
1285
1355
|
}
|
|
1286
1356
|
} else {
|
|
1287
|
-
|
|
1357
|
+
const data = await fetchJson(statusUrl())
|
|
1358
|
+
if (sequence !== refreshSequence) return
|
|
1359
|
+
if (applyFullData(data) || state.scanRequested) delay = 1500
|
|
1288
1360
|
}
|
|
1289
1361
|
} catch (error) {
|
|
1362
|
+
if (sequence !== refreshSequence) return
|
|
1290
1363
|
state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
|
|
1291
1364
|
renderFeedback()
|
|
1292
1365
|
delay = 5000
|
|
1293
1366
|
} finally {
|
|
1367
|
+
if (sequence !== refreshSequence) return
|
|
1294
1368
|
clearTimeout(window.__vaultRefresh)
|
|
1295
1369
|
window.__vaultRefresh = delay == null ? null : setTimeout(refresh, delay)
|
|
1296
1370
|
}
|
|
@@ -1476,8 +1550,8 @@ document.addEventListener("click", async (event) => {
|
|
|
1476
1550
|
state.displayMode = target.dataset.displayMode === "files" ? "files" : "folders"
|
|
1477
1551
|
state.sizeSort = state.displayMode === "files" ? "desc" : null
|
|
1478
1552
|
render()
|
|
1479
|
-
} else if (target.dataset.view) {
|
|
1480
|
-
state.view = target.dataset.view
|
|
1553
|
+
} else if (target.dataset.view || target.id === "btn-review-cleanup") {
|
|
1554
|
+
state.view = target.dataset.view || "reclaimable"
|
|
1481
1555
|
state.sourceId = SCOPE_ID
|
|
1482
1556
|
state.query = ""
|
|
1483
1557
|
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)
|
package/test/vault-ui.test.js
CHANGED
|
@@ -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,27 +1235,36 @@ 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
|
-
assert.match(vaultPage, /
|
|
1106
|
-
assert.match(vaultPage, /
|
|
1263
|
+
assert.match(vaultPage, /disk_space_freed:\s*"of disk space freed"/)
|
|
1264
|
+
assert.match(vaultPage, /without_sharing_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
|
-
assert.match(vaultPage, /
|
|
1267
|
+
assert.match(vaultPage, /Number\(data\.saved_by_sharing\)/)
|
|
1110
1268
|
assert.match(vaultPage, /Number\(data\.bytes_without_sharing\)/)
|
|
1111
1269
|
assert.match(vaultPage, /Number\(data\.bytes_on_disk\)/)
|
|
1112
1270
|
assert.match(vaultPage, /Number\(data\.pending_bytes\)/)
|
|
@@ -1118,7 +1276,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1118
1276
|
assert.match(vaultPage, /candidate_size:\s*candidateSize\(\)/)
|
|
1119
1277
|
assert.match(vaultPage, /localStorage\.setItem\(candidateSizeKey/)
|
|
1120
1278
|
assert.match(vaultPage, /id='vault-storage-details'/)
|
|
1121
|
-
assert.match(vaultPage, /
|
|
1279
|
+
assert.match(vaultPage, /Number\(data\.lifetime_bytes_saved\)/)
|
|
1280
|
+
assert.match(vaultPage, /Math\.max\(0, sharedNow - freedBytes\)/)
|
|
1122
1281
|
assert.match(vaultPage, /repair_index:\s*"Repair index"/)
|
|
1123
1282
|
assert.match(vaultPage, /<details class='vault-advanced'/)
|
|
1124
1283
|
assert.doesNotMatch(vaultPage, /id='btn-rebuild'/)
|
|
@@ -1186,7 +1345,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1186
1345
|
assert.match(vaultPage, /pinokio:vault:reviewed-scan/)
|
|
1187
1346
|
assert.match(vaultPage, /completed \|\| incomplete \|\| \(!state\.scanResult && unreviewed\)/)
|
|
1188
1347
|
assert.match(vaultPage, /state\.data\.undo_batches/)
|
|
1189
|
-
assert.match(vaultPage, /data-undo="\$\{attr\(
|
|
1348
|
+
assert.match(vaultPage, /data-undo="\$\{attr\(item\.batch_id\)\}"/)
|
|
1349
|
+
assert.match(vaultPage, /data-undo="\$\{attr\(event\.batch_id\)\}"/)
|
|
1190
1350
|
assert.match(vaultPage, /last_scan && data\.last_scan\.hash_failures/)
|
|
1191
1351
|
assert.match(vaultPage, /scan_not_analyzed:\s*"could not be analyzed"/)
|
|
1192
1352
|
assert.match(vaultPage, /\.vault-progress-bar\.determinate \{[\s\S]*?transform:\s*scaleX/)
|
|
@@ -1366,7 +1526,7 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1366
1526
|
await runVaultScript(dom)
|
|
1367
1527
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1368
1528
|
|
|
1369
|
-
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '4.51 GB
|
|
1529
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, 'Sharing saves 4.51 GB for this app')
|
|
1370
1530
|
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['7.09 GB', '2.58 GB'])
|
|
1371
1531
|
assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:36\.36%/)
|
|
1372
1532
|
assert.strictEqual(dom.window.document.getElementById('vault-after-help').textContent,
|
|
@@ -1387,11 +1547,14 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1387
1547
|
const formatter = await fs.promises.readFile(
|
|
1388
1548
|
path.resolve(__dirname, '..', 'server', 'public', 'storage-size.js'), 'utf8')
|
|
1389
1549
|
dom.window.eval(formatter)
|
|
1550
|
+
const screenshotFolderBytes = 2_002_022_055_452
|
|
1390
1551
|
assert.strictEqual(dom.window.PinokioFormatStorageSize(7.05e9), '7.05 GB')
|
|
1391
1552
|
assert.strictEqual(dom.window.PinokioFormatStorageSize(5.3e9), '5.3 GB')
|
|
1392
1553
|
assert.strictEqual(dom.window.PinokioFormatStorageSize(1024 ** 3), '1.07 GB')
|
|
1554
|
+
assert.strictEqual(dom.window.PinokioFormatStorageSize(screenshotFolderBytes), '2 TB')
|
|
1393
1555
|
dom.window.document.body.dataset.platform = 'win32'
|
|
1394
1556
|
assert.strictEqual(dom.window.PinokioFormatStorageSize(1024 ** 3), '1 GB')
|
|
1557
|
+
assert.strictEqual(dom.window.PinokioFormatStorageSize(screenshotFolderBytes), '1.82 TB')
|
|
1395
1558
|
|
|
1396
1559
|
const appView = await fs.promises.readFile(
|
|
1397
1560
|
path.resolve(__dirname, '..', 'server', 'views', 'app.ejs'), 'utf8')
|
|
@@ -1439,24 +1602,37 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1439
1602
|
await runVaultScript(dom)
|
|
1440
1603
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1441
1604
|
|
|
1442
|
-
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '
|
|
1443
|
-
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['
|
|
1605
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '258.77 GB of disk space freed')
|
|
1606
|
+
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['Without sharing', 'On disk'])
|
|
1444
1607
|
assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['661.75 GB', '404.8 GB'])
|
|
1445
1608
|
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,
|
|
1609
|
+
assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent,
|
|
1610
|
+
'Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.')
|
|
1447
1611
|
assert.strictEqual(dom.window.document.querySelector('.vault-compare-info').getAttribute('tabindex'), '0')
|
|
1448
1612
|
assert.match(dom.window.document.querySelector('.vault-summary-side').textContent, /13\.31 GB more can be saved/)
|
|
1449
1613
|
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').textContent, 'Review files')
|
|
1450
1614
|
assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), true)
|
|
1451
1615
|
assert.strictEqual(dom.window.document.getElementById('btn-scan').classList.contains('primary'), false)
|
|
1452
1616
|
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Pinokio folder\s*166\.22 GB/)
|
|
1453
|
-
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /
|
|
1617
|
+
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Kept deduplicated\s*256\.95 GB/)
|
|
1618
|
+
// lifetime (241) exceeds live sharing (239.3) here, so the clamp keeps
|
|
1619
|
+
// the pre-existing figure at zero instead of going negative.
|
|
1620
|
+
assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Already shared before Save space\s*0 B/)
|
|
1621
|
+
const cleanupNotice = dom.window.document.getElementById('vault-cleanup-notice')
|
|
1622
|
+
assert.strictEqual(cleanupNotice.classList.contains('show'), true)
|
|
1623
|
+
assert.match(cleanupNotice.textContent, /ready to clean up/)
|
|
1624
|
+
assert.match(cleanupNotice.textContent, /1 private link left/)
|
|
1625
|
+
dom.window.document.getElementById('btn-review-cleanup').click()
|
|
1626
|
+
assert.strictEqual(dom.window.document.querySelector('[data-view="reclaimable"]').classList.contains('selected'), true)
|
|
1627
|
+
assert.strictEqual(cleanupNotice.classList.contains('show'), false)
|
|
1628
|
+
dom.window.document.querySelector('[data-view="all"]').click()
|
|
1454
1629
|
const viewDescriptions = {
|
|
1455
1630
|
all: 'Every scanned file and its current deduplication status.',
|
|
1456
1631
|
duplicates: 'Identical files waiting to be deduplicated or kept separate.',
|
|
1457
1632
|
shared: 'Files that share disk storage across multiple locations.',
|
|
1633
|
+
tracked: 'Files with no duplicate action required.',
|
|
1458
1634
|
independent: 'Duplicate files that remain as separate copies.',
|
|
1459
|
-
reclaimable: '
|
|
1635
|
+
reclaimable: 'Private links no longer used by any linked file.',
|
|
1460
1636
|
activity: 'A history of scans and changes made by Save space.'
|
|
1461
1637
|
}
|
|
1462
1638
|
for (const [view, description] of Object.entries(viewDescriptions)) {
|
|
@@ -1472,9 +1648,10 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1472
1648
|
const unusedView = dom.window.document.querySelector('[data-view="reclaimable"]')
|
|
1473
1649
|
assert.strictEqual(unusedView.querySelector('.vault-nav-name').textContent, 'Unused files')
|
|
1474
1650
|
unusedView.click()
|
|
1475
|
-
assert.strictEqual(dom.window.document.getElementById('btn-reclaim-all').textContent, '
|
|
1476
|
-
assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, '
|
|
1477
|
-
assert.match(dom.window.document.getElementById('vault-pane-footer').textContent,
|
|
1651
|
+
assert.strictEqual(dom.window.document.getElementById('btn-reclaim-all').textContent, 'Clean up all')
|
|
1652
|
+
assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, 'Clean up')
|
|
1653
|
+
assert.match(dom.window.document.getElementById('vault-pane-footer').textContent,
|
|
1654
|
+
/Cleaning them up frees disk space/)
|
|
1478
1655
|
dom.window.close()
|
|
1479
1656
|
})
|
|
1480
1657
|
|
|
@@ -1511,12 +1688,13 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1511
1688
|
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
1512
1689
|
|
|
1513
1690
|
assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent,
|
|
1514
|
-
'3 GB of disk space
|
|
1691
|
+
'3 GB of disk space freed')
|
|
1515
1692
|
assert.deepStrictEqual(
|
|
1516
1693
|
[...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent),
|
|
1517
1694
|
['10 GB', '7 GB'])
|
|
1518
1695
|
assert.match(dom.window.document.querySelector('.vault-summary-side').textContent,
|
|
1519
1696
|
/1 GB more can be saved.*Not scanned yet/)
|
|
1697
|
+
assert.strictEqual(dom.window.document.getElementById('vault-cleanup-notice').classList.contains('show'), false)
|
|
1520
1698
|
dom.window.close()
|
|
1521
1699
|
})
|
|
1522
1700
|
|
|
@@ -1549,8 +1727,8 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1549
1727
|
assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.can_free, ""\]/)
|
|
1550
1728
|
assert.match(source, /headers = \[COPY\.name, COPY\.size, COPY\.last_scanned, ""\]/)
|
|
1551
1729
|
assert.match(source, /reclaimable:\s*"Unused files"/)
|
|
1552
|
-
assert.match(source, /reclaim:\s*"
|
|
1553
|
-
assert.match(source, /reclaim_all:\s*"
|
|
1730
|
+
assert.match(source, /reclaim:\s*"Clean up"/)
|
|
1731
|
+
assert.match(source, /reclaim_all:\s*"Clean up all"/)
|
|
1554
1732
|
assert.match(source, /data-sort-size/)
|
|
1555
1733
|
assert.match(source, /data-display-mode="folders"/)
|
|
1556
1734
|
assert.match(source, /data-display-mode="files"/)
|
|
@@ -1853,6 +2031,156 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
1853
2031
|
}
|
|
1854
2032
|
})
|
|
1855
2033
|
|
|
2034
|
+
test('No action needed is a counted, filtered, paginated file view', async () => {
|
|
2035
|
+
const views = path.resolve(__dirname, '..', 'server', 'views')
|
|
2036
|
+
const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
|
|
2037
|
+
theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
|
|
2038
|
+
})
|
|
2039
|
+
const dom = new JSDOM(html, {
|
|
2040
|
+
url: 'http://localhost/vault/app/appB',
|
|
2041
|
+
runScripts: 'dangerously'
|
|
2042
|
+
})
|
|
2043
|
+
const blobs = Array.from({ length: 501 }, (_, index) => {
|
|
2044
|
+
const name = `tracked-${String(index).padStart(4, '0')}.bin`
|
|
2045
|
+
return {
|
|
2046
|
+
hash: index.toString(16).padStart(64, '0'),
|
|
2047
|
+
size: 1024 + index,
|
|
2048
|
+
orphan: false,
|
|
2049
|
+
nlink: 2,
|
|
2050
|
+
names: [{
|
|
2051
|
+
path: `/pinokio/api/appB/${name}`,
|
|
2052
|
+
relative_path: name,
|
|
2053
|
+
source_id: 'app:appB',
|
|
2054
|
+
source_label: 'appB',
|
|
2055
|
+
mode: 'link'
|
|
2056
|
+
}]
|
|
2057
|
+
}
|
|
2058
|
+
})
|
|
2059
|
+
const status = {
|
|
2060
|
+
enabled: true,
|
|
2061
|
+
mode: 'link',
|
|
2062
|
+
scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
|
|
2063
|
+
last_scan: { ts: Date.now(), bytes_total: 1024 * 502 },
|
|
2064
|
+
tracked_bytes: 1024 * 502,
|
|
2065
|
+
effective_bytes: 1024 * 502,
|
|
2066
|
+
shared_bytes: 0,
|
|
2067
|
+
pending_bytes: 1024,
|
|
2068
|
+
activity_error: null,
|
|
2069
|
+
cloud_sync_warning: null,
|
|
2070
|
+
sources: [{
|
|
2071
|
+
id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
|
|
2072
|
+
display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
|
|
2073
|
+
}],
|
|
2074
|
+
blobs,
|
|
2075
|
+
duplicates: [{
|
|
2076
|
+
path: '/pinokio/api/appB/duplicate.bin',
|
|
2077
|
+
relative_path: 'duplicate.bin',
|
|
2078
|
+
source_id: 'app:appB',
|
|
2079
|
+
source_label: 'appB',
|
|
2080
|
+
size: 1024,
|
|
2081
|
+
shareable: true,
|
|
2082
|
+
match: { path: '/pinokio/api/appA/duplicate.bin' }
|
|
2083
|
+
}],
|
|
2084
|
+
excluded: [],
|
|
2085
|
+
events: [],
|
|
2086
|
+
undo_batches: []
|
|
2087
|
+
}
|
|
2088
|
+
dom.window.fetch = async () => ({ ok: true, json: async () => status })
|
|
2089
|
+
await runVaultScript(dom)
|
|
2090
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
2091
|
+
|
|
2092
|
+
const trackedView = dom.window.document.querySelector('[data-view="tracked"]')
|
|
2093
|
+
assert.strictEqual(trackedView.querySelector('.vault-nav-name').textContent, 'No action needed')
|
|
2094
|
+
assert.strictEqual(trackedView.querySelector('.vault-nav-count').textContent, '501')
|
|
2095
|
+
trackedView.click()
|
|
2096
|
+
|
|
2097
|
+
const rows = () => [...dom.window.document.querySelectorAll('.vault-table .vault-file-row')]
|
|
2098
|
+
assert.strictEqual(rows().length, 500)
|
|
2099
|
+
assert.strictEqual(dom.window.document.getElementById('vault-search').placeholder,
|
|
2100
|
+
'Search files with no action needed')
|
|
2101
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 501')
|
|
2102
|
+
assert.doesNotMatch(dom.window.document.getElementById('vault-table-wrap').textContent, /duplicate\.bin/)
|
|
2103
|
+
|
|
2104
|
+
dom.window.document.querySelector('[data-page="next"]').click()
|
|
2105
|
+
assert.strictEqual(rows().length, 1)
|
|
2106
|
+
assert.strictEqual(rows()[0].querySelector('.vault-file-name').textContent, 'tracked-0500.bin')
|
|
2107
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–501 of 501')
|
|
2108
|
+
dom.window.close()
|
|
2109
|
+
})
|
|
2110
|
+
|
|
2111
|
+
test('Activity is paginated without repeating a batch undo action', async () => {
|
|
2112
|
+
const views = path.resolve(__dirname, '..', 'server', 'views')
|
|
2113
|
+
const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
|
|
2114
|
+
theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'app:appB'
|
|
2115
|
+
})
|
|
2116
|
+
const dom = new JSDOM(html, {
|
|
2117
|
+
url: 'http://localhost/vault/app/appB',
|
|
2118
|
+
runScripts: 'dangerously'
|
|
2119
|
+
})
|
|
2120
|
+
const events = Array.from({ length: 501 }, (_, index) => ({
|
|
2121
|
+
kind: 'convert',
|
|
2122
|
+
path: `/pinokio/api/appB/event-${String(index).padStart(4, '0')}.bin`,
|
|
2123
|
+
relative_path: `event-${String(index).padStart(4, '0')}.bin`,
|
|
2124
|
+
source_id: 'app:appB',
|
|
2125
|
+
source_label: 'appB',
|
|
2126
|
+
batch_id: 'batch-large',
|
|
2127
|
+
undoable: index !== 0,
|
|
2128
|
+
bytes_saved: 1024 + index,
|
|
2129
|
+
ts: Date.now() - index
|
|
2130
|
+
}))
|
|
2131
|
+
const status = {
|
|
2132
|
+
enabled: true,
|
|
2133
|
+
mode: 'link',
|
|
2134
|
+
scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
|
|
2135
|
+
last_scan: { ts: Date.now(), bytes_total: 1024 * 501 },
|
|
2136
|
+
tracked_bytes: 0,
|
|
2137
|
+
effective_bytes: 0,
|
|
2138
|
+
shared_bytes: 0,
|
|
2139
|
+
pending_bytes: 0,
|
|
2140
|
+
activity_error: null,
|
|
2141
|
+
cloud_sync_warning: null,
|
|
2142
|
+
sources: [{
|
|
2143
|
+
id: 'app:appB', kind: 'app', label: 'appB', root: '/pinokio/api/appB',
|
|
2144
|
+
display_path: '/pinokio/api/appB', parent_id: null, available: true, shareable: true
|
|
2145
|
+
}],
|
|
2146
|
+
blobs: [],
|
|
2147
|
+
duplicates: [],
|
|
2148
|
+
excluded: [],
|
|
2149
|
+
events,
|
|
2150
|
+
undo_batches: [{
|
|
2151
|
+
batch_id: 'batch-large',
|
|
2152
|
+
files: events.length,
|
|
2153
|
+
bytes: events.reduce((sum, event) => sum + event.bytes_saved, 0),
|
|
2154
|
+
ts: events[0].ts
|
|
2155
|
+
}, {
|
|
2156
|
+
batch_id: 'batch-compacted',
|
|
2157
|
+
files: 2,
|
|
2158
|
+
bytes: 4096,
|
|
2159
|
+
ts: events[events.length - 1].ts - 1
|
|
2160
|
+
}]
|
|
2161
|
+
}
|
|
2162
|
+
dom.window.fetch = async () => ({ ok: true, json: async () => status })
|
|
2163
|
+
await runVaultScript(dom)
|
|
2164
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
2165
|
+
|
|
2166
|
+
const activityView = dom.window.document.querySelector('[data-view="activity"]')
|
|
2167
|
+
assert.strictEqual(activityView.querySelector('.vault-nav-count').textContent, '502')
|
|
2168
|
+
activityView.click()
|
|
2169
|
+
|
|
2170
|
+
const rows = () => [...dom.window.document.querySelectorAll('.vault-table.activity .vault-file-row')]
|
|
2171
|
+
assert.strictEqual(rows().length, 500)
|
|
2172
|
+
assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-large"]').length, 1)
|
|
2173
|
+
assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-compacted"]').length, 1)
|
|
2174
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '1–500 of 502')
|
|
2175
|
+
|
|
2176
|
+
dom.window.document.querySelector('[data-page="next"]').click()
|
|
2177
|
+
assert.strictEqual(rows().length, 2)
|
|
2178
|
+
assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-large"]').length, 0)
|
|
2179
|
+
assert.strictEqual(dom.window.document.querySelectorAll('[data-undo="batch-compacted"]').length, 0)
|
|
2180
|
+
assert.strictEqual(dom.window.document.querySelector('.vault-page-range').textContent, '501–502 of 502')
|
|
2181
|
+
dom.window.close()
|
|
2182
|
+
})
|
|
2183
|
+
|
|
1856
2184
|
test('large file lists render bounded pages without narrowing Deduplicate all', async () => {
|
|
1857
2185
|
const views = path.resolve(__dirname, '..', 'server', 'views')
|
|
1858
2186
|
const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
|
|
@@ -2148,6 +2476,64 @@ describe('vault dashboard backend (phase 4)', () => {
|
|
|
2148
2476
|
dom.window.close()
|
|
2149
2477
|
})
|
|
2150
2478
|
|
|
2479
|
+
test('external file rows and expanded matches show the full target path', async () => {
|
|
2480
|
+
const views = path.resolve(__dirname, '..', 'server', 'views')
|
|
2481
|
+
const html = await ejs.renderFile(path.resolve(views, 'vault_app.ejs'), {
|
|
2482
|
+
theme: 'light', platform: 'darwin', agent: 'electron', scope_id: 'external:api'
|
|
2483
|
+
})
|
|
2484
|
+
const dom = new JSDOM(html, { url: 'http://localhost/vault/app/external%3Aapi', runScripts: 'dangerously' })
|
|
2485
|
+
const targetRoot = '/Volumes/Models/api'
|
|
2486
|
+
const relativePath = 'ideogram/app/comfy_models/model.safetensors'
|
|
2487
|
+
const externalPath = `${targetRoot}/${relativePath}`
|
|
2488
|
+
const status = {
|
|
2489
|
+
enabled: true,
|
|
2490
|
+
mode: 'link',
|
|
2491
|
+
scan: { active: false, pending: false, queued: 0, phase: 'idle', scope_id: null },
|
|
2492
|
+
last_scan: { ts: Date.now(), bytes_total: 4096 },
|
|
2493
|
+
tracked_bytes: 4096,
|
|
2494
|
+
effective_bytes: 2048,
|
|
2495
|
+
shared_bytes: 4096,
|
|
2496
|
+
pending_bytes: 0,
|
|
2497
|
+
activity_error: null,
|
|
2498
|
+
cloud_sync_warning: null,
|
|
2499
|
+
sources: [{
|
|
2500
|
+
id: 'external:api', kind: 'external', label: 'api', root: targetRoot,
|
|
2501
|
+
display_path: '/pinokio/vault/sources/api', target_path: targetRoot,
|
|
2502
|
+
parent_id: null, available: true, shareable: true
|
|
2503
|
+
}],
|
|
2504
|
+
blobs: [{
|
|
2505
|
+
hash: 'a'.repeat(64), size: 4096, orphan: false, nlink: 3,
|
|
2506
|
+
names: [
|
|
2507
|
+
{
|
|
2508
|
+
path: externalPath, relative_path: relativePath,
|
|
2509
|
+
source_id: 'external:api', source_label: 'api', mode: 'link'
|
|
2510
|
+
},
|
|
2511
|
+
{
|
|
2512
|
+
path: `/pinokio/api/appA/${relativePath}`, relative_path: relativePath,
|
|
2513
|
+
source_id: 'app:appA', source_label: 'appA', mode: 'link'
|
|
2514
|
+
}
|
|
2515
|
+
]
|
|
2516
|
+
}],
|
|
2517
|
+
duplicates: [], excluded: [], events: [], undo_batches: []
|
|
2518
|
+
}
|
|
2519
|
+
dom.window.fetch = async () => ({ ok: true, json: async () => status })
|
|
2520
|
+
await runVaultScript(dom)
|
|
2521
|
+
await new Promise((resolve) => setTimeout(resolve, 25))
|
|
2522
|
+
|
|
2523
|
+
dom.window.document.querySelector('[data-view="shared"]').click()
|
|
2524
|
+
assert.strictEqual(
|
|
2525
|
+
dom.window.document.querySelector('.vault-flat-location').textContent,
|
|
2526
|
+
externalPath
|
|
2527
|
+
)
|
|
2528
|
+
dom.window.document.querySelector('[data-expand-file]').click()
|
|
2529
|
+
assert.deepStrictEqual(
|
|
2530
|
+
[...dom.window.document.querySelectorAll('.vault-location-detail span')]
|
|
2531
|
+
.map((node) => node.textContent),
|
|
2532
|
+
[externalPath, `appA / ${relativePath}`]
|
|
2533
|
+
)
|
|
2534
|
+
dom.window.close()
|
|
2535
|
+
})
|
|
2536
|
+
|
|
2151
2537
|
test('vault route provisions the dev requirements needed by its folder picker', async () => {
|
|
2152
2538
|
const serverSource = await fs.promises.readFile(path.resolve(__dirname, '..', 'server', 'index.js'), 'utf8')
|
|
2153
2539
|
const routeStart = serverSource.indexOf('this.app.get("/vault"')
|