pinokiod 8.0.105 → 8.0.106
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 +27 -10
- package/kernel/vault/registry_core.js +9 -0
- package/package.json +1 -1
- package/server/public/vault.css +58 -1
- package/server/public/vault.js +117 -19
- package/test/vault-engine.test.js +6 -7
- package/test/vault-ui.test.js +57 -6
package/kernel/vault/index.js
CHANGED
|
@@ -44,6 +44,12 @@ const AUTOMATIC_ACTIONS = new Set([
|
|
|
44
44
|
])
|
|
45
45
|
const PERMISSION_DENIED_CODES = new Set(["EACCES", "EPERM"])
|
|
46
46
|
const BUSY_CODES = new Set(["EBUSY"])
|
|
47
|
+
const PUBLIC_FILE_STATUS = {
|
|
48
|
+
reference: "tracked",
|
|
49
|
+
duplicate: "duplicate",
|
|
50
|
+
unavailable: "unavailable",
|
|
51
|
+
linked: "shared"
|
|
52
|
+
}
|
|
47
53
|
const hardlinkUnavailableCode = (code) =>
|
|
48
54
|
HARDLINK_UNSUPPORTED_CODES.has(code) || PERMISSION_DENIED_CODES.has(code)
|
|
49
55
|
const replacementLockedCode = (code) =>
|
|
@@ -197,7 +203,16 @@ const revealInFileManager = (filePath, platform = process.platform) =>
|
|
|
197
203
|
execFile(command, args, {
|
|
198
204
|
timeout: 10000,
|
|
199
205
|
windowsHide: true
|
|
200
|
-
}, (error) =>
|
|
206
|
+
}, (error) => {
|
|
207
|
+
// Explorer reports a non-zero exit code even when it opens the folder,
|
|
208
|
+
// so an exit status alone is not a failure on Windows. A missing binary
|
|
209
|
+
// or a refused spawn still is.
|
|
210
|
+
if (error && platform === "win32" && Number.isInteger(error.code)) {
|
|
211
|
+
resolve()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
error ? reject(error) : resolve()
|
|
215
|
+
})
|
|
201
216
|
})
|
|
202
217
|
|
|
203
218
|
class Vault {
|
|
@@ -1359,8 +1374,10 @@ class Vault {
|
|
|
1359
1374
|
if (!entry || entry.unavailable_reason === "stale") {
|
|
1360
1375
|
return { error: "This file is no longer tracked. Scan again to refresh this view." }
|
|
1361
1376
|
}
|
|
1377
|
+
// Opening a file manager mutates nothing, so it is allowed for any tracked
|
|
1378
|
+
// path regardless of the scope the request came from.
|
|
1362
1379
|
const source = this.sourceForPath(entry.path, entry.source_id)
|
|
1363
|
-
if (!source
|
|
1380
|
+
if (!source) {
|
|
1364
1381
|
return { error: "This file is outside the current location." }
|
|
1365
1382
|
}
|
|
1366
1383
|
if (!await this.canonicalPathIsWithinSource(entry.path, source)) {
|
|
@@ -1373,7 +1390,10 @@ class Vault {
|
|
|
1373
1390
|
try {
|
|
1374
1391
|
await this.fileManagerLauncher(entry.path)
|
|
1375
1392
|
} catch (error) {
|
|
1376
|
-
|
|
1393
|
+
// Naming the underlying failure is what makes a report actionable; the
|
|
1394
|
+
// generic sentence hid whether the spawn, the binary, or the path failed.
|
|
1395
|
+
const detail = error && error.message ? ` (${error.message})` : ""
|
|
1396
|
+
return { error: `The file manager could not open this file.${detail}` }
|
|
1377
1397
|
}
|
|
1378
1398
|
return { revealed: true }
|
|
1379
1399
|
}
|
|
@@ -3253,12 +3273,7 @@ class Vault {
|
|
|
3253
3273
|
dev: match.dev,
|
|
3254
3274
|
ino: match.ino
|
|
3255
3275
|
}, this.locationForPath(match.path, match.source_id)))
|
|
3256
|
-
const publicStatus =
|
|
3257
|
-
reference: "tracked",
|
|
3258
|
-
duplicate: "duplicate",
|
|
3259
|
-
unavailable: "unavailable",
|
|
3260
|
-
linked: "shared"
|
|
3261
|
-
}[row.status]
|
|
3276
|
+
const publicStatus = PUBLIC_FILE_STATUS[row.status]
|
|
3262
3277
|
const result = Object.assign({
|
|
3263
3278
|
path: row.path,
|
|
3264
3279
|
hash: row.hash,
|
|
@@ -3388,9 +3403,11 @@ class Vault {
|
|
|
3388
3403
|
return {
|
|
3389
3404
|
path: target,
|
|
3390
3405
|
items: (result.rows || []).map((row) => Object.assign({
|
|
3391
|
-
path: row.path
|
|
3406
|
+
path: row.path,
|
|
3407
|
+
status: PUBLIC_FILE_STATUS[row.status] || null
|
|
3392
3408
|
}, this.locationForPath(row.path, row.source_id))),
|
|
3393
3409
|
total: Number(result.total) || 0,
|
|
3410
|
+
deduplicatable: Number(result.duplicateTotal) || 0,
|
|
3394
3411
|
next_cursor: result.nextCursor || null
|
|
3395
3412
|
}
|
|
3396
3413
|
}
|
|
@@ -5691,9 +5691,18 @@ class RegistryCore {
|
|
|
5691
5691
|
WHERE ${identityWhere}
|
|
5692
5692
|
AND ${authorized.where.join(" AND ")}
|
|
5693
5693
|
`).get(...identityValues, ...authorized.values).count) || 0
|
|
5694
|
+
// Only paths still storing their own copy can free anything.
|
|
5695
|
+
const duplicateTotal = Number(this.database.prepare(`
|
|
5696
|
+
SELECT COUNT(*) AS count
|
|
5697
|
+
FROM files child
|
|
5698
|
+
WHERE ${identityWhere}
|
|
5699
|
+
AND child.status = 'duplicate'
|
|
5700
|
+
AND ${authorized.where.join(" AND ")}
|
|
5701
|
+
`).get(...identityValues, ...authorized.values).count) || 0
|
|
5694
5702
|
return {
|
|
5695
5703
|
rows,
|
|
5696
5704
|
total,
|
|
5705
|
+
duplicateTotal,
|
|
5697
5706
|
nextCursor: hasMore && last
|
|
5698
5707
|
? this.encodeCursor({
|
|
5699
5708
|
sort: "file-locations",
|
package/package.json
CHANGED
package/server/public/vault.css
CHANGED
|
@@ -964,6 +964,32 @@ body[data-vault-mode="global"] .vault-explorer {
|
|
|
964
964
|
outline: 2px solid color-mix(in srgb, var(--task-accent) 55%, transparent);
|
|
965
965
|
outline-offset: 1px;
|
|
966
966
|
}
|
|
967
|
+
.vault-confirm-overlay {
|
|
968
|
+
position: fixed;
|
|
969
|
+
z-index: 140;
|
|
970
|
+
inset: 0;
|
|
971
|
+
display: grid;
|
|
972
|
+
place-items: center;
|
|
973
|
+
padding: 24px;
|
|
974
|
+
background: color-mix(in srgb, var(--task-text) 34%, transparent);
|
|
975
|
+
}
|
|
976
|
+
.vault-confirm-overlay[hidden] { display: none; }
|
|
977
|
+
.vault-confirm-dialog {
|
|
978
|
+
width: min(420px, 100%);
|
|
979
|
+
padding: 18px 20px 16px;
|
|
980
|
+
border: 1px solid var(--task-border-strong);
|
|
981
|
+
border-radius: 10px;
|
|
982
|
+
background: var(--task-panel);
|
|
983
|
+
box-shadow: 0 22px 70px color-mix(in srgb, var(--task-text) 24%, transparent);
|
|
984
|
+
}
|
|
985
|
+
.vault-confirm-message {
|
|
986
|
+
margin: 0 0 16px;
|
|
987
|
+
color: var(--task-text);
|
|
988
|
+
font-size: 12.5px;
|
|
989
|
+
line-height: 1.5;
|
|
990
|
+
}
|
|
991
|
+
.vault-confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
992
|
+
|
|
967
993
|
.vault-find-overlay {
|
|
968
994
|
position: fixed;
|
|
969
995
|
z-index: 120;
|
|
@@ -2043,9 +2069,40 @@ body.dark .vault-row-action .vault-text-button:hover:not(:disabled) {
|
|
|
2043
2069
|
background: color-mix(in srgb, var(--task-soft) 48%, transparent);
|
|
2044
2070
|
}
|
|
2045
2071
|
.vault-detail-label { margin-bottom: 5px; color: var(--task-muted); font-size: 10px; font-weight: 650; }
|
|
2046
|
-
.vault-location-detail { display: flex; align-items: center; gap: 7px;
|
|
2072
|
+
.vault-location-detail { display: flex; align-items: center; gap: 7px; min-height: 26px; color: var(--task-text); font-size: 10.5px; }
|
|
2047
2073
|
.vault-location-detail i { color: var(--task-muted); }
|
|
2048
2074
|
.vault-location-detail > span { min-width: 0; flex: 1; overflow-wrap: anywhere; }
|
|
2075
|
+
.vault-location-detail > .vault-location-path {
|
|
2076
|
+
min-width: 0;
|
|
2077
|
+
flex: 1;
|
|
2078
|
+
white-space: nowrap;
|
|
2079
|
+
overflow: hidden;
|
|
2080
|
+
text-overflow: ellipsis;
|
|
2081
|
+
}
|
|
2082
|
+
.vault-location-detail > .vault-location-note {
|
|
2083
|
+
flex: 0 0 auto;
|
|
2084
|
+
margin-left: auto;
|
|
2085
|
+
padding-left: 12px;
|
|
2086
|
+
color: var(--task-muted);
|
|
2087
|
+
white-space: nowrap;
|
|
2088
|
+
}
|
|
2089
|
+
.vault-location-detail > .vault-location-reveal-placeholder { flex: 0 0 26px; }
|
|
2090
|
+
.vault-location-link {
|
|
2091
|
+
appearance: none;
|
|
2092
|
+
padding: 0;
|
|
2093
|
+
border: 0;
|
|
2094
|
+
background: none;
|
|
2095
|
+
color: var(--task-accent);
|
|
2096
|
+
font: inherit;
|
|
2097
|
+
text-align: left;
|
|
2098
|
+
cursor: pointer;
|
|
2099
|
+
overflow: hidden;
|
|
2100
|
+
text-overflow: ellipsis;
|
|
2101
|
+
white-space: nowrap;
|
|
2102
|
+
max-width: 100%;
|
|
2103
|
+
}
|
|
2104
|
+
.vault-location-link:hover { text-decoration: underline; }
|
|
2105
|
+
.vault-location-link i { color: inherit; font-size: 9px; margin-left: 5px; }
|
|
2049
2106
|
.vault-duplicate-content-group {
|
|
2050
2107
|
min-height: 52px;
|
|
2051
2108
|
background: color-mix(in srgb, var(--task-soft) 28%, transparent);
|
package/server/public/vault.js
CHANGED
|
@@ -237,6 +237,9 @@ const COPY = {
|
|
|
237
237
|
loading_copies: "Loading copies…",
|
|
238
238
|
show_more_copies: "Show {count} more copies",
|
|
239
239
|
loading_locations: "Loading locations…",
|
|
240
|
+
open_in_disk_saver: "Open this file in Disk Saver",
|
|
241
|
+
open_location_confirm: "Open Disk Saver for {location}?",
|
|
242
|
+
open_location_accept: "Open",
|
|
240
243
|
show_more_locations: "Show {count} more locations",
|
|
241
244
|
making_separate: "Making file separate",
|
|
242
245
|
make_separate: "Make separate",
|
|
@@ -258,7 +261,12 @@ const COPY = {
|
|
|
258
261
|
separation_cancelled: "Separation cancelled after {count}.",
|
|
259
262
|
select_for_separation: "Select to make separate",
|
|
260
263
|
select_all_on_page: "Select all on this page",
|
|
261
|
-
|
|
264
|
+
stored_times: "Stored {count} times · {size} each",
|
|
265
|
+
this_file: "this file",
|
|
266
|
+
copy_that_stays: "the copy that stays",
|
|
267
|
+
already_deduplicated: "already deduplicated",
|
|
268
|
+
not_deduplicated_yet: "not deduplicated yet",
|
|
269
|
+
cannot_be_deduplicated: "cannot be deduplicated",
|
|
262
270
|
no_files: "No files found",
|
|
263
271
|
no_files_hint: "Run a scan to find files that can be deduplicated. Scanning never links files together or replaces them.",
|
|
264
272
|
scan_waiting: "Waiting for scan results",
|
|
@@ -290,6 +298,15 @@ const COPY = {
|
|
|
290
298
|
}
|
|
291
299
|
|
|
292
300
|
const SCOPE_ID = document.body.dataset.vaultScope || null
|
|
301
|
+
const FOCUS_GROUP = (() => {
|
|
302
|
+
const value = new URLSearchParams(window.location.search).get("group")
|
|
303
|
+
return typeof value === "string" && /^[a-f0-9]{64}$/.test(value.toLowerCase())
|
|
304
|
+
? value.toLowerCase()
|
|
305
|
+
: null
|
|
306
|
+
})()
|
|
307
|
+
// A scoped page hands one content group off to the global page, which opens
|
|
308
|
+
// on it once its first page of duplicate results arrives.
|
|
309
|
+
let focusGroupPending = !!FOCUS_GROUP
|
|
293
310
|
const IS_APP_MODE = document.body.dataset.vaultMode === "app" && !!SCOPE_ID
|
|
294
311
|
const APP_NAME = IS_APP_MODE ? document.body.dataset.vaultApp || "" : ""
|
|
295
312
|
const HOME_PATH = document.body.dataset.vaultHome || ""
|
|
@@ -410,12 +427,12 @@ const state = {
|
|
|
410
427
|
candidateSize: defaultCandidateSize,
|
|
411
428
|
persistedCandidateSize: defaultCandidateSize,
|
|
412
429
|
candidateSizeInitialized: false,
|
|
413
|
-
view: "all",
|
|
430
|
+
view: FOCUS_GROUP ? "duplicates" : "all",
|
|
414
431
|
sourceId: SCOPE_ID,
|
|
415
432
|
query: "",
|
|
416
433
|
statusFilter: "all",
|
|
417
|
-
displayMode: "folders",
|
|
418
|
-
sizeSort: null,
|
|
434
|
+
displayMode: FOCUS_GROUP ? "files" : "folders",
|
|
435
|
+
sizeSort: FOCUS_GROUP ? "desc" : null,
|
|
419
436
|
collapsedSources: new Set(),
|
|
420
437
|
collapsedDirs: new Set(),
|
|
421
438
|
expandedFiles: new Set(),
|
|
@@ -609,9 +626,59 @@ const saveCandidateSize = (size) => {
|
|
|
609
626
|
})
|
|
610
627
|
return candidateSizeSaveTail
|
|
611
628
|
}
|
|
629
|
+
// Small confirm dialog in the workspace's own visual language, so leaving the
|
|
630
|
+
// page never happens through a native browser prompt.
|
|
631
|
+
const confirmLeave = (message, confirmLabel) => new Promise((resolve) => {
|
|
632
|
+
const overlay = document.createElement("div")
|
|
633
|
+
overlay.className = "vault-confirm-overlay"
|
|
634
|
+
overlay.innerHTML = `<section class="vault-confirm-dialog" role="dialog" aria-modal="true"><p class="vault-confirm-message"></p><div class="vault-confirm-actions"><button class="vault-button" type="button" data-confirm-cancel>${esc(COPY.cancel)}</button><button class="vault-button primary" type="button" data-confirm-accept>${esc(confirmLabel)}</button></div></section>`
|
|
635
|
+
overlay.querySelector(".vault-confirm-message").textContent = message
|
|
636
|
+
const settle = (value) => {
|
|
637
|
+
document.removeEventListener("keydown", onKey, true)
|
|
638
|
+
overlay.remove()
|
|
639
|
+
resolve(value)
|
|
640
|
+
}
|
|
641
|
+
const onKey = (event) => {
|
|
642
|
+
if (event.key !== "Escape") return
|
|
643
|
+
event.preventDefault()
|
|
644
|
+
event.stopPropagation()
|
|
645
|
+
settle(false)
|
|
646
|
+
}
|
|
647
|
+
overlay.addEventListener("click", (event) => {
|
|
648
|
+
if (event.target === overlay) settle(false)
|
|
649
|
+
if (event.target.closest("[data-confirm-cancel]")) settle(false)
|
|
650
|
+
if (event.target.closest("[data-confirm-accept]")) settle(true)
|
|
651
|
+
})
|
|
652
|
+
document.addEventListener("keydown", onKey, true)
|
|
653
|
+
document.body.appendChild(overlay)
|
|
654
|
+
const accept = overlay.querySelector("[data-confirm-accept]")
|
|
655
|
+
if (accept) accept.focus()
|
|
656
|
+
})
|
|
657
|
+
|
|
612
658
|
const openGlobalWorkspace = () => {
|
|
613
659
|
window.parent.location.assign("/vault")
|
|
614
660
|
}
|
|
661
|
+
// Opens a new tab rather than navigating: this workspace can be embedded in an
|
|
662
|
+
// app page, where navigating would either nest a second copy of the interface
|
|
663
|
+
// or drop the user out of the app they were looking at.
|
|
664
|
+
// A copy that belongs to another app opens that app's own workspace, where it
|
|
665
|
+
// can actually be acted on. Anything else — caches, external folders — has no
|
|
666
|
+
// app page, so it opens the global one. Either way the user is leaving this
|
|
667
|
+
// page, so it is confirmed first and the parent window is replaced rather than
|
|
668
|
+
// nesting a second copy of the interface inside this frame.
|
|
669
|
+
const openContentGroup = async (group, kind, label) => {
|
|
670
|
+
// The label and kind travel with the row: an app workspace cannot look up
|
|
671
|
+
// sources belonging to other apps, so a lookup here would name every one of
|
|
672
|
+
// them "unknown" and send them all to the global page.
|
|
673
|
+
const app = kind === "app" && label ? label : null
|
|
674
|
+
const name = label || COPY.unknown_location
|
|
675
|
+
const accepted = await confirmLeave(
|
|
676
|
+
COPY.open_location_confirm.replace("{location}", name),
|
|
677
|
+
COPY.open_location_accept)
|
|
678
|
+
if (!accepted) return
|
|
679
|
+
const base = app ? `/vault/app/${encodeURIComponent(app)}` : "/vault"
|
|
680
|
+
window.parent.location.assign(`${base}?group=${encodeURIComponent(group)}`)
|
|
681
|
+
}
|
|
615
682
|
const applyAutomaticScanSnapshot = (snapshot) => {
|
|
616
683
|
if (!IS_APP_MODE || !AUTOMATIC_SUPPORTED) return
|
|
617
684
|
const settings = snapshot && Array.isArray(snapshot.settings)
|
|
@@ -703,24 +770,15 @@ const onAutomaticScanMessage = (event) => {
|
|
|
703
770
|
}
|
|
704
771
|
const sourceById = (id) => (state.data.sources || []).find((source) => source.id === id)
|
|
705
772
|
const sourceChildren = (id) => (state.data.sources || []).filter((source) => source.parent_id === id)
|
|
706
|
-
const sourceIsWithinScope = (sourceId) => {
|
|
707
|
-
if (!SCOPE_ID) return true
|
|
708
|
-
const seen = new Set()
|
|
709
|
-
let source = sourceById(sourceId)
|
|
710
|
-
while (source && !seen.has(source.id)) {
|
|
711
|
-
if (source.id === SCOPE_ID) return true
|
|
712
|
-
seen.add(source.id)
|
|
713
|
-
source = sourceById(source.parent_id)
|
|
714
|
-
}
|
|
715
|
-
return false
|
|
716
|
-
}
|
|
717
773
|
const revealLabel = document.body.dataset.platform === "darwin"
|
|
718
774
|
? COPY.show_in_finder
|
|
719
775
|
: document.body.dataset.platform === "win32"
|
|
720
776
|
? COPY.show_in_file_explorer
|
|
721
777
|
: COPY.open_containing_folder
|
|
778
|
+
// Revealing changes nothing, so it is offered for every tracked path, not
|
|
779
|
+
// only the ones the current scope can act on.
|
|
722
780
|
const revealButton = (filePath, sourceId, name) => {
|
|
723
|
-
if (!filePath
|
|
781
|
+
if (!filePath) return ""
|
|
724
782
|
const label = `${revealLabel}: ${name || basename(filePath)}`
|
|
725
783
|
return `<button class="vault-reveal-button" type="button" data-reveal-file="${attr(filePath)}" aria-label="${attr(label)}" title="${attr(revealLabel)}"><i class="fa-regular fa-folder-open" aria-hidden="true"></i></button>`
|
|
726
784
|
}
|
|
@@ -1720,13 +1778,37 @@ const fileDetail = (item) => {
|
|
|
1720
1778
|
return `<div class="vault-detail"><div class="vault-detail-label error">${esc(locations.error)}</div></div>`
|
|
1721
1779
|
}
|
|
1722
1780
|
const total = Math.max(locations.items.length, Number(locations.total) || 0)
|
|
1723
|
-
|
|
1781
|
+
// Copies are byte-identical, so the size is stated once for the group.
|
|
1782
|
+
const label = COPY.stored_times
|
|
1783
|
+
.replace("{count}", total)
|
|
1784
|
+
.replace("{size}", fmt(item.size))
|
|
1724
1785
|
const remaining = Math.max(0, total - locations.items.length)
|
|
1725
1786
|
const more = locations.nextCursor
|
|
1726
1787
|
? `<div class="vault-location-detail"><button class="vault-text-button" type="button" data-more-file-locations="${attr(item.path)}" ${locations.loading ? "disabled" : ""}>${esc(COPY.show_more_locations.replace("{count}", Math.min(remaining, DUPLICATE_CHILD_PAGE_SIZE)))}</button></div>`
|
|
1727
1788
|
: ""
|
|
1728
|
-
|
|
1729
|
-
|
|
1789
|
+
const body = locations.items.map((location) => {
|
|
1790
|
+
const where = externalLocation(location) ||
|
|
1791
|
+
[location.source_label, location.relative_path].filter(Boolean).join(" / ")
|
|
1792
|
+
const note = location.path === item.path
|
|
1793
|
+
? COPY.this_file
|
|
1794
|
+
: location.status === "tracked"
|
|
1795
|
+
? COPY.copy_that_stays
|
|
1796
|
+
: location.status === "shared"
|
|
1797
|
+
? COPY.already_deduplicated
|
|
1798
|
+
: location.status === "unavailable"
|
|
1799
|
+
? COPY.cannot_be_deduplicated
|
|
1800
|
+
: COPY.not_deduplicated_yet
|
|
1801
|
+
const outside = SCOPE_ID && item.hash && location.source_id !== SCOPE_ID
|
|
1802
|
+
const text = outside
|
|
1803
|
+
? `<button class="vault-location-link" type="button" data-open-group="${attr(item.hash)}" data-open-kind="${attr(location.source_kind || "")}" data-open-label="${attr(location.source_label || "")}" title="${attr(COPY.open_in_disk_saver)}">${esc(where)}</button>`
|
|
1804
|
+
: esc(where)
|
|
1805
|
+
// Rows keep identical slots whether or not a reveal button exists, so the
|
|
1806
|
+
// notes form a column and every row is the same height.
|
|
1807
|
+
const reveal = revealButton(
|
|
1808
|
+
location.path, location.source_id, basename(location.relative_path))
|
|
1809
|
+
return `<div class="vault-location-detail"><i class="fa-regular fa-file"></i><span class="vault-location-path" title="${attr(where)}">${text}</span><span class="vault-location-note">${esc(note)}</span>${reveal || `<span class="vault-location-reveal-placeholder"></span>`}</div>`
|
|
1810
|
+
}).join("")
|
|
1811
|
+
return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${body}${more}</div>`
|
|
1730
1812
|
}
|
|
1731
1813
|
const separateCheckbox = (item) => item.status === "shared"
|
|
1732
1814
|
? `<input class="vault-row-checkbox" type="checkbox" data-select-separate="${attr(item.path)}" aria-label="${attr(`${COPY.select_for_separation}: ${basename(item.relative_path)}`)}" ${state.separateAllMatching || state.selectedSeparateFiles.has(item.path) ? "checked" : ""} />`
|
|
@@ -2886,6 +2968,9 @@ const loadPagedChildren = async (cache, key, buildUrl, append) => {
|
|
|
2886
2968
|
const items = Array.isArray(result.items) ? result.items : []
|
|
2887
2969
|
current.items = append ? current.items.concat(items) : items
|
|
2888
2970
|
current.total = Math.max(0, Number(result.total) || 0)
|
|
2971
|
+
// Location lists carry how many copies still cost space; group children
|
|
2972
|
+
// do not, and read as zero.
|
|
2973
|
+
current.deduplicatable = Math.max(0, Number(result.deduplicatable) || 0)
|
|
2889
2974
|
current.nextCursor = result.next_cursor || null
|
|
2890
2975
|
current.loaded = true
|
|
2891
2976
|
} catch (error) {
|
|
@@ -2942,6 +3027,12 @@ const applyFullData = (data) => {
|
|
|
2942
3027
|
reviewedScan() !== String(data.last_scan.ts) &&
|
|
2943
3028
|
(shareableDuplicateCount > 0 || data.last_scan.partial)
|
|
2944
3029
|
settleFolderDiscoveryStart()
|
|
3030
|
+
if (focusGroupPending &&
|
|
3031
|
+
data.inventory && data.inventory.view === "duplicates") {
|
|
3032
|
+
focusGroupPending = false
|
|
3033
|
+
state.expandedDuplicateGroups.add(FOCUS_GROUP)
|
|
3034
|
+
loadDuplicateGroupChildren(FOCUS_GROUP)
|
|
3035
|
+
}
|
|
2945
3036
|
const publishedScan = data.last_scan ? data.last_scan.ts : null
|
|
2946
3037
|
if (publishedScan !== state.locationsScanTs) {
|
|
2947
3038
|
state.locationsScanTs = publishedScan
|
|
@@ -3400,6 +3491,13 @@ const closeScanSizeMenu = () => {
|
|
|
3400
3491
|
document.addEventListener("click", async (event) => {
|
|
3401
3492
|
const target = event.target.closest("button")
|
|
3402
3493
|
if (!target) return
|
|
3494
|
+
if (target.dataset.openGroup) {
|
|
3495
|
+
await openContentGroup(
|
|
3496
|
+
target.dataset.openGroup,
|
|
3497
|
+
target.dataset.openKind,
|
|
3498
|
+
target.dataset.openLabel)
|
|
3499
|
+
return
|
|
3500
|
+
}
|
|
3403
3501
|
if (target.hasAttribute("data-dismiss-automatic-scan-coachmark")) {
|
|
3404
3502
|
dismissAutomaticScanCoachmark(true)
|
|
3405
3503
|
return
|
|
@@ -1553,7 +1553,7 @@ describe("Save Space engine", () => {
|
|
|
1553
1553
|
await close(vault)
|
|
1554
1554
|
})
|
|
1555
1555
|
|
|
1556
|
-
test("revealing
|
|
1556
|
+
test("revealing works for any tracked path, whatever the scope", async () => {
|
|
1557
1557
|
const { home, vault } = await makeVault()
|
|
1558
1558
|
const pair = await duplicatePair(home, 'model "quoted".bin')
|
|
1559
1559
|
await vault.sweeper.scan()
|
|
@@ -1571,19 +1571,18 @@ describe("Save Space engine", () => {
|
|
|
1571
1571
|
}), { revealed: true })
|
|
1572
1572
|
assert.deepEqual(launched, [pair.first])
|
|
1573
1573
|
|
|
1574
|
-
|
|
1574
|
+
assert.notEqual(first.source_id, second.source_id)
|
|
1575
|
+
assert.deepEqual(await vault.perform("reveal", {
|
|
1575
1576
|
scope_id: first.source_id,
|
|
1576
1577
|
path: pair.second
|
|
1577
|
-
})
|
|
1578
|
-
assert.
|
|
1579
|
-
assert.notEqual(first.source_id, second.source_id)
|
|
1580
|
-
assert.deepEqual(launched, [pair.first])
|
|
1578
|
+
}), { revealed: true })
|
|
1579
|
+
assert.deepEqual(launched, [pair.first, pair.second])
|
|
1581
1580
|
|
|
1582
1581
|
const untracked = await vault.perform("reveal", {
|
|
1583
1582
|
path: path.join(home, "api", "first", "missing.bin")
|
|
1584
1583
|
})
|
|
1585
1584
|
assert.match(untracked.error, /no longer tracked/i)
|
|
1586
|
-
assert.deepEqual(launched, [pair.first])
|
|
1585
|
+
assert.deepEqual(launched, [pair.first, pair.second])
|
|
1587
1586
|
|
|
1588
1587
|
await close(vault)
|
|
1589
1588
|
})
|
package/test/vault-ui.test.js
CHANGED
|
@@ -160,7 +160,9 @@ const makePage = async (status, options = {}) => {
|
|
|
160
160
|
runScripts: "outside-only",
|
|
161
161
|
url: appMode
|
|
162
162
|
? "http://localhost/vault/app/app"
|
|
163
|
-
:
|
|
163
|
+
: `http://localhost/vault${options.focusGroup
|
|
164
|
+
? `?group=${options.focusGroup}`
|
|
165
|
+
: ""}`
|
|
164
166
|
}
|
|
165
167
|
)
|
|
166
168
|
const requests = []
|
|
@@ -811,13 +813,19 @@ describe("Save Space interface", () => {
|
|
|
811
813
|
if (parsed.searchParams.get("locations_path")) {
|
|
812
814
|
return {
|
|
813
815
|
path: parsed.searchParams.get("locations_path"),
|
|
814
|
-
items: [
|
|
816
|
+
items: [
|
|
817
|
+
["duplicate", "duplicate"],
|
|
818
|
+
["first", "tracked"],
|
|
819
|
+
["third", "shared"]
|
|
820
|
+
].map(([name, status]) => ({
|
|
815
821
|
path: `/pinokio/api/app/models/${name}.bin`,
|
|
822
|
+
status,
|
|
816
823
|
source_id: "app:app",
|
|
817
824
|
source_label: "app",
|
|
818
825
|
relative_path: `models/${name}.bin`
|
|
819
826
|
})),
|
|
820
827
|
total: 3,
|
|
828
|
+
deduplicatable: 1,
|
|
821
829
|
next_cursor: null
|
|
822
830
|
}
|
|
823
831
|
}
|
|
@@ -839,13 +847,18 @@ describe("Save Space interface", () => {
|
|
|
839
847
|
await waitFor(() => getRequests.some((url) =>
|
|
840
848
|
new URL(url, "http://localhost").searchParams
|
|
841
849
|
.get("locations_path") === "/pinokio/api/app/models/duplicate.bin"))
|
|
842
|
-
await waitFor(() => document.querySelector(".vault-
|
|
843
|
-
document.querySelector(".vault-detail").textContent.includes("third.bin"))
|
|
850
|
+
await waitFor(() => document.querySelector(".vault-location-path"))
|
|
844
851
|
const detail = document.querySelector(".vault-detail")
|
|
845
|
-
assert.match(detail.textContent, /
|
|
846
|
-
assert.match(detail.textContent, /first\.bin/)
|
|
852
|
+
assert.match(detail.textContent, /Stored 3 times · 4\.1 KB each/)
|
|
847
853
|
assert.doesNotMatch(detail.textContent, /sample\.bin/)
|
|
848
854
|
assert.doesNotMatch(detail.textContent, /of 3 locations shown/)
|
|
855
|
+
const notes = [...detail.querySelectorAll(".vault-location-note")]
|
|
856
|
+
.map((node) => node.textContent)
|
|
857
|
+
assert.deepEqual(notes, [
|
|
858
|
+
"this file",
|
|
859
|
+
"the copy that stays",
|
|
860
|
+
"already deduplicated"
|
|
861
|
+
])
|
|
849
862
|
await settle()
|
|
850
863
|
dom.window.close()
|
|
851
864
|
})
|
|
@@ -888,6 +901,44 @@ describe("Save Space interface", () => {
|
|
|
888
901
|
dom.window.close()
|
|
889
902
|
})
|
|
890
903
|
|
|
904
|
+
test("a scoped page hands one content group to the global page", async () => {
|
|
905
|
+
const hash = "c".repeat(64)
|
|
906
|
+
const duplicate = item({
|
|
907
|
+
path: "/pinokio/api/app/models/duplicate.bin",
|
|
908
|
+
relative_path: "models/duplicate.bin",
|
|
909
|
+
status: "duplicate",
|
|
910
|
+
shareable: true,
|
|
911
|
+
hash,
|
|
912
|
+
location_count: 3
|
|
913
|
+
})
|
|
914
|
+
const base = fixture([duplicate])
|
|
915
|
+
base.inventory.source_counts.duplicates["app:app"] = 1
|
|
916
|
+
base.inventory.shareable_by_source["app:app"] = 1
|
|
917
|
+
const response = (url) => {
|
|
918
|
+
const parsed = new URL(url, "http://localhost")
|
|
919
|
+
if (parsed.searchParams.get("group_hash")) {
|
|
920
|
+
return { hash, items: [], total: 3, next_cursor: null }
|
|
921
|
+
}
|
|
922
|
+
const result = JSON.parse(JSON.stringify(base))
|
|
923
|
+
result.inventory.view = parsed.searchParams.get("view") || "all"
|
|
924
|
+
result.items = [duplicate]
|
|
925
|
+
return result
|
|
926
|
+
}
|
|
927
|
+
const { dom, getRequests } = await makePage(response, {
|
|
928
|
+
focusGroup: hash
|
|
929
|
+
})
|
|
930
|
+
const document = dom.window.document
|
|
931
|
+
|
|
932
|
+
await waitFor(() => document.querySelector(
|
|
933
|
+
'[data-view="duplicates"].selected'))
|
|
934
|
+
assert.ok(document.querySelector('[data-display-mode="files"].selected'))
|
|
935
|
+
await waitFor(() => getRequests.some((url) =>
|
|
936
|
+
new URL(url, "http://localhost").searchParams
|
|
937
|
+
.get("group_hash") === hash))
|
|
938
|
+
await settle()
|
|
939
|
+
dom.window.close()
|
|
940
|
+
})
|
|
941
|
+
|
|
891
942
|
test("Cannot deduplicate is separate from actionable duplicates", async () => {
|
|
892
943
|
const duplicate = item({
|
|
893
944
|
path: "/pinokio/api/app/models/duplicate.bin",
|