@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.4
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.
- package/README.i18n.yaml +2 -2
- package/README.md +12 -10
- package/README.zh.md +12 -10
- package/lib/client.js +278 -599
- package/lib/client.js.map +1 -1
- package/lib/index.js +396 -913
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +8 -9
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/index.d.ts +32 -3
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +51 -8
- package/lib/types/client/locales.d.ts +0 -84
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +0 -84
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +142 -8
- package/lib/types/client/settings-entry-form.d.ts +50 -0
- package/lib/types/client/settings-entry-form.d.ts.map +1 -0
- package/lib/types/client/settings-entry-form.js +98 -0
- package/lib/types/index.d.ts +99 -7
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +150 -149
- package/lib/types/lan-bind-plan.d.ts +14 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -1
- package/lib/types/lan-bind-plan.js +16 -0
- package/lib/types/lan-bind.d.ts +1 -1
- package/lib/types/lan-bind.js +1 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/mount-once.d.ts.map +1 -1
- package/lib/types/mount-once.js +96 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +43 -0
- package/lib/types/remote-api.d.ts +13 -1
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +14 -1
- package/lib/types/tunnel.d.ts +60 -3
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +136 -3
- package/package.json +20 -20
- package/src/client/RemoteEntry.tsx +7 -9
- package/src/client/RemoteSettingsCard.tsx +0 -2
- package/src/client/index.ts +53 -9
- package/src/client/locales.ts +0 -84
- package/src/client/mobile-adapt.ts +144 -5
- package/src/client/remote.module.css +50 -200
- package/src/client/settings-entry-form.ts +142 -0
- package/src/index.ts +202 -164
- package/src/lan-bind-plan.ts +22 -0
- package/src/lan-bind.ts +1 -1
- package/src/mount-once.ts +107 -13
- package/src/pairing.ts +44 -1
- package/src/remote-api.ts +14 -1
- package/src/tunnel.ts +142 -3
- package/lib/types/client/UpdateEntry.d.ts +0 -14
- package/lib/types/client/UpdateEntry.d.ts.map +0 -1
- package/lib/types/client/UpdateEntry.js +0 -112
- package/lib/types/client/UpdatePanel.d.ts +0 -42
- package/lib/types/client/UpdatePanel.d.ts.map +0 -1
- package/lib/types/client/UpdatePanel.js +0 -101
- package/lib/types/client/update-api.d.ts +0 -29
- package/lib/types/client/update-api.d.ts.map +0 -1
- package/lib/types/client/update-api.js +0 -47
- package/lib/types/console-output.d.ts +0 -41
- package/lib/types/console-output.d.ts.map +0 -1
- package/lib/types/console-output.js +0 -89
- package/lib/types/update-routes.d.ts +0 -30
- package/lib/types/update-routes.d.ts.map +0 -1
- package/lib/types/update-routes.js +0 -47
- package/lib/types/update.d.ts +0 -282
- package/lib/types/update.d.ts.map +0 -1
- package/lib/types/update.js +0 -773
- package/src/client/UpdateEntry.tsx +0 -144
- package/src/client/UpdatePanel.tsx +0 -256
- package/src/client/update-api.ts +0 -49
- package/src/console-output.ts +0 -97
- package/src/update-routes.ts +0 -65
- package/src/update.ts +0 -915
|
@@ -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
|
|
23
|
-
|
|
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
|
|
67
|
-
|
|
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:
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
792
|
-
|
|
793
|
-
child (the usage glance card, another plugin's block)
|
|
794
|
-
its own above them. The rail (data-sidebar-collapsed on
|
|
795
|
-
the shell's stacked foot, where the circles stack by
|
|
796
|
-
the container classes; the semantic suffixes survive
|
|
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*='
|
|
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
|
+
}
|