@fishdivinity/dsh-local-search 0.2.1 → 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/lib/client.js CHANGED
@@ -1,32 +1,34 @@
1
1
  /**
2
- * dsh-local-search browser half — the plugin's configuration page.
2
+ * dsh-local-search 的浏览器半边——插件的配置页。
3
3
  *
4
- * It registers into `plugins.item`, the list the Plugins page shows as the
5
- * **Official plugins** group, the same way `dsh-github` contributes its GitHub
6
- * card: one card, click it, the plugin's own page opens.
4
+ * 它注册进 `plugins.item`,也就是 Plugins 页显示的 **Official plugins** 组,和 `dsh-github`
5
+ * 贡献 GitHub 卡片的方式一样:一张卡,点开是本插件自己的页面。
7
6
  *
8
- * The card's `id` is what the page resolves the form with
9
- * (`formFor(openItem.id)`), and that lookup only answers for an id the Host
10
- * serves as a settings namespace — which is the row id this bundle's patch
11
- * declares. So `id` must stay `fishdivinity-local-search`; the package name
12
- * would resolve no form.
7
+ * 卡片 id 必须等于设置命名空间(= 这个 bundle 的 patch 声明的行 id `fishdivinity-local-search`),
8
+ * 页面才能用 `formFor(item.id)` 取到表单;写包名会查不到表单,页面就只剩「本部署没有提供设置
9
+ * 命名空间」那一行。
13
10
  *
14
- * The page receives `{ view, form }`: `view: 'summary'` is the card's one-liner,
15
- * `view: 'page'` the form with its save control.
11
+ * 页面不硬编码任何后端:能力节、每个后端有哪些字段、字段类型与上限,全部由 Host 的
12
+ * `GET /dsh-local-search/api/describe` 给;这里只负责渲染,措辞留在本文件的字典里(zh/en 两列)。
13
+ * 加一个后端时页面逻辑不用改,只补它两个语言的文案。
16
14
  *
17
- * Every control comes from `@deepseek-ai/dsh-client-ui-primitives`, which reads
18
- * the active theme's `--dsw-*` tokens, so a UI skin (for example the
19
- * maid-atelier skin) restyles the page without this package shipping CSS. The
20
- * layout follows the official settings pages: sections with a small heading and
21
- * a responsive field grid, so fields sit side by side instead of one per row.
15
+ * 所有控件来自 `@deepseek-ai/dsh-client-ui-primitives`(读取当前主题的 `--dsw-*` token),
16
+ * 本包不发布任何 CSS;版式照官方设置页:小节标题 + 响应式字段网格。
22
17
  *
23
- * The Test control cannot call the local Anytxt JSON-RPC from the page (a
24
- * cross-origin POST is refused by both sides), so it asks the Host's read-only
25
- * probe route declared in `lib/route.js`.
18
+ * 测试连接不能由页面直接 POST 本机 Anytxt JSON-RPC(跨源两边都拒),所以它问 Host 的只读
19
+ * 探测路由(`lib/route.js`)。
26
20
  *
27
- * This file is the shipped client bundle, not its source: the client module
28
- * system executes it as a classic script in the lazy-CJS factory format, so it
29
- * declares no ESM import or export.
21
+ * 确认与结果都在 primitives 的 `Modal` 里:它自己 portal 到 body,Esc 与焦点由壳子管;本包没有
22
+ * 自绘浮层的样式,所以部署里缺 `Modal` 时退回页内提示(与 `Toast` 同一条规矩)。
23
+ *
24
+ * 分区(grep 这些词直接跳):
25
+ * FIELD_COPY / dictionary —— 全部中英文案
26
+ * statusUrl / verifyQuery / draftPlan / applyCanonical / normalizeWinPath —— 校验与路径改写
27
+ * ConfigPage —— 页面(state → edit → checkField → verify/save/reset → dialogNode → children)
28
+ * EngineNotice / engineNotice / apply —— 首用提示与注册
29
+ *
30
+ * 本文件是发布出去的客户端 bundle,不是源码:客户端模块系统按 lazy-CJS 工厂格式把它当普通
31
+ * 脚本执行,所以这里没有 ESM 的 import/export。
30
32
  */
31
33
  window.__ModuleLoader__.load({
32
34
  id: '@fishdivinity/dsh-local-search',
@@ -34,140 +36,187 @@ window.__ModuleLoader__.load({
34
36
  var module = { exports: {} };
35
37
  var exports = module.exports;
36
38
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
37
- const { createElement: h, useState } = require('react');
39
+ const { createElement: h, useState, useEffect } = require('react');
38
40
  const {
39
- Button, SegmentedControl, SettingsForm, SettingsValueField, Tag,
41
+ Button, Modal, SegmentedControl, SettingsForm, SettingsValueField, Toast,
40
42
  } = require('@deepseek-ai/dsh-client-ui-primitives');
41
43
 
42
- /** Dictionary namespace owned by this plugin. */
44
+ /** 本插件拥有的字典命名空间。 */
43
45
  const NS = 'localSearchConfig';
44
- /** The card id, which must be the settings namespace = the row id the bundle patch declares. */
46
+ /** 卡片 id,必须等于设置命名空间 = bundle patch 声明的行 id。 */
45
47
  const ITEM_ID = 'fishdivinity-local-search';
46
- /** The Host's read-only probe route, resolved against this page's own base URI. */
48
+ /** Host 的后端描述路由,按本页自己的 base URI 解析。 */
49
+ const DESCRIBE_ROUTE = '/dsh-local-search/api/describe';
50
+ /** Host 的只读探测路由。 */
47
51
  const STATUS_ROUTE = '/dsh-local-search/api/status';
48
- /** Client services this half requires; both are provided by the web shell. */
52
+ /** 探测路由接受未保存地址的参数名。 */
53
+ const DRAFT_PARAM = 'anytxtUrl';
54
+ /** 探测路由校验未保存 `es.exe` 路径用的参数名。 */
55
+ const DRAFT_PATH_PARAM = 'esPath';
56
+ /** 探测路由校验未保存目录用的参数名(可重复)。 */
57
+ const DRAFT_DIR_PARAM = 'dir';
58
+ /** 只要校验草稿、不跑引擎探测的参数名:失焦校验用它。 */
59
+ const CHECK_ONLY_PARAM = 'checkOnly';
60
+ /** 本半边需要的客户端服务;两个都由 web shell 提供。 */
49
61
  const inject = ['slots', 'locale'];
50
62
 
51
- /** Where each engine is installed from. This plugin never downloads them. */
63
+ /** 每个引擎的下载页。本插件从不下载它们。 */
52
64
  const DOWNLOADS = {
53
65
  everything: 'https://www.voidtools.com/downloads/',
54
66
  anytxt: 'https://anytxt.net/download/',
55
67
  };
56
68
 
57
- /** Code pages the page offers. The Host accepts any TextDecoder label. */
58
- const ENCODINGS = ['gbk', 'utf-8'];
59
-
60
69
  /**
61
- * Hosts a saved endpoint may name. The bundle cannot import `lib/config.js`
62
- * (a classic script has no ESM imports), so this list duplicates the Host's;
63
- * its test compares the two.
70
+ * 已保存地址允许指向的 host。这个 bundle 不能 import `lib/config.js`(普通脚本没有 ESM
71
+ * import),所以这份列表是 Host 那份的副本;有一条测试比对两者。
64
72
  */
65
73
  const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'];
66
74
 
67
- /** Hard caps the Host enforces in its schema; the page mirrors them so a save cannot be rejected. */
68
- const LIMITS = { maxResults: 100, readMaxChars: 20000 };
69
-
70
- /**
71
- * The segment a saved code page selects. 0.1 accepted any label, so an
72
- * old `utf8` (or `gb18030`) must land on the right segment instead of
73
- * showing GBK and then rewriting the value on save.
74
- * @param value - the saved label
75
- * @returns `gbk` or `utf-8`
76
- */
77
- function normalizeEncoding(value) {
78
- const label = String(value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
79
- return label === 'utf8' || label === 'utf' ? 'utf-8' : 'gbk';
80
- }
75
+ /** 引擎不可用提示的「已提示过」标记,按浏览器会话记一次。 */
76
+ const NOTICE_SEEN_KEY = 'dsh-local-search.engine-notice';
77
+ /** 首用探测在后台跑,页面要等它结束才决定提不提示。 */
78
+ const NOTICE_POLL_MS = 1500;
79
+ /** 轮询上限:五次足够覆盖一次 es.exe + 一次 HTTP 的探测预算。 */
80
+ const NOTICE_POLL_LIMIT = 5;
81
81
 
82
- /** The editable fields. `count` fields are whole numbers with a hard cap. */
83
- const FIELDS = {
84
- esPath: { key: 'esPath', kind: 'text' },
85
- esEncoding: { key: 'esEncoding', kind: 'choice' },
86
- anytxtUrl: { key: 'anytxtUrl', kind: 'text' },
87
- workspaceRoot: { key: 'workspaceRoot', kind: 'text' },
88
- workRoot: { key: 'workRoot', kind: 'text' },
89
- personalRoot: { key: 'personalRoot', kind: 'text' },
90
- allRoots: { key: 'allRoots', kind: 'text' },
91
- maxResults: { key: 'maxResults', kind: 'count', max: LIMITS.maxResults },
92
- readMaxChars: { key: 'readMaxChars', kind: 'count', max: LIMITS.readMaxChars },
93
- };
94
-
95
- /** Per-field copy: [label, hint]. */
82
+ /** 逐字段文案:[标签, 说明]。键名与 Host describe 的 labelKey / hintKey 一一对应。 */
96
83
  const FIELD_COPY = {
97
84
  esPath: {
98
- en: ['es.exe path', 'Absolute path to es.exe; empty auto-detects PATH and the usual install folders.'],
99
- zh: ['es.exe 路径', 'es.exe 的绝对路径;留空则自动从 PATH 与常见安装目录查找。'],
85
+ en: ['es.exe path', 'Full path, e.g. C:\\Tools\\es.exe; leave empty to detect it.'],
86
+ zh: ['es.exe 路径', '完整路径,例如 C:\\Tools\\es.exe;留空自动查找。'],
100
87
  },
101
88
  esEncoding: {
102
- en: ['es.exe output encoding', 'Code page used to decode es.exe output: GBK for a zh-CN console, UTF-8 otherwise.'],
103
- zh: ['es.exe 输出编码', '解码 es.exe 输出所用的代码页:简中控制台用 GBK,其余用 UTF-8。'],
89
+ en: ['es.exe output encoding', 'GBK for a zh-CN console, UTF-8 otherwise.'],
90
+ zh: ['es.exe 输出编码', '简中控制台选 GBK,其余选 UTF-8。'],
104
91
  },
105
92
  anytxtUrl: {
106
- en: ['Anytxt endpoint', 'This machine only (127.0.0.1 / localhost); an address ending in :9920 also tries :9921.'],
107
- zh: ['Anytxt 地址', '只允许本机地址(127.0.0.1 / localhost);以 :9920 结尾时还会自动尝试 :9921。'],
93
+ en: ['Anytxt endpoint', 'A local address, e.g. http://127.0.0.1:9920.'],
94
+ zh: ['Anytxt 地址', '本机地址,例如 http://127.0.0.1:9920。'],
108
95
  },
109
96
  workspaceRoot: {
110
- en: ['Workspace root', 'Root for scope=workspace; empty follows the session working directory.'],
111
- zh: ['工作区根', 'scope=workspace 的根;留空跟随会话工作目录。'],
97
+ en: ['Workspace root', 'A full directory, e.g. D:\\Alpha; empty follows the working directory.'],
98
+ zh: ['工作区根', '完整目录,例如 D:\\Alpha;留空跟随当前工作目录。'],
112
99
  },
113
100
  workRoot: {
114
- en: ['Work root', 'Root for scope=work; the scope is unavailable while this is empty.'],
115
- zh: ['工作根', 'scope=work 的根;留空时该 scope 不可用。'],
101
+ en: ['Work root', 'A full directory, e.g. E:\\Beta; the scope is unavailable while empty.'],
102
+ zh: ['工作根', '完整目录,例如 E:\\Beta;留空则这个范围不可用。'],
116
103
  },
117
104
  personalRoot: {
118
- en: ['Personal root', 'Root for scope=personal; the scope is unavailable while this is empty.'],
119
- zh: ['个人根', 'scope=personal 的根;留空时该 scope 不可用。'],
105
+ en: ['Personal root', 'A full directory; the scope is unavailable while empty.'],
106
+ zh: ['个人根', '完整目录;留空则这个范围不可用。'],
120
107
  },
121
108
  allRoots: {
122
- en: ['All-scope roots', 'Semicolon-separated directories for scope=all; empty uses C: and D: when they exist.'],
123
- zh: ['全局检索根', 'scope=all 使用的目录,分号分隔;留空使用存在的 C: 与 D:。'],
109
+ en: ['All-scope roots', 'Full directories separated by semicolons, e.g. D:\\Alpha;E:\\Beta; empty uses C: and D:.'],
110
+ zh: ['全局检索根', '多个完整目录,分号隔开,例如 D:\\Alpha;E:\\Beta;留空用 C: 与 D:。'],
124
111
  },
125
112
  maxResults: {
126
- en: ['Results per call', `Default result cap for both search tools; 1–${LIMITS.maxResults}.`],
127
- zh: ['每次结果数', `两个检索工具的默认结果上限;1–${LIMITS.maxResults}。`],
113
+ en: ['Results per call', 'Results per call, 1–100.'],
114
+ zh: ['每次结果数', '每次返回多少条,1–100。'],
128
115
  },
129
116
  readMaxChars: {
130
- en: ['Read window (characters)', `Default window for local_search_read; 1–${LIMITS.readMaxChars}.`],
131
- zh: ['读取窗口(字符)', `local_search_read 的默认窗口;1–${LIMITS.readMaxChars}。`],
117
+ en: ['Read window (characters)', 'Characters per read, 1–20,000.'],
118
+ zh: ['读取窗口(字符)', '每次读取多少字符,1–20,000。'],
132
119
  },
133
120
  };
134
121
 
135
- /** Type styles from theme tokens; a skin owns every colour and size it overrides. */
122
+ /** 版式样式,全部取自主题 token;皮肤要改就覆盖 token。 */
136
123
  const H3_STYLE = { margin: 0, fontSize: '13px', fontWeight: '600', lineHeight: '1.5', color: 'var(--dsw-alias-label-primary)' };
137
124
  const LABEL_STYLE = { fontSize: '13px', fontWeight: '600', color: 'var(--dsw-alias-label-primary)' };
138
125
  const HINT_STYLE = { margin: '4px 0 0', fontSize: '12px', lineHeight: '1.6', color: 'var(--dsw-alias-label-secondary)' };
139
- const LINK_STYLE = { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'none' };
140
- const ROW_STYLE = { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' };
141
126
  /**
142
- * The field grid. A 460px minimum keeps two columns on a wide page and one
143
- * on a narrow one, so four paths read as two rows instead of four squeezed
144
- * columns; the four-path section is why the minimum is not the official
145
- * page's 220px.
127
+ * 被拦下的保存、以及重置的提示共用这一行样式:红色、正常字重。
128
+ *
129
+ * token 名必须是主题真有的:`--dsw-alias-state-warning-primary` 不存在,写错只会静默
130
+ * 回落到普通文字色(看起来像没上色)。状态的专用 token 是
131
+ * `--dsw-alias-state-{error,warn,success}-primary`。
132
+ */
133
+ const NOTICE_STYLE = {
134
+ margin: '8px 0 0',
135
+ fontSize: '13px',
136
+ lineHeight: '1.6',
137
+ color: 'var(--dsw-alias-state-error-primary)',
138
+ };
139
+ /**
140
+ * 探测结果的无序列表:一行一个引擎,失败的那行下面跟一句修法。
141
+ *
142
+ * 字号/行高/颜色照壳子弹窗正文那句 `_description`(14px / 22px / label-primary)写:
143
+ * 不写就会继承到别处的基准字号(16px),跟「重置」「仍要保存?」那些弹窗的正文差一档。
146
144
  */
147
- const GRID_STYLE = {
148
- display: 'grid',
149
- gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 460px), 1fr))',
150
- gap: '16px',
151
- marginTop: '8px',
145
+ const PROBE_LIST_STYLE = {
146
+ margin: 0,
147
+ paddingLeft: '18px',
148
+ fontSize: '14px',
149
+ lineHeight: '22px',
150
+ color: 'var(--dsw-alias-label-primary)',
152
151
  };
152
+ const PROBE_ITEM_STYLE = { marginTop: '2px' };
153
+ /** 修法那行:比结论浅一档,是补充说明,不跟结论抢同一行。 */
154
+ const PROBE_FIX_STYLE = {
155
+ display: 'block',
156
+ fontSize: '12px',
157
+ lineHeight: '1.6',
158
+ color: 'var(--dsw-alias-label-secondary)',
159
+ };
160
+ /**
161
+ * 探测结果那块的外壳。
162
+ *
163
+ * 弹窗没有 `description` 时,壳子给 `.body` 固定加 20px 上边距,于是列表比「重置」那种
164
+ * 弹窗的正文低一截,标题下面空一块。把这个负边距还回去,列表就从和 description 一样的
165
+ * 位置开始;`body` 是 flex 列、不与子元素折叠外边距,所以这 20px 会被真的抵消。
166
+ */
167
+ const PROBE_BODY_STYLE = { marginTop: '-20px' };
168
+ /** 链接用主题的链接色:品牌色在亮色主题下是近黑,看起来像没上色。 */
169
+ const LINK_STYLE = { color: 'var(--dsw-alias-link)', textDecoration: 'none' };
170
+ const ROW_STYLE = { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' };
171
+ /**
172
+ * 字段网格:两列,整块限宽 1100px,容器窄于 {@link WIDE_QUERY} 时回落一列。
173
+ *
174
+ * 为什么不是 `auto-fit` + 最小列宽:一条最小列宽没法同时满足「700px 的容器也排两列」和
175
+ * 「全屏不要第三列」——前者要求 ≤342px,后者要求 >366px(1100/3)。所以列数直接由宽度
176
+ * 判断(`matchMedia`),两列时每列 `1fr`,全屏就是原来的 2×542。
177
+ */
178
+ const WIDE_QUERY = '(min-width: 656px)';
179
+ const GRID_MAX_WIDTH = '1100px';
180
+
181
+ function gridStyle(wide) {
182
+ return {
183
+ display: 'grid',
184
+ gridTemplateColumns: wide ? 'repeat(2, minmax(0, 1fr))' : 'minmax(0, 1fr)',
185
+ gap: '16px',
186
+ marginTop: '8px',
187
+ maxWidth: GRID_MAX_WIDTH,
188
+ };
189
+ }
190
+
153
191
  const SECTION_STYLE = { minWidth: 0, padding: '16px 0' };
192
+ /** 一个字段占的格子;上限让单列布局不至于横贯整屏。 */
193
+ const CELL_STYLE = { minWidth: 0, maxWidth: '560px' };
194
+ /** 后端选择器只占一小条,别拉满整行。 */
195
+ const SELECTOR_STYLE = { marginTop: '8px', maxWidth: '320px' };
196
+ /** 重置那行:描述正下方,靠下留白与第一个小节分开。 */
197
+ const RESET_TOP_STYLE = { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' };
154
198
 
155
199
  function dictionary(copy) {
156
200
  const words = {
157
201
  description: copy('只读的 Everything 文件名检索与 Anytxt 文档内容检索。', 'Read-only Everything filename search and Anytxt document-content search.'),
158
202
  itemLabel: copy('本地搜索', 'Local Search'),
159
- sectionEverything: copy('Everything(文件名检索)', 'Everything (file names)'),
160
- sectionAnytxt: copy('Anytxt(文档内容检索)', 'Anytxt (document contents)'),
203
+ sectionFiles: copy('文件名检索', 'File names'),
204
+ sectionContent: copy('文件内容检索', 'File contents'),
161
205
  sectionScopes: copy('检索范围', 'Search scopes'),
162
206
  sectionLimits: copy('上限', 'Limits'),
207
+ engineEverything: 'Everything',
208
+ engineAnytxt: 'Anytxt',
163
209
  note: copy(
164
- '单次调用超时仍留在 profile 的 YAML:工具注册时就固定了截止时间。',
165
- 'The per-call timeout stays in the profile YAML: each tool fixes its deadline when it registers.',
210
+ '单次调用的超时只能改 profile 的 YAML,改完要重载插件。',
211
+ 'The per-call timeout lives in the profile YAML only; change it there and reload the plugin.',
166
212
  ),
167
- overridden: copy('本页设置', 'Set on this page'),
168
- reset: copy('重置', 'Reset'),
169
213
  invalidValue: copy('这个值不合法。', 'Not a valid value.'),
170
214
  invalidNumber: copy('请填范围内的整数。', 'Enter a whole number in range.'),
215
+ invalidFile: copy('这个文件不存在。', 'No such file.'),
216
+ invalidDir: copy('这个目录不存在。', 'No such directory.'),
217
+ invalidNotFile: copy('这里要填文件,不是目录。', 'A file goes here, not a directory.'),
218
+ invalidNotDir: copy('这里要填目录,不是文件。', 'A directory goes here, not a file.'),
219
+ invalidLocal: copy('只支持本机地址。', 'A local address only.'),
171
220
  unavailable: copy(
172
221
  '当前部署没有提供本插件的设置命名空间,请改在 profile 的 cordis.patch.yml 里配置。',
173
222
  "This deployment does not serve this plugin's settings; set the values in the profile's cordis.patch.yml instead.",
@@ -176,10 +225,40 @@ window.__ModuleLoader__.load({
176
225
  saveFailed: copy('宿主没有接受这些值。', 'The Host did not accept these values.'),
177
226
  save: copy('保存', 'Save'),
178
227
  saving: copy('保存中…', 'Saving…'),
228
+ // 拼接用的标点:中文全角、英文半角加空格。写死在模板里,英文界面就会冒出「。」。
229
+ listSeparator: copy('、', ', '),
230
+ colon: copy(':', ': '),
231
+ fullStop: copy('。', '.'),
232
+ saveAnyway: copy('仍然保存', 'Save anyway'),
233
+ saveEngineDown: copy('引擎现在不可用:', 'The engine is unavailable now: '),
234
+ saveNoWrite: copy(',没有写入。', ', nothing was written.'),
235
+ savePathMissing: copy('es.exe 路径上找不到文件', 'No file at that es.exe path'),
236
+ savePathNotFile: copy('es.exe 路径是个目录,不是文件', 'The es.exe path is a directory, not a file'),
237
+ saveDirMissing: copy('这个目录不存在', 'No such directory'),
238
+ saveDirNotDir: copy('这个路径是个文件,不是目录', 'That path is a file, not a directory'),
239
+ saveEndpointInvalid: copy('这个地址不被接受', 'That address was rejected'),
240
+ saveBlockedTitle: copy('无法保存', 'Could not save'),
241
+ saveAnywayTitle: copy('仍要保存?', 'Save anyway?'),
242
+ close: copy('关闭', 'Close'),
243
+ cancel: copy('取消', 'Cancel'),
244
+ reset: copy('重置', 'Reset'),
245
+ resetTitle: copy('重置', 'Reset'),
246
+ resetConfirm: copy('确认重置', 'Confirm reset'),
247
+ resetHint: copy(
248
+ '按探测结果重新填入;检索范围保留,其余恢复默认。',
249
+ 'Refills from a fresh probe; search scopes stay and everything else returns to its default.',
250
+ ),
251
+ resetting: copy('重置中…', 'Resetting…'),
252
+ resetDone: copy('已重置。', 'Reset done.'),
253
+ resetFailed: copy('重置没有写进去。', 'The reset was not written.'),
254
+ describeLoading: copy('正在读取后端列表…', 'Loading the backend list…'),
255
+ describeFailed: copy('读不到后端列表:', 'Could not read the backend list:'),
256
+ retry: copy('重试', 'Retry'),
179
257
  encodingGbk: copy('GBK(简中)', 'GBK (zh-CN)'),
180
258
  encodingUtf8: 'UTF-8',
181
259
  test: copy('测试连接', 'Test connection'),
182
260
  testing: copy('测试中…', 'Testing…'),
261
+ testAgain: copy('再测一次', 'Test again'),
183
262
  testHint: copy(
184
263
  '用上面输入框里的地址试一次,不用先保存;只探测,不改配置。',
185
264
  'Probes the address above without saving it first; changes nothing.',
@@ -189,7 +268,6 @@ window.__ModuleLoader__.load({
189
268
  'The address must be a local http(s) url: 127.0.0.1, localhost or [::1].',
190
269
  ),
191
270
  testFailed: copy('测试请求失败', 'The test request failed'),
192
- testWhere: copy('测试请求发往', 'The test request goes to'),
193
271
  everythingOk: copy('Everything 正常', 'Everything: OK'),
194
272
  everythingDown: copy('Everything 不可用', 'Everything: DOWN'),
195
273
  anytxtOk: copy('Anytxt 正常', 'Anytxt: OK'),
@@ -197,17 +275,18 @@ window.__ModuleLoader__.load({
197
275
  downloadEverything: copy('下载 Everything', 'Download Everything'),
198
276
  downloadAnytxt: copy('下载 Anytxt', 'Download Anytxt'),
199
277
  fixEverythingNotRunning: copy(
200
- 'Everything 已安装但没在运行:启动它。',
201
- 'Everything is installed but not running: start it.',
278
+ '已安装但没在运行:启动它',
279
+ 'Installed but not running: start it',
202
280
  ),
203
281
  fixEverythingNotInstalled: copy(
204
- '没有检测到 Everything:先安装它,再安装随附的 es.exe。',
205
- 'Everything is not installed: install it, then the es.exe that ships with it.',
282
+ '没装:先安装 Everything',
283
+ 'Not installed: install Everything first',
206
284
  ),
207
285
  fixEverythingEsMissing: copy(
208
- '缺 es.exe:从 voidtools 下载后填到上面的 es.exe 路径。',
209
- 'es.exe is missing: download it and set the path above.',
286
+ '缺 es.exe:装上后填到上面的路径',
287
+ 'es.exe is missing: install it and set the path',
210
288
  ),
289
+ noticeEnginesDown: copy('这些检索后端现在不可用:', 'These search backends are unavailable: '),
211
290
  };
212
291
  for (const [key, entry] of Object.entries(FIELD_COPY)) {
213
292
  words[`${key}Label`] = copy(entry.zh[0], entry.en[0]);
@@ -220,10 +299,9 @@ window.__ModuleLoader__.load({
220
299
  const zh = dictionary((zhText) => zhText);
221
300
 
222
301
  /**
223
- * A route under this page's own base URI, so a deployment mounted below
224
- * the origin root still reaches the Host's route.
225
- * @param path - absolute path on the Host
226
- * @returns the request path
302
+ * 本页 base URI 下的一条路由,因此挂载在 origin 子路径上的部署也能到达 Host。
303
+ * @param path - Host 上的绝对路径
304
+ * @returns 请求路径
227
305
  */
228
306
  function api(path) {
229
307
  const relative = String(path).replace(/^\/+/, '');
@@ -231,40 +309,249 @@ window.__ModuleLoader__.load({
231
309
  return new URL(relative, document.baseURI).pathname;
232
310
  }
233
311
 
312
+ /** 取后端描述。 */
313
+ function describeUrl() {
314
+ return api(DESCRIBE_ROUTE);
315
+ }
316
+
234
317
  /**
235
- * The probe request for the address currently in the field. A draft rides
236
- * the query, so testing never needs a save first; the Host accepts a draft
237
- * only as a loopback `http(s)` URL.
238
- * @param draft - the endpoint as typed, or empty to probe the saved value
239
- * @returns the request path, query included
318
+ * 探测请求的地址。草稿走查询串,所以测试永远不需要先保存;Host 只接受本机 `http(s)`
319
+ * 的地址、只查存在性的 `es.exe` 路径。
320
+ * @param draft - 输入框里的地址,空串表示探测已保存的值
321
+ * @param esPath - 输入框里的 `es.exe` 路径,空串表示用已保存的(或自动查找)
322
+ * @returns 请求路径,含查询串
240
323
  */
241
- function statusUrl(draft) {
324
+ function statusUrl(draft, esPath) {
242
325
  const path = api(STATUS_ROUTE);
243
- const trimmed = String(draft ?? '').trim();
244
- if (trimmed === '') return path;
245
- return `${path}?anytxtUrl=${encodeURIComponent(trimmed)}`;
326
+ const params = new URLSearchParams();
327
+ const address = String(draft ?? '').trim();
328
+ if (address !== '') params.set(DRAFT_PARAM, address);
329
+ const binary = String(esPath ?? '').trim();
330
+ if (binary !== '') params.set(DRAFT_PATH_PARAM, binary);
331
+ const query = params.toString();
332
+ return query === '' ? path : `${path}?${query}`;
333
+ }
334
+
335
+ /** 路径的字符串键;staged 用它当 Map 键。 */
336
+ function pathKey(path) {
337
+ return path.join('.');
338
+ }
339
+
340
+ /** 沿路径取值。 */
341
+ function pathValue(object, path) {
342
+ let node = object;
343
+ for (const key of path) {
344
+ if (node === null || typeof node !== 'object') return undefined;
345
+ node = node[key];
346
+ }
347
+ return node;
348
+ }
349
+
350
+ /** 盘符路径 / UNC 路径。只重写这两种写法,POSIX 与相对路径原样留着。 */
351
+ const WINDOWS_PATH = /^[a-z]:[\\/]/i;
352
+ const UNC_PATH = /^[\\/]{2}/;
353
+
354
+ /**
355
+ * 把一个路径草稿改写成标准写法:盘符大写、只用 `\`、去掉多余与末尾的分隔符。
356
+ *
357
+ * 只认 `D:\` / `D:/` 与 `\\server\share` 两种开头——别的部署写 `/home/me` 时,把分隔符
358
+ * 换成 `\` 只会造出一个不存在的路径。名字里的空格、中文、`drawio a` 那种带空格的目录名
359
+ * 都不动:Windows 允许,宿主也是按值传给 `es.exe` 与 `fs`,不经过 shell。
360
+ * @param text - 输入框里的路径
361
+ * @returns 标准写法;空串仍是空串
362
+ */
363
+ function normalizeWinPath(text) {
364
+ const trimmed = String(text ?? '').trim();
365
+ if (trimmed === '') return '';
366
+ if (!WINDOWS_PATH.test(trimmed) && !UNC_PATH.test(trimmed)) return trimmed;
367
+ const unc = UNC_PATH.test(trimmed);
368
+ let value = trimmed.replace(/\//g, '\\').replace(/\\{2,}/g, '\\');
369
+ // 折叠分隔符会把 UNC 开头的两个也压成一个,这里补回来。
370
+ if (unc) value = `\\${value}`;
371
+ if (/^[a-z]:/.test(value)) value = `${value[0].toUpperCase()}${value.slice(1)}`;
372
+ // 目录末尾的分隔符没有信息量;盘符根 `D:\` 只有三个字符,留着。
373
+ if (value.length > 3 && value.endsWith('\\')) value = value.replace(/\\+$/, '');
374
+ return value;
246
375
  }
247
376
 
248
377
  /**
249
- * The staged edits as one atomic operation list. A cleared field is an
250
- * `unset`, so it falls back to the profile layer instead of pinning an
251
- * empty override.
252
- * @param staged - key to `{ op: 'set', text }` or `{ op: 'unset' }`
253
- * @returns the operations a save submits
378
+ * 分号列表的同一个改写;空项丢掉(它们本来就会被 verifyQuery 跳过)。
379
+ * @param text - 分号分隔的目录列表
380
+ * @returns 标准写法,分号分隔
381
+ */
382
+ function normalizeWinPaths(text) {
383
+ return String(text ?? '')
384
+ .split(';')
385
+ .map((value) => normalizeWinPath(value))
386
+ .filter((value) => value !== '')
387
+ .join(';');
388
+ }
389
+
390
+ /**
391
+ * 保存前把带路径语义的草稿改写成标准写法。
392
+ * @param staged - 路径键到草稿
393
+ * @param fields - 路径键到 describe 字段
394
+ * @returns 改写后的草稿,以及是否真的改动了
395
+ */
396
+ function normalizeStaged(staged, fields) {
397
+ let changed = false;
398
+ const next = {};
399
+ for (const [key, entry] of Object.entries(staged)) {
400
+ const field = fields.get(key);
401
+ if (entry.op !== 'set' || field === undefined) {
402
+ next[key] = entry;
403
+ continue;
404
+ }
405
+ const text = String(entry.text);
406
+ const normalized = field.verifyPath === true
407
+ ? normalizeWinPath(text)
408
+ : field.verifyDir === true ? normalizeWinPaths(text) : text;
409
+ if (normalized === text) {
410
+ next[key] = entry;
411
+ continue;
412
+ }
413
+ changed = true;
414
+ next[key] = { ...entry, text: normalized };
415
+ }
416
+ return { next, changed };
417
+ }
418
+
419
+ /**
420
+ * 一个字段的草稿要翻成哪些查询参数。
421
+ *
422
+ * 保存前的整表校验、失焦时的单字段校验、测试连接都走这里:参数名与拆分规则只写一遍
423
+ * ——以前三处各写一份,加字段时总会漏掉一处。
424
+ * @param field - describe 给的字段
425
+ * @param text - 输入框里的值
426
+ * @returns 参数名到值的列表;不可校验或空值时为空
427
+ */
428
+ function draftEntries(field, text) {
429
+ const value = String(text ?? '').trim();
430
+ if (value === '') return [];
431
+ if (field.probeEndpoint === true) return [[DRAFT_PARAM, value]];
432
+ if (field.verifyPath === true) return [[DRAFT_PATH_PARAM, normalizeWinPath(value)]];
433
+ if (field.verifyDir === true) {
434
+ // `allRoots` 是分号列表:每个目录单独校验,空项跳过。
435
+ return normalizeWinPaths(value).split(';').filter(Boolean).map((dir) => [DRAFT_DIR_PARAM, dir]);
436
+ }
437
+ return [];
438
+ }
439
+
440
+ /**
441
+ * 一次保存要交给 Host 校验的草稿,按发送顺序列出。
442
+ *
443
+ * 顺序是回填的依据:Host 按同样的顺序回答每个目录的规范写法(磁盘上的真实大小写),所以
444
+ * 查询串与回填都从这份清单来,不能各算一遍。
445
+ * @param staged - 路径键到草稿
446
+ * @param fields - 路径键到 describe 字段
447
+ * @returns 每一项的路径键、种类(endpoint/path/dir)、规范后的值
448
+ */
449
+ function draftPlan(staged, fields) {
450
+ const plan = [];
451
+ for (const [key, entry] of Object.entries(staged)) {
452
+ if (entry.op !== 'set') continue;
453
+ const field = fields.get(key);
454
+ if (field === undefined) continue;
455
+ const entries = draftEntries(field, entry.text);
456
+ if (entries.length === 0) continue;
457
+ const kind = field.probeEndpoint === true ? 'endpoint' : field.verifyPath === true ? 'path' : 'dir';
458
+ const values = entries.map(([, value]) => value);
459
+ plan.push({
460
+ key,
461
+ kind,
462
+ text: kind === 'dir' ? values.join(';') : values[0],
463
+ dirs: kind === 'dir' ? values : [],
464
+ entries,
465
+ });
466
+ }
467
+ return plan;
468
+ }
469
+
470
+ /**
471
+ * 保存前要交给 Host 校验的草稿查询串。
472
+ *
473
+ * 只有「能安全校验」的字段进得来:地址(Host 用草稿探测)、`es.exe` 路径(Host 只查
474
+ * 文件存在,绝不启动它)、以及目录类字段(只查是不是一个已存在的目录)。没有可校验字段
475
+ * 时返回空串,保存就不必先多发一次请求。
476
+ * @param staged - 路径键到草稿
477
+ * @param fields - 路径键到 describe 字段
478
+ * @returns 查询串(不含 `?`),或空串
479
+ */
480
+ function verifyQuery(staged, fields) {
481
+ const params = new URLSearchParams();
482
+ for (const item of draftPlan(staged, fields)) {
483
+ for (const [name, value] of item.entries) params.append(name, value);
484
+ }
485
+ return params.toString();
486
+ }
487
+
488
+ /**
489
+ * 把 Host 回来的规范写法(磁盘上的真实大小写、真实分隔符)写回草稿。
490
+ *
491
+ * 只改写法,不改语义:`value` 已经校验过存在,`resolved` 指向同一个文件/目录。
492
+ * @param draft - 规范化之后的草稿
493
+ * @param fields - 路径键到 describe 字段
494
+ * @param drafts - status 路由回答里的 `drafts`
495
+ * @returns 改写后的草稿;没有可回填的值时原样返回
496
+ */
497
+ function applyCanonical(draft, fields, drafts) {
498
+ if (drafts === undefined || drafts === null) return draft;
499
+ const plan = draftPlan(draft, fields);
500
+ let cursor = 0;
501
+ let changed = false;
502
+ const next = { ...draft };
503
+ for (const item of plan) {
504
+ if (item.kind === 'path') {
505
+ const value = drafts.esPath;
506
+ if (typeof value !== 'string' || value === '' || value === item.text) continue;
507
+ next[item.key] = { ...next[item.key], text: value };
508
+ changed = true;
509
+ continue;
510
+ }
511
+ if (item.kind !== 'dir') continue;
512
+ const slice = (drafts.dirs ?? []).slice(cursor, cursor + item.dirs.length);
513
+ cursor += item.dirs.length;
514
+ if (slice.length !== item.dirs.length) continue;
515
+ const joined = slice.join(';');
516
+ if (joined === item.text) continue;
517
+ next[item.key] = { ...next[item.key], text: joined };
518
+ changed = true;
519
+ }
520
+ return changed ? next : draft;
521
+ }
522
+
523
+ /**
524
+ * 从探测结果里取「要写回某个字段的值」。
525
+ * @param probed - status 路由的回答
526
+ * @param from - 点分路径,如 `everything.path`
527
+ * @returns 非空字符串,或 undefined
528
+ */
529
+ function fillValue(probed, from) {
530
+ let node = probed;
531
+ for (const key of String(from ?? '').split('.')) {
532
+ if (node === null || typeof node !== 'object') return undefined;
533
+ node = node[key];
534
+ }
535
+ return typeof node === 'string' && node !== '' ? node : undefined;
536
+ }
537
+
538
+ /**
539
+ * 一次保存提交的原子操作列表。清空的字段是 `unset`,回落到 profile 层,而不是钉一个
540
+ * 空覆盖值。
541
+ * @param staged - 路径键到 `{ op: 'set', text }` 或 `{ op: 'unset' }`
542
+ * @returns 保存提交的操作
254
543
  */
255
544
  function operations(staged) {
256
545
  return Object.entries(staged).map(([key, entry]) => {
257
- if (entry.op === 'unset') return { op: 'unset', path: [key] };
258
- const field = FIELDS[key];
259
- return { op: 'set', path: [key], value: field.kind === 'count' ? Number(entry.text) : entry.text };
546
+ if (entry.op === 'unset') return { op: 'unset', path: key.split('.') };
547
+ return { op: 'set', path: key.split('.'), value: entry.numeric === true ? Number(entry.text) : entry.text };
260
548
  });
261
549
  }
262
550
 
263
551
  /**
264
- * Whether a value is an `http(s)` URL on this machine — the only shape the
265
- * Host accepts, so the field refuses the same values.
266
- * @param value - candidate endpoint
267
- * @returns true for a loopback `http(s)` URL
552
+ * 值是否是本机 `http(s)` 地址——Host 只接受这种形状,所以字段先拒绝同一批值。
553
+ * @param value - 候选地址
554
+ * @returns 本机 `http(s)` 地址时为 true
268
555
  */
269
556
  function isLoopbackUrl(value) {
270
557
  let parsed;
@@ -279,17 +566,38 @@ window.__ModuleLoader__.load({
279
566
  }
280
567
 
281
568
  /**
282
- * The first staged field a save would have to refuse.
283
- * @param staged - key to staged edit
284
- * @returns the key, or undefined when every draft is acceptable
569
+ * 已保存的值该落在那一段上。
570
+ *
571
+ * 0.1 接受任意 `TextDecoder` 标签,所以旧的 `utf8`(或 `gb18030`)要落到正确的段,
572
+ * 而不是显示成 GBK 再在保存时把值改写。别名表由 Host 的 describe 给。
573
+ * @param field - describe 给的字段描述
574
+ * @param value - 已保存的值
575
+ * @returns 选中的 choice 值
285
576
  */
286
- function invalidField(staged) {
577
+ function choiceValue(field, value) {
578
+ const normalize = (raw) => String(raw ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
579
+ const key = normalize(value);
580
+ const exact = field.choices.find((choice) => normalize(choice.value) === key);
581
+ if (exact !== undefined) return exact.value;
582
+ const alias = field.aliases === undefined ? undefined : field.aliases[key];
583
+ return alias === undefined ? field.choices[0].value : alias;
584
+ }
585
+
586
+ /**
587
+ * 保存会被拒绝的第一个草稿字段。
588
+ *
589
+ * 只管「不借助文件系统就能判」的那些:整数范围在这里挡,地址留到失焦或保存前的 Host
590
+ * 校验(`isLoopbackUrl` 判得了形状,但把红字提前到每敲一个字符就跳出来只会烦人)。
591
+ * @param staged - 路径键到草稿
592
+ * @param fields - 路径键到 describe 字段
593
+ * @returns 路径键;全部可接受时 undefined
594
+ */
595
+ function invalidField(staged, fields) {
287
596
  for (const [key, entry] of Object.entries(staged)) {
288
597
  if (entry.op === 'unset') continue;
289
- const trimmed = String(entry.text).trim();
290
- if (key === 'anytxtUrl' && trimmed !== '' && !isLoopbackUrl(trimmed)) return key;
291
- const field = FIELDS[key];
292
- if (field.kind === 'count') {
598
+ const field = fields.get(key);
599
+ if (field === undefined) continue;
600
+ if (field.type === 'count') {
293
601
  const value = Number(entry.text);
294
602
  if (!Number.isInteger(value) || value < 1 || value > field.max) return key;
295
603
  }
@@ -298,39 +606,38 @@ window.__ModuleLoader__.load({
298
606
  }
299
607
 
300
608
  /**
301
- * Render the probe result: one line per engine, plus where to install an
302
- * engine that is missing. Nothing here installs anything.
303
- * @param t - dictionary lookup
304
- * @param body - the route's JSON answer
305
- * @returns the result text and any guidance links
609
+ * 探测结果:一行一个引擎,通过的带版本,失败的不带错误码——修法比错误码有用。
610
+ *
611
+ * 修法挂在失败的那一行下面,所以两处都不再重复引擎名(原来两行结论下面拼一行
612
+ * 「Everything 已安装但没在运行:启动它。 · 下载 Anytxt」,既重复又对不上是哪个引擎)。
613
+ * @param t - 字典查找
614
+ * @param body - 路由的 JSON 回答
615
+ * @returns 每个引擎的状态行与(可选的)修法
306
616
  */
307
617
  function probeResult(t, body) {
308
618
  const everything = body.everything ?? {};
309
619
  const anytxt = body.anytxt ?? {};
310
- // One line, one clause per engine: a passing engine needs its version, a
311
- // failing one needs its code, and neither needs its prose message.
312
- const parts = [
313
- everything.ok
314
- ? `${t('everythingOk')}${everything.version ? ` ${everything.version}` : ''}`
315
- : `${t('everythingDown')} (${everything.code ?? 'UNKNOWN'})`,
316
- anytxt.ok
317
- ? t('anytxtOk')
318
- : `${t('anytxtDown')} (${anytxt.code ?? 'UNKNOWN'})`,
620
+ return [
621
+ {
622
+ key: 'everything',
623
+ text: everything.ok
624
+ ? `${t('everythingOk')}${everything.version ? ` · ${everything.version}` : ''}`
625
+ : t('everythingDown'),
626
+ fix: everything.ok ? undefined : everythingGuidance(t, everything),
627
+ },
628
+ {
629
+ key: 'anytxt',
630
+ text: anytxt.ok ? t('anytxtOk') : t('anytxtDown'),
631
+ fix: anytxt.ok ? undefined : { key: 'anytxt', text: t('downloadAnytxt'), href: DOWNLOADS.anytxt },
632
+ },
319
633
  ];
320
- // The route also reports which link of the Everything chain is missing
321
- // (`detail`); the page is where a reader can act on that, so the guidance
322
- // names the cause instead of always asking for a download.
323
- const guidance = [];
324
- if (!everything.ok) guidance.push(everythingGuidance(t, everything));
325
- if (!anytxt.ok) guidance.push({ key: 'anytxt', text: t('downloadAnytxt'), href: DOWNLOADS.anytxt });
326
- return { text: parts.join(' · '), guidance };
327
634
  }
328
635
 
329
636
  /**
330
- * The one action that would fix a failed Everything probe.
331
- * @param t - the page's translator
332
- * @param everything - the route's Everything answer, `detail` included
333
- * @returns a guidance line, with a download link only when one is the fix
637
+ * 修好一次失败探测所需的唯一动作。
638
+ * @param t - 页面的翻译函数
639
+ * @param everything - 路由的 Everything 回答,含 `detail`
640
+ * @returns 一行指引,只有下载才是解法时才带链接
334
641
  */
335
642
  function everythingGuidance(t, everything) {
336
643
  if (everything.detail === 'not-running') return { key: 'everything', text: t('fixEverythingNotRunning') };
@@ -344,153 +651,452 @@ window.__ModuleLoader__.load({
344
651
  }
345
652
 
346
653
  /**
347
- * One section: a small heading, then fields on the official grid.
348
- * @param props - title, heading id, whether to use the grid, and the fields
349
- * @returns the section element
654
+ * 一个小节:小标题、可选的后端选择器、官方网格上的字段,以及网格下的附加块。
655
+ * @param props - 标题、标题 id、选择器、网格样式、字段与页脚
656
+ * @returns 小节元素
350
657
  */
351
658
  function Section(props) {
352
659
  return h('section', { style: SECTION_STYLE, 'aria-labelledby': props.id },
353
660
  h('h3', { id: props.id, style: H3_STYLE }, props.title),
354
- props.grid === false
355
- ? h('div', { style: { marginTop: '8px' } }, props.children)
356
- : h('div', { style: GRID_STYLE }, props.children),
661
+ props.header === undefined ? null : props.header,
662
+ h('div', { style: props.grid }, props.children),
663
+ props.footer === undefined ? null : props.footer,
357
664
  );
358
665
  }
359
666
 
360
667
  /**
361
- * The encoding field. The value set is closed, so it is a control instead
362
- * of a text field the user could mistype into a silent UTF-8 fallback.
668
+ * 二选一字段。取值集合是封闭的,所以用控件而不是文本框——文本框会让用户敲出一个
669
+ * 静默回退到 UTF-8 的错值。
363
670
  */
364
- function EncodingField(props) {
365
- const { t, id, value, overridden, disabled, onChange, onReset } = props;
366
- const options = ENCODINGS.map((encoding) => ({
367
- value: encoding,
368
- label: encoding === 'gbk' ? t('encodingGbk') : t('encodingUtf8'),
671
+ function ChoiceField(props) {
672
+ const { t, field, id, value, invalid, disabled, onEdit } = props;
673
+ const label = t(field.labelKey);
674
+ const options = field.choices.map((choice) => ({
675
+ value: choice.value,
676
+ label: choice.labelKey === undefined ? String(choice.value) : t(choice.labelKey),
369
677
  }));
370
678
  return h('div', { style: { minWidth: 0 } },
371
679
  h('div', { style: ROW_STYLE },
372
- h('span', { style: LABEL_STYLE }, t('esEncodingLabel')),
373
- overridden ? h(Tag, { tone: 'neutral' }, t('overridden')) : null,
374
- overridden
375
- ? h(Button, { variant: 'ghost', size: 'sm', disabled, onClick: onReset }, t('reset'))
376
- : null,
680
+ h('span', { style: LABEL_STYLE }, label),
377
681
  ),
378
682
  h('div', { style: { marginTop: '6px' } },
379
683
  h(SegmentedControl, {
380
684
  id,
381
- value: normalizeEncoding(value),
685
+ value: choiceValue(field, value),
382
686
  options,
383
- onChange,
384
- label: t('esEncodingLabel'),
687
+ onChange: onEdit,
688
+ label,
385
689
  disabled,
386
690
  }),
387
691
  ),
388
- h('p', { style: HINT_STYLE }, t('esEncodingHint')),
692
+ h('p', { style: HINT_STYLE }, t(field.hintKey)),
693
+ invalid ? h('p', { role: 'alert', style: HINT_STYLE }, t('invalidValue')) : null,
389
694
  );
390
695
  }
391
696
 
392
697
  /**
393
- * Render the configuration form shared by both surfaces.
394
- * @param props - locale, view, and the configuration form
395
- * @returns the page's React node
698
+ * 两种表面共用的配置表单。
699
+ * @param props - 语言、视图与配置表单
700
+ * @returns 页面的 React 节点
396
701
  */
397
702
  function ConfigPage(props) {
398
703
  const { t, form, view } = props;
399
704
  const [staged, setStaged] = useState({});
400
705
  const [saving, setSaving] = useState(false);
401
706
  const [failed, setFailed] = useState(false);
707
+ // 探测:idle → running → done / rejected(地址被拒)/ path-missing(es.exe 不在)/
708
+ // path-not-file(es.exe 位置上是个目录)/ failed。
402
709
  const [probe, setProbe] = useState({ status: 'idle' });
710
+ const [described, setDescribed] = useState({ status: 'idle' });
711
+ const [reload, setReload] = useState(0);
712
+ // 保存前校验:idle → running → idle / failed。失败时由弹窗问一次「仍然保存」。
713
+ const [check, setCheck] = useState({ status: 'idle' });
714
+ // 只在没有 Modal 的部署里用:按过一次「仍然保存」后,下一次保存跳过校验。
715
+ const [override, setOverride] = useState(false);
716
+ // 统一重置:idle → running → done / failed。没有 Modal 的部署才多一个 confirm(点两下)。
717
+ const [resetState, setResetState] = useState({ status: 'idle' });
718
+ // 开着的弹窗:{ kind: 'reset' }、{ kind: 'save-anyway', message }、
719
+ // { kind: 'blocked', message } 或 { kind: 'probe', field }。
720
+ const [dialog, setDialog] = useState(null);
721
+ // 失焦校验的结论:路径键 → 'missing' | 'not-file' | 'not-dir' | 'bad-url'。值一改就清掉。
722
+ const [pathChecks, setPathChecks] = useState({});
723
+ // 列数由宽度决定:656px 以下一列,以上两列,两列时每列 1fr(全屏就是 2×542)。
724
+ const [wide, setWide] = useState(true);
403
725
 
404
- // A summary render receives no form, so it must not claim a value state.
726
+ useEffect(() => {
727
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined;
728
+ const query = window.matchMedia(WIDE_QUERY);
729
+ setWide(query.matches);
730
+ const onChange = (event) => setWide(event.matches);
731
+ query.addEventListener('change', onChange);
732
+ return () => query.removeEventListener('change', onChange);
733
+ }, []);
734
+
735
+ // 后端描述只在页面视图里取;拿不到就显示一行可重试的提示,而不是空白表单。
736
+ //
737
+ // 依赖数组里只能放「什么时候该重取」的因素(视图、重试次数),绝不能放结果状态:
738
+ // 一旦 status 进了依赖,effect 里那句「标记为读取中」会立刻触发一次 cleanup,把本次
739
+ // 请求的 live 置 false,于是请求回来时被丢掉,页面就永远停在「正在读取后端列表…」。
740
+ useEffect(() => {
741
+ if (view !== 'page') return undefined;
742
+ let live = true;
743
+ fetch(describeUrl(), { cache: 'no-store' })
744
+ .then((response) => {
745
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
746
+ return response.json();
747
+ })
748
+ .then((body) => {
749
+ if (live) setDescribed({ status: 'done', body });
750
+ })
751
+ .catch((error) => {
752
+ if (!live) return;
753
+ setDescribed({ status: 'failed', message: error instanceof Error ? error.message : String(error) });
754
+ });
755
+ return () => {
756
+ live = false;
757
+ };
758
+ }, [view, reload]);
759
+
760
+ // 摘要渲染拿不到 form,所以它不能假装持有值状态。
405
761
  if (view === 'summary') return h('span', null, t('description'));
406
762
 
407
- // A deployment whose primitives predate one of these renders the
408
- // unavailable line instead of throwing inside the host page's tree.
409
- // Every entry must be present, but not a plain function: `Button` is a
410
- // `forwardRef` object, so a typeof check would reject every deployment.
411
- const primitivesReady = [SettingsForm, SettingsValueField, Button, SegmentedControl, Tag]
763
+ // 部署里的 primitives 可能比这些控件旧:这时显示「不可用」那一行,而不是让宿主页面
764
+ // 的树里抛异常。每一项都必须在,但不能按「是不是函数」判断——`Button` 是
765
+ // `forwardRef` 的返回值(对象)。
766
+ const primitivesReady = [SettingsForm, SettingsValueField, Button, SegmentedControl]
412
767
  .every((primitive) => primitive !== undefined && primitive !== null);
413
768
  if (!primitivesReady) {
414
769
  return h('p', { role: 'status' }, t('unavailable'));
415
770
  }
416
771
 
417
772
  const values = form?.state.value ?? {};
418
- const user = form?.state.user ?? {};
419
773
  const writable = form?.state.writable ?? false;
420
- const invalid = invalidField(staged);
774
+ // 弹窗与 `Toast` 一样不是每个部署都有:没有就退回页内提示,而不是让这棵 React 树抛异常。
775
+ const dialogReady = Modal !== undefined && Modal !== null;
776
+ const sections = described.status === 'done' ? described.body.sections ?? [] : [];
777
+ const resetInfo = described.status === 'done' ? described.body.reset : undefined;
778
+ // 所有后端的所有字段,按路径键索引:校验和取值都要用它,不能只看当前选中后端的。
779
+ const fields = new Map();
780
+ for (const section of sections) {
781
+ for (const field of section.fields ?? []) fields.set(pathKey(field.path), field);
782
+ }
783
+ const invalid = invalidField(staged, fields);
421
784
  const dirty = Object.keys(staged).length > 0;
422
- const busy = saving || probe.status === 'running';
785
+ const busy = saving || probe.status === 'running' || check.status === 'running' || resetState.status === 'running';
423
786
  const disabled = !writable || busy;
424
787
 
425
- const edit = (key, text) => setStaged((previous) => ({ ...previous, [key]: { op: 'set', text } }));
426
- // Reset toggles: pressing it again puts the field back to its edited
427
- // state, so a staged reset is never a trap.
428
- const reset = (key) => setStaged((previous) => {
429
- if (previous[key]?.op === 'unset') {
788
+ const edit = (path, text, numeric) => {
789
+ const key = pathKey(path);
790
+ // 清空 = unset(回落到 profile 层或默认值),不是写一个空覆盖值把 profile 挡住。
791
+ const cleared = String(text ?? '').trim() === '';
792
+ setStaged((previous) => ({
793
+ ...previous,
794
+ [key]: cleared ? { op: 'unset' } : { op: 'set', text, numeric: numeric === true },
795
+ }));
796
+ // 草稿一变,上一次的「仍然保存」、校验结论、失焦结论与开着的弹窗就都过期了。
797
+ setCheck({ status: 'idle' });
798
+ setOverride(false);
799
+ setResetState({ status: 'idle' });
800
+ setDialog(null);
801
+ clearPathCheck(key);
802
+ };
803
+ const valueOf = (path) => {
804
+ const entry = staged[pathKey(path)];
805
+ if (entry === undefined) return pathValue(values, path);
806
+ // 清空的字段显示成空串——别把已保存的值弹回输入框,那会让「清空」看起来没生效。
807
+ return entry.op === 'unset' ? '' : entry.text;
808
+ };
809
+ // 选中的后端:草稿优先,空值回落到 describe 的第一个后端。
810
+ const backendOf = (section) => {
811
+ if (section.backendPath === undefined) return undefined;
812
+ const value = String(valueOf(section.backendPath) ?? '');
813
+ if (value !== '') return value;
814
+ return section.backends?.[0]?.name;
815
+ };
816
+ const visibleFields = (section) => (section.fields ?? [])
817
+ .filter((field) => field.backend === undefined || field.backend === backendOf(section));
818
+
819
+ const clearPathCheck = (key) => {
820
+ setPathChecks((previous) => {
821
+ if (previous[key] === undefined) return previous;
430
822
  const next = { ...previous };
431
823
  delete next[key];
432
824
  return next;
825
+ });
826
+ };
827
+
828
+ /**
829
+ * 失焦时判一次这个字段:路径问 Host 还在不在,地址在本地判形状。
830
+ *
831
+ * 边打字边校验会在半截值上一直报错,所以只在这里做;路径那条请求带 `checkOnly`,
832
+ * 不花一次 es.exe 启动。取不到答案(网络失败)时不标红——那是 Host 的问题,不是值的问题。
833
+ * @param field - describe 给的字段
834
+ */
835
+ const checkField = async (field) => {
836
+ const key = pathKey(field.path);
837
+ const text = String(valueOf(field.path) ?? '').trim();
838
+ if (field.localUrl === true) {
839
+ if (text === '' || isLoopbackUrl(text)) clearPathCheck(key);
840
+ else setPathChecks((previous) => ({ ...previous, [key]: 'bad-url' }));
841
+ return;
433
842
  }
434
- return { ...previous, [key]: { op: 'unset', text: '' } };
435
- });
436
- const valueOf = (key) => {
437
- const entry = staged[key];
438
- return entry === undefined ? String(values[key] ?? '') : entry.text;
843
+ const entries = draftEntries(field, text);
844
+ if (entries.length === 0) {
845
+ clearPathCheck(key);
846
+ return;
847
+ }
848
+ const params = new URLSearchParams();
849
+ for (const [name, value] of entries) params.append(name, value);
850
+ params.set(CHECK_ONLY_PARAM, '1');
851
+ let reason;
852
+ try {
853
+ const response = await fetch(`${api(STATUS_ROUTE)}?${params}`, { cache: 'no-store' });
854
+ if (response.ok) {
855
+ clearPathCheck(key);
856
+ return;
857
+ }
858
+ const body = await response.json().catch(() => undefined);
859
+ reason = body?.code === 'ES_PATH_NOT_FILE' ? 'not-file'
860
+ : body?.code === 'DIR_NOT_DIRECTORY' ? 'not-dir' : 'missing';
861
+ } catch {
862
+ return;
863
+ }
864
+ setPathChecks((previous) => ({ ...previous, [key]: reason }));
439
865
  };
440
- // The badge and the reset offer follow the saved user layer, not the
441
- // staged edit: hiding them the moment a reset is staged would leave no
442
- // way back before saving.
443
- const overriddenOf = (key) => Object.prototype.hasOwnProperty.call(user, key);
444
-
445
- const textField = (key) => h(SettingsValueField, {
446
- key,
447
- id: `plugin-config-local-search-${key}`,
448
- label: t(`${key}Label`),
449
- hint: t(`${key}Hint`),
450
- text: valueOf(key),
451
- overridden: overriddenOf(key),
452
- invalid: invalid === key,
453
- overriddenLabel: t('overridden'),
454
- resetLabel: t('reset'),
455
- invalidLabel: t(FIELDS[key].kind === 'count' ? 'invalidNumber' : 'invalidValue'),
456
- disabled,
457
- numeric: FIELDS[key].kind === 'count',
458
- onEdit: (next) => edit(key, next),
459
- onReset: () => reset(key),
460
- });
461
866
 
462
- // The field primitive draws its row separator with `.field + .field`, which
463
- // would paint a half-width rule inside a grid cell. Every field therefore
464
- // gets a plain wrapper, the way the official subagent page does it.
465
- const cell = (key, node) => h('div', { key, style: { minWidth: 0 } }, node);
867
+ // 字段原件用 `.field + .field` 画分隔线,直接进网格会在格子里画一条半宽横线。
868
+ // 所以每个字段都包一层普通 div,官方 subagent 页也是这么做的。失焦挂在这一层上:
869
+ // 原件没有 onBlur 入参,而 focusout 会从输入框冒到这里。
870
+ const cell = (field) => h('div', {
871
+ key: pathKey(field.path),
872
+ style: CELL_STYLE,
873
+ onBlur: () => { void checkField(field); },
874
+ }, fieldNode(field));
466
875
 
467
- const save = async () => {
876
+ /** 失焦结论 → 红字文案。 */
877
+ const invalidCopy = (field, reason) => {
878
+ if (reason === 'not-file') return t('invalidNotFile');
879
+ if (reason === 'not-dir') return t('invalidNotDir');
880
+ if (reason === 'bad-url') return t('invalidLocal');
881
+ if (field.verifyPath === true) return t('invalidFile');
882
+ if (field.verifyDir === true) return t('invalidDir');
883
+ return t('invalidValue');
884
+ };
885
+
886
+ // 字段上不挂徽标与重置:这个插件只有一个写入面,逐行标「本页设置」没有信息量;
887
+ // 回默认值交给描述下方那个统一按钮,那里能一次说清会动到什么。
888
+ const textField = (field) => {
889
+ const key = pathKey(field.path);
890
+ // 失焦校验的结论与整数越界共用原件的 invalid:原件会把说明换成这行红字。
891
+ const reason = pathChecks[key];
892
+ const invalidLabel = invalid === key ? t('invalidNumber') : invalidCopy(field, reason);
893
+ return h(SettingsValueField, {
894
+ key,
895
+ id: `plugin-config-local-search-${key}`,
896
+ label: t(field.labelKey),
897
+ hint: t(field.hintKey),
898
+ text: String(valueOf(field.path) ?? ''),
899
+ overridden: false,
900
+ invalid: reason !== undefined || invalid === key,
901
+ invalidLabel,
902
+ disabled,
903
+ numeric: field.type === 'count',
904
+ onEdit: (next) => edit(field.path, next, field.type === 'count'),
905
+ });
906
+ };
907
+
908
+ const fieldNode = (field) => (field.type === 'choice'
909
+ ? h(ChoiceField, {
910
+ key: pathKey(field.path),
911
+ t,
912
+ field,
913
+ id: `plugin-config-local-search-${pathKey(field.path)}`,
914
+ value: valueOf(field.path),
915
+ invalid: invalid === pathKey(field.path),
916
+ disabled,
917
+ onEdit: (next) => edit(field.path, next, false),
918
+ })
919
+ : textField(field));
920
+
921
+ /**
922
+ * 保存前问一次 Host:草稿里的地址能通、`es.exe` 路径真的存在。
923
+ *
924
+ * 校验只覆盖「能安全校验」的字段(Host 侧不 spawn 任何草稿路径)。返回 ok 就直接写;
925
+ * 失败时不写,只把结论挂出来。
926
+ *
927
+ * `hard` 区分两种失败:400(路径上没有文件 / 目录不存在 / 地址被拒)是值本身错了,
928
+ * 没有「稍后会好」的可能,给不出「仍然保存」;引擎不可用与请求失败是暂时的,可以强行保存。
929
+ * @returns 校验结论、是否硬错,与失败文案
930
+ */
931
+ const verify = async (draft) => {
932
+ const query = verifyQuery(draft, fields);
933
+ if (query === '') return { ok: true };
934
+ setCheck({ status: 'running' });
935
+ let body;
936
+ let response;
937
+ try {
938
+ response = await fetch(`${api(STATUS_ROUTE)}?${query}`, { cache: 'no-store' });
939
+ body = await response.json().catch(() => undefined);
940
+ } catch (error) {
941
+ return { ok: false, reason: `${t('testFailed')}: ${error instanceof Error ? error.message : String(error)}` };
942
+ }
943
+ if (response.status === 400) {
944
+ const reason = body?.code === 'ES_PATH_REJECTED' ? t('savePathMissing')
945
+ : body?.code === 'ES_PATH_NOT_FILE' ? t('savePathNotFile')
946
+ : body?.code === 'DIR_REJECTED' ? t('saveDirMissing')
947
+ : body?.code === 'DIR_NOT_DIRECTORY' ? t('saveDirNotDir') : t('saveEndpointInvalid');
948
+ return { ok: false, hard: true, reason };
949
+ }
950
+ if (!response.ok) return { ok: false, reason: `${t('testFailed')}: HTTP ${response.status}` };
951
+ if (body?.ok === true) return { ok: true, drafts: body.drafts };
952
+ const down = [body?.everything?.ok === false ? t('everythingDown') : '', body?.anytxt?.ok === false ? t('anytxtDown') : '']
953
+ .filter((line) => line !== '');
954
+ return { ok: false, reason: `${t('saveEngineDown')}${down.join(t('listSeparator'))}` };
955
+ };
956
+
957
+ /**
958
+ * 重置的原子写入:清掉引擎与上限,再按探测结果把 esPath 与 Anytxt 地址填回去。
959
+ *
960
+ * 它就是「把首用自动配置重跑一遍」。`scopes` 不在清空列表里(那是手填的机器路径)。
961
+ */
962
+ const writeReset = async () => {
963
+ if (form === undefined || resetInfo === undefined) return;
964
+ setResetState({ status: 'running' });
965
+ let probed;
966
+ try {
967
+ const response = await fetch(statusUrl(''), { cache: 'no-store' });
968
+ probed = response.ok ? await response.json() : undefined;
969
+ } catch {
970
+ probed = undefined;
971
+ }
972
+ const ops = (resetInfo.clear ?? []).map((path) => ({ op: 'unset', path }));
973
+ for (const fill of resetInfo.fills ?? []) {
974
+ const value = fillValue(probed, fill.from);
975
+ if (value === undefined) continue;
976
+ // 探到的值与默认一致就不写:写一条等于默认的覆盖,只会让 profile 变脏。
977
+ if (fill.default !== undefined && String(value) === String(fill.default)) continue;
978
+ ops.push({ op: 'set', path: fill.path, value });
979
+ }
980
+ if (resetInfo.marker !== undefined) ops.push({ op: 'set', path: resetInfo.marker, value: true });
981
+ let landed = false;
982
+ try {
983
+ landed = await form.mutate(ops);
984
+ } catch {
985
+ landed = false;
986
+ }
987
+ if (landed) {
988
+ setStaged({});
989
+ setPathChecks({});
990
+ }
991
+ setResetState({ status: landed ? 'done' : 'failed' });
992
+ };
993
+
994
+ /**
995
+ * 重置按钮:只负责开确认弹窗(或让没有 Modal 的部署点两下)。真写由弹窗里的确认触发。
996
+ */
997
+ const runReset = () => {
998
+ if (form === undefined || busy || resetInfo === undefined) return;
999
+ if (dialogReady) {
1000
+ setDialog({ kind: 'reset' });
1001
+ return;
1002
+ }
1003
+ if (resetState.status !== 'confirm') {
1004
+ setResetState({ status: 'confirm' });
1005
+ return;
1006
+ }
1007
+ void writeReset();
1008
+ };
1009
+
1010
+ /**
1011
+ * @param force - 用户在弹窗里选了「仍然保存」,跳过这次校验直接写
1012
+ */
1013
+ const save = async (force) => {
468
1014
  if (form === undefined || busy || !dirty || invalid !== undefined) return;
1015
+ // 路径先改成标准写法再校验、再写:`d:/alpha/` 落盘的是 `D:\alpha`,草稿也跟着更新,
1016
+ // 所以用户看到的就是存进去的样子。
1017
+ const { next: normalized, changed } = normalizeStaged(staged, fields);
1018
+ let draft = normalized;
1019
+ if (changed) setStaged(draft);
1020
+ // 没有 Modal 的部署靠按钮上的「仍然保存」表达同一件事:第二次按就不再校验。
1021
+ const skipCheck = force === true || (dialogReady !== true && override === true);
1022
+ if (skipCheck !== true) {
1023
+ const result = await verify(draft);
1024
+ if (!result.ok) {
1025
+ // 校验的 running 必须在这里收掉:它会算进 `busy`,否则接下来的写入会被自己挡住。
1026
+ setCheck({ status: 'idle' });
1027
+ // 没有 Modal 的部署沿用页内那一行红字;硬错则不再把保存按钮改成「仍然保存」。
1028
+ if (!dialogReady) {
1029
+ setCheck({ status: 'failed', message: `${result.reason}${t('saveNoWrite')}` });
1030
+ setOverride(result.hard !== true);
1031
+ return;
1032
+ }
1033
+ setDialog(result.hard === true
1034
+ ? { kind: 'blocked', message: `${result.reason}${t('fullStop')}` }
1035
+ : { kind: 'save-anyway', message: `${result.reason}${t('fullStop')}` });
1036
+ return;
1037
+ }
1038
+ // Host 会把草稿解析成磁盘上的真实写法(`d:/alpha` → `D:\Alpha`),回填后再落盘。
1039
+ const canonical = applyCanonical(draft, fields, result.drafts);
1040
+ if (canonical !== draft) {
1041
+ draft = canonical;
1042
+ setStaged(draft);
1043
+ }
1044
+ }
1045
+ setCheck({ status: 'idle' });
1046
+ setDialog(null);
469
1047
  setSaving(true);
470
1048
  setFailed(false);
471
1049
  let landed = false;
472
1050
  try {
473
- // No explicit revision: the form fences the write with the latest one
474
- // it knows, so this save cannot be rejected as stale by an older read.
475
- landed = await form.mutate(operations(staged));
1051
+ // 不显式带 revision:表单会用它知道的最新 revision 圈住这次写入,所以这个保存
1052
+ // 不会因为读到的是旧值而被判过期。
1053
+ landed = await form.mutate(operations(draft));
476
1054
  } catch {
477
1055
  landed = false;
478
1056
  }
479
1057
  setSaving(false);
480
- if (landed) setStaged({});
481
- else setFailed(true);
1058
+ if (landed) {
1059
+ setStaged({});
1060
+ setOverride(false);
1061
+ setPathChecks({});
1062
+ } else {
1063
+ setFailed(true);
1064
+ }
482
1065
  };
483
1066
 
484
- const runProbe = async () => {
1067
+ /** 输入框里的 `es.exe` 路径;空串表示用已保存的(或自动查找)。 */
1068
+ const esPathDraft = () => {
1069
+ for (const field of fields.values()) {
1070
+ if (field.verifyPath !== true) continue;
1071
+ const entries = draftEntries(field, valueOf(field.path));
1072
+ return entries.length === 0 ? '' : entries[0][1];
1073
+ }
1074
+ return '';
1075
+ };
1076
+
1077
+ const runProbe = async (field) => {
485
1078
  if (probe.status === 'running') return;
486
1079
  setProbe({ status: 'running' });
487
- const target = valueOf('anytxtUrl');
1080
+ const target = String(valueOf(field.path) ?? '').trim();
488
1081
  try {
489
- const response = await fetch(statusUrl(target), { cache: 'no-store' });
1082
+ // 连输入框里的 `es.exe` 路径一起交给 Host:它只查存在性,所以路径打错时这次
1083
+ // 测试会直接报出来,而不是拿已保存的那个(或自动找到的那个)报「正常」。
1084
+ const response = await fetch(statusUrl(target, esPathDraft()), { cache: 'no-store' });
490
1085
  const body = await response.json().catch(() => undefined);
491
- if (response.status === 400 && body?.code === 'ANYTXT_URL_REJECTED') {
492
- setProbe({ status: 'rejected' });
493
- return;
1086
+ if (response.status === 400) {
1087
+ if (body?.code === 'ANYTXT_URL_REJECTED') {
1088
+ setProbe({ status: 'rejected' });
1089
+ return;
1090
+ }
1091
+ // 路径的两种拒绝要分开说:不存在与「是个目录」是两种改法。
1092
+ if (body?.code === 'ES_PATH_REJECTED') {
1093
+ setProbe({ status: 'path-missing' });
1094
+ return;
1095
+ }
1096
+ if (body?.code === 'ES_PATH_NOT_FILE') {
1097
+ setProbe({ status: 'path-not-file' });
1098
+ return;
1099
+ }
494
1100
  }
495
1101
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
496
1102
  setProbe({ status: 'done', body });
@@ -499,47 +1105,231 @@ window.__ModuleLoader__.load({
499
1105
  }
500
1106
  };
501
1107
 
502
- // The Test control sits in the Anytxt field's own cell: it answers a
503
- // question about that endpoint, so it must not drift down the page. It
504
- // stays enabled while the form is dirty, because it probes the value in
505
- // the field rather than the stored one.
506
- const probeBlock = [
507
- h('div', { key: 'action', style: { marginTop: '8px' } },
1108
+ /**
1109
+ * 测试按钮:开弹窗并立刻探一次。没有 Modal 的部署把结果留在页内。
1110
+ * @param field - 带 `probeEndpoint` 的那个字段
1111
+ */
1112
+ const openProbe = (field) => {
1113
+ if (dialogReady) setDialog({ kind: 'probe', field });
1114
+ void runProbe(field);
1115
+ };
1116
+
1117
+ /**
1118
+ * 探测的单行结论:被拒的草稿、请求失败。
1119
+ *
1120
+ * 放进弹窗的 `description`,字号与「重置」「仍要保存?」的正文一致;探测跑完时没有
1121
+ * 单行结论(那时正文是列表)。
1122
+ * @returns 一行文字;没有时为 undefined
1123
+ */
1124
+ const probeMessage = () => {
1125
+ if (probe.status === 'path-missing') return `${t('savePathMissing')}${t('fullStop')}`;
1126
+ if (probe.status === 'path-not-file') return `${t('savePathNotFile')}${t('fullStop')}`;
1127
+ if (probe.status === 'rejected') return t('testLocalOnly');
1128
+ if (probe.status === 'failed') return `${t('testFailed')}${t('colon')}${probe.message}${t('fullStop')}`;
1129
+ return undefined;
1130
+ };
1131
+
1132
+ /**
1133
+ * 探测结果的列表:每个引擎一行,失败的那行下面再挂一句可照做的修法。
1134
+ * @returns 列表节点;探测没跑完时为空数组
1135
+ */
1136
+ const probeBody = () => {
1137
+ if (probe.status !== 'done') return [];
1138
+ const rows = probeResult(t, probe.body);
1139
+ return [h('ul', { key: 'status', style: PROBE_LIST_STYLE }, rows.map((row) => h('li', {
1140
+ key: row.key,
1141
+ style: PROBE_ITEM_STYLE,
1142
+ },
1143
+ h('span', null, row.text),
1144
+ row.fix === undefined ? null : h('span', { key: 'fix', style: PROBE_FIX_STYLE },
1145
+ row.fix.href === undefined
1146
+ ? row.fix.text
1147
+ : h('a', { href: row.fix.href, target: '_blank', rel: 'noreferrer noopener', style: LINK_STYLE }, row.fix.text)),
1148
+ )))];
1149
+ };
1150
+
1151
+ /**
1152
+ * 没有 `Modal` 的部署用的页内提示:列表或那一行结论,二者其一。
1153
+ */
1154
+ const probeLines = () => {
1155
+ const body = probeBody();
1156
+ if (body.length > 0) return body;
1157
+ const message = probeMessage();
1158
+ return message === undefined ? [] : [h('p', { key: 'message', style: HINT_STYLE }, message)];
1159
+ };
1160
+
1161
+ // 测试按钮只出现在带 `probeEndpoint` 的字段所在小节,且紧跟其网格:它回答的是那个
1162
+ // 地址的问题,不该随页面漂移。表单脏着也可用,因为它探测的是输入框里的值。
1163
+ // 有 Modal 的部署把结论放进弹窗;页内就不再重复一遍。
1164
+ const probeBlock = (section) => {
1165
+ const field = visibleFields(section).find((candidate) => candidate.probeEndpoint === true);
1166
+ if (field === undefined) return undefined;
1167
+ const block = [
1168
+ h('div', { key: 'action', style: { marginTop: '8px' } },
1169
+ h(Button, {
1170
+ variant: 'outline',
1171
+ size: 'sm',
1172
+ disabled,
1173
+ onClick: () => { openProbe(field); },
1174
+ }, probe.status === 'running' ? t('testing') : t('test')),
1175
+ ),
1176
+ ];
1177
+ if (!dialogReady) {
1178
+ const lines = probeLines();
1179
+ block.push(...(lines.length > 0 ? lines : [h('p', { key: 'message', style: HINT_STYLE }, t('testHint'))]));
1180
+ }
1181
+ return h('div', { key: 'probe' }, block);
1182
+ };
1183
+
1184
+ /**
1185
+ * 当前开着的那个弹窗。
1186
+ *
1187
+ * 全部用 primitives 的 `Modal`:它自己 portal 到 `document.body`,自带 Esc 关闭、Tab 焦点
1188
+ * 陷阱与关闭后的焦点归还,所以本包仍然一行 CSS 都不发。`title` 同时是对话框的可访问名,
1189
+ * `closeLabel` 是关闭按钮的名字,两个都得给。
1190
+ * @returns 弹窗节点;没开或部署没有 `Modal` 时为 null
1191
+ */
1192
+ const dialogNode = () => {
1193
+ if (!dialogReady || dialog === null) return null;
1194
+ const close = () => { setDialog(null); };
1195
+ const button = (key, variant, label, onClick, extra) => h(Button, {
1196
+ key, variant, onClick, ...extra,
1197
+ }, label);
1198
+ if (dialog.kind === 'reset') {
1199
+ return h(Modal, {
1200
+ key: 'dialog-reset',
1201
+ open: true,
1202
+ title: t('resetTitle'),
1203
+ closeLabel: t('close'),
1204
+ description: t('resetHint'),
1205
+ onClose: close,
1206
+ footer: [
1207
+ button('cancel', 'outline', t('cancel'), close),
1208
+ // 确认即关弹窗:进度由页面上那个按钮显示(重置中…),弹窗不该盖着一页按钮。
1209
+ button('confirm', 'primary', t('reset'), () => {
1210
+ setDialog(null);
1211
+ void writeReset();
1212
+ }),
1213
+ ],
1214
+ });
1215
+ }
1216
+ // 硬错没有「稍后会好」的可能:说清是哪个值不行,只给关闭。
1217
+ if (dialog.kind === 'blocked') {
1218
+ return h(Modal, {
1219
+ key: 'dialog-blocked',
1220
+ open: true,
1221
+ title: t('saveBlockedTitle'),
1222
+ closeLabel: t('close'),
1223
+ description: dialog.message,
1224
+ onClose: close,
1225
+ footer: [button('close', 'primary', t('close'), close)],
1226
+ });
1227
+ }
1228
+ if (dialog.kind === 'save-anyway') {
1229
+ return h(Modal, {
1230
+ key: 'dialog-save-anyway',
1231
+ open: true,
1232
+ title: t('saveAnywayTitle'),
1233
+ closeLabel: t('close'),
1234
+ description: dialog.message,
1235
+ onClose: close,
1236
+ footer: [
1237
+ button('cancel', 'outline', t('cancel'), close),
1238
+ button('confirm', 'primary', t('saveAnyway'), () => { void save(true); }),
1239
+ ],
1240
+ });
1241
+ }
1242
+ const body = probeBody();
1243
+ return h(Modal, {
1244
+ key: 'dialog-probe',
1245
+ open: true,
1246
+ title: t('test'),
1247
+ closeLabel: t('close'),
1248
+ // 单行结论走 description(和别的弹窗正文同一个字号),探测结果走列表。
1249
+ description: probe.status === 'running' ? t('testing') : probeMessage(),
1250
+ onClose: close,
1251
+ children: body.length > 0 ? h('div', { key: 'body', style: PROBE_BODY_STYLE }, body) : undefined,
1252
+ footer: [
1253
+ button('again', 'outline', t('testAgain'), () => { void runProbe(dialog.field); }, { disabled: probe.status === 'running' }),
1254
+ button('close', 'primary', t('close'), close),
1255
+ ],
1256
+ });
1257
+ };
1258
+
1259
+ const sectionNode = (section) => {
1260
+ const backends = section.backends ?? [];
1261
+ const header = backends.length === 0 ? undefined : h('div', { style: SELECTOR_STYLE },
1262
+ h(SegmentedControl, {
1263
+ value: backendOf(section),
1264
+ options: backends.map((backend) => ({
1265
+ value: backend.name,
1266
+ label: backend.labelKey === undefined ? backend.name : t(backend.labelKey),
1267
+ })),
1268
+ onChange: (next) => edit(section.backendPath, next, false),
1269
+ label: t(section.titleKey),
1270
+ disabled,
1271
+ }));
1272
+ return h(Section, {
1273
+ key: section.id,
1274
+ id: `plugin-config-local-search-${section.id}`,
1275
+ title: t(section.titleKey),
1276
+ header,
1277
+ grid: gridStyle(wide),
1278
+ footer: probeBlock(section),
1279
+ }, visibleFields(section).map((field) => cell(field)));
1280
+ };
1281
+
1282
+ // 描述还没到手时,表单至少要有内容:加载中一行,失败一行带重试。
1283
+ let children;
1284
+ if (described.status === 'failed') {
1285
+ children = [h('p', { key: 'describe', role: 'status', style: HINT_STYLE },
1286
+ `${t('describeFailed')} ${described.message} `,
508
1287
  h(Button, {
509
- variant: 'outline',
1288
+ variant: 'ghost',
510
1289
  size: 'sm',
511
- disabled,
512
- onClick: () => { void runProbe(); },
513
- }, probe.status === 'running' ? t('testing') : t('test')),
514
- ),
515
- ];
516
- if (probe.status === 'done') {
517
- const result = probeResult(t, probe.body);
518
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, result.text));
519
- if (result.guidance.length > 0) {
520
- probeBlock.push(h('p', { key: 'guidance', style: HINT_STYLE }, result.guidance.map((line, index) => [
521
- index > 0 ? ' · ' : '',
522
- line.href
523
- ? h('a', { key: line.key, href: line.href, target: '_blank', rel: 'noreferrer noopener', style: LINK_STYLE }, line.text)
524
- : h('span', { key: line.key }, line.text),
525
- ])));
1290
+ onClick: () => {
1291
+ setDescribed({ status: 'idle' });
1292
+ setReload((count) => count + 1);
1293
+ },
1294
+ }, t('retry')))];
1295
+ } else if (described.status === 'done') {
1296
+ const resetButton = h(Button, {
1297
+ key: 'reset',
1298
+ variant: 'primary',
1299
+ size: 'sm',
1300
+ disabled,
1301
+ onClick: () => { runReset(); },
1302
+ }, resetState.status === 'running'
1303
+ ? t('resetting')
1304
+ : resetState.status === 'confirm' ? t('resetConfirm') : t('reset'));
1305
+ children = [
1306
+ ...(resetInfo === undefined ? [] : [h('div', { key: 'reset-1', style: RESET_TOP_STYLE }, resetButton)]),
1307
+ ...sections.map(sectionNode),
1308
+ h('p', { key: 'note', style: HINT_STYLE }, t('note')),
1309
+ ];
1310
+ if (resetState.status === 'confirm') {
1311
+ children = [...children, h('p', { key: 'reset-hint', style: NOTICE_STYLE }, t('resetHint'))];
1312
+ } else if (resetState.status === 'done') {
1313
+ children = [...children, h('p', { key: 'reset-done', style: NOTICE_STYLE }, t('resetDone'))];
1314
+ } else if (resetState.status === 'failed') {
1315
+ children = [...children, h('p', { key: 'reset-failed', role: 'alert', style: NOTICE_STYLE }, t('resetFailed'))];
526
1316
  }
527
- } else if (probe.status === 'rejected') {
528
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, t('testLocalOnly')));
529
- } else if (probe.status === 'failed') {
530
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE },
531
- `${t('testFailed')}: ${probe.message}`, ' ',
532
- h('span', null, `${t('testWhere')} ${api(STATUS_ROUTE)}`)));
533
1317
  } else {
534
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, t('testHint')));
1318
+ children = [h('p', { key: 'describe', role: 'status', style: HINT_STYLE }, t('describeLoading'))];
1319
+ }
1320
+ // 没有 Modal 的部署才把校验结论留在页内;有弹窗时结论就在弹窗里。
1321
+ if (check.status === 'failed') {
1322
+ children = [...children, h('p', { key: 'check', role: 'alert', style: NOTICE_STYLE }, check.message)];
535
1323
  }
1324
+ const modal = dialogNode();
1325
+ if (modal !== null) children = [...children, modal];
536
1326
 
537
1327
  return h(SettingsForm, {
538
1328
  labels: {
539
1329
  unavailable: t('unavailable'),
540
1330
  readOnly: t('readOnly'),
541
1331
  saveFailed: t('saveFailed'),
542
- save: t('save'),
1332
+ save: override ? t('saveAnyway') : t('save'),
543
1333
  saving: t('saving'),
544
1334
  },
545
1335
  state: {
@@ -551,49 +1341,44 @@ window.__ModuleLoader__.load({
551
1341
  failed,
552
1342
  },
553
1343
  onSave: () => { void save(); },
554
- // SettingsForm calls this only on unmount; the draft belongs to this
555
- // component instance, so a fresh mount starts clean either way.
1344
+ // SettingsForm 只在卸载时调它;草稿属于这个组件实例,重新挂载本来就是干净的。
556
1345
  onDiscard: () => { setStaged({}); },
557
- children: [
558
- h(Section, { key: 'everything', id: 'plugin-config-local-search-everything', title: t('sectionEverything') }, [
559
- cell('esPath', textField('esPath')),
560
- cell('esEncoding', h(EncodingField, {
561
- t,
562
- id: 'plugin-config-local-search-esEncoding',
563
- value: valueOf('esEncoding'),
564
- overridden: overriddenOf('esEncoding'),
565
- disabled,
566
- onChange: (next) => edit('esEncoding', next),
567
- onReset: () => reset('esEncoding'),
568
- })),
569
- ]),
570
- h(Section, {
571
- key: 'anytxt',
572
- id: 'plugin-config-local-search-anytxt',
573
- title: t('sectionAnytxt'),
574
- grid: false,
575
- }, [
576
- cell('anytxt', [textField('anytxtUrl'), ...probeBlock]),
577
- ]),
578
- h(Section, { key: 'scopes', id: 'plugin-config-local-search-scopes', title: t('sectionScopes') }, [
579
- cell('workspaceRoot', textField('workspaceRoot')),
580
- cell('workRoot', textField('workRoot')),
581
- cell('personalRoot', textField('personalRoot')),
582
- cell('allRoots', textField('allRoots')),
583
- ]),
584
- h(Section, { key: 'limits', id: 'plugin-config-local-search-limits', title: t('sectionLimits') }, [
585
- cell('maxResults', textField('maxResults')),
586
- cell('readMaxChars', textField('readMaxChars')),
587
- ]),
588
- h('p', { key: 'note', style: HINT_STYLE }, t('note')),
589
- ],
1346
+ children,
590
1347
  });
591
1348
  }
592
1349
 
593
1350
  /**
594
- * Mount the configuration card into the Plugins page's Official plugins
595
- * group, the way dsh-github contributes its card.
596
- * @param ctx - the browser plugin context
1351
+ * 首用探测发现引擎不可用时的提示。
1352
+ *
1353
+ * 只在 primitives 提供 `Toast` 时出现:本包不发布 CSS,没有 `Toast` 的部署就干脆不提示,
1354
+ * 而不是自己画一个带内联样式的浮层。
1355
+ */
1356
+ function EngineNotice(props) {
1357
+ const { t, engines, onDone } = props;
1358
+ if (Toast === undefined || Toast === null) return null;
1359
+ return h(Toast, { text: `${t('noticeEnginesDown')}${engines.join(', ')}`, holdMs: 6000, onDone });
1360
+ }
1361
+
1362
+ /** 这个浏览器会话里提示过没有。 */
1363
+ function noticeSeen() {
1364
+ try {
1365
+ return window.sessionStorage?.getItem(NOTICE_SEEN_KEY) === '1';
1366
+ } catch {
1367
+ return false;
1368
+ }
1369
+ }
1370
+
1371
+ function markNoticeSeen() {
1372
+ try {
1373
+ window.sessionStorage?.setItem(NOTICE_SEEN_KEY, '1');
1374
+ } catch {
1375
+ // 隐私模式下 sessionStorage 会抛,提示退化成每次启动都出现。
1376
+ }
1377
+ }
1378
+
1379
+ /**
1380
+ * 挂载配置卡进 Plugins 页的 Official plugins 组,并在首用探测报错时提示一次。
1381
+ * @param ctx - 浏览器插件上下文
597
1382
  */
598
1383
  function apply(ctx) {
599
1384
  const t = ctx.locale.bind(NS);
@@ -605,17 +1390,91 @@ window.__ModuleLoader__.load({
605
1390
  label: () => t('itemLabel'),
606
1391
  locale: NS,
607
1392
  }, ConfigPage)), 'local-search: config card');
1393
+ ctx.effect(() => engineNotice(ctx, t), 'local-search: engine notice');
1394
+ }
1395
+
1396
+ /**
1397
+ * 等首用探测结束,若报告有引擎不可用且本会话还没提示过,就在 `shell.overlay` 里提示一次。
1398
+ *
1399
+ * 探测在 Host 侧异步跑,所以这里按小间隔轮询 describe,直到 `autoconfig.pending` 变 false
1400
+ * 或轮询次数用完;中途任何失败都安静退出。
1401
+ * @param ctx - 浏览器插件上下文
1402
+ * @param t - 字典查找
1403
+ * @returns 清理函数
1404
+ */
1405
+ function engineNotice(ctx, t) {
1406
+ let disposed = false;
1407
+ let disposeSlot;
1408
+ let timer;
1409
+
1410
+ const stop = () => {
1411
+ disposed = true;
1412
+ if (timer !== undefined) clearTimeout(timer);
1413
+ disposeSlot?.();
1414
+ disposeSlot = undefined;
1415
+ };
1416
+
1417
+ const show = (engines) => {
1418
+ // 没有 Toast 就不注册任何东西:本包不发 CSS,画不出一个像样的提示。
1419
+ if (Toast === undefined || Toast === null) return false;
1420
+ if (typeof ctx.slots?.inject !== 'function') return false;
1421
+ try {
1422
+ disposeSlot = ctx.slots.inject('shell.overlay', () => ctx.slots.register({
1423
+ name: 'shell.overlay',
1424
+ id: 'fishdivinity-local-search.engine-notice',
1425
+ order: 900,
1426
+ locale: NS,
1427
+ }, () => h(EngineNotice, { t, engines, onDone: stop })));
1428
+ return true;
1429
+ } catch {
1430
+ // 没有这个槽位的部署就不提示;也不会把「已提示」标上,免得换一个部署后永远不提示。
1431
+ return false;
1432
+ }
1433
+ };
1434
+
1435
+ const tick = async (attempt) => {
1436
+ if (disposed) return;
1437
+ let body;
1438
+ try {
1439
+ const response = await fetch(describeUrl(), { cache: 'no-store' });
1440
+ if (!response.ok || disposed) return;
1441
+ body = await response.json();
1442
+ } catch {
1443
+ return;
1444
+ }
1445
+ const autoconfig = body?.autoconfig;
1446
+ if (autoconfig === undefined || disposed) return;
1447
+ if (autoconfig.pending === true && attempt < NOTICE_POLL_LIMIT) {
1448
+ timer = setTimeout(() => { void tick(attempt + 1); }, NOTICE_POLL_MS);
1449
+ return;
1450
+ }
1451
+ const failures = Array.isArray(autoconfig.failures) ? autoconfig.failures : [];
1452
+ if (failures.length === 0 || noticeSeen()) return;
1453
+ if (show(failures.map((failure) => String(failure.engine ?? 'engine')))) markNoticeSeen();
1454
+ };
1455
+
1456
+ timer = setTimeout(() => { void tick(0); }, NOTICE_POLL_MS);
1457
+ return stop;
608
1458
  }
609
1459
 
610
1460
  exports.apply = apply;
611
1461
  exports.inject = inject;
612
1462
  exports.NS = NS;
613
1463
  exports.ITEM_ID = ITEM_ID;
1464
+ exports.DESCRIBE_ROUTE = DESCRIBE_ROUTE;
614
1465
  exports.STATUS_ROUTE = STATUS_ROUTE;
1466
+ exports.DRAFT_PARAM = DRAFT_PARAM;
1467
+ exports.DRAFT_PATH_PARAM = DRAFT_PATH_PARAM;
1468
+ exports.DRAFT_DIR_PARAM = DRAFT_DIR_PARAM;
615
1469
  exports.ConfigPage = ConfigPage;
616
- // Exposed so a test can prove these stay equal to the Host's own rules.
1470
+ // 暴露出来,让测试能证明它们与 Host 的规则一致 / 能把草稿翻成嵌套路径。
617
1471
  exports.LOOPBACK_HOSTS = LOOPBACK_HOSTS;
618
- exports.LIMITS = LIMITS;
1472
+ exports.internals = {
1473
+ pathKey, pathValue, operations, invalidField, choiceValue, verifyQuery, fillValue, EngineNotice,
1474
+ normalizeWinPath, normalizeWinPaths, normalizeStaged,
1475
+ };
1476
+ // 样式常量也暴露出来,测试才能钉住「用的是主题真有的 token」。
1477
+ exports.styles = { NOTICE_STYLE, LINK_STYLE, gridStyle, GRID_MAX_WIDTH, WIDE_QUERY, CELL_STYLE, RESET_TOP_STYLE };
619
1478
  exports.en = en;
620
1479
  exports.zh = zh;
621
1480
  return module.exports;