@zhz8888/dsh-android-emulator-helper 1.0.0 → 1.1.0
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.md +33 -17
- package/README.zh-CN.md +22 -12
- package/lib/client.js +297 -105
- package/lib/index.js +15 -2
- package/lib/providers/avd.js +53 -4
- package/lib/providers/build.js +1 -1
- package/lib/providers/preflight.js +31 -4
- package/lib/providers/project-template.js +53 -0
- package/lib/providers/project.js +10 -2
- package/lib/providers/ui.js +68 -28
- package/lib/settings.js +56 -16
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -39,38 +39,58 @@ window.__ModuleLoader__.load({
|
|
|
39
39
|
const CSS_TAG = "@zhz8888/dsh-android-emulator-helper/Settings.module.css";
|
|
40
40
|
|
|
41
41
|
const CSS = [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
// Page frame. The shell renders the section's nav label but no title of
|
|
43
|
+
// its own, so the page carries one, then a secondary intro. Below that
|
|
44
|
+
// it is one card per category: the card outline is what separates the
|
|
45
|
+
// categories, so no rule is needed between them and each group reads as
|
|
46
|
+
// one block. The metrics are read from the platform's own field kit —
|
|
47
|
+
// `@deepseek-ai/dsh-client-ui-primitives/lib/settings-form/fields.module.css`
|
|
48
|
+
// (MIT), which every built-in settings page uses — so the page sits
|
|
49
|
+
// beside them: a 13/1.5 label, a 12px hint, a 34px control on a 0.5px
|
|
50
|
+
// border-l4 edge. The composition above those pieces is this page's own.
|
|
51
|
+
".aemh_page{flex-direction:column;display:flex;max-width:720px;color:var(--dsw-alias-label-primary)}",
|
|
52
|
+
".aemh_title{margin:0;font-size:16px;font-weight:600;line-height:1.5}",
|
|
53
|
+
".aemh_intro{margin:6px 0 0;font-size:13px;line-height:1.6;color:var(--dsw-alias-label-secondary)}",
|
|
54
|
+
".aemh_readOnly{margin:12px 0 0;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary)}",
|
|
55
|
+
".aemh_group{margin-top:20px;padding:14px 16px 16px;border:.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);background:var(--dsw-alias-bg-layer-2);flex-direction:column;display:flex}",
|
|
56
|
+
".aemh_groupHead{align-items:baseline;gap:8px;display:flex}",
|
|
57
|
+
".aemh_groupTitle{margin:0;font-size:14px;font-weight:600;line-height:1.5}",
|
|
58
|
+
".aemh_groupHint{color:var(--dsw-alias-label-tertiary);margin:2px 0 4px;font-size:12px;line-height:1.6}",
|
|
59
|
+
".aemh_field{flex-direction:column;gap:6px;display:flex;padding:10px 0;min-width:0}",
|
|
49
60
|
".aemh_fieldHead{align-items:center;gap:8px;display:flex;min-width:0}",
|
|
50
|
-
".aemh_label{font-size:13px;font-weight:500;line-height:
|
|
51
|
-
".aemh_reset{font:
|
|
52
|
-
".aemh_reset:hover{color:var(--dsw-alias-label-primary)}",
|
|
53
|
-
".
|
|
54
|
-
".aemh_control:
|
|
55
|
-
".aemh_control
|
|
56
|
-
".aemh_control
|
|
57
|
-
"
|
|
58
|
-
".
|
|
59
|
-
".
|
|
60
|
-
".
|
|
61
|
-
".
|
|
62
|
-
".
|
|
61
|
+
".aemh_label{font-size:13px;font-weight:500;line-height:1.5}",
|
|
62
|
+
".aemh_reset{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);text-decoration:underline;cursor:pointer;background:none;border:0;padding:0}",
|
|
63
|
+
".aemh_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}",
|
|
64
|
+
".aemh_reset:disabled{cursor:default;opacity:.5}",
|
|
65
|
+
".aemh_control{box-sizing:border-box;border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);outline:none;padding:0 12px;font-size:13px;line-height:1.5;width:100%}",
|
|
66
|
+
".aemh_control:focus-visible{border-color:var(--dsw-alias-state-business-primary)}",
|
|
67
|
+
".aemh_control[aria-invalid=true]{border-color:var(--dsw-alias-state-error-primary)}",
|
|
68
|
+
".aemh_control:disabled{color:var(--dsw-alias-label-tertiary);cursor:default}",
|
|
69
|
+
"textarea.aemh_control{height:auto;min-height:96px;padding:10px 12px;line-height:1.6;resize:vertical;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);font-size:12px}",
|
|
70
|
+
".aemh_selectWrap{position:relative;display:block}",
|
|
71
|
+
".aemh_selectWrap::after{content:\"\";position:absolute;right:12px;top:calc(50% - 2px);border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid var(--dsw-alias-label-tertiary);pointer-events:none}",
|
|
72
|
+
".aemh_select{appearance:none;cursor:pointer;padding-right:28px}",
|
|
73
|
+
".aemh_hint{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-tertiary)}",
|
|
74
|
+
".aemh_error{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px;line-height:1.6}",
|
|
63
75
|
".aemh_toolRow{flex-wrap:wrap;align-items:center;gap:10px;display:flex}",
|
|
64
76
|
".aemh_checks{flex-direction:column;gap:2px;margin:0;padding:0;list-style:none;display:flex}",
|
|
65
77
|
".aemh_check{border-bottom:.5px solid var(--dsw-alias-border-l2);align-items:flex-start;gap:10px;padding:8px 0;display:flex}",
|
|
66
78
|
".aemh_check:last-child{border-bottom:0}",
|
|
67
79
|
".aemh_checkBody{flex-direction:column;gap:2px;min-width:0;flex:1;display:flex}",
|
|
68
|
-
".aemh_checkName{font-size:13px;line-height:
|
|
69
|
-
".aemh_checkDetail{color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;white-space:pre-wrap;margin:0;font-size:12px;line-height:
|
|
70
|
-
".aemh_checkFix{color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-secondary));overflow-wrap:anywhere;margin:0;font-size:12px;line-height:
|
|
71
|
-
".aemh_verdict{align-items:center;gap:8px;font-size:13px;line-height:
|
|
72
|
-
".aemh_note{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:
|
|
80
|
+
".aemh_checkName{font-size:13px;line-height:1.5}",
|
|
81
|
+
".aemh_checkDetail{color:var(--dsw-alias-label-tertiary);overflow-wrap:anywhere;white-space:pre-wrap;margin:0;font-size:12px;line-height:1.6}",
|
|
82
|
+
".aemh_checkFix{color:var(--dsw-alias-state-warn-label,var(--dsw-alias-label-secondary));overflow-wrap:anywhere;margin:0;font-size:12px;line-height:1.6}",
|
|
83
|
+
".aemh_verdict{align-items:center;gap:8px;font-size:13px;line-height:1.5;display:flex}",
|
|
84
|
+
".aemh_note{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.6}",
|
|
73
85
|
".aemh_mono{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);overflow-wrap:anywhere}",
|
|
86
|
+
// A small pill, not a full-width panel: it hugs its message and buttons and
|
|
87
|
+
// centres itself in the page column with auto margins, so it reads as a
|
|
88
|
+
// floating notice. It is still one in-flow child held at the bottom by
|
|
89
|
+
// `position: sticky`, so it never covers a field and nothing has to
|
|
90
|
+
// reserve room for it.
|
|
91
|
+
".aemh_actions{position:sticky;bottom:16px;z-index:1;box-sizing:border-box;margin:20px auto 0;padding:6px 8px 6px 14px;width:max-content;max-width:min(560px,100%);border:.5px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-soft,0 4px 14px -6px rgba(0,0,0,.2));align-items:center;gap:8px;display:flex}",
|
|
92
|
+
".aemh_actionsText{flex:0 1 auto;min-width:0;margin:0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;line-height:1.5}",
|
|
93
|
+
".aemh_actionsError .aemh_actionsText{color:var(--dsw-alias-state-error-primary)}",
|
|
74
94
|
].join("");
|
|
75
95
|
|
|
76
96
|
/**
|
|
@@ -174,12 +194,12 @@ window.__ModuleLoader__.load({
|
|
|
174
194
|
},
|
|
175
195
|
},
|
|
176
196
|
{
|
|
177
|
-
key: "
|
|
197
|
+
key: "bootTimeoutSeconds",
|
|
178
198
|
kind: "number",
|
|
179
|
-
label: { zh: "
|
|
199
|
+
label: { zh: "模拟器启动超时(秒)", en: "Emulator boot timeout (seconds)" },
|
|
180
200
|
hint: {
|
|
181
|
-
zh: "等待模拟器完成启动的最长时间,默认
|
|
182
|
-
en: "Longest wait for the emulator to finish booting;
|
|
201
|
+
zh: "等待模拟器完成启动的最长时间,默认 180 秒。",
|
|
202
|
+
en: "Longest wait for the emulator to finish booting; 180 seconds by default.",
|
|
183
203
|
},
|
|
184
204
|
},
|
|
185
205
|
],
|
|
@@ -199,8 +219,8 @@ window.__ModuleLoader__.load({
|
|
|
199
219
|
placeholder: "35",
|
|
200
220
|
label: { zh: "Android API 级别", en: "Android API level" },
|
|
201
221
|
hint: {
|
|
202
|
-
zh: "
|
|
203
|
-
en: "
|
|
222
|
+
zh: "生成项目、安装 SDK 包与选择系统镜像时使用的 API 级别。",
|
|
223
|
+
en: "API level used when generating projects, installing SDK packages and picking a system image.",
|
|
204
224
|
},
|
|
205
225
|
},
|
|
206
226
|
{
|
|
@@ -241,12 +261,12 @@ window.__ModuleLoader__.load({
|
|
|
241
261
|
hint: { zh: "Gradle 构建的超时与附加参数。", en: "Gradle timeouts and extra arguments." },
|
|
242
262
|
fields: [
|
|
243
263
|
{
|
|
244
|
-
key: "
|
|
264
|
+
key: "buildTimeoutSeconds",
|
|
245
265
|
kind: "number",
|
|
246
|
-
label: { zh: "Gradle
|
|
266
|
+
label: { zh: "Gradle 构建超时(秒)", en: "Gradle build timeout (seconds)" },
|
|
247
267
|
hint: {
|
|
248
|
-
zh: "一次 assemble 任务的最长运行时间,默认
|
|
249
|
-
en: "Longest runtime of one assemble task;
|
|
268
|
+
zh: "一次 assemble 任务的最长运行时间,默认 600 秒。",
|
|
269
|
+
en: "Longest runtime of one assemble task; 600 seconds by default.",
|
|
250
270
|
},
|
|
251
271
|
},
|
|
252
272
|
{
|
|
@@ -374,6 +394,7 @@ window.__ModuleLoader__.load({
|
|
|
374
394
|
function buildDictionaries() {
|
|
375
395
|
const en = {
|
|
376
396
|
nav: "Android emulator",
|
|
397
|
+
title: "Android Emulator",
|
|
377
398
|
intro:
|
|
378
399
|
"Parameters for the android_* tools: which Android SDK and JDK to use, how an emulator starts, and where captures and build logs are written. Changes are saved to this profile and take effect on the next tool call.",
|
|
379
400
|
invalid: "Enter a whole number, or leave blank to use the default.",
|
|
@@ -381,7 +402,11 @@ window.__ModuleLoader__.load({
|
|
|
381
402
|
reset: "Reset to default",
|
|
382
403
|
save: "Save",
|
|
383
404
|
saving: "Saving…",
|
|
384
|
-
|
|
405
|
+
discard: "Discard changes",
|
|
406
|
+
pendingWrite: "Unsaved changes",
|
|
407
|
+
writeBlocked: "One value is invalid; fix it before saving",
|
|
408
|
+
writeFailed: "These values were not accepted, so they are still staged.",
|
|
409
|
+
written: "Saved to this profile",
|
|
385
410
|
readOnly: "This deployment stores settings read-only.",
|
|
386
411
|
unavailable: "This plugin's settings are not served, so it cannot be configured right now.",
|
|
387
412
|
loading: "Loading saved values…",
|
|
@@ -402,6 +427,7 @@ window.__ModuleLoader__.load({
|
|
|
402
427
|
};
|
|
403
428
|
const zh = {
|
|
404
429
|
nav: "Android 模拟器",
|
|
430
|
+
title: "Android 模拟器",
|
|
405
431
|
intro:
|
|
406
432
|
"android_* 工具的运行参数:使用哪个 Android SDK 与 JDK、模拟器如何启动、截图与构建日志写到哪里。保存后写入当前 profile,并在下一次工具调用时生效。",
|
|
407
433
|
invalid: "请填整数;留空表示使用默认值。",
|
|
@@ -409,7 +435,11 @@ window.__ModuleLoader__.load({
|
|
|
409
435
|
reset: "恢复默认",
|
|
410
436
|
save: "保存",
|
|
411
437
|
saving: "保存中…",
|
|
412
|
-
|
|
438
|
+
discard: "放弃改动",
|
|
439
|
+
pendingWrite: "有改动尚未保存",
|
|
440
|
+
writeBlocked: "有取值不合法,修正后才能保存",
|
|
441
|
+
writeFailed: "这批取值未被接受,仍保留在表单里。",
|
|
442
|
+
written: "已保存到当前 profile",
|
|
413
443
|
readOnly: "本部署的设置为只读。",
|
|
414
444
|
unavailable: "该插件的设置当前未被服务,暂时无法配置。",
|
|
415
445
|
loading: "正在读取已保存的值…",
|
|
@@ -517,30 +547,34 @@ window.__ModuleLoader__.load({
|
|
|
517
547
|
const { field, state, disabled, id, listId, suggestions, onChange, t } = input;
|
|
518
548
|
switch (field.kind) {
|
|
519
549
|
case "boolean":
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}),
|
|
529
|
-
h("span", { className: "aemh_hint" }, state.text === "true" ? "on" : "off"),
|
|
530
|
-
);
|
|
550
|
+
// The switch is its own state indicator: a neighbouring "on"/"off"
|
|
551
|
+
// label would only repeat what the track already shows.
|
|
552
|
+
return h(P.Switch, {
|
|
553
|
+
checked: state.text === "true",
|
|
554
|
+
disabled,
|
|
555
|
+
label: t(`field.${field.key}`),
|
|
556
|
+
onChange: (next) => onChange(next ? "true" : "false"),
|
|
557
|
+
});
|
|
531
558
|
case "select":
|
|
559
|
+
// The chevron is drawn by the wrapper: a native select cannot carry a
|
|
560
|
+
// pseudo-element of its own, and the CSS triangle follows the theme
|
|
561
|
+
// where a data-URI glyph could not.
|
|
532
562
|
return h(
|
|
533
|
-
"
|
|
534
|
-
{
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
563
|
+
"span",
|
|
564
|
+
{ className: "aemh_selectWrap" },
|
|
565
|
+
h(
|
|
566
|
+
"select",
|
|
567
|
+
{
|
|
568
|
+
id,
|
|
569
|
+
className: "aemh_control aemh_select",
|
|
570
|
+
value: state.text,
|
|
571
|
+
disabled,
|
|
572
|
+
"aria-invalid": state.invalid ? "true" : undefined,
|
|
573
|
+
onChange: (event) => onChange(event.target.value),
|
|
574
|
+
},
|
|
575
|
+
selectOptions(field, state.text).map((value) =>
|
|
576
|
+
h("option", { key: value === "" ? "__auto__" : value, value }, optionLabel(field, value, t)),
|
|
577
|
+
),
|
|
544
578
|
),
|
|
545
579
|
);
|
|
546
580
|
case "multiline":
|
|
@@ -583,6 +617,93 @@ window.__ModuleLoader__.load({
|
|
|
583
617
|
}
|
|
584
618
|
}
|
|
585
619
|
|
|
620
|
+
/**
|
|
621
|
+
* The action bar's state for one projection of the form.
|
|
622
|
+
*
|
|
623
|
+
* The bar is the only place a save starts, and it exists only while there is
|
|
624
|
+
* something to act on: a staged edit waiting to be written, a write in
|
|
625
|
+
* flight, or a failure worth retrying. A clean form answers `null`, which is
|
|
626
|
+
* what removes the bar from the page — nothing lingers in a hidden state.
|
|
627
|
+
*
|
|
628
|
+
* The shape is flat on purpose: the component below only maps it to
|
|
629
|
+
* elements, so every rule about what may be pressed lives here, in one place.
|
|
630
|
+
*
|
|
631
|
+
* @param {{dirty: boolean, invalid: boolean, saving: boolean, failed: boolean}} shell -
|
|
632
|
+
* the form-level state the shared model publishes.
|
|
633
|
+
* @param {Function} t - the page's dictionary lookup.
|
|
634
|
+
* @returns {{tone: string, message: string, canSave: boolean, canDiscard: boolean}|null}
|
|
635
|
+
* the bar state, or `null` when the bar has nothing to say.
|
|
636
|
+
*/
|
|
637
|
+
function actionBarState(shell, t) {
|
|
638
|
+
if (shell.failed) {
|
|
639
|
+
return { tone: "error", message: t("writeFailed"), canDiscard: true, canSave: shell.dirty && !shell.invalid };
|
|
640
|
+
}
|
|
641
|
+
if (shell.saving) return { tone: "pending", message: t("saving"), canDiscard: false, canSave: false };
|
|
642
|
+
if (!shell.dirty) return null;
|
|
643
|
+
if (shell.invalid) return { tone: "error", message: t("writeBlocked"), canDiscard: true, canSave: false };
|
|
644
|
+
return { tone: "pending", message: t("pendingWrite"), canDiscard: true, canSave: true };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* The action bar: the staged form's status plus the two ways out of it.
|
|
649
|
+
*
|
|
650
|
+
* A plain element at the end of the page column, kept on screen by the
|
|
651
|
+
* stylesheet while the page scrolls. Both buttons are the shared `Button`
|
|
652
|
+
* primitive and the leading mark is the shared `StateDot`, so what it looks
|
|
653
|
+
* like comes from the platform rather than from this page.
|
|
654
|
+
*
|
|
655
|
+
* @param {object} props - the bar state, the form shell and the two actions.
|
|
656
|
+
* @returns {object|null} the bar, or nothing while the form is clean.
|
|
657
|
+
*/
|
|
658
|
+
function ActionBar(props) {
|
|
659
|
+
const { view, shell, t, onDiscard, onSave } = props;
|
|
660
|
+
if (view === null) return null;
|
|
661
|
+
const failed = view.tone === "error";
|
|
662
|
+
return h(
|
|
663
|
+
"div",
|
|
664
|
+
{ className: failed ? "aemh_actions aemh_actionsError" : "aemh_actions" },
|
|
665
|
+
h(P.StateDot, { state: failed ? "error" : "warning", size: 8 }),
|
|
666
|
+
// The pill is as wide as its content, so a long failure message is
|
|
667
|
+
// truncated; the title attribute keeps the whole sentence reachable.
|
|
668
|
+
h(
|
|
669
|
+
"p",
|
|
670
|
+
{ className: "aemh_actionsText", role: "status", "aria-live": "polite", title: view.message },
|
|
671
|
+
view.message,
|
|
672
|
+
),
|
|
673
|
+
h(
|
|
674
|
+
P.Button,
|
|
675
|
+
{ variant: "ghost", size: "sm", disabled: !view.canDiscard || shell.saving, onClick: onDiscard },
|
|
676
|
+
t("discard"),
|
|
677
|
+
),
|
|
678
|
+
h(
|
|
679
|
+
P.Button,
|
|
680
|
+
{ variant: "primary", size: "sm", disabled: !view.canSave || shell.saving, onClick: onSave },
|
|
681
|
+
shell.saving ? t("saving") : t("save"),
|
|
682
|
+
),
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The transient "written" confirmation.
|
|
688
|
+
*
|
|
689
|
+
* The shared `Toast` owns the hold, the fade and the teardown: it reports
|
|
690
|
+
* completion through `onDone`, which clears the sequence and unmounts it.
|
|
691
|
+
* Keying the toast by the sequence restarts the cycle when a later write
|
|
692
|
+
* lands, and a page that has never saved mounts nothing at all.
|
|
693
|
+
*
|
|
694
|
+
* @param {object} props - the landed-write counter and the dictionary lookup.
|
|
695
|
+
* @returns {object|null} the toast, or nothing.
|
|
696
|
+
*/
|
|
697
|
+
function SavedToast(props) {
|
|
698
|
+
const { tick, t } = props;
|
|
699
|
+
const [shown, setShown] = React.useState(0);
|
|
700
|
+
React.useEffect(() => {
|
|
701
|
+
if (tick > 0) setShown(tick);
|
|
702
|
+
}, [tick]);
|
|
703
|
+
if (shown === 0) return null;
|
|
704
|
+
return h(P.Toast, { key: shown, tone: "success", text: t("written"), onDone: () => setShown(0) });
|
|
705
|
+
}
|
|
706
|
+
|
|
586
707
|
/** The full settings page. */
|
|
587
708
|
function SettingsPage(props) {
|
|
588
709
|
const state = props.useAndroidEmulatorSettings((snapshot) => snapshot);
|
|
@@ -603,10 +724,20 @@ window.__ModuleLoader__.load({
|
|
|
603
724
|
buildTools: unique((inventory.data?.buildTools ?? []).map((id) => id.split(";")[1] ?? "").filter(Boolean)).sort(),
|
|
604
725
|
};
|
|
605
726
|
|
|
727
|
+
// Leaving the page abandons staged edits, exactly as the harness's own
|
|
728
|
+
// settings forms do: the controller outlives the page, so without this a
|
|
729
|
+
// half-finished edit would still be staged on the next visit.
|
|
730
|
+
const discard = React.useRef(props.discard);
|
|
731
|
+
discard.current = props.discard;
|
|
732
|
+
React.useEffect(() => () => discard.current(), []);
|
|
733
|
+
|
|
734
|
+
const bar = actionBarState(state, t);
|
|
735
|
+
|
|
606
736
|
if (!state.available) {
|
|
607
737
|
return h(
|
|
608
738
|
"div",
|
|
609
739
|
{ className: "aemh_page" },
|
|
740
|
+
h("h2", { className: "aemh_title" }, t("title")),
|
|
610
741
|
h("p", { className: "aemh_intro", role: "status" }, t(state.status === "loading" ? "loading" : "unavailable")),
|
|
611
742
|
);
|
|
612
743
|
}
|
|
@@ -614,51 +745,39 @@ window.__ModuleLoader__.load({
|
|
|
614
745
|
return h(
|
|
615
746
|
"div",
|
|
616
747
|
{ className: "aemh_page" },
|
|
748
|
+
h("h2", { className: "aemh_title" }, t("title")),
|
|
617
749
|
h("p", { className: "aemh_intro" }, t("intro")),
|
|
618
|
-
h(
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
unavailable: t("unavailable"),
|
|
624
|
-
readOnly: t("readOnly"),
|
|
625
|
-
saveFailed: t("saveFailed"),
|
|
626
|
-
save: t("save"),
|
|
627
|
-
saving: t("saving"),
|
|
628
|
-
},
|
|
629
|
-
onSave: props.save,
|
|
630
|
-
onDiscard: props.discard,
|
|
631
|
-
},
|
|
632
|
-
GROUPS.map((group) =>
|
|
750
|
+
state.writable ? null : h("p", { className: "aemh_readOnly", role: "status" }, t("readOnly")),
|
|
751
|
+
GROUPS.map((group) =>
|
|
752
|
+
h(
|
|
753
|
+
"section",
|
|
754
|
+
{ className: "aemh_group", key: group.id },
|
|
633
755
|
h(
|
|
634
|
-
"
|
|
635
|
-
{ className: "
|
|
636
|
-
h(
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
onReset: () => props.resetField(field.key),
|
|
651
|
-
t,
|
|
652
|
-
}),
|
|
653
|
-
),
|
|
756
|
+
"div",
|
|
757
|
+
{ className: "aemh_groupHead" },
|
|
758
|
+
h("h3", { className: "aemh_groupTitle" }, t(`group.${group.id}`)),
|
|
759
|
+
),
|
|
760
|
+
h("p", { className: "aemh_groupHint" }, t(`group.${group.id}.hint`)),
|
|
761
|
+
group.fields.map((field) =>
|
|
762
|
+
h(Field, {
|
|
763
|
+
key: field.key,
|
|
764
|
+
field,
|
|
765
|
+
state: state.fields[field.key],
|
|
766
|
+
disabled: !state.writable || state.saving,
|
|
767
|
+
suggestions: field.suggest ? suggestions[field.suggest] : undefined,
|
|
768
|
+
onChange: (text) => props.edit(field.key, text),
|
|
769
|
+
onReset: () => props.resetField(field.key),
|
|
770
|
+
t,
|
|
771
|
+
}),
|
|
654
772
|
),
|
|
655
773
|
),
|
|
774
|
+
),
|
|
775
|
+
h(
|
|
776
|
+
"section",
|
|
777
|
+
{ className: "aemh_group" },
|
|
778
|
+
h("div", { className: "aemh_groupHead" }, h("h3", { className: "aemh_groupTitle" }, t("toolsTitle"))),
|
|
779
|
+
h("p", { className: "aemh_groupHint" }, t("toolsHint")),
|
|
656
780
|
h(
|
|
657
|
-
"section",
|
|
658
|
-
{ className: "aemh_tools" },
|
|
659
|
-
h("h3", { className: "aemh_groupTitle" }, t("toolsTitle")),
|
|
660
|
-
h("p", { className: "aemh_groupHint" }, t("toolsHint")),
|
|
661
|
-
h(
|
|
662
781
|
"div",
|
|
663
782
|
{ className: "aemh_toolRow" },
|
|
664
783
|
h(
|
|
@@ -685,7 +804,8 @@ window.__ModuleLoader__.load({
|
|
|
685
804
|
renderDiagnostics(diagnostics, t),
|
|
686
805
|
renderInventory(inventory, t),
|
|
687
806
|
),
|
|
688
|
-
),
|
|
807
|
+
h(ActionBar, { view: bar, shell: state, t, onDiscard: props.discard, onSave: props.save }),
|
|
808
|
+
h(SavedToast, { tick: state.savedCount, t }),
|
|
689
809
|
);
|
|
690
810
|
}
|
|
691
811
|
|
|
@@ -810,6 +930,29 @@ window.__ModuleLoader__.load({
|
|
|
810
930
|
return body;
|
|
811
931
|
}
|
|
812
932
|
|
|
933
|
+
/**
|
|
934
|
+
* Whether a draft would actually change anything.
|
|
935
|
+
*
|
|
936
|
+
* The shared model counts any staged draft as dirty, including one that
|
|
937
|
+
* stages the value already in effect: typing a field's own value back left
|
|
938
|
+
* the form dirty and the control flagged as overridden while a save would
|
|
939
|
+
* write nothing. This classifies the draft by comparing the value it would
|
|
940
|
+
* write with the one in effect, so the page can present a control that has
|
|
941
|
+
* not actually changed as unchanged.
|
|
942
|
+
*
|
|
943
|
+
* @param {object} model - the bound `SettingsFormModel`.
|
|
944
|
+
* @param {object} field - the field descriptor.
|
|
945
|
+
* @param {string} text - the control's current text.
|
|
946
|
+
* @returns {"noop"|"real"|"invalid"} `noop` when a save would change nothing,
|
|
947
|
+
* `invalid` when the draft is not a value the field accepts.
|
|
948
|
+
*/
|
|
949
|
+
function draftDelta(model, field, text) {
|
|
950
|
+
const write = specFor(field).parse(text);
|
|
951
|
+
if (write === undefined) return "invalid";
|
|
952
|
+
if (write.kind === "clear") return model.stored(field.key) ? "real" : "noop";
|
|
953
|
+
return Object.is(write.value, model.sectionValue(field.key)) ? "noop" : "real";
|
|
954
|
+
}
|
|
955
|
+
|
|
813
956
|
/**
|
|
814
957
|
* Own the page's staged form and the face its slot entry injects.
|
|
815
958
|
*
|
|
@@ -824,14 +967,45 @@ window.__ModuleLoader__.load({
|
|
|
824
967
|
scope,
|
|
825
968
|
FIELDS.map((field) => specFor(field)),
|
|
826
969
|
);
|
|
970
|
+
/** Saves that landed; the page flashes its confirmation when this grows. */
|
|
971
|
+
this.savedCount = 0;
|
|
972
|
+
/** Whether a write was in flight at the last projection. */
|
|
973
|
+
this.saveInFlight = false;
|
|
827
974
|
this.store = this.model.bind(() => this.projection());
|
|
828
975
|
}
|
|
829
976
|
|
|
830
977
|
Controller.prototype.projection = function projection() {
|
|
831
978
|
const shell = this.model.shell();
|
|
979
|
+
// The primitive reports its state, not its outcome, so a save that landed
|
|
980
|
+
// is observed as the transition out of "saving" with no failure and no
|
|
981
|
+
// edits left staged. Counting it here keeps the page's confirmation free
|
|
982
|
+
// of timers of its own.
|
|
983
|
+
if (shell.saving) this.saveInFlight = true;
|
|
984
|
+
else if (this.saveInFlight) {
|
|
985
|
+
this.saveInFlight = false;
|
|
986
|
+
if (!shell.failed && !shell.dirty) this.savedCount += 1;
|
|
987
|
+
}
|
|
832
988
|
const fields = {};
|
|
833
|
-
|
|
834
|
-
|
|
989
|
+
let changes = 0;
|
|
990
|
+
for (const field of FIELDS) {
|
|
991
|
+
const state = this.model.field(field.key);
|
|
992
|
+
const delta = draftDelta(this.model, field, state.text);
|
|
993
|
+
if (delta !== "noop") changes += 1;
|
|
994
|
+
fields[field.key] =
|
|
995
|
+
delta === "noop" && state.overridden
|
|
996
|
+
? { ...state, overridden: this.model.stored(field.key) }
|
|
997
|
+
: state;
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
...shell,
|
|
1001
|
+
// The model counts any staged draft as dirty, including one that would
|
|
1002
|
+
// write the value already in effect; the bar and the save button follow
|
|
1003
|
+
// this count instead, so reverting an edit really does clean the form.
|
|
1004
|
+
dirty: shell.dirty && changes > 0,
|
|
1005
|
+
savedCount: this.savedCount,
|
|
1006
|
+
status: this.scope.getSnapshot().status,
|
|
1007
|
+
fields,
|
|
1008
|
+
};
|
|
835
1009
|
};
|
|
836
1010
|
|
|
837
1011
|
Controller.prototype.inject = function inject() {
|
|
@@ -903,17 +1077,35 @@ window.__ModuleLoader__.load({
|
|
|
903
1077
|
|
|
904
1078
|
exports.NS = NS;
|
|
905
1079
|
/**
|
|
906
|
-
*
|
|
1080
|
+
* The field table as this half sees it, complete.
|
|
907
1081
|
*
|
|
908
1082
|
* Exported for the drift check in `scripts/check-bundle.mjs` only: the
|
|
909
|
-
* browser cannot import the host's `lib/settings.js`, so the
|
|
910
|
-
*
|
|
1083
|
+
* browser cannot import the host's `lib/settings.js`, so the labels,
|
|
1084
|
+
* hints and options are written twice and must be kept identical by
|
|
1085
|
+
* construction. Exporting the *whole* descriptor rather than a projection
|
|
1086
|
+
* is what lets the gate catch a reworded label, not just a renamed field.
|
|
911
1087
|
* Nothing at runtime reads this.
|
|
912
1088
|
*/
|
|
913
1089
|
exports.FIELD_META = FIELDS.map((field) => ({
|
|
914
1090
|
key: field.key,
|
|
915
1091
|
kind: field.kind,
|
|
916
1092
|
options: field.options,
|
|
1093
|
+
label: field.label,
|
|
1094
|
+
hint: field.hint,
|
|
1095
|
+
placeholder: field.placeholder,
|
|
1096
|
+
optionLabel: field.optionLabel,
|
|
1097
|
+
}));
|
|
1098
|
+
/**
|
|
1099
|
+
* The group table as this half sees it: the headings and hints the page
|
|
1100
|
+
* renders around each card, plus the field keys each card holds.
|
|
1101
|
+
*
|
|
1102
|
+
* Also for the drift check. Nothing at runtime reads this.
|
|
1103
|
+
*/
|
|
1104
|
+
exports.GROUP_META = GROUPS.map((group) => ({
|
|
1105
|
+
id: group.id,
|
|
1106
|
+
title: group.title,
|
|
1107
|
+
hint: group.hint,
|
|
1108
|
+
fields: group.fields.map((field) => field.key),
|
|
917
1109
|
}));
|
|
918
1110
|
exports.apply = apply;
|
|
919
1111
|
exports.inject = inject;
|
package/lib/index.js
CHANGED
|
@@ -105,6 +105,16 @@ function registerSkill(ctx, logger) {
|
|
|
105
105
|
description: loaded.description,
|
|
106
106
|
...(loaded.whenToUse === undefined ? {} : { whenToUse: loaded.whenToUse }),
|
|
107
107
|
content: loaded.content,
|
|
108
|
+
// `source` and `path` are not optional in practice. The registry builds a
|
|
109
|
+
// discovery CANDIDATE out of the registered skill and validates it with
|
|
110
|
+
// `typeof candidate.source !== "string" -> throw`, so a registration that
|
|
111
|
+
// omits `source` fails when the skill is looked up, not when it is
|
|
112
|
+
// registered: `apply` succeeds and `skills.get('android-dev')` then throws
|
|
113
|
+
// "loaded skill android-dev source must be a string". `path` is what a
|
|
114
|
+
// caller shows and resolves the body from.
|
|
115
|
+
source: 'runtime',
|
|
116
|
+
path: path.join(SKILL_DIR, 'SKILL.md'),
|
|
117
|
+
provider: 'runtime',
|
|
108
118
|
resourceBase: { kind: 'directory', path: SKILL_DIR },
|
|
109
119
|
})
|
|
110
120
|
}, 'android-emulator-helper: android-dev skill')
|
|
@@ -247,8 +257,11 @@ function registerRoutes(ctx, { config, logger }) {
|
|
|
247
257
|
methods: ['GET'],
|
|
248
258
|
requestBody: 'buffered',
|
|
249
259
|
fetch: async () => {
|
|
250
|
-
|
|
260
|
+
// `createRuntime` reads the live settings, so it belongs inside the
|
|
261
|
+
// guard: a reference that cannot be read must still answer with a
|
|
262
|
+
// JSON body instead of rejecting the request.
|
|
251
263
|
try {
|
|
264
|
+
const rt = createRuntime({ config, exec: undefined, logger })
|
|
252
265
|
return jsonResponse(200, await preflight(rt))
|
|
253
266
|
} catch (error) {
|
|
254
267
|
return jsonResponse(500, { error: message(error) })
|
|
@@ -264,8 +277,8 @@ function registerRoutes(ctx, { config, logger }) {
|
|
|
264
277
|
methods: ['GET'],
|
|
265
278
|
requestBody: 'buffered',
|
|
266
279
|
fetch: async () => {
|
|
267
|
-
const rt = createRuntime({ config, exec: undefined, logger })
|
|
268
280
|
try {
|
|
281
|
+
const rt = createRuntime({ config, exec: undefined, logger })
|
|
269
282
|
return jsonResponse(200, await inventory(rt))
|
|
270
283
|
} catch (error) {
|
|
271
284
|
return jsonResponse(500, { error: message(error) })
|