@jetcrab/pi-desk 1.1.16 → 1.1.20-dev
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/docs/plugins.md +3 -1
- package/next.config.ts +1 -0
- package/package.json +4 -3
- package/src/client/l2_biz/workbench/file-workspace/l2-file-workspace-runtime.ts +1 -1
- package/src/client/l2_biz/workbench/hooks/l2-workbench-chat-reload.ts +42 -0
- package/src/client/l2_biz/workbench/l2-work-session-sidebar.tsx +19 -1
- package/src/client/l2_biz/workbench/l2-workbench-chat.ts +45 -0
- package/src/client/l2_biz/workbench/l2-workbench.tsx +4 -0
- package/src/client/l3_modules/conversation/l3-conversation-markdown.module.css +1 -0
- package/src/client/l3_modules/conversation/l3-conversation-markdown.tsx +10 -1
- package/src/client/l3_modules/project-files/l3-markdown-preview.tsx +10 -1
- package/src/client/l3_modules/project-files/l3-project-document-view.tsx +2 -0
- package/src/client/l3_modules/project-files/l3-project-file-browser.tsx +171 -229
- package/src/client/l3_modules/project-files/l3-project-file-runtime.ts +133 -70
- package/src/client/l3_modules/project-files/l3-project-reader.ts +4 -3
- package/src/client/l3_modules/project-files/views/l3-project-search-controls.tsx +201 -0
- package/src/client/l3_modules/project-files/views/l3-project-search-results.tsx +333 -0
- package/src/client/l4_foundation/ui/code/l4-code-preview.tsx +47 -3
- package/src/client/l4_foundation/ui/markdown/l4-markdown-remark.ts +89 -0
- package/src/client/l4_foundation/ui/markdown/l4-markdown.module.css +58 -0
- package/src/client/l4_foundation/ui/markdown/l4-markdown.tsx +108 -0
- package/src/common/l2_biz/work-session/l2-work-session-file-contract.ts +4 -3
- package/src/common/l2_biz/workbench/l2-workbench-locale-messages.ts +6 -0
- package/src/common/l3_modules/project-files/l3-project-files-contract.ts +2 -3
- package/src/common/l3_modules/project-files/l3-project-files-locale-messages.ts +56 -10
- package/src/common/l4_foundation/file/l4-project-file-contract.ts +51 -1
- package/src/common/l4_foundation/locale/l4-host-error-messages.ts +11 -0
- package/src/common/l4_foundation/locale/l4-locale-messages.ts +16 -0
- package/src/instrumentation.ts +0 -2
- package/src/server/l1_entry/api/l1-project-read-routes.ts +6 -4
- package/src/server/l1_entry/api/l1-project-route.ts +9 -8
- package/src/server/l1_entry/api/l1-work-session-file-error.ts +23 -0
- package/src/server/l2_biz/plugin-management/l2-plugin-management.ts +0 -3
- package/src/server/l2_biz/work-session/l2-work-session-chat-runtime.ts +30 -58
- package/src/server/l2_biz/work-session/l2-work-session-manage.ts +0 -4
- package/src/server/l4_foundation/file/l4-project-file-index.ts +276 -0
- package/src/server/l4_foundation/file/l4-project-search.ts +399 -0
- package/src/server/l4_foundation/file/l4-ripgrep-search.ts +192 -0
- package/src/server/l4_foundation/file/l4-work-session-project-file.ts +8 -332
- package/src/server/l4_foundation/pi/l4-pi-package-maintenance.ts +0 -6
- package/src/server/l4_foundation/pi/l4-pi-plugin-capability-probe.ts +1 -3
- package/src/server/l4_foundation/pi/l4-pi-plugin-probe.ts +1 -3
- package/src/server/l4_foundation/pi/l4-pi-runner-process.ts +3 -15
- package/src/server/l4_foundation/process/l4-process-tree.js +31 -5
- package/temp/build/pi-desk/release/.next/BUILD_ID +1 -1
- package/temp/build/pi-desk/release/.next/build-manifest.json +3 -3
- package/temp/build/pi-desk/release/.next/prerender-manifest.json +3 -3
- package/temp/build/pi-desk/release/.next/react-loadable-manifest.json +1 -1
- package/temp/build/pi-desk/release/.next/required-server-files.js +4 -3
- package/temp/build/pi-desk/release/.next/required-server-files.json +4 -3
- package/temp/build/pi-desk/release/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.html +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_not-found/page.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/api/health/route.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-files/get/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-files/list/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-files/reveal/route.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-files/search/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-git/get/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-git-branches/list/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-git-changes/list/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-git-diff/get/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-git-heads/get/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/api/project-git-log/list/route.js +2 -1
- package/temp/build/pi-desk/release/.next/server/app/login/page.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/app/page.js +77 -77
- package/temp/build/pi-desk/release/.next/server/app/page_client-reference-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/{33658.js → 10881.js} +8 -8
- package/temp/build/pi-desk/release/.next/server/chunks/23054.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/25406.js +3 -3
- package/temp/build/pi-desk/release/.next/server/chunks/35985.js +5 -5
- package/temp/build/pi-desk/release/.next/server/chunks/50520.js +2 -4
- package/temp/build/pi-desk/release/.next/server/chunks/6917.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/70780.js +1 -0
- package/temp/build/pi-desk/release/.next/server/chunks/74528.js +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/92386.js +1 -1
- package/temp/build/pi-desk/release/.next/server/instrumentation.js +1 -1
- package/temp/build/pi-desk/release/.next/server/middleware-build-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/temp/build/pi-desk/release/.next/server/pages/500.html +1 -1
- package/temp/build/pi-desk/release/.next/server/server-reference-manifest.json +1 -1
- package/temp/build/pi-desk/release/.next/static/chunks/27611-0890482fe76bd9d6.js +238 -0
- package/temp/build/pi-desk/release/.next/static/chunks/{31087-ba426d09d6f68bfd.js → 31087-d8e5765d541d5c26.js} +1 -1
- package/temp/build/pi-desk/release/.next/static/chunks/{49516-cc4dcd84d64ab4fe.js → 51474-8dc714cb76b2e60a.js} +2 -2
- package/temp/build/pi-desk/release/.next/static/chunks/77592-5bef6acb8e7c4b96.js +13 -0
- package/temp/build/pi-desk/release/.next/static/chunks/app/layout-a50c456648537afb.js +3 -0
- package/temp/build/pi-desk/release/.next/static/chunks/app/{page-0069319a4fb97211.js → page-0789c43551b29dd2.js} +4 -4
- package/temp/build/pi-desk/release/.next/static/chunks/{webpack-40a782fc8a3535b9.js → webpack-d7248206b7fc7ba8.js} +1 -1
- package/temp/build/pi-desk/release/.next/static/css/250a6b93cc2222d1.css +1 -0
- package/temp/build/pi-desk/release/.next/static/css/6dbae2922f88ca31.css +1 -0
- package/temp/build/pi-desk/release/.next/static/css/a4cf28fd76c70165.css +1 -0
- package/temp/build/pi-desk/release/.next/trace-build +1 -1
- package/temp/build/pi-desk/release/.next/server/chunks/8584.js +0 -2
- package/temp/build/pi-desk/release/.next/static/chunks/39654-b5fedd762ea0312e.js +0 -238
- package/temp/build/pi-desk/release/.next/static/chunks/75130-d2ca74894aff8085.js +0 -13
- package/temp/build/pi-desk/release/.next/static/chunks/app/layout-c0e1c5cef8620cf8.js +0 -3
- package/temp/build/pi-desk/release/.next/static/css/6427d02b6edf406f.css +0 -1
- package/temp/build/pi-desk/release/.next/static/css/949d0d1a99d5de92.css +0 -1
- /package/temp/build/pi-desk/release/.next/static/{DqamF28k0ShQLbNVzXDIj → RLKcK-RQF5FvEeDmS9uih}/_buildManifest.js +0 -0
- /package/temp/build/pi-desk/release/.next/static/{DqamF28k0ShQLbNVzXDIj → RLKcK-RQF5FvEeDmS9uih}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { lstat, open, realpath, stat } from 'node:fs/promises'
|
|
4
|
+
import { basename, resolve } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
L4_PROJECT_CONTENT_SEARCH_MAX_RESULTS,
|
|
7
|
+
L4_PROJECT_FILE_SEARCH_MAX_RESULTS,
|
|
8
|
+
L4_PROJECT_SEARCH_TEXT_MAX_LENGTH,
|
|
9
|
+
L4_PROJECT_TEXT_FILE_MAX_BYTES,
|
|
10
|
+
L4ProjectFilePathSchema,
|
|
11
|
+
L4ProjectSearchInputSchema,
|
|
12
|
+
type L4ProjectContentMatch,
|
|
13
|
+
type L4ProjectFileMatch,
|
|
14
|
+
type L4ProjectFileSearch,
|
|
15
|
+
type L4ProjectSearchInput,
|
|
16
|
+
type L4ProjectSearchRange
|
|
17
|
+
} from '@common/l4_foundation/file/l4-project-file-contract'
|
|
18
|
+
import { isL4PathInsideRoot } from './l4-file-path'
|
|
19
|
+
import { getSearchIndex } from './l4-project-file-index'
|
|
20
|
+
import { L4WorkSessionProjectFileError } from './l4-work-session-project-file'
|
|
21
|
+
import {
|
|
22
|
+
checkSearchActive,
|
|
23
|
+
L4ProjectSearchError,
|
|
24
|
+
ripgrepPatternArgs,
|
|
25
|
+
ripgrepText,
|
|
26
|
+
runRipgrep,
|
|
27
|
+
type RipgrepBudget,
|
|
28
|
+
type RipgrepMatch
|
|
29
|
+
} from './l4-ripgrep-search'
|
|
30
|
+
|
|
31
|
+
export { L4ProjectSearchError } from './l4-ripgrep-search'
|
|
32
|
+
|
|
33
|
+
// 预留 rg 可执行路径、选项及 Windows 的参数转义开销。
|
|
34
|
+
const MAX_BATCH_ARGUMENT_UNITS = 16_000
|
|
35
|
+
const TEXT_VALIDATION_CONCURRENCY = 16
|
|
36
|
+
|
|
37
|
+
function plainFileMatch(
|
|
38
|
+
path: string,
|
|
39
|
+
input: L4ProjectSearchInput
|
|
40
|
+
): { match: L4ProjectFileMatch; score: number } | null {
|
|
41
|
+
// 词尾 Σ 等转换依赖上下文,匹配值必须整串折叠。
|
|
42
|
+
const value = input.caseSensitive ? path : path.toLocaleLowerCase()
|
|
43
|
+
const query = input.caseSensitive ? input.query : input.query.toLocaleLowerCase()
|
|
44
|
+
const name = basename(value)
|
|
45
|
+
const nameOffset = value.length - name.length
|
|
46
|
+
let score: number
|
|
47
|
+
let positions: number[]
|
|
48
|
+
if (name === query) {
|
|
49
|
+
score = 0
|
|
50
|
+
positions = Array.from({ length: query.length }, (_, index) => nameOffset + index)
|
|
51
|
+
} else if (name.startsWith(query)) {
|
|
52
|
+
score = 100 + name.length - query.length
|
|
53
|
+
positions = Array.from({ length: query.length }, (_, index) => nameOffset + index)
|
|
54
|
+
} else if (name.includes(query)) {
|
|
55
|
+
const start = name.indexOf(query)
|
|
56
|
+
score = 1_000 + start * 4 + name.length
|
|
57
|
+
positions = Array.from({ length: query.length }, (_, index) => nameOffset + start + index)
|
|
58
|
+
} else if (value.includes(query)) {
|
|
59
|
+
const start = value.indexOf(query)
|
|
60
|
+
score = 10_000 + start * 4 + value.length
|
|
61
|
+
positions = Array.from({ length: query.length }, (_, index) => start + index)
|
|
62
|
+
} else {
|
|
63
|
+
positions = []
|
|
64
|
+
let next = 0
|
|
65
|
+
for (let index = 0; index < value.length && next < query.length; index += 1) {
|
|
66
|
+
if (value[index] === query[next]) {
|
|
67
|
+
positions.push(index)
|
|
68
|
+
next += 1
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (next !== query.length) return null
|
|
72
|
+
const gaps = positions.at(-1)! - positions[0]! + 1 - query.length
|
|
73
|
+
score = 100_000 + positions[0]! * 4 + gaps + value.length
|
|
74
|
+
}
|
|
75
|
+
const offsets: Array<{ start: number; end: number }> = []
|
|
76
|
+
let offset = 0
|
|
77
|
+
for (const character of path) {
|
|
78
|
+
const folded = input.caseSensitive ? character : character.toLocaleLowerCase()
|
|
79
|
+
for (let index = 0; index < folded.length; index += 1) {
|
|
80
|
+
offsets.push({ start: offset, end: offset + character.length })
|
|
81
|
+
}
|
|
82
|
+
offset += character.length
|
|
83
|
+
}
|
|
84
|
+
const ranges: L4ProjectSearchRange[] = []
|
|
85
|
+
for (const position of positions) {
|
|
86
|
+
const range = offsets[position]!
|
|
87
|
+
const previous = ranges.at(-1)
|
|
88
|
+
if (previous && previous.end >= range.start) previous.end = Math.max(previous.end, range.end)
|
|
89
|
+
else ranges.push({ ...range })
|
|
90
|
+
}
|
|
91
|
+
return { match: { path, ranges }, score }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function utf16Ranges(text: string, match: RipgrepMatch): L4ProjectSearchRange[] {
|
|
95
|
+
const bytes = Buffer.from(ripgrepText(match.lines))
|
|
96
|
+
// rg 的命中有序且不重叠,逐段转换避免反复解码整段行前缀。
|
|
97
|
+
let byteOffset = 0
|
|
98
|
+
let utf16Offset = 0
|
|
99
|
+
const convert = (offset: number): number => {
|
|
100
|
+
utf16Offset += bytes.subarray(byteOffset, offset).toString('utf8').length
|
|
101
|
+
byteOffset = offset
|
|
102
|
+
return Math.min(text.length, utf16Offset)
|
|
103
|
+
}
|
|
104
|
+
return match.submatches.map(({ start, end }) => ({ start: convert(start), end: convert(end) }))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function contentMatch(path: string, match: RipgrepMatch): L4ProjectContentMatch {
|
|
108
|
+
const text = ripgrepText(match.lines).replace(/\r?\n$/, '')
|
|
109
|
+
const ranges = utf16Ranges(text, match)
|
|
110
|
+
let start = Math.max(0, (ranges[0]?.start ?? 0) - 200)
|
|
111
|
+
let end = Math.min(text.length, start + L4_PROJECT_SEARCH_TEXT_MAX_LENGTH)
|
|
112
|
+
if (start > 0 && /[\uDC00-\uDFFF]/.test(text[start]!) && /[\uD800-\uDBFF]/.test(text[start - 1]!))
|
|
113
|
+
start += 1
|
|
114
|
+
if (
|
|
115
|
+
end < text.length &&
|
|
116
|
+
/[\uD800-\uDBFF]/.test(text[end - 1]!) &&
|
|
117
|
+
/[\uDC00-\uDFFF]/.test(text[end]!)
|
|
118
|
+
)
|
|
119
|
+
end -= 1
|
|
120
|
+
return {
|
|
121
|
+
path,
|
|
122
|
+
line: match.line_number,
|
|
123
|
+
text: text.slice(start, end),
|
|
124
|
+
ranges: ranges
|
|
125
|
+
.filter((range) => range.end >= start && range.start <= end)
|
|
126
|
+
.map((range) => ({
|
|
127
|
+
start: Math.max(start, range.start) - start,
|
|
128
|
+
end: Math.min(end, range.end) - start
|
|
129
|
+
}))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function isSearchableDirectory(
|
|
134
|
+
root: string,
|
|
135
|
+
path: string,
|
|
136
|
+
checks: Map<string, Promise<boolean>>
|
|
137
|
+
): Promise<boolean> {
|
|
138
|
+
if (!path) return true
|
|
139
|
+
const cached = checks.get(path)
|
|
140
|
+
if (cached) return cached
|
|
141
|
+
const check = (async (): Promise<boolean> => {
|
|
142
|
+
const slash = path.lastIndexOf('/')
|
|
143
|
+
if (!(await isSearchableDirectory(root, slash < 0 ? '' : path.slice(0, slash), checks)))
|
|
144
|
+
return false
|
|
145
|
+
const info = await lstat(resolve(root, path))
|
|
146
|
+
return info.isDirectory() && !info.isSymbolicLink()
|
|
147
|
+
})()
|
|
148
|
+
checks.set(path, check)
|
|
149
|
+
return check
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function isSearchableText(
|
|
153
|
+
root: string,
|
|
154
|
+
path: string,
|
|
155
|
+
signal: AbortSignal | undefined,
|
|
156
|
+
budget: RipgrepBudget,
|
|
157
|
+
directoryChecks: Map<string, Promise<boolean>>
|
|
158
|
+
): Promise<boolean> {
|
|
159
|
+
try {
|
|
160
|
+
checkSearchActive(signal, budget)
|
|
161
|
+
const slash = path.lastIndexOf('/')
|
|
162
|
+
if (
|
|
163
|
+
!(await isSearchableDirectory(root, slash < 0 ? '' : path.slice(0, slash), directoryChecks))
|
|
164
|
+
)
|
|
165
|
+
return false
|
|
166
|
+
checkSearchActive(signal, budget)
|
|
167
|
+
const target = resolve(root, path)
|
|
168
|
+
const info = await lstat(target)
|
|
169
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > L4_PROJECT_TEXT_FILE_MAX_BYTES)
|
|
170
|
+
return false
|
|
171
|
+
if (!isL4PathInsideRoot(root, await realpath(target))) return false
|
|
172
|
+
checkSearchActive(signal, budget)
|
|
173
|
+
const handle = await open(target, 'r')
|
|
174
|
+
try {
|
|
175
|
+
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
176
|
+
const buffer = Buffer.alloc(64 * 1024)
|
|
177
|
+
let total = 0
|
|
178
|
+
for (;;) {
|
|
179
|
+
checkSearchActive(signal, budget)
|
|
180
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null)
|
|
181
|
+
if (bytesRead === 0) break
|
|
182
|
+
total += bytesRead
|
|
183
|
+
if (total > L4_PROJECT_TEXT_FILE_MAX_BYTES) return false
|
|
184
|
+
const bytes = buffer.subarray(0, bytesRead)
|
|
185
|
+
if (bytes.includes(0)) return false
|
|
186
|
+
try {
|
|
187
|
+
decoder.decode(bytes, { stream: true })
|
|
188
|
+
} catch {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
decoder.decode()
|
|
194
|
+
} catch {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
return true
|
|
198
|
+
} finally {
|
|
199
|
+
await handle.close()
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
checkSearchActive(signal, budget)
|
|
203
|
+
if (
|
|
204
|
+
error instanceof Error &&
|
|
205
|
+
'code' in error &&
|
|
206
|
+
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
207
|
+
)
|
|
208
|
+
return false
|
|
209
|
+
throw new L4ProjectSearchError('read-failed', `无法读取搜索文件:${path}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function waitForIndex(
|
|
214
|
+
root: string,
|
|
215
|
+
signal: AbortSignal | undefined,
|
|
216
|
+
budget: RipgrepBudget
|
|
217
|
+
): ReturnType<typeof getSearchIndex> {
|
|
218
|
+
checkSearchActive(signal, budget)
|
|
219
|
+
return new Promise((resolveIndex, rejectIndex) => {
|
|
220
|
+
const cleanup = (): void => {
|
|
221
|
+
clearTimeout(timer)
|
|
222
|
+
signal?.removeEventListener('abort', abort)
|
|
223
|
+
}
|
|
224
|
+
const abort = (): void => {
|
|
225
|
+
cleanup()
|
|
226
|
+
rejectIndex(signal?.reason ?? new DOMException('搜索已取消', 'AbortError'))
|
|
227
|
+
}
|
|
228
|
+
const timer = setTimeout(
|
|
229
|
+
() => {
|
|
230
|
+
cleanup()
|
|
231
|
+
rejectIndex(new L4ProjectSearchError('timeout', '项目搜索超时,请缩小搜索范围'))
|
|
232
|
+
},
|
|
233
|
+
Math.max(1, budget.deadline - Date.now())
|
|
234
|
+
)
|
|
235
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
236
|
+
// 只取消本次等待,不中止其他调用者共用的索引构建。
|
|
237
|
+
getSearchIndex(root).then(resolveIndex, rejectIndex).finally(cleanup)
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function searchL4ProjectFiles(
|
|
242
|
+
cwd: string,
|
|
243
|
+
input: L4ProjectSearchInput,
|
|
244
|
+
signal?: AbortSignal
|
|
245
|
+
): Promise<L4ProjectFileSearch> {
|
|
246
|
+
const started = Date.now()
|
|
247
|
+
const budget: RipgrepBudget = { deadline: started + 10_000, outputBytes: 0 }
|
|
248
|
+
let stage = 'index'
|
|
249
|
+
try {
|
|
250
|
+
checkSearchActive(signal, budget)
|
|
251
|
+
// HTTP 以外的调用者也遵守公共搜索条件,实际搜索词不 trim。
|
|
252
|
+
if (!L4ProjectSearchInputSchema.safeParse(input).success)
|
|
253
|
+
throw new L4ProjectSearchError('invalid-pattern', '搜索条件无效')
|
|
254
|
+
let root: string
|
|
255
|
+
try {
|
|
256
|
+
root = await realpath(cwd)
|
|
257
|
+
if (!(await stat(root)).isDirectory())
|
|
258
|
+
throw new L4WorkSessionProjectFileError('not-directory', '项目根路径不是目录')
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof L4WorkSessionProjectFileError) throw error
|
|
261
|
+
throw new L4WorkSessionProjectFileError('not-found', '项目目录不存在')
|
|
262
|
+
}
|
|
263
|
+
const index = await waitForIndex(root, signal, budget)
|
|
264
|
+
checkSearchActive(signal, budget)
|
|
265
|
+
const paths = index.files.filter((path) => L4ProjectFilePathSchema.safeParse(path).success)
|
|
266
|
+
const result: L4ProjectFileSearch = {
|
|
267
|
+
fileMatches: [],
|
|
268
|
+
contentMatches: [],
|
|
269
|
+
truncated: index.truncated
|
|
270
|
+
}
|
|
271
|
+
const args = ripgrepPatternArgs(input)
|
|
272
|
+
if (input.scope !== 'content') {
|
|
273
|
+
stage = 'files'
|
|
274
|
+
if (input.regex || input.wholeWord) {
|
|
275
|
+
const candidates = new Set(paths)
|
|
276
|
+
const truncated = await runRipgrep(
|
|
277
|
+
root,
|
|
278
|
+
[...args, '--null-data', '-'],
|
|
279
|
+
budget,
|
|
280
|
+
signal,
|
|
281
|
+
(match) => {
|
|
282
|
+
const path = ripgrepText(match.lines).replace(/\0$/, '')
|
|
283
|
+
if (!candidates.has(path)) return true
|
|
284
|
+
if (result.fileMatches.length >= L4_PROJECT_FILE_SEARCH_MAX_RESULTS) return false
|
|
285
|
+
result.fileMatches.push({ path, ranges: utf16Ranges(path, match) })
|
|
286
|
+
return true
|
|
287
|
+
},
|
|
288
|
+
(function* (): Generator<string> {
|
|
289
|
+
for (const path of paths) yield `${path}\0`
|
|
290
|
+
})()
|
|
291
|
+
)
|
|
292
|
+
result.truncated ||= truncated
|
|
293
|
+
} else {
|
|
294
|
+
const matches = paths
|
|
295
|
+
.flatMap((path) => {
|
|
296
|
+
const match = plainFileMatch(path, input)
|
|
297
|
+
return match ? [match] : []
|
|
298
|
+
})
|
|
299
|
+
.sort(
|
|
300
|
+
(left, right) =>
|
|
301
|
+
left.score - right.score || left.match.path.localeCompare(right.match.path)
|
|
302
|
+
)
|
|
303
|
+
result.fileMatches = matches
|
|
304
|
+
.slice(0, L4_PROJECT_FILE_SEARCH_MAX_RESULTS)
|
|
305
|
+
.map(({ match }) => match)
|
|
306
|
+
result.truncated ||= matches.length > L4_PROJECT_FILE_SEARCH_MAX_RESULTS
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// 输出预算已经耗尽时,不再启动正文批次;文件结果截断不影响正文分支。
|
|
310
|
+
if (input.scope !== 'files' && budget.outputBytes <= 4 * 1024 * 1024) {
|
|
311
|
+
stage = 'content'
|
|
312
|
+
let batch: string[] = []
|
|
313
|
+
let units = 0
|
|
314
|
+
let searched = false
|
|
315
|
+
let stopped = false
|
|
316
|
+
// 只在本次请求复用目录检查;不保存正文或跨请求的校验结论。
|
|
317
|
+
const directoryChecks = new Map<string, Promise<boolean>>()
|
|
318
|
+
const flush = async (): Promise<void> => {
|
|
319
|
+
const validPaths: string[] = []
|
|
320
|
+
for (let offset = 0; offset < batch.length; offset += TEXT_VALIDATION_CONCURRENCY) {
|
|
321
|
+
checkSearchActive(signal, budget)
|
|
322
|
+
const paths = batch.slice(offset, offset + TEXT_VALIDATION_CONCURRENCY)
|
|
323
|
+
// 等待同组全部释放文件句柄后再抛错,不让取消留下后台校验。
|
|
324
|
+
const checked = await Promise.allSettled(
|
|
325
|
+
paths.map((path) => isSearchableText(root, path, signal, budget, directoryChecks))
|
|
326
|
+
)
|
|
327
|
+
for (const [index, entry] of checked.entries()) {
|
|
328
|
+
if (entry.status === 'rejected') throw entry.reason
|
|
329
|
+
if (entry.value) validPaths.push(paths[index]!)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
batch = []
|
|
333
|
+
units = 0
|
|
334
|
+
if (validPaths.length === 0) return
|
|
335
|
+
searched = true
|
|
336
|
+
const candidates = new Set(validPaths)
|
|
337
|
+
stopped = await runRipgrep(
|
|
338
|
+
root,
|
|
339
|
+
[
|
|
340
|
+
...args,
|
|
341
|
+
'--no-ignore',
|
|
342
|
+
'--hidden',
|
|
343
|
+
'--no-follow',
|
|
344
|
+
'--no-mmap',
|
|
345
|
+
'--threads',
|
|
346
|
+
'1',
|
|
347
|
+
'--encoding',
|
|
348
|
+
'utf-8',
|
|
349
|
+
'--max-filesize',
|
|
350
|
+
String(L4_PROJECT_TEXT_FILE_MAX_BYTES),
|
|
351
|
+
'--',
|
|
352
|
+
...validPaths.map((path) => `./${path}`)
|
|
353
|
+
],
|
|
354
|
+
budget,
|
|
355
|
+
signal,
|
|
356
|
+
(match) => {
|
|
357
|
+
const path = ripgrepText(match.path).replace(/^\.\//, '').replaceAll('\\', '/')
|
|
358
|
+
if (!candidates.has(path) || !L4ProjectFilePathSchema.safeParse(path).success)
|
|
359
|
+
return true
|
|
360
|
+
if (result.contentMatches.length >= L4_PROJECT_CONTENT_SEARCH_MAX_RESULTS) return false
|
|
361
|
+
result.contentMatches.push(contentMatch(path, match))
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
result.truncated ||= stopped
|
|
366
|
+
}
|
|
367
|
+
for (const path of paths) {
|
|
368
|
+
checkSearchActive(signal, budget)
|
|
369
|
+
const cost = Math.max(path.length, Buffer.byteLength(path)) * 2 + 8
|
|
370
|
+
if (units + cost > MAX_BATCH_ARGUMENT_UNITS) await flush()
|
|
371
|
+
if (stopped) break
|
|
372
|
+
batch.push(path)
|
|
373
|
+
units += cost
|
|
374
|
+
}
|
|
375
|
+
if (!stopped) await flush()
|
|
376
|
+
// 空项目同样交给 Rust 引擎验证正则语法。
|
|
377
|
+
if (!searched && input.regex && input.scope === 'content') {
|
|
378
|
+
const truncated = await runRipgrep(root, [...args, '-'], budget, signal, () => true)
|
|
379
|
+
result.truncated ||= truncated
|
|
380
|
+
}
|
|
381
|
+
result.contentMatches.sort(
|
|
382
|
+
(left, right) => left.path.localeCompare(right.path) || left.line - right.line
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
checkSearchActive(signal, budget)
|
|
386
|
+
return result
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (!signal?.aborted && !(error instanceof Error && error.name === 'AbortError')) {
|
|
389
|
+
console.warn('[Pi Desk][Project Search] 项目搜索失败', {
|
|
390
|
+
cwd,
|
|
391
|
+
scope: input.scope,
|
|
392
|
+
stage,
|
|
393
|
+
elapsedMs: Date.now() - started,
|
|
394
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
throw error
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'node:child_process'
|
|
4
|
+
import { Readable } from 'node:stream'
|
|
5
|
+
import type { L4ProjectSearchInput } from '@common/l4_foundation/file/l4-project-file-contract'
|
|
6
|
+
|
|
7
|
+
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
8
|
+
const MAX_RECORD_BYTES = 1024 * 1024
|
|
9
|
+
const MAX_STDERR_BYTES = 64 * 1024
|
|
10
|
+
|
|
11
|
+
export class L4ProjectSearchError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
readonly kind: 'invalid-pattern' | 'unavailable' | 'timeout' | 'read-failed',
|
|
14
|
+
message: string
|
|
15
|
+
) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'L4ProjectSearchError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RipgrepMatch {
|
|
22
|
+
path: { text?: string; bytes?: string }
|
|
23
|
+
lines: { text?: string; bytes?: string }
|
|
24
|
+
line_number: number
|
|
25
|
+
submatches: Array<{ start: number; end: number }>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RipgrepBudget {
|
|
29
|
+
deadline: number
|
|
30
|
+
outputBytes: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function checkSearchActive(signal: AbortSignal | undefined, budget: RipgrepBudget): void {
|
|
34
|
+
signal?.throwIfAborted()
|
|
35
|
+
if (Date.now() >= budget.deadline) {
|
|
36
|
+
throw new L4ProjectSearchError('timeout', '项目搜索超时,请缩小搜索范围')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ripgrepPatternArgs(input: L4ProjectSearchInput): string[] {
|
|
41
|
+
return [
|
|
42
|
+
'--no-config',
|
|
43
|
+
'--json',
|
|
44
|
+
input.caseSensitive ? '--case-sensitive' : '--ignore-case',
|
|
45
|
+
...(input.regex ? [] : ['--fixed-strings']),
|
|
46
|
+
...(input.wholeWord ? ['--word-regexp'] : []),
|
|
47
|
+
'-e',
|
|
48
|
+
input.query
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function resolveRipgrep(): Promise<string> {
|
|
53
|
+
try {
|
|
54
|
+
// ESM 导入让 Next 保留外部依赖;require 会导致开发编译器继续解析平台二进制。
|
|
55
|
+
return (await import('@vscode/ripgrep')).rgPath
|
|
56
|
+
} catch {
|
|
57
|
+
throw new L4ProjectSearchError('unavailable', '项目搜索引擎不可用,请重新安装完整运行依赖')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runRipgrep(
|
|
62
|
+
cwd: string,
|
|
63
|
+
args: string[],
|
|
64
|
+
budget: RipgrepBudget,
|
|
65
|
+
signal: AbortSignal | undefined,
|
|
66
|
+
onMatch: (match: RipgrepMatch) => boolean,
|
|
67
|
+
stdin?: Iterable<string>
|
|
68
|
+
): Promise<boolean> {
|
|
69
|
+
checkSearchActive(signal, budget)
|
|
70
|
+
const executable = await resolveRipgrep()
|
|
71
|
+
checkSearchActive(signal, budget)
|
|
72
|
+
return new Promise<boolean>((resolveRun, rejectRun) => {
|
|
73
|
+
const child = spawn(executable, args, { cwd, shell: false, windowsHide: true, stdio: 'pipe' })
|
|
74
|
+
let pending = Buffer.alloc(0)
|
|
75
|
+
let stderr = Buffer.alloc(0)
|
|
76
|
+
let truncated = false
|
|
77
|
+
let failure: unknown
|
|
78
|
+
let stopped = false
|
|
79
|
+
const source = stdin ? Readable.from(stdin) : null
|
|
80
|
+
|
|
81
|
+
const stop = (): void => {
|
|
82
|
+
stopped = true
|
|
83
|
+
source?.unpipe(child.stdin)
|
|
84
|
+
source?.destroy()
|
|
85
|
+
child.stdin.destroy()
|
|
86
|
+
child.kill()
|
|
87
|
+
}
|
|
88
|
+
const abort = (): void => {
|
|
89
|
+
failure = signal?.reason ?? new DOMException('搜索已取消', 'AbortError')
|
|
90
|
+
stop()
|
|
91
|
+
}
|
|
92
|
+
const timer = setTimeout(
|
|
93
|
+
() => {
|
|
94
|
+
failure = new L4ProjectSearchError('timeout', '项目搜索超时,请缩小搜索范围')
|
|
95
|
+
stop()
|
|
96
|
+
},
|
|
97
|
+
Math.max(1, budget.deadline - Date.now())
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const consume = (line: Buffer): void => {
|
|
101
|
+
if (line.length === 0) return
|
|
102
|
+
try {
|
|
103
|
+
const event = JSON.parse(line.toString('utf8')) as { type: string; data: RipgrepMatch }
|
|
104
|
+
if (event.type === 'match' && !onMatch(event.data)) {
|
|
105
|
+
truncated = true
|
|
106
|
+
stop()
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
failure =
|
|
110
|
+
error instanceof L4ProjectSearchError
|
|
111
|
+
? error
|
|
112
|
+
: new L4ProjectSearchError('read-failed', '无法解析项目搜索结果')
|
|
113
|
+
stop()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
child.stdout.on('data', (chunk: Buffer) => {
|
|
117
|
+
if (stopped) return
|
|
118
|
+
const remaining = MAX_OUTPUT_BYTES - budget.outputBytes
|
|
119
|
+
budget.outputBytes += chunk.length
|
|
120
|
+
const accepted = chunk.subarray(0, Math.max(0, remaining))
|
|
121
|
+
pending = Buffer.concat([pending, accepted])
|
|
122
|
+
let end: number
|
|
123
|
+
while (!stopped && (end = pending.indexOf(10)) >= 0) {
|
|
124
|
+
if (end > MAX_RECORD_BYTES) {
|
|
125
|
+
budget.outputBytes = MAX_OUTPUT_BYTES + 1
|
|
126
|
+
truncated = true
|
|
127
|
+
stop()
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
consume(pending.subarray(0, end))
|
|
131
|
+
pending = pending.subarray(end + 1)
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
!stopped &&
|
|
135
|
+
(pending.length > MAX_RECORD_BYTES || budget.outputBytes > MAX_OUTPUT_BYTES)
|
|
136
|
+
) {
|
|
137
|
+
budget.outputBytes = MAX_OUTPUT_BYTES + 1
|
|
138
|
+
truncated = true
|
|
139
|
+
stop()
|
|
140
|
+
}
|
|
141
|
+
if (stopped) pending = Buffer.alloc(0)
|
|
142
|
+
})
|
|
143
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
144
|
+
const remaining = MAX_STDERR_BYTES - stderr.length
|
|
145
|
+
if (remaining > 0) stderr = Buffer.concat([stderr, chunk.subarray(0, remaining)])
|
|
146
|
+
})
|
|
147
|
+
child.stdin.on('error', (error: NodeJS.ErrnoException) => {
|
|
148
|
+
// rg 可因非法正则先关闭 stdin,真实原因由 close 的退出码与 stderr 决定。
|
|
149
|
+
if (error.code !== 'EPIPE' && error.code !== 'ERR_STREAM_DESTROYED' && !stopped) {
|
|
150
|
+
failure = new L4ProjectSearchError('read-failed', '无法向项目搜索引擎传递文件列表')
|
|
151
|
+
stop()
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
child.once('error', () => {
|
|
155
|
+
failure = new L4ProjectSearchError('unavailable', '项目搜索引擎无法启动,请检查运行依赖')
|
|
156
|
+
})
|
|
157
|
+
child.once('close', (code) => {
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
signal?.removeEventListener('abort', abort)
|
|
160
|
+
source?.unpipe(child.stdin)
|
|
161
|
+
source?.destroy()
|
|
162
|
+
child.stdin.destroy()
|
|
163
|
+
if (!stopped && pending.length > 0) consume(pending)
|
|
164
|
+
pending = Buffer.alloc(0)
|
|
165
|
+
const diagnostic = stderr.toString('utf8')
|
|
166
|
+
stderr = Buffer.alloc(0)
|
|
167
|
+
if (failure) rejectRun(failure)
|
|
168
|
+
else if (truncated || code === 0 || code === 1) resolveRun(truncated)
|
|
169
|
+
else if (
|
|
170
|
+
/regex parse error|regex syntax error|error parsing.*regex|compiled regex exceeds size limit/i.test(
|
|
171
|
+
diagnostic
|
|
172
|
+
)
|
|
173
|
+
) {
|
|
174
|
+
rejectRun(new L4ProjectSearchError('invalid-pattern', '正则表达式无效或包含不支持的语法'))
|
|
175
|
+
} else
|
|
176
|
+
rejectRun(
|
|
177
|
+
new L4ProjectSearchError(
|
|
178
|
+
'read-failed',
|
|
179
|
+
`项目搜索读取失败:${diagnostic.trim() || `退出码 ${code}`}`
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
184
|
+
if (signal?.aborted) abort()
|
|
185
|
+
else if (source) source.pipe(child.stdin)
|
|
186
|
+
else child.stdin.end()
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function ripgrepText(value: { text?: string; bytes?: string }): string {
|
|
191
|
+
return value.text ?? Buffer.from(value.bytes ?? '', 'base64').toString('utf8')
|
|
192
|
+
}
|