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.
@@ -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) => error ? reject(error) : resolve())
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 || !this.sourceIsWithinScope(source, scopeId)) {
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
- return { error: "The file manager could not open this file." }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.105",
3
+ "version": "8.0.106",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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; padding: 2px 0; color: var(--task-text); font-size: 10.5px; }
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);
@@ -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
- identical_contents_at: "Identical contents at",
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 || !sourceIsWithinScope(sourceId)) return ""
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
- const label = `${COPY.identical_contents_at} ${countLabel(total, COPY.location, COPY.locations_lower)}`
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
- return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${locations.items.map((location) => `
1729
- <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>${revealButton(location.path, location.source_id, basename(location.relative_path))}</div>`).join("")}${more}</div>`
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 a file is scoped to a current registered path", async () => {
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
- const outside = await vault.perform("reveal", {
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.match(outside.error, /outside the current location/i)
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
  })
@@ -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
- : "http://localhost/vault"
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: ["first", "second", "third"].map((name) => ({
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-detail") &&
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, /Identical contents at 3 locations/)
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",