@ff-labs/fff-bun 0.5.3-nightly.ea1f980 → 0.6.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/examples/grep.ts CHANGED
@@ -111,7 +111,6 @@ async function main() {
111
111
  console.log(`${DIM}Initializing index for: ${directory}${RESET}`);
112
112
  const createResult = FileFinder.create({
113
113
  basePath: directory,
114
- warmupMmapCache: true,
115
114
  });
116
115
 
117
116
  if (!createResult.ok) {
@@ -8,10 +8,18 @@
8
8
  *
9
9
  * Indexes the specified directory (or cwd) and provides an interactive
10
10
  * search prompt with detailed metadata about results.
11
+ *
12
+ * Modes:
13
+ * :files - fuzzy file search (default)
14
+ * :dirs - fuzzy directory search
15
+ * :mixed - mixed file + directory search
11
16
  */
12
17
 
13
18
  import { FileFinder } from "../src/index";
19
+ import type { DirItem, FileItem, MixedItem, Score } from "../src/index";
14
20
  import * as readline from "node:readline";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
15
23
 
16
24
  const RESET = "\x1b[0m";
17
25
  const BOLD = "\x1b[1m";
@@ -20,8 +28,11 @@ const GREEN = "\x1b[32m";
20
28
  const YELLOW = "\x1b[33m";
21
29
  const BLUE = "\x1b[34m";
22
30
  const CYAN = "\x1b[36m";
31
+ const MAGENTA = "\x1b[35m";
23
32
  const RED = "\x1b[31m";
24
33
 
34
+ type SearchMode = "files" | "dirs" | "mixed";
35
+
25
36
  function formatGitStatus(status: string): string {
26
37
  switch (status) {
27
38
  case "modified":
@@ -43,12 +54,30 @@ function formatGitStatus(status: string): string {
43
54
  }
44
55
 
45
56
  function formatScore(score: number): string {
46
- if (score >= 100) return `${GREEN}${score}${RESET}`;
47
- if (score >= 50) return `${YELLOW}${score}${RESET}`;
48
- if (score > 0) return `${DIM}${score}${RESET}`;
57
+ const s = String(score);
58
+ if (score >= 100) return `${GREEN}${s}${RESET}`;
59
+ if (score >= 50) return `${YELLOW}${s}${RESET}`;
60
+ if (score > 0) return `${DIM}${s}${RESET}`;
49
61
  return `${DIM}0${RESET}`;
50
62
  }
51
63
 
64
+ /** Pad a plain string first, then wrap with ANSI color. */
65
+ function padColor(
66
+ value: string,
67
+ width: number,
68
+ color: string,
69
+ align: "left" | "right" = "right",
70
+ ): string {
71
+ const padded = align === "right" ? value.padStart(width) : value.padEnd(width);
72
+ return `${color}${padded}${RESET}`;
73
+ }
74
+
75
+ function scoreColor(score: number): string {
76
+ if (score >= 100) return GREEN;
77
+ if (score >= 50) return YELLOW;
78
+ return DIM;
79
+ }
80
+
52
81
  function formatSize(bytes: number): string {
53
82
  if (bytes < 1024) return `${bytes}B`;
54
83
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
@@ -71,6 +100,68 @@ function formatTime(unixSeconds: number): string {
71
100
  return date.toLocaleDateString();
72
101
  }
73
102
 
103
+ function formatModeTag(mode: SearchMode): string {
104
+ switch (mode) {
105
+ case "files":
106
+ return `${CYAN}files${RESET}`;
107
+ case "dirs":
108
+ return `${MAGENTA}dirs${RESET}`;
109
+ case "mixed":
110
+ return `${YELLOW}mixed${RESET}`;
111
+ }
112
+ }
113
+
114
+ function printScoreBreakdown(score: Score, indent: string) {
115
+ const breakdown: string[] = [];
116
+ if (score.baseScore > 0) breakdown.push(`base:${score.baseScore}`);
117
+ if (score.filenameBonus > 0) breakdown.push(`filename:+${score.filenameBonus}`);
118
+ if (score.frecencyBoost > 0) breakdown.push(`frecency:+${score.frecencyBoost}`);
119
+ if (score.comboMatchBoost > 0) breakdown.push(`combo:+${score.comboMatchBoost}`);
120
+ if (score.distancePenalty < 0) breakdown.push(`distance:${score.distancePenalty}`);
121
+ if (score.exactMatch) breakdown.push(`${GREEN}exact${RESET}`);
122
+ if (score.matchType) breakdown.push(`[${score.matchType}]`);
123
+
124
+ if (breakdown.length > 0) {
125
+ console.log(`${DIM}${indent}└─ ${breakdown.join(", ")}${RESET}`);
126
+ }
127
+ }
128
+
129
+ function printFileResult(item: FileItem, score: Score, showBreakdown: boolean) {
130
+ const git = formatGitStatus(item.gitStatus);
131
+ const sc = padColor(String(score.total), 5, scoreColor(score.total));
132
+ const size = formatSize(item.size).padStart(6);
133
+ const modified = formatTime(item.modified).padEnd(10);
134
+
135
+ console.log(` ${git} │ ${sc} │ ${size} │ ${modified} │ ${item.relativePath}`);
136
+
137
+ if (showBreakdown && score.total > 0) {
138
+ printScoreBreakdown(score, " │ │ │ │ ");
139
+ }
140
+ }
141
+
142
+ function printDirResult(item: DirItem, score: Score, showBreakdown: boolean) {
143
+ const sc = padColor(String(score.total), 5, scoreColor(score.total));
144
+ const frecency = (
145
+ item.maxAccessFrecency > 0 ? `f:${item.maxAccessFrecency}` : "-"
146
+ ).padStart(6);
147
+
148
+ console.log(
149
+ ` ${MAGENTA}D${RESET} │ ${sc} │ ${frecency} │ ${"".padEnd(10)} │ ${MAGENTA}${item.relativePath}${RESET}`,
150
+ );
151
+
152
+ if (showBreakdown && score.total > 0) {
153
+ printScoreBreakdown(score, " │ │ │ │ ");
154
+ }
155
+ }
156
+
157
+ function printMixedResult(mixed: MixedItem, score: Score, showBreakdown: boolean) {
158
+ if (mixed.type === "file") {
159
+ printFileResult(mixed.item, score, showBreakdown);
160
+ } else {
161
+ printDirResult(mixed.item, score, showBreakdown);
162
+ }
163
+ }
164
+
74
165
  async function main() {
75
166
  const targetDir = process.argv[2] || process.cwd();
76
167
 
@@ -83,10 +174,21 @@ async function main() {
83
174
  process.exit(1);
84
175
  }
85
176
 
86
- // Create instance
177
+ // Use the same frecency + history databases as the Neovim plugin
178
+ // so the demo benefits from real access history.
179
+ const nvimCache = process.env.XDG_CACHE_HOME || join(homedir(), ".cache", "nvim");
180
+ const nvimData =
181
+ process.env.XDG_DATA_HOME || join(homedir(), ".local", "share", "nvim");
182
+ const frecencyDbPath = join(nvimCache, "fff_nvim");
183
+ const historyDbPath = join(nvimData, "fff_queries");
184
+
87
185
  console.log(`${DIM}Initializing index for: ${targetDir}${RESET}`);
186
+ console.log(`${DIM}Frecency DB: ${frecencyDbPath}${RESET}`);
88
187
  const createResult = FileFinder.create({
89
188
  basePath: targetDir,
189
+ frecencyDbPath,
190
+ historyDbPath,
191
+ useUnsafeNoLock: true,
90
192
  });
91
193
 
92
194
  if (!createResult.ok) {
@@ -128,91 +230,160 @@ async function main() {
128
230
  }
129
231
  }
130
232
 
233
+ let mode: SearchMode = "files";
234
+
131
235
  // Interactive search loop
132
236
  const rl = readline.createInterface({
133
237
  input: process.stdin,
134
238
  output: process.stdout,
135
239
  });
136
240
 
137
- console.log(
138
- `${BOLD}Enter a search query${RESET} (or 'q' to quit, empty for all files):\n`,
139
- );
241
+ console.log(`\n${BOLD}Commands:${RESET}`);
242
+ console.log(` ${DIM}:files${RESET} - file search mode (default)`);
243
+ console.log(` ${DIM}:dirs${RESET} - directory search mode`);
244
+ console.log(` ${DIM}:mixed${RESET} - mixed files + directories mode`);
245
+ console.log(` ${DIM}q${RESET} - quit`);
246
+ console.log();
140
247
 
141
248
  const prompt = () => {
142
- rl.question(`${CYAN}search>${RESET} `, (query) => {
143
- if (query.toLowerCase() === "q" || query.toLowerCase() === "quit") {
249
+ rl.question(`${CYAN}[${formatModeTag(mode)}]>${RESET} `, (query) => {
250
+ const trimmed = query.trim();
251
+
252
+ if (trimmed.toLowerCase() === "q" || trimmed.toLowerCase() === "quit") {
144
253
  console.log(`\n${DIM}Goodbye!${RESET}`);
145
254
  finder.destroy();
146
255
  rl.close();
147
256
  process.exit(0);
148
257
  }
149
258
 
150
- const searchStart = Date.now();
151
- const result = finder.search(query, { pageSize: 15 });
152
- const searchTime = Date.now() - searchStart;
153
-
154
- if (!result.ok) {
155
- console.log(`${RED}Search error: ${result.error}${RESET}\n`);
259
+ // Mode switching commands
260
+ if (trimmed === ":files") {
261
+ mode = "files";
262
+ console.log(`${GREEN}Switched to file search mode${RESET}\n`);
156
263
  prompt();
157
264
  return;
158
265
  }
159
-
160
- const { items, scores, totalMatched, totalFiles } = result.value;
161
-
162
- console.log();
163
- console.log(
164
- `${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`,
165
- );
166
- console.log();
167
-
168
- if (items.length === 0) {
169
- console.log(`${DIM}No matches found.${RESET}\n`);
266
+ if (trimmed === ":dirs") {
267
+ mode = "dirs";
268
+ console.log(`${MAGENTA}Switched to directory search mode${RESET}\n`);
170
269
  prompt();
171
270
  return;
172
271
  }
272
+ if (trimmed === ":mixed") {
273
+ mode = "mixed";
274
+ console.log(`${YELLOW}Switched to mixed search mode${RESET}\n`);
275
+ prompt();
276
+ return;
277
+ }
278
+
279
+ const searchStart = Date.now();
173
280
 
174
- // Header
175
- console.log(`${DIM} Git Score │ Size │ Modified │ Path${RESET}`);
281
+ if (mode === "files") {
282
+ const result = finder.fileSearch(trimmed, { pageSize: 15 });
283
+ const searchTime = Date.now() - searchStart;
176
284
 
177
- // Results
178
- for (let i = 0; i < items.length; i++) {
179
- const item = items[i];
180
- const score = scores[i];
285
+ if (!result.ok) {
286
+ console.log(`${RED}Search error: ${result.error}${RESET}\n`);
287
+ prompt();
288
+ return;
289
+ }
181
290
 
182
- const gitStatus = formatGitStatus(item.gitStatus);
183
- const totalScore = formatScore(score.total);
184
- const size = formatSize(item.size).padStart(6);
185
- const modified = formatTime(item.modified).padEnd(10);
186
- const path = item.relativePath;
291
+ const { items, scores, totalMatched, totalFiles } = result.value;
187
292
 
293
+ console.log();
188
294
  console.log(
189
- ` ${gitStatus} ${totalScore.padStart(5)}${size} ${modified} ${path}`,
295
+ `${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalFiles} files (${searchTime}ms)${RESET}`,
190
296
  );
297
+ console.log();
191
298
 
192
- // Show score breakdown for top results
193
- if (i < 3 && score.total > 0) {
194
- const breakdown: string[] = [];
195
- if (score.baseScore > 0) breakdown.push(`base:${score.baseScore}`);
196
- if (score.filenameBonus > 0) breakdown.push(`filename:+${score.filenameBonus}`);
197
- if (score.frecencyBoost > 0) breakdown.push(`frecency:+${score.frecencyBoost}`);
198
- if (score.comboMatchBoost > 0)
199
- breakdown.push(`combo:+${score.comboMatchBoost}`);
200
- if (score.distancePenalty < 0)
201
- breakdown.push(`distance:${score.distancePenalty}`);
202
- if (score.exactMatch) breakdown.push(`${GREEN}exact${RESET}`);
203
-
204
- if (breakdown.length > 0) {
205
- console.log(
206
- `${DIM} │ │ │ │ └─ ${breakdown.join(", ")}${RESET}`,
207
- );
208
- }
299
+ if (items.length === 0) {
300
+ console.log(`${DIM}No matches found.${RESET}\n`);
301
+ prompt();
302
+ return;
209
303
  }
210
- }
211
304
 
212
- if (totalMatched > items.length) {
305
+ console.log(`${DIM} Git Score │ Size │ Modified │ Path${RESET}`);
306
+
307
+ for (let i = 0; i < items.length; i++) {
308
+ printFileResult(items[i], scores[i], i < 3);
309
+ }
310
+
311
+ if (totalMatched > items.length) {
312
+ console.log(
313
+ `${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
314
+ );
315
+ }
316
+ } else if (mode === "dirs") {
317
+ const result = finder.directorySearch(trimmed, { pageSize: 15 });
318
+ const searchTime = Date.now() - searchStart;
319
+
320
+ if (!result.ok) {
321
+ console.log(`${RED}Search error: ${result.error}${RESET}\n`);
322
+ prompt();
323
+ return;
324
+ }
325
+
326
+ const { items, scores, totalMatched, totalDirs } = result.value;
327
+
328
+ console.log();
329
+ console.log(
330
+ `${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches in ${totalDirs} directories (${searchTime}ms)${RESET}`,
331
+ );
332
+ console.log();
333
+
334
+ if (items.length === 0) {
335
+ console.log(`${DIM}No matches found.${RESET}\n`);
336
+ prompt();
337
+ return;
338
+ }
339
+
340
+ console.log(`${DIM} Dir │ Score │ Frecnc │ │ Path${RESET}`);
341
+
342
+ for (let i = 0; i < items.length; i++) {
343
+ printDirResult(items[i], scores[i], i < 3);
344
+ }
345
+
346
+ if (totalMatched > items.length) {
347
+ console.log(
348
+ `${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
349
+ );
350
+ }
351
+ } else {
352
+ // mixed mode
353
+ const result = finder.mixedSearch(trimmed, { pageSize: 20 });
354
+ const searchTime = Date.now() - searchStart;
355
+
356
+ if (!result.ok) {
357
+ console.log(`${RED}Search error: ${result.error}${RESET}\n`);
358
+ prompt();
359
+ return;
360
+ }
361
+
362
+ const { items, scores, totalMatched, totalFiles, totalDirs } = result.value;
363
+
364
+ console.log();
213
365
  console.log(
214
- `${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
366
+ `${DIM}Found ${BOLD}${totalMatched}${RESET}${DIM} matches (${totalFiles} files, ${totalDirs} dirs) (${searchTime}ms)${RESET}`,
215
367
  );
368
+ console.log();
369
+
370
+ if (items.length === 0) {
371
+ console.log(`${DIM}No matches found.${RESET}\n`);
372
+ prompt();
373
+ return;
374
+ }
375
+
376
+ console.log(`${DIM} Type │ Score │ Info │ Modified │ Path${RESET}`);
377
+
378
+ for (let i = 0; i < items.length; i++) {
379
+ printMixedResult(items[i], scores[i], i < 5);
380
+ }
381
+
382
+ if (totalMatched > items.length) {
383
+ console.log(
384
+ `${DIM} │ │ │ │ ... and ${totalMatched - items.length} more${RESET}`,
385
+ );
386
+ }
216
387
  }
217
388
 
218
389
  console.log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ff-labs/fff-bun",
3
- "version": "0.5.3-nightly.ea1f980",
3
+ "version": "0.6.0",
4
4
  "private": false,
5
5
  "description": "High-performance fuzzy file finder for Bun - perfect for LLM agent tools",
6
6
  "type": "module",
@@ -62,14 +62,14 @@
62
62
  },
63
63
  "homepage": "https://github.com/dmtrKovalenko/fff.nvim#readme",
64
64
  "optionalDependencies": {
65
- "@ff-labs/fff-bin-darwin-arm64": "0.5.3-nightly.ea1f980",
66
- "@ff-labs/fff-bin-darwin-x64": "0.5.3-nightly.ea1f980",
67
- "@ff-labs/fff-bin-linux-x64-gnu": "0.5.3-nightly.ea1f980",
68
- "@ff-labs/fff-bin-linux-arm64-gnu": "0.5.3-nightly.ea1f980",
69
- "@ff-labs/fff-bin-linux-x64-musl": "0.5.3-nightly.ea1f980",
70
- "@ff-labs/fff-bin-linux-arm64-musl": "0.5.3-nightly.ea1f980",
71
- "@ff-labs/fff-bin-win32-x64": "0.5.3-nightly.ea1f980",
72
- "@ff-labs/fff-bin-win32-arm64": "0.5.3-nightly.ea1f980"
65
+ "@ff-labs/fff-bin-darwin-arm64": "0.6.0",
66
+ "@ff-labs/fff-bin-darwin-x64": "0.6.0",
67
+ "@ff-labs/fff-bin-linux-x64-gnu": "0.6.0",
68
+ "@ff-labs/fff-bin-linux-arm64-gnu": "0.6.0",
69
+ "@ff-labs/fff-bin-linux-x64-musl": "0.6.0",
70
+ "@ff-labs/fff-bin-linux-arm64-musl": "0.6.0",
71
+ "@ff-labs/fff-bin-win32-x64": "0.6.0",
72
+ "@ff-labs/fff-bin-win32-arm64": "0.6.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@types/bun": "^1.3.8",
package/src/ffi.ts CHANGED
@@ -11,10 +11,14 @@
11
11
  import { CString, dlopen, FFIType, type Pointer, ptr, read } from "bun:ffi";
12
12
  import { findBinary } from "./download";
13
13
  import type {
14
+ DirItem,
15
+ DirSearchResult,
14
16
  FileItem,
15
17
  GrepMatch,
16
18
  GrepResult,
17
19
  Location,
20
+ MixedItem,
21
+ MixedSearchResult,
18
22
  Result,
19
23
  ScanProgress,
20
24
  Score,
@@ -46,7 +50,9 @@ const ffiDefinition = {
46
50
  FFIType.cstring, // frecency_db_path
47
51
  FFIType.cstring, // history_db_path
48
52
  FFIType.bool, // use_unsafe_no_lock
49
- FFIType.bool, // warmup_mmap_cache
53
+ FFIType.bool, // enable_mmap_cache
54
+ FFIType.bool, // enable_content_indexing
55
+ FFIType.bool, // watch
50
56
  FFIType.bool, // ai_mode
51
57
  ],
52
58
  returns: FFIType.ptr,
@@ -71,6 +77,34 @@ const ffiDefinition = {
71
77
  returns: FFIType.ptr,
72
78
  },
73
79
 
80
+ // Directory search
81
+ fff_search_directories: {
82
+ args: [
83
+ FFIType.ptr, // handle
84
+ FFIType.cstring, // query
85
+ FFIType.cstring, // current_file
86
+ FFIType.u32, // max_threads
87
+ FFIType.u32, // page_index
88
+ FFIType.u32, // page_size
89
+ ],
90
+ returns: FFIType.ptr,
91
+ },
92
+
93
+ // Mixed search (files + directories)
94
+ fff_search_mixed: {
95
+ args: [
96
+ FFIType.ptr, // handle
97
+ FFIType.cstring, // query
98
+ FFIType.cstring, // current_file
99
+ FFIType.u32, // max_threads
100
+ FFIType.u32, // page_index
101
+ FFIType.u32, // page_size
102
+ FFIType.i32, // combo_boost_multiplier
103
+ FFIType.u32, // min_combo_count
104
+ ],
105
+ returns: FFIType.ptr,
106
+ },
107
+
74
108
  // Live grep (content search)
75
109
  fff_live_grep: {
76
110
  args: [
@@ -118,6 +152,10 @@ const ffiDefinition = {
118
152
  args: [FFIType.ptr],
119
153
  returns: FFIType.bool,
120
154
  },
155
+ fff_get_base_path: {
156
+ args: [FFIType.ptr],
157
+ returns: FFIType.ptr,
158
+ },
121
159
  fff_get_scan_progress: {
122
160
  args: [FFIType.ptr],
123
161
  returns: FFIType.ptr,
@@ -171,6 +209,34 @@ const ffiDefinition = {
171
209
  returns: FFIType.ptr,
172
210
  },
173
211
 
212
+ // Dir search result accessors / free
213
+ fff_free_dir_search_result: {
214
+ args: [FFIType.ptr],
215
+ returns: FFIType.void,
216
+ },
217
+ fff_dir_search_result_get_item: {
218
+ args: [FFIType.ptr, FFIType.u32],
219
+ returns: FFIType.ptr,
220
+ },
221
+ fff_dir_search_result_get_score: {
222
+ args: [FFIType.ptr, FFIType.u32],
223
+ returns: FFIType.ptr,
224
+ },
225
+
226
+ // Mixed search result accessors / free
227
+ fff_free_mixed_search_result: {
228
+ args: [FFIType.ptr],
229
+ returns: FFIType.void,
230
+ },
231
+ fff_mixed_search_result_get_item: {
232
+ args: [FFIType.ptr, FFIType.u32],
233
+ returns: FFIType.ptr,
234
+ },
235
+ fff_mixed_search_result_get_score: {
236
+ args: [FFIType.ptr, FFIType.u32],
237
+ returns: FFIType.ptr,
238
+ },
239
+
174
240
  // Grep result accessors / free
175
241
  fff_free_grep_result: {
176
242
  args: [FFIType.ptr],
@@ -355,7 +421,9 @@ export function ffiCreate(
355
421
  frecencyDbPath: string,
356
422
  historyDbPath: string,
357
423
  useUnsafeNoLock: boolean,
358
- warmupMmapCache: boolean,
424
+ enableMmapCache: boolean,
425
+ enableContentIndexing: boolean,
426
+ watch: boolean,
359
427
  aiMode: boolean,
360
428
  ): Result<NativeHandle> {
361
429
  const library = loadLibrary();
@@ -364,7 +432,9 @@ export function ffiCreate(
364
432
  ptr(encodeString(frecencyDbPath)),
365
433
  ptr(encodeString(historyDbPath)),
366
434
  useUnsafeNoLock,
367
- warmupMmapCache,
435
+ enableMmapCache,
436
+ enableContentIndexing,
437
+ watch,
368
438
  aiMode,
369
439
  );
370
440
 
@@ -417,18 +487,17 @@ const SR_LOC_COL = 36; // i32 (4)
417
487
  const SR_LOC_END_LINE = 40; // i32 (4)
418
488
  const SR_LOC_END_COL = 44; // i32 (4)
419
489
 
420
- // FffFileItem (80 bytes)
421
- const FI_PATH = 0; // *mut c_char (8)
422
- const FI_RELPATH = 8; // *mut c_char (8)
423
- const FI_FNAME = 16; // *mut c_char (8)
424
- const FI_GIT = 24; // *mut c_char (8)
425
- const FI_SIZE = 32; // u64 (8)
426
- const FI_MODIFIED = 40; // u64 (8)
427
- const FI_ACCESS = 48; // i64 (8)
428
- const FI_MODFR = 56; // i64 (8)
429
- const FI_TOTAL_FR = 64; // i64 (8)
430
- const _FI_BINARY = 72; // bool (1 + 7 pad)
431
- const FI_SIZE_OF = 80;
490
+ // FffFileItem (72 bytes)
491
+ const FI_RELPATH = 0; // *mut c_char (8)
492
+ const FI_FNAME = 8; // *mut c_char (8)
493
+ const FI_GIT = 16; // *mut c_char (8)
494
+ const FI_SIZE = 24; // u64 (8)
495
+ const FI_MODIFIED = 32; // u64 (8)
496
+ const FI_ACCESS = 40; // i64 (8)
497
+ const FI_MODFR = 48; // i64 (8)
498
+ const FI_TOTAL_FR = 56; // i64 (8)
499
+ const _FI_BINARY = 64; // bool (1 + 7 pad)
500
+ const FI_SIZE_OF = 72;
432
501
 
433
502
  // FffScore (48 bytes)
434
503
  const SC_TOTAL = 0; // i32 (4)
@@ -454,7 +523,6 @@ function asPtr(n: number): Pointer {
454
523
  function readFileItemStruct(p: number): FileItem {
455
524
  const pp = asPtr(p);
456
525
  return {
457
- path: readCString(read.ptr(pp, FI_PATH)) ?? "",
458
526
  relativePath: readCString(read.ptr(pp, FI_RELPATH)) ?? "",
459
527
  fileName: readCString(read.ptr(pp, FI_FNAME)) ?? "",
460
528
  gitStatus: readCString(read.ptr(pp, FI_GIT)) ?? "",
@@ -548,44 +616,251 @@ function parseSearchResult(resultPtr: Pointer | null): Result<SearchResult> {
548
616
  return { ok: true, value: result };
549
617
  }
550
618
 
619
+ // ---------------------------------------------------------------------------
620
+ // FffDirSearchResult byte offsets (must match #[repr(C)] layout on 64-bit)
621
+ // { items: *mut, scores: *mut, count: u32, total_matched: u32, total_dirs: u32 }
622
+ // ---------------------------------------------------------------------------
623
+ const DSR_ITEMS = 0; // *mut FffDirItem (8)
624
+ const DSR_SCORES = 8; // *mut FffScore (8)
625
+ const DSR_COUNT = 16; // u32 (4)
626
+ const DSR_MATCHED = 20; // u32 (4)
627
+ const DSR_TOTAL_DIRS = 24; // u32 (4)
628
+
629
+ // FffDirItem (24 bytes: 8 + 8 + 4 + 4pad)
630
+ const DI_RELPATH = 0; // *mut c_char (8)
631
+ const DI_DIRNAME = 8; // *mut c_char (8)
632
+ const DI_MAX_FRECENCY = 16; // i32 (4)
633
+
634
+ /**
635
+ * Read an FffDirItem struct at the given raw address.
636
+ */
637
+ function readDirItemStruct(p: number): DirItem {
638
+ const pp = asPtr(p);
639
+ return {
640
+ relativePath: readCString(read.ptr(pp, DI_RELPATH)) ?? "",
641
+ dirName: readCString(read.ptr(pp, DI_DIRNAME)) ?? "",
642
+ maxAccessFrecency: read.i32(pp, DI_MAX_FRECENCY),
643
+ };
644
+ }
645
+
646
+ /**
647
+ * Parse an FffDirSearchResult from a raw FffResult pointer, then free native memory.
648
+ */
649
+ function parseDirSearchResult(resultPtr: Pointer | null): Result<DirSearchResult> {
650
+ if (resultPtr === null) {
651
+ return err("FFI returned null pointer");
652
+ }
653
+
654
+ const envelope = readResultEnvelope(resultPtr);
655
+ if (!("success" in envelope)) return envelope;
656
+
657
+ if (envelope.handlePtr === 0) {
658
+ return err("fff_search_directories returned null search result");
659
+ }
660
+
661
+ const hp = asPtr(envelope.handlePtr);
662
+ const count = read.u32(hp, DSR_COUNT);
663
+ const totalMatched = read.u32(hp, DSR_MATCHED);
664
+ const totalDirs = read.u32(hp, DSR_TOTAL_DIRS);
665
+
666
+ const library = loadLibrary();
667
+
668
+ const items: DirItem[] = [];
669
+ const scores: Score[] = [];
670
+
671
+ for (let i = 0; i < count; i++) {
672
+ const itemPtr = library.symbols.fff_dir_search_result_get_item(hp, i);
673
+ if (itemPtr !== null && (itemPtr as unknown as number) !== 0) {
674
+ items.push(readDirItemStruct(itemPtr as unknown as number));
675
+ }
676
+ const scorePtr = library.symbols.fff_dir_search_result_get_score(hp, i);
677
+ if (scorePtr !== null && (scorePtr as unknown as number) !== 0) {
678
+ scores.push(readScoreStruct(scorePtr as unknown as number));
679
+ }
680
+ }
681
+
682
+ // Free native dir search result
683
+ library.symbols.fff_free_dir_search_result(hp);
684
+
685
+ return { ok: true, value: { items, scores, totalMatched, totalDirs } };
686
+ }
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // FffMixedSearchResult byte offsets (must match #[repr(C)] layout on 64-bit)
690
+ // { items: *mut, scores: *mut, count: u32, total_matched: u32, total_files: u32, total_dirs: u32, location: FffLocation }
691
+ // ---------------------------------------------------------------------------
692
+ const MSR_ITEMS = 0; // *mut FffMixedItem (8)
693
+ const MSR_SCORES = 8; // *mut FffScore (8)
694
+ const MSR_COUNT = 16; // u32 (4)
695
+ const MSR_MATCHED = 20; // u32 (4)
696
+ const MSR_TOTAL_FILES = 24; // u32 (4)
697
+ const MSR_TOTAL_DIRS = 28; // u32 (4)
698
+ // FffLocation is inlined at offset 32
699
+ const MSR_LOC_TAG = 32; // u8 (1 + 3 padding)
700
+ const MSR_LOC_LINE = 36; // i32 (4)
701
+ const MSR_LOC_COL = 40; // i32 (4)
702
+ const MSR_LOC_END_LINE = 44; // i32 (4)
703
+ const MSR_LOC_END_COL = 48; // i32 (4)
704
+
705
+ // FffMixedItem (80 bytes)
706
+ const MI_TYPE = 0; // u8 (1 + 7 pad)
707
+ const MI_RELPATH = 8; // *mut c_char (8)
708
+ const MI_DISPLAY = 16; // *mut c_char (8)
709
+ const MI_GIT = 24; // *mut c_char (8)
710
+ const MI_SIZE = 32; // u64 (8)
711
+ const MI_MODIFIED = 40; // u64 (8)
712
+ const MI_ACCESS = 48; // i64 (8)
713
+ const MI_MODFR = 56; // i64 (8)
714
+ const MI_TOTAL_FR = 64; // i64 (8)
715
+ const MI_BINARY = 72; // bool (1 + 7 pad)
716
+
717
+ /**
718
+ * Read an FffMixedItem struct at the given raw address and return a MixedItem.
719
+ */
720
+ function readMixedItemStruct(p: number): MixedItem {
721
+ const pp = asPtr(p);
722
+ const itemType = read.u8(pp, MI_TYPE);
723
+
724
+ if (itemType === 1) {
725
+ // Directory
726
+ return {
727
+ type: "directory",
728
+ item: {
729
+ relativePath: readCString(read.ptr(pp, MI_RELPATH)) ?? "",
730
+ dirName: readCString(read.ptr(pp, MI_DISPLAY)) ?? "",
731
+ maxAccessFrecency: Number(read.i64(pp, MI_ACCESS)),
732
+ },
733
+ };
734
+ }
735
+
736
+ // File (itemType === 0)
737
+ return {
738
+ type: "file",
739
+ item: {
740
+ relativePath: readCString(read.ptr(pp, MI_RELPATH)) ?? "",
741
+ fileName: readCString(read.ptr(pp, MI_DISPLAY)) ?? "",
742
+ gitStatus: readCString(read.ptr(pp, MI_GIT)) ?? "",
743
+ size: Number(read.u64(pp, MI_SIZE)),
744
+ modified: Number(read.u64(pp, MI_MODIFIED)),
745
+ accessFrecencyScore: Number(read.i64(pp, MI_ACCESS)),
746
+ modificationFrecencyScore: Number(read.i64(pp, MI_MODFR)),
747
+ totalFrecencyScore: Number(read.i64(pp, MI_TOTAL_FR)),
748
+ },
749
+ };
750
+ }
751
+
752
+ /**
753
+ * Parse an FffMixedSearchResult from a raw FffResult pointer, then free native memory.
754
+ */
755
+ function parseMixedSearchResult(resultPtr: Pointer | null): Result<MixedSearchResult> {
756
+ if (resultPtr === null) {
757
+ return err("FFI returned null pointer");
758
+ }
759
+
760
+ const envelope = readResultEnvelope(resultPtr);
761
+ if (!("success" in envelope)) return envelope;
762
+
763
+ if (envelope.handlePtr === 0) {
764
+ return err("fff_search_mixed returned null search result");
765
+ }
766
+
767
+ const hp = asPtr(envelope.handlePtr);
768
+ const count = read.u32(hp, MSR_COUNT);
769
+ const totalMatched = read.u32(hp, MSR_MATCHED);
770
+ const totalFiles = read.u32(hp, MSR_TOTAL_FILES);
771
+ const totalDirs = read.u32(hp, MSR_TOTAL_DIRS);
772
+
773
+ // Read location
774
+ const locTag = read.u8(hp, MSR_LOC_TAG);
775
+ let location: Location | undefined;
776
+ if (locTag === 1) {
777
+ location = { type: "line", line: read.i32(hp, MSR_LOC_LINE) };
778
+ } else if (locTag === 2) {
779
+ location = {
780
+ type: "position",
781
+ line: read.i32(hp, MSR_LOC_LINE),
782
+ col: read.i32(hp, MSR_LOC_COL),
783
+ };
784
+ } else if (locTag === 3) {
785
+ location = {
786
+ type: "range",
787
+ start: { line: read.i32(hp, MSR_LOC_LINE), col: read.i32(hp, MSR_LOC_COL) },
788
+ end: {
789
+ line: read.i32(hp, MSR_LOC_END_LINE),
790
+ col: read.i32(hp, MSR_LOC_END_COL),
791
+ },
792
+ };
793
+ }
794
+
795
+ const library = loadLibrary();
796
+
797
+ const items: MixedItem[] = [];
798
+ const scores: Score[] = [];
799
+
800
+ for (let i = 0; i < count; i++) {
801
+ const itemPtr = library.symbols.fff_mixed_search_result_get_item(hp, i);
802
+ if (itemPtr !== null && (itemPtr as unknown as number) !== 0) {
803
+ items.push(readMixedItemStruct(itemPtr as unknown as number));
804
+ }
805
+ const scorePtr = library.symbols.fff_mixed_search_result_get_score(hp, i);
806
+ if (scorePtr !== null && (scorePtr as unknown as number) !== 0) {
807
+ scores.push(readScoreStruct(scorePtr as unknown as number));
808
+ }
809
+ }
810
+
811
+ // Free native mixed search result
812
+ library.symbols.fff_free_mixed_search_result(hp);
813
+
814
+ const result: MixedSearchResult = {
815
+ items,
816
+ scores,
817
+ totalMatched,
818
+ totalFiles,
819
+ totalDirs,
820
+ };
821
+ if (location) {
822
+ result.location = location;
823
+ }
824
+ return { ok: true, value: result };
825
+ }
826
+
551
827
  // ---------------------------------------------------------------------------
552
828
  // FffGrepMatch byte offsets (must match #[repr(C)] layout on 64-bit)
553
829
  // ---------------------------------------------------------------------------
554
830
 
555
831
  // Pointers (8 bytes each)
556
- const GM_PATH = 0;
557
- const GM_RELPATH = 8;
558
- const GM_FNAME = 16;
559
- const GM_GIT = 24;
560
- const GM_LINE_CONTENT = 32;
561
- const GM_MATCH_RANGES = 40;
562
- const GM_CTX_BEFORE = 48;
563
- const GM_CTX_AFTER = 56;
832
+ const GM_RELPATH = 0;
833
+ const GM_FNAME = 8;
834
+ const GM_GIT = 16;
835
+ const GM_LINE_CONTENT = 24;
836
+ const GM_MATCH_RANGES = 32;
837
+ const GM_CTX_BEFORE = 40;
838
+ const GM_CTX_AFTER = 48;
564
839
 
565
840
  // 8-byte numeric fields
566
- const GM_SIZE = 64;
567
- const GM_MODIFIED = 72;
568
- const GM_TOTAL_FR = 80;
569
- const GM_ACCESS_FR = 88;
570
- const GM_MOD_FR = 96;
571
- const GM_LINE_NUM = 104;
572
- const GM_BYTE_OFF = 112;
841
+ const GM_SIZE = 56;
842
+ const GM_MODIFIED = 64;
843
+ const GM_TOTAL_FR = 72;
844
+ const GM_ACCESS_FR = 80;
845
+ const GM_MOD_FR = 88;
846
+ const GM_LINE_NUM = 96;
847
+ const GM_BYTE_OFF = 104;
573
848
 
574
849
  // 4-byte fields
575
- const GM_COL = 120;
576
- const GM_MR_COUNT = 124;
577
- const GM_CTX_B_COUNT = 128;
578
- const GM_CTX_A_COUNT = 132;
850
+ const GM_COL = 112;
851
+ const GM_MR_COUNT = 116;
852
+ const GM_CTX_B_COUNT = 120;
853
+ const GM_CTX_A_COUNT = 124;
579
854
 
580
855
  // 2-byte
581
- const GM_FUZZY_SCORE = 136;
856
+ const GM_FUZZY_SCORE = 128;
582
857
  // 1-byte
583
- const GM_HAS_FUZZY = 138;
584
- const GM_IS_BINARY = 139;
585
- const _GM_IS_DEF = 140;
858
+ const GM_HAS_FUZZY = 130;
859
+ const GM_IS_BINARY = 131;
860
+ const _GM_IS_DEF = 132;
586
861
 
587
- // struct size: pad to 8-byte alignment → 144
588
- const GM_SIZE_OF = 144;
862
+ // struct size: pad to 8-byte alignment → 136
863
+ const GM_SIZE_OF = 136;
589
864
 
590
865
  // FffGrepResult
591
866
  const GR_ITEMS = 0; // *mut FffGrepMatch (8)
@@ -635,7 +910,6 @@ function readGrepMatchStruct(p: number): GrepMatch {
635
910
  const ctxAfterCount = read.u32(pp, GM_CTX_A_COUNT);
636
911
 
637
912
  const match: GrepMatch = {
638
- path: readCString(read.ptr(pp, GM_PATH)) ?? "",
639
913
  relativePath: readCString(read.ptr(pp, GM_RELPATH)) ?? "",
640
914
  fileName: readCString(read.ptr(pp, GM_FNAME)) ?? "",
641
915
  gitStatus: readCString(read.ptr(pp, GM_GIT)) ?? "",
@@ -735,6 +1009,56 @@ export function ffiSearch(
735
1009
  return parseSearchResult(resultPtr);
736
1010
  }
737
1011
 
1012
+ /**
1013
+ * Perform fuzzy directory search.
1014
+ */
1015
+ export function ffiSearchDirectories(
1016
+ handle: NativeHandle,
1017
+ query: string,
1018
+ currentFile: string | null,
1019
+ maxThreads: number,
1020
+ pageIndex: number,
1021
+ pageSize: number,
1022
+ ): Result<DirSearchResult> {
1023
+ const library = loadLibrary();
1024
+ const resultPtr = library.symbols.fff_search_directories(
1025
+ handle,
1026
+ ptr(encodeString(query)),
1027
+ ptr(encodeString(currentFile ?? "")),
1028
+ maxThreads,
1029
+ pageIndex,
1030
+ pageSize,
1031
+ );
1032
+ return parseDirSearchResult(resultPtr);
1033
+ }
1034
+
1035
+ /**
1036
+ * Perform mixed (files + directories) fuzzy search.
1037
+ */
1038
+ export function ffiSearchMixed(
1039
+ handle: NativeHandle,
1040
+ query: string,
1041
+ currentFile: string,
1042
+ maxThreads: number,
1043
+ pageIndex: number,
1044
+ pageSize: number,
1045
+ comboBoostMultiplier: number,
1046
+ minComboCount: number,
1047
+ ): Result<MixedSearchResult> {
1048
+ const library = loadLibrary();
1049
+ const resultPtr = library.symbols.fff_search_mixed(
1050
+ handle,
1051
+ ptr(encodeString(query)),
1052
+ ptr(encodeString(currentFile)),
1053
+ maxThreads,
1054
+ pageIndex,
1055
+ pageSize,
1056
+ comboBoostMultiplier,
1057
+ minComboCount,
1058
+ );
1059
+ return parseMixedSearchResult(resultPtr);
1060
+ }
1061
+
738
1062
  /**
739
1063
  * Live grep - search file contents.
740
1064
  */
@@ -822,6 +1146,15 @@ export function ffiIsScanning(handle: NativeHandle): boolean {
822
1146
  return library.symbols.fff_is_scanning(handle) as boolean;
823
1147
  }
824
1148
 
1149
+ /**
1150
+ * Get the base path of the file picker.
1151
+ */
1152
+ export function ffiGetBasePath(handle: NativeHandle): Result<string | null> {
1153
+ const library = loadLibrary();
1154
+ const resultPtr = library.symbols.fff_get_base_path(handle);
1155
+ return parseStringResult(resultPtr);
1156
+ }
1157
+
825
1158
  // FffScanProgress { scanned_files_count: u64(8), is_scanning: bool(1), is_watcher_ready: bool(1), is_warmup_complete: bool(1) + pad }
826
1159
  const SP_COUNT = 0; // u64 (8)
827
1160
  const SP_SCANNING = 8; // bool (1)
package/src/finder.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  ensureLoaded,
13
13
  ffiCreate,
14
14
  ffiDestroy,
15
+ ffiGetBasePath,
15
16
  ffiGetHistoricalQuery,
16
17
  ffiGetScanProgress,
17
18
  ffiHealthCheck,
@@ -22,6 +23,8 @@ import {
22
23
  ffiRestartIndex,
23
24
  ffiScanFiles,
24
25
  ffiSearch,
26
+ ffiSearchDirectories,
27
+ ffiSearchMixed,
25
28
  ffiTrackQuery,
26
29
  ffiWaitForScan,
27
30
  ffiWaitForWatcher,
@@ -30,10 +33,13 @@ import {
30
33
  } from "./ffi";
31
34
 
32
35
  import type {
36
+ DirSearchOptions,
37
+ DirSearchResult,
33
38
  GrepOptions,
34
39
  GrepResult,
35
40
  HealthCheck,
36
- InitOptions,
41
+ InitOptions as FFFInitOptions,
42
+ MixedSearchResult,
37
43
  MultiGrepOptions,
38
44
  Result,
39
45
  ScanProgress,
@@ -101,13 +107,15 @@ export class FileFinder {
101
107
  * });
102
108
  * ```
103
109
  */
104
- static create(options: InitOptions): Result<FileFinder> {
110
+ static create(options: FFFInitOptions): Result<FileFinder> {
105
111
  const result = ffiCreate(
106
112
  options.basePath,
107
113
  options.frecencyDbPath ?? "",
108
114
  options.historyDbPath ?? "",
109
115
  options.useUnsafeNoLock ?? false,
110
- options.warmupMmapCache ?? false,
116
+ !(options.disableMmapCache ?? false),
117
+ !(options.disableContentIndexing ?? options.disableMmapCache ?? false),
118
+ !(options.disableWatch ?? false),
111
119
  options.aiMode ?? false,
112
120
  );
113
121
 
@@ -189,6 +197,77 @@ export class FileFinder {
189
197
  );
190
198
  }
191
199
 
200
+ /**
201
+ * Search for directories matching the query.
202
+ *
203
+ * @param query - Search query string
204
+ * @param options - Directory search options
205
+ * @returns Search results with matched directories and scores
206
+ *
207
+ * @example
208
+ * ```typescript
209
+ * const result = finder.directorySearch("components", { pageSize: 10 });
210
+ * if (result.ok) {
211
+ * console.log(`Found ${result.value.totalMatched} directories`);
212
+ * for (const item of result.value.items) {
213
+ * console.log(item.relativePath);
214
+ * }
215
+ * }
216
+ * ```
217
+ */
218
+ directorySearch(query: string, options?: DirSearchOptions): Result<DirSearchResult> {
219
+ const guard = this.ensureAlive();
220
+ if (!guard.ok) return guard;
221
+
222
+ return ffiSearchDirectories(
223
+ guard.value,
224
+ query,
225
+ options?.currentFile ?? null,
226
+ options?.maxThreads ?? 0,
227
+ options?.pageIndex ?? 0,
228
+ options?.pageSize ?? 0,
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Search for files and directories together (mixed search).
234
+ *
235
+ * Results are interleaved by total score in descending order.
236
+ *
237
+ * @param query - Search query string
238
+ * @param options - Search options
239
+ * @returns Mixed search results with files and directories interleaved by score
240
+ *
241
+ * @example
242
+ * ```typescript
243
+ * const result = finder.mixedSearch("main", { pageSize: 20 });
244
+ * if (result.ok) {
245
+ * for (const entry of result.value.items) {
246
+ * if (entry.type === "file") {
247
+ * console.log(`File: ${entry.item.relativePath}`);
248
+ * } else {
249
+ * console.log(`Dir: ${entry.item.relativePath}`);
250
+ * }
251
+ * }
252
+ * }
253
+ * ```
254
+ */
255
+ mixedSearch(query: string, options?: SearchOptions): Result<MixedSearchResult> {
256
+ const guard = this.ensureAlive();
257
+ if (!guard.ok) return guard;
258
+
259
+ return ffiSearchMixed(
260
+ guard.value,
261
+ query,
262
+ options?.currentFile ?? "",
263
+ options?.maxThreads ?? 0,
264
+ options?.pageIndex ?? 0,
265
+ options?.pageSize ?? 0,
266
+ options?.comboBoostMultiplier ?? 0,
267
+ options?.minComboCount ?? 0,
268
+ );
269
+ }
270
+
192
271
  /**
193
272
  * Search file contents (live grep).
194
273
  *
@@ -314,6 +393,15 @@ export class FileFinder {
314
393
  return ffiIsScanning(this.handle);
315
394
  }
316
395
 
396
+ /**
397
+ * Get the base path of the file picker (the root directory being indexed).
398
+ */
399
+ getBasePath(): Result<string | null> {
400
+ const guard = this.ensureAlive();
401
+ if (!guard.ok) return guard;
402
+ return ffiGetBasePath(guard.value);
403
+ }
404
+
317
405
  /**
318
406
  * Get the current scan progress.
319
407
  */
package/src/index.ts CHANGED
@@ -52,6 +52,9 @@ export {
52
52
 
53
53
  export type {
54
54
  DbHealth,
55
+ DirItem,
56
+ DirSearchOptions,
57
+ DirSearchResult,
55
58
  FileItem,
56
59
  GrepCursor,
57
60
  GrepMatch,
@@ -61,6 +64,8 @@ export type {
61
64
  HealthCheck,
62
65
  InitOptions,
63
66
  Location,
67
+ MixedItem,
68
+ MixedSearchResult,
64
69
  MultiGrepOptions,
65
70
  Result,
66
71
  ScanProgress,
package/src/types.ts CHANGED
@@ -30,12 +30,24 @@ export interface InitOptions {
30
30
  /** Use unsafe no-lock mode for databases (optional, defaults to false) */
31
31
  useUnsafeNoLock?: boolean;
32
32
  /**
33
- * Pre-populate mmap caches for all files after the initial scan completes.
34
- * When enabled, the first grep search will be as fast as subsequent ones
35
- * at the cost of a longer scan time and higher initial memory usage.
33
+ * Disable mmap cache warmup after the initial scan. When mmap cache is
34
+ * enabled (the default), the first grep search is as fast as subsequent
35
+ * ones at the cost of background resources spent on awarming up the cache
36
+ */
37
+ disableMmapCache?: boolean;
38
+ /**
39
+ * Disable the content index built after the initial scan.
40
+ * Content indexing enables faster content-aware filtering during grep.
41
+ * When omitted, follows `disableMmapCache` for backward compatibility.
42
+ * (default: follows `disableMmapCache`)
43
+ */
44
+ disableContentIndexing?: boolean;
45
+ /**
46
+ * Disable the background file-system watcher. When the watcher is
47
+ * disabled, files are scanned once but not monitored for changes.
36
48
  * (default: false)
37
49
  */
38
- warmupMmapCache?: boolean;
50
+ disableWatch?: boolean;
39
51
  /** enables optimizations for AI agent assistants. Provide as true if running via mcp/agent */
40
52
  aiMode?: boolean;
41
53
  }
@@ -62,8 +74,6 @@ export interface SearchOptions {
62
74
  * A file item in search results
63
75
  */
64
76
  export interface FileItem {
65
- /** Absolute path to the file */
66
- path: string;
67
77
  /** Path relative to the indexed directory */
68
78
  relativePath: string;
69
79
  /** File name only */
@@ -136,6 +146,72 @@ export interface SearchResult {
136
146
  location?: Location;
137
147
  }
138
148
 
149
+ /**
150
+ * A directory item in search results
151
+ */
152
+ export interface DirItem {
153
+ /** Path relative to the indexed directory (e.g., "src/components/") */
154
+ relativePath: string;
155
+ /** Last path segment (e.g., "components/" for "src/components/") */
156
+ dirName: string;
157
+ /** Maximum access frecency score among direct child files */
158
+ maxAccessFrecency: number;
159
+ }
160
+
161
+ /**
162
+ * Search options for directory search (subset of SearchOptions)
163
+ */
164
+ export interface DirSearchOptions {
165
+ /** Maximum threads for parallel search (0 = auto) */
166
+ maxThreads?: number;
167
+ /** Current file path (for distance scoring) */
168
+ currentFile?: string;
169
+ /** Page index for pagination (default: 0) */
170
+ pageIndex?: number;
171
+ /** Page size for pagination (default: 100) */
172
+ pageSize?: number;
173
+ }
174
+
175
+ /**
176
+ * Search result from fuzzy directory search
177
+ */
178
+ export interface DirSearchResult {
179
+ /** Matched directory items */
180
+ items: DirItem[];
181
+ /** Corresponding scores for each item */
182
+ scores: Score[];
183
+ /** Total number of directories that matched */
184
+ totalMatched: number;
185
+ /** Total number of indexed directories */
186
+ totalDirs: number;
187
+ }
188
+
189
+ /**
190
+ * A single item in a mixed (files + directories) search result
191
+ */
192
+ export type MixedItem =
193
+ | { type: "file"; item: FileItem }
194
+ | { type: "directory"; item: DirItem };
195
+
196
+ /**
197
+ * Search result from mixed (files + directories) fuzzy search.
198
+ * Items are interleaved by total score in descending order.
199
+ */
200
+ export interface MixedSearchResult {
201
+ /** Matched items (files and directories interleaved by score) */
202
+ items: MixedItem[];
203
+ /** Corresponding scores for each item */
204
+ scores: Score[];
205
+ /** Total number of items (files + dirs) that matched */
206
+ totalMatched: number;
207
+ /** Total number of indexed files */
208
+ totalFiles: number;
209
+ /** Total number of indexed directories */
210
+ totalDirs: number;
211
+ /** Location parsed from query */
212
+ location?: Location;
213
+ }
214
+
139
215
  /**
140
216
  * Scan progress information
141
217
  */
@@ -271,8 +347,6 @@ export interface GrepOptions {
271
347
  * A single grep match with file and line information
272
348
  */
273
349
  export interface GrepMatch {
274
- /** Absolute path to the file */
275
- path: string;
276
350
  /** Path relative to the indexed directory */
277
351
  relativePath: string;
278
352
  /** File name only */