@lijian-ui/dsh-file-manager 0.1.20
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/LICENSE +38 -0
- package/README.i18n.yaml +6 -0
- package/README.md +66 -0
- package/README.zh.md +66 -0
- package/cordis.patch.yml +13 -0
- package/lib/assets/mermaid.min.js +3587 -0
- package/lib/client.js +7621 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3207 -0
- package/lib/tsconfig.client.tsbuildinfo +1 -0
- package/lib/tsconfig.host.tsbuildinfo +1 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
- package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +104 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/api.d.ts +75 -0
- package/lib/types/client/api.d.ts.map +1 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
- package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
- package/lib/types/client/chat/file-ref.d.ts +35 -0
- package/lib/types/client/chat/file-ref.d.ts.map +1 -0
- package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
- package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
- package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
- package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
- package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
- package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
- package/lib/types/client/components/FileIcon.d.ts +15 -0
- package/lib/types/client/components/FileIcon.d.ts.map +1 -0
- package/lib/types/client/components/ScmPanel.d.ts +23 -0
- package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
- package/lib/types/client/components/a11y.d.ts +12 -0
- package/lib/types/client/components/a11y.d.ts.map +1 -0
- package/lib/types/client/components/icons.d.ts +41 -0
- package/lib/types/client/components/icons.d.ts.map +1 -0
- package/lib/types/client/components/overlay.d.ts +45 -0
- package/lib/types/client/components/overlay.d.ts.map +1 -0
- package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
- package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
- package/lib/types/client/drag/file-drag.d.ts +47 -0
- package/lib/types/client/drag/file-drag.d.ts.map +1 -0
- package/lib/types/client/drag.d.ts +31 -0
- package/lib/types/client/drag.d.ts.map +1 -0
- package/lib/types/client/file-source.d.ts +29 -0
- package/lib/types/client/file-source.d.ts.map +1 -0
- package/lib/types/client/fileType.d.ts +31 -0
- package/lib/types/client/fileType.d.ts.map +1 -0
- package/lib/types/client/floating.d.ts +27 -0
- package/lib/types/client/floating.d.ts.map +1 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
- package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
- package/lib/types/client/hooks/useStore.d.ts +10 -0
- package/lib/types/client/hooks/useStore.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +57 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/layout.d.ts +104 -0
- package/lib/types/client/layout.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +125 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/maximize.d.ts +24 -0
- package/lib/types/client/maximize.d.ts.map +1 -0
- package/lib/types/client/mention.d.ts +44 -0
- package/lib/types/client/mention.d.ts.map +1 -0
- package/lib/types/client/mount.d.ts +22 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/persist.d.ts +55 -0
- package/lib/types/client/persist.d.ts.map +1 -0
- package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
- package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
- package/lib/types/client/picker/file-picker.d.ts +62 -0
- package/lib/types/client/picker/file-picker.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
- package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
- package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
- package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
- package/lib/types/client/preview/content.d.ts +43 -0
- package/lib/types/client/preview/content.d.ts.map +1 -0
- package/lib/types/client/preview/markdown.d.ts +60 -0
- package/lib/types/client/preview/markdown.d.ts.map +1 -0
- package/lib/types/client/preview/mermaid.d.ts +78 -0
- package/lib/types/client/preview/mermaid.d.ts.map +1 -0
- package/lib/types/client/preview/selection.d.ts +26 -0
- package/lib/types/client/preview/selection.d.ts.map +1 -0
- package/lib/types/client/reference.d.ts +75 -0
- package/lib/types/client/reference.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +206 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/store.d.ts +279 -0
- package/lib/types/client/store.d.ts.map +1 -0
- package/lib/types/core/types.d.ts +132 -0
- package/lib/types/core/types.d.ts.map +1 -0
- package/lib/types/host/fs-service.d.ts +96 -0
- package/lib/types/host/fs-service.d.ts.map +1 -0
- package/lib/types/host/gate.d.ts +45 -0
- package/lib/types/host/gate.d.ts.map +1 -0
- package/lib/types/host/git-runner.d.ts +80 -0
- package/lib/types/host/git-runner.d.ts.map +1 -0
- package/lib/types/host/git-service.d.ts +130 -0
- package/lib/types/host/git-service.d.ts.map +1 -0
- package/lib/types/host/loopback.d.ts +25 -0
- package/lib/types/host/loopback.d.ts.map +1 -0
- package/lib/types/host/poll-guard.d.ts +55 -0
- package/lib/types/host/poll-guard.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +54 -0
- package/lib/types/host/routes.d.ts.map +1 -0
- package/lib/types/index.d.ts +35 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/mount-once.d.ts +25 -0
- package/lib/types/mount-once.d.ts.map +1 -0
- package/package.json +85 -0
- package/src/client/FileManagerSettingsCard.tsx +117 -0
- package/src/client/PluginSettingsCard.tsx +337 -0
- package/src/client/api.ts +141 -0
- package/src/client/chat/file-ref.ts +144 -0
- package/src/client/chat/mermaid-chat.tsx +102 -0
- package/src/client/chat/placeholder-hint.tsx +46 -0
- package/src/client/components/ExplorerPanel.tsx +534 -0
- package/src/client/components/FileIcon.tsx +46 -0
- package/src/client/components/ScmPanel.tsx +480 -0
- package/src/client/components/a11y.ts +20 -0
- package/src/client/components/icons.tsx +274 -0
- package/src/client/components/overlay.tsx +225 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/drag/DragFileInlay.tsx +89 -0
- package/src/client/drag/file-drag.ts +67 -0
- package/src/client/drag.ts +154 -0
- package/src/client/file-source.ts +58 -0
- package/src/client/fileType.ts +129 -0
- package/src/client/floating.ts +63 -0
- package/src/client/hooks/useResizableSplit.ts +91 -0
- package/src/client/hooks/useStore.ts +15 -0
- package/src/client/index.ts +389 -0
- package/src/client/layout.ts +546 -0
- package/src/client/locales.ts +247 -0
- package/src/client/maximize.ts +34 -0
- package/src/client/mount.tsx +74 -0
- package/src/client/persist.ts +194 -0
- package/src/client/picker/FilePickerModal.tsx +506 -0
- package/src/client/picker/file-picker.ts +86 -0
- package/src/client/preview/PreviewPanel.tsx +233 -0
- package/src/client/preview/PreviewTabs.tsx +159 -0
- package/src/client/preview/PreviewToolbar.tsx +160 -0
- package/src/client/preview/content.tsx +617 -0
- package/src/client/preview/markdown.ts +351 -0
- package/src/client/preview/mermaid.ts +274 -0
- package/src/client/preview/selection.ts +82 -0
- package/src/client/reference.ts +161 -0
- package/src/client/settings-card.module.css +316 -0
- package/src/client/settings-form.ts +451 -0
- package/src/client/store.ts +1290 -0
- package/src/client/styles/chip.module.css +38 -0
- package/src/client/styles/drag.module.css +30 -0
- package/src/client/styles/explorer.module.css +371 -0
- package/src/client/styles/picker.module.css +344 -0
- package/src/client/styles/preview.module.css +904 -0
- package/src/client/styles/scm.module.css +380 -0
- package/src/client/styles/tokens.module.css +480 -0
- package/src/core/types.ts +162 -0
- package/src/host/fs-service.ts +513 -0
- package/src/host/gate.ts +74 -0
- package/src/host/git-runner.ts +116 -0
- package/src/host/git-service.ts +395 -0
- package/src/host/loopback.ts +63 -0
- package/src/host/poll-guard.ts +109 -0
- package/src/host/routes.ts +695 -0
- package/src/index.ts +97 -0
- package/src/mount-once.ts +48 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/client/settings/settings-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Staged form model behind the plugin settings card. A card stages what the
|
|
4
|
+
* user types and writes it only when they save — the settings write is a
|
|
5
|
+
* durable, revision-fenced document mutation, so staging keeps what is on
|
|
6
|
+
* screen exactly what a save would store. Family-shared slice inlined into
|
|
7
|
+
* each plugin's client bundle; mirrors the official ui-plugin-config
|
|
8
|
+
* card-store pattern.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
+
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
13
|
+
|
|
14
|
+
/** The write one field's staged text performs when the card is saved. */
|
|
15
|
+
export type FieldWrite =
|
|
16
|
+
| { kind: 'set'; value: unknown }
|
|
17
|
+
| { kind: 'clear' }
|
|
18
|
+
|
|
19
|
+
/** How one field converts between its stored value and its draft text. */
|
|
20
|
+
export interface FieldSpec {
|
|
21
|
+
/** Field name inside the namespace section. */
|
|
22
|
+
field: string
|
|
23
|
+
/**
|
|
24
|
+
* Whether the Host treats this field as a secret and redacts its value from
|
|
25
|
+
* the read-back (role('secret') in the section schema). Redacted secrets are
|
|
26
|
+
* never compared against the draft on save; the field lands when the scope
|
|
27
|
+
* reports the write succeeded (its secret-set marker under the bridge), so
|
|
28
|
+
* a successful secret save is not misreported as failed.
|
|
29
|
+
*/
|
|
30
|
+
secret?: boolean
|
|
31
|
+
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
32
|
+
format: (value: unknown) => string
|
|
33
|
+
/**
|
|
34
|
+
* The write this draft text stages, or undefined when the text is not a
|
|
35
|
+
* value this field accepts — which blocks the save rather than discarding it.
|
|
36
|
+
*/
|
|
37
|
+
parse: (text: string) => FieldWrite | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One field as the card renders it. */
|
|
41
|
+
export interface FieldState {
|
|
42
|
+
/** Draft text the control renders. */
|
|
43
|
+
text: string
|
|
44
|
+
/** Whether saving would leave a user-layer entry for this field. */
|
|
45
|
+
overridden: boolean
|
|
46
|
+
/** Whether the draft is not a value this field accepts, which blocks saving. */
|
|
47
|
+
invalid: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Form state every plugin settings card shares. */
|
|
51
|
+
export interface CardShell {
|
|
52
|
+
/** False while the namespace is still loading; the card renders nothing. */
|
|
53
|
+
available: boolean
|
|
54
|
+
/**
|
|
55
|
+
* Whether the namespace is actually served to this client. False when the
|
|
56
|
+
* Host deployment does not expose it (e.g. the official apiproxy settings
|
|
57
|
+
* allowlist omits third-party namespaces): the card renders an explanation
|
|
58
|
+
* instead of its form, so a missing namespace never looks like a missing
|
|
59
|
+
* plugin.
|
|
60
|
+
*/
|
|
61
|
+
exposed: boolean
|
|
62
|
+
/** Whether the Host document accepts writes. */
|
|
63
|
+
writable: boolean
|
|
64
|
+
/** Whether the form holds edits that a save would write. */
|
|
65
|
+
dirty: boolean
|
|
66
|
+
/** Whether any staged draft is invalid, which blocks the save. */
|
|
67
|
+
invalid: boolean
|
|
68
|
+
/** Whether a save is crossing the wire. */
|
|
69
|
+
saving: boolean
|
|
70
|
+
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
71
|
+
failed: boolean
|
|
72
|
+
/**
|
|
73
|
+
* The rejection code/message the Host returned for the last failed save,
|
|
74
|
+
* surfaced next to the generic failure text. Undefined while no save has
|
|
75
|
+
* failed (or the failure carried no server reason).
|
|
76
|
+
*/
|
|
77
|
+
failedReason?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The write actions the card's slot entry injects. */
|
|
81
|
+
export interface CardActions {
|
|
82
|
+
/** Stage draft text for one field. */
|
|
83
|
+
edit: (field: string, text: string) => void
|
|
84
|
+
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
|
|
85
|
+
resetField: (field: string) => void
|
|
86
|
+
/** Write every staged edit, then re-seed from what the Host accepted. */
|
|
87
|
+
save: () => void
|
|
88
|
+
/** Drop every staged edit. */
|
|
89
|
+
discard: () => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One field's staged edit. */
|
|
93
|
+
interface StagedEdit {
|
|
94
|
+
/** Draft text the control renders. */
|
|
95
|
+
text: string
|
|
96
|
+
/** True when this edit clears the field whatever text it shows. */
|
|
97
|
+
clear: boolean
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One staged edit resolved into the write a save performs. */
|
|
101
|
+
interface PlannedWrite {
|
|
102
|
+
/** Field this entry writes. */
|
|
103
|
+
field: string
|
|
104
|
+
/** The durable write this entry performs, described for a batched scope. */
|
|
105
|
+
op: BatchedWrite
|
|
106
|
+
/** Perform the write and report whether the Host holds the staged value afterwards. */
|
|
107
|
+
run: (() => Promise<boolean>) | undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One durable write a batched settings scope performs. */
|
|
111
|
+
export interface BatchedWrite {
|
|
112
|
+
/** Field this entry writes. */
|
|
113
|
+
field: string
|
|
114
|
+
/** set stores a value; unset drops the leaf. */
|
|
115
|
+
op: 'set' | 'unset'
|
|
116
|
+
/** Value for op set (absent for unset). */
|
|
117
|
+
value?: unknown
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Per-field outcome of one batched scope write. */
|
|
121
|
+
export interface BatchedFieldResult {
|
|
122
|
+
/** Field this entry writes. */
|
|
123
|
+
field: string
|
|
124
|
+
/** Whether the Host accepted this field's write (per the read-back view). */
|
|
125
|
+
landed: boolean
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Result of a batched scope write. The bridge scope posts every planned write
|
|
130
|
+
* in one /mutate so the Host validate hook judges baseURL+model together; a
|
|
131
|
+
* batched refusal fails the whole save rather than per-field.
|
|
132
|
+
*/
|
|
133
|
+
export interface BatchResult {
|
|
134
|
+
/** Whether the whole mutate was accepted. */
|
|
135
|
+
ok: boolean
|
|
136
|
+
/** Per-field success, in the request order (always present when ok). */
|
|
137
|
+
fields: BatchedFieldResult[]
|
|
138
|
+
/** Host rejection code (mutate refused). */
|
|
139
|
+
code?: string
|
|
140
|
+
/** Host rejection message (mutate refused). */
|
|
141
|
+
message?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The optional batch surface the bridge scope adds over the SettingsScope contract. */
|
|
145
|
+
interface BatchedSettingsScope {
|
|
146
|
+
/** Write every operation in one scope mutation, reporting per-field success. */
|
|
147
|
+
mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
|
|
151
|
+
export interface NumberConstraints {
|
|
152
|
+
/** The accepted value must be a whole number. */
|
|
153
|
+
integer?: boolean
|
|
154
|
+
/** The accepted value must be at least this. */
|
|
155
|
+
min?: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A whole- or decimal-number field. An empty draft clears the field; any other draft that is not a finite number within the constraints blocks the save. */
|
|
159
|
+
export function numberField(field: string, constraints: NumberConstraints = {}): FieldSpec {
|
|
160
|
+
const { integer = false, min } = constraints
|
|
161
|
+
return {
|
|
162
|
+
field,
|
|
163
|
+
format: value => typeof value === 'number' ? String(value) : '',
|
|
164
|
+
parse: (text) => {
|
|
165
|
+
const trimmed = text.trim()
|
|
166
|
+
if (trimmed === '') return { kind: 'clear' }
|
|
167
|
+
const parsed = Number(trimmed)
|
|
168
|
+
if (!Number.isFinite(parsed)) return undefined
|
|
169
|
+
if (integer && !Number.isInteger(parsed)) return undefined
|
|
170
|
+
if (min !== undefined && parsed < min) return undefined
|
|
171
|
+
return { kind: 'set', value: parsed }
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A free-text field. An empty draft clears the field. */
|
|
177
|
+
export function textField(field: string): FieldSpec {
|
|
178
|
+
return {
|
|
179
|
+
field,
|
|
180
|
+
format: value => typeof value === 'string' ? value : '',
|
|
181
|
+
parse: (text) => {
|
|
182
|
+
const trimmed = text.trim()
|
|
183
|
+
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A free-text field the Host treats as a secret and redacts from the read-back
|
|
190
|
+
* (role('secret') in the section schema). The card still edits it like text,
|
|
191
|
+
* but a save never compares the redacted value back and relies on the scope
|
|
192
|
+
* reporting the write landed.
|
|
193
|
+
*/
|
|
194
|
+
export function secretField(field: string): FieldSpec {
|
|
195
|
+
return { ...textField(field), secret: true }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A boolean field, edited through true/false draft text. */
|
|
199
|
+
export function booleanField(field: string): FieldSpec {
|
|
200
|
+
return {
|
|
201
|
+
field,
|
|
202
|
+
format: value => typeof value === 'boolean' ? String(value) : '',
|
|
203
|
+
parse: (text) => {
|
|
204
|
+
const trimmed = text.trim()
|
|
205
|
+
if (trimmed === '') return { kind: 'clear' }
|
|
206
|
+
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
207
|
+
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
208
|
+
return undefined
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** An enumerated string field; only the listed choices are accepted. An empty draft clears the field. */
|
|
214
|
+
export function choiceField(field: string, choices: readonly string[]): FieldSpec {
|
|
215
|
+
return {
|
|
216
|
+
field,
|
|
217
|
+
format: value => typeof value === 'string' && choices.includes(value) ? value : '',
|
|
218
|
+
parse: (text) => {
|
|
219
|
+
if (text === '') return { kind: 'clear' }
|
|
220
|
+
return choices.includes(text) ? { kind: 'set', value: text } : undefined
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Stages one card's edits over one settings namespace and writes them on save.
|
|
227
|
+
*
|
|
228
|
+
* The Host is the only authority on whether a value was accepted — its
|
|
229
|
+
* validators own the constraints no schema can express — so the outcome is
|
|
230
|
+
* read back from the section rather than predicted here. A save that did not
|
|
231
|
+
* land keeps its drafts, so the user can correct them instead of retyping.
|
|
232
|
+
*/
|
|
233
|
+
export class CardForm<T> {
|
|
234
|
+
private readonly specs: Map<string, FieldSpec>
|
|
235
|
+
private readonly staged = new Map<string, StagedEdit>()
|
|
236
|
+
private readonly listeners = new Set<() => void>()
|
|
237
|
+
/** The scope subscription installed in the constructor; released by dispose(). */
|
|
238
|
+
private readonly disposeScope: () => void
|
|
239
|
+
private disposed = false
|
|
240
|
+
private saving = false
|
|
241
|
+
private failed = false
|
|
242
|
+
private failedReason: string | undefined
|
|
243
|
+
|
|
244
|
+
/** @param scope - the bound settings scope for this card's namespace. */
|
|
245
|
+
constructor(
|
|
246
|
+
private readonly scope: SettingsScope<T>,
|
|
247
|
+
specs: FieldSpec[],
|
|
248
|
+
) {
|
|
249
|
+
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
250
|
+
this.disposeScope = scope.subscribe(() => { this.publish() })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Release the scope subscription and every bound store listener. The card
|
|
255
|
+
* must call this on teardown; later calls are no-ops.
|
|
256
|
+
*/
|
|
257
|
+
dispose(): void {
|
|
258
|
+
if (this.disposed) return
|
|
259
|
+
this.disposed = true
|
|
260
|
+
this.disposeScope()
|
|
261
|
+
this.listeners.clear()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
|
|
265
|
+
bind<S>(project: () => S): SnapshotStore<S> {
|
|
266
|
+
const store = createSnapshotStore(project())
|
|
267
|
+
this.listeners.add(() => { store.set(project()) })
|
|
268
|
+
return store
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Read the card-level state: what the Host serves, and what a save would do. */
|
|
272
|
+
shell(): CardShell {
|
|
273
|
+
const snapshot = this.scope.getSnapshot()
|
|
274
|
+
const plan = this.plan()
|
|
275
|
+
return {
|
|
276
|
+
available: snapshot.status !== 'loading',
|
|
277
|
+
exposed: snapshot.status === 'ready',
|
|
278
|
+
writable: snapshot.writable,
|
|
279
|
+
dirty: plan.length > 0,
|
|
280
|
+
invalid: plan.some(item => item.run === undefined),
|
|
281
|
+
saving: this.saving,
|
|
282
|
+
failed: this.failed,
|
|
283
|
+
...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Read one field's state from the effective section and its staged draft. */
|
|
288
|
+
field(field: string): FieldState {
|
|
289
|
+
const spec = this.specOf(field)
|
|
290
|
+
const staged = this.staged.get(field)
|
|
291
|
+
if (staged === undefined) {
|
|
292
|
+
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
|
293
|
+
}
|
|
294
|
+
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
|
295
|
+
return {
|
|
296
|
+
text: staged.text,
|
|
297
|
+
overridden: write?.kind === 'set',
|
|
298
|
+
invalid: write === undefined,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The actions the card's slot registration injects. */
|
|
303
|
+
actions(): CardActions {
|
|
304
|
+
return {
|
|
305
|
+
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
|
306
|
+
resetField: (field) => {
|
|
307
|
+
this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
|
|
308
|
+
},
|
|
309
|
+
save: () => { void this.save() },
|
|
310
|
+
discard: () => {
|
|
311
|
+
if (this.staged.size === 0 && !this.failed) return
|
|
312
|
+
this.staged.clear()
|
|
313
|
+
this.failed = false
|
|
314
|
+
this.failedReason = undefined
|
|
315
|
+
this.publish()
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Write every staged edit, then re-seed from what the Host accepted.
|
|
322
|
+
*
|
|
323
|
+
* When the scope carries the optional batch surface (the dsh-web-ui
|
|
324
|
+
* bridge scope), every planned write rides one mutation so cross-field
|
|
325
|
+
* validate hooks (baseURL+model) judge the batch as a unit instead of
|
|
326
|
+
* deadlocking on per-field writes. Otherwise the per-field loop runs.
|
|
327
|
+
* A field lands only when the Host reports it held the staged value; a
|
|
328
|
+
* landed field's draft is dropped, a failed one stays staged for the user.
|
|
329
|
+
* @returns settlement after every write and the read-back.
|
|
330
|
+
*/
|
|
331
|
+
async save(): Promise<void> {
|
|
332
|
+
const plan = this.plan()
|
|
333
|
+
const valid = plan.filter(item => item.run !== undefined)
|
|
334
|
+
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
335
|
+
const plannedWrites = valid.map(item => item.op)
|
|
336
|
+
// Snapshot the fields this save writes, so edits staged while it is in
|
|
337
|
+
// flight survive: only the staged keys this save actually wrote are cleared.
|
|
338
|
+
const fields = new Set(plan.map(item => item.field))
|
|
339
|
+
this.saving = true
|
|
340
|
+
this.failed = false
|
|
341
|
+
this.failedReason = undefined
|
|
342
|
+
this.publish()
|
|
343
|
+
const landed = new Set<string>()
|
|
344
|
+
const batch = this.batchedScope()
|
|
345
|
+
if (batch !== undefined) {
|
|
346
|
+
const result = await batch.mutate(plannedWrites)
|
|
347
|
+
if (result.ok) {
|
|
348
|
+
for (const field of result.fields) {
|
|
349
|
+
if (field.landed) landed.add(field.field)
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
this.failedReason = result.message
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
for (const item of valid) {
|
|
356
|
+
if (await item.run!()) landed.add(item.field)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
for (const field of fields) {
|
|
360
|
+
if (landed.has(field)) this.staged.delete(field)
|
|
361
|
+
}
|
|
362
|
+
this.saving = false
|
|
363
|
+
this.failed = landed.size !== fields.size
|
|
364
|
+
this.publish()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The scope's batch surface when it supports one; undefined conservatively otherwise. */
|
|
368
|
+
private batchedScope(): BatchedSettingsScope | undefined {
|
|
369
|
+
const candidate = this.scope as unknown as BatchedSettingsScope | undefined
|
|
370
|
+
return typeof candidate?.mutate === 'function' ? candidate : undefined
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Every staged edit a save would write. An entry whose draft is not a value
|
|
375
|
+
* its field accepts carries no write: the form is still dirty, and the save
|
|
376
|
+
* refuses rather than dropping the edit. A staged edit that matches the
|
|
377
|
+
* effective section is not a write at all.
|
|
378
|
+
* @returns the planned writes, in the order the fields were staged.
|
|
379
|
+
*/
|
|
380
|
+
private plan(): PlannedWrite[] {
|
|
381
|
+
const plan: PlannedWrite[] = []
|
|
382
|
+
for (const [field, staged] of this.staged) {
|
|
383
|
+
const spec = this.specOf(field)
|
|
384
|
+
if (staged.clear) {
|
|
385
|
+
if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
386
|
+
continue
|
|
387
|
+
}
|
|
388
|
+
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
389
|
+
const write = spec.parse(staged.text)
|
|
390
|
+
if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
|
|
391
|
+
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
|
|
392
|
+
else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
|
|
393
|
+
}
|
|
394
|
+
return plan
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private async clear(field: string): Promise<boolean> {
|
|
398
|
+
await this.scope.unset(field)
|
|
399
|
+
return !this.stored(field)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private async store(field: string, value: unknown): Promise<boolean> {
|
|
403
|
+
await this.scope.set(field, value)
|
|
404
|
+
// A redacted secret never appears in the user layer read-back; judging it
|
|
405
|
+
// by value would misreport a successful secret save as failed. The bridge
|
|
406
|
+
// reports secret writes through its secret-set markers (batch path); on
|
|
407
|
+
// the per-field path the scope resolved, so the write is landed.
|
|
408
|
+
if (this.specOf(field).secret) return true
|
|
409
|
+
return this.userLayer()?.[field] === value
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private stage(field: string, edit: StagedEdit): void {
|
|
413
|
+
this.staged.set(field, edit)
|
|
414
|
+
this.failed = false
|
|
415
|
+
this.failedReason = undefined
|
|
416
|
+
this.publish()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private specOf(field: string): FieldSpec {
|
|
420
|
+
const spec = this.specs.get(field)
|
|
421
|
+
// Every call site names a field this card declared; a missing one is a
|
|
422
|
+
// wiring mistake that must not degrade into a silently inert control.
|
|
423
|
+
if (spec === undefined) throw new Error(`settings card has no field ${field}`)
|
|
424
|
+
return spec
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private snapshotOf(): SettingsScopeSnapshot<T> {
|
|
428
|
+
return this.scope.getSnapshot()
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private sectionValue(field: string): unknown {
|
|
432
|
+
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private baseValue(field: string): unknown {
|
|
436
|
+
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private userLayer(): Record<string, unknown> | undefined {
|
|
440
|
+
return this.snapshotOf().user as Record<string, unknown> | undefined
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private stored(field: string): boolean {
|
|
444
|
+
const user = this.userLayer()
|
|
445
|
+
return user !== undefined && Object.hasOwn(user, field)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private publish(): void {
|
|
449
|
+
for (const listener of this.listeners) listener()
|
|
450
|
+
}
|
|
451
|
+
}
|