pinokiod 8.0.56 → 8.0.58

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.
@@ -152,8 +152,9 @@ class AutomaticScans {
152
152
  return setting && setting.mode === "manual" ? "manual" : "automatic"
153
153
  }
154
154
 
155
- broadcast() {
155
+ broadcast(completion = null) {
156
156
  const snapshot = this.snapshot()
157
+ if (completion) snapshot.completion = completion
157
158
  for (const listener of this.listeners) {
158
159
  try {
159
160
  listener(snapshot)
@@ -617,6 +618,7 @@ class AutomaticScans {
617
618
 
618
619
  async precheckFinished(active, result, error) {
619
620
  const app = active.app
621
+ let completion = null
620
622
  try {
621
623
  await this.withAppTransition(app, async () => {
622
624
  let reason = active.reason
@@ -661,13 +663,20 @@ class AutomaticScans {
661
663
  ? Object.assign({}, this.settings.get(app))
662
664
  : null
663
665
  try {
664
- await this.publishResultNow(app, result)
666
+ const outcome = await this.publishResultNow(app, result)
667
+ if (outcome === "empty") {
668
+ completion = {
669
+ app,
670
+ outcome: "no_possible_duplicates"
671
+ }
672
+ }
665
673
  } catch (publicationError) {
666
674
  this.restorePrevious(app)
667
675
  throw publicationError
668
676
  }
669
677
  reason = active.reason
670
678
  if (reason) {
679
+ completion = null
671
680
  await this.restorePublishedState(app, entry, previousSetting)
672
681
  this.entries.set(app, entry)
673
682
  this.log("publication-reverted", { app, reason })
@@ -682,7 +691,7 @@ class AutomaticScans {
682
691
  })
683
692
  } finally {
684
693
  if (this.active === active) this.active = null
685
- this.broadcast()
694
+ this.broadcast(completion)
686
695
  this.schedule()
687
696
  }
688
697
  }
@@ -764,7 +773,7 @@ class AutomaticScans {
764
773
  possible_files: possibleFiles,
765
774
  acknowledged: acknowledged === result.signature
766
775
  })
767
- return
776
+ return "result"
768
777
  }
769
778
  const acknowledged = (this.settings.get(app) || {})
770
779
  .acknowledged_signature
@@ -777,6 +786,7 @@ class AutomaticScans {
777
786
  }
778
787
  this.entries.delete(app)
779
788
  this.log("no-possible-matches", { app })
789
+ return "empty"
780
790
  }
781
791
 
782
792
  async clearAutomaticState(apps, reason, options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "8.0.56",
3
+ "version": "8.0.58",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -1993,6 +1993,19 @@ class Server {
1993
1993
  const vault = this.kernel.vault
1994
1994
  if (vault && vault.ready) await vault.ready
1995
1995
  result.vault_enabled = !!(vault && vault.enabled)
1996
+ result.vault_automatic_mode = "automatic"
1997
+ if (result.vault_enabled) {
1998
+ try {
1999
+ const snapshot = await vault.automaticScanStatus()
2000
+ const setting = Array.isArray(snapshot && snapshot.settings)
2001
+ ? snapshot.settings.find((item) =>
2002
+ item && item.app === name)
2003
+ : null
2004
+ result.vault_automatic_mode = setting && setting.mode === "manual"
2005
+ ? "manual"
2006
+ : "automatic"
2007
+ } catch (_) {}
2008
+ }
1996
2009
  if (!registryEnabled) {
1997
2010
  result.pendingSnapshotId = null
1998
2011
  }
@@ -0,0 +1,37 @@
1
+ (() => {
2
+ const status = document.querySelector("[data-app-vault-mode]")
3
+ if (!status) return
4
+
5
+ const app = status.dataset.app || ""
6
+ const tab = status.closest("#save-space-tab")
7
+ const label = status.querySelector("[data-app-vault-mode-label]")
8
+ const setMode = (value) => {
9
+ const mode = value === "manual" ? "manual" : "automatic"
10
+ status.dataset.mode = mode
11
+ status.hidden = false
12
+ if (label) label.textContent = mode === "automatic" ? "Auto" : "Manual"
13
+ if (tab) {
14
+ tab.setAttribute("aria-label",
15
+ `Disk Saver — ${mode === "automatic" ? "Automatic" : "Manual"} checking`)
16
+ }
17
+ }
18
+ const applySnapshot = (snapshot) => {
19
+ const settings = snapshot && Array.isArray(snapshot.settings)
20
+ ? snapshot.settings
21
+ : []
22
+ const setting = settings.find((item) => item && item.app === app)
23
+ setMode(setting && setting.mode)
24
+ }
25
+
26
+ setMode(status.dataset.mode)
27
+ if (!app || typeof window.EventSource !== "function") return
28
+
29
+ const source = new window.EventSource(
30
+ "/info/vault/automatic-scans/events")
31
+ source.onmessage = (event) => {
32
+ try {
33
+ applySnapshot(JSON.parse(event.data))
34
+ } catch (_) {}
35
+ }
36
+ window.addEventListener("beforeunload", () => source.close(), { once: true })
37
+ })()
@@ -866,8 +866,16 @@
866
866
  <circle cx="10" cy="10" r="7.5"></circle>
867
867
  <path d="M8 7.25v5.5M12 7.25v5.5"></path>
868
868
  </svg>`;
869
+ const COMPLETE_ICON = `
870
+ <svg viewBox="0 0 20 20" aria-hidden="true">
871
+ <circle cx="10" cy="10" r="7.5"></circle>
872
+ <path d="m6.75 10.1 2.1 2.1 4.6-4.65"></path>
873
+ </svg>`;
874
+ const COMPLETION_VISIBLE_MS = 4000;
869
875
 
870
876
  let eventSource = null;
877
+ let visibleCheckingApps = new Set();
878
+ const completions = new Map();
871
879
 
872
880
  function statusText(row) {
873
881
  if (row.state === 'paused') {
@@ -912,6 +920,97 @@
912
920
  tray.hidden = tray.childElementCount === 0;
913
921
  }
914
922
 
923
+ function removeCompletion(app) {
924
+ const completion = completions.get(app);
925
+ if (!completion) return;
926
+ if (completion.timer) window.clearTimeout(completion.timer);
927
+ completions.delete(app);
928
+ removeRow(completion.item);
929
+ }
930
+
931
+ function clearCompletions() {
932
+ [...completions.keys()].forEach(removeCompletion);
933
+ }
934
+
935
+ function scheduleCompletion(completion) {
936
+ if (completion.paused.size || completion.timer) return;
937
+ completion.startedAt = Date.now();
938
+ completion.timer = window.setTimeout(() => {
939
+ completion.timer = null;
940
+ removeCompletion(completion.app);
941
+ }, completion.remaining);
942
+ }
943
+
944
+ function setCompletionPaused(completion, reason, paused) {
945
+ if (paused) {
946
+ if (completion.paused.has(reason)) return;
947
+ if (!completion.paused.size && completion.timer) {
948
+ completion.remaining = Math.max(0,
949
+ completion.remaining - (Date.now() - completion.startedAt));
950
+ window.clearTimeout(completion.timer);
951
+ completion.timer = null;
952
+ }
953
+ completion.paused.add(reason);
954
+ return;
955
+ }
956
+ completion.paused.delete(reason);
957
+ if (!completion.paused.size) scheduleCompletion(completion);
958
+ }
959
+
960
+ function startCompletion(app) {
961
+ if (completions.has(app)) return;
962
+ const item = document.createElement('div');
963
+ item.className = 'vault-auto-scan-row';
964
+ item.dataset.state = 'complete';
965
+
966
+ const close = document.createElement('button');
967
+ close.type = 'button';
968
+ close.className = 'vault-auto-scan-close';
969
+ close.setAttribute('aria-label',
970
+ `Dismiss Disk Saver completion for ${app}`);
971
+ close.title = 'Dismiss';
972
+ close.textContent = '×';
973
+
974
+ const icon = document.createElement('span');
975
+ icon.className = 'vault-auto-scan-icon';
976
+ icon.innerHTML = COMPLETE_ICON;
977
+
978
+ const copy = document.createElement('span');
979
+ copy.className = 'vault-auto-scan-copy';
980
+ const appName = document.createElement('span');
981
+ appName.className = 'vault-auto-scan-app';
982
+ appName.textContent = app;
983
+ appName.title = app;
984
+ const status = document.createElement('span');
985
+ status.className = 'vault-auto-scan-status';
986
+ status.textContent = 'No possible duplicate files found';
987
+ copy.append(appName, status);
988
+ item.append(close, icon, copy);
989
+
990
+ const completion = {
991
+ app,
992
+ item,
993
+ timer: null,
994
+ remaining: COMPLETION_VISIBLE_MS,
995
+ startedAt: 0,
996
+ paused: new Set()
997
+ };
998
+ close.addEventListener('click', () => removeCompletion(app));
999
+ item.addEventListener('mouseenter', () =>
1000
+ setCompletionPaused(completion, 'pointer', true));
1001
+ item.addEventListener('mouseleave', () =>
1002
+ setCompletionPaused(completion, 'pointer', false));
1003
+ item.addEventListener('focusin', () =>
1004
+ setCompletionPaused(completion, 'focus', true));
1005
+ item.addEventListener('focusout', (event) => {
1006
+ if (!item.contains(event.relatedTarget)) {
1007
+ setCompletionPaused(completion, 'focus', false);
1008
+ }
1009
+ });
1010
+ completions.set(app, completion);
1011
+ scheduleCompletion(completion);
1012
+ }
1013
+
915
1014
  function automaticSettingsKey(app) {
916
1015
  return `pinokio:vault:auto-settings:${encodeURIComponent(app)}`;
917
1016
  }
@@ -968,15 +1067,34 @@
968
1067
  return opened;
969
1068
  }
970
1069
 
971
- function render(snapshot) {
1070
+ function render(snapshot, options = {}) {
972
1071
  const rows = snapshot && Array.isArray(snapshot.rows)
973
1072
  ? snapshot.rows
974
1073
  : [];
1074
+ const validRows = rows.filter((row) =>
1075
+ row && typeof row.app === 'string' && row.app);
1076
+ const liveApps = new Set(validRows.map((row) => row.app));
1077
+ [...completions.keys()].forEach((app) => {
1078
+ if (liveApps.has(app)) removeCompletion(app);
1079
+ });
1080
+ const manualApps = new Set(snapshot && Array.isArray(snapshot.settings)
1081
+ ? snapshot.settings.filter((setting) =>
1082
+ setting && setting.mode === 'manual').map((setting) => setting.app)
1083
+ : []);
1084
+ manualApps.forEach(removeCompletion);
1085
+
1086
+ const completion = snapshot && snapshot.completion;
1087
+ if (options.acceptCompletion && completion &&
1088
+ completion.outcome === 'no_possible_duplicates' &&
1089
+ typeof completion.app === 'string' && completion.app &&
1090
+ visibleCheckingApps.has(completion.app) &&
1091
+ !liveApps.has(completion.app) &&
1092
+ !manualApps.has(completion.app)) {
1093
+ startCompletion(completion.app);
1094
+ }
1095
+
975
1096
  const fragment = document.createDocumentFragment();
976
- rows.forEach((row) => {
977
- if (!row || typeof row.app !== 'string' || !row.app) {
978
- return;
979
- }
1097
+ validRows.forEach((row) => {
980
1098
  const item = document.createElement('div');
981
1099
  item.className = 'vault-auto-scan-row';
982
1100
  item.dataset.state = row.state || 'checking';
@@ -988,6 +1106,7 @@
988
1106
  close.title = 'Dismiss';
989
1107
  close.textContent = '×';
990
1108
  close.addEventListener('click', async () => {
1109
+ if (row.state === 'checking') visibleCheckingApps.delete(row.app);
991
1110
  close.disabled = true;
992
1111
  try {
993
1112
  const result = await requestAction(
@@ -1000,6 +1119,9 @@
1000
1119
  removeRow(item);
1001
1120
  } catch (error) {
1002
1121
  console.warn('[Disk Saver] Automatic check dismissal failed', error);
1122
+ if (row.state === 'checking' && item.isConnected) {
1123
+ visibleCheckingApps.add(row.app);
1124
+ }
1003
1125
  close.disabled = false;
1004
1126
  }
1005
1127
  });
@@ -1088,8 +1210,16 @@
1088
1210
  item.append(close, icon, copy, controls);
1089
1211
  fragment.appendChild(item);
1090
1212
  });
1213
+ completions.forEach((completionRow) => {
1214
+ if (!liveApps.has(completionRow.app)) {
1215
+ fragment.appendChild(completionRow.item);
1216
+ }
1217
+ });
1091
1218
  tray.replaceChildren(fragment);
1092
1219
  tray.hidden = tray.childElementCount === 0;
1220
+ visibleCheckingApps = new Set(validRows
1221
+ .filter((row) => row.state === 'checking')
1222
+ .map((row) => row.app));
1093
1223
  }
1094
1224
 
1095
1225
  async function loadState() {
@@ -1119,12 +1249,13 @@
1119
1249
  '/info/vault/automatic-scans/events');
1120
1250
  eventSource.onmessage = (event) => {
1121
1251
  try {
1122
- render(JSON.parse(event.data));
1252
+ render(JSON.parse(event.data), { acceptCompletion: true });
1123
1253
  } catch (error) {
1124
1254
  console.debug('[Disk Saver] Invalid automatic check state', error);
1125
1255
  }
1126
1256
  };
1127
1257
  eventSource.onerror = () => {
1258
+ clearCompletions();
1128
1259
  loadState();
1129
1260
  };
1130
1261
  }
@@ -2274,8 +2274,8 @@ const renderOverview = () => {
2274
2274
  : activeScan
2275
2275
  ? `<i class="fa-solid fa-circle-notch fa-spin"></i>${esc(COPY.scanning)}`
2276
2276
  : `<i class="fa-solid fa-rotate"></i>${esc(idleScanLabel)}`
2277
- const firstGlobalScan = !IS_APP_MODE && !last && !activeScan
2278
- scanButton.classList.toggle("primary", firstGlobalScan)
2277
+ const firstScan = !last && !activeScan
2278
+ scanButton.classList.toggle("primary", firstScan)
2279
2279
  scanButton.disabled = busyElsewhere || state.scanCancelRequested
2280
2280
  if (state.automaticReviewRequested && !activeScan &&
2281
2281
  !scanButton.disabled) {
@@ -2296,7 +2296,7 @@ const renderOverview = () => {
2296
2296
  if (scanSizeMenu) {
2297
2297
  const scanSizeTrigger = scanSizeMenu.querySelector("summary")
2298
2298
  if (scanSizeTrigger) {
2299
- scanSizeTrigger.classList.toggle("primary", firstGlobalScan)
2299
+ scanSizeTrigger.classList.toggle("primary", firstScan)
2300
2300
  }
2301
2301
  scanSizeMenu.hidden = activeScan
2302
2302
  if (activeScan) scanSizeMenu.open = false
@@ -4102,6 +4102,48 @@ body.dark .disk-usage {
4102
4102
  flex: 0 0 auto;
4103
4103
  min-width: 0;
4104
4104
  }
4105
+ .appcanvas.vertical > aside .menu-actions #save-space-tab {
4106
+ width: 100%;
4107
+ max-width: none;
4108
+ box-sizing: border-box;
4109
+ }
4110
+ .app-vault-mode {
4111
+ flex: 0 0 auto;
4112
+ margin-left: auto;
4113
+ color: rgba(71, 85, 105, 0.82);
4114
+ font-size: 12px;
4115
+ font-weight: 500;
4116
+ letter-spacing: 0;
4117
+ line-height: 1.2;
4118
+ text-align: right;
4119
+ white-space: nowrap;
4120
+ }
4121
+ .app-vault-mode[data-mode="automatic"] {
4122
+ color: #b45309;
4123
+ }
4124
+ body.dark .app-vault-mode {
4125
+ color: rgba(203, 213, 225, 0.74);
4126
+ }
4127
+ body.dark .app-vault-mode[data-mode="automatic"] {
4128
+ color: #e0a54b;
4129
+ }
4130
+ .app-vault-mode-chevron,
4131
+ .app-autolaunch-chevron {
4132
+ display: inline-flex;
4133
+ align-items: center;
4134
+ justify-content: center;
4135
+ width: 10px;
4136
+ flex: 0 0 10px;
4137
+ margin-right: 0;
4138
+ padding: 0;
4139
+ color: var(--pinokio-sidebar-action-muted);
4140
+ font-size: 10px;
4141
+ line-height: 1;
4142
+ box-sizing: border-box;
4143
+ }
4144
+ .appcanvas > aside .menu-actions .header-item .tab .app-vault-mode-chevron {
4145
+ margin-right: 0;
4146
+ }
4105
4147
  .app-autolaunch-row {
4106
4148
  appearance: none;
4107
4149
  display: flex;
@@ -4171,19 +4213,14 @@ body.dark .disk-usage {
4171
4213
  .app-autolaunch-status {
4172
4214
  flex: 0 0 auto;
4173
4215
  color: var(--pinokio-sidebar-action-muted);
4174
- font-size: 11px;
4175
- font-weight: 700;
4176
- letter-spacing: 0.02em;
4216
+ font-size: 12px;
4217
+ font-weight: 500;
4218
+ letter-spacing: 0;
4177
4219
  white-space: nowrap;
4178
4220
  }
4179
4221
  .app-autolaunch-row[data-enabled="true"] .app-autolaunch-status {
4180
4222
  color: rgba(22, 101, 52, 0.95);
4181
4223
  }
4182
- .app-autolaunch-chevron {
4183
- flex: 0 0 auto;
4184
- color: var(--pinokio-sidebar-action-muted);
4185
- font-size: 10px;
4186
- }
4187
4224
  body.dark .app-autolaunch-row {
4188
4225
  background: transparent;
4189
4226
  color: var(--pinokio-sidebar-tab-muted);
@@ -4193,6 +4230,7 @@ body.dark .app-autolaunch.open .app-autolaunch-row {
4193
4230
  background: var(--pinokio-sidebar-tab-hover);
4194
4231
  color: var(--pinokio-sidebar-tab-active-color);
4195
4232
  }
4233
+ body.dark .app-vault-mode-chevron,
4196
4234
  body.dark .app-autolaunch-status,
4197
4235
  body.dark .app-autolaunch-chevron {
4198
4236
  color: var(--pinokio-sidebar-action-muted);
@@ -8163,6 +8201,7 @@ body.dark .pinokio-custom-terminal-header {
8163
8201
  <script src="/tippy-bundle.umd.min.js"></script>
8164
8202
  <script src="/tab-link-popover.js"></script>
8165
8203
  <script src="/browser-popout-surface.js"></script>
8204
+ <script src="/app-vault-mode.js" defer></script>
8166
8205
  <script>
8167
8206
  (function() {
8168
8207
  try {
@@ -8461,11 +8500,16 @@ body.dark .pinokio-custom-terminal-header {
8461
8500
  </div>
8462
8501
  </button>
8463
8502
  <% if (typeof vault_enabled !== 'undefined' && vault_enabled) { %>
8464
- <a id='save-space-tab' data-mode="refresh" target="app-vault" href="/vault/app/<%=encodeURIComponent(name)%>" class="btn header-item frame-link" data-index="vault" data-static="retain" data-tab-link-popover="false">
8503
+ <% const vaultAutomaticMode = typeof vault_automatic_mode === 'string' && vault_automatic_mode === 'manual' ? 'manual' : 'automatic' %>
8504
+ <a id='save-space-tab' data-mode="refresh" target="app-vault" href="/vault/app/<%=encodeURIComponent(name)%>" class="btn header-item frame-link" data-index="vault" data-static="retain" data-tab-link-popover="false" aria-label="Disk Saver — <%=vaultAutomaticMode === 'automatic' ? 'Automatic' : 'Manual'%> checking">
8465
8505
  <div class='tab'>
8466
8506
  <i class="fa-solid fa-hard-drive menu-action-leading-icon"></i>
8467
8507
  <div class='display'>Disk Saver</div>
8468
8508
  <div class='flexible'></div>
8509
+ <span class="app-vault-mode" data-app-vault-mode data-app="<%=name%>" data-mode="<%=vaultAutomaticMode%>" aria-hidden="true">
8510
+ <span data-app-vault-mode-label><%=vaultAutomaticMode === 'automatic' ? 'Auto' : 'Manual'%></span>
8511
+ </span>
8512
+ <i class="fa-solid fa-angle-down app-vault-mode-chevron" aria-hidden="true"></i>
8469
8513
  </div>
8470
8514
  </a>
8471
8515
  <% } %>
@@ -8474,7 +8518,7 @@ body.dark .pinokio-custom-terminal-header {
8474
8518
  <button type="button" class="app-autolaunch-row" data-app-autolaunch-button data-enabled="<%= autolaunch_app.autolaunch_enabled ? 'true' : 'false' %>" aria-haspopup="dialog" aria-expanded="false">
8475
8519
  <span class="app-autolaunch-label"><i class="fa-solid fa-power-off"></i><span>Autolaunch</span></span>
8476
8520
  <span class="app-autolaunch-spacer" aria-hidden="true"></span>
8477
- <span class="app-autolaunch-status" data-app-autolaunch-status><%= autolaunch_app.autolaunch_enabled ? 'ON' : 'OFF' %></span>
8521
+ <span class="app-autolaunch-status" data-app-autolaunch-status><%= autolaunch_app.autolaunch_enabled ? 'On' : 'Off' %></span>
8478
8522
  <i class="fa-solid fa-angle-down app-autolaunch-chevron" aria-hidden="true"></i>
8479
8523
  </button>
8480
8524
  <div class="app-autolaunch-modal hidden" data-app-autolaunch-modal role="dialog" aria-modal="true" aria-label="Autolaunch">
@@ -333,7 +333,8 @@
333
333
  opacity: 0.55;
334
334
  }
335
335
 
336
- .vault-auto-scan-row[data-state="result"] {
336
+ .vault-auto-scan-row[data-state="result"],
337
+ .vault-auto-scan-row[data-state="complete"] {
337
338
  grid-template-rows: 16px 30px;
338
339
  min-height: 66px;
339
340
  padding-top: 10px;
@@ -342,14 +343,25 @@
342
343
 
343
344
  .vault-auto-scan-row[data-state="result"] .vault-auto-scan-icon,
344
345
  .vault-auto-scan-row[data-state="result"] .vault-auto-scan-copy,
345
- .vault-auto-scan-row[data-state="result"] .vault-auto-scan-controls {
346
+ .vault-auto-scan-row[data-state="result"] .vault-auto-scan-controls,
347
+ .vault-auto-scan-row[data-state="complete"] .vault-auto-scan-icon,
348
+ .vault-auto-scan-row[data-state="complete"] .vault-auto-scan-copy {
346
349
  grid-row: 1 / 3;
347
350
  }
348
351
 
349
- .vault-auto-scan-row[data-state="result"] .vault-auto-scan-copy {
352
+ .vault-auto-scan-row[data-state="result"] .vault-auto-scan-copy,
353
+ .vault-auto-scan-row[data-state="complete"] .vault-auto-scan-copy {
350
354
  grid-template-rows: 16px 30px;
351
355
  }
352
356
 
357
+ .vault-auto-scan-row[data-state="complete"] .vault-auto-scan-copy {
358
+ grid-column: 2 / 4;
359
+ }
360
+
361
+ .vault-auto-scan-row[data-state="complete"] .vault-auto-scan-icon {
362
+ color: var(--vault-notice-accent);
363
+ }
364
+
353
365
  @keyframes vault-notice-enter {
354
366
  from { opacity: 0; transform: translateY(6px); }
355
367
  to { opacity: 1; transform: translateY(0); }
@@ -169,7 +169,7 @@
169
169
  }
170
170
  const renderStatus = () => {
171
171
  const enabled = !!(state && state.autolaunch_enabled)
172
- status.textContent = enabled ? "ON" : "OFF"
172
+ status.textContent = enabled ? "On" : "Off"
173
173
  button.dataset.enabled = enabled ? "true" : "false"
174
174
  button.setAttribute("aria-label", `Autolaunch. Start with Pinokio ${enabled ? "On" : "Off"}`)
175
175
  switchButton.setAttribute("aria-checked", enabled ? "true" : "false")
@@ -122,7 +122,7 @@ function appAutolaunchMarkup(initialApp) {
122
122
  <div class="app-autolaunch" data-app-autolaunch data-app-id="target">
123
123
  <button type="button" class="app-autolaunch-row" data-app-autolaunch-button data-enabled="${initialApp.autolaunch_enabled ? "true" : "false"}" aria-haspopup="dialog" aria-expanded="false">
124
124
  <span class="app-autolaunch-label">Autolaunch</span>
125
- <span class="app-autolaunch-status" data-app-autolaunch-status>${initialApp.autolaunch_enabled ? "ON" : "OFF"}</span>
125
+ <span class="app-autolaunch-status" data-app-autolaunch-status>${initialApp.autolaunch_enabled ? "On" : "Off"}</span>
126
126
  </button>
127
127
  <div class="app-autolaunch-modal hidden" data-app-autolaunch-modal role="dialog" aria-modal="true" aria-label="Autolaunch">
128
128
  <button type="button" class="app-autolaunch-switch" role="switch" aria-checked="${initialApp.autolaunch_enabled ? "true" : "false"}" data-app-autolaunch-switch aria-label="Start with Pinokio">
@@ -478,7 +478,7 @@ browserTest("browser: selecting a launch script from empty state persists and st
478
478
  enabled: false
479
479
  })
480
480
  assert.equal(await page.isChecked('input[name="app-autolaunch-script"][value="start.js"]'), true)
481
- assert.equal((await textContent(page, "[data-app-autolaunch-status]")).trim(), "OFF")
481
+ assert.equal((await textContent(page, "[data-app-autolaunch-status]")).trim(), "Off")
482
482
  })
483
483
  })
484
484
 
@@ -532,7 +532,7 @@ browserTest("browser: startup toggle from empty selection saves the single eligi
532
532
  })
533
533
  assert.equal(state.launchRequirementsGets, 0)
534
534
  assert.equal(await page.isChecked('input[name="app-autolaunch-script"][value="start.js"]'), true)
535
- assert.equal((await textContent(page, "[data-app-autolaunch-status]")).trim(), "ON")
535
+ assert.equal((await textContent(page, "[data-app-autolaunch-status]")).trim(), "On")
536
536
  })
537
537
  })
538
538
 
@@ -570,7 +570,7 @@ browserTest("browser: startup toggle from empty selection warns when no eligible
570
570
  })
571
571
 
572
572
  assert.equal(state.autolaunchPosts.length, 0, scenario.name)
573
- assert.equal((await textContent(page, "[data-app-autolaunch-status]")).trim(), "OFF")
573
+ assert.equal((await textContent(page, "[data-app-autolaunch-status]")).trim(), "Off")
574
574
  })
575
575
  }
576
576
  })
@@ -227,6 +227,234 @@ test("checking notices expose settings, Pause, and dismissal", async () => {
227
227
  dom.window.close()
228
228
  })
229
229
 
230
+ test("an empty automatic check briefly confirms completion", async () => {
231
+ const template = await fs.promises.readFile(
232
+ path.join(root, "server", "views", "layout.ejs"), "utf8")
233
+ const script = await fs.promises.readFile(
234
+ path.join(root, "server", "public", "layout.js"), "utf8")
235
+ const html = ejs.render(template, {
236
+ theme: "light",
237
+ agent: "web",
238
+ initialPath: "/v/ComfyUI",
239
+ defaultPath: "/home",
240
+ sessionId: null,
241
+ vaultEnabled: true
242
+ })
243
+ const dom = new JSDOM(html, {
244
+ runScripts: "outside-only",
245
+ pretendToBeVisual: true,
246
+ url: "http://localhost/"
247
+ })
248
+ const eventSources = []
249
+ const requests = []
250
+ const completionTimers = []
251
+ const nativeSetTimeout = dom.window.setTimeout.bind(dom.window)
252
+ const nativeClearTimeout = dom.window.clearTimeout.bind(dom.window)
253
+ dom.window.setTimeout = (callback, delay, ...args) => {
254
+ if (delay <= 4000 && delay > 3500) {
255
+ const timer = {
256
+ callback,
257
+ delay,
258
+ cleared: false,
259
+ id: 10000 + completionTimers.length
260
+ }
261
+ completionTimers.push(timer)
262
+ return timer.id
263
+ }
264
+ return nativeSetTimeout(callback, delay, ...args)
265
+ }
266
+ dom.window.clearTimeout = (id) => {
267
+ const timer = completionTimers.find((candidate) => candidate.id === id)
268
+ if (timer) {
269
+ timer.cleared = true
270
+ return
271
+ }
272
+ nativeClearTimeout(id)
273
+ }
274
+ dom.window.EventSource = class EventSource {
275
+ constructor(url) {
276
+ this.url = url
277
+ eventSources.push(this)
278
+ }
279
+ close() {}
280
+ }
281
+ dom.window.fetch = async (url, options = {}) => {
282
+ requests.push({ url, options })
283
+ if (url === "/info/vault/automatic-scans") {
284
+ return {
285
+ ok: true,
286
+ status: 200,
287
+ json: async () => ({
288
+ enabled: true,
289
+ rows: [],
290
+ settings: [{ app: "ComfyUI", mode: "automatic" }]
291
+ })
292
+ }
293
+ }
294
+ if (url === "/vault/action") {
295
+ return {
296
+ ok: true,
297
+ status: 200,
298
+ json: async () => ({ dismissed: true, app: "ComfyUI" })
299
+ }
300
+ }
301
+ throw new Error(`Unexpected request: ${url}`)
302
+ }
303
+
304
+ dom.window.eval(script)
305
+ await waitFor(() => eventSources.length === 1)
306
+ const send = (payload) => eventSources[0].onmessage({
307
+ data: JSON.stringify(payload)
308
+ })
309
+
310
+ send({
311
+ enabled: true,
312
+ rows: [],
313
+ settings: [{ app: "ComfyUI", mode: "automatic" }],
314
+ completion: {
315
+ app: "ComfyUI",
316
+ outcome: "no_possible_duplicates"
317
+ }
318
+ })
319
+ const tray = dom.window.document.getElementById("vault-auto-scan-tray")
320
+ assert.equal(tray.hidden, true,
321
+ "a completion event cannot appear without a visible checking row")
322
+
323
+ send({
324
+ enabled: true,
325
+ rows: [
326
+ {
327
+ app: "ComfyUI",
328
+ state: "checking",
329
+ notice_id: "checking:1:"
330
+ },
331
+ {
332
+ app: "OtherApp",
333
+ state: "paused",
334
+ notice_id: "paused:1:"
335
+ }
336
+ ],
337
+ settings: [
338
+ { app: "ComfyUI", mode: "automatic" },
339
+ { app: "OtherApp", mode: "manual" }
340
+ ]
341
+ })
342
+ send({
343
+ enabled: true,
344
+ rows: [{
345
+ app: "OtherApp",
346
+ state: "paused",
347
+ notice_id: "paused:1:"
348
+ }],
349
+ settings: [
350
+ { app: "ComfyUI", mode: "automatic" },
351
+ { app: "OtherApp", mode: "manual" }
352
+ ],
353
+ completion: {
354
+ app: "ComfyUI",
355
+ outcome: "no_possible_duplicates"
356
+ }
357
+ })
358
+
359
+ const completed = tray.querySelector(
360
+ '.vault-auto-scan-row[data-state="complete"]')
361
+ assert.ok(completed)
362
+ assert.equal(completed.querySelector(
363
+ ".vault-auto-scan-app").textContent, "ComfyUI")
364
+ assert.equal(completed.querySelector(
365
+ ".vault-auto-scan-status").textContent,
366
+ "No possible duplicate files found")
367
+ assert.ok(completed.querySelector(".vault-auto-scan-icon svg"))
368
+ assert.equal(completed.querySelector(".vault-auto-scan-settings"), null)
369
+ assert.equal(completed.querySelector(".vault-auto-scan-action"), null)
370
+ assert.equal(completionTimers[0].delay, 4000)
371
+
372
+ tray.querySelector(
373
+ '.vault-auto-scan-row[data-state="paused"] .vault-auto-scan-action').click()
374
+ await waitFor(() => requests.some((request) =>
375
+ request.url === "/info/vault/automatic-scans"))
376
+ await new Promise((resolve) => nativeSetTimeout(resolve, 0))
377
+ assert.equal(completed.isConnected, true,
378
+ "refreshing durable tray state preserves an active completion")
379
+ assert.equal(completionTimers.length, 1,
380
+ "a durable state refresh does not restart the completion timer")
381
+
382
+ completed.dispatchEvent(new dom.window.MouseEvent("mouseenter"))
383
+ assert.equal(completionTimers[0].cleared, true)
384
+ completed.dispatchEvent(new dom.window.MouseEvent("mouseleave"))
385
+ assert.equal(completionTimers.length, 2)
386
+ assert.ok(completionTimers[1].delay <= 4000)
387
+
388
+ const closeButton = completed.querySelector(".vault-auto-scan-close")
389
+ closeButton.dispatchEvent(new dom.window.FocusEvent("focusin", {
390
+ bubbles: true
391
+ }))
392
+ assert.equal(completionTimers[1].cleared, true)
393
+ closeButton.dispatchEvent(new dom.window.FocusEvent("focusout", {
394
+ bubbles: true,
395
+ relatedTarget: null
396
+ }))
397
+ assert.equal(completionTimers.length, 3)
398
+ completionTimers[2].callback()
399
+ assert.equal(tray.hidden, true)
400
+
401
+ send({
402
+ enabled: true,
403
+ rows: [{
404
+ app: "ComfyUI",
405
+ state: "checking",
406
+ notice_id: "checking:2:"
407
+ }],
408
+ settings: [{ app: "ComfyUI", mode: "automatic" }]
409
+ })
410
+ send({
411
+ enabled: true,
412
+ rows: [],
413
+ settings: [{ app: "ComfyUI", mode: "automatic" }],
414
+ completion: {
415
+ app: "ComfyUI",
416
+ outcome: "no_possible_duplicates"
417
+ }
418
+ })
419
+ assert.equal(tray.hidden, false)
420
+ eventSources[0].onerror()
421
+ await new Promise((resolve) => nativeSetTimeout(resolve, 0))
422
+ assert.equal(tray.hidden, true,
423
+ "a reconnect drops presentation-only completion state")
424
+
425
+ send({
426
+ enabled: true,
427
+ rows: [{
428
+ app: "ComfyUI",
429
+ state: "checking",
430
+ notice_id: "checking:3:"
431
+ }],
432
+ settings: [{ app: "ComfyUI", mode: "automatic" }]
433
+ })
434
+ tray.querySelector(".vault-auto-scan-close").click()
435
+ send({
436
+ enabled: true,
437
+ rows: [],
438
+ settings: [{ app: "ComfyUI", mode: "automatic" }],
439
+ completion: {
440
+ app: "ComfyUI",
441
+ outcome: "no_possible_duplicates"
442
+ }
443
+ })
444
+ await waitFor(() => tray.hidden)
445
+ assert.equal(tray.querySelector(
446
+ '.vault-auto-scan-row[data-state="complete"]'), null,
447
+ "a closed checking row cannot be replaced by completion")
448
+ assert.ok(requests.some((request) => {
449
+ if (request.url !== "/vault/action" || !request.options.body) return false
450
+ const payload = JSON.parse(request.options.body)
451
+ return payload.action === "automatic_dismiss" &&
452
+ payload.notice_id === "checking:3:"
453
+ }))
454
+
455
+ dom.window.close()
456
+ })
457
+
230
458
  test("the shared layout does not initialize automatic notices when Vault is disabled", async () => {
231
459
  const template = await fs.promises.readFile(
232
460
  path.join(root, "server", "views", "layout.ejs"), "utf8")
@@ -290,3 +518,80 @@ test("the app workspace focuses Scan this app without starting it after Review",
290
518
  assert.doesNotMatch(source,
291
519
  /automaticReviewRequested[\s\S]{0,200}(post\(|btn-scan\.click)/)
292
520
  })
521
+
522
+ test("the app sidebar mirrors Automatic and Manual Disk Saver modes", async () => {
523
+ const template = await fs.promises.readFile(
524
+ path.join(root, "server", "views", "app.ejs"), "utf8")
525
+ const server = await fs.promises.readFile(
526
+ path.join(root, "server", "index.js"), "utf8")
527
+ const script = await fs.promises.readFile(
528
+ path.join(root, "server", "public", "app-vault-mode.js"), "utf8")
529
+ const dom = new JSDOM(`<a id="save-space-tab">
530
+ <span data-app-vault-mode data-app="ComfyUI" data-mode="automatic">
531
+ <span data-app-vault-mode-label>Auto</span>
532
+ </span>
533
+ </a>`, {
534
+ runScripts: "outside-only",
535
+ url: "http://localhost/v/ComfyUI"
536
+ })
537
+ const eventSources = []
538
+ dom.window.EventSource = class EventSource {
539
+ constructor(url) {
540
+ this.url = url
541
+ eventSources.push(this)
542
+ }
543
+ close() {}
544
+ }
545
+
546
+ dom.window.eval(script)
547
+ const status = dom.window.document.querySelector("[data-app-vault-mode]")
548
+ const label = status.querySelector("[data-app-vault-mode-label]")
549
+ assert.equal(eventSources.length, 1)
550
+ assert.equal(eventSources[0].url,
551
+ "/info/vault/automatic-scans/events")
552
+ assert.equal(status.dataset.mode, "automatic")
553
+ assert.equal(status.hidden, false)
554
+ assert.equal(label.textContent, "Auto")
555
+ assert.equal(dom.window.document.getElementById("save-space-tab")
556
+ .getAttribute("aria-label"), "Disk Saver — Automatic checking")
557
+
558
+ eventSources[0].onmessage({
559
+ data: JSON.stringify({
560
+ settings: [{ app: "ComfyUI", mode: "manual" }]
561
+ })
562
+ })
563
+ assert.equal(status.dataset.mode, "manual")
564
+ assert.equal(status.hidden, false)
565
+ assert.equal(label.textContent, "Manual")
566
+ assert.equal(dom.window.document.getElementById("save-space-tab")
567
+ .getAttribute("aria-label"), "Disk Saver — Manual checking")
568
+
569
+ eventSources[0].onmessage({ data: JSON.stringify({ settings: [] }) })
570
+ assert.equal(status.dataset.mode, "automatic")
571
+ assert.equal(status.hidden, false)
572
+ assert.equal(label.textContent, "Auto")
573
+ assert.match(template, /data-app-vault-mode/)
574
+ assert.match(template, /app-vault-mode\.js/)
575
+ assert.match(template,
576
+ /#save-space-tab\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*none;/s)
577
+ assert.match(template,
578
+ /\.app-vault-mode\s*\{[^}]*margin-left:\s*auto;[^}]*font-size:\s*12px;[^}]*font-weight:\s*500;/s)
579
+ assert.match(template,
580
+ /\.app-vault-mode-chevron,\s*\.app-autolaunch-chevron\s*\{[^}]*width:\s*10px;[^}]*flex:\s*0 0 10px;/s)
581
+ assert.doesNotMatch(template,
582
+ /\.app-vault-mode\s*\{[^}]*(background|border-radius|padding):/s)
583
+ assert.match(template,
584
+ /vaultAutomaticMode === 'automatic' \? 'Auto' : 'Manual'/)
585
+ assert.match(template,
586
+ /fa-solid fa-angle-down app-vault-mode-chevron/)
587
+ assert.match(template,
588
+ /autolaunch_app\.autolaunch_enabled \? 'On' : 'Off'/)
589
+ assert.match(template,
590
+ /\.app-autolaunch-status\s*\{[^}]*font-size:\s*12px;[^}]*font-weight:\s*500;[^}]*letter-spacing:\s*0;/s)
591
+ assert.doesNotMatch(template, /data-app-vault-mode[^>]*hidden/)
592
+ assert.doesNotMatch(template, /app-vault-mode-dot/)
593
+ assert.match(server,
594
+ /result\.vault_automatic_mode = setting && setting\.mode === "manual"/)
595
+
596
+ dom.window.close()
597
+ })
@@ -409,12 +409,29 @@ describe("automatic app checks", () => {
409
409
  await handle.close()
410
410
  }
411
411
  const vault = await makeVault(home)
412
+ const broadcasts = []
413
+ const unsubscribe = vault.automaticScans.subscribe((snapshot) => {
414
+ broadcasts.push(snapshot)
415
+ })
412
416
 
413
417
  vault.automaticScans.queueApp("threshold-app")
414
418
  await waitFor(() => !vault.automaticScans.active &&
415
419
  !vault.automaticScans.entries.has("threshold-app"))
416
420
 
417
421
  assert.deepEqual(vault.automaticScans.snapshot().rows, [])
422
+ assert.deepEqual(broadcasts.find((snapshot) => snapshot.completion)
423
+ ?.completion, {
424
+ app: "threshold-app",
425
+ outcome: "no_possible_duplicates"
426
+ })
427
+ assert.equal("completion" in vault.automaticScans.snapshot(), false)
428
+ let restoredSnapshot = null
429
+ const unsubscribeRestored = vault.automaticScans.subscribe((snapshot) => {
430
+ restoredSnapshot = snapshot
431
+ })
432
+ assert.equal("completion" in restoredSnapshot, false)
433
+ unsubscribeRestored()
434
+ unsubscribe()
418
435
  assert.equal(await vault.registry.scanFor("app:threshold-app"), null)
419
436
  await close(vault)
420
437
  })
@@ -2184,6 +2184,26 @@ describe("Save Space interface", () => {
2184
2184
  dom.window.close()
2185
2185
  })
2186
2186
 
2187
+ test("a fresh app workspace makes Scan this app primary", async () => {
2188
+ const status = fixture([], {
2189
+ last_scan: null,
2190
+ bytes_without_sharing: 0,
2191
+ bytes_on_disk: 0,
2192
+ saved_by_sharing: 0,
2193
+ effective_bytes: 0
2194
+ })
2195
+ const { dom } = await makePage(status, {
2196
+ appMode: true,
2197
+ scopeId: "app:app"
2198
+ })
2199
+ const scanButton = dom.window.document.getElementById("btn-scan")
2200
+
2201
+ assert.match(scanButton.textContent, /Scan this app/)
2202
+ assert.equal(scanButton.classList.contains("primary"), true)
2203
+
2204
+ dom.window.close()
2205
+ })
2206
+
2187
2207
  test("app mode uses full-width results without redundant locations", async () => {
2188
2208
  const status = fixture([item()])
2189
2209
  const { dom, requests } = await makePage(status, {