@fishdivinity/dsh-local-search 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/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,130 +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
- * The segment a saved code page selects. 0.1 accepted any label, so an
62
- * old `utf8` (or `gb18030`) must land on the right segment instead of
63
- * showing GBK and then rewriting the value on save.
64
- * @param value - the saved label
65
- * @returns `gbk` or `utf-8`
70
+ * 已保存地址允许指向的 host。这个 bundle 不能 import `lib/config.js`(普通脚本没有 ESM
71
+ * import),所以这份列表是 Host 那份的副本;有一条测试比对两者。
66
72
  */
67
- function normalizeEncoding(value) {
68
- const label = String(value ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
69
- return label === 'utf8' || label === 'utf' ? 'utf-8' : 'gbk';
70
- }
73
+ const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'];
71
74
 
72
- /** The editable fields. `count` fields are whole numbers with a hard cap. */
73
- const FIELDS = {
74
- esPath: { key: 'esPath', kind: 'text' },
75
- esEncoding: { key: 'esEncoding', kind: 'choice' },
76
- anytxtUrl: { key: 'anytxtUrl', kind: 'text' },
77
- workspaceRoot: { key: 'workspaceRoot', kind: 'text' },
78
- workRoot: { key: 'workRoot', kind: 'text' },
79
- personalRoot: { key: 'personalRoot', kind: 'text' },
80
- allRoots: { key: 'allRoots', kind: 'text' },
81
- maxResults: { key: 'maxResults', kind: 'count', max: 100 },
82
- readMaxChars: { key: 'readMaxChars', kind: 'count', max: 20000 },
83
- };
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;
84
81
 
85
- /** Per-field copy: [label, hint]. */
82
+ /** 逐字段文案:[标签, 说明]。键名与 Host describe 的 labelKey / hintKey 一一对应。 */
86
83
  const FIELD_COPY = {
87
84
  esPath: {
88
- en: ['es.exe path', 'Absolute path to es.exe; empty auto-detects PATH and the usual install folders.'],
89
- 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;留空自动查找。'],
90
87
  },
91
88
  esEncoding: {
92
- en: ['es.exe output encoding', 'Code page used to decode es.exe output: GBK for a zh-CN console, UTF-8 otherwise.'],
93
- 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。'],
94
91
  },
95
92
  anytxtUrl: {
96
- en: ['Anytxt endpoint', 'This machine only (127.0.0.1 / localhost); an address ending in :9920 also tries :9921.'],
97
- 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。'],
98
95
  },
99
96
  workspaceRoot: {
100
- en: ['Workspace root', 'Root for scope=workspace; empty follows the session working directory.'],
101
- zh: ['工作区根', 'scope=workspace 的根;留空跟随会话工作目录。'],
97
+ en: ['Workspace root', 'A full directory, e.g. D:\\Alpha; empty follows the working directory.'],
98
+ zh: ['工作区根', '完整目录,例如 D:\\Alpha;留空跟随当前工作目录。'],
102
99
  },
103
100
  workRoot: {
104
- en: ['Work root', 'Root for scope=work; the scope is unavailable while this is empty.'],
105
- 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;留空则这个范围不可用。'],
106
103
  },
107
104
  personalRoot: {
108
- en: ['Personal root', 'Root for scope=personal; the scope is unavailable while this is empty.'],
109
- zh: ['个人根', 'scope=personal 的根;留空时该 scope 不可用。'],
105
+ en: ['Personal root', 'A full directory; the scope is unavailable while empty.'],
106
+ zh: ['个人根', '完整目录;留空则这个范围不可用。'],
110
107
  },
111
108
  allRoots: {
112
- en: ['All-scope roots', 'Semicolon-separated directories for scope=all; empty uses C: and D: when they exist.'],
113
- 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:。'],
114
111
  },
115
112
  maxResults: {
116
- en: ['Results per call', 'Default result cap for both search tools; 1–100.'],
117
- zh: ['每次结果数', '两个检索工具的默认结果上限;1–100。'],
113
+ en: ['Results per call', 'Results per call, 1–100.'],
114
+ zh: ['每次结果数', '每次返回多少条,1–100。'],
118
115
  },
119
116
  readMaxChars: {
120
- en: ['Read window (characters)', 'Default window for local_search_read; 1–20000.'],
121
- zh: ['读取窗口(字符)', 'local_search_read 的默认窗口;1–20000。'],
117
+ en: ['Read window (characters)', 'Characters per read, 1–20,000.'],
118
+ zh: ['读取窗口(字符)', '每次读取多少字符,1–20,000。'],
122
119
  },
123
120
  };
124
121
 
125
- /** Type styles from theme tokens; a skin owns every colour and size it overrides. */
122
+ /** 版式样式,全部取自主题 token;皮肤要改就覆盖 token。 */
126
123
  const H3_STYLE = { margin: 0, fontSize: '13px', fontWeight: '600', lineHeight: '1.5', color: 'var(--dsw-alias-label-primary)' };
127
124
  const LABEL_STYLE = { fontSize: '13px', fontWeight: '600', color: 'var(--dsw-alias-label-primary)' };
128
125
  const HINT_STYLE = { margin: '4px 0 0', fontSize: '12px', lineHeight: '1.6', color: 'var(--dsw-alias-label-secondary)' };
129
- const LINK_STYLE = { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'none' };
130
- const ROW_STYLE = { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px' };
131
126
  /**
132
- * The field grid. A 460px minimum keeps two columns on a wide page and one
133
- * on a narrow one, so four paths read as two rows instead of four squeezed
134
- * columns; the four-path section is why the minimum is not the official
135
- * page's 220px.
127
+ * 被拦下的保存、以及重置的提示共用这一行样式:红色、正常字重。
128
+ *
129
+ * token 名必须是主题真有的:`--dsw-alias-state-warning-primary` 不存在,写错只会静默
130
+ * 回落到普通文字色(看起来像没上色)。状态的专用 token 是
131
+ * `--dsw-alias-state-{error,warn,success}-primary`。
136
132
  */
137
- const GRID_STYLE = {
138
- display: 'grid',
139
- gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 460px), 1fr))',
140
- gap: '16px',
141
- marginTop: '8px',
133
+ const NOTICE_STYLE = {
134
+ margin: '8px 0 0',
135
+ fontSize: '13px',
136
+ lineHeight: '1.6',
137
+ color: 'var(--dsw-alias-state-error-primary)',
142
138
  };
139
+ /**
140
+ * 探测结果的无序列表:一行一个引擎,失败的那行下面跟一句修法。
141
+ *
142
+ * 字号/行高/颜色照壳子弹窗正文那句 `_description`(14px / 22px / label-primary)写:
143
+ * 不写就会继承到别处的基准字号(16px),跟「重置」「仍要保存?」那些弹窗的正文差一档。
144
+ */
145
+ const PROBE_LIST_STYLE = {
146
+ margin: 0,
147
+ paddingLeft: '18px',
148
+ fontSize: '14px',
149
+ lineHeight: '22px',
150
+ color: 'var(--dsw-alias-label-primary)',
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
+
143
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' };
144
198
 
145
199
  function dictionary(copy) {
146
200
  const words = {
147
201
  description: copy('只读的 Everything 文件名检索与 Anytxt 文档内容检索。', 'Read-only Everything filename search and Anytxt document-content search.'),
148
202
  itemLabel: copy('本地搜索', 'Local Search'),
149
- sectionEverything: copy('Everything(文件名检索)', 'Everything (file names)'),
150
- sectionAnytxt: copy('Anytxt(文档内容检索)', 'Anytxt (document contents)'),
203
+ sectionFiles: copy('文件名检索', 'File names'),
204
+ sectionContent: copy('文件内容检索', 'File contents'),
151
205
  sectionScopes: copy('检索范围', 'Search scopes'),
152
206
  sectionLimits: copy('上限', 'Limits'),
207
+ engineEverything: 'Everything',
208
+ engineAnytxt: 'Anytxt',
153
209
  note: copy(
154
- '单次调用超时仍留在 profile 的 YAML:工具注册时就固定了截止时间。',
155
- '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.',
156
212
  ),
157
- overridden: copy('本页设置', 'Set on this page'),
158
- reset: copy('重置', 'Reset'),
159
213
  invalidValue: copy('这个值不合法。', 'Not a valid value.'),
160
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.'),
161
220
  unavailable: copy(
162
221
  '当前部署没有提供本插件的设置命名空间,请改在 profile 的 cordis.patch.yml 里配置。',
163
222
  "This deployment does not serve this plugin's settings; set the values in the profile's cordis.patch.yml instead.",
@@ -166,10 +225,40 @@ window.__ModuleLoader__.load({
166
225
  saveFailed: copy('宿主没有接受这些值。', 'The Host did not accept these values.'),
167
226
  save: copy('保存', 'Save'),
168
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'),
169
257
  encodingGbk: copy('GBK(简中)', 'GBK (zh-CN)'),
170
258
  encodingUtf8: 'UTF-8',
171
259
  test: copy('测试连接', 'Test connection'),
172
260
  testing: copy('测试中…', 'Testing…'),
261
+ testAgain: copy('再测一次', 'Test again'),
173
262
  testHint: copy(
174
263
  '用上面输入框里的地址试一次,不用先保存;只探测,不改配置。',
175
264
  'Probes the address above without saving it first; changes nothing.',
@@ -179,13 +268,25 @@ window.__ModuleLoader__.load({
179
268
  'The address must be a local http(s) url: 127.0.0.1, localhost or [::1].',
180
269
  ),
181
270
  testFailed: copy('测试请求失败', 'The test request failed'),
182
- testWhere: copy('测试请求发往', 'The test request goes to'),
183
271
  everythingOk: copy('Everything 正常', 'Everything: OK'),
184
272
  everythingDown: copy('Everything 不可用', 'Everything: DOWN'),
185
273
  anytxtOk: copy('Anytxt 正常', 'Anytxt: OK'),
186
274
  anytxtDown: copy('Anytxt 不可达', 'Anytxt: DOWN'),
187
275
  downloadEverything: copy('下载 Everything', 'Download Everything'),
188
276
  downloadAnytxt: copy('下载 Anytxt', 'Download Anytxt'),
277
+ fixEverythingNotRunning: copy(
278
+ '已安装但没在运行:启动它',
279
+ 'Installed but not running: start it',
280
+ ),
281
+ fixEverythingNotInstalled: copy(
282
+ '没装:先安装 Everything',
283
+ 'Not installed: install Everything first',
284
+ ),
285
+ fixEverythingEsMissing: copy(
286
+ '缺 es.exe:装上后填到上面的路径',
287
+ 'es.exe is missing: install it and set the path',
288
+ ),
289
+ noticeEnginesDown: copy('这些检索后端现在不可用:', 'These search backends are unavailable: '),
189
290
  };
190
291
  for (const [key, entry] of Object.entries(FIELD_COPY)) {
191
292
  words[`${key}Label`] = copy(entry.zh[0], entry.en[0]);
@@ -198,10 +299,9 @@ window.__ModuleLoader__.load({
198
299
  const zh = dictionary((zhText) => zhText);
199
300
 
200
301
  /**
201
- * A route under this page's own base URI, so a deployment mounted below
202
- * the origin root still reaches the Host's route.
203
- * @param path - absolute path on the Host
204
- * @returns the request path
302
+ * 本页 base URI 下的一条路由,因此挂载在 origin 子路径上的部署也能到达 Host。
303
+ * @param path - Host 上的绝对路径
304
+ * @returns 请求路径
205
305
  */
206
306
  function api(path) {
207
307
  const relative = String(path).replace(/^\/+/, '');
@@ -209,40 +309,249 @@ window.__ModuleLoader__.load({
209
309
  return new URL(relative, document.baseURI).pathname;
210
310
  }
211
311
 
312
+ /** 取后端描述。 */
313
+ function describeUrl() {
314
+ return api(DESCRIBE_ROUTE);
315
+ }
316
+
212
317
  /**
213
- * The probe request for the address currently in the field. A draft rides
214
- * the query, so testing never needs a save first; the Host accepts a draft
215
- * only as a loopback `http(s)` URL.
216
- * @param draft - the endpoint as typed, or empty to probe the saved value
217
- * @returns the request path, query included
318
+ * 探测请求的地址。草稿走查询串,所以测试永远不需要先保存;Host 只接受本机 `http(s)`
319
+ * 的地址、只查存在性的 `es.exe` 路径。
320
+ * @param draft - 输入框里的地址,空串表示探测已保存的值
321
+ * @param esPath - 输入框里的 `es.exe` 路径,空串表示用已保存的(或自动查找)
322
+ * @returns 请求路径,含查询串
218
323
  */
219
- function statusUrl(draft) {
324
+ function statusUrl(draft, esPath) {
220
325
  const path = api(STATUS_ROUTE);
221
- const trimmed = String(draft ?? '').trim();
222
- if (trimmed === '') return path;
223
- 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;
375
+ }
376
+
377
+ /**
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();
224
486
  }
225
487
 
226
488
  /**
227
- * The staged edits as one atomic operation list. A cleared field is an
228
- * `unset`, so it falls back to the profile layer instead of pinning an
229
- * empty override.
230
- * @param staged - key to `{ op: 'set', text }` or `{ op: 'unset' }`
231
- * @returns the operations a save submits
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 保存提交的操作
232
543
  */
233
544
  function operations(staged) {
234
545
  return Object.entries(staged).map(([key, entry]) => {
235
- if (entry.op === 'unset') return { op: 'unset', path: [key] };
236
- const field = FIELDS[key];
237
- 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 };
238
548
  });
239
549
  }
240
550
 
241
551
  /**
242
- * Whether a value is an `http(s)` URL on this machine — the only shape the
243
- * Host accepts, so the field refuses the same values.
244
- * @param value - candidate endpoint
245
- * @returns true for a loopback `http(s)` URL
552
+ * 值是否是本机 `http(s)` 地址——Host 只接受这种形状,所以字段先拒绝同一批值。
553
+ * @param value - 候选地址
554
+ * @returns 本机 `http(s)` 地址时为 true
246
555
  */
247
556
  function isLoopbackUrl(value) {
248
557
  let parsed;
@@ -253,21 +562,42 @@ window.__ModuleLoader__.load({
253
562
  }
254
563
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
255
564
  const host = parsed.hostname.replace(/^\[|\]$/g, '');
256
- return host === '127.0.0.1' || host === '::1' || host === 'localhost';
565
+ return LOOPBACK_HOSTS.includes(host);
566
+ }
567
+
568
+ /**
569
+ * 已保存的值该落在那一段上。
570
+ *
571
+ * 0.1 接受任意 `TextDecoder` 标签,所以旧的 `utf8`(或 `gb18030`)要落到正确的段,
572
+ * 而不是显示成 GBK 再在保存时把值改写。别名表由 Host 的 describe 给。
573
+ * @param field - describe 给的字段描述
574
+ * @param value - 已保存的值
575
+ * @returns 选中的 choice 值
576
+ */
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;
257
584
  }
258
585
 
259
586
  /**
260
- * The first staged field a save would have to refuse.
261
- * @param staged - key to staged edit
262
- * @returns the key, or undefined when every draft is acceptable
587
+ * 保存会被拒绝的第一个草稿字段。
588
+ *
589
+ * 只管「不借助文件系统就能判」的那些:整数范围在这里挡,地址留到失焦或保存前的 Host
590
+ * 校验(`isLoopbackUrl` 判得了形状,但把红字提前到每敲一个字符就跳出来只会烦人)。
591
+ * @param staged - 路径键到草稿
592
+ * @param fields - 路径键到 describe 字段
593
+ * @returns 路径键;全部可接受时 undefined
263
594
  */
264
- function invalidField(staged) {
595
+ function invalidField(staged, fields) {
265
596
  for (const [key, entry] of Object.entries(staged)) {
266
597
  if (entry.op === 'unset') continue;
267
- const trimmed = String(entry.text).trim();
268
- if (key === 'anytxtUrl' && trimmed !== '' && !isLoopbackUrl(trimmed)) return key;
269
- const field = FIELDS[key];
270
- if (field.kind === 'count') {
598
+ const field = fields.get(key);
599
+ if (field === undefined) continue;
600
+ if (field.type === 'count') {
271
601
  const value = Number(entry.text);
272
602
  if (!Number.isInteger(value) || value < 1 || value > field.max) return key;
273
603
  }
@@ -276,179 +606,497 @@ window.__ModuleLoader__.load({
276
606
  }
277
607
 
278
608
  /**
279
- * Render the probe result: one line per engine, plus where to install an
280
- * engine that is missing. Nothing here installs anything.
281
- * @param t - dictionary lookup
282
- * @param body - the route's JSON answer
283
- * @returns the result text and any guidance links
609
+ * 探测结果:一行一个引擎,通过的带版本,失败的不带错误码——修法比错误码有用。
610
+ *
611
+ * 修法挂在失败的那一行下面,所以两处都不再重复引擎名(原来两行结论下面拼一行
612
+ * 「Everything 已安装但没在运行:启动它。 · 下载 Anytxt」,既重复又对不上是哪个引擎)。
613
+ * @param t - 字典查找
614
+ * @param body - 路由的 JSON 回答
615
+ * @returns 每个引擎的状态行与(可选的)修法
284
616
  */
285
617
  function probeResult(t, body) {
286
618
  const everything = body.everything ?? {};
287
619
  const anytxt = body.anytxt ?? {};
288
- // One line, one clause per engine: a passing engine needs its version, a
289
- // failing one needs its code, and neither needs its prose message.
290
- const parts = [
291
- everything.ok
292
- ? `${t('everythingOk')}${everything.version ? ` ${everything.version}` : ''}`
293
- : `${t('everythingDown')} (${everything.code ?? 'UNKNOWN'})`,
294
- anytxt.ok
295
- ? t('anytxtOk')
296
- : `${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
+ },
297
633
  ];
298
- const guidance = [];
299
- if (!everything.ok) guidance.push({ key: 'everything', text: t('downloadEverything'), href: DOWNLOADS.everything });
300
- if (!anytxt.ok) guidance.push({ key: 'anytxt', text: t('downloadAnytxt'), href: DOWNLOADS.anytxt });
301
- return { text: parts.join(' · '), guidance };
302
634
  }
303
635
 
304
636
  /**
305
- * One section: a small heading, then fields on the official grid.
306
- * @param props - title, heading id, whether to use the grid, and the fields
307
- * @returns the section element
637
+ * 修好一次失败探测所需的唯一动作。
638
+ * @param t - 页面的翻译函数
639
+ * @param everything - 路由的 Everything 回答,含 `detail`
640
+ * @returns 一行指引,只有下载才是解法时才带链接
641
+ */
642
+ function everythingGuidance(t, everything) {
643
+ if (everything.detail === 'not-running') return { key: 'everything', text: t('fixEverythingNotRunning') };
644
+ if (everything.detail === 'not-installed') {
645
+ return { key: 'everything', text: t('fixEverythingNotInstalled'), href: DOWNLOADS.everything };
646
+ }
647
+ if (everything.detail === 'es-missing') {
648
+ return { key: 'everything', text: t('fixEverythingEsMissing'), href: DOWNLOADS.everything };
649
+ }
650
+ return { key: 'everything', text: t('downloadEverything'), href: DOWNLOADS.everything };
651
+ }
652
+
653
+ /**
654
+ * 一个小节:小标题、可选的后端选择器、官方网格上的字段,以及网格下的附加块。
655
+ * @param props - 标题、标题 id、选择器、网格样式、字段与页脚
656
+ * @returns 小节元素
308
657
  */
309
658
  function Section(props) {
310
659
  return h('section', { style: SECTION_STYLE, 'aria-labelledby': props.id },
311
660
  h('h3', { id: props.id, style: H3_STYLE }, props.title),
312
- props.grid === false
313
- ? h('div', { style: { marginTop: '8px' } }, props.children)
314
- : 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,
315
664
  );
316
665
  }
317
666
 
318
667
  /**
319
- * The encoding field. The value set is closed, so it is a control instead
320
- * of a text field the user could mistype into a silent UTF-8 fallback.
668
+ * 二选一字段。取值集合是封闭的,所以用控件而不是文本框——文本框会让用户敲出一个
669
+ * 静默回退到 UTF-8 的错值。
321
670
  */
322
- function EncodingField(props) {
323
- const { t, id, value, overridden, disabled, onChange, onReset } = props;
324
- const options = ENCODINGS.map((encoding) => ({
325
- value: encoding,
326
- 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),
327
677
  }));
328
678
  return h('div', { style: { minWidth: 0 } },
329
679
  h('div', { style: ROW_STYLE },
330
- h('span', { style: LABEL_STYLE }, t('esEncodingLabel')),
331
- overridden ? h(Tag, { tone: 'neutral' }, t('overridden')) : null,
332
- overridden
333
- ? h(Button, { variant: 'ghost', size: 'sm', disabled, onClick: onReset }, t('reset'))
334
- : null,
680
+ h('span', { style: LABEL_STYLE }, label),
335
681
  ),
336
682
  h('div', { style: { marginTop: '6px' } },
337
683
  h(SegmentedControl, {
338
684
  id,
339
- value: normalizeEncoding(value),
685
+ value: choiceValue(field, value),
340
686
  options,
341
- onChange,
342
- label: t('esEncodingLabel'),
687
+ onChange: onEdit,
688
+ label,
343
689
  disabled,
344
690
  }),
345
691
  ),
346
- 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,
347
694
  );
348
695
  }
349
696
 
350
697
  /**
351
- * Render the configuration form shared by both surfaces.
352
- * @param props - locale, view, and the configuration form
353
- * @returns the page's React node
698
+ * 两种表面共用的配置表单。
699
+ * @param props - 语言、视图与配置表单
700
+ * @returns 页面的 React 节点
354
701
  */
355
702
  function ConfigPage(props) {
356
703
  const { t, form, view } = props;
357
704
  const [staged, setStaged] = useState({});
358
705
  const [saving, setSaving] = useState(false);
359
706
  const [failed, setFailed] = useState(false);
707
+ // 探测:idle → running → done / rejected(地址被拒)/ path-missing(es.exe 不在)/
708
+ // path-not-file(es.exe 位置上是个目录)/ failed。
360
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);
361
725
 
362
- // 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,所以它不能假装持有值状态。
363
761
  if (view === 'summary') return h('span', null, t('description'));
364
762
 
365
- // A deployment whose primitives predate one of these renders the
366
- // unavailable line instead of throwing inside the host page's tree.
367
- // Every entry must be present, but not a plain function: `Button` is a
368
- // `forwardRef` object, so a typeof check would reject every deployment.
369
- const primitivesReady = [SettingsForm, SettingsValueField, Button, SegmentedControl, Tag]
763
+ // 部署里的 primitives 可能比这些控件旧:这时显示「不可用」那一行,而不是让宿主页面
764
+ // 的树里抛异常。每一项都必须在,但不能按「是不是函数」判断——`Button` 是
765
+ // `forwardRef` 的返回值(对象)。
766
+ const primitivesReady = [SettingsForm, SettingsValueField, Button, SegmentedControl]
370
767
  .every((primitive) => primitive !== undefined && primitive !== null);
371
768
  if (!primitivesReady) {
372
769
  return h('p', { role: 'status' }, t('unavailable'));
373
770
  }
374
771
 
375
772
  const values = form?.state.value ?? {};
376
- const user = form?.state.user ?? {};
377
773
  const writable = form?.state.writable ?? false;
378
- 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);
379
784
  const dirty = Object.keys(staged).length > 0;
380
- const busy = saving || probe.status === 'running';
785
+ const busy = saving || probe.status === 'running' || check.status === 'running' || resetState.status === 'running';
381
786
  const disabled = !writable || busy;
382
787
 
383
- const edit = (key, text) => setStaged((previous) => ({ ...previous, [key]: { op: 'set', text } }));
384
- // Reset toggles: pressing it again puts the field back to its edited
385
- // state, so a staged reset is never a trap.
386
- const reset = (key) => setStaged((previous) => {
387
- 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;
388
822
  const next = { ...previous };
389
823
  delete next[key];
390
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;
391
842
  }
392
- return { ...previous, [key]: { op: 'unset', text: '' } };
393
- });
394
- const valueOf = (key) => {
395
- const entry = staged[key];
396
- 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 }));
865
+ };
866
+
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));
875
+
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' });
397
992
  };
398
- // The badge and the reset offer follow the saved user layer, not the
399
- // staged edit: hiding them the moment a reset is staged would leave no
400
- // way back before saving.
401
- const overriddenOf = (key) => Object.prototype.hasOwnProperty.call(user, key);
402
-
403
- const textField = (key) => h(SettingsValueField, {
404
- key,
405
- id: `plugin-config-local-search-${key}`,
406
- label: t(`${key}Label`),
407
- hint: t(`${key}Hint`),
408
- text: valueOf(key),
409
- overridden: overriddenOf(key),
410
- invalid: invalid === key,
411
- overriddenLabel: t('overridden'),
412
- resetLabel: t('reset'),
413
- invalidLabel: t(FIELDS[key].kind === 'count' ? 'invalidNumber' : 'invalidValue'),
414
- disabled,
415
- numeric: FIELDS[key].kind === 'count',
416
- onEdit: (next) => edit(key, next),
417
- onReset: () => reset(key),
418
- });
419
993
 
420
- // The field primitive draws its row separator with `.field + .field`, which
421
- // would paint a half-width rule inside a grid cell. Every field therefore
422
- // gets a plain wrapper, the way the official subagent page does it.
423
- const cell = (key, node) => h('div', { key, style: { minWidth: 0 } }, node);
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
+ };
424
1009
 
425
- const save = async () => {
1010
+ /**
1011
+ * @param force - 用户在弹窗里选了「仍然保存」,跳过这次校验直接写
1012
+ */
1013
+ const save = async (force) => {
426
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);
427
1047
  setSaving(true);
428
1048
  setFailed(false);
429
1049
  let landed = false;
430
1050
  try {
431
- // No explicit revision: the form fences the write with the latest one
432
- // it knows, so this save cannot be rejected as stale by an older read.
433
- landed = await form.mutate(operations(staged));
1051
+ // 不显式带 revision:表单会用它知道的最新 revision 圈住这次写入,所以这个保存
1052
+ // 不会因为读到的是旧值而被判过期。
1053
+ landed = await form.mutate(operations(draft));
434
1054
  } catch {
435
1055
  landed = false;
436
1056
  }
437
1057
  setSaving(false);
438
- if (landed) setStaged({});
439
- else setFailed(true);
1058
+ if (landed) {
1059
+ setStaged({});
1060
+ setOverride(false);
1061
+ setPathChecks({});
1062
+ } else {
1063
+ setFailed(true);
1064
+ }
1065
+ };
1066
+
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 '';
440
1075
  };
441
1076
 
442
- const runProbe = async () => {
1077
+ const runProbe = async (field) => {
443
1078
  if (probe.status === 'running') return;
444
1079
  setProbe({ status: 'running' });
445
- const target = valueOf('anytxtUrl');
1080
+ const target = String(valueOf(field.path) ?? '').trim();
446
1081
  try {
447
- 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' });
448
1085
  const body = await response.json().catch(() => undefined);
449
- if (response.status === 400 && body?.code === 'ANYTXT_URL_REJECTED') {
450
- setProbe({ status: 'rejected' });
451
- 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
+ }
452
1100
  }
453
1101
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
454
1102
  setProbe({ status: 'done', body });
@@ -457,45 +1105,231 @@ window.__ModuleLoader__.load({
457
1105
  }
458
1106
  };
459
1107
 
460
- // The Test control sits in the Anytxt field's own cell: it answers a
461
- // question about that endpoint, so it must not drift down the page. It
462
- // stays enabled while the form is dirty, because it probes the value in
463
- // the field rather than the stored one.
464
- const probeBlock = [
465
- 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} `,
466
1287
  h(Button, {
467
- variant: 'outline',
1288
+ variant: 'ghost',
468
1289
  size: 'sm',
469
- disabled,
470
- onClick: () => { void runProbe(); },
471
- }, probe.status === 'running' ? t('testing') : t('test')),
472
- ),
473
- ];
474
- if (probe.status === 'done') {
475
- const result = probeResult(t, probe.body);
476
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, result.text));
477
- if (result.guidance.length > 0) {
478
- probeBlock.push(h('p', { key: 'guidance', style: HINT_STYLE }, result.guidance.map((line, index) => [
479
- index > 0 ? ' · ' : '',
480
- h('a', { key: line.key, href: line.href, target: '_blank', rel: 'noreferrer noopener', style: LINK_STYLE }, line.text),
481
- ])));
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'))];
482
1316
  }
483
- } else if (probe.status === 'rejected') {
484
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, t('testLocalOnly')));
485
- } else if (probe.status === 'failed') {
486
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE },
487
- `${t('testFailed')}: ${probe.message}`, ' ',
488
- h('span', null, `${t('testWhere')} ${api(STATUS_ROUTE)}`)));
489
1317
  } else {
490
- probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, t('testHint')));
1318
+ children = [h('p', { key: 'describe', role: 'status', style: HINT_STYLE }, t('describeLoading'))];
491
1319
  }
1320
+ // 没有 Modal 的部署才把校验结论留在页内;有弹窗时结论就在弹窗里。
1321
+ if (check.status === 'failed') {
1322
+ children = [...children, h('p', { key: 'check', role: 'alert', style: NOTICE_STYLE }, check.message)];
1323
+ }
1324
+ const modal = dialogNode();
1325
+ if (modal !== null) children = [...children, modal];
492
1326
 
493
1327
  return h(SettingsForm, {
494
1328
  labels: {
495
1329
  unavailable: t('unavailable'),
496
1330
  readOnly: t('readOnly'),
497
1331
  saveFailed: t('saveFailed'),
498
- save: t('save'),
1332
+ save: override ? t('saveAnyway') : t('save'),
499
1333
  saving: t('saving'),
500
1334
  },
501
1335
  state: {
@@ -507,49 +1341,44 @@ window.__ModuleLoader__.load({
507
1341
  failed,
508
1342
  },
509
1343
  onSave: () => { void save(); },
510
- // SettingsForm calls this only on unmount; the draft belongs to this
511
- // component instance, so a fresh mount starts clean either way.
1344
+ // SettingsForm 只在卸载时调它;草稿属于这个组件实例,重新挂载本来就是干净的。
512
1345
  onDiscard: () => { setStaged({}); },
513
- children: [
514
- h(Section, { key: 'everything', id: 'plugin-config-local-search-everything', title: t('sectionEverything') }, [
515
- cell('esPath', textField('esPath')),
516
- cell('esEncoding', h(EncodingField, {
517
- t,
518
- id: 'plugin-config-local-search-esEncoding',
519
- value: valueOf('esEncoding'),
520
- overridden: overriddenOf('esEncoding'),
521
- disabled,
522
- onChange: (next) => edit('esEncoding', next),
523
- onReset: () => reset('esEncoding'),
524
- })),
525
- ]),
526
- h(Section, {
527
- key: 'anytxt',
528
- id: 'plugin-config-local-search-anytxt',
529
- title: t('sectionAnytxt'),
530
- grid: false,
531
- }, [
532
- cell('anytxt', [textField('anytxtUrl'), ...probeBlock]),
533
- ]),
534
- h(Section, { key: 'scopes', id: 'plugin-config-local-search-scopes', title: t('sectionScopes') }, [
535
- cell('workspaceRoot', textField('workspaceRoot')),
536
- cell('workRoot', textField('workRoot')),
537
- cell('personalRoot', textField('personalRoot')),
538
- cell('allRoots', textField('allRoots')),
539
- ]),
540
- h(Section, { key: 'limits', id: 'plugin-config-local-search-limits', title: t('sectionLimits') }, [
541
- cell('maxResults', textField('maxResults')),
542
- cell('readMaxChars', textField('readMaxChars')),
543
- ]),
544
- h('p', { key: 'note', style: HINT_STYLE }, t('note')),
545
- ],
1346
+ children,
546
1347
  });
547
1348
  }
548
1349
 
549
1350
  /**
550
- * Mount the configuration card into the Plugins page's Official plugins
551
- * group, the way dsh-github contributes its card.
552
- * @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 - 浏览器插件上下文
553
1382
  */
554
1383
  function apply(ctx) {
555
1384
  const t = ctx.locale.bind(NS);
@@ -561,14 +1390,91 @@ window.__ModuleLoader__.load({
561
1390
  label: () => t('itemLabel'),
562
1391
  locale: NS,
563
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;
564
1458
  }
565
1459
 
566
1460
  exports.apply = apply;
567
1461
  exports.inject = inject;
568
1462
  exports.NS = NS;
569
1463
  exports.ITEM_ID = ITEM_ID;
1464
+ exports.DESCRIBE_ROUTE = DESCRIBE_ROUTE;
570
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;
571
1469
  exports.ConfigPage = ConfigPage;
1470
+ // 暴露出来,让测试能证明它们与 Host 的规则一致 / 能把草稿翻成嵌套路径。
1471
+ exports.LOOPBACK_HOSTS = LOOPBACK_HOSTS;
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 };
572
1478
  exports.en = en;
573
1479
  exports.zh = zh;
574
1480
  return module.exports;