dsh-locator 0.7.7 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README-es.md +8 -1
- package/README-hi.md +8 -1
- package/README-pt.md +8 -1
- package/README.en.md +8 -1
- package/README.md +8 -1
- package/client.js +782 -320
- package/lib/combined.d.ts +110 -0
- package/lib/combined.d.ts.map +1 -0
- package/lib/combined.js +190 -0
- package/lib/combined.js.map +1 -0
- package/lib/http.d.ts.map +1 -1
- package/lib/http.js +30 -2
- package/lib/http.js.map +1 -1
- package/package.json +1 -1
- package/src/combined.ts +267 -0
- package/src/http.ts +36 -2
package/src/combined.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combined search: one request for the three scopes the Web panel shows by
|
|
3
|
+
* default — content (bodies and sessions), files and directories.
|
|
4
|
+
*
|
|
5
|
+
* The name index and the content index live in different stores, so this module
|
|
6
|
+
* owns the orchestration and the merge rules as pure, injectable functions:
|
|
7
|
+
* scope gating, the lazy-build guard for empty queries, per-group degradation
|
|
8
|
+
* and de-duplication of a path that appears both as a body hit and a name hit.
|
|
9
|
+
* @module dsh-locator/combined
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ContentHit, ContentState } from './content.ts'
|
|
13
|
+
import type { SearchArgs, SearchHit, SearchResult } from './runtime.ts'
|
|
14
|
+
|
|
15
|
+
/** Which groups a combined request asked for; every scope is on by default. */
|
|
16
|
+
export interface CombinedScopes {
|
|
17
|
+
content: boolean
|
|
18
|
+
files: boolean
|
|
19
|
+
dirs: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A validated combined request with per-group limits already clamped. */
|
|
23
|
+
export interface CombinedRequest {
|
|
24
|
+
query: string
|
|
25
|
+
limit: number
|
|
26
|
+
contentLimit: number
|
|
27
|
+
sort: string
|
|
28
|
+
desc: boolean
|
|
29
|
+
scopes: CombinedScopes
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One name-index group: files or directories. */
|
|
33
|
+
export interface NameGroup {
|
|
34
|
+
hits: SearchHit[]
|
|
35
|
+
returned: number
|
|
36
|
+
total: number
|
|
37
|
+
truncated: boolean
|
|
38
|
+
/** Rows dropped because the same path already appears in the content group. */
|
|
39
|
+
deduped: number
|
|
40
|
+
tookMs: number
|
|
41
|
+
error?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The content group: body/session hits plus the index state at query time. */
|
|
45
|
+
export interface ContentGroup {
|
|
46
|
+
hits: ContentHit[]
|
|
47
|
+
returned: number
|
|
48
|
+
/** True when the group was cut off by its page limit. */
|
|
49
|
+
capped: boolean
|
|
50
|
+
building: boolean
|
|
51
|
+
files: number
|
|
52
|
+
sessions: number
|
|
53
|
+
messages: number
|
|
54
|
+
truncated: boolean
|
|
55
|
+
/** Wall-clock time of the content lookup; 0 when it was skipped. */
|
|
56
|
+
tookMs: number
|
|
57
|
+
error?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Everything the panel needs from one combined search. */
|
|
61
|
+
export interface CombinedResponse {
|
|
62
|
+
scopes: CombinedScopes
|
|
63
|
+
/** False when the plugin runs with `contentIndex: false`. */
|
|
64
|
+
contentEnabled: boolean
|
|
65
|
+
content?: ContentGroup
|
|
66
|
+
files?: NameGroup
|
|
67
|
+
dirs?: NameGroup
|
|
68
|
+
tookMs: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Dependencies required to run a combined search; the HTTP layer supplies them. */
|
|
72
|
+
export interface CombinedDeps {
|
|
73
|
+
search(args: SearchArgs): Promise<SearchResult>
|
|
74
|
+
contentSearch?: (query: string, limit: number) => Promise<ContentHit[]>
|
|
75
|
+
contentStatus?: () => ContentState
|
|
76
|
+
ensure?: () => void
|
|
77
|
+
maxResults: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the three scope flags from a request body. Anything that is not an
|
|
82
|
+
* explicit `false` counts as enabled, so a panel that only sends `{ content:
|
|
83
|
+
* true }` still gets the name groups.
|
|
84
|
+
* @param body - parsed request body.
|
|
85
|
+
* @returns normalized scopes.
|
|
86
|
+
*/
|
|
87
|
+
export function readScopes(body: Record<string, unknown>): CombinedScopes {
|
|
88
|
+
return {
|
|
89
|
+
content: body.content !== false,
|
|
90
|
+
files: body.files !== false,
|
|
91
|
+
dirs: body.dirs !== false,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate and clamp a combined request body. The two pages have separate
|
|
97
|
+
* limits: content hits are heavier (bodies/snippets) than name rows.
|
|
98
|
+
* @param body - parsed request body.
|
|
99
|
+
* @param maxResults - the plugin's hard per-page cap.
|
|
100
|
+
* @returns a normalized request.
|
|
101
|
+
*/
|
|
102
|
+
export function readCombinedRequest(body: Record<string, unknown>, maxResults: number): CombinedRequest {
|
|
103
|
+
const nameLimit = clamp(body.limit, 1, Math.max(1, maxResults), 50)
|
|
104
|
+
const contentLimit = clamp(body.contentLimit, 1, 100, 30)
|
|
105
|
+
return {
|
|
106
|
+
query: typeof body.query === 'string' ? body.query : '',
|
|
107
|
+
limit: nameLimit,
|
|
108
|
+
contentLimit,
|
|
109
|
+
sort: sortKey(body.sort),
|
|
110
|
+
desc: body.desc === true,
|
|
111
|
+
scopes: readScopes(body),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Drop name hits whose path is already present in the content group. Content
|
|
117
|
+
* rows carry the snippet and the jump target, so one row per path is enough;
|
|
118
|
+
* directories are never dropped.
|
|
119
|
+
* @param contentHits - body/session hits from the content index.
|
|
120
|
+
* @param nameHits - hits from the name index.
|
|
121
|
+
* @returns the kept hits and how many were dropped.
|
|
122
|
+
*/
|
|
123
|
+
export function dedupeNameHits(
|
|
124
|
+
contentHits: readonly ContentHit[],
|
|
125
|
+
nameHits: readonly SearchHit[],
|
|
126
|
+
): { hits: SearchHit[], deduped: number } {
|
|
127
|
+
const seen = new Set<string>()
|
|
128
|
+
for (const hit of contentHits) seen.add(hit.path)
|
|
129
|
+
if (seen.size === 0) return { hits: [...nameHits], deduped: 0 }
|
|
130
|
+
const hits: SearchHit[] = []
|
|
131
|
+
let deduped = 0
|
|
132
|
+
for (const hit of nameHits) {
|
|
133
|
+
if (!hit.dir && seen.has(hit.path)) {
|
|
134
|
+
deduped++
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
hits.push(hit)
|
|
138
|
+
}
|
|
139
|
+
return { hits, deduped }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Run one combined search. Empty queries never touch the content index: the
|
|
144
|
+
* panel opens with an empty box, and a lazy content build there would cost
|
|
145
|
+
* minutes and gigabytes for no results. Failures are contained per group so a
|
|
146
|
+
* broken content cache still shows name results.
|
|
147
|
+
* @param deps - stores and the lazy-start hook.
|
|
148
|
+
* @param request - normalized request.
|
|
149
|
+
* @returns grouped result in content → files → dirs order.
|
|
150
|
+
*/
|
|
151
|
+
export async function runCombinedSearch(deps: CombinedDeps, request: CombinedRequest): Promise<CombinedResponse> {
|
|
152
|
+
const started = Date.now()
|
|
153
|
+
const { scopes, query } = request
|
|
154
|
+
const contentEnabled = deps.contentSearch !== undefined
|
|
155
|
+
const wantsContent = scopes.content && contentEnabled
|
|
156
|
+
// Two characters are the floor for the content store: one-character queries
|
|
157
|
+
// fall back to an unindexed LIKE scan and are not worth a build or a query.
|
|
158
|
+
const askContent = wantsContent && query.trim().length >= 2
|
|
159
|
+
|
|
160
|
+
const contentPromise = wantsContent ? contentGroup(deps, request, askContent) : Promise.resolve(undefined)
|
|
161
|
+
const filesPromise = scopes.files ? nameGroup(deps, request, true) : Promise.resolve(undefined)
|
|
162
|
+
const dirsPromise = scopes.dirs ? nameGroup(deps, request, false) : Promise.resolve(undefined)
|
|
163
|
+
const [content, files, dirs] = await Promise.all([contentPromise, filesPromise, dirsPromise])
|
|
164
|
+
|
|
165
|
+
const dedupedFiles = files === undefined
|
|
166
|
+
? undefined
|
|
167
|
+
: (() => {
|
|
168
|
+
const result = dedupeNameHits(content?.hits ?? [], files.hits)
|
|
169
|
+
return {
|
|
170
|
+
...files,
|
|
171
|
+
hits: result.hits,
|
|
172
|
+
returned: result.hits.length,
|
|
173
|
+
deduped: result.deduped,
|
|
174
|
+
truncated: files.truncated || result.deduped > 0,
|
|
175
|
+
}
|
|
176
|
+
})()
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
scopes,
|
|
180
|
+
contentEnabled,
|
|
181
|
+
...(content === undefined ? {} : { content }),
|
|
182
|
+
...(dedupedFiles === undefined ? {} : { files: dedupedFiles }),
|
|
183
|
+
...(dirs === undefined ? {} : { dirs }),
|
|
184
|
+
tookMs: Date.now() - started,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Page through a directory-shaped name query. */
|
|
189
|
+
async function nameGroup(deps: CombinedDeps, request: CombinedRequest, files: boolean): Promise<NameGroup> {
|
|
190
|
+
const started = Date.now()
|
|
191
|
+
try {
|
|
192
|
+
const result = await deps.search({
|
|
193
|
+
query: request.query,
|
|
194
|
+
limit: request.limit,
|
|
195
|
+
offset: 0,
|
|
196
|
+
sort: request.sort,
|
|
197
|
+
desc: request.desc,
|
|
198
|
+
regex: false,
|
|
199
|
+
caseSensitive: false,
|
|
200
|
+
wholeWord: false,
|
|
201
|
+
filesOnly: files,
|
|
202
|
+
dirsOnly: !files,
|
|
203
|
+
})
|
|
204
|
+
return {
|
|
205
|
+
hits: result.hits,
|
|
206
|
+
returned: result.hits.length,
|
|
207
|
+
total: result.total,
|
|
208
|
+
truncated: result.truncated,
|
|
209
|
+
deduped: 0,
|
|
210
|
+
tookMs: result.tookNs / 1e6,
|
|
211
|
+
}
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return { hits: [], returned: 0, total: 0, truncated: false, deduped: 0, tookMs: Date.now() - started, error: messageOf(error) }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Query the content store, or report its state without starting a build. */
|
|
218
|
+
async function contentGroup(deps: CombinedDeps, request: CombinedRequest, ask: boolean): Promise<ContentGroup> {
|
|
219
|
+
const started = Date.now()
|
|
220
|
+
const empty = (): ContentGroup => {
|
|
221
|
+
const state = deps.contentStatus?.()
|
|
222
|
+
return {
|
|
223
|
+
hits: [],
|
|
224
|
+
returned: 0,
|
|
225
|
+
capped: false,
|
|
226
|
+
building: state?.building ?? false,
|
|
227
|
+
files: state?.files ?? 0,
|
|
228
|
+
sessions: state?.sessions ?? 0,
|
|
229
|
+
messages: state?.messages ?? 0,
|
|
230
|
+
truncated: state?.truncated ?? false,
|
|
231
|
+
tookMs: 0,
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (!ask || deps.contentSearch === undefined) return empty()
|
|
235
|
+
try {
|
|
236
|
+
deps.ensure?.()
|
|
237
|
+
const hits = await deps.contentSearch(request.query, request.contentLimit)
|
|
238
|
+
const state = deps.contentStatus?.()
|
|
239
|
+
return {
|
|
240
|
+
hits,
|
|
241
|
+
returned: hits.length,
|
|
242
|
+
capped: hits.length >= request.contentLimit,
|
|
243
|
+
building: state?.building ?? false,
|
|
244
|
+
files: state?.files ?? 0,
|
|
245
|
+
sessions: state?.sessions ?? 0,
|
|
246
|
+
messages: state?.messages ?? 0,
|
|
247
|
+
truncated: state?.truncated ?? false,
|
|
248
|
+
tookMs: Date.now() - started,
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return { ...empty(), error: messageOf(error), tookMs: Date.now() - started }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function clamp(value: unknown, min: number, max: number, fallback: number): number {
|
|
256
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback
|
|
257
|
+
return Math.min(max, Math.max(min, Math.trunc(value)))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sortKey(value: unknown): string {
|
|
261
|
+
return value === 'name' || value === 'path' || value === 'size' || value === 'mtime' || value === 'ext' ? value : 'mtime'
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function messageOf(error: unknown): string {
|
|
265
|
+
return error instanceof Error ? error.message : String(error)
|
|
266
|
+
}
|
|
267
|
+
|
package/src/http.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
16
16
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
17
17
|
import type { LocatorRuntime, SearchArgs } from './runtime.ts'
|
|
18
18
|
import type { ContentIndex } from './content.ts'
|
|
19
|
+
import { readCombinedRequest, runCombinedSearch, type CombinedDeps } from './combined.ts'
|
|
19
20
|
import { openWithDefaultApp, revealInFileManager, withinRoots } from './reveal.ts'
|
|
20
21
|
import { statSync } from 'node:fs'
|
|
21
22
|
|
|
@@ -63,13 +64,18 @@ export function registerHttp(ctx: Context, rt: LocatorRuntime, content?: Content
|
|
|
63
64
|
scoped.effect(() => server.register({
|
|
64
65
|
kind: 'exact',
|
|
65
66
|
path: '/dsh-locator/status',
|
|
66
|
-
handler: handler(scoped, async () => await status(rt)),
|
|
67
|
+
handler: handler(scoped, async () => await status(rt, content)),
|
|
67
68
|
}))
|
|
68
69
|
scoped.effect(() => server.register({
|
|
69
70
|
kind: 'exact',
|
|
70
71
|
path: '/dsh-locator/reveal',
|
|
71
72
|
handler: handler(scoped, async body => await reveal(rt, body)),
|
|
72
73
|
}))
|
|
74
|
+
scoped.effect(() => server.register({
|
|
75
|
+
kind: 'exact',
|
|
76
|
+
path: '/dsh-locator/combined',
|
|
77
|
+
handler: handler(scoped, async body => await combined(rt, content, ensure, body)),
|
|
78
|
+
}))
|
|
73
79
|
if (content !== undefined) {
|
|
74
80
|
scoped.effect(() => server.register({
|
|
75
81
|
kind: 'exact',
|
|
@@ -154,6 +160,32 @@ async function search(rt: LocatorRuntime, raw: Record<string, unknown>): Promise
|
|
|
154
160
|
}
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
/**
|
|
164
|
+
* One panel search across all enabled scopes. The stores are injected so the
|
|
165
|
+
* orchestration (scope gating, lazy-build guard, de-duplication) stays testable
|
|
166
|
+
* without an HTTP request or an engine process.
|
|
167
|
+
*/
|
|
168
|
+
async function combined(
|
|
169
|
+
rt: LocatorRuntime,
|
|
170
|
+
content: ContentIndex | undefined,
|
|
171
|
+
ensure: (() => void) | undefined,
|
|
172
|
+
raw: Record<string, unknown>,
|
|
173
|
+
): Promise<unknown> {
|
|
174
|
+
rt.start()
|
|
175
|
+
const deps: CombinedDeps = {
|
|
176
|
+
search: async args => await rt.search(args),
|
|
177
|
+
maxResults: rt.config.maxResults,
|
|
178
|
+
...(content === undefined ? {} : {
|
|
179
|
+
contentSearch: async (query: string, limit: number) => await content.search(query, limit),
|
|
180
|
+
contentStatus: () => content.status(),
|
|
181
|
+
...(ensure === undefined ? {} : { ensure }),
|
|
182
|
+
}),
|
|
183
|
+
}
|
|
184
|
+
const request = readCombinedRequest(raw, rt.config.maxResults)
|
|
185
|
+
const result = await runCombinedSearch(deps, request)
|
|
186
|
+
return { query: request.query, ...result }
|
|
187
|
+
}
|
|
188
|
+
|
|
157
189
|
async function reveal(rt: LocatorRuntime, raw: Record<string, unknown>): Promise<unknown> {
|
|
158
190
|
const path = typeof raw.path === 'string' ? raw.path : ''
|
|
159
191
|
if (path === '' || !withinRoots(path, rt.roots)) {
|
|
@@ -192,9 +224,10 @@ async function openPath(rt: LocatorRuntime, raw: Record<string, unknown>): Promi
|
|
|
192
224
|
}
|
|
193
225
|
}
|
|
194
226
|
|
|
195
|
-
async function status(rt: LocatorRuntime): Promise<unknown> {
|
|
227
|
+
async function status(rt: LocatorRuntime, content?: ContentIndex): Promise<unknown> {
|
|
196
228
|
rt.start()
|
|
197
229
|
const engine = await rt.engineStatus()
|
|
230
|
+
const contentState = content === undefined ? { enabled: false } : { enabled: true, ...content.status() }
|
|
198
231
|
return {
|
|
199
232
|
running: rt.status.running,
|
|
200
233
|
entries: engine?.entries ?? 0,
|
|
@@ -206,6 +239,7 @@ async function status(rt: LocatorRuntime): Promise<unknown> {
|
|
|
206
239
|
scanning: engine?.scanning === true || rt.status.scanning !== undefined,
|
|
207
240
|
stateDir: engine?.stateDir ?? rt.stateDir,
|
|
208
241
|
version: engine?.version ?? '',
|
|
242
|
+
content: contentState,
|
|
209
243
|
...(rt.status.error === undefined ? {} : { error: rt.status.error }),
|
|
210
244
|
...(rt.status.warning === undefined ? {} : { warning: rt.status.warning }),
|
|
211
245
|
}
|