dsh-chef 0.2.0 → 0.3.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/dist/client.js CHANGED
@@ -4,84 +4,561 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
- //#region src/slot.ts
8
- const SLOT_ID = "ilife:chef";
9
- const SLOT_TITLE = "大厨";
10
- /** 写死原生组件描述(纯数据,无按需加载,无外嵌页)。 */
11
- const TAB_COMPONENT = {
12
- kind: "native",
13
- name: "ChefPanel"
7
+ //#region \0rolldown/runtime.js
8
+ var __create = Object.create;
9
+ var __defProp = Object.defineProperty;
10
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __getProtoOf = Object.getPrototypeOf;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __copyProps = (to, from, except, desc) => {
15
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
16
+ key = keys[i];
17
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
18
+ get: ((k) => from[k]).bind(null, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ return to;
14
23
  };
15
- /** 自注册:幂等(调用方持 disposer),失败返回 false 由调用方决定重试。 */
16
- function registerSingle(port) {
17
- if (!port.hasTabs()) return null;
18
- return port.registerTab({
19
- id: SLOT_ID,
20
- title: SLOT_TITLE,
21
- order: 90,
22
- single: true,
23
- component: TAB_COMPONENT
24
- });
24
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
25
+ value: mod,
26
+ enumerable: true
27
+ }) : target, mod));
28
+ //#endregion
29
+ let react = require("react");
30
+ react = __toESM(react, 1);
31
+ //#region src/slot.ts
32
+ const SLOT_TITLE = "私家大厨";
33
+ const PLUGIN = "dsh-chef";
34
+ //#endregion
35
+ //#region src/settings.ts
36
+ /** 常用项:改了就影响「库与产物落在哪」,放页面上。 */
37
+ const COMMON = [
38
+ {
39
+ key: "db.dir",
40
+ title: "数据目录",
41
+ tier: "common",
42
+ control: "directory",
43
+ hint: "库文件与产物的根目录。留空=按默认落点(配置目录下的 data/),也就是改造前那个位置。"
44
+ },
45
+ {
46
+ key: "db.name",
47
+ title: "库文件名",
48
+ tier: "common",
49
+ control: "text",
50
+ hint: "数据目录下的数据库文件名。改它等于换一个菜谱库,老菜谱留在旧文件里不会跟过来。"
51
+ },
52
+ {
53
+ key: "html.dir",
54
+ title: "HTML 产物目录名",
55
+ tier: "common",
56
+ control: "text",
57
+ hint: "数据目录下放交付页面的子目录名,可带一层:默认两段 cook_html/help。改它之后新页面落新地方。"
58
+ }
59
+ ];
60
+ /** 高级项:默认收起。改错了多半只是自己撞上麻烦,不确定就别动。 */
61
+ const ADVANCED = [{
62
+ key: "files.help",
63
+ title: "产物文件名主体 · HELP",
64
+ tier: "advanced",
65
+ control: "text",
66
+ hint: "HELP 文件的文件名前缀,后面自动跟时间戳。改它之后旧的 HELP 产物不再被复用。"
67
+ }, {
68
+ key: "files.lookup",
69
+ title: "产物文件名主体 · 速查表",
70
+ tier: "advanced",
71
+ control: "text",
72
+ hint: "速查表文件的文件名前缀。改它之后新产物落新名字,旧的留在原处。"
73
+ }];
74
+ /** 设置页的行表,顺序即页面顺序。 */
75
+ const CONFIG_ITEMS = [...COMMON, ...ADVANCED];
76
+ /** 常用项行数(页面上直接画出来的那些)。 */
77
+ const COMMON_ITEM_COUNT = COMMON.length;
78
+ /** 高级组标题与副文案。 */
79
+ const ADVANCED_GROUP_TITLE = "高级";
80
+ const ADVANCED_GROUP_NOTE = "不常改。留空=按默认落点(改造前那个位置),不确定就别动。";
81
+ /** 按 `a.b` 路径从配置取值里读(缺层或类型不符一律回 undefined)。 */
82
+ function readPath(values, key) {
83
+ const parts = key.split(".");
84
+ let cur = values;
85
+ for (const part of parts) {
86
+ if (typeof cur !== "object" || cur === null || Array.isArray(cur)) return void 0;
87
+ cur = cur[part];
88
+ }
89
+ return cur;
25
90
  }
26
- /** 内容型打开:path seed,折叠面板自动展开。 */
27
- function openSingle(port, sessionId) {
28
- if (!port.hasTabs()) return;
29
- port.openTab({
30
- type: SLOT_ID,
31
- path: SLOT_ID
32
- }, sessionId ? { sessionId } : void 0);
91
+ /** 按 `a.b` 路径把值写进配置取值(中间层缺就建一层空组;已存在非组即覆盖成组)。 */
92
+ function writePath(values, key, value) {
93
+ const parts = key.split(".");
94
+ let cur = values;
95
+ for (let i = 0; i < parts.length - 1; i++) {
96
+ const part = parts[i];
97
+ const next = cur[part];
98
+ if (typeof next !== "object" || next === null || Array.isArray(next)) cur[part] = {};
99
+ cur = cur[part];
100
+ }
101
+ cur[parts[parts.length - 1]] = value;
33
102
  }
34
103
  //#endregion
35
- //#region src/bridge.ts
36
- /** dsh-chef 取数桥(P10 脚手架:纯 CLI 单轨)。
104
+ //#region src/contract.ts
105
+ /** dsh-chef 端口(port):DSH 世界与私家大厨六边形之间的契约。
106
+ *
107
+ * browser 安全:零 node 导入。host 适配器(注册 RPC 通道)与 client 适配器(调用 RPC)
108
+ * 共用同一份定义。通道命名遵守宿主约束:单段且不等于 /api(见 cookbook「RPC 通道」)。
37
109
  *
38
- * 面板与跨技能只经 host.call 触发本桥,本桥只经 spawn 调技能包唯一出口
39
- * packages/skill-chef/dist/cli/cmd_read.js(argv+JSON+exit,stdout 纯 envelope JSON 一行)。
40
- * 本文件不 import 任何技能实现(只读消费其 dist/CLI);缺失阻断不返空:
41
- * CLI 缺席/非 0/非 JSON/回执 key 不符一律抛错,绝不返回空数组冒充正常。
42
- * 技能包不动(只读);combos.yaml 不动。
110
+ * 照 #56 卡路里/#677 记账样板同形:大厨插件原先**没有任何 RPC 通道**,本件是它第一份端口定义
111
+ * (#696:设置页要读改配置,走的就是这条通道)。
43
112
  */
44
- const HOST_CALL_METHOD = "ilife.chef.read";
113
+ const RPC_CHANNEL = "/ilife-chef";
114
+ /** #696 配置端点:读设置页整面、保存一份取值、重置为默认。同走一条通道,靠端点名分发。 */
115
+ const RPC_ENDPOINT_CONFIG_GET = "config.get";
116
+ const RPC_ENDPOINT_CONFIG_SAVE = "config.save";
117
+ const RPC_ENDPOINT_CONFIG_RESET = "config.reset";
118
+ /** 信封守卫:供 client 拆包前校验(port 拥有信封定义,守卫住 port)。 */
119
+ function isRpcResult(raw) {
120
+ if (typeof raw !== "object" || raw === null) return false;
121
+ const okFlag = raw.ok;
122
+ return okFlag === true || okFlag === false;
123
+ }
124
+ //#endregion
125
+ //#region src/dsh-ctx.ts
126
+ /** 宿主目录选择命名空间的服务名(#736;出处与禁令见 cookbook §13)。 */
127
+ const REMOTE_DIRECTORY_PICKER = "remote.directoryPicker";
45
128
  //#endregion
46
129
  //#region src/client.ts
47
- /** dsh-chef 客户端存根(P10 脚手架)。
130
+ /** dsh-chef client 适配器:技能设置页(#696 起是真能配的页,只配置、不干活)。
48
131
  *
49
- * 写死槽位原生组件,不做动态按需加载,不做外嵌页。
50
- * 面板取数只经 host.call 进 host 桥(见 bridge.ts),不 import 技能实现。
51
- * 缺席纯条件渲染,无轮询(本文件无任何定时器)。
132
+ * 三态:loading(一次 RPC 内)/ready(真表单)/failed(人话报错 + 指引,不返空、不转圈)。
133
+ * 页面只经宿主通道 `/api/ilife-chef` 的三个配置端点读改配置;**没有任何干活入口**——
134
+ * 搜菜、记做菜在技能功能页(sidebar 槽),不在本文件。
135
+ *
136
+ * 产物经 tsdown 打成 loader 工厂包(browser/CJS,见 tsdown.config.ts)。
137
+ * 本文件传递闭包:禁 node、禁 DOM 直写(document/window/process),
138
+ * 不 import 任何 host 实现(含 bridge,见 #130 纪律),只用自家 contract/settings/slot 与 dsh-ctx 的 type。
139
+ * 组件 React.createElement 手写,不引入 JSX。
140
+ *
141
+ * #736:目录行(`control:'directory'`)多一枚「选择文件夹…」入口,经 `ctx.get` 可选查找拿宿主
142
+ * 命名空间(**软依赖**,不写进 inject);拿不到就不画按钮,文本框照旧。
52
143
  */
53
- const CLIENT_COMPONENT = TAB_COMPONENT;
54
- const CLIENT_METHOD = HOST_CALL_METHOD;
55
- /** 客户端插件声明的 ctx 服务短名(loader 契约之一)。占位实现不读任何服务,故为空数组;
56
- * `package.json` 的 `dsh.client.inject` 与本数组同为空;真面板接线见 #57。 */
57
- const inject = [];
58
- /** 客户端插件入口(loader 契约之二):DSH 客户端 runner 只把「有 `apply` 函数」的对象当插件
59
- * (`dsh-cordis-client-runner` 里 `typeof value === 'object' && typeof value.apply === 'function'`)。
144
+ /** client 短名声明:只有这两个(#736 的目录选择走**可选查找**,不写进来——
145
+ * 写进来=硬依赖,提供方缺席时整包被停靠,设置页会跟着装不上;见 cookbook §13)。 */
146
+ const inject = ["slots", "connection"];
147
+ /** 面板视觉(内联 style;颜色走 DSH 主题别名,深浅主题自适应,写死值只做回退)。 */
148
+ const S = {
149
+ card: {
150
+ padding: "12px 14px",
151
+ borderRadius: 10,
152
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.35))",
153
+ background: "var(--dsw-alias-bg-base, transparent)",
154
+ color: "var(--dsw-alias-label-primary, inherit)",
155
+ lineHeight: 1.6
156
+ },
157
+ title: {
158
+ fontSize: "1.08em",
159
+ fontWeight: 700,
160
+ marginBottom: 8
161
+ },
162
+ muted: {
163
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
164
+ fontSize: "0.92em"
165
+ },
166
+ info: {
167
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
168
+ fontSize: "0.92em",
169
+ overflowWrap: "anywhere"
170
+ },
171
+ rows: {
172
+ marginTop: 8,
173
+ borderTop: "1px solid var(--dsw-alias-border, rgba(128,128,128,.25))",
174
+ paddingTop: 8
175
+ },
176
+ row: { marginBottom: 10 },
177
+ label: { fontWeight: 600 },
178
+ hint: {
179
+ color: "var(--dsw-alias-label-secondary, #9a9a9a)",
180
+ fontSize: "0.92em",
181
+ marginBottom: 4
182
+ },
183
+ input: {
184
+ width: "100%",
185
+ padding: "4px 8px",
186
+ borderRadius: 6,
187
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
188
+ background: "var(--dsw-alias-bg-base, #fff)",
189
+ color: "inherit"
190
+ },
191
+ pickRow: {
192
+ display: "flex",
193
+ gap: 6,
194
+ alignItems: "center"
195
+ },
196
+ btnPick: {
197
+ flex: "0 0 auto",
198
+ padding: "4px 10px",
199
+ borderRadius: 6,
200
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
201
+ background: "transparent",
202
+ color: "inherit",
203
+ cursor: "pointer",
204
+ whiteSpace: "nowrap"
205
+ },
206
+ advanced: { marginTop: 12 },
207
+ summary: {
208
+ cursor: "pointer",
209
+ fontWeight: 600
210
+ },
211
+ bar: {
212
+ display: "flex",
213
+ gap: 8,
214
+ marginTop: 12,
215
+ flexWrap: "wrap"
216
+ },
217
+ btn: {
218
+ padding: "4px 12px",
219
+ borderRadius: 6,
220
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
221
+ background: "transparent",
222
+ color: "inherit",
223
+ cursor: "pointer"
224
+ },
225
+ btnPrimary: {
226
+ padding: "4px 12px",
227
+ borderRadius: 6,
228
+ border: "1px solid var(--dsw-alias-border, rgba(128,128,128,.45))",
229
+ background: "var(--dsw-alias-brand-primary, #2f6fed)",
230
+ color: "#fff",
231
+ cursor: "pointer"
232
+ },
233
+ okText: {
234
+ marginTop: 8,
235
+ color: "var(--dsw-alias-label-success, #12805c)",
236
+ fontSize: "0.96em"
237
+ },
238
+ error: {
239
+ marginTop: 8,
240
+ color: "var(--dsw-alias-label-error, #b3261e)",
241
+ fontSize: "1em",
242
+ whiteSpace: "pre-wrap"
243
+ }
244
+ };
245
+ /** 请求超时毫秒:与 host 侧 SPAWN_TIMEOUT_MS 同级,UI 永不无限转圈。 */
246
+ const READ_TIMEOUT_MS = 2e4;
247
+ /** 配置面报错 → 人话指引。
60
248
  *
61
- * **这是占位**:真面板接线(槽位注册/软依赖有界重试/AbortSignal 超时/三态渲染)属 #57 那条线。
62
- * 做成空实现只为让客户端产物在 loader 里是**合法插件**、不阻碍 web profile 启动——
63
- * 不是把面板「做完」。 */
64
- function apply() {}
65
- function mountSingleClient(port) {
66
- return registerSingle(port);
67
- }
68
- function openSingleClient(port, sessionId) {
69
- openSingle(port, sessionId);
70
- }
71
- function requestReadViaHost(host, key, params = {}) {
72
- return host.call(HOST_CALL_METHOD, {
73
- key,
74
- params
249
+ * 报文由技能侧给出:base-link-core 的 `ConfigError` 已带行号与文件名,技能 `cli/config.ts` 原样交出来,
250
+ * 插件 bridge 原样带上(`fetch-failed:出口非 0(1):<那一句>`)。所以这里**不重写报文**,
251
+ * 只按报错类补一句「接下来怎么办」——免得两处各写一套人话。
252
+ */
253
+ function humanizeConfigFailure(code, message) {
254
+ const text = message.trim().length > 0 ? message.trim() : `(${code},宿主未给报文)`;
255
+ if (/解析|YAML|parse/i.test(text)) return `${text}\n改回「键: 值」的写法,或点「重置为默认」重来一份(重置会先留一份 .bak)。`;
256
+ if (/不认识|未知键|UNKNOWN_KEY/i.test(text)) return `${text}\n本页只认表里这些项;多余的行删掉,或点「重置为默认」。`;
257
+ if (/类型|TYPE_MISMATCH/i.test(text)) return `${text}\n按本页给的形状填(文本填文本、数字填数字),或点「重置为默认」。`;
258
+ if (/缺席|missing-cli/i.test(text)) return `${text}\n技能出口没找着:先确认技能包已构建(packages/skill-chef/dist/cli/cmd_read.js)。`;
259
+ return `${text}\n改不动就点「重置为默认」(会先留一份 .bak),或照报文里的路径手工改配置文件。`;
260
+ }
261
+ /** 一次配置 RPC(同通道过三个端点;永不抛,失败落字)。 */
262
+ async function configRpc(call, method, payload) {
263
+ if (typeof call !== "function") return {
264
+ ok: false,
265
+ message: "宿主连接缺席:connection.rpc.call 不可用"
266
+ };
267
+ try {
268
+ const raw = await call("/api", RPC_CHANNEL.slice(1), {
269
+ method,
270
+ payload
271
+ }, AbortSignal.timeout(READ_TIMEOUT_MS));
272
+ if (!isRpcResult(raw)) return {
273
+ ok: false,
274
+ message: "回执信封异常(非 ok 信封)"
275
+ };
276
+ if (!raw.ok) return {
277
+ ok: false,
278
+ message: humanizeConfigFailure(raw.error?.code ?? "unknown", raw.error?.message ?? "")
279
+ };
280
+ return {
281
+ ok: true,
282
+ surface: raw.value
283
+ };
284
+ } catch (e) {
285
+ if (e instanceof Error && (e.name === "AbortError" || e.name === "TimeoutError")) return {
286
+ ok: false,
287
+ message: `配置请求超时(${Math.round(READ_TIMEOUT_MS / 1e3)}s):宿主未回`
288
+ };
289
+ return {
290
+ ok: false,
291
+ message: `配置失败:${e instanceof Error ? e.message : String(e)}`
292
+ };
293
+ }
294
+ }
295
+ /** 读设置页整面。 */
296
+ function fetchConfigSurface(call) {
297
+ return configRpc(call, RPC_ENDPOINT_CONFIG_GET, {});
298
+ }
299
+ /** 保存一份取值。 */
300
+ function saveConfigSurface(call, values) {
301
+ return configRpc(call, RPC_ENDPOINT_CONFIG_SAVE, { values });
302
+ }
303
+ /** 重置为默认(技能侧先落 .bak)。 */
304
+ function resetConfigSurface(call) {
305
+ return configRpc(call, RPC_ENDPOINT_CONFIG_RESET, {});
306
+ }
307
+ /** 把配置取值铺成「行键 → 输入框文本」(缺项即空串,不返空留白)。 */
308
+ function toDraft(values) {
309
+ const draft = {};
310
+ for (const item of CONFIG_ITEMS) {
311
+ const v = readPath(values, item.key);
312
+ draft[item.key] = v === void 0 || v === null ? "" : String(v);
313
+ }
314
+ return draft;
315
+ }
316
+ /** 表单态 → 配置取值(按控件种类还原类型;空串对文本项照收,语义由「未配」承担)。 */
317
+ function fromDraft(draft) {
318
+ const values = {};
319
+ for (const item of CONFIG_ITEMS) {
320
+ const raw = draft[item.key] ?? "";
321
+ const value = item.control === "number" ? Number(raw) : item.control === "switch" ? raw === "true" : raw;
322
+ writePath(values, item.key, item.control === "number" && !Number.isFinite(value) ? 0 : value);
323
+ }
324
+ return values;
325
+ }
326
+ /** 形状守卫:拿到的那个东西像不像目录选择命名空间(本包只认 `pick` 是函数)。 */
327
+ function isDirectoryPicker(raw) {
328
+ if (typeof raw !== "object" || raw === null) return false;
329
+ return typeof raw.pick === "function";
330
+ }
331
+ /** 现取目录选择命名空间:**软依赖**,拿不到回 null(页面据此不出入口)。
332
+ *
333
+ * 为什么不用 `inject` 声明:见 cookbook §13——写进声明=硬依赖,提供方缺席时整包被停靠,
334
+ * 设置页会跟着装不上。这里按平台给的「可选查找」办:缺席只是没有入口。
335
+ * 守卫拒绝或提供方中途卸载会抛,一律当「没有」,绝不把设置页带下来。 */
336
+ function resolveDirectoryPicker(getService) {
337
+ if (typeof getService !== "function") return null;
338
+ try {
339
+ const raw = getService(REMOTE_DIRECTORY_PICKER);
340
+ return isDirectoryPicker(raw) ? raw : null;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+ /** 唤起一次系统文件夹选择器并归一结果(**永不抛**)。 */
346
+ async function pickDirectory(picker) {
347
+ try {
348
+ const picked = await picker.pick();
349
+ if (typeof picked === "string" && picked.trim() !== "") return {
350
+ kind: "picked",
351
+ path: picked
352
+ };
353
+ return { kind: "cancelled" };
354
+ } catch (e) {
355
+ return {
356
+ kind: "unavailable",
357
+ message: "这台部署用不了系统文件夹对话框(" + (e instanceof Error ? e.message : String(e)) + "):请直接在框里填绝对路径。"
358
+ };
359
+ }
360
+ }
361
+ /** 「选择文件夹」按钮的真装配函数(目录行渲染出来的按钮,onClick 就是它)。
362
+ *
363
+ * 回 Promise 是为了本地可判(用例直接 await);接进 React 时调用方 `void` 掉。 */
364
+ function createBrowseHandler(deps) {
365
+ return async (key) => {
366
+ const outcome = await pickDirectory(deps.picker);
367
+ if (outcome.kind === "picked") deps.onChange(key, outcome.path);
368
+ else if (outcome.kind === "unavailable") deps.onUnavailable(outcome.message);
369
+ };
370
+ }
371
+ /** 一行输入(四种控件对齐受限 YAML 子集:文本/数字/布尔/目录)。
372
+ *
373
+ * 目录档=文本框 + 一枚唤起系统文件夹选择器的按钮;`onBrowse` 缺席(命名空间拿不到、
374
+ * 或这条路已被拒)时不画按钮,文本框照旧——那是该缝自己的契约(供不了就收起入口,不是失败)。 */
375
+ function Row(props) {
376
+ const { item } = props;
377
+ const onChange = (e) => props.onChange(item.key, e.target.value);
378
+ const control = item.control === "switch" ? react.createElement("input", {
379
+ type: "checkbox",
380
+ checked: props.value === "true",
381
+ disabled: props.disabled,
382
+ onChange: (e) => props.onChange(item.key, e.target.checked ? "true" : "false")
383
+ }) : react.createElement("input", {
384
+ style: S.input,
385
+ type: item.control === "number" ? "number" : "text",
386
+ value: props.value,
387
+ disabled: props.disabled,
388
+ spellCheck: false,
389
+ onChange
75
390
  });
391
+ const browse = item.control === "directory" && props.onBrowse !== void 0 ? react.createElement("button", {
392
+ style: S.btnPick,
393
+ type: "button",
394
+ disabled: props.disabled,
395
+ onClick: () => props.onBrowse?.(item.key)
396
+ }, "选择文件夹…") : null;
397
+ return react.createElement("div", { style: S.row }, react.createElement("div", { style: S.label }, item.title), react.createElement("div", { style: S.hint }, item.hint), browse === null ? control : react.createElement("div", { style: S.pickRow }, control, browse));
398
+ }
399
+ /** 技能设置页:承载私家大厨自己的全部可配置项(只配置,不干活)。 */
400
+ function ChefConfig(props) {
401
+ const [state, setState] = react.useState({ kind: "loading" });
402
+ const [pickerGone, setPickerGone] = react.useState(false);
403
+ const [draft, setDraft] = react.useState({});
404
+ const [busy, setBusy] = react.useState(false);
405
+ const [notice, setNotice] = react.useState(null);
406
+ const [error, setError] = react.useState(null);
407
+ const apply = react.useCallback((surface) => {
408
+ setState({
409
+ kind: "ready",
410
+ surface
411
+ });
412
+ setDraft(toDraft(surface.values));
413
+ }, []);
414
+ const load = react.useCallback(async () => {
415
+ setNotice(null);
416
+ setError(null);
417
+ const r = await fetchConfigSurface(props.getCall());
418
+ if (r.ok) apply(r.surface);
419
+ else setState({
420
+ kind: "failed",
421
+ message: r.message
422
+ });
423
+ }, [apply, props.getCall]);
424
+ react.useEffect(() => {
425
+ let alive = true;
426
+ (async () => {
427
+ const r = await fetchConfigSurface(props.getCall());
428
+ if (!alive) return;
429
+ if (r.ok) apply(r.surface);
430
+ else setState({
431
+ kind: "failed",
432
+ message: r.message
433
+ });
434
+ })();
435
+ return () => {
436
+ alive = false;
437
+ };
438
+ }, [apply, props.getCall]);
439
+ const onChange = react.useCallback((key, next) => {
440
+ setDraft((prev) => ({
441
+ ...prev,
442
+ [key]: next
443
+ }));
444
+ setNotice(null);
445
+ setError(null);
446
+ }, []);
447
+ /** 目录选择:拿不到命名空间就没有入口;被拒一次即收起入口(不留死按钮)。 */
448
+ const picker = pickerGone ? null : props.getPicker();
449
+ const onBrowse = react.useMemo(() => picker === null ? void 0 : createBrowseHandler({
450
+ picker,
451
+ onChange,
452
+ onUnavailable: (message) => {
453
+ setError(message);
454
+ setPickerGone(true);
455
+ }
456
+ }), [picker, onChange]);
457
+ const surface = state.kind === "ready" ? state.surface : null;
458
+ const dirty = surface !== null && CONFIG_ITEMS.some((i) => (draft[i.key] ?? "") !== (toDraft(surface.values)[i.key] ?? ""));
459
+ /** 写完之后重新读一份:写回执只有 {path, values},头部那两行要的是读整面。 */
460
+ const writeThenReload = react.useCallback(async (done) => {
461
+ setBusy(true);
462
+ setNotice(null);
463
+ setError(null);
464
+ const r = await fetchConfigSurface(props.getCall());
465
+ if (r.ok) {
466
+ apply(r.surface);
467
+ setNotice(done);
468
+ } else setState({
469
+ kind: "failed",
470
+ message: r.message
471
+ });
472
+ setBusy(false);
473
+ }, [apply, props.getCall]);
474
+ const onSave = react.useCallback(async () => {
475
+ setBusy(true);
476
+ setNotice(null);
477
+ setError(null);
478
+ const r = await saveConfigSurface(props.getCall(), fromDraft(draft));
479
+ setBusy(false);
480
+ if (r.ok) await writeThenReload("已保存,立即生效(不用重启宿主)");
481
+ else setError(r.message);
482
+ }, [
483
+ draft,
484
+ props.getCall,
485
+ writeThenReload
486
+ ]);
487
+ const onReset = react.useCallback(async () => {
488
+ setBusy(true);
489
+ setNotice(null);
490
+ setError(null);
491
+ const r = await resetConfigSurface(props.getCall());
492
+ setBusy(false);
493
+ if (r.ok) await writeThenReload("已重置为默认(原配置已另存一份 .bak)");
494
+ else setError(r.message);
495
+ }, [props.getCall, writeThenReload]);
496
+ const head = react.createElement("div", null, react.createElement("div", { style: S.title }, `${SLOT_TITLE} · 配置`), surface !== null ? react.createElement("div", null, react.createElement("div", { style: S.info }, `配置文件 ${surface.path}`), react.createElement("div", { style: S.info }, `数据目录 ${surface.dataDir}`), surface.created ? react.createElement("div", { style: S.muted }, "(这份配置是刚按默认值建出来的)") : null) : null);
497
+ if (state.kind === "loading") return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.muted }, "配置读取中…"));
498
+ if (state.kind === "failed") return react.createElement("div", { style: S.card }, react.createElement("div", { style: S.title }, `${SLOT_TITLE} · 配置`), react.createElement("div", { style: S.error }, state.message), react.createElement("div", { style: S.bar }, react.createElement("button", {
499
+ style: S.btn,
500
+ type: "button",
501
+ onClick: () => void load()
502
+ }, "重试"), react.createElement("button", {
503
+ style: S.btn,
504
+ type: "button",
505
+ onClick: () => void onReset()
506
+ }, "重置为默认")));
507
+ const common = CONFIG_ITEMS.slice(0, COMMON_ITEM_COUNT);
508
+ const advanced = CONFIG_ITEMS.slice(COMMON_ITEM_COUNT);
509
+ const renderRow = (item) => react.createElement(Row, {
510
+ key: item.key,
511
+ item,
512
+ value: draft[item.key] ?? "",
513
+ disabled: busy,
514
+ onChange,
515
+ onBrowse
516
+ });
517
+ return react.createElement("div", { style: S.card }, head, react.createElement("div", { style: S.rows }, common.map(renderRow)), react.createElement("details", { style: S.advanced }, react.createElement("summary", { style: S.summary }, ADVANCED_GROUP_TITLE), react.createElement("div", { style: S.muted }, ADVANCED_GROUP_NOTE), advanced.map(renderRow)), react.createElement("div", { style: S.bar }, react.createElement("button", {
518
+ style: dirty ? S.btnPrimary : S.btn,
519
+ type: "button",
520
+ disabled: busy || !dirty,
521
+ onClick: () => void onSave()
522
+ }, busy ? "处理中…" : "保存"), react.createElement("button", {
523
+ style: S.btn,
524
+ type: "button",
525
+ disabled: busy,
526
+ onClick: () => void onReset()
527
+ }, "重置为默认"), react.createElement("button", {
528
+ style: S.btn,
529
+ type: "button",
530
+ disabled: busy,
531
+ onClick: () => void load()
532
+ }, "重新读取")), notice !== null ? react.createElement("div", { style: S.okText }, notice) : null, error !== null ? react.createElement("div", { style: S.error }, error) : null, react.createElement("div", { style: S.muted }, `${PLUGIN} 只配置,不干活:搜菜与做菜在技能功能页。`));
533
+ }
534
+ function apply(ctx) {
535
+ const getCall = () => ctx.connection?.rpc?.call ?? null;
536
+ const getPicker = () => typeof ctx.get === "function" ? resolveDirectoryPicker((name) => ctx.get?.(name)) : null;
537
+ ctx.slots.inject("ilife.config-tab", () => ctx.slots.register({
538
+ name: "ilife.config-tab",
539
+ id: PLUGIN,
540
+ label: () => SLOT_TITLE,
541
+ channel: RPC_CHANNEL
542
+ }, () => react.createElement(ChefConfig, {
543
+ getCall,
544
+ getPicker
545
+ })));
76
546
  }
77
547
  //#endregion
78
- exports.CLIENT_COMPONENT = CLIENT_COMPONENT;
79
- exports.CLIENT_METHOD = CLIENT_METHOD;
548
+ exports.READ_TIMEOUT_MS = READ_TIMEOUT_MS;
549
+ exports.Row = Row;
80
550
  exports.apply = apply;
551
+ exports.createBrowseHandler = createBrowseHandler;
552
+ exports.fetchConfigSurface = fetchConfigSurface;
553
+ exports.fromDraft = fromDraft;
554
+ exports.humanizeConfigFailure = humanizeConfigFailure;
81
555
  exports.inject = inject;
82
- exports.mountSingleClient = mountSingleClient;
83
- exports.openSingleClient = openSingleClient;
84
- exports.requestReadViaHost = requestReadViaHost;
556
+ exports.isDirectoryPicker = isDirectoryPicker;
557
+ exports.pickDirectory = pickDirectory;
558
+ exports.resetConfigSurface = resetConfigSurface;
559
+ exports.resolveDirectoryPicker = resolveDirectoryPicker;
560
+ exports.saveConfigSurface = saveConfigSurface;
561
+ exports.toDraft = toDraft;
85
562
  return module.exports;
86
563
  }
87
564
  });