pinokiod 8.0.102 → 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
  }
@@ -2854,6 +2874,9 @@ class Vault {
2854
2874
  ))
2855
2875
  await this.refreshInodeSnapshots(entry.hash, entry.dev, entry.ino)
2856
2876
  if (options.reclassify !== false) {
2877
+ // Free before reclassifying: classification must see the final state,
2878
+ // or it records a reference against an anchor about to be deleted.
2879
+ await this.freeUnusedAnchors([entry.hash])
2857
2880
  await this.reclassifyHashes([entry.hash])
2858
2881
  }
2859
2882
  if (options.changedHashes) options.changedHashes.add(entry.hash)
@@ -2915,6 +2938,7 @@ class Vault {
2915
2938
  }
2916
2939
  }
2917
2940
  if (affectedHashes.size) {
2941
+ await this.freeUnusedAnchors(affectedHashes)
2918
2942
  await this.reclassifyHashes(affectedHashes)
2919
2943
  }
2920
2944
  const event = {
@@ -2998,6 +3022,7 @@ class Vault {
2998
3022
  }
2999
3023
  }
3000
3024
  if (affectedHashes.size) {
3025
+ await this.freeUnusedAnchors(affectedHashes)
3001
3026
  await this.reclassifyHashes(affectedHashes)
3002
3027
  }
3003
3028
  if (changedHashes instanceof FileActionChanges) {
@@ -3074,6 +3099,27 @@ class Vault {
3074
3099
  return result
3075
3100
  }
3076
3101
 
3102
+ // Separating the last path to shared content leaves an anchor nothing
3103
+ // references. Freeing it here keeps that cleanup out of the user's hands.
3104
+ // reclaim() revalidates and refuses while any path still links, so a
3105
+ // partial separation frees nothing and a failure is never fatal.
3106
+ async freeUnusedAnchors(hashes) {
3107
+ for (const hash of hashes) {
3108
+ let anchors = []
3109
+ try {
3110
+ anchors = await this.registry.anchorsForHash(hash)
3111
+ } catch (error) {
3112
+ continue
3113
+ }
3114
+ for (const anchor of anchors) {
3115
+ if (anchor.nlink !== 1) continue
3116
+ try {
3117
+ await this.reclaim(hash, anchor.store_id)
3118
+ } catch (error) {}
3119
+ }
3120
+ }
3121
+ }
3122
+
3077
3123
  async reclaimAll() {
3078
3124
  const summary = { reclaimed: 0, bytes_freed: 0, failed: 0 }
3079
3125
  let cursor = { store_id: "", hash: "" }
@@ -3227,12 +3273,7 @@ class Vault {
3227
3273
  dev: match.dev,
3228
3274
  ino: match.ino
3229
3275
  }, this.locationForPath(match.path, match.source_id)))
3230
- const publicStatus = {
3231
- reference: "tracked",
3232
- duplicate: "duplicate",
3233
- unavailable: "unavailable",
3234
- linked: "shared"
3235
- }[row.status]
3276
+ const publicStatus = PUBLIC_FILE_STATUS[row.status]
3236
3277
  const result = Object.assign({
3237
3278
  path: row.path,
3238
3279
  hash: row.hash,
@@ -3330,6 +3371,47 @@ class Vault {
3330
3371
  }
3331
3372
  }
3332
3373
 
3374
+ async fileLocations(scopeId, filePath, options = {}) {
3375
+ if (typeof filePath !== "string" || !filePath) {
3376
+ throw new TypeError("Invalid vault file path.")
3377
+ }
3378
+ const target = path.resolve(filePath)
3379
+ const entry = await this.registry.getFile(target)
3380
+ const sourceIds = this.scopeSourceIds(scopeId)
3381
+ if (!entry ||
3382
+ (scopeId && !sourceIds.includes(entry.source_id))) {
3383
+ return { path: target, items: [], total: 0, next_cursor: null }
3384
+ }
3385
+ const cursor = typeof options.cursor === "string"
3386
+ ? options.cursor.slice(0, 2048)
3387
+ : ""
3388
+ const pageSize = boundedInteger(
3389
+ options.page_size, 100, 1, STATUS_PAGE_SIZE)
3390
+ const identity = entry.status === "linked"
3391
+ ? { dev: entry.dev, ino: entry.ino }
3392
+ : { hash: entry.hash }
3393
+ if (!identity.hash && !Number.isFinite(identity.dev)) {
3394
+ return { path: target, items: [], total: 0, next_cursor: null }
3395
+ }
3396
+ // Every authorized location is listed whatever the scope. A scope decides
3397
+ // what can be selected and acted on, not what the user is told exists.
3398
+ const result = await this.registry.fileLocationChildren(Object.assign({
3399
+ externalSourceIds: this.configuredExternalSourceIds(),
3400
+ cursor,
3401
+ pageSize
3402
+ }, identity))
3403
+ return {
3404
+ path: target,
3405
+ items: (result.rows || []).map((row) => Object.assign({
3406
+ path: row.path,
3407
+ status: PUBLIC_FILE_STATUS[row.status] || null
3408
+ }, this.locationForPath(row.path, row.source_id))),
3409
+ total: Number(result.total) || 0,
3410
+ deduplicatable: Number(result.duplicateTotal) || 0,
3411
+ next_cursor: result.nextCursor || null
3412
+ }
3413
+ }
3414
+
3333
3415
  async duplicateGroupSelection(scopeId, hash, options = {}) {
3334
3416
  if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
3335
3417
  throw new TypeError("Invalid vault content identifier.")
@@ -206,6 +206,7 @@ for (const method of [
206
206
  "appsForHashes",
207
207
  "reclaimableBatch",
208
208
  "duplicateGroupChildren",
209
+ "fileLocationChildren",
209
210
  "duplicateGroupSelection",
210
211
  "duplicateGroupPageSelection",
211
212
  "statusSnapshot",
@@ -5633,6 +5633,86 @@ class RegistryCore {
5633
5633
  }
5634
5634
  }
5635
5635
 
5636
+ fileLocationChildren(options = {}) {
5637
+ const externalSourceIds = [...new Set(
5638
+ (options.externalSourceIds || []).filter(Boolean))]
5639
+ const pageSize = Math.max(
5640
+ 1, Math.min(500, Number(options.pageSize) || 100))
5641
+ const inode = Number.isFinite(options.dev) && Number.isFinite(options.ino)
5642
+ const identity = inode
5643
+ ? `${options.dev}:${options.ino}`
5644
+ : String(options.hash || "")
5645
+ const authorized = this.duplicateGroupFilter(
5646
+ [],
5647
+ "",
5648
+ true,
5649
+ "child",
5650
+ inode ? ["linked"] : [],
5651
+ externalSourceIds
5652
+ )
5653
+ const identityWhere = inode
5654
+ ? "child.dev = ? AND child.ino = ?"
5655
+ : "child.hash = ?"
5656
+ const identityValues = inode
5657
+ ? [options.dev, options.ino]
5658
+ : [options.hash]
5659
+ const decoded = this.decodeCursor(options.cursor)
5660
+ const cursorWhere = []
5661
+ const cursorValues = []
5662
+ if (decoded &&
5663
+ decoded.sort === "file-locations" &&
5664
+ decoded.identity === identity &&
5665
+ typeof decoded.path === "string") {
5666
+ cursorWhere.push("child.path > ?")
5667
+ cursorValues.push(decoded.path)
5668
+ }
5669
+ const rows = this.database.prepare(`
5670
+ SELECT child.*
5671
+ FROM files child
5672
+ WHERE ${identityWhere}
5673
+ AND ${authorized.where.join(" AND ")}
5674
+ ${cursorWhere.length
5675
+ ? `AND ${cursorWhere.join(" AND ")}`
5676
+ : ""}
5677
+ ORDER BY child.path
5678
+ LIMIT ?
5679
+ `).all(
5680
+ ...identityValues,
5681
+ ...authorized.values,
5682
+ ...cursorValues,
5683
+ pageSize + 1
5684
+ )
5685
+ const hasMore = rows.length > pageSize
5686
+ if (hasMore) rows.pop()
5687
+ const last = rows[rows.length - 1]
5688
+ const total = Number(this.database.prepare(`
5689
+ SELECT COUNT(*) AS count
5690
+ FROM files child
5691
+ WHERE ${identityWhere}
5692
+ AND ${authorized.where.join(" AND ")}
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
5702
+ return {
5703
+ rows,
5704
+ total,
5705
+ duplicateTotal,
5706
+ nextCursor: hasMore && last
5707
+ ? this.encodeCursor({
5708
+ sort: "file-locations",
5709
+ identity,
5710
+ path: last.path
5711
+ })
5712
+ : null
5713
+ }
5714
+ }
5715
+
5636
5716
  duplicateGroupSelection(options = {}) {
5637
5717
  const sourceIds = [...new Set(
5638
5718
  (options.sourceIds || []).filter(Boolean))]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.102",
3
+ "version": "8.0.106",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -15883,6 +15883,17 @@ class Server {
15883
15883
  req.query.folder_discovery_child_page))
15884
15884
  return
15885
15885
  }
15886
+ const locationsPath = req.query &&
15887
+ typeof req.query.locations_path === "string"
15888
+ ? req.query.locations_path
15889
+ : null
15890
+ if (locationsPath) {
15891
+ res.json(await vault.fileLocations(scopeId, locationsPath, {
15892
+ cursor: req.query && req.query.cursor,
15893
+ page_size: req.query && req.query.page_size
15894
+ }))
15895
+ return
15896
+ }
15886
15897
  const groupHash = req.query &&
15887
15898
  typeof req.query.group_hash === "string"
15888
15899
  ? req.query.group_hash
@@ -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);
@@ -5,14 +5,14 @@ const COPY = {
5
5
  duplicates: "Duplicates",
6
6
  cannot_deduplicate: "Cannot deduplicate",
7
7
  shared: "Deduplicated",
8
- reclaimable: "Unused files",
8
+ reclaimable: "Trash",
9
9
  activity: "Activity",
10
10
  all_description: "Every scanned file and its current deduplication status.",
11
11
  duplicates_description: "Identical files waiting to be deduplicated.",
12
12
  unavailable_description: "Identical files that cannot share storage.",
13
13
  shared_description: "Files currently sharing disk storage through hardlinks.",
14
14
  tracked_description: "Files with no duplicate action required.",
15
- reclaimable_description: "Private links no longer used by any linked file.",
15
+ reclaimable_description: "Spare copies no file is using.",
16
16
  activity_description: "A history of changes made by Disk Saver.",
17
17
  add_external_folder: "Add external folder",
18
18
  find_folders: "Find more savings",
@@ -236,16 +236,18 @@ const COPY = {
236
236
  selected_copy: "Selected",
237
237
  loading_copies: "Loading copies…",
238
238
  show_more_copies: "Show {count} more copies",
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",
243
+ show_more_locations: "Show {count} more locations",
239
244
  making_separate: "Making file separate",
240
245
  make_separate: "Make separate",
241
246
  try_again: "Try again",
242
- reclaim: "Clean up",
243
- reclaim_all: "Clean up all",
244
- cleanup_ready: "{size} ready to clean up",
245
- cleanup_ready_detail: "{count} left after their linked files were deleted.",
246
- review_cleanup: "Review cleanup",
247
- private_link: "private link",
248
- private_links: "private links",
247
+ reclaim: "Delete",
248
+ reclaim_all: "Empty Trash",
249
+ cleanup_ready: "{size} in the Trash",
250
+ review_cleanup: "Open Trash",
249
251
  make_file_separate: "Make file separate",
250
252
  make_files_separate: "Make {count} files separate",
251
253
  making_separate_selected: "Making files separate",
@@ -259,8 +261,12 @@ const COPY = {
259
261
  separation_cancelled: "Separation cancelled after {count}.",
260
262
  select_for_separation: "Select to make separate",
261
263
  select_all_on_page: "Select all on this page",
262
- identical_contents_at: "Identical contents at",
263
- locations_shown: "{shown} of {total} locations shown",
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",
264
270
  no_files: "No files found",
265
271
  no_files_hint: "Run a scan to find files that can be deduplicated. Scanning never links files together or replaces them.",
266
272
  scan_waiting: "Waiting for scan results",
@@ -273,26 +279,34 @@ const COPY = {
273
279
  no_shared_hint: "Deduplicated files will appear here after you review duplicates.",
274
280
  no_tracked: "No files with no action needed",
275
281
  no_tracked_hint: "Files without a duplicate action will appear here after a scan.",
276
- no_reclaimable: "No cleanup needed",
277
- no_reclaimable_hint: "Private links with no remaining linked files will appear here.",
282
+ no_reclaimable: "Trash is empty",
283
+ no_reclaimable_hint: "Spare copies Pinokio no longer needs appear here.",
278
284
  no_activity: "No activity yet",
279
285
  no_activity_hint: "Changes made by Disk Saver will appear here.",
280
286
  view_all: "View all files",
281
287
  tracked_note: "Only files {size} and larger appear here. Files keep their current locations.",
282
288
  tracked_note_all: "All non-empty files appear here. Files keep their current locations.",
283
289
  duplicate_note: "Only files waiting for review are shown.",
284
- reclaimable_note: "These private links have no remaining linked files. Cleaning them up frees disk space.",
285
290
  activity_note: "Recent changes made by Disk Saver.",
286
291
  converted: "Deduplicated",
287
292
  separated: "Separated",
288
293
  reclaimed: "Cleaned up",
289
294
  event_convert: "Deduplicated",
290
- event_reclaim: "Cleaned up unused link",
295
+ event_reclaim: "Emptied from Trash",
291
296
  event_detach: "Separated",
292
297
  event_change: "File state changed"
293
298
  }
294
299
 
295
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
296
310
  const IS_APP_MODE = document.body.dataset.vaultMode === "app" && !!SCOPE_ID
297
311
  const APP_NAME = IS_APP_MODE ? document.body.dataset.vaultApp || "" : ""
298
312
  const HOME_PATH = document.body.dataset.vaultHome || ""
@@ -330,6 +344,14 @@ const folderDiscoveryChildrenUrl = (folder, page = 0) => {
330
344
  })
331
345
  return `/info/dedup?${query.toString()}`
332
346
  }
347
+ const fileLocationsUrl = (filePath, options = {}) => {
348
+ const query = new URLSearchParams()
349
+ if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
350
+ query.set("locations_path", filePath)
351
+ if (options.cursor) query.set("cursor", options.cursor)
352
+ query.set("page_size", String(DUPLICATE_CHILD_PAGE_SIZE))
353
+ return `/info/dedup?${query.toString()}`
354
+ }
333
355
  const duplicateGroupUrl = (hash, options = {}) => {
334
356
  const query = new URLSearchParams()
335
357
  if (SCOPE_ID) query.set("scope_id", SCOPE_ID)
@@ -405,17 +427,19 @@ const state = {
405
427
  candidateSize: defaultCandidateSize,
406
428
  persistedCandidateSize: defaultCandidateSize,
407
429
  candidateSizeInitialized: false,
408
- view: "all",
430
+ view: FOCUS_GROUP ? "duplicates" : "all",
409
431
  sourceId: SCOPE_ID,
410
432
  query: "",
411
433
  statusFilter: "all",
412
- displayMode: "folders",
413
- sizeSort: null,
434
+ displayMode: FOCUS_GROUP ? "files" : "folders",
435
+ sizeSort: FOCUS_GROUP ? "desc" : null,
414
436
  collapsedSources: new Set(),
415
437
  collapsedDirs: new Set(),
416
438
  expandedFiles: new Set(),
417
439
  expandedDuplicateGroups: new Set(),
418
440
  duplicateGroupChildren: new Map(),
441
+ fileLocations: new Map(),
442
+ locationsScanTs: undefined,
419
443
  duplicateGroupGeneration: 0,
420
444
  selectedDuplicateFiles: new Map(),
421
445
  selectedSeparateFiles: new Set(),
@@ -454,11 +478,19 @@ const state = {
454
478
  pageCursors: [""]
455
479
  }
456
480
 
481
+ // Expanded location lists describe one grouping of one file. Anything that
482
+ // can change that grouping closes them instead of leaving stale paths open.
483
+ const closeFileLocations = () => {
484
+ state.expandedFiles.clear()
485
+ state.fileLocations.clear()
486
+ }
487
+
457
488
  const resetPage = () => {
458
489
  state.page = 0
459
490
  state.pageCursors = [""]
460
491
  state.expandedDuplicateGroups.clear()
461
492
  state.duplicateGroupChildren.clear()
493
+ closeFileLocations()
462
494
  state.duplicateGroupGeneration += 1
463
495
  }
464
496
 
@@ -594,9 +626,59 @@ const saveCandidateSize = (size) => {
594
626
  })
595
627
  return candidateSizeSaveTail
596
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
+
597
658
  const openGlobalWorkspace = () => {
598
659
  window.parent.location.assign("/vault")
599
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
+ }
600
682
  const applyAutomaticScanSnapshot = (snapshot) => {
601
683
  if (!IS_APP_MODE || !AUTOMATIC_SUPPORTED) return
602
684
  const settings = snapshot && Array.isArray(snapshot.settings)
@@ -688,24 +770,15 @@ const onAutomaticScanMessage = (event) => {
688
770
  }
689
771
  const sourceById = (id) => (state.data.sources || []).find((source) => source.id === id)
690
772
  const sourceChildren = (id) => (state.data.sources || []).filter((source) => source.parent_id === id)
691
- const sourceIsWithinScope = (sourceId) => {
692
- if (!SCOPE_ID) return true
693
- const seen = new Set()
694
- let source = sourceById(sourceId)
695
- while (source && !seen.has(source.id)) {
696
- if (source.id === SCOPE_ID) return true
697
- seen.add(source.id)
698
- source = sourceById(source.parent_id)
699
- }
700
- return false
701
- }
702
773
  const revealLabel = document.body.dataset.platform === "darwin"
703
774
  ? COPY.show_in_finder
704
775
  : document.body.dataset.platform === "win32"
705
776
  ? COPY.show_in_file_explorer
706
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.
707
780
  const revealButton = (filePath, sourceId, name) => {
708
- if (!filePath || !sourceIsWithinScope(sourceId)) return ""
781
+ if (!filePath) return ""
709
782
  const label = `${revealLabel}: ${name || basename(filePath)}`
710
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>`
711
784
  }
@@ -1696,15 +1769,46 @@ const sharingControl = (item) => {
1696
1769
  }
1697
1770
 
1698
1771
  const fileDetail = (item) => {
1699
- if (!state.expandedFiles.has(item.path) || !item.locations || item.locations.length < 2) return ""
1700
- const total = Math.max(item.locations.length, Number(item.location_count) || 0)
1701
- const label = total > item.locations.length
1702
- ? COPY.locations_shown
1703
- .replace("{shown}", item.locations.length)
1704
- .replace("{total}", total)
1705
- : `${COPY.identical_contents_at} ${countLabel(total, COPY.location, COPY.locations_lower)}`
1706
- return `<div class="vault-detail"><div class="vault-detail-label">${esc(label)}</div>${item.locations.map((location) => `
1707
- <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("")}</div>`
1772
+ if (!state.expandedFiles.has(item.path)) return ""
1773
+ const locations = state.fileLocations.get(item.path)
1774
+ if (!locations || (locations.loading && !locations.loaded)) {
1775
+ return `<div class="vault-detail"><div class="vault-detail-label"><i class="fa-solid fa-circle-notch fa-spin"></i> ${esc(COPY.loading_locations)}</div></div>`
1776
+ }
1777
+ if (locations.error) {
1778
+ return `<div class="vault-detail"><div class="vault-detail-label error">${esc(locations.error)}</div></div>`
1779
+ }
1780
+ const total = Math.max(locations.items.length, Number(locations.total) || 0)
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))
1785
+ const remaining = Math.max(0, total - locations.items.length)
1786
+ const more = locations.nextCursor
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>`
1788
+ : ""
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>`
1708
1812
  }
1709
1813
  const separateCheckbox = (item) => item.status === "shared"
1710
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" : ""} />`
@@ -1721,7 +1825,7 @@ const rowSelectionCheckbox = (item) =>
1721
1825
  const renderFileRow = (item, depth = 0, showMatch = false) => {
1722
1826
  const directoryPath = dirname(item.relative_path)
1723
1827
  const match = item.match
1724
- const expandable = item.locations && item.locations.length > 1
1828
+ const expandable = Number(item.location_count) > 1
1725
1829
  const rowTail = showMatch
1726
1830
  ? `<span>${match ? `<span class="vault-match-path">${esc(match.path)}</span>` : "—"}</span>
1727
1831
  <span class="vault-space">${esc(spaceMarkup(item))}</span>
@@ -1826,7 +1930,7 @@ const flatLocation = (item) => externalLocation(item) ||
1826
1930
  const renderFlatFiles = (items) => [...items]
1827
1931
  .sort((a, b) => compareRows(a, b, flatLocation))
1828
1932
  .map((item) => {
1829
- const expandable = item.locations && item.locations.length > 1
1933
+ const expandable = Number(item.location_count) > 1
1830
1934
  return `<div class="vault-file-row">
1831
1935
  <div class="vault-name-cell">
1832
1936
  ${separateCheckbox(item)}
@@ -2230,7 +2334,7 @@ const paneFooterText = () => {
2230
2334
  return `${count}${order ? ` · ${order}` : ""}`
2231
2335
  }
2232
2336
  if (state.view === "duplicates") return COPY.duplicate_note
2233
- if (state.view === "reclaimable") return COPY.reclaimable_note
2337
+ if (state.view === "reclaimable") return ""
2234
2338
  if (state.view === "activity") return COPY.activity_note
2235
2339
  const minimumSize = state.data.last_scan &&
2236
2340
  Number.isFinite(state.data.last_scan.candidate_min_bytes)
@@ -2754,12 +2858,8 @@ const renderCleanupNotice = () => {
2754
2858
  }
2755
2859
  const bytes = Number(state.data.reclaimable) || 0
2756
2860
  const title = COPY.cleanup_ready.replace("{size}", fmt(bytes))
2757
- const detail = COPY.cleanup_ready_detail.replace(
2758
- "{count}",
2759
- countLabel(unusedCount, COPY.private_link, COPY.private_links)
2760
- )
2761
2861
  notice.className = "vault-cleanup-notice show"
2762
- notice.innerHTML = `<i class="fa-solid fa-broom" aria-hidden="true"></i><strong>${esc(title)}</strong><span class="vault-cleanup-notice-detail">${esc(detail)}</span><button class="vault-button" id="btn-review-cleanup" type="button">${esc(COPY.review_cleanup)}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
2862
+ notice.innerHTML = `<i class="fa-regular fa-trash-can" aria-hidden="true"></i><strong>${esc(title)}</strong><button class="vault-button" id="btn-review-cleanup" type="button">${esc(COPY.review_cleanup)}<i class="fa-solid fa-chevron-right" aria-hidden="true"></i></button>`
2763
2863
  }
2764
2864
 
2765
2865
  const clearPanel = (id, className) => {
@@ -2846,9 +2946,9 @@ const fetchJson = async (url) => {
2846
2946
  if (!response.ok) throw new Error(COPY.status_request_failed.replace("{status}", response.status))
2847
2947
  return response.json()
2848
2948
  }
2849
- const loadDuplicateGroupChildren = async (hash, append = false) => {
2949
+ const loadPagedChildren = async (cache, key, buildUrl, append) => {
2850
2950
  const generation = state.duplicateGroupGeneration
2851
- const current = state.duplicateGroupChildren.get(hash) || {
2951
+ const current = cache.get(key) || {
2852
2952
  items: [],
2853
2953
  total: 0,
2854
2954
  nextCursor: null,
@@ -2859,16 +2959,18 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
2859
2959
  if (current.loading) return
2860
2960
  current.loading = true
2861
2961
  current.error = null
2862
- state.duplicateGroupChildren.set(hash, current)
2962
+ cache.set(key, current)
2863
2963
  render()
2864
2964
  try {
2865
- const result = await fetchJson(duplicateGroupUrl(hash, {
2866
- cursor: append ? current.nextCursor : null
2867
- }))
2965
+ const result = await fetchJson(
2966
+ buildUrl(append ? current.nextCursor : null))
2868
2967
  if (generation !== state.duplicateGroupGeneration) return
2869
2968
  const items = Array.isArray(result.items) ? result.items : []
2870
2969
  current.items = append ? current.items.concat(items) : items
2871
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)
2872
2974
  current.nextCursor = result.next_cursor || null
2873
2975
  current.loaded = true
2874
2976
  } catch (error) {
@@ -2883,6 +2985,12 @@ const loadDuplicateGroupChildren = async (hash, append = false) => {
2883
2985
  }
2884
2986
  }
2885
2987
  }
2988
+ const loadDuplicateGroupChildren = (hash, append = false) =>
2989
+ loadPagedChildren(state.duplicateGroupChildren, hash, (cursor) =>
2990
+ duplicateGroupUrl(hash, { cursor }), append)
2991
+ const loadFileLocations = (filePath, append = false) =>
2992
+ loadPagedChildren(state.fileLocations, filePath, (cursor) =>
2993
+ fileLocationsUrl(filePath, { cursor }), append)
2886
2994
  const duplicateGroupSelectionPaths = async (hash) =>
2887
2995
  fetchJson(duplicateGroupUrl(hash, { select: true }))
2888
2996
  const duplicateGroupPageSelectionItems = async () =>
@@ -2919,6 +3027,17 @@ const applyFullData = (data) => {
2919
3027
  reviewedScan() !== String(data.last_scan.ts) &&
2920
3028
  (shareableDuplicateCount > 0 || data.last_scan.partial)
2921
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
+ }
3036
+ const publishedScan = data.last_scan ? data.last_scan.ts : null
3037
+ if (publishedScan !== state.locationsScanTs) {
3038
+ state.locationsScanTs = publishedScan
3039
+ closeFileLocations()
3040
+ }
2922
3041
  state.data = data
2923
3042
  const fileAction = serverFileAction(data.file_action)
2924
3043
  if (fileAction) state.actionProgress = fileAction
@@ -3034,6 +3153,7 @@ const runAction = async (payload, success) => {
3034
3153
  } catch (error) {
3035
3154
  state.feedback = { error: true, message: error && error.message ? error.message : String(error) }
3036
3155
  }
3156
+ closeFileLocations()
3037
3157
  await refresh(true)
3038
3158
  }
3039
3159
 
@@ -3371,6 +3491,13 @@ const closeScanSizeMenu = () => {
3371
3491
  document.addEventListener("click", async (event) => {
3372
3492
  const target = event.target.closest("button")
3373
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
+ }
3374
3501
  if (target.hasAttribute("data-dismiss-automatic-scan-coachmark")) {
3375
3502
  dismissAutomaticScanCoachmark(true)
3376
3503
  return
@@ -3741,11 +3868,21 @@ document.addEventListener("click", async (event) => {
3741
3868
  target.dataset.moreDuplicateGroup,
3742
3869
  true
3743
3870
  )
3871
+ } else if (target.dataset.moreFileLocations) {
3872
+ await loadFileLocations(target.dataset.moreFileLocations, true)
3744
3873
  } else if (target.dataset.expandFile) {
3745
3874
  const file = target.dataset.expandFile
3746
- if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
3747
- else state.expandedFiles.add(file)
3748
- render()
3875
+ if (state.expandedFiles.has(file)) {
3876
+ state.expandedFiles.delete(file)
3877
+ render()
3878
+ } else {
3879
+ state.expandedFiles.add(file)
3880
+ render()
3881
+ const loaded = state.fileLocations.get(file)
3882
+ if (!loaded || (!loaded.loaded && !loaded.loading)) {
3883
+ await loadFileLocations(file)
3884
+ }
3885
+ }
3749
3886
  } else if (target.dataset.removeSource) {
3750
3887
  const source = sourceById(target.dataset.removeSource)
3751
3888
  if (!source) 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
  })
@@ -2447,6 +2446,159 @@ describe("Save Space engine", () => {
2447
2446
  await close(vault)
2448
2447
  })
2449
2448
 
2449
+ test("Make separate frees the anchor once nothing links to it", async () => {
2450
+ const { home, vault } = await makeVault()
2451
+ const pair = await duplicatePair(home)
2452
+ await vault.sweeper.scan()
2453
+ const duplicate = [...await vault.registry.files({
2454
+ statuses: ["duplicate"]
2455
+ })][0]
2456
+ await vault.perform("deduplicate", { path: duplicate.path })
2457
+ const hash = duplicate.hash
2458
+ const storePath = vault.storePathFor(hash)
2459
+ const linked = [...await vault.registry.files({ statuses: ["linked"] })]
2460
+ assert.equal(linked.length, 2)
2461
+
2462
+ assert.equal((await vault.perform("detach", {
2463
+ path: linked[0].path
2464
+ })).status, "detached")
2465
+ assert.equal(fs.existsSync(storePath), true)
2466
+ assert.equal((await fs.promises.stat(storePath)).nlink, 2)
2467
+ assert.equal((await vault.registry.anchorsForHash(hash)).length, 1)
2468
+
2469
+ assert.equal((await vault.perform("detach", {
2470
+ path: linked[1].path
2471
+ })).status, "detached")
2472
+ assert.equal(fs.existsSync(storePath), false)
2473
+ assert.equal((await vault.registry.anchorsForHash(hash)).length, 0)
2474
+
2475
+ for (const entry of linked) {
2476
+ await assertCandidateContents(entry.path, pair.contents, pair.size)
2477
+ }
2478
+ const activity = await vault.status(null, {
2479
+ view: "activity",
2480
+ page_size: 500
2481
+ })
2482
+ assert.equal(activity.items.some((item) => item.kind === "reclaim"), true)
2483
+ await close(vault)
2484
+ })
2485
+
2486
+ test("freeing the anchor leaves the registry ready to deduplicate again", async () => {
2487
+ const { home, vault } = await makeVault()
2488
+ await duplicatePair(home)
2489
+ await vault.sweeper.scan()
2490
+ const duplicate = [...await vault.registry.files({
2491
+ statuses: ["duplicate"]
2492
+ })][0]
2493
+ await vault.perform("deduplicate", { path: duplicate.path })
2494
+ for (const row of [...await vault.registry.files({
2495
+ statuses: ["linked"]
2496
+ })]) {
2497
+ assert.equal((await vault.perform("detach", {
2498
+ path: row.path
2499
+ })).status, "detached")
2500
+ }
2501
+
2502
+ const rows = [...await vault.registry.files({})]
2503
+ assert.equal(rows.filter((row) => row.status === "reference").length, 1)
2504
+ assert.equal(rows.filter((row) => row.status === "duplicate").length, 1)
2505
+ const target = rows.find((row) => row.status === "duplicate")
2506
+ assert.equal((await vault.perform("deduplicate", {
2507
+ path: target.path
2508
+ })).status, "converted")
2509
+ await close(vault)
2510
+ })
2511
+
2512
+ test("bulk Make separate frees only the anchors nothing links to", async () => {
2513
+ const { home, vault } = await makeVault()
2514
+ await duplicatePair(home, "kept.bin")
2515
+ const trio = crypto.randomBytes(4096)
2516
+ for (const name of ["one", "two", "three"]) {
2517
+ await writeCandidate(path.join(home, "api", name, "freed.bin"), trio)
2518
+ }
2519
+ await vault.sweeper.scan()
2520
+ const result = await vault.perform("deduplicate_files", {
2521
+ paths: [...await vault.registry.files({
2522
+ statuses: ["duplicate"]
2523
+ })].map((item) => item.path)
2524
+ })
2525
+ assert.equal(result.converted, 3)
2526
+
2527
+ const linked = [...await vault.registry.files({ statuses: ["linked"] })]
2528
+ const freedHash = linked.find((item) =>
2529
+ path.basename(item.path) === "freed.bin").hash
2530
+ const keptHash = linked.find((item) =>
2531
+ path.basename(item.path) === "kept.bin").hash
2532
+ const freedStore = vault.storePathFor(freedHash)
2533
+ const keptStore = vault.storePathFor(keptHash)
2534
+
2535
+ const separated = await vault.perform("separate_files", {
2536
+ paths: linked
2537
+ .filter((item) => path.basename(item.path) === "freed.bin")
2538
+ .map((item) => item.path)
2539
+ .concat(linked.find((item) =>
2540
+ path.basename(item.path) === "kept.bin").path)
2541
+ })
2542
+ assert.equal(separated.separated, 4)
2543
+
2544
+ assert.equal(fs.existsSync(freedStore), false)
2545
+ assert.equal((await vault.registry.anchorsForHash(freedHash)).length, 0)
2546
+ assert.equal(fs.existsSync(keptStore), true)
2547
+ assert.equal((await vault.registry.anchorsForHash(keptHash)).length, 1)
2548
+ await close(vault)
2549
+ })
2550
+
2551
+ test("Expanding a file lists every location for its own identity", async () => {
2552
+ const { home, vault } = await makeVault()
2553
+ const contents = crypto.randomBytes(4096)
2554
+ for (const name of ["one", "two", "three"]) {
2555
+ await writeCandidate(path.join(home, "api", name, "shared.bin"), contents)
2556
+ }
2557
+ await vault.sweeper.scan()
2558
+ const duplicate = [...await vault.registry.files({
2559
+ statuses: ["duplicate"]
2560
+ })][0]
2561
+
2562
+ const byHash = await vault.fileLocations(null, duplicate.path)
2563
+ assert.equal(byHash.total, 3)
2564
+ assert.equal(byHash.items.length, 3)
2565
+ assert.equal(byHash.items.some((entry) =>
2566
+ entry.path === duplicate.path), true)
2567
+
2568
+ await vault.perform("deduplicate", { path: duplicate.path })
2569
+ const byInode = await vault.fileLocations(null, duplicate.path)
2570
+ assert.equal(byInode.total, 2)
2571
+ assert.equal(byInode.items.length, 2)
2572
+ const untouched = [...await vault.registry.files({
2573
+ statuses: ["duplicate"]
2574
+ })][0]
2575
+ assert.equal(byInode.items.some((entry) =>
2576
+ entry.path === untouched.path), false)
2577
+ await close(vault)
2578
+ })
2579
+
2580
+ test("an app workspace lists every location but acts only on its own", async () => {
2581
+ const { home, vault } = await makeVault()
2582
+ const contents = crypto.randomBytes(4096)
2583
+ for (const app of ["one", "two", "three"]) {
2584
+ await writeCandidate(path.join(home, "api", app, "shared.bin"), contents)
2585
+ }
2586
+ await vault.sweeper.scan()
2587
+
2588
+ const scope = "app:two"
2589
+ const status = await vault.status(scope, { view: "all", page_size: 100 })
2590
+ assert.equal(status.items.length, 1)
2591
+ const row = status.items[0]
2592
+ assert.equal(row.location_count, 3)
2593
+
2594
+ const locations = await vault.fileLocations(scope, row.path)
2595
+ assert.equal(locations.total, 3)
2596
+ assert.equal(locations.items.length, 3)
2597
+ assert.equal(new Set(locations.items.map((item) =>
2598
+ item.source_id)).size, 3)
2599
+ await close(vault)
2600
+ })
2601
+
2450
2602
  test("invalid scoped actions cannot expand into a global mutation", async () => {
2451
2603
  const { home, vault } = await makeVault()
2452
2604
  await duplicatePair(home)
@@ -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 = []
@@ -784,6 +786,159 @@ describe("Save Space interface", () => {
784
786
  dom.window.close()
785
787
  })
786
788
 
789
+ test("expanding a file loads its real locations instead of row samples", async () => {
790
+ const duplicate = item({
791
+ path: "/pinokio/api/app/models/duplicate.bin",
792
+ relative_path: "models/duplicate.bin",
793
+ status: "duplicate",
794
+ shareable: true,
795
+ location_count: 3,
796
+ locations: [{
797
+ path: "/pinokio/api/app/models/duplicate.bin",
798
+ source_id: "app:app",
799
+ source_label: "app",
800
+ relative_path: "models/duplicate.bin"
801
+ }, {
802
+ path: "/pinokio/api/app/models/sample.bin",
803
+ source_id: "app:app",
804
+ source_label: "app",
805
+ relative_path: "models/sample.bin"
806
+ }]
807
+ })
808
+ const base = fixture([duplicate])
809
+ base.inventory.source_counts.duplicates["app:app"] = 1
810
+ base.inventory.shareable_by_source["app:app"] = 1
811
+ const response = (url) => {
812
+ const parsed = new URL(url, "http://localhost")
813
+ if (parsed.searchParams.get("locations_path")) {
814
+ return {
815
+ path: parsed.searchParams.get("locations_path"),
816
+ items: [
817
+ ["duplicate", "duplicate"],
818
+ ["first", "tracked"],
819
+ ["third", "shared"]
820
+ ].map(([name, status]) => ({
821
+ path: `/pinokio/api/app/models/${name}.bin`,
822
+ status,
823
+ source_id: "app:app",
824
+ source_label: "app",
825
+ relative_path: `models/${name}.bin`
826
+ })),
827
+ total: 3,
828
+ deduplicatable: 1,
829
+ next_cursor: null
830
+ }
831
+ }
832
+ const result = JSON.parse(JSON.stringify(base))
833
+ result.items = [duplicate]
834
+ return result
835
+ }
836
+ const { dom, getRequests } = await makePage(response)
837
+ const document = dom.window.document
838
+
839
+ document.querySelector('[data-view="duplicates"]').click()
840
+ await waitFor(() => document.querySelector(
841
+ '[data-view="duplicates"].selected'))
842
+ const disclosure = document.querySelector(
843
+ '[data-expand-file="/pinokio/api/app/models/duplicate.bin"]')
844
+ assert.ok(disclosure)
845
+ disclosure.click()
846
+
847
+ await waitFor(() => getRequests.some((url) =>
848
+ new URL(url, "http://localhost").searchParams
849
+ .get("locations_path") === "/pinokio/api/app/models/duplicate.bin"))
850
+ await waitFor(() => document.querySelector(".vault-location-path"))
851
+ const detail = document.querySelector(".vault-detail")
852
+ assert.match(detail.textContent, /Stored 3 times · 4\.1 KB each/)
853
+ assert.doesNotMatch(detail.textContent, /sample\.bin/)
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
+ ])
862
+ await settle()
863
+ dom.window.close()
864
+ })
865
+
866
+ test("leftover storage is presented as a Trash the user can empty", async () => {
867
+ const blob = {
868
+ store_id: "home",
869
+ hash: "b".repeat(64),
870
+ size: 11500000,
871
+ nlink: 1,
872
+ orphan: 1
873
+ }
874
+ const base = fixture([])
875
+ base.inventory.counts.reclaimable = 1
876
+ base.reclaimable = blob.size
877
+ base.enabled = true
878
+ const response = (url) => {
879
+ const view = new URL(url, "http://localhost")
880
+ .searchParams.get("view") || "all"
881
+ const result = JSON.parse(JSON.stringify(base))
882
+ result.inventory.view = view
883
+ result.items = view === "reclaimable" ? [blob] : []
884
+ return result
885
+ }
886
+ const { dom } = await makePage(response)
887
+ const document = dom.window.document
888
+
889
+ assert.match(document.querySelector("#vault-views").textContent, /Trash/)
890
+ document.querySelector('[data-view="reclaimable"]').click()
891
+ await waitFor(() => document.querySelector(
892
+ '[data-view="reclaimable"].selected'))
893
+ await waitFor(() => document.querySelector("[data-reclaim]"))
894
+
895
+ const body = document.body.textContent
896
+ assert.match(body, /Empty Trash/)
897
+ assert.doesNotMatch(body, /private link/i)
898
+ assert.doesNotMatch(body, /ready to clean up/i)
899
+ assert.doesNotMatch(body, /their linked files were deleted/i)
900
+ await settle()
901
+ dom.window.close()
902
+ })
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
+
787
942
  test("Cannot deduplicate is separate from actionable duplicates", async () => {
788
943
  const duplicate = item({
789
944
  path: "/pinokio/api/app/models/duplicate.bin",