@fishdivinity/dsh-local-search 0.1.0 → 0.2.1
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/LICENSE +21 -21
- package/README-zh.md +167 -166
- package/README.md +177 -176
- package/cordis.patch.yml +9 -9
- package/icon.svg +35 -7
- package/lib/anytxt.js +272 -252
- package/lib/client.js +623 -0
- package/lib/config.js +101 -0
- package/lib/engines.js +85 -85
- package/lib/errors.js +18 -18
- package/lib/everything.js +277 -219
- package/lib/format.js +101 -92
- package/lib/index.js +77 -71
- package/lib/limits.js +14 -0
- package/lib/probe.js +88 -0
- package/lib/ref.js +38 -38
- package/lib/roots.js +114 -86
- package/lib/route.js +170 -0
- package/lib/tools.js +498 -506
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +86 -65
package/lib/client.js
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-local-search browser half — the plugin's configuration page.
|
|
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.
|
|
7
|
+
*
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* The page receives `{ view, form }`: `view: 'summary'` is the card's one-liner,
|
|
15
|
+
* `view: 'page'` the form with its save control.
|
|
16
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
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`.
|
|
26
|
+
*
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
window.__ModuleLoader__.load({
|
|
32
|
+
id: '@fishdivinity/dsh-local-search',
|
|
33
|
+
factory: (require) => {
|
|
34
|
+
var module = { exports: {} };
|
|
35
|
+
var exports = module.exports;
|
|
36
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
37
|
+
const { createElement: h, useState } = require('react');
|
|
38
|
+
const {
|
|
39
|
+
Button, SegmentedControl, SettingsForm, SettingsValueField, Tag,
|
|
40
|
+
} = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
41
|
+
|
|
42
|
+
/** Dictionary namespace owned by this plugin. */
|
|
43
|
+
const NS = 'localSearchConfig';
|
|
44
|
+
/** The card id, which must be the settings namespace = the row id the bundle patch declares. */
|
|
45
|
+
const ITEM_ID = 'fishdivinity-local-search';
|
|
46
|
+
/** The Host's read-only probe route, resolved against this page's own base URI. */
|
|
47
|
+
const STATUS_ROUTE = '/dsh-local-search/api/status';
|
|
48
|
+
/** Client services this half requires; both are provided by the web shell. */
|
|
49
|
+
const inject = ['slots', 'locale'];
|
|
50
|
+
|
|
51
|
+
/** Where each engine is installed from. This plugin never downloads them. */
|
|
52
|
+
const DOWNLOADS = {
|
|
53
|
+
everything: 'https://www.voidtools.com/downloads/',
|
|
54
|
+
anytxt: 'https://anytxt.net/download/',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Code pages the page offers. The Host accepts any TextDecoder label. */
|
|
58
|
+
const ENCODINGS = ['gbk', 'utf-8'];
|
|
59
|
+
|
|
60
|
+
/**
|
|
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.
|
|
64
|
+
*/
|
|
65
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', '::1', 'localhost'];
|
|
66
|
+
|
|
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
|
+
}
|
|
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]. */
|
|
96
|
+
const FIELD_COPY = {
|
|
97
|
+
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 与常见安装目录查找。'],
|
|
100
|
+
},
|
|
101
|
+
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。'],
|
|
104
|
+
},
|
|
105
|
+
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。'],
|
|
108
|
+
},
|
|
109
|
+
workspaceRoot: {
|
|
110
|
+
en: ['Workspace root', 'Root for scope=workspace; empty follows the session working directory.'],
|
|
111
|
+
zh: ['工作区根', 'scope=workspace 的根;留空跟随会话工作目录。'],
|
|
112
|
+
},
|
|
113
|
+
workRoot: {
|
|
114
|
+
en: ['Work root', 'Root for scope=work; the scope is unavailable while this is empty.'],
|
|
115
|
+
zh: ['工作根', 'scope=work 的根;留空时该 scope 不可用。'],
|
|
116
|
+
},
|
|
117
|
+
personalRoot: {
|
|
118
|
+
en: ['Personal root', 'Root for scope=personal; the scope is unavailable while this is empty.'],
|
|
119
|
+
zh: ['个人根', 'scope=personal 的根;留空时该 scope 不可用。'],
|
|
120
|
+
},
|
|
121
|
+
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:。'],
|
|
124
|
+
},
|
|
125
|
+
maxResults: {
|
|
126
|
+
en: ['Results per call', `Default result cap for both search tools; 1–${LIMITS.maxResults}.`],
|
|
127
|
+
zh: ['每次结果数', `两个检索工具的默认结果上限;1–${LIMITS.maxResults}。`],
|
|
128
|
+
},
|
|
129
|
+
readMaxChars: {
|
|
130
|
+
en: ['Read window (characters)', `Default window for local_search_read; 1–${LIMITS.readMaxChars}.`],
|
|
131
|
+
zh: ['读取窗口(字符)', `local_search_read 的默认窗口;1–${LIMITS.readMaxChars}。`],
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Type styles from theme tokens; a skin owns every colour and size it overrides. */
|
|
136
|
+
const H3_STYLE = { margin: 0, fontSize: '13px', fontWeight: '600', lineHeight: '1.5', color: 'var(--dsw-alias-label-primary)' };
|
|
137
|
+
const LABEL_STYLE = { fontSize: '13px', fontWeight: '600', color: 'var(--dsw-alias-label-primary)' };
|
|
138
|
+
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
|
+
/**
|
|
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.
|
|
146
|
+
*/
|
|
147
|
+
const GRID_STYLE = {
|
|
148
|
+
display: 'grid',
|
|
149
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 460px), 1fr))',
|
|
150
|
+
gap: '16px',
|
|
151
|
+
marginTop: '8px',
|
|
152
|
+
};
|
|
153
|
+
const SECTION_STYLE = { minWidth: 0, padding: '16px 0' };
|
|
154
|
+
|
|
155
|
+
function dictionary(copy) {
|
|
156
|
+
const words = {
|
|
157
|
+
description: copy('只读的 Everything 文件名检索与 Anytxt 文档内容检索。', 'Read-only Everything filename search and Anytxt document-content search.'),
|
|
158
|
+
itemLabel: copy('本地搜索', 'Local Search'),
|
|
159
|
+
sectionEverything: copy('Everything(文件名检索)', 'Everything (file names)'),
|
|
160
|
+
sectionAnytxt: copy('Anytxt(文档内容检索)', 'Anytxt (document contents)'),
|
|
161
|
+
sectionScopes: copy('检索范围', 'Search scopes'),
|
|
162
|
+
sectionLimits: copy('上限', 'Limits'),
|
|
163
|
+
note: copy(
|
|
164
|
+
'单次调用超时仍留在 profile 的 YAML:工具注册时就固定了截止时间。',
|
|
165
|
+
'The per-call timeout stays in the profile YAML: each tool fixes its deadline when it registers.',
|
|
166
|
+
),
|
|
167
|
+
overridden: copy('本页设置', 'Set on this page'),
|
|
168
|
+
reset: copy('重置', 'Reset'),
|
|
169
|
+
invalidValue: copy('这个值不合法。', 'Not a valid value.'),
|
|
170
|
+
invalidNumber: copy('请填范围内的整数。', 'Enter a whole number in range.'),
|
|
171
|
+
unavailable: copy(
|
|
172
|
+
'当前部署没有提供本插件的设置命名空间,请改在 profile 的 cordis.patch.yml 里配置。',
|
|
173
|
+
"This deployment does not serve this plugin's settings; set the values in the profile's cordis.patch.yml instead.",
|
|
174
|
+
),
|
|
175
|
+
readOnly: copy('本部署的设置为只读。', 'This deployment stores settings read-only.'),
|
|
176
|
+
saveFailed: copy('宿主没有接受这些值。', 'The Host did not accept these values.'),
|
|
177
|
+
save: copy('保存', 'Save'),
|
|
178
|
+
saving: copy('保存中…', 'Saving…'),
|
|
179
|
+
encodingGbk: copy('GBK(简中)', 'GBK (zh-CN)'),
|
|
180
|
+
encodingUtf8: 'UTF-8',
|
|
181
|
+
test: copy('测试连接', 'Test connection'),
|
|
182
|
+
testing: copy('测试中…', 'Testing…'),
|
|
183
|
+
testHint: copy(
|
|
184
|
+
'用上面输入框里的地址试一次,不用先保存;只探测,不改配置。',
|
|
185
|
+
'Probes the address above without saving it first; changes nothing.',
|
|
186
|
+
),
|
|
187
|
+
testLocalOnly: copy(
|
|
188
|
+
'地址必须是本机 http(s):127.0.0.1、localhost 或 [::1]。',
|
|
189
|
+
'The address must be a local http(s) url: 127.0.0.1, localhost or [::1].',
|
|
190
|
+
),
|
|
191
|
+
testFailed: copy('测试请求失败', 'The test request failed'),
|
|
192
|
+
testWhere: copy('测试请求发往', 'The test request goes to'),
|
|
193
|
+
everythingOk: copy('Everything 正常', 'Everything: OK'),
|
|
194
|
+
everythingDown: copy('Everything 不可用', 'Everything: DOWN'),
|
|
195
|
+
anytxtOk: copy('Anytxt 正常', 'Anytxt: OK'),
|
|
196
|
+
anytxtDown: copy('Anytxt 不可达', 'Anytxt: DOWN'),
|
|
197
|
+
downloadEverything: copy('下载 Everything', 'Download Everything'),
|
|
198
|
+
downloadAnytxt: copy('下载 Anytxt', 'Download Anytxt'),
|
|
199
|
+
fixEverythingNotRunning: copy(
|
|
200
|
+
'Everything 已安装但没在运行:启动它。',
|
|
201
|
+
'Everything is installed but not running: start it.',
|
|
202
|
+
),
|
|
203
|
+
fixEverythingNotInstalled: copy(
|
|
204
|
+
'没有检测到 Everything:先安装它,再安装随附的 es.exe。',
|
|
205
|
+
'Everything is not installed: install it, then the es.exe that ships with it.',
|
|
206
|
+
),
|
|
207
|
+
fixEverythingEsMissing: copy(
|
|
208
|
+
'缺 es.exe:从 voidtools 下载后填到上面的 es.exe 路径。',
|
|
209
|
+
'es.exe is missing: download it and set the path above.',
|
|
210
|
+
),
|
|
211
|
+
};
|
|
212
|
+
for (const [key, entry] of Object.entries(FIELD_COPY)) {
|
|
213
|
+
words[`${key}Label`] = copy(entry.zh[0], entry.en[0]);
|
|
214
|
+
words[`${key}Hint`] = copy(entry.zh[1], entry.en[1]);
|
|
215
|
+
}
|
|
216
|
+
return words;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const en = dictionary((zhText, enText) => enText);
|
|
220
|
+
const zh = dictionary((zhText) => zhText);
|
|
221
|
+
|
|
222
|
+
/**
|
|
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
|
|
227
|
+
*/
|
|
228
|
+
function api(path) {
|
|
229
|
+
const relative = String(path).replace(/^\/+/, '');
|
|
230
|
+
if (typeof document === 'undefined') return `/${relative}`;
|
|
231
|
+
return new URL(relative, document.baseURI).pathname;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
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
|
|
240
|
+
*/
|
|
241
|
+
function statusUrl(draft) {
|
|
242
|
+
const path = api(STATUS_ROUTE);
|
|
243
|
+
const trimmed = String(draft ?? '').trim();
|
|
244
|
+
if (trimmed === '') return path;
|
|
245
|
+
return `${path}?anytxtUrl=${encodeURIComponent(trimmed)}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
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
|
|
254
|
+
*/
|
|
255
|
+
function operations(staged) {
|
|
256
|
+
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 };
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
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
|
|
268
|
+
*/
|
|
269
|
+
function isLoopbackUrl(value) {
|
|
270
|
+
let parsed;
|
|
271
|
+
try {
|
|
272
|
+
parsed = new URL(String(value));
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
|
|
277
|
+
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
|
278
|
+
return LOOPBACK_HOSTS.includes(host);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
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
|
|
285
|
+
*/
|
|
286
|
+
function invalidField(staged) {
|
|
287
|
+
for (const [key, entry] of Object.entries(staged)) {
|
|
288
|
+
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') {
|
|
293
|
+
const value = Number(entry.text);
|
|
294
|
+
if (!Number.isInteger(value) || value < 1 || value > field.max) return key;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
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
|
|
306
|
+
*/
|
|
307
|
+
function probeResult(t, body) {
|
|
308
|
+
const everything = body.everything ?? {};
|
|
309
|
+
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'})`,
|
|
319
|
+
];
|
|
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
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
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
|
|
334
|
+
*/
|
|
335
|
+
function everythingGuidance(t, everything) {
|
|
336
|
+
if (everything.detail === 'not-running') return { key: 'everything', text: t('fixEverythingNotRunning') };
|
|
337
|
+
if (everything.detail === 'not-installed') {
|
|
338
|
+
return { key: 'everything', text: t('fixEverythingNotInstalled'), href: DOWNLOADS.everything };
|
|
339
|
+
}
|
|
340
|
+
if (everything.detail === 'es-missing') {
|
|
341
|
+
return { key: 'everything', text: t('fixEverythingEsMissing'), href: DOWNLOADS.everything };
|
|
342
|
+
}
|
|
343
|
+
return { key: 'everything', text: t('downloadEverything'), href: DOWNLOADS.everything };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
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
|
|
350
|
+
*/
|
|
351
|
+
function Section(props) {
|
|
352
|
+
return h('section', { style: SECTION_STYLE, 'aria-labelledby': props.id },
|
|
353
|
+
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),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
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.
|
|
363
|
+
*/
|
|
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'),
|
|
369
|
+
}));
|
|
370
|
+
return h('div', { style: { minWidth: 0 } },
|
|
371
|
+
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,
|
|
377
|
+
),
|
|
378
|
+
h('div', { style: { marginTop: '6px' } },
|
|
379
|
+
h(SegmentedControl, {
|
|
380
|
+
id,
|
|
381
|
+
value: normalizeEncoding(value),
|
|
382
|
+
options,
|
|
383
|
+
onChange,
|
|
384
|
+
label: t('esEncodingLabel'),
|
|
385
|
+
disabled,
|
|
386
|
+
}),
|
|
387
|
+
),
|
|
388
|
+
h('p', { style: HINT_STYLE }, t('esEncodingHint')),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
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
|
|
396
|
+
*/
|
|
397
|
+
function ConfigPage(props) {
|
|
398
|
+
const { t, form, view } = props;
|
|
399
|
+
const [staged, setStaged] = useState({});
|
|
400
|
+
const [saving, setSaving] = useState(false);
|
|
401
|
+
const [failed, setFailed] = useState(false);
|
|
402
|
+
const [probe, setProbe] = useState({ status: 'idle' });
|
|
403
|
+
|
|
404
|
+
// A summary render receives no form, so it must not claim a value state.
|
|
405
|
+
if (view === 'summary') return h('span', null, t('description'));
|
|
406
|
+
|
|
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]
|
|
412
|
+
.every((primitive) => primitive !== undefined && primitive !== null);
|
|
413
|
+
if (!primitivesReady) {
|
|
414
|
+
return h('p', { role: 'status' }, t('unavailable'));
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const values = form?.state.value ?? {};
|
|
418
|
+
const user = form?.state.user ?? {};
|
|
419
|
+
const writable = form?.state.writable ?? false;
|
|
420
|
+
const invalid = invalidField(staged);
|
|
421
|
+
const dirty = Object.keys(staged).length > 0;
|
|
422
|
+
const busy = saving || probe.status === 'running';
|
|
423
|
+
const disabled = !writable || busy;
|
|
424
|
+
|
|
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') {
|
|
430
|
+
const next = { ...previous };
|
|
431
|
+
delete next[key];
|
|
432
|
+
return next;
|
|
433
|
+
}
|
|
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;
|
|
439
|
+
};
|
|
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
|
+
|
|
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);
|
|
466
|
+
|
|
467
|
+
const save = async () => {
|
|
468
|
+
if (form === undefined || busy || !dirty || invalid !== undefined) return;
|
|
469
|
+
setSaving(true);
|
|
470
|
+
setFailed(false);
|
|
471
|
+
let landed = false;
|
|
472
|
+
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));
|
|
476
|
+
} catch {
|
|
477
|
+
landed = false;
|
|
478
|
+
}
|
|
479
|
+
setSaving(false);
|
|
480
|
+
if (landed) setStaged({});
|
|
481
|
+
else setFailed(true);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const runProbe = async () => {
|
|
485
|
+
if (probe.status === 'running') return;
|
|
486
|
+
setProbe({ status: 'running' });
|
|
487
|
+
const target = valueOf('anytxtUrl');
|
|
488
|
+
try {
|
|
489
|
+
const response = await fetch(statusUrl(target), { cache: 'no-store' });
|
|
490
|
+
const body = await response.json().catch(() => undefined);
|
|
491
|
+
if (response.status === 400 && body?.code === 'ANYTXT_URL_REJECTED') {
|
|
492
|
+
setProbe({ status: 'rejected' });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
496
|
+
setProbe({ status: 'done', body });
|
|
497
|
+
} catch (error) {
|
|
498
|
+
setProbe({ status: 'failed', message: error instanceof Error ? error.message : String(error) });
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
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' } },
|
|
508
|
+
h(Button, {
|
|
509
|
+
variant: 'outline',
|
|
510
|
+
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
|
+
])));
|
|
526
|
+
}
|
|
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
|
+
} else {
|
|
534
|
+
probeBlock.push(h('p', { key: 'message', style: HINT_STYLE }, t('testHint')));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return h(SettingsForm, {
|
|
538
|
+
labels: {
|
|
539
|
+
unavailable: t('unavailable'),
|
|
540
|
+
readOnly: t('readOnly'),
|
|
541
|
+
saveFailed: t('saveFailed'),
|
|
542
|
+
save: t('save'),
|
|
543
|
+
saving: t('saving'),
|
|
544
|
+
},
|
|
545
|
+
state: {
|
|
546
|
+
available: form !== undefined,
|
|
547
|
+
writable,
|
|
548
|
+
dirty,
|
|
549
|
+
invalid: invalid !== undefined,
|
|
550
|
+
saving,
|
|
551
|
+
failed,
|
|
552
|
+
},
|
|
553
|
+
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.
|
|
556
|
+
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
|
+
],
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
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
|
|
597
|
+
*/
|
|
598
|
+
function apply(ctx) {
|
|
599
|
+
const t = ctx.locale.bind(NS);
|
|
600
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'local-search: config dictionary');
|
|
601
|
+
ctx.effect(() => ctx.slots.inject('plugins.item', () => ctx.slots.register({
|
|
602
|
+
name: 'plugins.item',
|
|
603
|
+
id: ITEM_ID,
|
|
604
|
+
order: 100,
|
|
605
|
+
label: () => t('itemLabel'),
|
|
606
|
+
locale: NS,
|
|
607
|
+
}, ConfigPage)), 'local-search: config card');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
exports.apply = apply;
|
|
611
|
+
exports.inject = inject;
|
|
612
|
+
exports.NS = NS;
|
|
613
|
+
exports.ITEM_ID = ITEM_ID;
|
|
614
|
+
exports.STATUS_ROUTE = STATUS_ROUTE;
|
|
615
|
+
exports.ConfigPage = ConfigPage;
|
|
616
|
+
// Exposed so a test can prove these stay equal to the Host's own rules.
|
|
617
|
+
exports.LOOPBACK_HOSTS = LOOPBACK_HOSTS;
|
|
618
|
+
exports.LIMITS = LIMITS;
|
|
619
|
+
exports.en = en;
|
|
620
|
+
exports.zh = zh;
|
|
621
|
+
return module.exports;
|
|
622
|
+
}
|
|
623
|
+
});
|