@xynogen/pix-search 0.1.1
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/LICENSE +21 -0
- package/README.md +17 -0
- package/package.json +53 -0
- package/src/extension.ts +19 -0
- package/src/index.ts +1 -0
- package/src/provider.ts +165 -0
- package/src/recency.ts +59 -0
- package/src/rg.ts +189 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 xynogen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# pix-search
|
|
2
|
+
|
|
3
|
+
Smarter `@` file search for [Pi Coding Agent](https://github.com/earendil-works/pi).
|
|
4
|
+
|
|
5
|
+
Wraps the built-in autocomplete provider to improve file ranking when you type `@` in the prompt:
|
|
6
|
+
|
|
7
|
+
- **Fuzzy scoring** — proper character-order matching with gap penalties and word-boundary bonuses (uses pi-tui's `fuzzyMatch`)
|
|
8
|
+
- **Git recency** — recently modified files (from `git log`) rank higher
|
|
9
|
+
- **Depth penalty** — shallower files win ties
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:@xynogen/pix-search
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Standalone — **not** bundled by `@xynogen/pix-core`.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-search",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Pi extension — smarter @ file search with fuzzy + git recency ranking",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./extension": "./src/extension.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"!src/**/*.test.*",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"src/extension.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"pi",
|
|
27
|
+
"pi-package",
|
|
28
|
+
"pi-extension",
|
|
29
|
+
"search",
|
|
30
|
+
"autocomplete"
|
|
31
|
+
],
|
|
32
|
+
"author": "xynogen",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
37
|
+
"directory": "packages/pix-search"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-search#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/xynogen/pix-mono/issues"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@vscode/ripgrep": "^1.18.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { createSearchProvider } from "./provider.ts";
|
|
3
|
+
import { loadRecency, type RecencyMap } from "./recency.ts";
|
|
4
|
+
|
|
5
|
+
export default function (pi: ExtensionAPI): void {
|
|
6
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
7
|
+
const cwd = ctx.cwd;
|
|
8
|
+
|
|
9
|
+
// Warm recency cache in background — non-blocking
|
|
10
|
+
let recency: RecencyMap = new Map();
|
|
11
|
+
const recencyReady = loadRecency(cwd).then((map) => {
|
|
12
|
+
recency = map;
|
|
13
|
+
});
|
|
14
|
+
// Fire-and-forget; if git isn't available, recency stays empty
|
|
15
|
+
void recencyReady;
|
|
16
|
+
|
|
17
|
+
ctx.ui.addAutocompleteProvider((current) => createSearchProvider(current, cwd, () => recency));
|
|
18
|
+
});
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extension.ts";
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete provider wrapper — intercepts @ suggestions and re-ranks
|
|
3
|
+
* using rg file discovery, fuzzy scoring, and git recency.
|
|
4
|
+
*
|
|
5
|
+
* Non-@ completions (slash commands, path completions) pass through to the
|
|
6
|
+
* built-in provider unchanged.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { basename } from "node:path";
|
|
10
|
+
import type {
|
|
11
|
+
AutocompleteItem,
|
|
12
|
+
AutocompleteProvider,
|
|
13
|
+
AutocompleteSuggestions,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import type { RecencyMap } from "./recency.ts";
|
|
16
|
+
import { rgContent, rgFiles, scoreFilename } from "./rg.ts";
|
|
17
|
+
|
|
18
|
+
const MAX_SUGGESTIONS = 20;
|
|
19
|
+
/** Minimum query length before we also search file contents */
|
|
20
|
+
const CONTENT_SEARCH_MIN = 2;
|
|
21
|
+
|
|
22
|
+
/** Weight factors for the combined score */
|
|
23
|
+
const W_FILENAME = 1.0;
|
|
24
|
+
const W_RECENCY = 30;
|
|
25
|
+
const W_CONTENT = 25;
|
|
26
|
+
const W_DEPTH = -0.5; // penalty per path depth level
|
|
27
|
+
|
|
28
|
+
interface ScoredEntry {
|
|
29
|
+
path: string;
|
|
30
|
+
filenameScore: number;
|
|
31
|
+
recencyScore: number;
|
|
32
|
+
contentMatch: boolean;
|
|
33
|
+
depth: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function depthOf(path: string): number {
|
|
37
|
+
return path.split("/").length - 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function combinedScore(entry: ScoredEntry): number {
|
|
41
|
+
return (
|
|
42
|
+
entry.filenameScore * W_FILENAME +
|
|
43
|
+
entry.recencyScore * W_RECENCY +
|
|
44
|
+
(entry.contentMatch ? W_CONTENT : 0) +
|
|
45
|
+
entry.depth * W_DEPTH
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAtPrefix(prefix: string): boolean {
|
|
50
|
+
return prefix.startsWith("@") || prefix.startsWith('@"');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractRawQuery(prefix: string): string {
|
|
54
|
+
let raw = prefix;
|
|
55
|
+
if (raw.startsWith('@"')) raw = raw.slice(2);
|
|
56
|
+
else if (raw.startsWith("@")) raw = raw.slice(1);
|
|
57
|
+
if (raw.endsWith('"')) raw = raw.slice(0, -1);
|
|
58
|
+
return raw;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildValue(path: string, isDir: boolean, quoted: boolean): string {
|
|
62
|
+
const displayPath = isDir ? `${path}/` : path;
|
|
63
|
+
const needsQuotes = quoted || path.includes(" ");
|
|
64
|
+
if (!needsQuotes) return `@${displayPath}`;
|
|
65
|
+
return `@"${displayPath}"`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createSearchProvider(
|
|
69
|
+
inner: AutocompleteProvider,
|
|
70
|
+
cwd: string,
|
|
71
|
+
getRecency: () => RecencyMap,
|
|
72
|
+
): AutocompleteProvider {
|
|
73
|
+
return {
|
|
74
|
+
triggerCharacters: inner.triggerCharacters,
|
|
75
|
+
|
|
76
|
+
async getSuggestions(
|
|
77
|
+
lines,
|
|
78
|
+
cursorLine,
|
|
79
|
+
cursorCol,
|
|
80
|
+
options,
|
|
81
|
+
): Promise<AutocompleteSuggestions | null> {
|
|
82
|
+
// Let inner figure out if this is an @ prefix
|
|
83
|
+
const result = await inner.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
84
|
+
|
|
85
|
+
// Not an @ prefix, or inner returned nothing — pass through
|
|
86
|
+
if (!result || !isAtPrefix(result.prefix)) return result;
|
|
87
|
+
|
|
88
|
+
const query = extractRawQuery(result.prefix);
|
|
89
|
+
const isQuoted = result.prefix.includes('"');
|
|
90
|
+
const { signal } = options;
|
|
91
|
+
|
|
92
|
+
if (signal.aborted) return result;
|
|
93
|
+
|
|
94
|
+
// Run rg in parallel: file listing + content search
|
|
95
|
+
const [allFiles, contentFiles] = await Promise.all([
|
|
96
|
+
rgFiles(cwd, signal),
|
|
97
|
+
query.length >= CONTENT_SEARCH_MIN ? rgContent(query, cwd, signal) : Promise.resolve([]),
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
if (signal.aborted) return result;
|
|
101
|
+
|
|
102
|
+
const contentSet = new Set(contentFiles);
|
|
103
|
+
const recency = getRecency();
|
|
104
|
+
|
|
105
|
+
// Score every file
|
|
106
|
+
const scored: ScoredEntry[] = [];
|
|
107
|
+
for (const path of allFiles) {
|
|
108
|
+
const fnScore = scoreFilename(path, query);
|
|
109
|
+
const isContent = contentSet.has(path);
|
|
110
|
+
// Skip if neither filename nor content match
|
|
111
|
+
if (fnScore === 0 && !isContent) continue;
|
|
112
|
+
|
|
113
|
+
scored.push({
|
|
114
|
+
path,
|
|
115
|
+
filenameScore: fnScore,
|
|
116
|
+
recencyScore: recency.get(path) ?? 0,
|
|
117
|
+
contentMatch: isContent,
|
|
118
|
+
depth: depthOf(path),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Add content-only matches not in allFiles
|
|
123
|
+
for (const path of contentFiles) {
|
|
124
|
+
if (scored.some((e) => e.path === path)) continue;
|
|
125
|
+
scored.push({
|
|
126
|
+
path,
|
|
127
|
+
filenameScore: 0,
|
|
128
|
+
recencyScore: recency.get(path) ?? 0,
|
|
129
|
+
contentMatch: true,
|
|
130
|
+
depth: depthOf(path),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Sort by combined score descending
|
|
135
|
+
scored.sort((a, b) => combinedScore(b) - combinedScore(a));
|
|
136
|
+
|
|
137
|
+
const items: AutocompleteItem[] = scored.slice(0, MAX_SUGGESTIONS).map((entry) => {
|
|
138
|
+
const isDir = entry.path.endsWith("/");
|
|
139
|
+
const cleanPath = isDir ? entry.path.slice(0, -1) : entry.path;
|
|
140
|
+
const label = basename(cleanPath) + (isDir ? "/" : "");
|
|
141
|
+
const desc =
|
|
142
|
+
entry.contentMatch && entry.filenameScore === 0
|
|
143
|
+
? `${entry.path} (content match)`
|
|
144
|
+
: entry.path;
|
|
145
|
+
return {
|
|
146
|
+
value: buildValue(cleanPath, isDir, isQuoted),
|
|
147
|
+
label,
|
|
148
|
+
description: desc,
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (items.length === 0) return result; // fall back to built-in
|
|
153
|
+
|
|
154
|
+
return { items, prefix: result.prefix };
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
158
|
+
return inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
162
|
+
return inner.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/recency.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git recency cache — maps relative file paths to a recency score (0–1).
|
|
3
|
+
* Uses `git log --name-only` to find recently touched files.
|
|
4
|
+
* Cached per session, refreshed on demand.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const MAX_COMMITS = 200;
|
|
10
|
+
const DECAY = 0.97; // exponential decay per commit position
|
|
11
|
+
|
|
12
|
+
export type RecencyMap = Map<string, number>;
|
|
13
|
+
|
|
14
|
+
export function buildRecencyScores(gitOutput: string): RecencyMap {
|
|
15
|
+
const scores: RecencyMap = new Map();
|
|
16
|
+
// git log --name-only output: commit header, blank, files, blank, repeat
|
|
17
|
+
const files = gitOutput
|
|
18
|
+
.split("\n")
|
|
19
|
+
.map((l) => l.trim())
|
|
20
|
+
.filter((l) => l.length > 0 && !l.startsWith("commit "));
|
|
21
|
+
|
|
22
|
+
let position = 0;
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
if (scores.has(file)) continue; // first occurrence = most recent
|
|
25
|
+
scores.set(file, DECAY ** position);
|
|
26
|
+
position++;
|
|
27
|
+
}
|
|
28
|
+
return scores;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function loadRecency(cwd: string, signal?: AbortSignal): Promise<RecencyMap> {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
if (signal?.aborted) {
|
|
34
|
+
resolve(new Map());
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const child = execFile(
|
|
39
|
+
"git",
|
|
40
|
+
["log", "--name-only", "--pretty=format:", `-n${MAX_COMMITS}`, "--diff-filter=ACMR"],
|
|
41
|
+
{ cwd, maxBuffer: 2 * 1024 * 1024, signal },
|
|
42
|
+
(err, stdout) => {
|
|
43
|
+
if (err || !stdout) {
|
|
44
|
+
resolve(new Map());
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolve(buildRecencyScores(stdout));
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
signal?.addEventListener(
|
|
52
|
+
"abort",
|
|
53
|
+
() => {
|
|
54
|
+
child.kill("SIGKILL");
|
|
55
|
+
},
|
|
56
|
+
{ once: true },
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/rg.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ripgrep-based file discovery.
|
|
3
|
+
* - `rgFiles(query)` — filename matches via `rg --files` piped through fuzzy
|
|
4
|
+
* - `rgContent(query)` — files containing query text via `rg -l`
|
|
5
|
+
*
|
|
6
|
+
* Both respect .gitignore, hidden-file exclusions, and abort signals.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { basename } from "node:path";
|
|
11
|
+
|
|
12
|
+
// Bundled ripgrep: @vscode/ripgrep pulls a prebuilt rg per-platform on install
|
|
13
|
+
// and exposes its absolute path, so users no longer need rg on PATH. Fall back
|
|
14
|
+
// to a bare "rg" (PATH lookup) if the postinstall binary download was skipped
|
|
15
|
+
// (e.g. npm_config_ignore_scripts).
|
|
16
|
+
let RG_BIN = "rg";
|
|
17
|
+
try {
|
|
18
|
+
RG_BIN = (require("@vscode/ripgrep") as { rgPath: string }).rgPath;
|
|
19
|
+
} catch {
|
|
20
|
+
/* bundled binary unavailable — fall back to PATH `rg` */
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RgEntry {
|
|
24
|
+
/** Relative path from basePath */
|
|
25
|
+
path: string;
|
|
26
|
+
/** Whether this matched by content (true) or filename (false) */
|
|
27
|
+
contentMatch: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TIMEOUT_MS = 3_000;
|
|
31
|
+
|
|
32
|
+
function spawnRg(args: string[], cwd: string, signal: AbortSignal): Promise<string[]> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
if (signal.aborted) {
|
|
35
|
+
resolve([]);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const child = spawn(RG_BIN, args, {
|
|
40
|
+
cwd,
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
// ponytail: no env filtering — inherits .gitignore respect from rg defaults
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let stdout = "";
|
|
46
|
+
let resolved = false;
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
if (!resolved) child.kill("SIGKILL");
|
|
49
|
+
}, TIMEOUT_MS);
|
|
50
|
+
|
|
51
|
+
const finish = (lines: string[]) => {
|
|
52
|
+
if (resolved) return;
|
|
53
|
+
resolved = true;
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
signal.removeEventListener("abort", onAbort);
|
|
56
|
+
resolve(lines);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const onAbort = () => {
|
|
60
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
61
|
+
};
|
|
62
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
63
|
+
|
|
64
|
+
child.stdout.setEncoding("utf-8");
|
|
65
|
+
child.stdout.on("data", (chunk: string) => {
|
|
66
|
+
stdout += chunk;
|
|
67
|
+
});
|
|
68
|
+
child.on("error", () => finish([]));
|
|
69
|
+
child.on("close", () => {
|
|
70
|
+
const lines = stdout
|
|
71
|
+
.trim()
|
|
72
|
+
.split("\n")
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map((l) => l.replace(/\\/g, "/"));
|
|
75
|
+
finish(lines);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* List all files via `rg --files`, return raw paths.
|
|
82
|
+
* Fast — just enumerates, rg does .gitignore filtering.
|
|
83
|
+
*/
|
|
84
|
+
export async function rgFiles(cwd: string, signal: AbortSignal): Promise<string[]> {
|
|
85
|
+
return spawnRg(["--files", "--hidden", "--glob", "!.git"], cwd, signal);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Search file contents for `query`, return matching file paths.
|
|
90
|
+
* Uses fixed-string search (no regex) for speed + safety.
|
|
91
|
+
*/
|
|
92
|
+
export async function rgContent(
|
|
93
|
+
query: string,
|
|
94
|
+
cwd: string,
|
|
95
|
+
signal: AbortSignal,
|
|
96
|
+
): Promise<string[]> {
|
|
97
|
+
if (!query || query.length < 2) return [];
|
|
98
|
+
return spawnRg(
|
|
99
|
+
[
|
|
100
|
+
"--files-with-matches",
|
|
101
|
+
"--fixed-strings",
|
|
102
|
+
"--ignore-case",
|
|
103
|
+
"--hidden",
|
|
104
|
+
"--glob",
|
|
105
|
+
"!.git",
|
|
106
|
+
"--max-count",
|
|
107
|
+
"1", // stop after first match per file
|
|
108
|
+
"--",
|
|
109
|
+
query,
|
|
110
|
+
],
|
|
111
|
+
cwd,
|
|
112
|
+
signal,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Combined file search: filename fuzzy + content grep.
|
|
118
|
+
* Returns deduplicated entries, filename matches first.
|
|
119
|
+
*/
|
|
120
|
+
export async function rgSearch(
|
|
121
|
+
query: string,
|
|
122
|
+
cwd: string,
|
|
123
|
+
signal: AbortSignal,
|
|
124
|
+
): Promise<RgEntry[]> {
|
|
125
|
+
// Run both in parallel
|
|
126
|
+
const [allFiles, contentFiles] = await Promise.all([
|
|
127
|
+
rgFiles(cwd, signal),
|
|
128
|
+
rgContent(query, cwd, signal),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
if (signal.aborted) return [];
|
|
132
|
+
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
const results: RgEntry[] = [];
|
|
135
|
+
|
|
136
|
+
// Filename matches (all files, will be fuzzy-scored by caller)
|
|
137
|
+
for (const path of allFiles) {
|
|
138
|
+
if (seen.has(path)) continue;
|
|
139
|
+
seen.add(path);
|
|
140
|
+
results.push({ path, contentMatch: false });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Content-only matches (files not already in filename results)
|
|
144
|
+
for (const path of contentFiles) {
|
|
145
|
+
if (seen.has(path)) continue;
|
|
146
|
+
seen.add(path);
|
|
147
|
+
results.push({ path, contentMatch: true });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return results;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Score a file path against a query for filename relevance.
|
|
155
|
+
* Returns 0 for no match. Higher = better.
|
|
156
|
+
*/
|
|
157
|
+
export function scoreFilename(filePath: string, query: string): number {
|
|
158
|
+
if (!query) return 1;
|
|
159
|
+
|
|
160
|
+
const fileName = basename(filePath);
|
|
161
|
+
const lowerFile = fileName.toLowerCase();
|
|
162
|
+
const lowerPath = filePath.toLowerCase();
|
|
163
|
+
const lowerQuery = query.toLowerCase();
|
|
164
|
+
|
|
165
|
+
// Exact filename
|
|
166
|
+
if (lowerFile === lowerQuery) return 100;
|
|
167
|
+
// Filename starts with query
|
|
168
|
+
if (lowerFile.startsWith(lowerQuery)) return 80;
|
|
169
|
+
// Filename contains query
|
|
170
|
+
if (lowerFile.includes(lowerQuery)) return 60;
|
|
171
|
+
// Full path contains query
|
|
172
|
+
if (lowerPath.includes(lowerQuery)) return 30;
|
|
173
|
+
|
|
174
|
+
// Fuzzy: all query chars appear in order in filename
|
|
175
|
+
let qi = 0;
|
|
176
|
+
for (let i = 0; i < lowerFile.length && qi < lowerQuery.length; i++) {
|
|
177
|
+
if (lowerFile[i] === lowerQuery[qi]) qi++;
|
|
178
|
+
}
|
|
179
|
+
if (qi === lowerQuery.length) return 20;
|
|
180
|
+
|
|
181
|
+
// Fuzzy on full path
|
|
182
|
+
qi = 0;
|
|
183
|
+
for (let i = 0; i < lowerPath.length && qi < lowerQuery.length; i++) {
|
|
184
|
+
if (lowerPath[i] === lowerQuery[qi]) qi++;
|
|
185
|
+
}
|
|
186
|
+
if (qi === lowerQuery.length) return 10;
|
|
187
|
+
|
|
188
|
+
return 0;
|
|
189
|
+
}
|