@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.3

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.
Files changed (69) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +8 -6
  3. package/README.zh.md +8 -6
  4. package/lib/client.js +154 -594
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +323 -908
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +8 -9
  9. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/RemoteSettingsCard.js +1 -1
  11. package/lib/types/client/index.d.ts +32 -3
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +51 -8
  14. package/lib/types/client/locales.d.ts +0 -84
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +0 -84
  17. package/lib/types/client/settings-entry-form.d.ts +50 -0
  18. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  19. package/lib/types/client/settings-entry-form.js +98 -0
  20. package/lib/types/index.d.ts +99 -7
  21. package/lib/types/index.d.ts.map +1 -1
  22. package/lib/types/index.js +104 -137
  23. package/lib/types/lan-bind-plan.d.ts +14 -0
  24. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  25. package/lib/types/lan-bind-plan.js +16 -0
  26. package/lib/types/lan-bind.d.ts +1 -1
  27. package/lib/types/lan-bind.js +1 -1
  28. package/lib/types/mount-once.d.ts +38 -7
  29. package/lib/types/mount-once.d.ts.map +1 -1
  30. package/lib/types/mount-once.js +96 -12
  31. package/lib/types/tunnel.d.ts +60 -3
  32. package/lib/types/tunnel.d.ts.map +1 -1
  33. package/lib/types/tunnel.js +136 -3
  34. package/package.json +2 -2
  35. package/src/client/RemoteEntry.tsx +7 -9
  36. package/src/client/RemoteSettingsCard.tsx +0 -2
  37. package/src/client/index.ts +53 -9
  38. package/src/client/locales.ts +0 -84
  39. package/src/client/remote.module.css +50 -200
  40. package/src/client/settings-entry-form.ts +142 -0
  41. package/src/index.ts +156 -152
  42. package/src/lan-bind-plan.ts +22 -0
  43. package/src/lan-bind.ts +1 -1
  44. package/src/mount-once.ts +107 -13
  45. package/src/tunnel.ts +142 -3
  46. package/lib/types/client/UpdateEntry.d.ts +0 -14
  47. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  48. package/lib/types/client/UpdateEntry.js +0 -112
  49. package/lib/types/client/UpdatePanel.d.ts +0 -42
  50. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  51. package/lib/types/client/UpdatePanel.js +0 -101
  52. package/lib/types/client/update-api.d.ts +0 -29
  53. package/lib/types/client/update-api.d.ts.map +0 -1
  54. package/lib/types/client/update-api.js +0 -47
  55. package/lib/types/console-output.d.ts +0 -41
  56. package/lib/types/console-output.d.ts.map +0 -1
  57. package/lib/types/console-output.js +0 -89
  58. package/lib/types/update-routes.d.ts +0 -30
  59. package/lib/types/update-routes.d.ts.map +0 -1
  60. package/lib/types/update-routes.js +0 -47
  61. package/lib/types/update.d.ts +0 -282
  62. package/lib/types/update.d.ts.map +0 -1
  63. package/lib/types/update.js +0 -773
  64. package/src/client/UpdateEntry.tsx +0 -144
  65. package/src/client/UpdatePanel.tsx +0 -256
  66. package/src/client/update-api.ts +0 -49
  67. package/src/console-output.ts +0 -97
  68. package/src/update-routes.ts +0 -65
  69. package/src/update.ts +0 -915
@@ -123,48 +123,6 @@ export const zh = {
123
123
  'settings.saveFailed': '部署未接受这些值,已保留供你修改。',
124
124
  'settings.invalidNumber': '请输入数字,留空则使用默认值。',
125
125
  // 更新面板(侧边栏更新按钮)。
126
- 'update.label': '检查更新',
127
- 'update.badge': '有更新',
128
- 'update.availableLabel': '发现新版本,检查更新',
129
- 'update.title': '检查更新',
130
- 'update.checking': '正在检查远程仓库中的新版本…',
131
- 'update.upToDate': '当前已是最新版本',
132
- 'update.upToDateDetail': '{name} 已是最新版本 v{version}。',
133
- 'update.found': '发现新版本',
134
- 'update.foundDetail': '{name} 有新版本 v{version}。确认后开始更新。',
135
- 'update.start': '开始更新',
136
- 'update.updating': '正在更新 {name} 至 v{version},可能需要 1-2 分钟…',
137
- 'update.cooldownNotice': '本次更新由你主动触发,将跳过 pnpm 的 24 小时发布冷却期(供应链保护)直接安装所选版本。',
138
- 'update.updatingTitle': '正在更新…',
139
- 'update.done': '更新完成',
140
- 'update.doneDetail': '所有组件已更新到最新版本。',
141
- 'update.restartHint': '请重启 dsh web 使新版本生效:在终端按 Ctrl+C 停止后重新运行 dsh web。',
142
- 'update.linkMode': '当前为本地开发模式',
143
- 'update.linkModeDetail': 'dsh-web 或其组件通过本地链接安装(开发模式),无法自动更新。npm 上最新版本:{version}。请同步本地仓库,或移除与全家桶重复的独立链接。',
144
- 'update.missing': '未找到 dsh-web 安装',
145
- 'update.missingDetail': '无法定位已安装的 dsh-web 聚合包。请先运行 dsh plugin --profile <profile> add @linxin666/dsh-web-all。',
146
- 'update.offline': '无法连接更新源',
147
- 'update.offlineDetail': '网络不可用或更新服务异常,请稍后重试。',
148
- 'update.unmounted': '更新服务未加载',
149
- 'update.unmountedDetail': '当前 dsh web 进程运行的插件版本不含更新服务,请重启 dsh web(Ctrl+C 后重新运行)后再试。',
150
- 'update.error': '更新失败',
151
- 'update.packages': '组件版本',
152
- 'update.releaseNotes': 'v{version} 更新说明',
153
- 'update.releaseFeatures': '新增功能',
154
- 'update.releaseFixes': '修复',
155
- 'update.releaseOther': '其他变动',
156
- 'update.releaseUnavailable': '本次发布说明暂不可用,可前往 GitHub Release 查看。',
157
- 'update.componentVersions': '查看组件版本',
158
- 'update.retry': '重新检查',
159
- 'update.close': '关闭更新面板',
160
- 'update.error.pnpmMissing': '未找到 pnpm,已尝试 pnpm / corepack / npx 仍不可用。请安装 pnpm 后重启应用(Windows: npm install -g pnpm 或 corepack enable;macOS: brew install pnpm 或 corepack enable)。',
161
- 'update.error.timeout': '更新超时,已终止安装进程,请重试。',
162
- 'update.error.notFound': '未找到 dsh-web 聚合包安装,无法更新。',
163
- 'update.error.link': 'dsh-web 或其组件使用本地链接,无法自动更新;请同步本地仓库,或移除与全家桶重复的独立链接。',
164
- 'update.error.pnpmFailed': '更新执行失败(pnpm 退出码 {code}),详见下方输出。',
165
- 'update.error.stale': '更新命令已执行,但安装版本未变化。常见原因:pnpm 11 的 minimumReleaseAge 门禁会静默跳过发布不足 24 小时的新版本。请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml 中配置 minimumReleaseAgeExclude(例如 "@linxin666/*")或 minimumReleaseAge: 0 后重新点击更新。',
166
- 'update.error.verifyFailed': '更新命令已执行,但无法核对安装后的版本(可能是 registry 探测失败或安装路径变化)。请查看下方输出,或稍后重试。',
167
- 'update.error.unknown': '更新失败,请重试。',
168
126
  } satisfies Record<string, string>
169
127
 
170
128
  /** The remote namespace key union. */
@@ -296,46 +254,4 @@ export const en = {
296
254
  'settings.saveFailed': 'The deployment did not accept these values; they were left for you to correct.',
297
255
  'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
298
256
  // Update panel (the sidebar update trigger).
299
- 'update.label': 'Check for updates',
300
- 'update.badge': 'Update available',
301
- 'update.availableLabel': 'New version available. Check for updates',
302
- 'update.title': 'Check for updates',
303
- 'update.checking': 'Checking the remote repository for newer releases…',
304
- 'update.upToDate': 'Everything is up to date',
305
- 'update.upToDateDetail': '{name} is at the latest version v{version}.',
306
- 'update.found': 'A new version is available',
307
- 'update.foundDetail': '{name} has a newer release v{version}. Confirm to start the update.',
308
- 'update.start': 'Update now',
309
- 'update.updating': 'Updating {name} to v{version}; this may take 1-2 minutes…',
310
- 'update.cooldownNotice': 'This user-initiated update bypasses pnpm\'s 24-hour release cooldown (supply-chain protection) and installs the selected version immediately.',
311
- 'update.updatingTitle': 'Updating…',
312
- 'update.done': 'Update complete',
313
- 'update.doneDetail': 'All components are up to date.',
314
- 'update.restartHint': 'Restart dsh web for the new version to take effect: stop it with Ctrl+C in the terminal, then run dsh web again.',
315
- 'update.linkMode': 'Local development mode',
316
- 'update.linkModeDetail': 'dsh-web or one of its components is installed via a local link and cannot be updated remotely. Latest npm release: {version}. Sync the local checkout, or remove a standalone link duplicated by the aggregate.',
317
- 'update.missing': 'dsh-web not found',
318
- 'update.missingDetail': 'Could not locate the installed dsh-web aggregate package. Install it with: dsh plugin --profile <profile> add @linxin666/dsh-web-all',
319
- 'update.offline': 'Cannot reach the update source',
320
- 'update.offlineDetail': 'The network is unavailable or the update service is failing; try again later.',
321
- 'update.unmounted': 'The update service is not loaded',
322
- 'update.unmountedDetail': 'The running dsh web process loads a plugin build without the update service; restart dsh web (Ctrl+C, then run dsh web again) and retry.',
323
- 'update.error': 'Update failed',
324
- 'update.packages': 'Component versions',
325
- 'update.releaseNotes': 'v{version} release notes',
326
- 'update.releaseFeatures': 'New Features',
327
- 'update.releaseFixes': 'Bug Fixes',
328
- 'update.releaseOther': 'Other Changes',
329
- 'update.releaseUnavailable': 'Release notes are unavailable for this version; see the GitHub release instead.',
330
- 'update.componentVersions': 'View component versions',
331
- 'update.retry': 'Check again',
332
- 'update.close': 'Close update panel',
333
- 'update.error.pnpmMissing': 'pnpm was not found (tried pnpm / corepack / npx). Install pnpm and restart the app (Windows: npm install -g pnpm or corepack enable; macOS: brew install pnpm or corepack enable).',
334
- 'update.error.timeout': 'The update timed out and the install process was killed; try again.',
335
- 'update.error.notFound': 'The dsh-web aggregate package is not installed; nothing to update.',
336
- 'update.error.link': 'dsh-web or one of its components uses a local link. Sync the checkout, or remove a standalone link duplicated by the aggregate.',
337
- 'update.error.pnpmFailed': 'The update failed (pnpm exited with code {code}); see the output below.',
338
- 'update.error.stale': 'The update command ran but the installed versions did not change. Likely cause: the pnpm 11 minimumReleaseAge gate silently skips releases published less than 24 hours ago. Add minimumReleaseAgeExclude (e.g. "@linxin666/*") or set minimumReleaseAge: 0 in pnpm-workspace.yaml under the profile directory (~/.dsh/profiles/<profile>), then run the update again.',
339
- 'update.error.verifyFailed': 'The update command ran but the installed versions could not be verified afterwards (registry probe failed or the install path changed). Inspect the output below, or retry later.',
340
- 'update.error.unknown': 'Update failed; try again.',
341
257
  } satisfies Record<RemoteKey, string>
@@ -19,9 +19,9 @@
19
19
  }
20
20
 
21
21
  /* Trigger: the 36px rail circle, or a fully rounded (pill) full-row button
22
- in wide mode — both maximally rounded so the footer action pair shares one
23
- shape family (#1035); the seat's owner (ui-sidebar) lays out the
24
- geometry. */
22
+ in wide mode — both maximally rounded so the seat's occupants (this
23
+ trigger, the dsh-update trigger, the usage card) share one shape family
24
+ (#1035); the seat's owner (ui-sidebar) lays out the geometry. */
25
25
  .trigger {
26
26
  position: relative;
27
27
  flex: none;
@@ -40,52 +40,18 @@
40
40
  transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
41
41
  }
42
42
 
43
- .trigger[data-update-available] {
44
- color: var(--dsw-alias-brand-primary);
45
- }
46
-
47
- .trigger[data-update-available]::after {
48
- position: absolute;
49
- top: 4px;
50
- right: 4px;
51
- width: 8px;
52
- height: 8px;
53
- box-sizing: border-box;
54
- border: 2px solid var(--dsw-alias-bg-layer-1);
55
- border-radius: 50%;
56
- background: var(--dsw-alias-brand-primary);
57
- content: '';
58
- pointer-events: none;
59
- }
60
-
61
43
  .trigger[data-wide='wide'] {
62
44
  flex: 1 1 auto;
63
45
  justify-content: flex-start;
64
46
  width: auto;
65
47
  min-width: 0;
66
- /* Pill: maximal rounding, same shape family as the 36px rail circle next
67
- to it (the update trigger) — one consistent footer-action look (#1035). */
48
+ /* Pill: maximal rounding, the same shape family as the 36px rail circle
49
+ beside it — one consistent footer-action look (#1035). */
68
50
  border-radius: 999px;
69
51
  gap: 8px;
70
52
  padding: 0 10px;
71
53
  }
72
54
 
73
- .trigger[data-update-available][data-wide='wide'] {
74
- flex: none;
75
- gap: 6px;
76
- }
77
-
78
- .trigger[data-update-available][data-wide='wide']::after {
79
- display: none;
80
- }
81
-
82
- .updateBadgeText {
83
- font-size: 13px;
84
- font-weight: 500;
85
- line-height: 1;
86
- white-space: nowrap;
87
- }
88
-
89
55
  .trigger:hover:not(:disabled) {
90
56
  background: var(--dsw-alias-interactive-bg-hover);
91
57
  color: var(--dsw-alias-label-primary);
@@ -768,9 +734,9 @@
768
734
  }
769
735
  }
770
736
 
771
- /* Footer seat row: the update trigger sits LEFT of the remote trigger in wide
772
- mode; the rail stacks them with the update circle below the remote circle
773
- (mirroring the settings circle keeping its bottom anchor). */
737
+ /* Footer seat row: this plugin's own occupant (the remote trigger) sits in
738
+ the shared sidebar.footer.action seat; the rail marker lets the seat stack
739
+ its occupants in the 56px icon column (see the rail rule below). */
774
740
  .entryRow {
775
741
  flex: none;
776
742
  display: flex;
@@ -788,12 +754,13 @@
788
754
 
789
755
  The official Settings trigger owns a whole row while the footer actions
790
756
  stack above it, so the trigger's right half sits empty. On the wide column
791
- the two share one line (the Settings trigger, then the update and remote
792
- triggers) and every other foot
793
- child (the usage glance card, another plugin's block) keeps a full row of
794
- its own above them. The rail (data-sidebar-collapsed on the frame) keeps
795
- the shell's stacked foot, where the circles stack by design. The shell owns
796
- the container classes; the semantic suffixes survive its rebuilds. */
757
+ the two share one line (the Settings trigger, then the footer-action
758
+ occupants — this trigger, the dsh-update trigger, another plugin's control)
759
+ and every other foot child (the usage glance card, another plugin's block)
760
+ keeps a full row of its own above them. The rail (data-sidebar-collapsed on
761
+ the frame) keeps the shell's stacked foot, where the circles stack by
762
+ design. The shell owns the container classes; the semantic suffixes survive
763
+ its rebuilds. */
797
764
  :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] {
798
765
  flex-direction: row;
799
766
  flex-wrap: wrap;
@@ -805,8 +772,42 @@
805
772
  min-width: 0;
806
773
  }
807
774
 
808
- :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='settingsArea'] {
775
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] > [class*='footerActions'] {
776
+ /* The seat's own slot host is already `display: contents`, so the seat's box
777
+ is what decided its occupants' layout. That made this plugin's rule fatal
778
+ to a SHARED container (issue #1710): the moment any other plugin registered
779
+ into `sidebar.footer.action` — the reporter had a 339px balance stack and a
780
+ context panel — the seat became a tall column, and the settings trigger,
781
+ which is a shell element this plugin does not own, was squeezed to 102px and
782
+ vertically centred inside it (`y534 = 351 + (417-50)/2`).
783
+ Dropping the box hands every occupant back to the foot's own wrapping
784
+ layout, where each one is sized on its own merits. */
785
+ display: contents;
786
+ }
787
+
788
+ /* A registrant that is not one of the family's own footer entries (an
789
+ icon-sized row) takes a full line of its own above the bottom row. This is
790
+ the shell's own intent for the seat, and it is what keeps a tall third-party
791
+ block from displacing the settings trigger. */
792
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footerActions'] > [data-slot='sidebar.footer.action'] > :not([data-dsh-part='entry']):not([class*='entryRow']) {
793
+ flex: 1 1 100%;
809
794
  order: 1;
795
+ min-width: 0;
796
+ }
797
+
798
+ /* The family's own footer entries keep their intrinsic width and close the
799
+ bottom row, sharing it with the settings trigger. `data-dsh-part="entry"` is
800
+ the family contract for these triggers (dsh-update, dsh-session-id); the
801
+ remote trigger marks its row with its own class. */
802
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footerActions'] > [data-slot='sidebar.footer.action'] > [data-dsh-part='entry'],
803
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footerActions'] > [data-slot='sidebar.footer.action'] > [class*='entryRow'] {
804
+ flex: 0 0 auto;
805
+ order: 4;
806
+ min-width: 0;
807
+ }
808
+
809
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='settingsArea'] {
810
+ order: 3;
810
811
  /* Content-sized basis: the trigger's own width forces it onto the line after
811
812
  a full-width sibling, then flex-shrink gives the actions their space. */
812
813
  flex: 1 1 auto;
@@ -814,14 +815,6 @@
814
815
  min-width: 0;
815
816
  }
816
817
 
817
- :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footerActions'] {
818
- order: 2;
819
- flex: none;
820
- width: auto;
821
- min-width: 0;
822
- align-items: center;
823
- }
824
-
825
818
  /* ── Collapsed rail foot seat ────────────────────────────────────────────
826
819
 
827
820
  The shell renders `sidebar.footer.action` as a centered horizontal row
@@ -839,146 +832,3 @@
839
832
  align-items: center;
840
833
  gap: 4px;
841
834
  }
842
-
843
- /* ── update panel extras ─────────────────────────────────────────────── */
844
-
845
- .updateStatus {
846
- margin: 0;
847
- font-weight: 600;
848
- }
849
-
850
- .updateDetail {
851
- margin: 6px 0 0;
852
- color: var(--dsw-alias-label-secondary);
853
- font-size: 13px;
854
- }
855
-
856
- .updateError {
857
- margin: 0;
858
- color: var(--dsw-alias-text-danger, var(--dsw-alias-label-primary));
859
- font-weight: 600;
860
- }
861
-
862
- .updateOutput {
863
- max-height: 180px;
864
- margin: 10px 0 0;
865
- overflow: auto;
866
- padding: 10px 12px;
867
- border-radius: 10px;
868
- background: var(--dsw-alias-bg-layer-1);
869
- color: var(--dsw-alias-label-secondary);
870
- font-size: 12px;
871
- line-height: 18px;
872
- white-space: pre-wrap;
873
- word-break: break-all;
874
- }
875
-
876
- .updateList {
877
- margin: 12px 0 0;
878
- padding: 0;
879
- list-style: none;
880
- display: flex;
881
- flex-direction: column;
882
- gap: 6px;
883
- }
884
-
885
- .updateListItem {
886
- display: flex;
887
- align-items: baseline;
888
- justify-content: space-between;
889
- gap: 12px;
890
- font-size: 13px;
891
- }
892
-
893
- .updateListName {
894
- min-width: 0;
895
- overflow: hidden;
896
- text-overflow: ellipsis;
897
- white-space: nowrap;
898
- color: var(--dsw-alias-label-primary);
899
- }
900
-
901
- .updateListVersions {
902
- flex: none;
903
- color: var(--dsw-alias-label-secondary);
904
- font-variant-numeric: tabular-nums;
905
- }
906
-
907
- .updateNotes {
908
- margin-top: 14px;
909
- }
910
-
911
- .updateNotesTitle {
912
- margin: 0 0 8px;
913
- font-size: 14px;
914
- font-weight: 600;
915
- }
916
-
917
- .updateNoteSection {
918
- display: flex;
919
- flex-direction: column;
920
- gap: 4px;
921
- margin-top: 10px;
922
- }
923
-
924
- .updateNoteHeading {
925
- margin: 0;
926
- font-size: 13px;
927
- font-weight: 600;
928
- color: var(--dsw-alias-label-secondary);
929
- }
930
-
931
- .updateNoteList {
932
- margin: 0;
933
- padding-left: 18px;
934
- display: flex;
935
- flex-direction: column;
936
- gap: 4px;
937
- font-size: 13px;
938
- }
939
-
940
- .updateNoteItem {
941
- color: var(--dsw-alias-label-primary);
942
- overflow-wrap: anywhere;
943
- }
944
-
945
- .updateVersions {
946
- margin-top: 12px;
947
- }
948
-
949
- .updateVersionsSummary {
950
- cursor: pointer;
951
- color: var(--dsw-alias-label-secondary);
952
- font-size: 12px;
953
- user-select: none;
954
- }
955
-
956
- .updateActions {
957
- display: flex;
958
- justify-content: flex-end;
959
- margin-top: 16px;
960
- }
961
-
962
- .updateRetry {
963
- display: inline-flex;
964
- align-items: center;
965
- gap: 6px;
966
- border: none;
967
- border-radius: 10px;
968
- padding: 8px 14px;
969
- background: var(--dsw-alias-interactive-bg-hover);
970
- color: var(--dsw-alias-label-primary);
971
- font-size: 13px;
972
- cursor: pointer;
973
- transition: background-color 120ms ease, color 120ms ease;
974
- }
975
-
976
- .updateRetry:hover {
977
- background: var(--dsw-alias-interactive-bg-active);
978
- }
979
-
980
- @media (prefers-reduced-motion: reduce) {
981
- .updateRetry {
982
- transition: none;
983
- }
984
- }
@@ -0,0 +1,142 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
3
+ /**
4
+ * Family settings form bound to the profile entry id a Host actually serves.
5
+ *
6
+ * Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
7
+ * a family card knows only its own settings namespace. The family binder
8
+ * (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
9
+ * the other and is the preferred transport; this module is the fallback for a
10
+ * page that serves no binder — the group plugin is not installed, or its client
11
+ * half has not applied yet (the aggregate mounts its children in order, and a
12
+ * child still waiting on its own injects publishes nothing).
13
+ *
14
+ * The shared describe mirror is the only local evidence of which row id this
15
+ * profile serves, but it answers asynchronously: at plugin activation it usually
16
+ * holds nothing yet. A one-shot guess therefore cannot be right for every
17
+ * deployment, and the form is bound once per session:
18
+ *
19
+ * - binding the bare namespace made every save answer `No configurable plugin
20
+ * entry "<namespace>"` on an aggregate install (the namespace is not an entry
21
+ * id there), which the card reported as "the deployment did not accept these
22
+ * values";
23
+ * - binding the aggregate row id would break a standalone install, whose row id
24
+ * is the namespace or the package's own `ui-*` row.
25
+ *
26
+ * So the fallback starts on the most likely row and REBINDS the moment the
27
+ * mirror names one of this package's rows. Until the mirror answers, the form
28
+ * reports the binding it holds (usually `loading`), which the card renders as an
29
+ * unavailable form rather than a doomed one.
30
+ */
31
+ import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
32
+
33
+ /** Options of {@link createServedEntryForm}. */
34
+ export interface ServedEntryFormOptions<T> {
35
+ /** The shared configuration forms service (`ctx.configForms`). */
36
+ forms: ConfigForms
37
+ /**
38
+ * Profile entry ids this package's rows carry, most likely first: the
39
+ * aggregate's generated row, the package's own standalone row, then the bare
40
+ * namespace. The first is the fallback while the mirror has no answer; the
41
+ * last is the answer when the mirror serves none of them.
42
+ */
43
+ entryIds: readonly string[]
44
+ }
45
+
46
+ /** The snapshot a form reports before the Host has answered with this entry. */
47
+ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
48
+ return {
49
+ status: 'loading',
50
+ value: undefined,
51
+ base: undefined,
52
+ user: undefined,
53
+ revision: undefined,
54
+ writable: false,
55
+ mode: 'host',
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Create a settings form bound to the profile entry id this Host serves, and
61
+ * rebound whenever the shared describe mirror names a different one of this
62
+ * package's rows.
63
+ * @param options - the shared forms service and this package's candidate row ids.
64
+ * @returns a form delegating to the currently resolved entry's own form.
65
+ */
66
+ export function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T> {
67
+ const { forms, entryIds } = options
68
+ const fallbackId = entryIds[0]
69
+ const namespaceId = entryIds[entryIds.length - 1]
70
+ const listeners = new Set<() => void>()
71
+ let boundId: string | undefined
72
+ let bound: ConfigForm<T> | undefined
73
+ let offBound: (() => void) | undefined
74
+ let snapshot: ConfigFormSnapshot<T> = pendingSnapshot<T>()
75
+
76
+ /** Republish: the delegated snapshot when one is bound, the pending one otherwise. */
77
+ const publish = (): void => {
78
+ if (bound !== undefined) snapshot = bound.getSnapshot()
79
+ for (const listener of [...listeners]) listener()
80
+ }
81
+
82
+ /**
83
+ * The entry id the mirror currently justifies. An unanswered or empty mirror
84
+ * is not evidence of absence, so it keeps the first candidate; a mirror that
85
+ * answers without any of this package's rows leaves the namespace itself.
86
+ * @returns the entry id to bind.
87
+ */
88
+ const resolve = (): string => {
89
+ let served: readonly string[] | undefined
90
+ try {
91
+ served = forms.describe().getSnapshot().view?.namespaces.map(row => row.ns)
92
+ } catch {
93
+ // A mirror that refuses the read is unanswered too, not absent.
94
+ served = undefined
95
+ }
96
+ if (served === undefined || served.length === 0) return fallbackId
97
+ return entryIds.find(id => served.includes(id)) ?? namespaceId
98
+ }
99
+
100
+ /** Bind (or rebind) the resolved entry's form; a no-op while it is unchanged. */
101
+ const bind = (): void => {
102
+ const target = resolve()
103
+ if (target === boundId) return
104
+ offBound?.()
105
+ offBound = undefined
106
+ let form: ConfigForm<T>
107
+ try {
108
+ form = forms.get<T>(target)
109
+ } catch {
110
+ // A service that refuses the id stays unbound; the next mirror answer
111
+ // retries rather than pinning a wrong entry for the session.
112
+ boundId = undefined
113
+ return
114
+ }
115
+ boundId = target
116
+ bound = form
117
+ offBound = form.subscribe(() => { publish() })
118
+ publish()
119
+ }
120
+
121
+ // The mirror subscription lives for the page: the card owns its form for the
122
+ // session, and the ConfigForm contract carries no disposer to hang it on. The
123
+ // callback is cheap (a resolve plus a no-op while the id is unchanged). A
124
+ // mirror without a subscription seat keeps the initial binding.
125
+ try {
126
+ forms.describe().subscribe(() => { bind() })
127
+ } catch {
128
+ // A describe face that refuses the subscription: the initial binding stands.
129
+ }
130
+ bind()
131
+
132
+ return {
133
+ getSnapshot: () => snapshot,
134
+ subscribe: (listener) => {
135
+ listeners.add(listener)
136
+ return () => { listeners.delete(listener) }
137
+ },
138
+ set: (field, value) => bound?.set(field, value) ?? Promise.resolve(false),
139
+ unset: (field) => bound?.unset(field) ?? Promise.resolve(false),
140
+ mutate: (ops, expectedRevision) => bound?.mutate(ops, expectedRevision) ?? Promise.resolve(false),
141
+ }
142
+ }