pinokiod 8.0.105 → 8.0.107

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,7 +3403,8 @@ 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,
3394
3410
  next_cursor: result.nextCursor || null
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.105",
3
+ "version": "8.0.107",
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,56 @@ 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
+ // A copy that belongs to another app opens that app's own workspace, where it
662
+ // can actually be acted on; caches and external folders have no app page, so
663
+ // they open the global one. Accepting replaces the parent window, because this
664
+ // workspace can be embedded in an app page and navigating in place would nest a
665
+ // second copy of the interface inside that frame.
666
+ const openContentGroup = async (group, kind, label) => {
667
+ // The label and kind travel with the row: an app workspace cannot look up
668
+ // sources belonging to other apps, so a lookup here would name every one of
669
+ // them "unknown" and send them all to the global page.
670
+ const app = kind === "app" && label ? label : null
671
+ const name = label || COPY.unknown_location
672
+ const accepted = await confirmLeave(
673
+ COPY.open_location_confirm.replace("{location}", name),
674
+ COPY.open_location_accept)
675
+ if (!accepted) return
676
+ const base = app ? `/vault/app/${encodeURIComponent(app)}` : "/vault"
677
+ window.parent.location.assign(`${base}?group=${encodeURIComponent(group)}`)
678
+ }
615
679
  const applyAutomaticScanSnapshot = (snapshot) => {
616
680
  if (!IS_APP_MODE || !AUTOMATIC_SUPPORTED) return
617
681
  const settings = snapshot && Array.isArray(snapshot.settings)
@@ -703,24 +767,15 @@ const onAutomaticScanMessage = (event) => {
703
767
  }
704
768
  const sourceById = (id) => (state.data.sources || []).find((source) => source.id === id)
705
769
  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
770
  const revealLabel = document.body.dataset.platform === "darwin"
718
771
  ? COPY.show_in_finder
719
772
  : document.body.dataset.platform === "win32"
720
773
  ? COPY.show_in_file_explorer
721
774
  : COPY.open_containing_folder
775
+ // Revealing changes nothing, so it is offered for every tracked path, not
776
+ // only the ones the current scope can act on.
722
777
  const revealButton = (filePath, sourceId, name) => {
723
- if (!filePath || !sourceIsWithinScope(sourceId)) return ""
778
+ if (!filePath) return ""
724
779
  const label = `${revealLabel}: ${name || basename(filePath)}`
725
780
  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
781
  }
@@ -1720,13 +1775,37 @@ const fileDetail = (item) => {
1720
1775
  return `<div class="vault-detail"><div class="vault-detail-label error">${esc(locations.error)}</div></div>`
1721
1776
  }
1722
1777
  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)}`
1778
+ // Copies are byte-identical, so the size is stated once for the group.
1779
+ const label = COPY.stored_times
1780
+ .replace("{count}", total)
1781
+ .replace("{size}", fmt(item.size))
1724
1782
  const remaining = Math.max(0, total - locations.items.length)
1725
1783
  const more = locations.nextCursor
1726
1784
  ? `<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
1785
  : ""
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>`
1786
+ const body = locations.items.map((location) => {
1787
+ const where = externalLocation(location) ||
1788
+ [location.source_label, location.relative_path].filter(Boolean).join(" / ")
1789
+ const note = location.path === item.path
1790
+ ? COPY.this_file
1791
+ : location.status === "tracked"
1792
+ ? COPY.copy_that_stays
1793
+ : location.status === "shared"
1794
+ ? COPY.already_deduplicated
1795
+ : location.status === "unavailable"
1796
+ ? COPY.cannot_be_deduplicated
1797
+ : COPY.not_deduplicated_yet
1798
+ const outside = SCOPE_ID && item.hash && location.source_id !== SCOPE_ID
1799
+ const text = outside
1800
+ ? `<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>`
1801
+ : esc(where)
1802
+ // Rows keep identical slots whether or not a reveal button exists, so the
1803
+ // notes form a column and every row is the same height.
1804
+ const reveal = revealButton(
1805
+ location.path, location.source_id, basename(location.relative_path))
1806
+ 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>`
1807
+ }).join("")
1808
+ return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${body}${more}</div>`
1730
1809
  }
1731
1810
  const separateCheckbox = (item) => item.status === "shared"
1732
1811
  ? `<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" : ""} />`
@@ -2942,6 +3021,12 @@ const applyFullData = (data) => {
2942
3021
  reviewedScan() !== String(data.last_scan.ts) &&
2943
3022
  (shareableDuplicateCount > 0 || data.last_scan.partial)
2944
3023
  settleFolderDiscoveryStart()
3024
+ if (focusGroupPending &&
3025
+ data.inventory && data.inventory.view === "duplicates") {
3026
+ focusGroupPending = false
3027
+ state.expandedDuplicateGroups.add(FOCUS_GROUP)
3028
+ loadDuplicateGroupChildren(FOCUS_GROUP)
3029
+ }
2945
3030
  const publishedScan = data.last_scan ? data.last_scan.ts : null
2946
3031
  if (publishedScan !== state.locationsScanTs) {
2947
3032
  state.locationsScanTs = publishedScan
@@ -3400,6 +3485,13 @@ const closeScanSizeMenu = () => {
3400
3485
  document.addEventListener("click", async (event) => {
3401
3486
  const target = event.target.closest("button")
3402
3487
  if (!target) return
3488
+ if (target.dataset.openGroup) {
3489
+ await openContentGroup(
3490
+ target.dataset.openGroup,
3491
+ target.dataset.openKind,
3492
+ target.dataset.openLabel)
3493
+ return
3494
+ }
3403
3495
  if (target.hasAttribute("data-dismiss-automatic-scan-coachmark")) {
3404
3496
  dismissAutomaticScanCoachmark(true)
3405
3497
  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,8 +813,13 @@ 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`
@@ -839,13 +846,18 @@ describe("Save Space interface", () => {
839
846
  await waitFor(() => getRequests.some((url) =>
840
847
  new URL(url, "http://localhost").searchParams
841
848
  .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"))
849
+ await waitFor(() => document.querySelector(".vault-location-path"))
844
850
  const detail = document.querySelector(".vault-detail")
845
- assert.match(detail.textContent, /Identical contents at 3 locations/)
846
- assert.match(detail.textContent, /first\.bin/)
851
+ assert.match(detail.textContent, /Stored 3 times · 4\.1 KB each/)
847
852
  assert.doesNotMatch(detail.textContent, /sample\.bin/)
848
853
  assert.doesNotMatch(detail.textContent, /of 3 locations shown/)
854
+ const notes = [...detail.querySelectorAll(".vault-location-note")]
855
+ .map((node) => node.textContent)
856
+ assert.deepEqual(notes, [
857
+ "this file",
858
+ "the copy that stays",
859
+ "already deduplicated"
860
+ ])
849
861
  await settle()
850
862
  dom.window.close()
851
863
  })
@@ -888,6 +900,44 @@ describe("Save Space interface", () => {
888
900
  dom.window.close()
889
901
  })
890
902
 
903
+ test("a scoped page hands one content group to the global page", async () => {
904
+ const hash = "c".repeat(64)
905
+ const duplicate = item({
906
+ path: "/pinokio/api/app/models/duplicate.bin",
907
+ relative_path: "models/duplicate.bin",
908
+ status: "duplicate",
909
+ shareable: true,
910
+ hash,
911
+ location_count: 3
912
+ })
913
+ const base = fixture([duplicate])
914
+ base.inventory.source_counts.duplicates["app:app"] = 1
915
+ base.inventory.shareable_by_source["app:app"] = 1
916
+ const response = (url) => {
917
+ const parsed = new URL(url, "http://localhost")
918
+ if (parsed.searchParams.get("group_hash")) {
919
+ return { hash, items: [], total: 3, next_cursor: null }
920
+ }
921
+ const result = JSON.parse(JSON.stringify(base))
922
+ result.inventory.view = parsed.searchParams.get("view") || "all"
923
+ result.items = [duplicate]
924
+ return result
925
+ }
926
+ const { dom, getRequests } = await makePage(response, {
927
+ focusGroup: hash
928
+ })
929
+ const document = dom.window.document
930
+
931
+ await waitFor(() => document.querySelector(
932
+ '[data-view="duplicates"].selected'))
933
+ assert.ok(document.querySelector('[data-display-mode="files"].selected'))
934
+ await waitFor(() => getRequests.some((url) =>
935
+ new URL(url, "http://localhost").searchParams
936
+ .get("group_hash") === hash))
937
+ await settle()
938
+ dom.window.close()
939
+ })
940
+
891
941
  test("Cannot deduplicate is separate from actionable duplicates", async () => {
892
942
  const duplicate = item({
893
943
  path: "/pinokio/api/app/models/duplicate.bin",