@ff-labs/fff-bun 0.1.0-nightly.00750c2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -0
- package/examples/grep.ts +285 -0
- package/examples/search.ts +224 -0
- package/package.json +86 -0
- package/scripts/cli.ts +114 -0
- package/scripts/postinstall.ts +51 -0
- package/src/download.ts +252 -0
- package/src/ffi.ts +450 -0
- package/src/finder.ts +413 -0
- package/src/git-lifecycle.test.ts +291 -0
- package/src/index.test.ts +357 -0
- package/src/index.ts +83 -0
- package/src/platform.ts +121 -0
- package/src/types.ts +404 -0
package/src/finder.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileFinder - High-level API for the fff file finder
|
|
3
|
+
*
|
|
4
|
+
* This class provides a type-safe, ergonomic API for file finding operations.
|
|
5
|
+
* Each instance owns an independent native file picker that can be created
|
|
6
|
+
* and destroyed independently. Multiple instances can coexist.
|
|
7
|
+
*
|
|
8
|
+
* All methods return Result types for explicit error handling.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
ffiCreate,
|
|
13
|
+
ffiDestroy,
|
|
14
|
+
ffiSearch,
|
|
15
|
+
ffiLiveGrep,
|
|
16
|
+
ffiScanFiles,
|
|
17
|
+
ffiIsScanning,
|
|
18
|
+
ffiGetScanProgress,
|
|
19
|
+
ffiWaitForScan,
|
|
20
|
+
ffiRestartIndex,
|
|
21
|
+
ffiTrackAccess,
|
|
22
|
+
ffiRefreshGitStatus,
|
|
23
|
+
ffiTrackQuery,
|
|
24
|
+
ffiGetHistoricalQuery,
|
|
25
|
+
ffiHealthCheck,
|
|
26
|
+
ensureLoaded,
|
|
27
|
+
isAvailable,
|
|
28
|
+
type NativeHandle,
|
|
29
|
+
} from "./ffi";
|
|
30
|
+
|
|
31
|
+
import type {
|
|
32
|
+
Result,
|
|
33
|
+
InitOptions,
|
|
34
|
+
SearchOptions,
|
|
35
|
+
SearchResult,
|
|
36
|
+
ScanProgress,
|
|
37
|
+
HealthCheck,
|
|
38
|
+
GrepOptions,
|
|
39
|
+
GrepResult,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
err,
|
|
44
|
+
toInternalInitOptions,
|
|
45
|
+
toInternalSearchOptions,
|
|
46
|
+
toInternalGrepOptions,
|
|
47
|
+
createGrepCursor,
|
|
48
|
+
} from "./types";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* FileFinder - Fast file finder with fuzzy search
|
|
52
|
+
*
|
|
53
|
+
* Each instance is backed by an independent native file picker. Create as many
|
|
54
|
+
* as you need and destroy them when done.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* import { FileFinder } from "fff";
|
|
59
|
+
*
|
|
60
|
+
* // Create an instance
|
|
61
|
+
* const finder = FileFinder.create({ basePath: "/path/to/project" });
|
|
62
|
+
* if (!finder.ok) {
|
|
63
|
+
* console.error(finder.error);
|
|
64
|
+
* process.exit(1);
|
|
65
|
+
* }
|
|
66
|
+
*
|
|
67
|
+
* // Wait for initial scan
|
|
68
|
+
* finder.value.waitForScan(5000);
|
|
69
|
+
*
|
|
70
|
+
* // Search for files
|
|
71
|
+
* const search = finder.value.search("main.ts");
|
|
72
|
+
* if (search.ok) {
|
|
73
|
+
* for (const item of search.value.items) {
|
|
74
|
+
* console.log(item.relativePath);
|
|
75
|
+
* }
|
|
76
|
+
* }
|
|
77
|
+
*
|
|
78
|
+
* // Cleanup
|
|
79
|
+
* finder.value.destroy();
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export class FileFinder {
|
|
83
|
+
private handle: NativeHandle | null;
|
|
84
|
+
|
|
85
|
+
private constructor(handle: NativeHandle) {
|
|
86
|
+
this.handle = handle;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create a new file finder instance.
|
|
91
|
+
*
|
|
92
|
+
* @param options - Initialization options
|
|
93
|
+
* @returns Result containing the new FileFinder instance or an error
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* // Basic initialization
|
|
98
|
+
* const finder = FileFinder.create({ basePath: "/path/to/project" });
|
|
99
|
+
*
|
|
100
|
+
* // With custom database paths
|
|
101
|
+
* const finder = FileFinder.create({
|
|
102
|
+
* basePath: "/path/to/project",
|
|
103
|
+
* frecencyDbPath: "/custom/frecency.mdb",
|
|
104
|
+
* historyDbPath: "/custom/history.mdb",
|
|
105
|
+
* });
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
static create(options: InitOptions): Result<FileFinder> {
|
|
109
|
+
const internalOpts = toInternalInitOptions(options);
|
|
110
|
+
const result = ffiCreate(JSON.stringify(internalOpts));
|
|
111
|
+
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ok: true, value: new FileFinder(result.value) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Destroy and clean up all resources.
|
|
121
|
+
*
|
|
122
|
+
* Call this when you're done using the file finder to free memory
|
|
123
|
+
* and stop background file watching. After calling this, the instance
|
|
124
|
+
* must not be used again.
|
|
125
|
+
*/
|
|
126
|
+
destroy(): void {
|
|
127
|
+
if (this.handle !== null) {
|
|
128
|
+
ffiDestroy(this.handle);
|
|
129
|
+
this.handle = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Check if this instance has been destroyed.
|
|
135
|
+
*/
|
|
136
|
+
get isDestroyed(): boolean {
|
|
137
|
+
return this.handle === null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Guard that returns an error if the instance has been destroyed.
|
|
142
|
+
*/
|
|
143
|
+
private ensureAlive(): Result<NativeHandle> {
|
|
144
|
+
if (this.handle === null) {
|
|
145
|
+
return err("FileFinder instance has been destroyed.");
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, value: this.handle };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Search for files matching the query.
|
|
152
|
+
*
|
|
153
|
+
* The query supports fuzzy matching and special syntax:
|
|
154
|
+
* - `foo bar` - Match files containing "foo" and "bar"
|
|
155
|
+
* - `src/` - Match files in src directory
|
|
156
|
+
* - `file.ts:42` - Match file.ts with line 42
|
|
157
|
+
* - `file.ts:42:10` - Match file.ts with line 42, column 10
|
|
158
|
+
*
|
|
159
|
+
* @param query - Search query string
|
|
160
|
+
* @param options - Search options
|
|
161
|
+
* @returns Search results with matched files and scores
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* const result = finder.search("main.ts", { pageSize: 10 });
|
|
166
|
+
* if (result.ok) {
|
|
167
|
+
* console.log(`Found ${result.value.totalMatched} files`);
|
|
168
|
+
* for (const item of result.value.items) {
|
|
169
|
+
* console.log(item.relativePath);
|
|
170
|
+
* }
|
|
171
|
+
* }
|
|
172
|
+
* ```
|
|
173
|
+
*/
|
|
174
|
+
search(query: string, options?: SearchOptions): Result<SearchResult> {
|
|
175
|
+
const guard = this.ensureAlive();
|
|
176
|
+
if (!guard.ok) return guard;
|
|
177
|
+
|
|
178
|
+
const internalOpts = toInternalSearchOptions(options);
|
|
179
|
+
const result = ffiSearch(guard.value, query, JSON.stringify(internalOpts));
|
|
180
|
+
|
|
181
|
+
if (!result.ok) {
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result as Result<SearchResult>;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Search file contents (live grep).
|
|
190
|
+
*
|
|
191
|
+
* Searches through the contents of indexed files using the specified mode:
|
|
192
|
+
* - `"plain"` (default): SIMD-accelerated literal text matching
|
|
193
|
+
* - `"regex"`: Regular expression matching
|
|
194
|
+
* - `"fuzzy"`: Smith-Waterman fuzzy matching per line
|
|
195
|
+
*
|
|
196
|
+
* Supports pagination for large result sets. The result includes a `nextCursor`
|
|
197
|
+
* that can be passed back to fetch the next page.
|
|
198
|
+
*
|
|
199
|
+
* The query also supports constraint syntax:
|
|
200
|
+
* - `*.ts pattern` - Only search in TypeScript files
|
|
201
|
+
* - `src/ pattern` - Only search in the src directory
|
|
202
|
+
*
|
|
203
|
+
* @param query - Search query string
|
|
204
|
+
* @param options - Grep options (mode, pagination, limits)
|
|
205
|
+
* @returns Grep results with matched lines and file metadata
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```typescript
|
|
209
|
+
* // First page
|
|
210
|
+
* const result = finder.liveGrep("TODO", { mode: "plain" });
|
|
211
|
+
* if (result.ok) {
|
|
212
|
+
* for (const match of result.value.items) {
|
|
213
|
+
* console.log(`${match.relativePath}:${match.lineNumber}: ${match.lineContent}`);
|
|
214
|
+
* }
|
|
215
|
+
* // Fetch next page
|
|
216
|
+
* if (result.value.nextCursor) {
|
|
217
|
+
* const page2 = finder.liveGrep("TODO", {
|
|
218
|
+
* cursor: result.value.nextCursor,
|
|
219
|
+
* });
|
|
220
|
+
* }
|
|
221
|
+
* }
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
liveGrep(query: string, options?: GrepOptions): Result<GrepResult> {
|
|
225
|
+
const guard = this.ensureAlive();
|
|
226
|
+
if (!guard.ok) return guard;
|
|
227
|
+
|
|
228
|
+
const internalOpts = toInternalGrepOptions(options);
|
|
229
|
+
const result = ffiLiveGrep(
|
|
230
|
+
guard.value,
|
|
231
|
+
query,
|
|
232
|
+
JSON.stringify(internalOpts)
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
if (!result.ok) {
|
|
236
|
+
return result;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Transform the raw FFI result: replace nextFileOffset with an opaque cursor
|
|
240
|
+
const raw = result.value as Record<string, unknown>;
|
|
241
|
+
const nextFileOffset = raw.nextFileOffset as number;
|
|
242
|
+
|
|
243
|
+
const grepResult: GrepResult = {
|
|
244
|
+
items: raw.items as GrepResult["items"],
|
|
245
|
+
totalMatched: raw.totalMatched as number,
|
|
246
|
+
totalFilesSearched: raw.totalFilesSearched as number,
|
|
247
|
+
totalFiles: raw.totalFiles as number,
|
|
248
|
+
filteredFileCount: raw.filteredFileCount as number,
|
|
249
|
+
nextCursor: nextFileOffset > 0 ? createGrepCursor(nextFileOffset) : null,
|
|
250
|
+
regexFallbackError: raw.regexFallbackError as string | undefined,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return { ok: true, value: grepResult };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Trigger a rescan of the indexed directory.
|
|
258
|
+
*
|
|
259
|
+
* This is useful after major file system changes that the
|
|
260
|
+
* background watcher might have missed.
|
|
261
|
+
*/
|
|
262
|
+
scanFiles(): Result<void> {
|
|
263
|
+
const guard = this.ensureAlive();
|
|
264
|
+
if (!guard.ok) return guard;
|
|
265
|
+
return ffiScanFiles(guard.value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Check if a scan is currently in progress.
|
|
270
|
+
*/
|
|
271
|
+
isScanning(): boolean {
|
|
272
|
+
if (this.handle === null) return false;
|
|
273
|
+
return ffiIsScanning(this.handle);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Get the current scan progress.
|
|
278
|
+
*/
|
|
279
|
+
getScanProgress(): Result<ScanProgress> {
|
|
280
|
+
const guard = this.ensureAlive();
|
|
281
|
+
if (!guard.ok) return guard;
|
|
282
|
+
return ffiGetScanProgress(guard.value) as Result<ScanProgress>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Wait for the initial file scan to complete.
|
|
287
|
+
*
|
|
288
|
+
* @param timeoutMs - Maximum time to wait in milliseconds (default: 5000)
|
|
289
|
+
* @returns true if scan completed, false if timed out
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```typescript
|
|
293
|
+
* const finder = FileFinder.create({ basePath: "/path/to/project" });
|
|
294
|
+
* if (finder.ok) {
|
|
295
|
+
* const completed = finder.value.waitForScan(10000);
|
|
296
|
+
* if (!completed.ok || !completed.value) {
|
|
297
|
+
* console.warn("Scan did not complete in time");
|
|
298
|
+
* }
|
|
299
|
+
* }
|
|
300
|
+
* ```
|
|
301
|
+
*/
|
|
302
|
+
waitForScan(timeoutMs: number = 5000): Result<boolean> {
|
|
303
|
+
const guard = this.ensureAlive();
|
|
304
|
+
if (!guard.ok) return guard;
|
|
305
|
+
return ffiWaitForScan(guard.value, timeoutMs);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Change the indexed directory to a new path.
|
|
310
|
+
*
|
|
311
|
+
* This stops the current file watcher and starts indexing the new directory.
|
|
312
|
+
*
|
|
313
|
+
* @param newPath - New directory path to index
|
|
314
|
+
*/
|
|
315
|
+
reindex(newPath: string): Result<void> {
|
|
316
|
+
const guard = this.ensureAlive();
|
|
317
|
+
if (!guard.ok) return guard;
|
|
318
|
+
return ffiRestartIndex(guard.value, newPath);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Track file access for frecency scoring.
|
|
323
|
+
*
|
|
324
|
+
* Call this when a user opens a file to improve future search rankings.
|
|
325
|
+
*
|
|
326
|
+
* @param filePath - Absolute path to the accessed file
|
|
327
|
+
*/
|
|
328
|
+
trackAccess(filePath: string): Result<boolean> {
|
|
329
|
+
const guard = this.ensureAlive();
|
|
330
|
+
if (!guard.ok) return guard;
|
|
331
|
+
return ffiTrackAccess(guard.value, filePath);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Refresh the git status cache.
|
|
336
|
+
*
|
|
337
|
+
* @returns Number of files with updated git status
|
|
338
|
+
*/
|
|
339
|
+
refreshGitStatus(): Result<number> {
|
|
340
|
+
const guard = this.ensureAlive();
|
|
341
|
+
if (!guard.ok) return guard;
|
|
342
|
+
return ffiRefreshGitStatus(guard.value);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Track query completion for smart suggestions.
|
|
347
|
+
*
|
|
348
|
+
* Call this when a user selects a file from search results.
|
|
349
|
+
* This helps improve future search rankings for similar queries.
|
|
350
|
+
*
|
|
351
|
+
* @param query - The search query that was used
|
|
352
|
+
* @param selectedFilePath - The file path that was selected
|
|
353
|
+
*/
|
|
354
|
+
trackQuery(query: string, selectedFilePath: string): Result<boolean> {
|
|
355
|
+
const guard = this.ensureAlive();
|
|
356
|
+
if (!guard.ok) return guard;
|
|
357
|
+
return ffiTrackQuery(guard.value, query, selectedFilePath);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get a historical query by offset.
|
|
362
|
+
*
|
|
363
|
+
* @param offset - Offset from most recent (0 = most recent)
|
|
364
|
+
* @returns The historical query string, or null if not found
|
|
365
|
+
*/
|
|
366
|
+
getHistoricalQuery(offset: number): Result<string | null> {
|
|
367
|
+
const guard = this.ensureAlive();
|
|
368
|
+
if (!guard.ok) return guard;
|
|
369
|
+
return ffiGetHistoricalQuery(guard.value, offset);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Get health check information.
|
|
374
|
+
*
|
|
375
|
+
* Useful for debugging and verifying the file finder is working correctly.
|
|
376
|
+
*
|
|
377
|
+
* @param testPath - Optional path to test git repository detection
|
|
378
|
+
*/
|
|
379
|
+
healthCheck(testPath?: string): Result<HealthCheck> {
|
|
380
|
+
return ffiHealthCheck(
|
|
381
|
+
this.handle,
|
|
382
|
+
testPath || ""
|
|
383
|
+
) as Result<HealthCheck>;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Check if the native library is available.
|
|
388
|
+
*/
|
|
389
|
+
static isAvailable(): boolean {
|
|
390
|
+
return isAvailable();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Ensure the native library is loaded.
|
|
395
|
+
*
|
|
396
|
+
* This will download the binary if needed and load it.
|
|
397
|
+
* Useful for preloading before first use.
|
|
398
|
+
*/
|
|
399
|
+
static async ensureLoaded(): Promise<void> {
|
|
400
|
+
return ensureLoaded();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Get a health check without requiring an instance.
|
|
405
|
+
*
|
|
406
|
+
* Returns limited info (version + git only, no picker/frecency/query data).
|
|
407
|
+
*
|
|
408
|
+
* @param testPath - Optional path to test git repository detection
|
|
409
|
+
*/
|
|
410
|
+
static healthCheckStatic(testPath?: string): Result<HealthCheck> {
|
|
411
|
+
return ffiHealthCheck(null, testPath || "") as Result<HealthCheck>;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { FileFinder } from "./index";
|
|
3
|
+
import type { FileItem } from "./types";
|
|
4
|
+
import {
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Integration test: full git lifecycle with a real repository.
|
|
18
|
+
*
|
|
19
|
+
* Creates a temporary git repo, initialises a FileFinder instance pointing at
|
|
20
|
+
* it, then walks through:
|
|
21
|
+
* 1. Initial scan – committed files should have status "clean"
|
|
22
|
+
* 2. Add a new untracked file – should appear as "untracked"
|
|
23
|
+
* 3. Stage the new file – should appear as "staged_new"
|
|
24
|
+
* 4. Commit – should become "clean"
|
|
25
|
+
* 5. Modify a tracked file – should become "modified"
|
|
26
|
+
* 6. Stage the modification – should become "staged_modified"
|
|
27
|
+
* 7. Commit again – back to "clean"
|
|
28
|
+
* 8. Delete a file – should disappear from the index
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const WATCHER_SETTLE_MS = 500; // accompany for the debouncer and replicate real life uasage
|
|
32
|
+
|
|
33
|
+
function git(cwd: string, ...args: string[]) {
|
|
34
|
+
const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
|
|
35
|
+
execSync(`git ${escaped}`, {
|
|
36
|
+
cwd,
|
|
37
|
+
stdio: "pipe",
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
GIT_AUTHOR_NAME: "test",
|
|
41
|
+
GIT_AUTHOR_EMAIL: "test@test.com",
|
|
42
|
+
GIT_COMMITTER_NAME: "test",
|
|
43
|
+
GIT_COMMITTER_EMAIL: "test@test.com",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sleep(ms: number) {
|
|
49
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function findFile(finder: FileFinder, name: string): FileItem | undefined {
|
|
53
|
+
const result = finder.search(name, { pageSize: 200 });
|
|
54
|
+
if (!result.ok) throw new Error(`search failed: ${result.error}`);
|
|
55
|
+
return result.value.items.find((item) => item.fileName === name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe.skipIf(process.platform === "win32")(
|
|
59
|
+
"Git lifecycle integration",
|
|
60
|
+
() => {
|
|
61
|
+
let tmpDir: string;
|
|
62
|
+
let finder: FileFinder;
|
|
63
|
+
|
|
64
|
+
beforeAll(() => {
|
|
65
|
+
// Create temp directory and initialise a git repo with two committed files.
|
|
66
|
+
// Use realpathSync to resolve symlinks (macOS /var -> /private/var) so
|
|
67
|
+
// that git2's resolved workdir paths match the file picker's base_path.
|
|
68
|
+
tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "fff-git-test-")));
|
|
69
|
+
|
|
70
|
+
git(tmpDir, "init", "-b", "main");
|
|
71
|
+
// Need at least one commit for status to work properly
|
|
72
|
+
writeFileSync(join(tmpDir, "hello.txt"), "hello world\n");
|
|
73
|
+
writeFileSync(join(tmpDir, "readme.md"), "# Test Project\n");
|
|
74
|
+
mkdirSync(join(tmpDir, "src"));
|
|
75
|
+
writeFileSync(
|
|
76
|
+
join(tmpDir, "src", "main.rs"),
|
|
77
|
+
'fn main() { println?."hi"); }\n',
|
|
78
|
+
);
|
|
79
|
+
git(tmpDir, "add", "-A");
|
|
80
|
+
git(tmpDir, "commit", "-m", "initial commit");
|
|
81
|
+
|
|
82
|
+
// Create the FileFinder instance
|
|
83
|
+
const result = FileFinder.create({ basePath: tmpDir });
|
|
84
|
+
expect(result.ok).toBe(true);
|
|
85
|
+
if (!result.ok) throw new Error(result.error);
|
|
86
|
+
finder = result.value;
|
|
87
|
+
|
|
88
|
+
// Wait for the initial scan to finish
|
|
89
|
+
const scanResult = finder.waitForScan(10_000);
|
|
90
|
+
expect(scanResult.ok).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterAll(() => {
|
|
94
|
+
finder?.destroy();
|
|
95
|
+
if (tmpDir) {
|
|
96
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("initial scan indexes all committed files", () => {
|
|
101
|
+
const result = finder.search("", { pageSize: 200 });
|
|
102
|
+
expect(result.ok).toBe(true);
|
|
103
|
+
if (!result.ok) return;
|
|
104
|
+
|
|
105
|
+
const names = result.value.items.map((i) => i.relativePath).sort();
|
|
106
|
+
expect(names).toContain("hello.txt");
|
|
107
|
+
expect(names).toContain("readme.md");
|
|
108
|
+
expect(names).toContain("src/main.rs");
|
|
109
|
+
expect(result.value.totalFiles).toBe(3);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("committed files have clean git status", async () => {
|
|
113
|
+
// Wait for background watcher to process initial git status
|
|
114
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
115
|
+
|
|
116
|
+
const hello = findFile(finder, "hello.txt");
|
|
117
|
+
expect(hello).toBeDefined();
|
|
118
|
+
expect(hello?.gitStatus).toBe("clean");
|
|
119
|
+
|
|
120
|
+
const main = findFile(finder, "main.rs");
|
|
121
|
+
expect(main).toBeDefined();
|
|
122
|
+
expect(main?.gitStatus).toBe("clean");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("new untracked file appears with 'untracked' status", async () => {
|
|
126
|
+
writeFileSync(join(tmpDir, "new_file.ts"), "export const x = 1;\n");
|
|
127
|
+
|
|
128
|
+
// Wait for the background watcher to pick up the change and update git status
|
|
129
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
130
|
+
|
|
131
|
+
const newFile = findFile(finder, "new_file.ts");
|
|
132
|
+
expect(newFile).toBeDefined();
|
|
133
|
+
expect(newFile?.gitStatus).toBe("untracked");
|
|
134
|
+
|
|
135
|
+
// Total should now be 4
|
|
136
|
+
const all = finder.search("", { pageSize: 200 });
|
|
137
|
+
expect(all.ok).toBe(true);
|
|
138
|
+
if (all.ok) {
|
|
139
|
+
expect(all.value.totalFiles).toBe(4);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("staging a new file changes status to 'staged_new'", async () => {
|
|
144
|
+
git(tmpDir, "add", "new_file.ts");
|
|
145
|
+
|
|
146
|
+
// Wait for background watcher to detect .git/index change
|
|
147
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
148
|
+
|
|
149
|
+
const newFile = findFile(finder, "new_file.ts");
|
|
150
|
+
expect(newFile).toBeDefined();
|
|
151
|
+
expect(newFile?.gitStatus).toBe("staged_new");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("committing makes the file 'clean'", async () => {
|
|
155
|
+
git(tmpDir, "commit", "-m", "add new_file");
|
|
156
|
+
|
|
157
|
+
// Wait for background watcher to detect .git changes
|
|
158
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
159
|
+
|
|
160
|
+
const newFile = findFile(finder, "new_file.ts");
|
|
161
|
+
expect(newFile).toBeDefined();
|
|
162
|
+
expect(newFile?.gitStatus).toBe("clean");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("modifying a tracked file changes status to 'modified'", async () => {
|
|
166
|
+
writeFileSync(
|
|
167
|
+
join(tmpDir, "hello.txt"),
|
|
168
|
+
"hello world\nupdated content\n",
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Wait for background watcher to detect file modification and update git status
|
|
172
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
173
|
+
|
|
174
|
+
const hello = findFile(finder, "hello.txt");
|
|
175
|
+
expect(hello).toBeDefined();
|
|
176
|
+
expect(hello?.gitStatus).toBe("modified");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("staging a modification changes status to 'staged_modified'", async () => {
|
|
180
|
+
git(tmpDir, "add", "hello.txt");
|
|
181
|
+
|
|
182
|
+
// Wait for background watcher to detect .git/index change
|
|
183
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
184
|
+
|
|
185
|
+
const hello = findFile(finder, "hello.txt");
|
|
186
|
+
expect(hello).toBeDefined();
|
|
187
|
+
expect(hello?.gitStatus).toBe("staged_modified");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("committing the modification returns to 'clean'", async () => {
|
|
191
|
+
git(tmpDir, "commit", "-m", "update hello");
|
|
192
|
+
|
|
193
|
+
// Wait for background watcher to detect .git changes
|
|
194
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
195
|
+
|
|
196
|
+
const hello = findFile(finder, "hello.txt");
|
|
197
|
+
expect(hello).toBeDefined();
|
|
198
|
+
expect(hello?.gitStatus).toBe("clean");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("deleting a file removes it from the index", async () => {
|
|
202
|
+
unlinkSync(join(tmpDir, "new_file.ts"));
|
|
203
|
+
|
|
204
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
205
|
+
|
|
206
|
+
const result = finder.search("new_file.ts", { pageSize: 200 });
|
|
207
|
+
expect(result.ok).toBe(true);
|
|
208
|
+
if (!result.ok) return;
|
|
209
|
+
|
|
210
|
+
const found = result.value.items.find(
|
|
211
|
+
(i) => i.fileName === "new_file.ts",
|
|
212
|
+
);
|
|
213
|
+
expect(found).toBeUndefined();
|
|
214
|
+
|
|
215
|
+
// Total should be back to 3
|
|
216
|
+
const all = finder.search("", { pageSize: 200 });
|
|
217
|
+
expect(all.ok).toBe(true);
|
|
218
|
+
if (all.ok) {
|
|
219
|
+
expect(all.value.totalFiles).toBe(3);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("adding a file in a subdirectory works", async () => {
|
|
224
|
+
writeFileSync(join(tmpDir, "src", "utils.rs"), "pub fn helper() {}\n");
|
|
225
|
+
|
|
226
|
+
// Wait for background watcher to detect new file and update git status
|
|
227
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
228
|
+
|
|
229
|
+
const utils = findFile(finder, "utils.rs");
|
|
230
|
+
expect(utils).toBeDefined();
|
|
231
|
+
expect(utils?.relativePath).toBe("src/utils.rs");
|
|
232
|
+
expect(utils?.gitStatus).toBe("untracked");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("live grep finds content in a newly added file", async () => {
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(tmpDir, "src", "searchtarget.rs"),
|
|
238
|
+
'const UNIQUE_NEEDLE: &str = "xylophone_waterfall_97";\n',
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
242
|
+
|
|
243
|
+
const result = finder.liveGrep("xylophone_waterfall_97", {
|
|
244
|
+
mode: "plain",
|
|
245
|
+
});
|
|
246
|
+
expect(result.ok).toBe(true);
|
|
247
|
+
if (!result.ok) return;
|
|
248
|
+
|
|
249
|
+
expect(result.value.totalMatched).toBeGreaterThan(0);
|
|
250
|
+
const match = result.value.items.find(
|
|
251
|
+
(m) => m.relativePath === "src/searchtarget.rs",
|
|
252
|
+
);
|
|
253
|
+
expect(match).toBeDefined();
|
|
254
|
+
expect(match!.lineContent).toContain("xylophone_waterfall_97");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("live grep no longer finds content after file is deleted", async () => {
|
|
258
|
+
unlinkSync(join(tmpDir, "src", "searchtarget.rs"));
|
|
259
|
+
|
|
260
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
261
|
+
|
|
262
|
+
const result = finder.liveGrep("xylophone_waterfall_97", {
|
|
263
|
+
mode: "plain",
|
|
264
|
+
});
|
|
265
|
+
expect(result.ok).toBe(true);
|
|
266
|
+
if (!result.ok) return;
|
|
267
|
+
|
|
268
|
+
expect(result.value.totalMatched).toBe(0);
|
|
269
|
+
expect(result.value.items.length).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("full add-commit cycle for subdirectory file", async () => {
|
|
273
|
+
git(tmpDir, "add", "src/utils.rs");
|
|
274
|
+
|
|
275
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
276
|
+
|
|
277
|
+
let utils = findFile(finder, "utils.rs");
|
|
278
|
+
expect(utils).toBeDefined();
|
|
279
|
+
expect(utils?.gitStatus).toBe("staged_new");
|
|
280
|
+
|
|
281
|
+
git(tmpDir, "commit", "-m", "add utils");
|
|
282
|
+
|
|
283
|
+
// Wait for background watcher to detect .git changes
|
|
284
|
+
await sleep(WATCHER_SETTLE_MS);
|
|
285
|
+
|
|
286
|
+
utils = findFile(finder, "utils.rs");
|
|
287
|
+
expect(utils).toBeDefined();
|
|
288
|
+
expect(utils?.gitStatus).toBe("clean");
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
);
|