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