@huaqiu/dsh-tool-plugin-update 0.6.6 → 0.6.7

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.
@@ -21,7 +21,15 @@ import {
21
21
  observeHostLocale,
22
22
  type HostLocale,
23
23
  } from '@huaqiu/dsh-sidebar-entry'
24
- import { checkUpdates, fetchReport, type PluginUpdateReport } from './api.js'
24
+ import {
25
+ checkUpdates,
26
+ fetchJobStatus,
27
+ fetchReport,
28
+ TERMINAL_PHASES,
29
+ type PluginUpdateReport,
30
+ type UpdateJob,
31
+ type UpdateJobStatus,
32
+ } from './api.js'
25
33
  import { COPY } from './copy.js'
26
34
  import { PLUGIN_ICON } from './icons.js'
27
35
  import { openUpdateView, type UpdateStoreLike } from './update-view.js'
@@ -767,6 +775,97 @@ const VIEW_CSS_TEXT = `
767
775
  padding: 0 24px 20px;
768
776
  }
769
777
 
778
+ /* ── Durable job (active / result) ────────────────────────────────────── */
779
+ .hqpu-job {
780
+ display: flex;
781
+ flex-direction: column;
782
+ gap: 6px;
783
+ padding: 12px;
784
+ border: 0.5px solid var(--dsw-alias-border-l2);
785
+ border-radius: var(--dsw-radius-lg);
786
+ background: var(--dsw-alias-bg-layer-3);
787
+ }
788
+ .hqpu-job-meta {
789
+ font-variant-numeric: tabular-nums;
790
+ }
791
+ .hqpu-progress {
792
+ height: 6px;
793
+ border-radius: 999px;
794
+ background: var(--dsw-alias-bg-module-platform);
795
+ overflow: hidden;
796
+ }
797
+ .hqpu-progress-fill {
798
+ height: 100%;
799
+ border-radius: 999px;
800
+ background: var(--dsw-alias-state-business-primary);
801
+ transition: width var(--ds-transition-duration, 0.2s) ease;
802
+ }
803
+ .hqpu-job-steps {
804
+ display: flex;
805
+ flex-direction: column;
806
+ gap: 4px;
807
+ }
808
+ .hqpu-steps {
809
+ margin: 0;
810
+ padding: 0;
811
+ list-style: none;
812
+ display: flex;
813
+ flex-direction: column;
814
+ gap: 4px;
815
+ }
816
+ .hqpu-step {
817
+ display: flex;
818
+ align-items: center;
819
+ gap: 6px;
820
+ font-size: 12.5px;
821
+ line-height: 18px;
822
+ color: var(--dsw-alias-label-secondary);
823
+ }
824
+ .hqpu-step--skipped,
825
+ .hqpu-step--pending { opacity: 0.6; }
826
+
827
+ /* ── Per-plugin change list + release notes ────────────────────────────── */
828
+ .hqpu-changes {
829
+ padding: 0 8px 8px 70px;
830
+ }
831
+ .hqpu-changes-list,
832
+ .hqpu-release-notes {
833
+ margin: 4px 0 0;
834
+ padding-left: 18px;
835
+ font-size: 12.5px;
836
+ line-height: 19px;
837
+ color: var(--dsw-alias-label-secondary);
838
+ }
839
+ .hqpu-release-link {
840
+ align-self: flex-start;
841
+ }
842
+
843
+ /* ── Advanced disclosure (source editors) ───────────────────────────────── */
844
+ .hqpu-advanced > summary {
845
+ cursor: pointer;
846
+ font-size: 12.5px;
847
+ line-height: 20px;
848
+ color: var(--dsw-alias-label-tertiary);
849
+ list-style: none;
850
+ padding: 2px 0;
851
+ }
852
+ .hqpu-advanced > summary::-webkit-details-marker { display: none; }
853
+ .hqpu-advanced > summary::before {
854
+ content: '▸ ';
855
+ }
856
+ .hqpu-advanced[open] > summary::before {
857
+ content: '▾ ';
858
+ }
859
+
860
+ /* Danger-toned banner (check failure, breaking changes) */
861
+ .hqpu-banner[data-tone='danger'] {
862
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
863
+ color: var(--dsw-alias-label-secondary);
864
+ }
865
+ .hqpu-banner[data-tone='danger'] > svg {
866
+ color: var(--dsw-alias-state-error-primary);
867
+ }
868
+
770
869
  .hqpu-visually-hidden {
771
870
  position: absolute;
772
871
  width: 1px;
@@ -929,8 +1028,15 @@ export interface ClientContext {
929
1028
  class UpdateStore implements UpdateStoreLike {
930
1029
  private current: PluginUpdateReport | null = null
931
1030
  private installingFlag = false
1031
+ /** The durable job the server currently reports as active (resumed across
1032
+ * panel close and DSH restart); null when idle. */
1033
+ private active: UpdateJob | null = null
1034
+ private recent: UpdateJob[] = []
932
1035
  private readonly listeners = new Set<() => void>()
933
1036
  private refreshTimer: number | null = null
1037
+ private pollTimer: number | null = null
1038
+ private pollBackoffMs = 1_000
1039
+ private pollInFlight = false
934
1040
 
935
1041
  report(): PluginUpdateReport | null {
936
1042
  return this.current
@@ -940,6 +1046,14 @@ class UpdateStore implements UpdateStoreLike {
940
1046
  return this.installingFlag
941
1047
  }
942
1048
 
1049
+ activeJob(): UpdateJob | null {
1050
+ return this.active
1051
+ }
1052
+
1053
+ recentJobs(): UpdateJob[] {
1054
+ return [...this.recent]
1055
+ }
1056
+
943
1057
  subscribe(listener: () => void): () => void {
944
1058
  this.listeners.add(listener)
945
1059
  return () => {
@@ -947,15 +1061,28 @@ class UpdateStore implements UpdateStoreLike {
947
1061
  }
948
1062
  }
949
1063
 
950
- /** Badge count per spec §6.3: actionable `update` + `install`, excluding the
951
- * ignored offer (the server already folds ignore into item states; keep the
952
- * client-side filter as a defensive second pass). */
1064
+ /**
1065
+ * Badge: counts actionable plugin updates AND an available runtime update AND
1066
+ * (while a job runs) an attention pill. A recent failed/rolled-back job with
1067
+ * nothing actionable left also keeps a dot, so the outcome is never silently
1068
+ * forgotten after the panel closes.
1069
+ */
953
1070
  badgeCount(): number {
1071
+ const job = this.active
1072
+ if (job !== null && !TERMINAL_PHASES.has(job.phase)) return 1
954
1073
  const report = this.current
955
1074
  if (report === null) return 0
956
- return report.items.filter(
1075
+ let count = report.items.filter(
957
1076
  (item) => item.state === 'update' || item.state === 'install',
958
1077
  ).length
1078
+ if (report.runtime?.updateAvailable) count += 1
1079
+ if (count === 0) {
1080
+ const latest = this.recent[0]
1081
+ if (latest !== undefined && (latest.phase === 'failed' || latest.phase === 'rolled-back')) {
1082
+ count = 1
1083
+ }
1084
+ }
1085
+ return count
959
1086
  }
960
1087
 
961
1088
  private emit(): void {
@@ -968,6 +1095,71 @@ class UpdateStore implements UpdateStoreLike {
968
1095
  }
969
1096
  }
970
1097
 
1098
+ /** Adopt a fresh `/status` snapshot and (re)arm the poll loop. */
1099
+ private applyStatus(status: UpdateJobStatus): void {
1100
+ const wasActive = this.active !== null
1101
+ this.active = status.activeJob
1102
+ this.recent = status.recentJobs
1103
+ if (wasActive && this.active === null) {
1104
+ // The active job settled (moved to recent/terminal) — the installing
1105
+ // footer state must clear on its own, without the panel's closure.
1106
+ this.installingFlag = false
1107
+ }
1108
+ this.emit()
1109
+ this.schedulePoll()
1110
+ }
1111
+
1112
+ /**
1113
+ * Fetch the durable job status. Called on mount, on panel open (BEFORE
1114
+ * discovery) and by the poll loop. A failure (older hq-edge with no
1115
+ * `/status`, or a transient HTTP error) is swallowed: the last known job is
1116
+ * kept and the poller backs off and retries.
1117
+ */
1118
+ async fetchStatus(): Promise<void> {
1119
+ try {
1120
+ const status = await fetchJobStatus()
1121
+ this.pollBackoffMs = 1_000
1122
+ this.applyStatus(status)
1123
+ } catch {
1124
+ this.schedulePoll()
1125
+ }
1126
+ }
1127
+
1128
+ /** ~1 s poll while a job is active; stop on terminal; back off on error
1129
+ * without discarding the last known job; never overlap requests. */
1130
+ private schedulePoll(): void {
1131
+ if (this.pollTimer !== null) {
1132
+ window.clearTimeout(this.pollTimer)
1133
+ this.pollTimer = null
1134
+ }
1135
+ const job = this.active
1136
+ if (job === null || TERMINAL_PHASES.has(job.phase)) {
1137
+ this.pollBackoffMs = 1_000
1138
+ return
1139
+ }
1140
+ this.pollTimer = window.setTimeout(() => {
1141
+ void this.pollTick()
1142
+ }, this.pollBackoffMs)
1143
+ }
1144
+
1145
+ private async pollTick(): Promise<void> {
1146
+ if (this.pollInFlight) {
1147
+ this.schedulePoll()
1148
+ return
1149
+ }
1150
+ this.pollInFlight = true
1151
+ try {
1152
+ const status = await fetchJobStatus()
1153
+ this.pollBackoffMs = 1_000
1154
+ this.applyStatus(status)
1155
+ } catch {
1156
+ this.pollBackoffMs = Math.min(5_000, Math.max(1_000, this.pollBackoffMs * 2))
1157
+ this.schedulePoll()
1158
+ } finally {
1159
+ this.pollInFlight = false
1160
+ }
1161
+ }
1162
+
971
1163
  async refresh(): Promise<PluginUpdateReport | null> {
972
1164
  try {
973
1165
  const report = await fetchReport()
@@ -1009,6 +1201,10 @@ class UpdateStore implements UpdateStoreLike {
1009
1201
  window.clearInterval(this.refreshTimer)
1010
1202
  this.refreshTimer = null
1011
1203
  }
1204
+ if (this.pollTimer !== null) {
1205
+ window.clearTimeout(this.pollTimer)
1206
+ this.pollTimer = null
1207
+ }
1012
1208
  this.listeners.clear()
1013
1209
  }
1014
1210
  }
@@ -1060,10 +1256,31 @@ export function apply(ctx: ClientContext): () => void {
1060
1256
  // publishes them after this plugin mounted must win, and one that never
1061
1257
  // does must keep the canonical fallbacks.
1062
1258
  ensureDesignTokens()
1063
- // Always re-check on open so the review screen never shows stale data.
1064
- void store.check().then((report) => {
1065
- if (report !== null) openUpdateView(store, copyFor())
1066
- })
1259
+ // Resume-before-discovery: read the durable job status FIRST so an in-flight
1260
+ // install/runtime update is resumed (and its view rendered) even while the
1261
+ // update check round trip is still running. The panel itself opens from
1262
+ // whatever the store already holds; check() refreshes the list behind it.
1263
+ void store.fetchStatus()
1264
+ openUpdateView(store, copyFor())
1265
+ void store.check()
1266
+ }
1267
+
1268
+ // While a job runs the row label shows its progress ("更新中 42%"), and the
1269
+ // tooltip names the phase. Store changes re-apply the label (the badge
1270
+ // subscription already re-renders the count pill).
1271
+ const statusLabel = (): { label: string; tooltip: string } => {
1272
+ const base = entryCopy(locale)
1273
+ const job = store.activeJob()
1274
+ if (job !== null) {
1275
+ const cc = copyFor()
1276
+ const pct = job.progress.percent
1277
+ const phase = cc.job.phase[job.phase] ?? job.phase
1278
+ return {
1279
+ label: pct !== null ? `${base.label} · ${Math.round(pct)}%` : `${base.label} · ${phase}`,
1280
+ tooltip: `${base.tooltip} · ${phase}`,
1281
+ }
1282
+ }
1283
+ return base
1067
1284
  }
1068
1285
 
1069
1286
  const disposeEntry = mountSidebarEntry({
@@ -1076,8 +1293,8 @@ export function apply(ctx: ClientContext): () => void {
1076
1293
  // `refresh.subscribe`'s listener, which re-applies these) so the row
1077
1294
  // re-localizes when the host UI language changes — matching the sibling
1078
1295
  // family rows, which read a live `currentLang` variable the same way.
1079
- label: () => entryCopy(locale).label,
1080
- tooltip: () => entryCopy(locale).tooltip,
1296
+ label: () => statusLabel().label,
1297
+ tooltip: () => statusLabel().tooltip,
1081
1298
  refresh: { subscribe: refreshSubscribe },
1082
1299
  badge: {
1083
1300
  subscribe: (listener) => store.subscribe(listener),
@@ -1088,8 +1305,21 @@ export function apply(ctx: ClientContext): () => void {
1088
1305
  familySelectors: HUAQIU_SIDEBAR_ENTRY_FAMILY,
1089
1306
  })
1090
1307
 
1308
+ // Store changes (active-job percentage, result dot) also re-apply the row
1309
+ // label, not just the badge count.
1310
+ store.subscribe(() => {
1311
+ for (const fn of [...localeSubscribers]) {
1312
+ try {
1313
+ fn()
1314
+ } catch {
1315
+ /* one bad subscriber must not strand the rest */
1316
+ }
1317
+ }
1318
+ })
1319
+
1091
1320
  store.startIdleRefresh()
1092
1321
  void store.refresh()
1322
+ void store.fetchStatus()
1093
1323
 
1094
1324
  const dispose = (): void => {
1095
1325
  try {