@dsh-workbench/shared 0.3.5 → 0.3.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.
Files changed (153) hide show
  1. package/dist/api/context-ref.d.ts +0 -12
  2. package/dist/api/context-ref.js +0 -40
  3. package/dist/api/facts.d.ts +0 -6
  4. package/dist/api/facts.js +0 -8
  5. package/dist/api/long-task.d.ts +33 -4
  6. package/dist/api/long-task.js +34 -2
  7. package/dist/components/ConfirmBar.css +26 -0
  8. package/dist/components/ConfirmBar.d.ts +36 -0
  9. package/dist/components/ConfirmBar.js +26 -0
  10. package/dist/components/Form.d.ts +7 -5
  11. package/dist/components/Form.js +5 -3
  12. package/dist/components/FormNodes.d.ts +17 -13
  13. package/dist/components/FormNodes.js +13 -5
  14. package/dist/components/FormRows.css +31 -0
  15. package/dist/components/FormTypes.d.ts +39 -14
  16. package/dist/components/LongTaskRow.css +52 -0
  17. package/dist/components/LongTaskRow.d.ts +18 -2
  18. package/dist/components/LongTaskRow.js +33 -5
  19. package/dist/components/MockStrip.css +11 -1
  20. package/dist/components/StateDot.d.ts +5 -0
  21. package/dist/components/StateDot.js +10 -0
  22. package/dist/config/constants.d.ts +2 -0
  23. package/dist/config/constants.js +2 -0
  24. package/dist/host/__tests__/fakes.d.ts +41 -0
  25. package/dist/host/__tests__/fakes.js +76 -0
  26. package/dist/host/__tests__/preflight-fixtures.d.ts +19 -0
  27. package/dist/host/__tests__/preflight-fixtures.js +47 -0
  28. package/dist/host/board-fact.d.ts +8 -0
  29. package/dist/host/board-fact.js +16 -0
  30. package/dist/host/browser-read.js +2 -1
  31. package/dist/host/context-inject.d.ts +24 -0
  32. package/dist/host/context-inject.js +74 -0
  33. package/dist/host/context-ref.d.ts +13 -0
  34. package/dist/host/context-ref.js +41 -0
  35. package/dist/host/diy-debug.d.ts +80 -0
  36. package/dist/host/diy-debug.js +88 -0
  37. package/dist/host/diy-record.d.ts +135 -0
  38. package/dist/host/diy-record.js +162 -0
  39. package/dist/host/events.d.ts +9 -0
  40. package/dist/host/events.js +70 -0
  41. package/dist/host/facts.d.ts +6 -0
  42. package/dist/host/facts.js +9 -0
  43. package/dist/host/git.js +1 -1
  44. package/dist/host/http.d.ts +2 -2
  45. package/dist/host/http.js +3 -3
  46. package/dist/host/long-task.d.ts +12 -0
  47. package/dist/host/long-task.js +34 -5
  48. package/dist/host/mount-units.d.ts +52 -0
  49. package/dist/host/mount-units.js +186 -0
  50. package/dist/host/opencli-runtime.js +1 -1
  51. package/dist/host/opencli-window.js +1 -1
  52. package/dist/host/opencli.js +2 -1
  53. package/dist/host/panel-state.d.ts +120 -0
  54. package/dist/host/panel-state.js +176 -0
  55. package/dist/host/plugin-source.js +1 -1
  56. package/dist/host/preflight.d.ts +58 -0
  57. package/dist/host/preflight.js +233 -0
  58. package/dist/host/probe-cache.d.ts +6 -0
  59. package/dist/host/probe-cache.js +19 -0
  60. package/dist/host/probe-libs.d.ts +45 -0
  61. package/dist/host/probe-libs.js +61 -0
  62. package/dist/host/probe-units.d.ts +17 -0
  63. package/dist/host/probe-units.js +102 -0
  64. package/dist/host/probe-verdict.d.ts +7 -0
  65. package/dist/host/probe-verdict.js +143 -0
  66. package/dist/host/shell-text.d.ts +13 -0
  67. package/dist/host/shell-text.js +20 -0
  68. package/dist/host/tool-approval.d.ts +1 -0
  69. package/dist/host/tool-scope.d.ts +32 -0
  70. package/dist/host/tool-scope.js +84 -0
  71. package/dist/host/unit-declaration.d.ts +65 -0
  72. package/dist/host/unit-declaration.js +213 -0
  73. package/dist/host/unit-face.d.ts +1 -0
  74. package/dist/host/unit-readiness.d.ts +7 -0
  75. package/dist/host/unit-readiness.js +5 -0
  76. package/dist/host/unit-registry.d.ts +9 -0
  77. package/dist/host/unit-registry.js +33 -0
  78. package/dist/host/version-range.d.ts +2 -0
  79. package/dist/host/version-range.js +73 -0
  80. package/dist/host.d.ts +7 -4
  81. package/dist/host.js +7 -4
  82. package/dist/i18n/en.js +6 -0
  83. package/dist/i18n/index.d.ts +7 -1
  84. package/dist/i18n/zh.d.ts +6 -0
  85. package/dist/i18n/zh.js +6 -0
  86. package/dist/index.d.ts +1 -4
  87. package/dist/index.js +1 -4
  88. package/dist/lib/board-fact.d.ts +0 -7
  89. package/dist/lib/board-fact.js +0 -15
  90. package/dist/lib/diy-record.d.ts +38 -1
  91. package/dist/lib/diy-record.js +66 -0
  92. package/dist/lib/text.d.ts +0 -13
  93. package/dist/lib/text.js +0 -20
  94. package/dist/testing/declaration.js +1 -1
  95. package/package.json +1 -1
  96. package/src/api/__tests__/facts.test.ts +0 -13
  97. package/src/api/__tests__/long-task.test.ts +41 -1
  98. package/src/api/context-ref.ts +0 -39
  99. package/src/api/facts.ts +0 -9
  100. package/src/api/long-task.ts +46 -4
  101. package/src/components/ConfirmBar.css +26 -0
  102. package/src/components/ConfirmBar.test.tsx +130 -0
  103. package/src/components/ConfirmBar.tsx +63 -0
  104. package/src/components/Form.test.tsx +15 -0
  105. package/src/components/Form.tsx +7 -5
  106. package/src/components/FormNodes.tsx +50 -26
  107. package/src/components/FormRows.css +31 -0
  108. package/src/components/FormRows.test.tsx +71 -1
  109. package/src/components/FormTypes.test.ts +31 -0
  110. package/src/components/FormTypes.ts +46 -15
  111. package/src/components/FormWiring.test.tsx +1 -0
  112. package/src/components/LongTaskRow.css +52 -0
  113. package/src/components/LongTaskRow.tsx +59 -4
  114. package/src/components/__tests__/long-task-row.test.ts +115 -0
  115. package/src/config/constants.ts +3 -0
  116. package/src/host/__tests__/board-fact.test.ts +47 -0
  117. package/src/{lib → host/__tests__}/diy-debug.test.ts +1 -1
  118. package/src/{lib → host/__tests__}/diy-record.test.ts +137 -1
  119. package/src/host/__tests__/facts.test.ts +13 -0
  120. package/src/host/__tests__/long-task.test.ts +306 -0
  121. package/src/{lib → host/__tests__}/panel-state.test.ts +1 -1
  122. package/src/host/__tests__/preflight-route.test.ts +33 -0
  123. package/src/host/__tests__/shell-text.test.ts +44 -0
  124. package/src/host/board-fact.ts +14 -0
  125. package/src/host/browser-read.ts +2 -1
  126. package/src/host/context-ref.ts +40 -0
  127. package/src/{lib → host}/diy-record.ts +95 -1
  128. package/src/host/facts.ts +10 -0
  129. package/src/host/git.ts +1 -1
  130. package/src/host/http.ts +4 -4
  131. package/src/host/long-task.ts +52 -7
  132. package/src/host/opencli-runtime.ts +1 -1
  133. package/src/host/opencli-window.ts +1 -1
  134. package/src/host/opencli.ts +2 -1
  135. package/src/host/plugin-source.ts +1 -1
  136. package/src/host/shell-text.ts +21 -0
  137. package/src/host/tool-approval.ts +1 -0
  138. package/src/{api → host}/unit-declaration.ts +1 -1
  139. package/src/host/unit-face.ts +1 -0
  140. package/src/host.ts +7 -4
  141. package/src/i18n/en.ts +7 -0
  142. package/src/i18n/zh.ts +6 -0
  143. package/src/index.ts +1 -4
  144. package/src/lib/board-fact.test.ts +1 -33
  145. package/src/lib/board-fact.ts +0 -13
  146. package/src/lib/text.test.ts +1 -43
  147. package/src/lib/text.ts +0 -22
  148. package/src/testing/declaration.ts +1 -1
  149. package/dist/lib/lint-probe.d.ts +0 -1
  150. package/dist/lib/lint-probe.js +0 -6
  151. /package/src/{api → host}/__tests__/unit-declaration.test.ts +0 -0
  152. /package/src/{lib → host}/diy-debug.ts +0 -0
  153. /package/src/{lib → host}/panel-state.ts +0 -0
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from '../lib/react.js';
2
2
  import { h } from '../lib/react.js';
3
3
  import type { FormAction } from './FormActions.js';
4
- import type { FormField, FormValue } from './FormTypes.js';
4
+ import type { FormField, FormFieldOf, FormValues } from './FormTypes.js';
5
5
  import { FormRow } from './FormRows.js';
6
6
 
7
7
  /**
@@ -9,7 +9,9 @@ import { FormRow } from './FormRows.js';
9
9
  * @param fields - 表单的字段清单。
10
10
  * @returns 保序的新数组,`visible` 不给或为 `true` 的都在;全不可见时给空数组。
11
11
  */
12
- export function visibleFieldsOf(fields: readonly FormField[]): readonly FormField[] {
12
+ export function visibleFieldsOf<Values extends object>(
13
+ fields: readonly FormFieldOf<Values>[],
14
+ ): readonly FormFieldOf<Values>[] {
13
15
  return fields.filter((field) => field.visible !== false);
14
16
  }
15
17
 
@@ -19,7 +21,7 @@ export function visibleFieldsOf(fields: readonly FormField[]): readonly FormFiel
19
21
  * @param fields - 表单的字段清单(通常是已经过滤过可见性的那份)。
20
22
  * @returns 出问题的键的清单、按首次出现顺序去重;缺失的键一律记成空串(报的时候印成 `(空 key)`),空表给空数组。
21
23
  */
22
- export function duplicateKeysOf(fields: readonly FormField[]): readonly string[] {
24
+ export function duplicateKeysOf(fields: readonly { readonly key: string }[]): readonly string[] {
23
25
  const seen = new Set<string>();
24
26
  const duplicated = new Set<string>();
25
27
  for (const field of fields) {
@@ -39,7 +41,7 @@ export function duplicateKeysOf(fields: readonly FormField[]): readonly string[]
39
41
  * 形式与理由同 `FormActions` 里那颗多出来的 `primary`:装配的小错不该让 `CardBoundary` 把用户的整张卡变空白。
40
42
  * @param fields - 表单的字段清单(通常是已经过滤过可见性的那份)。
41
43
  */
42
- export function reportDuplicateKeys(fields: readonly FormField[]): void {
44
+ export function reportDuplicateKeys(fields: readonly { readonly key: string }[]): void {
43
45
  const duplicated = duplicateKeysOf(fields);
44
46
  if (duplicated.length === 0) return;
45
47
  const named = duplicated.map((key) => (key === '' ? '(空 key)' : key));
@@ -54,10 +56,12 @@ export function reportDuplicateKeys(fields: readonly FormField[]): void {
54
56
  * @param fields - 表单的字段清单;取的就是它们的 `value`,`value` 是数组的那格(多选)原样带出、不复制。
55
57
  * @returns 键为字段 `key`、值为该格 `value` 的表;`fields` 为空时给空表,不是 `undefined`。
56
58
  */
57
- export function formValuesOf(fields: readonly FormField[]): Readonly<Record<string, FormValue>> {
58
- const values: Record<string, FormValue> = {};
59
+ export function formValuesOf<Values extends object>(
60
+ fields: readonly FormFieldOf<Values>[],
61
+ ): Readonly<Values> {
62
+ const values: Record<string, unknown> = {};
59
63
  for (const field of fields) values[field.key] = field.value;
60
- return values;
64
+ return values as Readonly<Values>;
61
65
  }
62
66
 
63
67
  /**
@@ -68,19 +72,26 @@ export function formValuesOf(fields: readonly FormField[]): Readonly<Record<stri
68
72
  * @param show - 提交过没有;false 时全给 `undefined`,true 时逐格调 `field.validate` 并按真值收。
69
73
  * @returns 键为 `key`、值为那句文案或 `undefined` 的表;没给 `validate` 的格子也是 `undefined`。
70
74
  */
71
- export function formProblemsOf(
72
- fields: readonly FormField[],
75
+ export function formProblemsOf<Values extends object>(
76
+ fields: readonly FormFieldOf<Values>[],
73
77
  show: boolean,
74
78
  ): Readonly<Record<string, ReactNode | undefined>> {
75
79
  const values = formValuesOf(fields);
76
80
  const problems: Record<string, ReactNode | undefined> = {};
77
81
  for (const field of fields) {
78
- const found = show ? field.validate?.(field.value, values) : undefined;
82
+ const found = show ? problemOf(field, values) : undefined;
79
83
  problems[field.key] = found ? found : undefined;
80
84
  }
81
85
  return problems;
82
86
  }
83
87
 
88
+ function problemOf<K extends string, V, Vals extends object>(
89
+ field: FormField<K, V, Vals>,
90
+ values: Readonly<Vals>,
91
+ ): ReactNode | undefined {
92
+ return field.validate?.(field.value, values);
93
+ }
94
+
84
95
  /**
85
96
  * 按字段顺序取第一处错误 —— 提交被挡住时要带进视口的就是这一格。
86
97
  * 判的是**真值**(与 `formProblemsOf` 同一口径):假值那种"错误文案"渲染出来本来就是空的,不算错。
@@ -88,19 +99,19 @@ export function formProblemsOf(
88
99
  * @param problems - `formProblemsOf` 那张表,也可以是调用点自己拼的(同样按真值判)。
89
100
  * @returns 第一格有错的字段;一格都没错时给 `undefined`。
90
101
  */
91
- export function formFirstProblemOf(
92
- fields: readonly FormField[],
102
+ export function formFirstProblemOf<Values extends object>(
103
+ fields: readonly FormFieldOf<Values>[],
93
104
  problems: Readonly<Record<string, ReactNode | undefined>>,
94
- ): FormField | undefined {
105
+ ): FormFieldOf<Values> | undefined {
95
106
  return fields.find((field) => Boolean(problems[field.key]));
96
107
  }
97
108
 
98
109
  /**
99
110
  * 提交那一拍的结果:有错就挡住(并点出第一处错在哪一格),没错就把键值表交出去。
100
111
  */
101
- export type FormSubmitOutcome =
102
- | { readonly blocked: true; readonly first: FormField }
103
- | { readonly blocked: false; readonly values: Readonly<Record<string, FormValue>> };
112
+ export type FormSubmitOutcome<Values extends object = FormValues> =
113
+ | { readonly blocked: true; readonly first: FormFieldOf<Values> }
114
+ | { readonly blocked: false; readonly values: Readonly<Values> };
104
115
 
105
116
  /**
106
117
  * 算提交那一拍该干什么 —— `Form` 的提交钮点下去、以及 `<form>` 的 Enter 提交,走的都是它。
@@ -109,7 +120,9 @@ export type FormSubmitOutcome =
109
120
  * @returns `blocked: true` 时带上是按字段顺序数第一格有错的字段(拿它去 `revealFirstProblem`);
110
121
  * `blocked: false` 时带上从 `fields` 现取的键值表。空表一定给 `blocked: false` 与空 values。
111
122
  */
112
- export function formSubmitOf(fields: readonly FormField[]): FormSubmitOutcome {
123
+ export function formSubmitOf<Values extends object>(
124
+ fields: readonly FormFieldOf<Values>[],
125
+ ): FormSubmitOutcome<Values> {
113
126
  const problems = formProblemsOf(fields, true);
114
127
  const first = formFirstProblemOf(fields, problems);
115
128
  if (first !== undefined) return { blocked: true, first };
@@ -170,8 +183,8 @@ export function formActionsOf(args: {
170
183
  * @param args.busy - 提交中:每格的绑定 `disabled` 为真。
171
184
  * @returns `FormRow` 组成的数组;没有可画的格子时给空数组。
172
185
  */
173
- export function formRowsOf(args: {
174
- readonly fields: readonly FormField[];
186
+ export function formRowsOf<Values extends object>(args: {
187
+ readonly fields: readonly FormFieldOf<Values>[];
175
188
  readonly problems: Readonly<Record<string, ReactNode | undefined>>;
176
189
  readonly showProblem: boolean;
177
190
  readonly busy: boolean;
@@ -183,11 +196,22 @@ export function formRowsOf(args: {
183
196
  hint: field.hint,
184
197
  problem: args.showProblem ? args.problems[field.key] : undefined,
185
198
  layout: field.layout,
186
- }, field.control({
187
- id: `wb-form-${field.key}`,
188
- name: field.key,
189
- invalid: args.showProblem && Boolean(args.problems[field.key]),
190
- disabled: args.busy || field.disabled === true,
191
- })),
199
+ }, controlOf(field, args.problems, args.showProblem, args.busy)),
192
200
  );
193
- }
201
+ }
202
+
203
+ function controlOf<K extends string, V, Vals extends object>(
204
+ field: FormField<K, V, Vals>,
205
+ problems: Readonly<Record<string, ReactNode | undefined>>,
206
+ showProblem: boolean,
207
+ busy: boolean,
208
+ ): ReactNode {
209
+ return field.control({
210
+ id: `wb-form-${field.key}`,
211
+ name: field.key,
212
+ invalid: showProblem && Boolean(problems[field.key]),
213
+ disabled: busy || field.disabled === true,
214
+ value: field.value,
215
+ onChange: field.onChange,
216
+ });
217
+ }
@@ -24,6 +24,10 @@
24
24
  gap: var(--wb-len-6);
25
25
  }
26
26
 
27
+ .wb-formRows > .wb-formRow[data-layout='stacked'] {
28
+ grid-template-columns: minmax(0, 1fr);
29
+ }
30
+
27
31
  .wb-formLabel {
28
32
  min-width: 0;
29
33
  font-size: var(--dsw-font-xs-13-font-size);
@@ -36,12 +40,39 @@
36
40
  white-space: normal;
37
41
  }
38
42
 
43
+ .wb-formRow[data-layout='stacked'] .wb-formHint,
44
+ .wb-formRow[data-layout='stacked'] .wb-formProblem {
45
+ grid-column: 1 / -1;
46
+ }
47
+
39
48
  .wb-formControl {
40
49
  display: flex;
41
50
  align-items: center;
42
51
  min-width: 0;
43
52
  }
44
53
 
54
+ .wb-formControl
55
+ > :where(
56
+ .wb-selectAnchor,
57
+ .wb-choiceMenu,
58
+ input:not(
59
+ [type='checkbox'],
60
+ [type='radio'],
61
+ [type='button'],
62
+ [type='submit'],
63
+ [type='reset'],
64
+ [type='image'],
65
+ [type='color'],
66
+ [type='range']
67
+ ),
68
+ select,
69
+ textarea
70
+ ) {
71
+ box-sizing: border-box;
72
+ width: 100%;
73
+ min-width: 0;
74
+ }
75
+
45
76
  .wb-formHint {
46
77
  grid-column: 2;
47
78
  font-size: var(--dsw-font-xxs-12-font-size);
@@ -84,11 +84,37 @@ describe('FormRow', () => {
84
84
 
85
85
  const FORM_CSS = readFileSync(new URL('./FormRows.css', import.meta.url), 'utf8');
86
86
 
87
+ function normalize(selector: string): string {
88
+ return selector
89
+ .replace(/\s+/g, ' ')
90
+ .replace(/\(\s+/g, '(')
91
+ .replace(/\s+\)/g, ')')
92
+ .trim();
93
+ }
94
+
95
+ function selectorParts(head: string): string[] {
96
+ const parts: string[] = [];
97
+ let depth = 0;
98
+ let current = '';
99
+ for (const char of head) {
100
+ if (char === '(') depth += 1;
101
+ if (char === ')') depth -= 1;
102
+ if (char === ',' && depth === 0) {
103
+ parts.push(normalize(current));
104
+ current = '';
105
+ continue;
106
+ }
107
+ current += char;
108
+ }
109
+ parts.push(normalize(current));
110
+ return parts;
111
+ }
112
+
87
113
  function declarations(selector: string): string[] {
88
114
  for (const block of FORM_CSS.split('}')) {
89
115
  const [head, ...rest] = block.split('{');
90
116
  if (rest.length === 0 || head === undefined) continue;
91
- if (head.trim() !== selector) continue;
117
+ if (!selectorParts(head).includes(normalize(selector))) continue;
92
118
  return rest
93
119
  .join('{')
94
120
  .split(';')
@@ -132,7 +158,51 @@ describe('标签列:同一个 FormRows 共用一列(ADR-0052 §11)', () =>
132
158
  );
133
159
  });
134
160
 
161
+ it('★ stacked 行在 FormRows 里也压得过 subgrid 与那条回退(同权重、靠后那条赢过的老坑)', () => {
162
+ expect(declarations(".wb-formRows > .wb-formRow[data-layout='stacked']")).toContain(
163
+ 'grid-template-columns: minmax(0, 1fr)',
164
+ );
165
+ });
166
+
167
+ it('★ stacked 行里 hint / problem 通栏(它们的 grid-column: 2 会造出隐式第二列)', () => {
168
+ expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formHint")).toContain(
169
+ 'grid-column: 1 / -1',
170
+ );
171
+ expect(declarations(".wb-formRow[data-layout='stacked'] .wb-formProblem")).toContain(
172
+ 'grid-column: 1 / -1',
173
+ );
174
+ });
175
+
135
176
  it('★ 不是 FormRow 的子项(分组标题那种)通栏,别被塞进标签那一列', () => {
136
177
  expect(declarations('.wb-formRows > :not(.wb-formRow)')).toContain('grid-column: 1 / -1');
137
178
  });
138
179
  });
180
+
181
+ const EXCLUDED_INPUT_TYPES = [
182
+ 'checkbox',
183
+ 'radio',
184
+ 'button',
185
+ 'submit',
186
+ 'reset',
187
+ 'image',
188
+ 'color',
189
+ 'range',
190
+ ];
191
+ const CONTROL_FILL_SELECTOR = `.wb-formControl > :where(.wb-selectAnchor, .wb-choiceMenu, input:not(${EXCLUDED_INPUT_TYPES.map((one) => `[type='${one}']`).join(', ')}), select, textarea)`;
192
+
193
+ describe('控件槽那一格:字段控件铺满,宽度不归调用点写(ADR-0052 §13)', () => {
194
+ it('★ 那一格里的字段控件吃满控件槽:border-box + width 100% + min-width 0', () => {
195
+ const fill = declarations(CONTROL_FILL_SELECTOR);
196
+
197
+ expect(fill).toContain('box-sizing: border-box');
198
+ expect(fill).toContain('width: 100%');
199
+ expect(fill).toContain('min-width: 0');
200
+ });
201
+
202
+ it('★ 开关与按钮那一族不在名单里(拉宽一颗 switch、一颗 <input type=submit> 就是另一回事)', () => {
203
+ for (const type of EXCLUDED_INPUT_TYPES) {
204
+ expect(CONTROL_FILL_SELECTOR).toContain(`[type='${type}']`);
205
+ }
206
+ expect(FORM_CSS).not.toMatch(/\.wb-formControl\s*>\s*\*/);
207
+ });
208
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import type { FormFieldOf, FormProps } from './FormTypes.js';
3
+
4
+ type Draft = { readonly env: string; readonly branch?: string };
5
+
6
+ type Submitted = Parameters<FormProps<Draft>['onSubmit']>[0];
7
+ type EnvField = Extract<FormFieldOf<Draft>, { readonly key: 'env' }>;
8
+ type BranchField = Extract<FormFieldOf<Draft>, { readonly key: 'branch' }>;
9
+ type AcceptedKeys = Pick<FormFieldOf<Draft>, 'key'>['key'];
10
+ type StrangerKeyAccepted = { readonly key: 'nope' } extends Pick<FormFieldOf<Draft>, 'key'> ? true : false;
11
+
12
+ const draft: FormFieldOf<Draft> = {
13
+ key: 'env',
14
+ label: '环境',
15
+ value: 'stage',
16
+ onChange: (next) => void next.trim(),
17
+ control: (binding) => binding.value,
18
+ };
19
+
20
+ describe('Form<Values> 把每一格的键与值钉住(这几条由 tsc 核,`vitest run` 跑的是最后那条 expect)', () => {
21
+ it('★ 键只取 Values 的键、值只取那一键的类型;会隐藏的键写成可选键,提交表跟着带上 undefined', () => {
22
+ expectTypeOf<AcceptedKeys>().toEqualTypeOf<'env' | 'branch'>();
23
+ expectTypeOf<StrangerKeyAccepted>().toEqualTypeOf<false>();
24
+ expectTypeOf<EnvField['value']>().toEqualTypeOf<string>();
25
+ expectTypeOf<EnvField['onChange']>().toEqualTypeOf<(next: string) => void>();
26
+ expectTypeOf<BranchField['value']>().toEqualTypeOf<string | undefined>();
27
+ expectTypeOf<Submitted>().toEqualTypeOf<Readonly<Draft>>();
28
+ expectTypeOf<Submitted['branch']>().toEqualTypeOf<string | undefined>();
29
+ expect(draft.key).toBe('env');
30
+ });
31
+ });
@@ -2,38 +2,65 @@ import type { ReactNode } from '../lib/react.js';
2
2
  import type { FormAction } from './FormActions.js';
3
3
 
4
4
  /**
5
- * 一格控件的值:单值(`TextField` / `SelectField` / `ChoiceGroup` 单选),或一组值(`ChoiceGroup` 多选)。
5
+ * 默认档一格控件的值:单值(`TextField` / `SelectField` / `ChoiceGroup` 单选),或一组值(`ChoiceGroup` 多选)。
6
+ * 写了 `Form<Values>` 的类型参数之后,每一格的值类型由 `Values[那一格的键]` 定 —— 这个联合只是**没写类型参数**时的默认档。
6
7
  */
7
8
  export type FormValue = string | readonly string[];
8
9
 
9
10
  /**
10
- * `Form` 递给控件那一格的绑定:`id` / `name` / `invalid` / `disabled` 由 `Form` 定,控件只负责接上。
11
+ * 默认档的 `Values`:键是任意 `string`、值是 `FormValue` —— 不写 `Form<Values>` 的类型参数时就是它,
12
+ * 于是 `fields` 的键与值都不钉(老写法照旧能编),代价是每格拿到的都是那个联合、要自己收窄。
11
13
  */
12
- export interface FormFieldBinding {
14
+ export type FormValues = Readonly<Record<string, FormValue>>;
15
+
16
+ /**
17
+ * `Form` 递给控件那一格的绑定:`id` / `name` / `invalid` / `disabled` 由 `Form` 定;
18
+ * `value` / `onChange` 就是这一格自己那份配置里的那两个**原样转手**(`Form` 不持有值)——
19
+ * 控件接上即可,别再自己接 model:值只写在字段配置里那一次。
20
+ */
21
+ export interface FormFieldBinding<K extends string = string, V = FormValue> {
13
22
  readonly id: string;
14
- readonly name: string;
23
+ readonly name: K;
15
24
  readonly invalid: boolean;
16
25
  readonly disabled: boolean;
26
+ readonly value: V;
27
+ readonly onChange: (next: V) => void;
17
28
  }
18
29
 
19
30
  /**
20
- * 一格字段的配置:键、标签、值、怎么算错(`validate`)、控件本身 —— 排布、时机、阻断都不写在这里,那些归 `Form`。
31
+ * 一格字段的配置:键、标签、值、值怎么改(`onChange`)、怎么算错(`validate`)、控件本身 ——
32
+ * 排布、时机、阻断都不写在这里,那些归 `Form`。`value` 与 `onChange` 就在同一格里,`control` 从绑定直接取
33
+ * (`(binding) => …({ value: binding.value, onChange: binding.onChange })`),值不再有第二处。
21
34
  * `validate` 是纯函数、同步、只读 `values`:返回**真值**才算这一格错,假值(`undefined` / `null` / `false` / 空串)
22
35
  * 一律当"没错"—— 所以 `(value) => value.trim() === '' && '必填'` 这种用 `&&` 算文案的写法是对的,
23
36
  * 它的返回值就是错误行那句话。
24
37
  */
25
- export interface FormField {
26
- readonly key: string;
38
+ export interface FormField<
39
+ K extends string = string,
40
+ V = FormValue,
41
+ Vals extends object = FormValues,
42
+ > {
43
+ readonly key: K;
27
44
  readonly label: ReactNode;
28
45
  readonly hint?: ReactNode | undefined;
29
46
  readonly layout?: 'inline' | 'stacked' | undefined;
30
- readonly value: FormValue;
47
+ readonly value: V;
48
+ readonly onChange: (next: V) => void;
31
49
  readonly visible?: boolean | undefined;
32
50
  readonly disabled?: boolean | undefined;
33
- readonly validate?: (value: FormValue, values: Readonly<Record<string, FormValue>>) => ReactNode | undefined;
34
- readonly control: (binding: FormFieldBinding) => ReactNode;
51
+ readonly validate?: (value: V, values: Readonly<Vals>) => ReactNode | undefined;
52
+ readonly control: (binding: FormFieldBinding<K, V>) => ReactNode;
35
53
  }
36
54
 
55
+ /**
56
+ * 把 `Values` 的每一键映射成一格字段 —— 写 `Form<Values>` 时 `fields` 就是这个联合的数组:
57
+ * `{ key: 'env', … }` 那一格的 `value` / `onChange` / `validate` 只认 `Values['env']`,
58
+ * `control` 拿到的绑定也只带它。要"键值都不钉"的松档就干脆别写类型参数(默认档 `FormValues`),别手写这个类型。
59
+ */
60
+ export type FormFieldOf<Values extends object> = {
61
+ [K in keyof Values & string]: FormField<K, Values[K], Values>;
62
+ }[keyof Values & string];
63
+
37
64
  /**
38
65
  * 提交钮:文案归调用点,位置、禁用与忙态归 `Form`。
39
66
  */
@@ -51,15 +78,19 @@ export interface FormCancel {
51
78
  }
52
79
 
53
80
  /**
54
- * `Form` 的契约:fields 配置进,排布、校验时机、提交阻断、操作区全归组件。
81
+ * `Form` 的契约:`fields` 配置进,排布、校验时机、提交阻断、操作区全归组件。
82
+ * `Values` 是**那张值表**的形状(不写就是默认档 `FormValues`):`fields` 的键只能取它的键,
83
+ * 每格的 `value` / `onChange` / `validate` 只认它那一键的值类型,`onSubmit` 与 `validate` 第二参收到的也是它。
84
+ * **会 `visible: false` 的键写成可选键**(`branch?: string`)—— 那一格不渲染、不校验、不进 `values`(规则五),
85
+ * 可选键让"它可能不在表里"这件事在类型上也成立;永远可见的键照常必填。
55
86
  */
56
- export interface FormProps {
57
- readonly fields: readonly FormField[];
58
- readonly onSubmit: (values: Readonly<Record<string, FormValue>>) => void;
87
+ export interface FormProps<Values extends object = FormValues> {
88
+ readonly fields: readonly FormFieldOf<Values>[];
89
+ readonly onSubmit: (values: Readonly<Values>) => void;
59
90
  readonly submit: FormSubmit;
60
91
  readonly cancel?: FormCancel | undefined;
61
92
  readonly extra?: readonly FormAction[] | undefined;
62
93
  readonly busy?: boolean | undefined;
63
94
  readonly disabled?: boolean | undefined;
64
95
  readonly problem?: ReactNode | undefined;
65
- }
96
+ }
@@ -7,6 +7,7 @@ function field(key: string, value: FormValue, validate?: FormField['validate']):
7
7
  key,
8
8
  label: key,
9
9
  value,
10
+ onChange: () => {},
10
11
  control: (binding) => `${key}:${binding.id}:${String(binding.invalid)}:${String(binding.disabled)}`,
11
12
  ...(validate === undefined ? {} : { validate }),
12
13
  };
@@ -45,4 +45,56 @@
45
45
  margin: var(--wb-len-4) 0 0;
46
46
  max-height: var(--wb-len-220);
47
47
  overflow: auto;
48
+ }
49
+
50
+ .wb-longTaskStop {
51
+ display: inline-flex;
52
+ flex: none;
53
+ align-items: center;
54
+ align-self: center;
55
+ justify-content: center;
56
+ width: var(--wb-len-20);
57
+ height: var(--wb-len-20);
58
+ padding: 0;
59
+ margin-left: auto;
60
+ border: 0.5px solid var(--dsw-alias-border-l2);
61
+ border-radius: var(--dsw-radius-sm);
62
+ background: var(--dsw-specific-menu);
63
+ color: var(--dsw-alias-label-secondary);
64
+ cursor: pointer;
65
+ }
66
+
67
+ .wb-longTaskStop:hover:not(:disabled),
68
+ .wb-longTaskStop:focus-visible {
69
+ border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
70
+ color: var(--dsw-alias-state-error-primary);
71
+ }
72
+
73
+ .wb-longTaskStop:disabled {
74
+ cursor: default;
75
+ }
76
+
77
+ .wb-longTaskStopArmed,
78
+ .wb-longTaskStopArmed:hover:not(:disabled),
79
+ .wb-longTaskStopArmed:focus-visible {
80
+ gap: var(--wb-len-4);
81
+ width: auto;
82
+ padding: 0 var(--wb-len-7);
83
+ border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
84
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
85
+ color: var(--dsw-alias-state-error-primary);
86
+ }
87
+
88
+ .wb-longTaskStopLabel {
89
+ white-space: nowrap;
90
+ font-size: var(--dsw-font-xxxs-11-font-size);
91
+ line-height: var(--wb-line-18);
92
+ }
93
+
94
+ .wb-longTaskStopWhy {
95
+ margin: 0;
96
+ font-size: var(--dsw-font-xxxs-11-font-size);
97
+ line-height: var(--wb-line-16);
98
+ color: var(--dsw-alias-state-error-primary);
99
+ overflow-wrap: anywhere;
48
100
  }
@@ -1,11 +1,12 @@
1
- import { longTaskDot, type LongTaskStatus } from '../api/long-task.js';
1
+ import { longTaskDot, longTaskStopPress, longTaskStopStage, LONG_TASK_STOP_ARM_MS, type LongTaskStatus } from '../api/long-task.js';
2
2
  import { Link } from './Link.js';
3
- import { Fragment, h, useState } from '../lib/react.js';
3
+ import { Fragment, h, useEffect, useState } from '../lib/react.js';
4
4
  import {
5
5
  CodeBlock,
6
6
  DisclosureRow,
7
7
  IconCodeOutlineRegular,
8
8
  IconFlatListOutlineRegular,
9
+ IconStopFillRegular,
9
10
  StateDot,
10
11
  } from '@deepseek-ai/dsh-client-ui-primitives';
11
12
  import './LongTaskRow.css';
@@ -19,7 +20,22 @@ export interface LongTaskRowLink {
19
20
  }
20
21
 
21
22
  /**
22
- * `LongTaskRow` 的契约:状态、标题、问题、链接,与两处可展开的原始输出。
23
+ * `LongTaskRow` 那颗两按停止钮的契约:确认之后真发的那一下、三句词、在飞与失败两态。
24
+ * `label` 是没上膛时那颗钮的 aria-label(dsh 的词:`停止任务 {label}`);`askLabel` 是上膛时那颗钮上显示的字(`确认停止`);
25
+ * `armedLabel` 是上膛时的 aria-label,不给就用 `askLabel`(`再次点击确认停止`);`busy` = 这一趟正在被停、钮禁用;
26
+ * `failed` 给了才在问题那行后面再画一行。
27
+ */
28
+ export interface LongTaskRowStop {
29
+ readonly onStop: () => void;
30
+ readonly label: string;
31
+ readonly askLabel: string;
32
+ readonly armedLabel?: string | undefined;
33
+ readonly busy?: boolean | undefined;
34
+ readonly failed?: string | null | undefined;
35
+ }
36
+
37
+ /**
38
+ * `LongTaskRow` 的契约:状态、标题、问题、链接,两处可展开的原始输出,与最右边那颗可选的停止钮。
23
39
  */
24
40
  export interface LongTaskRowProps {
25
41
  readonly status: LongTaskStatus;
@@ -32,6 +48,7 @@ export interface LongTaskRowProps {
32
48
  readonly rawTitle?: string | undefined;
33
49
  readonly copyLabel?: string | undefined;
34
50
  readonly copiedLabel?: string | undefined;
51
+ readonly stop?: LongTaskRowStop | undefined;
35
52
  }
36
53
 
37
54
  function RowLink({ href, text }: LongTaskRowLink) {
@@ -45,7 +62,7 @@ function RowLink({ href, text }: LongTaskRowLink) {
45
62
  /**
46
63
  * 长任务那一行:状态点 + 标题 + 链接,跑失败时把步骤与原始输出收在两处可展开里。
47
64
  * 发布、扫描、迁移这类跑得久的事都用它,别自己画进度。
48
- * @param props.status - `longTaskDot` 认的四态之一,决定状态点颜色与要不要画"失败"那两块。
65
+ * @param props.status - `longTaskDot` 认的五态之一,决定状态点颜色与要不要画"失败"那两块。
49
66
  * @param props.headline - 这一行在干什么(一句话)。
50
67
  * @param props.problem - 失败原因;不给不画。
51
68
  * @param props.links - 行尾的链接,通常指向产物或日志。
@@ -53,15 +70,38 @@ function RowLink({ href, text }: LongTaskRowLink) {
53
70
  * @param props.raw - 原始输出;只在失败时画,且要同时给 `rawTitle` 才成一块可展开。
54
71
  * @param props.stepsTitle - 步骤那块展开行的标题;`rawTitle` 同理是原始输出那块。
55
72
  * @param props.copyLabel - 复制钮文案:给了才画复制钮;`copiedLabel` 是复制成功后那句。
73
+ * @param props.stop - 给了才在最右边画那颗两按停止钮;**不给就一个像素都不变**。只在生产方认 `signal` 时给。
56
74
  * @returns 一行长任务。
57
75
  */
58
76
  export function LongTaskRow(props: LongTaskRowProps) {
59
77
  const [stepsOpen, setStepsOpen] = useState(false);
60
78
  const [rawOpen, setRawOpen] = useState(false);
79
+ const [armed, setArmed] = useState(false);
61
80
  const failed = props.status === 'failed';
62
81
  const problem = props.problem ?? '';
63
82
  const raw = (props.raw ?? '').trim();
64
83
  const steps = props.steps ?? [];
84
+ const stop = props.stop;
85
+ const busy = stop?.busy === true;
86
+ const stopWhy = stop?.failed ?? '';
87
+
88
+ useEffect(() => {
89
+ if (!armed) return undefined;
90
+ const timer = setTimeout(() => setArmed(false), LONG_TASK_STOP_ARM_MS);
91
+ return () => clearTimeout(timer);
92
+ }, [armed]);
93
+
94
+ const onStopPress = (): void => {
95
+ if (stop === undefined) return;
96
+ const press = longTaskStopPress(longTaskStopStage({ armed, busy }));
97
+ if (press === 'ignore') return;
98
+ if (press === 'arm') {
99
+ setArmed(true);
100
+ return;
101
+ }
102
+ setArmed(false);
103
+ stop.onStop();
104
+ };
65
105
 
66
106
  return (
67
107
  <div className="wb-longTask">
@@ -71,8 +111,23 @@ export function LongTaskRow(props: LongTaskRowProps) {
71
111
  {(props.links ?? []).map((link) => (
72
112
  <RowLink href={link.href} key={`${link.text}@${link.href}`} text={link.text} />
73
113
  ))}
114
+ {stop === undefined ? null : (
115
+ <button
116
+ type="button"
117
+ className={`wb-longTaskStop${armed ? ' wb-longTaskStopArmed' : ''}`}
118
+ data-stop-state={longTaskStopStage({ armed, busy })}
119
+ aria-label={armed ? stop.armedLabel ?? stop.askLabel : stop.label}
120
+ disabled={busy}
121
+ onClick={onStopPress}
122
+ onBlur={() => setArmed(false)}
123
+ >
124
+ <IconStopFillRegular size={12} />
125
+ {armed ? <span className="wb-longTaskStopLabel">{stop.askLabel}</span> : null}
126
+ </button>
127
+ )}
74
128
  </p>
75
129
  {problem === '' ? null : <p className="wb-longTaskProblem">{problem}</p>}
130
+ {stopWhy === '' ? null : <p className="wb-longTaskStopWhy">{stopWhy}</p>}
76
131
  {!failed ? null : (
77
132
  <Fragment>
78
133
  {steps.length === 0 || props.stepsTitle === undefined ? null : (