pinokiod 8.0.49 → 8.0.51

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.
@@ -103,8 +103,8 @@ class Vault {
103
103
  get blobRoot() {
104
104
  return path.resolve(this.root, "sha256")
105
105
  }
106
- get sourceRoot() {
107
- return path.resolve(this.root, "sources")
106
+ get externalSourcesPath() {
107
+ return path.resolve(this.root, "external-sources.json")
108
108
  }
109
109
  storePathFor(hash) {
110
110
  if (typeof hash !== "string" || !SHA256_RE.test(hash)) {
@@ -129,6 +129,43 @@ class Vault {
129
129
  if (!st) throw unsafeStoragePath(directory)
130
130
  return st
131
131
  }
132
+ async readExternalSourcePaths() {
133
+ let raw
134
+ try {
135
+ raw = await this.registry.readFile(this.externalSourcesPath)
136
+ } catch (error) {
137
+ if (isMissingError(error)) return []
138
+ throw error
139
+ }
140
+ let parsed
141
+ try {
142
+ parsed = JSON.parse(raw)
143
+ } catch (error) {
144
+ throw new Error("External folder settings are invalid.")
145
+ }
146
+ if (!parsed || !Array.isArray(parsed.paths)) {
147
+ throw new Error("External folder settings are invalid.")
148
+ }
149
+ const paths = []
150
+ for (const value of parsed.paths) {
151
+ if (typeof value !== "string" || !path.isAbsolute(value.trim())) continue
152
+ const resolved = path.resolve(value.trim())
153
+ if (!paths.some((existing) => samePath(existing, resolved))) paths.push(resolved)
154
+ }
155
+ return paths
156
+ }
157
+ async writeExternalSourcePaths(folderPaths) {
158
+ const paths = []
159
+ for (const value of folderPaths) {
160
+ if (typeof value !== "string" || !path.isAbsolute(value.trim())) continue
161
+ const resolved = path.resolve(value.trim())
162
+ if (!paths.some((existing) => samePath(existing, resolved))) paths.push(resolved)
163
+ }
164
+ await this.registry.atomicWrite(
165
+ this.externalSourcesPath,
166
+ JSON.stringify({ paths }, null, 2)
167
+ )
168
+ }
132
169
  async storeStatIfPresent(storePath, options = {}) {
133
170
  if (!isPathWithin(this.blobRoot, storePath)) throw unsafeStoragePath(storePath)
134
171
  if (!await this.directoryIfSafe(this.root) || !await this.directoryIfSafe(this.blobRoot)) {
@@ -205,6 +242,11 @@ class Vault {
205
242
  }
206
243
  }
207
244
  }
245
+ case "remove_source":
246
+ if (typeof payload.source_id !== "string" || !payload.source_id) {
247
+ return { error: "Choose an external folder to remove." }
248
+ }
249
+ return this.runMutation(() => this.removeExternalSource(payload.source_id))
208
250
  case "scan": {
209
251
  if (payload.scope_id && !this.scanSource(payload.scope_id)) {
210
252
  return { error: "That scan location is no longer available." }
@@ -313,34 +355,6 @@ class Vault {
313
355
  return source
314
356
  }
315
357
 
316
- const seenExternalRoots = new Set()
317
- const addExternalEntries = async (importRoot, idPrefix = "") => {
318
- let entries = []
319
- try {
320
- entries = await fs.promises.readdir(importRoot, { withFileTypes: true })
321
- } catch (error) {
322
- if (!isMissingError(error)) throw error
323
- }
324
- for (const entry of entries) {
325
- if (!entry.isSymbolicLink()) continue
326
- const mountPath = path.resolve(importRoot, entry.name)
327
- try {
328
- const root = await fs.promises.realpath(mountPath)
329
- const st = await fs.promises.stat(root)
330
- if (!st.isDirectory()) continue
331
- const canonical = path.resolve(root)
332
- if (seenExternalRoots.has(canonical)) continue
333
- seenExternalRoots.add(canonical)
334
- sources.push(await decorate({
335
- id: sourceId("external", `${idPrefix}${entry.name}`), kind: "external", label: entry.name,
336
- root: canonical, mount_path: mountPath, parent_id: "external"
337
- }))
338
- } catch (error) {
339
- if (!isMissingError(error)) throw error
340
- }
341
- }
342
- }
343
-
344
358
  let apiEntries = []
345
359
  try {
346
360
  apiEntries = await fs.promises.readdir(apiRoot, { withFileTypes: true })
@@ -356,9 +370,15 @@ class Vault {
356
370
  }))
357
371
  }
358
372
  }
359
- await addExternalEntries(apiRoot)
360
- if (await this.directoryIfSafe(this.sourceRoot)) {
361
- await addExternalEntries(this.sourceRoot, "vault:")
373
+ for (const configuredPath of await this.readExternalSourcePaths()) {
374
+ sources.push(await decorate({
375
+ id: sourceId("external", configuredPath),
376
+ kind: "external",
377
+ label: path.basename(configuredPath) || configuredPath,
378
+ root: configuredPath,
379
+ parent_id: "external",
380
+ configured: true
381
+ }))
362
382
  }
363
383
 
364
384
  let homeEntries = []
@@ -421,49 +441,38 @@ class Vault {
421
441
  return { created: false, source: existing }
422
442
  }
423
443
 
424
- const importRoot = this.sourceRoot
425
- await this.ensureDirectory(importRoot)
426
- const baseLabel = path.basename(canonical) || "external-folder"
427
- let mountPath = null
428
- for (let index = 1; index < 10000; index++) {
429
- const label = index === 1 ? baseLabel : `${baseLabel}-${index}`
430
- const candidate = path.resolve(importRoot, label)
431
- try {
432
- await fs.promises.symlink(canonical, candidate, this.kernel.platform === "win32" ? "junction" : "dir")
433
- mountPath = candidate
434
- break
435
- } catch (error) {
436
- if (error && error.code === "EEXIST") continue
437
- if (error && (error.code === "EACCES" || error.code === "EPERM")) {
438
- throw new Error("Pinokio does not have permission to add that folder.")
439
- }
440
- throw new Error("Pinokio could not add that folder.")
441
- }
444
+ const configuredPaths = await this.readExternalSourcePaths()
445
+ configuredPaths.push(canonical)
446
+ await this.writeExternalSourcePaths(configuredPaths)
447
+ await this.refreshSources()
448
+ const source = this._sources.find((item) =>
449
+ item.kind === "external" && item.configured && samePath(item.root, canonical))
450
+ if (!source) {
451
+ throw new Error("The folder could not be added. Restart Pinokio and try again.")
442
452
  }
443
- if (!mountPath) throw new Error("Pinokio could not create a unique name for that folder.")
453
+ return { created: true, source }
454
+ }
455
+ async removeExternalSource(sourceIdToRemove) {
456
+ await this.refreshSources()
457
+ const source = this._sources.find((item) =>
458
+ item.id === sourceIdToRemove && item.kind === "external" && item.configured)
459
+ if (!source) return { error: "That external folder is no longer configured." }
444
460
 
445
- try {
446
- await this.refreshSources()
447
- const source = this._sources.find((item) => item.kind === "external" && item.mount_path && samePath(item.mount_path, mountPath))
448
- if (!source) throw new Error("The folder could not be added. Restart Pinokio and try again.")
449
- return { created: true, source }
450
- } catch (error) {
451
- // Adding a source is transactional. Roll back only the exact directory
452
- // link created above; preserve any path an external writer replaced.
453
- try {
454
- const [mounted, target] = await Promise.all([
455
- fs.promises.lstat(mountPath),
456
- fs.promises.realpath(mountPath)
457
- ])
458
- if (mounted.isSymbolicLink() && samePath(target, canonical)) {
459
- await fs.promises.unlink(mountPath)
460
- }
461
- } catch {
462
- // The original error remains authoritative; an unverified path is
463
- // deliberately preserved rather than deleted.
464
- }
465
- throw error
461
+ const configuredPaths = await this.readExternalSourcePaths()
462
+ const remaining = configuredPaths.filter((folderPath) => !samePath(folderPath, source.root))
463
+ if (remaining.length === configuredPaths.length) {
464
+ return { error: "That external folder is no longer configured." }
466
465
  }
466
+ await this.writeExternalSourcePaths(remaining)
467
+ await this.refreshSources()
468
+ this.reconcileConfiguredSources()
469
+ for (const [filePath, entry] of [...this.registry.excluded]) {
470
+ if (entry.source_id === source.id) this.registry.excluded.delete(filePath)
471
+ }
472
+ this.registry.sourceScans.delete(source.id)
473
+ this.registry.lastScan = null
474
+ this.registry.schedulePersist()
475
+ return { removed: true, source_id: source.id, label: source.label }
467
476
  }
468
477
  sourceForPath(filePath, preferredId) {
469
478
  let cursor = path.resolve(filePath)
@@ -1811,7 +1820,8 @@ class Vault {
1811
1820
  display_path: source.kind === "pinokio" ? source.root : (source.mount_path || source.root),
1812
1821
  target_path: source.kind === "external" ? source.root : null,
1813
1822
  parent_id: scopeId ? null : source.parent_id, app: source.app || null,
1814
- available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable
1823
+ available: source.available !== false, shareable: source.kind === "virtual" ? null : !!source.shareable,
1824
+ removable: source.kind === "external" && source.configured === true
1815
1825
  }))
1816
1826
  const result = {
1817
1827
  enabled: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.49",
3
+ "version": "8.0.51",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -473,16 +473,26 @@ body[data-vault-mode="app"] .vault-overview {
473
473
  .vault-cloud-warning .vault-text-button { margin-left: auto; }
474
474
  .vault-cleanup-notice {
475
475
  flex: 0 0 auto;
476
- background: color-mix(in srgb, var(--task-accent) 5%, var(--task-panel));
476
+ border-bottom-color: color-mix(in srgb, var(--vault-warning) 45%, var(--task-border));
477
+ background: color-mix(in srgb, var(--vault-warning) 16%, var(--task-panel));
477
478
  }
478
- .vault-cleanup-notice > i { color: var(--task-accent); }
479
+ .vault-cleanup-notice > i { color: var(--vault-warning); }
479
480
  .vault-cleanup-notice strong { flex: 0 0 auto; font-weight: 650; }
480
481
  .vault-cleanup-notice-detail {
481
482
  min-width: 0;
482
483
  flex: 1 1 auto;
483
- color: var(--task-muted);
484
+ color: color-mix(in srgb, var(--task-text) 78%, var(--vault-warning));
485
+ }
486
+ .vault-cleanup-notice .vault-button {
487
+ flex: 0 0 auto;
488
+ margin-left: auto;
489
+ border-color: color-mix(in srgb, var(--vault-warning) 70%, var(--task-border-strong));
490
+ background: var(--task-panel);
491
+ font-weight: 650;
492
+ }
493
+ .vault-cleanup-notice .vault-button:hover {
494
+ background: color-mix(in srgb, var(--vault-warning) 12%, var(--task-panel));
484
495
  }
485
- .vault-cleanup-notice .vault-button { flex: 0 0 auto; margin-left: auto; }
486
496
  .vault-explorer {
487
497
  display: grid;
488
498
  min-height: 0;
@@ -20,13 +20,17 @@ const COPY = {
20
20
  external_added: "Added to Locations. Run a scan when you’re ready.",
21
21
  external_exists: "That folder is already in Locations.",
22
22
  external_other_disk: "Added to Locations. It can be scanned, but files on this disk cannot be deduplicated with files in your Pinokio folder.",
23
+ remove_external_folder: "Remove from Locations",
24
+ remove_external_confirm: "Remove “{name}” from Locations? This does not delete or modify any files.",
25
+ external_removed: "Removed from Locations. No files were changed.",
23
26
  pinokio_folder: "Pinokio folder",
24
27
  save_space: "Save space",
25
- disk_space_saved: "of disk space saved",
26
- saved_for_app: "{size} saved for this app",
27
- before: "Before",
28
- before_help: "Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.",
29
- after: "After",
28
+ disk_space_freed: "of disk space freed",
29
+ sharing_saves_for_app: "Sharing saves {size} for this app",
30
+ without_sharing: "Without sharing",
31
+ without_sharing_help: "Estimated size of every scanned location if each app stored its own copy. File Explorer may count deduplicated files differently.",
32
+ on_disk: "On disk",
33
+ app_share: "This app’s share",
30
34
  effective_help: "For deduplicated files, disk usage is divided evenly among every location using them.",
31
35
  nothing_more_to_save: "Nothing else to save",
32
36
  more_can_be_saved: "{size} more can be saved",
@@ -36,7 +40,8 @@ const COPY = {
36
40
  find_savings: "Scan to find duplicate files and save disk space",
37
41
  find_app_savings: "Scan this app to find duplicate files and save disk space",
38
42
  storage_details: "Storage details",
39
- saved_by_actions: "Saved by your actions",
43
+ kept_deduplicated: "Kept deduplicated",
44
+ already_shared: "Already shared before Save space",
40
45
  last_scanned: "Last scanned",
41
46
  never: "Never",
42
47
  scan: "Scan now",
@@ -528,6 +533,9 @@ const batchAction = (source) => {
528
533
  if (!source.shareable || !shareable.length) return `<div class="vault-pane-action-note">${esc(COPY.sharing_unavailable)}</div>`
529
534
  return `<button class="vault-button" type="button" data-deduplicate-scope="${attr(source.id)}">${esc(COPY.deduplicate)} ${countLabel(shareable.length)}</button>`
530
535
  }
536
+ const removeSourceAction = (source) => source && source.removable
537
+ ? `<button class="vault-button" type="button" data-remove-source="${attr(source.id)}">${esc(COPY.remove_external_folder)}</button>`
538
+ : ""
531
539
 
532
540
  const toolbarSummary = (visibleItems) => {
533
541
  if (state.view === "duplicates") {
@@ -586,7 +594,8 @@ const renderToolbar = (visibleItems, allItems) => {
586
594
  ${displayModeControl()}
587
595
  ${descriptionMarkup}
588
596
  <span class="vault-toolbar-count" id="vault-toolbar-summary">${esc(toolbarSummary(visibleItems))}</span>
589
- ${state.view === "all" ? batchAction(source) : bulkDeduplicationAction(allItems)}`
597
+ ${state.view === "all" ? batchAction(source) : bulkDeduplicationAction(allItems)}
598
+ ${removeSourceAction(source)}`
590
599
  }
591
600
 
592
601
  const statusMarkup = (item) => {
@@ -939,23 +948,31 @@ const renderOverview = () => {
939
948
  (!!last || beforeBytes > 0 || afterBytes > 0 || Number(data.pending_bytes) > 0)
940
949
  const afterRatio = beforeBytes ? Math.min(100, (afterBytes / beforeBytes) * 100) : 0
941
950
  const pendingBytes = Math.max(0, Number(data.pending_bytes) || 0)
951
+ // The headline reports bytes actually freed by user actions, so it always
952
+ // survives a comparison against the OS file manager. Sharing that already
953
+ // existed when a scan adopted it (package-manager hardlinks, earlier runs)
954
+ // never changed free space and stays out of this number.
955
+ const freedBytes = Math.max(0, Number(data.lifetime_bytes_saved) || 0)
956
+ const sharedNow = Math.max(0, Number(data.saved_by_sharing) || 0)
942
957
  const headline = hasComparison
943
958
  ? (IS_APP_MODE
944
- ? COPY.saved_for_app.replace("{size}", fmt(Math.max(0, beforeBytes - afterBytes)))
945
- : `${fmt(data.saved_by_sharing)} ${COPY.disk_space_saved}`)
959
+ ? COPY.sharing_saves_for_app.replace("{size}", fmt(Math.max(0, beforeBytes - afterBytes)))
960
+ : `${fmt(freedBytes)} ${COPY.disk_space_freed}`)
946
961
  : (IS_APP_MODE ? COPY.find_app_savings : COPY.find_savings)
947
- const help = IS_APP_MODE ? COPY.effective_help : COPY.before_help
962
+ const beforeLabel = COPY.without_sharing
963
+ const afterLabel = IS_APP_MODE ? COPY.app_share : COPY.on_disk
964
+ const help = IS_APP_MODE ? COPY.effective_help : COPY.without_sharing_help
948
965
  const helpId = IS_APP_MODE ? "vault-after-help" : "vault-before-help"
949
966
  const helpMarkup = `<span class="vault-compare-info" tabindex="0" aria-describedby="${helpId}"><i class="fa-regular fa-circle-question" aria-hidden="true"></i><span class="vault-compare-tooltip" id="${helpId}" role="tooltip">${esc(help)}</span></span>`
950
967
  const comparison = hasComparison ? `
951
- <div class="vault-comparison" aria-label="${attr(`${COPY.before}: ${fmt(beforeBytes)}. ${COPY.after}: ${fmt(afterBytes)}.`)}">
968
+ <div class="vault-comparison" aria-label="${attr(`${beforeLabel}: ${fmt(beforeBytes)}. ${afterLabel}: ${fmt(afterBytes)}.`)}">
952
969
  <div class="vault-compare-row">
953
- <span class="vault-compare-label">${esc(COPY.before)}${IS_APP_MODE ? "" : helpMarkup}</span>
970
+ <span class="vault-compare-label">${esc(beforeLabel)}${IS_APP_MODE ? "" : helpMarkup}</span>
954
971
  <span class="vault-compare-track"><span class="vault-compare-fill before"></span></span>
955
972
  <span class="vault-compare-value">${fmt(beforeBytes)}</span>
956
973
  </div>
957
974
  <div class="vault-compare-row">
958
- <span class="vault-compare-label">${esc(COPY.after)}${IS_APP_MODE ? helpMarkup : ""}</span>
975
+ <span class="vault-compare-label">${esc(afterLabel)}${IS_APP_MODE ? helpMarkup : ""}</span>
959
976
  <span class="vault-compare-track"><span class="vault-compare-fill after" style="--vault-after-ratio:${afterRatio.toFixed(2)}%"></span></span>
960
977
  <span class="vault-compare-value">${fmt(afterBytes)}</span>
961
978
  </div>
@@ -976,10 +993,14 @@ const renderOverview = () => {
976
993
  const storageDetails = !IS_APP_MODE && el("vault-storage-details")
977
994
  if (storageDetails) {
978
995
  const homeBytes = last ? (last.home_bytes_total == null ? last.bytes_total : last.home_bytes_total) : null
996
+ // Clamped because deleting linked files later shrinks the live sharing
997
+ // total without touching the lifetime counter.
998
+ const alreadyShared = Math.max(0, sharedNow - freedBytes)
979
999
  storageDetails.innerHTML = `<div class="vault-advanced-title">${esc(COPY.storage_details)}</div>
980
1000
  <dl class="vault-storage-list">
981
1001
  <div><dt>${esc(COPY.pinokio_folder)}</dt><dd>${homeBytes == null ? "—" : fmt(homeBytes)}</dd></div>
982
- <div><dt>${esc(COPY.saved_by_actions)}</dt><dd>${fmt(data.lifetime_bytes_saved)}</dd></div>
1002
+ <div><dt>${esc(COPY.kept_deduplicated)}</dt><dd>${fmt(sharedNow)}</dd></div>
1003
+ <div><dt>${esc(COPY.already_shared)}</dt><dd>${fmt(alreadyShared)}</dd></div>
983
1004
  </dl>`
984
1005
  }
985
1006
  }
@@ -1568,6 +1589,18 @@ document.addEventListener("click", async (event) => {
1568
1589
  if (state.expandedFiles.has(file)) state.expandedFiles.delete(file)
1569
1590
  else state.expandedFiles.add(file)
1570
1591
  render()
1592
+ } else if (target.dataset.removeSource) {
1593
+ const source = sourceById(target.dataset.removeSource)
1594
+ if (!source) return
1595
+ const message = COPY.remove_external_confirm.replace("{name}", source.label)
1596
+ if (!window.confirm(message)) return
1597
+ await runAction({
1598
+ action: "remove_source",
1599
+ source_id: source.id
1600
+ }, () => {
1601
+ state.sourceId = null
1602
+ return COPY.external_removed
1603
+ })
1571
1604
  } else if (target.id === "btn-add-source") {
1572
1605
  target.disabled = true
1573
1606
  try {
@@ -265,7 +265,7 @@ describe('vault manual scan (phase 3)', () => {
265
265
  assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
266
266
  })
267
267
 
268
- test('top-level linked imports are scanned as external sources while nested links stay skipped', async (t) => {
268
+ test('top-level api links are not adopted as external sources', async (t) => {
269
269
  const { home, vault } = await makeEnv()
270
270
  const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-'))
271
271
  const nested = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-external-nested-'))
@@ -286,22 +286,20 @@ describe('vault manual scan (phase 3)', () => {
286
286
  const source = vault.sources().find((item) => item.kind === 'external' && item.label === 'linked-models')
287
287
  const canonicalImported = path.resolve(await fs.promises.realpath(external), 'models', 'm.bin')
288
288
  const canonicalNestedFile = path.resolve(await fs.promises.realpath(nested), 'should-not-scan.bin')
289
- assert.ok(source, 'linked import becomes an external source')
290
- assert.strictEqual(source.parent_id, 'external')
291
- assert.ok(vault.registry.scanIndex.has(canonicalImported), 'external root was scanned')
289
+ assert.strictEqual(source, undefined, 'api links are not Vault configuration')
290
+ assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
292
291
  assert.strictEqual(vault.registry.scanIndex.has(canonicalNestedFile), false, 'nested directory link was not followed')
293
- assert.strictEqual(vault.registry.duplicates.get(canonicalImported).source_id, source.id)
294
292
  assert.strictEqual((await fs.promises.stat(local)).nlink, 2)
295
293
 
296
294
  const status = await vault.status()
297
- assert.ok(status.sources.some((item) => item.id === source.id && item.kind === 'external'))
298
- assert.strictEqual(status.duplicates.find((item) => item.path === canonicalImported).source_id, source.id)
295
+ assert.strictEqual(status.sources.some((item) => item.kind === 'external'), false)
296
+ assert.strictEqual(status.duplicates.some((item) => item.path === canonicalImported), false)
299
297
  assert.strictEqual(status.last_scan.home_bytes_total, content.length, 'Pinokio metric excludes external files')
300
- assert.strictEqual(status.last_scan.source_bytes[source.id], content.length, 'external totals remain attributable')
298
+ assert.strictEqual(status.last_scan.bytes_total, content.length)
301
299
  })
302
300
 
303
- test('adding an external source creates one persistent import without starting a scan', async () => {
304
- const { home, vault } = await makeEnv()
301
+ test('adding and removing an external source persists only its path and never creates a link', async () => {
302
+ const { home, vault, kernel } = await makeEnv()
305
303
  const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-added-external-'))
306
304
  homes.push(external)
307
305
  const content = crypto.randomBytes(4096)
@@ -312,27 +310,42 @@ describe('vault manual scan (phase 3)', () => {
312
310
  assert.strictEqual(added.source.kind, 'external')
313
311
  assert.strictEqual(added.source.parent_id, 'external')
314
312
  assert.strictEqual(vault.registry.scanIndex.size, 0, 'adding a source never scans it')
315
- assert.strictEqual((await fs.promises.lstat(added.source.mount_path)).isSymbolicLink(), true)
316
- assert.strictEqual(path.dirname(added.source.mount_path), path.resolve(home, 'vault', 'sources'))
313
+ assert.strictEqual(added.source.root, path.resolve(await fs.promises.realpath(external)))
314
+ assert.strictEqual(added.source.mount_path, undefined)
315
+ assert.deepStrictEqual(
316
+ JSON.parse(await fs.promises.readFile(vault.externalSourcesPath, 'utf8')).paths,
317
+ [added.source.root]
318
+ )
319
+ assert.strictEqual(fs.existsSync(path.resolve(home, 'vault', 'sources')), false)
317
320
  assert.deepStrictEqual(await fs.promises.readdir(path.resolve(home, 'api')), [],
318
- 'a scan-only external source never enters global app discovery')
321
+ 'adding a Vault source never creates an api link')
319
322
 
320
323
  const repeated = await vault.addExternalSource(external)
321
324
  assert.strictEqual(repeated.created, false, 'the same physical folder is not imported twice')
322
325
  assert.strictEqual(vault.sources().filter((source) => source.kind === 'external').length, 1)
323
326
 
327
+ const fresh = new Vault(kernel)
328
+ await fresh.init()
329
+ assert.ok(fresh.sources().some((source) =>
330
+ source.kind === 'external' && source.root === added.source.root))
331
+
324
332
  await vault.sweeper.scan()
325
333
  const canonicalImported = path.resolve(await fs.promises.realpath(imported))
326
334
  assert.ok(vault.registry.scanIndex.has(canonicalImported))
327
335
 
328
- await fs.promises.unlink(added.source.mount_path)
329
- await vault.sweeper.scan()
336
+ const removed = await vault.perform('remove_source', { source_id: added.source.id })
337
+ assert.strictEqual(removed.removed, true)
338
+ assert.deepStrictEqual(
339
+ JSON.parse(await fs.promises.readFile(vault.externalSourcesPath, 'utf8')).paths,
340
+ []
341
+ )
330
342
  const status = await vault.status()
331
343
  assert.strictEqual(vault.registry.links.has(canonicalImported), false)
332
344
  assert.strictEqual(vault.registry.duplicates.has(canonicalImported), false)
333
345
  assert.strictEqual(vault.registry.scanIndex.has(canonicalImported), false)
334
346
  assert.strictEqual(status.sources.some((source) => source.kind === 'external'), false)
335
347
  assert.strictEqual(status.duplicates.some((item) => item.path === canonicalImported), false)
348
+ assert.deepStrictEqual(await fs.promises.readFile(imported), content)
336
349
  })
337
350
 
338
351
  test('nested external sources are walked once and attributed to the deepest source', async () => {
@@ -393,7 +406,7 @@ describe('vault manual scan (phase 3)', () => {
393
406
  const realStat = fs.promises.stat
394
407
  let externalStats = 0
395
408
  fs.promises.stat = async (target, options) => {
396
- if (path.resolve(String(target)) === path.resolve(canonicalExternal) && ++externalStats === 2) {
409
+ if (path.resolve(String(target)) === path.resolve(canonicalExternal) && ++externalStats === 1) {
397
410
  const error = new Error('temporary external metadata failure')
398
411
  error.code = 'EIO'
399
412
  throw error
@@ -452,28 +465,26 @@ describe('vault manual scan (phase 3)', () => {
452
465
  assert.strictEqual(vault.registry.lastScan.files, 2)
453
466
  })
454
467
 
455
- test('a failed source refresh rolls back only the import it just created', async () => {
468
+ test('a failed external-source settings write creates no configuration', async () => {
456
469
  const { vault } = await makeEnv()
457
470
  const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-add-rollback-'))
458
471
  homes.push(external)
459
- const importRoot = vault.sourceRoot
460
- const realReaddir = fs.promises.readdir
461
- let importReads = 0
462
- fs.promises.readdir = async (target, options) => {
463
- if (path.resolve(target) === importRoot && ++importReads === 1) {
472
+ const realAtomicWrite = vault.registry.atomicWrite
473
+ vault.registry.atomicWrite = async (target, contents) => {
474
+ if (path.resolve(target) === vault.externalSourcesPath) {
464
475
  const error = new Error('transient read failure')
465
476
  error.code = 'EIO'
466
477
  throw error
467
478
  }
468
- return realReaddir(target, options)
479
+ return realAtomicWrite.call(vault.registry, target, contents)
469
480
  }
470
481
  try {
471
482
  await assert.rejects(vault.addExternalSource(external), (error) => error.code === 'EIO')
472
483
  } finally {
473
- fs.promises.readdir = realReaddir
484
+ vault.registry.atomicWrite = realAtomicWrite
474
485
  }
475
486
 
476
- assert.deepStrictEqual(await fs.promises.readdir(importRoot), [])
487
+ assert.strictEqual(fs.existsSync(vault.externalSourcesPath), false)
477
488
  assert.strictEqual(vault.sources().some((source) => source.kind === 'external'), false)
478
489
  })
479
490
 
@@ -1260,11 +1260,11 @@ describe('vault dashboard backend (phase 4)', () => {
1260
1260
 
1261
1261
  test('repair is advanced, metrics distinguish detected and explicit savings, and refresh retries', async () => {
1262
1262
  const vaultPage = await vaultPageSource()
1263
- assert.match(vaultPage, /disk_space_saved:\s*"of disk space saved"/)
1264
- assert.match(vaultPage, /before_help:\s*"Estimated size of every scanned location if each app stored its own copy\. File Explorer may count deduplicated files differently\."/)
1263
+ assert.match(vaultPage, /disk_space_freed:\s*"of disk space freed"/)
1264
+ assert.match(vaultPage, /without_sharing_help:\s*"Estimated size of every scanned location if each app stored its own copy\. File Explorer may count deduplicated files differently\."/)
1265
1265
  assert.match(vaultPage, /nothing_more_to_save:\s*"Nothing else to save"/)
1266
1266
  assert.match(vaultPage, /more_can_be_saved:\s*"\{size\} more can be saved"/)
1267
- assert.match(vaultPage, /fmt\(data\.saved_by_sharing\)/)
1267
+ assert.match(vaultPage, /Number\(data\.saved_by_sharing\)/)
1268
1268
  assert.match(vaultPage, /Number\(data\.bytes_without_sharing\)/)
1269
1269
  assert.match(vaultPage, /Number\(data\.bytes_on_disk\)/)
1270
1270
  assert.match(vaultPage, /Number\(data\.pending_bytes\)/)
@@ -1276,7 +1276,8 @@ describe('vault dashboard backend (phase 4)', () => {
1276
1276
  assert.match(vaultPage, /candidate_size:\s*candidateSize\(\)/)
1277
1277
  assert.match(vaultPage, /localStorage\.setItem\(candidateSizeKey/)
1278
1278
  assert.match(vaultPage, /id='vault-storage-details'/)
1279
- assert.match(vaultPage, /fmt\(data\.lifetime_bytes_saved\)/)
1279
+ assert.match(vaultPage, /Number\(data\.lifetime_bytes_saved\)/)
1280
+ assert.match(vaultPage, /Math\.max\(0, sharedNow - freedBytes\)/)
1280
1281
  assert.match(vaultPage, /repair_index:\s*"Repair index"/)
1281
1282
  assert.match(vaultPage, /<details class='vault-advanced'/)
1282
1283
  assert.doesNotMatch(vaultPage, /id='btn-rebuild'/)
@@ -1333,6 +1334,8 @@ describe('vault dashboard backend (phase 4)', () => {
1333
1334
  assert.match(vaultPage, /id='btn-add-source'/)
1334
1335
  assert.match(vaultPage, /<script src="\/Socket\.js"><\/script>/)
1335
1336
  assert.match(vaultPage, /action:\s*"add_source"/)
1337
+ assert.match(vaultPage, /action:\s*"remove_source"/)
1338
+ assert.match(vaultPage, /remove_external_folder:\s*"Remove from Locations"/)
1336
1339
  assert.match(vaultPage, /identical_contents_at:\s*"Identical contents at"/)
1337
1340
  assert.doesNotMatch(vaultPage, /Matching locations/)
1338
1341
  assert.match(vaultPage, /scan_waiting:\s*"Waiting for scan results"/)
@@ -1525,7 +1528,7 @@ describe('vault dashboard backend (phase 4)', () => {
1525
1528
  await runVaultScript(dom)
1526
1529
  await new Promise((resolve) => setTimeout(resolve, 25))
1527
1530
 
1528
- assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '4.51 GB saved for this app')
1531
+ assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, 'Sharing saves 4.51 GB for this app')
1529
1532
  assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['7.09 GB', '2.58 GB'])
1530
1533
  assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:36\.36%/)
1531
1534
  assert.strictEqual(dom.window.document.getElementById('vault-after-help').textContent,
@@ -1601,8 +1604,8 @@ describe('vault dashboard backend (phase 4)', () => {
1601
1604
  await runVaultScript(dom)
1602
1605
  await new Promise((resolve) => setTimeout(resolve, 25))
1603
1606
 
1604
- assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '256.95 GB of disk space saved')
1605
- assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['Before', 'After'])
1607
+ assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent, '258.77 GB of disk space freed')
1608
+ assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-label')].map((node) => node.firstChild.textContent.trim()), ['Without sharing', 'On disk'])
1606
1609
  assert.deepStrictEqual([...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent), ['661.75 GB', '404.8 GB'])
1607
1610
  assert.match(dom.window.document.querySelector('.vault-compare-fill.after').getAttribute('style'), /--vault-after-ratio:61\.17%/)
1608
1611
  assert.strictEqual(dom.window.document.getElementById('vault-before-help').textContent,
@@ -1613,7 +1616,10 @@ describe('vault dashboard backend (phase 4)', () => {
1613
1616
  assert.strictEqual(dom.window.document.getElementById('btn-review-metric').classList.contains('primary'), true)
1614
1617
  assert.strictEqual(dom.window.document.getElementById('btn-scan').classList.contains('primary'), false)
1615
1618
  assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Pinokio folder\s*166\.22 GB/)
1616
- assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Saved by your actions\s*258\.77 GB/)
1619
+ assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Kept deduplicated\s*256\.95 GB/)
1620
+ // lifetime (241) exceeds live sharing (239.3) here, so the clamp keeps
1621
+ // the pre-existing figure at zero instead of going negative.
1622
+ assert.match(dom.window.document.getElementById('vault-storage-details').textContent, /Already shared before Save space\s*0 B/)
1617
1623
  const cleanupNotice = dom.window.document.getElementById('vault-cleanup-notice')
1618
1624
  assert.strictEqual(cleanupNotice.classList.contains('show'), true)
1619
1625
  assert.match(cleanupNotice.textContent, /ready to clean up/)
@@ -1684,7 +1690,7 @@ describe('vault dashboard backend (phase 4)', () => {
1684
1690
  await new Promise((resolve) => setTimeout(resolve, 25))
1685
1691
 
1686
1692
  assert.strictEqual(dom.window.document.querySelector('.vault-summary-value').textContent,
1687
- '3 GB of disk space saved')
1693
+ '3 GB of disk space freed')
1688
1694
  assert.deepStrictEqual(
1689
1695
  [...dom.window.document.querySelectorAll('.vault-compare-value')].map((node) => node.textContent),
1690
1696
  ['10 GB', '7 GB'])
@@ -2588,7 +2594,7 @@ describe('vault dashboard backend (phase 4)', () => {
2588
2594
  assert.doesNotMatch(actionRoute, /convertPending|runExclusive/)
2589
2595
  })
2590
2596
 
2591
- test('a stale external source id cannot authorize files outside its current target', async (t) => {
2597
+ test('a stale external source id cannot authorize files outside its current target', async () => {
2592
2598
  const { home, vault } = await makeEnv()
2593
2599
  const firstTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-old-'))
2594
2600
  const secondTarget = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-scope-new-'))
@@ -2597,16 +2603,8 @@ describe('vault dashboard backend (phase 4)', () => {
2597
2603
  const hash = sha256(content)
2598
2604
  const original = await writeFile(path.resolve(home, 'api', 'appA', 'model.bin'), content)
2599
2605
  const outside = await writeFile(path.resolve(firstTarget, 'model.bin'), content)
2600
- const mount = path.resolve(home, 'api', 'external-models')
2601
- try {
2602
- await fs.promises.symlink(firstTarget, mount, process.platform === 'win32' ? 'junction' : 'dir')
2603
- } catch (error) {
2604
- t.skip(`directory links unavailable: ${error.message}`)
2605
- return
2606
- }
2607
- await vault.refreshSources()
2606
+ const externalSource = (await vault.addExternalSource(firstTarget)).source
2608
2607
  const originalSource = vault.sources().find((source) => source.kind === 'app' && source.app === 'appA')
2609
- const externalSource = vault.sources().find((source) => source.kind === 'external' && source.label === 'external-models')
2610
2608
  await vault.adopt(original, hash, { app: 'appA', source_id: originalSource.id })
2611
2609
  const outsideStat = await fs.promises.stat(outside)
2612
2610
  vault.registry.duplicates.set(outside, {
@@ -2614,12 +2612,12 @@ describe('vault dashboard backend (phase 4)', () => {
2614
2612
  dev: outsideStat.dev, ino: outsideStat.ino,
2615
2613
  mtime: outsideStat.mtimeMs, ctime: outsideStat.ctimeMs
2616
2614
  })
2617
- await fs.promises.unlink(mount)
2618
- await fs.promises.symlink(secondTarget, mount, process.platform === 'win32' ? 'junction' : 'dir')
2615
+ await vault.writeExternalSourcePaths([secondTarget])
2616
+ await vault.refreshSources()
2619
2617
 
2620
2618
  const result = await vault.perform('deduplicate', { scope_id: externalSource.id })
2621
2619
 
2622
- assert.strictEqual(result.converted, 0)
2620
+ assert.match(result.error, /no longer available/i)
2623
2621
  assert.strictEqual((await fs.promises.stat(outside)).nlink, 1)
2624
2622
  assert.ok(vault.registry.duplicates.has(outside))
2625
2623
  })
@@ -2768,16 +2766,13 @@ describe('vault dashboard backend (phase 4)', () => {
2768
2766
  assert.strictEqual(vault.registry.links.has(pending), false)
2769
2767
  })
2770
2768
 
2771
- test('status separates app, Pinokio folder, and external source metadata', async (t) => {
2769
+ test('status separates app, Pinokio folder, and external source metadata', async () => {
2772
2770
  const { home, vault } = await makeEnv()
2773
- const external = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))
2774
- homes.push(external)
2775
- try {
2776
- await fs.promises.symlink(external, path.resolve(home, 'api', 'linked-models'), process.platform === 'win32' ? 'junction' : 'dir')
2777
- } catch (error) {
2778
- t.skip(`directory links unavailable: ${error.message}`)
2779
- return
2780
- }
2771
+ const externalParent = await fs.promises.mkdtemp(path.resolve(os.tmpdir(), 'pinokio-ui-external-'))
2772
+ const external = path.resolve(externalParent, 'linked-models')
2773
+ homes.push(externalParent)
2774
+ await fs.promises.mkdir(external)
2775
+ await vault.addExternalSource(external)
2781
2776
  await fs.promises.mkdir(path.resolve(home, 'api', 'local-app'), { recursive: true })
2782
2777
  await fs.promises.mkdir(path.resolve(home, 'cache'), { recursive: true })
2783
2778
  await vault.refreshSources()
@@ -2785,6 +2780,8 @@ describe('vault dashboard backend (phase 4)', () => {
2785
2780
  const byKind = new Map(status.sources.map((source) => [source.id, source]))
2786
2781
  assert.ok([...byKind.values()].some((source) => source.kind === 'app' && source.label === 'local-app' && source.parent_id === 'apps'))
2787
2782
  assert.ok([...byKind.values()].some((source) => source.kind === 'folder' && source.label === 'cache' && source.parent_id === 'pinokio'))
2788
- assert.ok([...byKind.values()].some((source) => source.kind === 'external' && source.label === 'linked-models' && source.parent_id === 'external'))
2783
+ assert.ok([...byKind.values()].some((source) =>
2784
+ source.kind === 'external' && source.label === 'linked-models' &&
2785
+ source.parent_id === 'external' && source.removable === true))
2789
2786
  })
2790
2787
  })