@trim21/personal-pi-extensions 0.1.450 → 0.1.451
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/package.json +1 -1
- package/src/claude-code/files.ts +3 -3
- package/src/lib/lsp/client.ts +125 -22
- package/src/lib/lsp/lsp.ts +165 -2
- package/src/lib/lsp/server-config.ts +3 -5
- package/src/lib/lsp/watcher.ts +174 -0
- package/src/opencode/files.ts +3 -3
package/README.md
CHANGED
|
@@ -372,6 +372,13 @@ read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级
|
|
|
372
372
|
"settings": {}, // → didChangeConfiguration / workspace/configuration 请求
|
|
373
373
|
},
|
|
374
374
|
},
|
|
375
|
+
"maxOpenDocuments": 32, // 驻留文档上限(LRU 容量),缺省 32
|
|
376
|
+
"watch": {
|
|
377
|
+
"enabled": true, // 工作区文件监听,缺省 true
|
|
378
|
+
"debounceMs": "300ms", // 事件去抖,缺省 300ms;也支持 "5s" / "1m"
|
|
379
|
+
"maxBatch": 500, // 单批事件上限,缺省 500,超出截断并提示一次
|
|
380
|
+
"ignore": [], // 追加忽略 glob(相对工作区根)
|
|
381
|
+
},
|
|
375
382
|
}
|
|
376
383
|
```
|
|
377
384
|
|
|
@@ -384,9 +391,13 @@ read/edit/write 工具内置 LSP 诊断(写文件后等待并报告 ERROR 级
|
|
|
384
391
|
- `startupTimeoutMs` / `diagnosticsWaitMs`:per-server 超时,覆盖全局配置与默认值
|
|
385
392
|
- `initializationOptions` 与 `settings` 按 LSP 语义分离:前者进 initialize 请求,后者进 didChangeConfiguration / workspace/configuration 请求
|
|
386
393
|
|
|
387
|
-
|
|
394
|
+
没有内置默认服务器:`servers` 的 key 就是服务器 id,全部来自你的配置,未定义 `servers` 时不启动任何语言服务器。executable 的发现逻辑(如 tsserver 路径、venv 里的 python)不内置,需要时用 `bin` / `args` / `settings` 自行表达。
|
|
395
|
+
|
|
396
|
+
启用控制只有顶层两处:`enabled`(白名单)与 `disabled`(排除),按服务器 id 生效。`enabled` 里的 id 必须是已配置服务器,否则视为配置错误;`disabled` 里未注册的 id 直接忽略。全局超时字段(`initializeTimeoutMs` 等)同样配在顶层。
|
|
397
|
+
|
|
398
|
+
顶层 `watch` 段控制工作区文件监听(事件源是会话 cwd 的递归 fs.watch,非本 agent 写入的改动——如 `git checkout`、外部格式化——也会以 `workspace/didChangeWatchedFiles` 批量通知服务器);内置忽略 `node_modules`、`.git`、`dist`、`build`、`.venv`、`venv`、`target`、`coverage`,`ignore` 可追加。`maxOpenDocuments` 是保持 open 的文档上限(LRU):超过时最久未使用的文档会被 `didClose`,服务器回落到读磁盘。`watch.enabled: false` 可整体关闭监听,回到仅工具触发同步的现状。
|
|
388
399
|
|
|
389
|
-
|
|
400
|
+
服务器记录里不认识的键会被忽略:历史配置中残留的 `"clangd": { "enabled": false }` 已无任何效果,要禁用某个已配置的服务器请改用顶层 `disabled`。
|
|
390
401
|
|
|
391
402
|
---
|
|
392
403
|
|
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -349,9 +349,9 @@ export function registerFileTools(
|
|
|
349
349
|
}
|
|
350
350
|
const snapshot = { ...snapshotOf(buffer), offset, limit };
|
|
351
351
|
state.reads.set(key, snapshot);
|
|
352
|
-
// LSP
|
|
353
|
-
void service.
|
|
354
|
-
//
|
|
352
|
+
// LSP 文件事件通知是后台任务,失败不影响读取(read 不驻留文档)
|
|
353
|
+
void service.notifyFile(filePath, ctx.cwd).catch(() => {
|
|
354
|
+
// 后台通知失败不影响读取
|
|
355
355
|
});
|
|
356
356
|
return {
|
|
357
357
|
content: [{ type: "text", text: formatted.text }],
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -23,12 +23,20 @@ import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types
|
|
|
23
23
|
|
|
24
24
|
import type { LspServerHandle } from "./adapter.js";
|
|
25
25
|
import { LANGUAGE_EXTENSIONS } from "./language.js";
|
|
26
|
+
import type { FileChange, FileChangeType } from "./watcher.js";
|
|
26
27
|
|
|
27
28
|
// LSP spec 常量
|
|
28
29
|
const FILE_CHANGE_CREATED = 1;
|
|
29
30
|
const FILE_CHANGE_CHANGED = 2;
|
|
31
|
+
const FILE_CHANGE_DELETED = 3;
|
|
30
32
|
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2;
|
|
31
33
|
|
|
34
|
+
const FILE_CHANGE_TYPE: Record<FileChangeType, number> = {
|
|
35
|
+
created: FILE_CHANGE_CREATED,
|
|
36
|
+
changed: FILE_CHANGE_CHANGED,
|
|
37
|
+
deleted: FILE_CHANGE_DELETED,
|
|
38
|
+
};
|
|
39
|
+
|
|
32
40
|
export type Diagnostic = VSCodeDiagnostic;
|
|
33
41
|
|
|
34
42
|
export class InitializeError extends Error {
|
|
@@ -69,6 +77,7 @@ interface CapabilityRegistration {
|
|
|
69
77
|
registerOptions?: {
|
|
70
78
|
identifier?: string;
|
|
71
79
|
workspaceDiagnostics?: boolean;
|
|
80
|
+
watchers?: { globPattern?: string }[];
|
|
72
81
|
};
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -93,6 +102,8 @@ export interface CreateInput {
|
|
|
93
102
|
diagnosticsFullWaitTimeoutMs?: number;
|
|
94
103
|
diagnosticsRequestTimeoutMs?: number;
|
|
95
104
|
initializeTimeoutMs?: number;
|
|
105
|
+
/** 驻留文档上限(LRU 容量,缺省 32);超过时淘汰最久未使用并 didClose。 */
|
|
106
|
+
maxOpenDocuments?: number;
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
export interface LspClient {
|
|
@@ -101,7 +112,11 @@ export interface LspClient {
|
|
|
101
112
|
readonly connection: MessageConnection;
|
|
102
113
|
readonly notify: {
|
|
103
114
|
open(request: { path: string }): Promise<number>;
|
|
115
|
+
/** 把工作区文件事件批量通知服务器;驻留文档不在此通道(走 didOpen/didChange/退场)。 */
|
|
116
|
+
watchedFiles(changes: FileChange[]): Promise<void>;
|
|
104
117
|
};
|
|
118
|
+
/** 服务器注册的 workspace/didChangeWatchedFiles watchers glob(去重)。 */
|
|
119
|
+
watchPatterns(): string[];
|
|
105
120
|
readonly diagnostics: Map<string, Diagnostic[]>;
|
|
106
121
|
waitForDiagnostics(request: {
|
|
107
122
|
path: string;
|
|
@@ -210,6 +225,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
210
225
|
const diagnosticsFullWaitTimeoutMs = input.diagnosticsFullWaitTimeoutMs ?? 10_000;
|
|
211
226
|
const diagnosticsRequestTimeoutMs = input.diagnosticsRequestTimeoutMs ?? 3_000;
|
|
212
227
|
const initializeTimeoutMs = input.initializeTimeoutMs ?? 45_000;
|
|
228
|
+
const maxOpenDocuments = input.maxOpenDocuments ?? 32;
|
|
213
229
|
|
|
214
230
|
const connection = createMessageConnection(
|
|
215
231
|
new StreamMessageReader(input.server.process.stdout),
|
|
@@ -231,6 +247,8 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
231
247
|
const pullDiagnostics = new Map<string, Diagnostic[]>();
|
|
232
248
|
const published = new Map<string, { at: number; version?: number }>();
|
|
233
249
|
const diagnosticRegistrations = new Map<string, CapabilityRegistration>();
|
|
250
|
+
/** registration id → workspace/didChangeWatchedFiles watchers glob。 */
|
|
251
|
+
const watcherRegistrations = new Map<string, string[]>();
|
|
234
252
|
const registrationListeners = new Set<() => void>();
|
|
235
253
|
const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>();
|
|
236
254
|
/** resolvedPath → 客户端已发送的最新文档版本(didOpen=0,didChange 递增)。 */
|
|
@@ -286,9 +304,16 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
286
304
|
(params as { registrations?: CapabilityRegistration[] }).registrations ?? [];
|
|
287
305
|
let changed = false;
|
|
288
306
|
for (const registration of registrations) {
|
|
289
|
-
if (registration.method
|
|
290
|
-
|
|
291
|
-
|
|
307
|
+
if (registration.method === "workspace/didChangeWatchedFiles") {
|
|
308
|
+
const watchers =
|
|
309
|
+
registration.registerOptions?.watchers
|
|
310
|
+
?.map((watcher) => watcher.globPattern)
|
|
311
|
+
.filter((pattern): pattern is string => typeof pattern === "string") ?? [];
|
|
312
|
+
watcherRegistrations.set(registration.id, watchers);
|
|
313
|
+
} else if (registration.method === "textDocument/diagnostic") {
|
|
314
|
+
diagnosticRegistrations.set(registration.id, registration);
|
|
315
|
+
changed = true;
|
|
316
|
+
}
|
|
292
317
|
}
|
|
293
318
|
if (changed) emitRegistrationChange();
|
|
294
319
|
});
|
|
@@ -297,9 +322,12 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
297
322
|
(params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? [];
|
|
298
323
|
let changed = false;
|
|
299
324
|
for (const registration of registrations) {
|
|
300
|
-
if (registration.method
|
|
301
|
-
|
|
302
|
-
|
|
325
|
+
if (registration.method === "workspace/didChangeWatchedFiles") {
|
|
326
|
+
watcherRegistrations.delete(registration.id);
|
|
327
|
+
} else if (registration.method === "textDocument/diagnostic") {
|
|
328
|
+
diagnosticRegistrations.delete(registration.id);
|
|
329
|
+
changed = true;
|
|
330
|
+
}
|
|
303
331
|
}
|
|
304
332
|
if (changed) emitRegistrationChange();
|
|
305
333
|
});
|
|
@@ -354,6 +382,39 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
354
382
|
|
|
355
383
|
const files: Record<string, { version: number; text: string }> = {};
|
|
356
384
|
|
|
385
|
+
// ── 驻留 LRU ────────────────────────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
/** path → 最近使用时间;迭代序即使用序(头部最久)。 */
|
|
388
|
+
const lruOrder = new Map<string, number>();
|
|
389
|
+
/** 正在等待诊断的文档(didClose 淘汰时跳过,见"关闭不得早于诊断收集")。 */
|
|
390
|
+
const waitingForDiagnostics = new Set<string>();
|
|
391
|
+
|
|
392
|
+
const touch = (path: string): void => {
|
|
393
|
+
lruOrder.delete(path);
|
|
394
|
+
lruOrder.set(path, Date.now());
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
/** 超过容量时淘汰最久未使用的文档(didClose 并移出驻留集合)。 */
|
|
398
|
+
async function evictExcess(): Promise<void> {
|
|
399
|
+
while (lruOrder.size > maxOpenDocuments) {
|
|
400
|
+
const oldest = lruOrder.keys().next().value;
|
|
401
|
+
if (oldest === undefined) return;
|
|
402
|
+
lruOrder.delete(oldest);
|
|
403
|
+
const document = files[oldest];
|
|
404
|
+
if (document === undefined) continue;
|
|
405
|
+
if (waitingForDiagnostics.has(oldest)) {
|
|
406
|
+
// 防御:等待中的文档挪到 MRU,等下轮再淘汰(正常不会发生,刚 touch 即 MRU)
|
|
407
|
+
touch(oldest);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
await connection.sendNotification("textDocument/didClose", {
|
|
411
|
+
textDocument: { uri: pathToFileURL(oldest).href },
|
|
412
|
+
});
|
|
413
|
+
delete files[oldest];
|
|
414
|
+
documentVersions.delete(oldest);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
357
418
|
// ── 诊断拉取(pull)辅助 ────────────────────────────────────────────────────
|
|
358
419
|
|
|
359
420
|
const mergeResults = (filePath: string, results: DiagnosticRequestResult[]): PullResult => {
|
|
@@ -693,6 +754,9 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
693
754
|
get serverID() {
|
|
694
755
|
return input.serverID;
|
|
695
756
|
},
|
|
757
|
+
watchPatterns(): string[] {
|
|
758
|
+
return [...new Set([...watcherRegistrations.values()].flat())];
|
|
759
|
+
},
|
|
696
760
|
get connection() {
|
|
697
761
|
return connection;
|
|
698
762
|
},
|
|
@@ -713,9 +777,6 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
713
777
|
// 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。
|
|
714
778
|
pushDiagnostics.delete(resolvedPath);
|
|
715
779
|
pullDiagnostics.delete(resolvedPath);
|
|
716
|
-
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
717
|
-
changes: [{ uri, type: FILE_CHANGE_CHANGED }],
|
|
718
|
-
});
|
|
719
780
|
|
|
720
781
|
const next = document.version + 1;
|
|
721
782
|
files[resolvedPath] = { version: next, text };
|
|
@@ -732,13 +793,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
732
793
|
]
|
|
733
794
|
: [{ text }],
|
|
734
795
|
});
|
|
796
|
+
touch(resolvedPath);
|
|
797
|
+
await evictExcess();
|
|
735
798
|
return next;
|
|
736
799
|
}
|
|
737
800
|
|
|
738
|
-
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
739
|
-
changes: [{ uri, type: FILE_CHANGE_CREATED }],
|
|
740
|
-
});
|
|
741
|
-
|
|
742
801
|
pushDiagnostics.delete(resolvedPath);
|
|
743
802
|
pullDiagnostics.delete(resolvedPath);
|
|
744
803
|
await connection.sendNotification("textDocument/didOpen", {
|
|
@@ -746,8 +805,47 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
746
805
|
});
|
|
747
806
|
files[resolvedPath] = { version: 0, text };
|
|
748
807
|
documentVersions.set(resolvedPath, 0);
|
|
808
|
+
touch(resolvedPath);
|
|
809
|
+
await evictExcess();
|
|
749
810
|
return 0;
|
|
750
811
|
},
|
|
812
|
+
async watchedFiles(changes: FileChange[]): Promise<void> {
|
|
813
|
+
const notified: { uri: string; type: number }[] = [];
|
|
814
|
+
for (const change of changes) {
|
|
815
|
+
const resolvedPath = normalize(
|
|
816
|
+
isAbsolute(change.path) ? change.path : resolve(input.directory, change.path),
|
|
817
|
+
);
|
|
818
|
+
const document = files[resolvedPath];
|
|
819
|
+
if (document !== undefined) {
|
|
820
|
+
// 写后诊断等待中的文档不退场(didClose 可能抹掉本次写入的诊断结果)
|
|
821
|
+
if (waitingForDiagnostics.has(resolvedPath)) continue;
|
|
822
|
+
// 驻留文档被外部改动:内容一致的自身写入 echo 完全忽略;否则先 didClose
|
|
823
|
+
// 让服务器回落磁盘,再以文件事件通知——不 bump 版本,避免与写后等待竞态。
|
|
824
|
+
if (change.type === "changed") {
|
|
825
|
+
let disk: string | undefined;
|
|
826
|
+
try {
|
|
827
|
+
disk = await readFile(resolvedPath, "utf8");
|
|
828
|
+
} catch {
|
|
829
|
+
// 文件已被删除或不可读:按磁盘状态变化处理
|
|
830
|
+
}
|
|
831
|
+
if (disk === document.text) continue;
|
|
832
|
+
}
|
|
833
|
+
await connection.sendNotification("textDocument/didClose", {
|
|
834
|
+
textDocument: { uri: pathToFileURL(resolvedPath).href },
|
|
835
|
+
});
|
|
836
|
+
delete files[resolvedPath];
|
|
837
|
+
documentVersions.delete(resolvedPath);
|
|
838
|
+
}
|
|
839
|
+
notified.push({
|
|
840
|
+
uri: pathToFileURL(resolvedPath).href,
|
|
841
|
+
type: FILE_CHANGE_TYPE[change.type],
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
if (notified.length === 0) return;
|
|
845
|
+
await connection.sendNotification("workspace/didChangeWatchedFiles", {
|
|
846
|
+
changes: notified,
|
|
847
|
+
});
|
|
848
|
+
},
|
|
751
849
|
},
|
|
752
850
|
get diagnostics() {
|
|
753
851
|
const result = new Map<string, Diagnostic[]>();
|
|
@@ -760,21 +858,26 @@ export async function create(input: CreateInput): Promise<LspClient> {
|
|
|
760
858
|
const normalizedPath = normalize(
|
|
761
859
|
isAbsolute(request.path) ? request.path : resolve(input.directory, request.path),
|
|
762
860
|
);
|
|
763
|
-
|
|
764
|
-
|
|
861
|
+
waitingForDiagnostics.add(normalizedPath);
|
|
862
|
+
try {
|
|
863
|
+
if (request.mode === "document") {
|
|
864
|
+
await waitForDocumentDiagnostics({
|
|
865
|
+
path: normalizedPath,
|
|
866
|
+
version: request.version,
|
|
867
|
+
after: request.after,
|
|
868
|
+
signal: request.signal,
|
|
869
|
+
});
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
await waitForFullDiagnostics({
|
|
765
873
|
path: normalizedPath,
|
|
766
874
|
version: request.version,
|
|
767
875
|
after: request.after,
|
|
768
876
|
signal: request.signal,
|
|
769
877
|
});
|
|
770
|
-
|
|
878
|
+
} finally {
|
|
879
|
+
waitingForDiagnostics.delete(normalizedPath);
|
|
771
880
|
}
|
|
772
|
-
await waitForFullDiagnostics({
|
|
773
|
-
path: normalizedPath,
|
|
774
|
-
version: request.version,
|
|
775
|
-
after: request.after,
|
|
776
|
-
signal: request.signal,
|
|
777
|
-
});
|
|
778
881
|
},
|
|
779
882
|
async shutdown() {
|
|
780
883
|
connection.end();
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -13,15 +13,16 @@
|
|
|
13
13
|
* 的 id 直接忽略;
|
|
14
14
|
* - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入 broken
|
|
15
15
|
* 集合(服务实例生命周期内不再重试);
|
|
16
|
-
* - 工具只与 touchFile / diagnostics / lspDiagnosticsForFile
|
|
16
|
+
* - 工具只与 touchFile / notifyFile / diagnostics / lspDiagnosticsForFile 四个方法打交道;通知回调按请求传入。
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFileSync } from "node:fs";
|
|
20
20
|
import { readFile } from "node:fs/promises";
|
|
21
21
|
import { homedir } from "node:os";
|
|
22
|
-
import { extname, join, normalize, sep } from "node:path";
|
|
22
|
+
import { extname, join, normalize, relative, sep } from "node:path";
|
|
23
23
|
|
|
24
24
|
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { minimatch } from "minimatch";
|
|
25
26
|
import { type Static, Type } from "typebox";
|
|
26
27
|
import { Value } from "typebox/value";
|
|
27
28
|
|
|
@@ -29,10 +30,23 @@ import { type LspServerAdapter } from "./adapter.js";
|
|
|
29
30
|
import { create, type CreateInput, type Diagnostic, type Info as LspClient } from "./client.js";
|
|
30
31
|
import { report } from "./diagnostic.js";
|
|
31
32
|
import { createAdapters, mergeServerRecords, serverConfigSchema } from "./server-config.js";
|
|
33
|
+
import { type FileChange, watchWorkspace, type WorkspaceWatcher } from "./watcher.js";
|
|
32
34
|
|
|
33
35
|
/** 超时值:number(毫秒,>=1)或字符串("500"、"5s"、"1m"),Parse 后由 toMs 统一换算。 */
|
|
34
36
|
const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
|
|
35
37
|
|
|
38
|
+
/** lsp.json 顶层 `watch` 段:工作区文件监听配置。 */
|
|
39
|
+
const watchConfigSchema = Type.Object({
|
|
40
|
+
/** 是否启用工作区文件监听(缺省 true)。 */
|
|
41
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
42
|
+
/** 事件去抖时长(ms,缺省 300),沿用 timeoutValue 字符串写法。 */
|
|
43
|
+
debounceMs: Type.Optional(timeoutValue),
|
|
44
|
+
/** 单批事件上限(缺省 500),超出截断并提示一次。 */
|
|
45
|
+
maxBatch: Type.Optional(Type.Number({ minimum: 1 })),
|
|
46
|
+
/** 追加忽略 glob(相对工作区根的 POSIX 路径)。 */
|
|
47
|
+
ignore: Type.Optional(Type.Array(Type.String())),
|
|
48
|
+
});
|
|
49
|
+
|
|
36
50
|
/** lsp.json 的配置项(全局与本地同构)。 */
|
|
37
51
|
const lspConfigSchema = Type.Object({
|
|
38
52
|
/** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝并回退空配置。 */
|
|
@@ -43,6 +57,10 @@ const lspConfigSchema = Type.Object({
|
|
|
43
57
|
enabled: Type.Optional(Type.Array(Type.String())),
|
|
44
58
|
/** 从启用集中排除的服务器 id(缺省 = 无)。 */
|
|
45
59
|
disabled: Type.Optional(Type.Array(Type.String())),
|
|
60
|
+
/** 工作区文件监听配置(缺省全部字段用 watcher 默认值)。 */
|
|
61
|
+
watch: Type.Optional(watchConfigSchema),
|
|
62
|
+
/** 驻留文档上限(LRU 容量,缺省 32)。 */
|
|
63
|
+
maxOpenDocuments: Type.Optional(Type.Number({ minimum: 1 })),
|
|
46
64
|
/** push 诊断去抖(ms,缺省 150)。 */
|
|
47
65
|
diagnosticsDebounceMs: Type.Optional(timeoutValue),
|
|
48
66
|
/** document 模式诊断等待上限(ms,缺省 5_000)。 */
|
|
@@ -94,6 +112,32 @@ function timeoutOptions(
|
|
|
94
112
|
};
|
|
95
113
|
}
|
|
96
114
|
|
|
115
|
+
/** 生效的工作区监听配置(应用缺省值)。 */
|
|
116
|
+
export interface EffectiveWatchConfig {
|
|
117
|
+
enabled: boolean;
|
|
118
|
+
debounceMs: number;
|
|
119
|
+
flushMs: number;
|
|
120
|
+
maxBatch: number;
|
|
121
|
+
ignore: string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** lsp.json 的 `watch` 段 + 缺省值;debounceMs 字符串时长在此换算。 */
|
|
125
|
+
export function watchOptions(config: LspConfig): EffectiveWatchConfig {
|
|
126
|
+
const watch = config.watch;
|
|
127
|
+
return {
|
|
128
|
+
enabled: watch?.enabled ?? true,
|
|
129
|
+
debounceMs: toMs(watch?.debounceMs) ?? 300,
|
|
130
|
+
flushMs: 1_000,
|
|
131
|
+
maxBatch: watch?.maxBatch ?? 500,
|
|
132
|
+
ignore: watch?.ignore ?? [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 驻留文档 LRU 容量(缺省 32)。 */
|
|
137
|
+
export function maxOpenDocuments(config: LspConfig): number {
|
|
138
|
+
return config.maxOpenDocuments ?? 32;
|
|
139
|
+
}
|
|
140
|
+
|
|
97
141
|
/** 读取并解析单个配置文件;文件不存在或解析失败时返回空配置。 */
|
|
98
142
|
async function readConfigFile(filePath: string): Promise<LspConfig> {
|
|
99
143
|
try {
|
|
@@ -179,6 +223,10 @@ interface LspState {
|
|
|
179
223
|
disabled: boolean;
|
|
180
224
|
/** root+serverID → 服务器状态,用于 footer status 显示。 */
|
|
181
225
|
servers: Map<string, { serverID: string; root: string; state: "running" | "broken" }>;
|
|
226
|
+
/** 当前会话工作目录(watcher 挂载点);变化时重建监听器。 */
|
|
227
|
+
cwd: string | undefined;
|
|
228
|
+
/** client → adapter 扩展名集合,fan-out 时按扩展名过滤。 */
|
|
229
|
+
clientExtensions: Map<LspClient, readonly string[]>;
|
|
182
230
|
}
|
|
183
231
|
|
|
184
232
|
/** 渲染 LSP status 文本的回调(传入 undefined 表示清除)。 */
|
|
@@ -209,6 +257,8 @@ export interface LspService {
|
|
|
209
257
|
diagnostics?: "document" | "full",
|
|
210
258
|
options?: LspRequestOptions,
|
|
211
259
|
): Promise<void>;
|
|
260
|
+
/** read 用:只发文件事件通知服务器磁盘上有该文件,不驻留、不等诊断。 */
|
|
261
|
+
notifyFile(file: string, cwd: string, options?: LspRequestOptions): Promise<void>;
|
|
212
262
|
diagnostics(): Promise<Record<string, Diagnostic[]>>;
|
|
213
263
|
lspDiagnosticsForFile(
|
|
214
264
|
file: string,
|
|
@@ -262,10 +312,94 @@ export function createLspService(
|
|
|
262
312
|
closing: false,
|
|
263
313
|
disabled: false,
|
|
264
314
|
servers: new Map(),
|
|
315
|
+
cwd: undefined,
|
|
316
|
+
clientExtensions: new Map(),
|
|
265
317
|
};
|
|
266
318
|
|
|
267
319
|
let renderStatus: StatusRenderer | undefined;
|
|
268
320
|
|
|
321
|
+
// ── 工作区文件监听(watcher)───────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
let watcher: WorkspaceWatcher | undefined;
|
|
324
|
+
let watcherCwd: string | undefined;
|
|
325
|
+
|
|
326
|
+
async function stopWatcher(): Promise<void> {
|
|
327
|
+
const current = watcher;
|
|
328
|
+
watcher = undefined;
|
|
329
|
+
watcherCwd = undefined;
|
|
330
|
+
if (current) {
|
|
331
|
+
try {
|
|
332
|
+
await current.stop();
|
|
333
|
+
} catch {
|
|
334
|
+
// 停止失败不影响流程
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 事件按各 client 的 root 前缀 / 注册 pattern / 扩展名过滤后投递。 */
|
|
340
|
+
async function fanOut(changes: FileChange[]): Promise<void> {
|
|
341
|
+
const cwd = state.cwd;
|
|
342
|
+
if (!cwd) return;
|
|
343
|
+
await Promise.all(
|
|
344
|
+
state.clients.map(async (client) => {
|
|
345
|
+
const filtered = changes.filter((change) => {
|
|
346
|
+
if (!containsPath(change.path, cwd) || !containsPath(change.path, client.root)) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
const patterns = client.watchPatterns();
|
|
350
|
+
if (patterns.length > 0) {
|
|
351
|
+
const candidate = relative(client.root, change.path).split(sep).join("/");
|
|
352
|
+
if (patterns.every((pattern) => !minimatch(candidate, pattern))) return false;
|
|
353
|
+
}
|
|
354
|
+
const extensions = state.clientExtensions.get(client);
|
|
355
|
+
return (
|
|
356
|
+
extensions === undefined ||
|
|
357
|
+
extensions.length === 0 ||
|
|
358
|
+
extensions.includes(extname(change.path))
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
if (filtered.length === 0) return;
|
|
362
|
+
try {
|
|
363
|
+
await client.notify.watchedFiles(filtered);
|
|
364
|
+
} catch {
|
|
365
|
+
// 单个 client 通知失败不影响其余 client
|
|
366
|
+
}
|
|
367
|
+
}),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** 首个 client 建立 / 会话 cwd 变化时(重)建监听器;watch.enabled: false 时不启动。 */
|
|
372
|
+
async function ensureWatcher(cwd: string, notify?: ExtensionUIContext["notify"]): Promise<void> {
|
|
373
|
+
if (state.closing || state.disabled) return;
|
|
374
|
+
const config = await loadLspConfig(cwd, globalConfigPath);
|
|
375
|
+
const watch = watchOptions(config);
|
|
376
|
+
if (!watch.enabled) return;
|
|
377
|
+
if (watcher && watcherCwd === cwd) return;
|
|
378
|
+
await stopWatcher();
|
|
379
|
+
try {
|
|
380
|
+
watcher = await watchWorkspace(cwd, (changes) => void fanOut(changes), {
|
|
381
|
+
debounceMs: watch.debounceMs,
|
|
382
|
+
flushMs: watch.flushMs,
|
|
383
|
+
maxBatch: watch.maxBatch,
|
|
384
|
+
ignore: watch.ignore,
|
|
385
|
+
onError: (message) => notify?.(message, "error"),
|
|
386
|
+
onTruncated: () =>
|
|
387
|
+
notify?.(
|
|
388
|
+
"workspace file events truncated (batch limit exceeded); run /lsp-reload <id> if diagnostics look stale",
|
|
389
|
+
"warning",
|
|
390
|
+
),
|
|
391
|
+
});
|
|
392
|
+
watcherCwd = cwd;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
notify?.(
|
|
395
|
+
`workspace watcher failed to start for ${cwd}: ${
|
|
396
|
+
error instanceof Error ? error.message : String(error)
|
|
397
|
+
}`,
|
|
398
|
+
"error",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
269
403
|
/** 汇总当前所有 LSP server 状态并渲染到 footer status。 */
|
|
270
404
|
function updateStatusText(): void {
|
|
271
405
|
if (!renderStatus) return;
|
|
@@ -301,6 +435,12 @@ export function createLspService(
|
|
|
301
435
|
const extension = extname(file) || file;
|
|
302
436
|
const result: LspClient[] = [];
|
|
303
437
|
|
|
438
|
+
// 会话 cwd 变化时重建 watcher(首个 client 建立后启动)
|
|
439
|
+
if (state.cwd !== cwd) {
|
|
440
|
+
state.cwd = cwd;
|
|
441
|
+
await stopWatcher();
|
|
442
|
+
}
|
|
443
|
+
|
|
304
444
|
for (const adapter of active) {
|
|
305
445
|
if (adapter.extensions.length > 0 && !adapter.extensions.includes(extension)) continue;
|
|
306
446
|
const root = await adapter.findRoot(file, cwd);
|
|
@@ -343,6 +483,7 @@ export function createLspService(
|
|
|
343
483
|
initializeTimeoutMs: adapter.startupTimeoutMs ?? timeout.initializeTimeoutMs,
|
|
344
484
|
diagnosticsDocumentWaitTimeoutMs:
|
|
345
485
|
adapter.diagnosticsWaitMs ?? timeout.diagnosticsDocumentWaitTimeoutMs,
|
|
486
|
+
maxOpenDocuments: maxOpenDocuments(config),
|
|
346
487
|
});
|
|
347
488
|
if (state.closing || state.disabled) {
|
|
348
489
|
await client.shutdown();
|
|
@@ -354,8 +495,10 @@ export function createLspService(
|
|
|
354
495
|
return duplicate;
|
|
355
496
|
}
|
|
356
497
|
state.clients.push(client);
|
|
498
|
+
state.clientExtensions.set(client, adapter.extensions);
|
|
357
499
|
state.servers.set(key, { serverID: adapter.id, root, state: "running" });
|
|
358
500
|
updateStatusText();
|
|
501
|
+
void ensureWatcher(cwd, notify);
|
|
359
502
|
return client;
|
|
360
503
|
} catch (error) {
|
|
361
504
|
state.broken.add(key);
|
|
@@ -425,6 +568,21 @@ export function createLspService(
|
|
|
425
568
|
return Promise.resolve(results);
|
|
426
569
|
}
|
|
427
570
|
|
|
571
|
+
/**
|
|
572
|
+
* read 的 warm-up:通知服务器磁盘上有这个文件(D5),不 didOpen 驻留、不等诊断。
|
|
573
|
+
* 驻留文档若磁盘已被外部改写会顺带触发退场。
|
|
574
|
+
*/
|
|
575
|
+
async function notifyFile(file: string, cwd: string, options?: LspRequestOptions): Promise<void> {
|
|
576
|
+
const clients = await getClients(file, cwd, options?.notify);
|
|
577
|
+
await Promise.all(
|
|
578
|
+
clients.map((client) =>
|
|
579
|
+
client.notify.watchedFiles([{ path: file, type: "changed", isDirectory: false }]),
|
|
580
|
+
),
|
|
581
|
+
).catch(() => {
|
|
582
|
+
// 文件事件通知失败不影响读取
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
428
586
|
/**
|
|
429
587
|
* edit/write 用:等待文档诊断并返回该文件的 ERROR / WARN 报告(text 空串表示无此类诊断)
|
|
430
588
|
* 与数量。内部所有 LSP 失败都会被吞掉,不干扰写操作本身。
|
|
@@ -451,8 +609,10 @@ export function createLspService(
|
|
|
451
609
|
// 个别进程退出失败不阻止清理流程
|
|
452
610
|
});
|
|
453
611
|
state.clients = [];
|
|
612
|
+
state.clientExtensions.clear();
|
|
454
613
|
state.broken.clear();
|
|
455
614
|
state.servers.clear();
|
|
615
|
+
await stopWatcher();
|
|
456
616
|
updateStatusText();
|
|
457
617
|
}
|
|
458
618
|
|
|
@@ -482,11 +642,13 @@ export function createLspService(
|
|
|
482
642
|
// 个别进程退出失败不阻止清理流程
|
|
483
643
|
});
|
|
484
644
|
state.clients = state.clients.filter((client) => client.serverID !== serverID);
|
|
645
|
+
for (const client of targets) state.clientExtensions.delete(client);
|
|
485
646
|
for (const [key, server] of state.servers) {
|
|
486
647
|
if (server.serverID !== serverID) continue;
|
|
487
648
|
state.broken.delete(key);
|
|
488
649
|
state.servers.delete(key);
|
|
489
650
|
}
|
|
651
|
+
if (state.clients.length === 0) await stopWatcher();
|
|
490
652
|
state.closing = false;
|
|
491
653
|
state.disabled = false;
|
|
492
654
|
updateStatusText();
|
|
@@ -498,6 +660,7 @@ export function createLspService(
|
|
|
498
660
|
|
|
499
661
|
return {
|
|
500
662
|
touchFile,
|
|
663
|
+
notifyFile,
|
|
501
664
|
diagnostics,
|
|
502
665
|
lspDiagnosticsForFile,
|
|
503
666
|
shutdownAll,
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* languageId/超时等),替代为每个语言写一个 adapter class。
|
|
4
4
|
*
|
|
5
5
|
* 配置文件沿用 lsp.json(全局 ~/.pi/agent/lsp.json + 本地 <cwd>/.pi/lsp.json):
|
|
6
|
-
* 顶层 `servers` 是 id → 配置的 record
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* 顶层 `servers` 是 id → 配置的 record,全局与本地按 id 合并(同名 id 整体
|
|
7
|
+
* 覆盖、新增 id、全局其余保留),之后受顶层 enabled/disabled 列表过滤。
|
|
8
|
+
* 没有内置默认服务器:未配置 servers 时不启动任何服务器。
|
|
9
9
|
*
|
|
10
10
|
* executable 发现统一由用户配置:bin 支持绝对路径 / 项目工作区
|
|
11
11
|
* (node_modules/.bin、.venv/bin、venv/bin)/ PATH,不再内置各语言的
|
|
@@ -23,8 +23,6 @@ import { exists, findBinaryInWorkspace, which } from "./bin.js";
|
|
|
23
23
|
import { spawnProcess } from "./launch.js";
|
|
24
24
|
|
|
25
25
|
export const serverConfigSchema = Type.Object({
|
|
26
|
-
/** false = 从启用集中移除(覆盖同名内置服务器);缺省启用。 */
|
|
27
|
-
enabled: Type.Optional(Type.Boolean()),
|
|
28
26
|
/** 文件 glob(相对项目根或调用 cwd,任一命中即可);缺省匹配所有文件。 */
|
|
29
27
|
include: Type.Optional(Type.Array(Type.String())),
|
|
30
28
|
/** 项目根标记文件(从文件目录向上查找);缺省用调用 cwd 作为根。 */
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区文件监听器:单个递归 fs.watch + 去抖批量回调。
|
|
3
|
+
*
|
|
4
|
+
* - 事件源用 `node:fs/promises` 的 `watch(dir, { recursive: true, signal })`,
|
|
5
|
+
* 不引入 chokidar(与仓库"FS 一律用 node:fs/promises"约定一致);
|
|
6
|
+
* - create / delete / rename 在底层都表现为 `rename`,内容改动为 `change`,
|
|
7
|
+
* 故 LSP 的 created / changed / deleted 类型由 `lstat` 判定;
|
|
8
|
+
* - 尾部去抖(缺省 300ms)合并短时洪峰,最长 flushMs(缺省 1s)强制清批;
|
|
9
|
+
* - 内置忽略 `node_modules` / `.git` / `dist` / `build` / `.venv` / `venv` /
|
|
10
|
+
* `target` / `coverage`,配置可追加;
|
|
11
|
+
* - 单批超过 maxBatch(缺省 500)截断并回调 onTruncated 提示一次;
|
|
12
|
+
* - 目录事件默认丢弃;目录被删除时上报(isDirectory: true),由上层对其中
|
|
13
|
+
* 的驻留文档补 deleted 事件。监听器启动或运行期失败时调用 onError 降级,
|
|
14
|
+
* 不抛错。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { lstat, watch } from "node:fs/promises";
|
|
18
|
+
import { join, normalize, relative, sep } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { minimatch } from "minimatch";
|
|
21
|
+
|
|
22
|
+
export type FileChangeType = "created" | "changed" | "deleted";
|
|
23
|
+
|
|
24
|
+
export interface FileChange {
|
|
25
|
+
/** 绝对路径(normalized)。 */
|
|
26
|
+
path: string;
|
|
27
|
+
type: FileChangeType;
|
|
28
|
+
/** 目录被删除时 true(created/changed 目录事件默认不上报)。 */
|
|
29
|
+
isDirectory: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const DEFAULT_IGNORE = [
|
|
33
|
+
"**/node_modules/**",
|
|
34
|
+
"**/.git/**",
|
|
35
|
+
"**/dist/**",
|
|
36
|
+
"**/build/**",
|
|
37
|
+
"**/.venv/**",
|
|
38
|
+
"**/venv/**",
|
|
39
|
+
"**/target/**",
|
|
40
|
+
"**/coverage/**",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export interface WatchOptions {
|
|
44
|
+
/** 尾部去抖窗口(ms,缺省 300)。 */
|
|
45
|
+
debounceMs?: number;
|
|
46
|
+
/** 从首个事件起的最长 flush 间隔(ms,缺省 1_000)。 */
|
|
47
|
+
flushMs?: number;
|
|
48
|
+
/** 单批上限,超出截断(缺省 500)。 */
|
|
49
|
+
maxBatch?: number;
|
|
50
|
+
/** 追加忽略 glob(相对工作区根的 POSIX 路径)。 */
|
|
51
|
+
ignore?: string[];
|
|
52
|
+
/** 监听器不可用(无法启动 / 运行期失败)时的降级提示回调。 */
|
|
53
|
+
onError?: (message: string) => void;
|
|
54
|
+
/** 单批超限被截断时回调(提示一次,不逐条刷屏)。 */
|
|
55
|
+
onTruncated?: () => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkspaceWatcher {
|
|
59
|
+
stop(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isIgnored(path: string, dir: string, patterns: string[]): boolean {
|
|
63
|
+
const candidate = relative(dir, path).split(sep).join("/");
|
|
64
|
+
if (candidate.startsWith("..")) return false;
|
|
65
|
+
return patterns.some((pattern) => minimatch(candidate, pattern));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 启动对 dir 的递归监听。onBatch 收到去抖合并后的批次;stop 后不再回调。
|
|
70
|
+
* 返回的 promise 只在监听器无法建立(目录不存在等)时 reject——运行期
|
|
71
|
+
* 错误一律走 onError 降级。
|
|
72
|
+
*/
|
|
73
|
+
export function watchWorkspace(
|
|
74
|
+
dir: string,
|
|
75
|
+
onBatch: (changes: FileChange[]) => void,
|
|
76
|
+
options?: WatchOptions,
|
|
77
|
+
): Promise<WorkspaceWatcher> {
|
|
78
|
+
const debounceMs = options?.debounceMs ?? 300;
|
|
79
|
+
const flushMs = options?.flushMs ?? 1_000;
|
|
80
|
+
const maxBatch = options?.maxBatch ?? 500;
|
|
81
|
+
const ignorePatterns = [...DEFAULT_IGNORE, ...(options?.ignore ?? [])];
|
|
82
|
+
const abort = new AbortController();
|
|
83
|
+
|
|
84
|
+
// 从 stat 成功事件学习已知目录,目录被删除时据此标记 isDirectory
|
|
85
|
+
const seenDirectories = new Set<string>();
|
|
86
|
+
|
|
87
|
+
let pending: FileChange[] = [];
|
|
88
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
89
|
+
let maxTimer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
|
|
91
|
+
const clearTimers = (): void => {
|
|
92
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
93
|
+
if (maxTimer) clearTimeout(maxTimer);
|
|
94
|
+
flushTimer = undefined;
|
|
95
|
+
maxTimer = undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const flush = (): void => {
|
|
99
|
+
clearTimers();
|
|
100
|
+
if (pending.length === 0) return;
|
|
101
|
+
let batch = pending;
|
|
102
|
+
pending = [];
|
|
103
|
+
if (batch.length > maxBatch) {
|
|
104
|
+
batch = batch.slice(0, maxBatch);
|
|
105
|
+
options?.onTruncated?.();
|
|
106
|
+
}
|
|
107
|
+
onBatch(batch);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const push = (change: FileChange): void => {
|
|
111
|
+
if (pending.length === 0) maxTimer = setTimeout(flush, flushMs);
|
|
112
|
+
pending.push(change);
|
|
113
|
+
if (flushTimer) clearTimeout(flushTimer);
|
|
114
|
+
flushTimer = setTimeout(flush, debounceMs);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const classify = async (
|
|
118
|
+
filename: string,
|
|
119
|
+
eventType: "rename" | "change",
|
|
120
|
+
): Promise<FileChange | undefined> => {
|
|
121
|
+
const path = normalize(join(dir, filename));
|
|
122
|
+
if (path === dir) return undefined;
|
|
123
|
+
let isDirectory = false;
|
|
124
|
+
let exists = true;
|
|
125
|
+
try {
|
|
126
|
+
const info = await lstat(path);
|
|
127
|
+
isDirectory = info.isDirectory();
|
|
128
|
+
if (isDirectory) seenDirectories.add(path);
|
|
129
|
+
} catch {
|
|
130
|
+
exists = false;
|
|
131
|
+
if (seenDirectories.delete(path)) isDirectory = true;
|
|
132
|
+
}
|
|
133
|
+
if (eventType === "change") {
|
|
134
|
+
if (!exists) return { path, type: "deleted", isDirectory };
|
|
135
|
+
if (isDirectory) return undefined;
|
|
136
|
+
return { path, type: "changed", isDirectory };
|
|
137
|
+
}
|
|
138
|
+
// rename:创建 / 删除 / 移入移出
|
|
139
|
+
if (!exists) return { path, type: "deleted", isDirectory };
|
|
140
|
+
if (isDirectory) return undefined;
|
|
141
|
+
return { path, type: "created", isDirectory };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const consumer = (async () => {
|
|
145
|
+
try {
|
|
146
|
+
const iterator = watch(dir, { recursive: true, signal: abort.signal });
|
|
147
|
+
for await (const event of iterator) {
|
|
148
|
+
if (!event.filename) continue;
|
|
149
|
+
const change = await classify(
|
|
150
|
+
event.filename,
|
|
151
|
+
event.eventType === "change" ? "change" : "rename",
|
|
152
|
+
);
|
|
153
|
+
if (!change) continue;
|
|
154
|
+
if (isIgnored(change.path, dir, ignorePatterns)) continue;
|
|
155
|
+
push(change);
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (abort.signal.aborted) return;
|
|
159
|
+
options?.onError?.(`workspace watcher failed for ${dir}: ${String(error)}`);
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
|
|
163
|
+
// 目录不存在 / 无权限等启动期问题在这里暴露,让调用方可以降级
|
|
164
|
+
return lstat(dir).then(() => ({
|
|
165
|
+
async stop(): Promise<void> {
|
|
166
|
+
abort.abort();
|
|
167
|
+
try {
|
|
168
|
+
await consumer;
|
|
169
|
+
} catch {
|
|
170
|
+
// 监听器已因 abort 正常退出
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
}
|
package/src/opencode/files.ts
CHANGED
|
@@ -431,9 +431,9 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
|
|
|
431
431
|
|
|
432
432
|
content = [{ type: "text", text: outputText }];
|
|
433
433
|
|
|
434
|
-
// opencode: LSP
|
|
435
|
-
void service.
|
|
436
|
-
//
|
|
434
|
+
// opencode: LSP 文件事件通知是后台任务,失败不影响读取(read 不驻留文档)
|
|
435
|
+
void service.notifyFile(absolutePath, ctx.cwd).catch(() => {
|
|
436
|
+
// 后台通知失败不影响读取
|
|
437
437
|
});
|
|
438
438
|
|
|
439
439
|
return {
|