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.
- package/kernel/vault/index.js +18 -0
- package/package.json +1 -1
- package/server/public/vault.css +25 -2
- package/server/public/vault.js +104 -44
- package/server/views/partials/vault_workspace.ejs +1 -0
- package/test/vault-engine.test.js +21 -0
- package/test/vault-ui.test.js +393 -11
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,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 {
|
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",
|
|
@@ -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
|
|
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: "
|
|
156
|
-
reclaim_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
|
|
170
|
-
no_reclaimable_hint: "
|
|
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
|
|
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: "
|
|
192
|
+
reclaimed: "Cleaned up",
|
|
183
193
|
event_convert: "Deduplicated",
|
|
184
194
|
event_found: "Duplicate found",
|
|
185
195
|
event_adopt: "Added",
|
|
186
|
-
event_reclaim: "
|
|
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:
|
|
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 =
|
|
402
|
-
|
|
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" ||
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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>`
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
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,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
|
|
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\(
|
|
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,
|
|
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: '
|
|
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, '
|
|
1476
|
-
assert.strictEqual(dom.window.document.querySelector('[data-reclaim]').textContent, '
|
|
1477
|
-
assert.match(dom.window.document.getElementById('vault-pane-footer').textContent,
|
|
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*"
|
|
1553
|
-
assert.match(source, /reclaim_all:\s*"
|
|
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"')
|