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.
@@ -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
  }