claude-code-session-manager 0.57.1 → 0.59.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/README.md +2 -2
- package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
- package/dist/assets/index-BPnfPdLW.js +3222 -0
- package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
- package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
- package/scripts/lib/watchdogHelpers.cjs +0 -434
- package/scripts/mint-epic.cjs +6 -6
- package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
- package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
- package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
- package/src/main/__tests__/epicMint.test.cjs +165 -207
- package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
- package/src/main/__tests__/prdMigration.test.cjs +160 -0
- package/src/main/__tests__/projectPages.test.cjs +151 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
- package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
- package/src/main/__tests__/rcaReport.test.cjs +188 -0
- package/src/main/__tests__/runVerify.test.cjs +109 -6
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
- package/src/main/chatRunner.cjs +10 -1
- package/src/main/health.cjs +5 -1
- package/src/main/index.cjs +3 -5
- package/src/main/ipcSchemas.cjs +72 -4
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
- package/src/main/lib/agentModelResolve.cjs +97 -0
- package/src/main/lib/auditLog.cjs +2 -2
- package/src/main/lib/classifyTranscriptLine.cjs +131 -48
- package/src/main/lib/epicContextDigest.cjs +78 -0
- package/src/main/lib/epicMint.cjs +86 -164
- package/src/main/lib/epicValidationHook.cjs +192 -0
- package/src/main/lib/prdMigration.cjs +76 -5
- package/src/main/lib/promptSessionSchema.cjs +95 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
- package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
- package/src/main/lib/schedulerBatch.cjs +70 -111
- package/src/main/lib/schedulerConfig.cjs +0 -1
- package/src/main/otel.cjs +3 -1
- package/src/main/projectPages.cjs +60 -14
- package/src/main/promptSessionEvents.cjs +16 -1
- package/src/main/runVerify.cjs +17 -2
- package/src/main/scheduler.cjs +302 -68
- package/src/main/templates/project-pages-default-home.html +123 -0
- package/src/main/transcripts.cjs +191 -32
- package/src/main/webRemote.cjs +8 -7
- package/src/preload/api.d.ts +100 -57
- package/src/preload/index.cjs +9 -7
- package/dist/assets/index-DCa3QvAi.js +0 -3227
- package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
- package/scripts/propose-epic.cjs +0 -56
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
- package/src/main/repoAnalyzer.cjs +0 -346
- package/src/main/search.cjs +0 -332
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* repo:analyze(cwd) — walk a project tree and return a compact stats snapshot
|
|
3
|
-
* for the RepoVisualizationModal.
|
|
4
|
-
*
|
|
5
|
-
* Ported in spirit from ClaudeCodeUnleashed's src/main/ipc/repoAnalyzer.ts, but
|
|
6
|
-
* stripped down to what we actually surface (no dependency listing, no full
|
|
7
|
-
* tree return, no file-content fetch). The renderer renders:
|
|
8
|
-
* - language breakdown bar (extension → file count, line count)
|
|
9
|
-
* - top directories treemap
|
|
10
|
-
* - uncommitted-file badge from git:status
|
|
11
|
-
* so the IPC return shape stays in lockstep with what the modal needs.
|
|
12
|
-
*
|
|
13
|
-
* Security:
|
|
14
|
-
* - cwd is validatePath'd (allowedRoots = home). Never follows symlinks
|
|
15
|
-
* (lstat-based; dirent.isSymbolicLink() check on every entry).
|
|
16
|
-
* - Hard ceilings: 30s wall-clock, 5000 files, 100KB per-file line counting.
|
|
17
|
-
* - Reads only — no writes, no spawn.
|
|
18
|
-
*
|
|
19
|
-
* Complexity: O(N) entries where N = files+dirs visited (bounded by
|
|
20
|
-
* MAX_FILES_TO_ANALYZE). Tail-recursion stack depth is bounded by
|
|
21
|
-
* MAX_DEPTH (10) so no stack hazards on pathologically nested trees.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const fsp = require('node:fs/promises');
|
|
25
|
-
const path = require('node:path');
|
|
26
|
-
const { validatePath } = require('./config.cjs');
|
|
27
|
-
const { schemas } = require('./ipcSchemas.cjs');
|
|
28
|
-
const gitMod = require('./git.cjs');
|
|
29
|
-
|
|
30
|
-
// Directories to skip — `.git`, common build / dep / cache dirs.
|
|
31
|
-
const IGNORE_DIRS = new Set([
|
|
32
|
-
'.git', 'node_modules', 'dist', 'build', 'out', '.next', '.nuxt',
|
|
33
|
-
'coverage', '.cache', '.vscode', '.idea', '__pycache__', 'venv',
|
|
34
|
-
'.env', 'target', 'vendor', 'bower_components',
|
|
35
|
-
'.turbo', '.parcel-cache', '.svelte-kit', '.angular',
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
// Filenames to skip outright (lockfiles, OS detritus).
|
|
39
|
-
const IGNORE_FILES = new Set([
|
|
40
|
-
'.DS_Store', 'Thumbs.db',
|
|
41
|
-
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
42
|
-
]);
|
|
43
|
-
|
|
44
|
-
// Per-extension display name. Anything not in here lumps under 'other'.
|
|
45
|
-
const LANG_LABELS = {
|
|
46
|
-
'.ts': 'TypeScript',
|
|
47
|
-
'.tsx': 'TSX',
|
|
48
|
-
'.js': 'JavaScript',
|
|
49
|
-
'.jsx': 'JSX',
|
|
50
|
-
'.cjs': 'CommonJS',
|
|
51
|
-
'.mjs': 'ES Module',
|
|
52
|
-
'.json': 'JSON',
|
|
53
|
-
'.css': 'CSS',
|
|
54
|
-
'.scss': 'SCSS',
|
|
55
|
-
'.less': 'Less',
|
|
56
|
-
'.html': 'HTML',
|
|
57
|
-
'.md': 'Markdown',
|
|
58
|
-
'.mdx': 'MDX',
|
|
59
|
-
'.py': 'Python',
|
|
60
|
-
'.go': 'Go',
|
|
61
|
-
'.rs': 'Rust',
|
|
62
|
-
'.java': 'Java',
|
|
63
|
-
'.kt': 'Kotlin',
|
|
64
|
-
'.c': 'C',
|
|
65
|
-
'.cpp': 'C++',
|
|
66
|
-
'.cc': 'C++',
|
|
67
|
-
'.h': 'C/C++ Header',
|
|
68
|
-
'.hpp': 'C++ Header',
|
|
69
|
-
'.swift': 'Swift',
|
|
70
|
-
'.rb': 'Ruby',
|
|
71
|
-
'.php': 'PHP',
|
|
72
|
-
'.sql': 'SQL',
|
|
73
|
-
'.sh': 'Shell',
|
|
74
|
-
'.bash': 'Shell',
|
|
75
|
-
'.zsh': 'Shell',
|
|
76
|
-
'.yaml': 'YAML',
|
|
77
|
-
'.yml': 'YAML',
|
|
78
|
-
'.toml': 'TOML',
|
|
79
|
-
'.xml': 'XML',
|
|
80
|
-
'.svg': 'SVG',
|
|
81
|
-
'.vue': 'Vue',
|
|
82
|
-
'.svelte': 'Svelte',
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
// Caps. Wall-clock is enforced by an AbortController-style deadline; the
|
|
86
|
-
// per-file line cap stops giant generated files (e.g. 5 MB package-lock that
|
|
87
|
-
// somehow escapes the ignore list) from blocking the event loop.
|
|
88
|
-
const MAX_DEPTH = 12;
|
|
89
|
-
const MAX_FILES_TO_ANALYZE = 5000;
|
|
90
|
-
const MAX_FILE_SIZE_FOR_LINE_COUNT = 100 * 1024; // 100 KiB
|
|
91
|
-
const DEADLINE_MS = 30_000;
|
|
92
|
-
const TOP_DIRS_LIMIT = 10;
|
|
93
|
-
|
|
94
|
-
/** Count newlines without loading entire file into memory beyond the cap. */
|
|
95
|
-
async function countLines(filePath, sizeHint) {
|
|
96
|
-
if (sizeHint > MAX_FILE_SIZE_FOR_LINE_COUNT) return 0;
|
|
97
|
-
try {
|
|
98
|
-
const buf = await fsp.readFile(filePath);
|
|
99
|
-
if (buf.length === 0) return 0;
|
|
100
|
-
let n = 1;
|
|
101
|
-
for (let i = 0; i < buf.length; i++) {
|
|
102
|
-
if (buf[i] === 0x0a) n++;
|
|
103
|
-
}
|
|
104
|
-
// Don't count a final empty trailing line.
|
|
105
|
-
if (buf[buf.length - 1] === 0x0a) n--;
|
|
106
|
-
return n;
|
|
107
|
-
} catch {
|
|
108
|
-
return 0;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function languageKey(ext) {
|
|
113
|
-
if (!ext) return 'other';
|
|
114
|
-
return LANG_LABELS[ext] ? ext.slice(1) : 'other';
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Recursive walk. Mutates `ctx` (single object per top-level call) so we
|
|
119
|
-
* don't allocate a fresh stats frame per directory — keeps the hot path
|
|
120
|
-
* contiguous (CLAUDE.md performance guidance). Stops early when deadline is
|
|
121
|
-
* past or the file ceiling is hit.
|
|
122
|
-
*
|
|
123
|
-
* Returns the per-immediate-subtree { fileCount, totalLines } so the caller
|
|
124
|
-
* (one level up) can attribute it to the top-level directory bucket.
|
|
125
|
-
*/
|
|
126
|
-
async function walk(absDir, relDir, depth, ctx) {
|
|
127
|
-
if (ctx.stopped || Date.now() > ctx.deadline) {
|
|
128
|
-
ctx.stopped = true;
|
|
129
|
-
return { fileCount: 0, totalLines: 0 };
|
|
130
|
-
}
|
|
131
|
-
if (depth > MAX_DEPTH) return { fileCount: 0, totalLines: 0 };
|
|
132
|
-
|
|
133
|
-
let entries;
|
|
134
|
-
try {
|
|
135
|
-
entries = await fsp.readdir(absDir, { withFileTypes: true });
|
|
136
|
-
} catch {
|
|
137
|
-
return { fileCount: 0, totalLines: 0 };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
let subFiles = 0;
|
|
141
|
-
let subLines = 0;
|
|
142
|
-
|
|
143
|
-
for (const entry of entries) {
|
|
144
|
-
if (ctx.stopped) break;
|
|
145
|
-
if (Date.now() > ctx.deadline) {
|
|
146
|
-
ctx.stopped = true;
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Never follow symlinks (security). withFileTypes gives us
|
|
151
|
-
// isSymbolicLink() directly — no extra stat needed.
|
|
152
|
-
if (entry.isSymbolicLink()) continue;
|
|
153
|
-
|
|
154
|
-
const name = entry.name;
|
|
155
|
-
|
|
156
|
-
if (entry.isDirectory()) {
|
|
157
|
-
if (IGNORE_DIRS.has(name)) continue;
|
|
158
|
-
const childAbs = path.join(absDir, name);
|
|
159
|
-
const childRel = relDir ? path.join(relDir, name) : name;
|
|
160
|
-
const result = await walk(childAbs, childRel, depth + 1, ctx);
|
|
161
|
-
subFiles += result.fileCount;
|
|
162
|
-
subLines += result.totalLines;
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (!entry.isFile()) continue;
|
|
167
|
-
if (IGNORE_FILES.has(name)) continue;
|
|
168
|
-
if (ctx.fileCount >= MAX_FILES_TO_ANALYZE) {
|
|
169
|
-
ctx.stopped = true;
|
|
170
|
-
break;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
ctx.fileCount++;
|
|
174
|
-
subFiles++;
|
|
175
|
-
ctx.totalFiles++;
|
|
176
|
-
|
|
177
|
-
const ext = path.extname(name).toLowerCase();
|
|
178
|
-
let size = 0;
|
|
179
|
-
let lines = 0;
|
|
180
|
-
try {
|
|
181
|
-
const st = await fsp.stat(path.join(absDir, name));
|
|
182
|
-
size = st.size;
|
|
183
|
-
} catch {
|
|
184
|
-
// unreadable — count the file but skip lines
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Only line-count "interesting" files. JSON / SVG / XML / YAML are noisy
|
|
188
|
-
// and don't really represent "code"; skipping them keeps the LOC number
|
|
189
|
-
// closer to what a developer expects.
|
|
190
|
-
if (LANG_LABELS[ext] && ext !== '.svg' && ext !== '.xml' && ext !== '.json') {
|
|
191
|
-
lines = await countLines(path.join(absDir, name), size);
|
|
192
|
-
}
|
|
193
|
-
subLines += lines;
|
|
194
|
-
ctx.totalLines += lines;
|
|
195
|
-
|
|
196
|
-
// Language breakdown bucket.
|
|
197
|
-
const key = languageKey(ext);
|
|
198
|
-
const langBucket = ctx.languages[key] ?? (ctx.languages[key] = { files: 0, lines: 0 });
|
|
199
|
-
langBucket.files++;
|
|
200
|
-
langBucket.lines += lines;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Top-dir attribution happens in analyze() based on the return value, not
|
|
204
|
-
// here — avoids double-pushing when walk recurses into nested dirs.
|
|
205
|
-
return { fileCount: subFiles, totalLines: subLines };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* repo:analyze entry — runs the walk, then asks git.cjs for status (cached,
|
|
210
|
-
* so this is cheap when called repeatedly). On any path-validation or fs
|
|
211
|
-
* failure returns a `{ ok:false, error }` shape so the renderer can decide
|
|
212
|
-
* whether to show an error state or just empty stats.
|
|
213
|
-
*/
|
|
214
|
-
async function analyze(cwd) {
|
|
215
|
-
let realCwd;
|
|
216
|
-
try {
|
|
217
|
-
realCwd = validatePath(cwd);
|
|
218
|
-
} catch (e) {
|
|
219
|
-
return { ok: false, error: (e && e.message) || 'invalid cwd' };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const startMs = Date.now();
|
|
223
|
-
const ctx = {
|
|
224
|
-
deadline: startMs + DEADLINE_MS,
|
|
225
|
-
stopped: false,
|
|
226
|
-
fileCount: 0,
|
|
227
|
-
totalFiles: 0,
|
|
228
|
-
totalLines: 0,
|
|
229
|
-
languages: Object.create(null),
|
|
230
|
-
topDirs: [],
|
|
231
|
-
};
|
|
232
|
-
|
|
233
|
-
// Walk the top-level dir manually (we want depth-1 directories listed in
|
|
234
|
-
// topDirs but not "." itself).
|
|
235
|
-
let entries;
|
|
236
|
-
try {
|
|
237
|
-
entries = await fsp.readdir(realCwd, { withFileTypes: true });
|
|
238
|
-
} catch (e) {
|
|
239
|
-
return { ok: false, error: (e && e.message) || 'readdir failed' };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// First pass: count files directly in the root, group root-level loose
|
|
243
|
-
// files under a synthetic "(root)" bucket so they're visible in the treemap.
|
|
244
|
-
let rootLooseFiles = 0;
|
|
245
|
-
let rootLooseLines = 0;
|
|
246
|
-
|
|
247
|
-
// Process directories at depth 1, files at depth 0 (root). We follow the
|
|
248
|
-
// pattern: lstat-equivalent (entry.isSymbolicLink()) → no follow.
|
|
249
|
-
for (const entry of entries) {
|
|
250
|
-
if (ctx.stopped) break;
|
|
251
|
-
if (Date.now() > ctx.deadline) { ctx.stopped = true; break; }
|
|
252
|
-
if (entry.isSymbolicLink()) continue;
|
|
253
|
-
const name = entry.name;
|
|
254
|
-
|
|
255
|
-
if (entry.isDirectory()) {
|
|
256
|
-
if (IGNORE_DIRS.has(name)) continue;
|
|
257
|
-
const result = await walk(path.join(realCwd, name), name, 1, ctx);
|
|
258
|
-
ctx.topDirs.push({ path: name, fileCount: result.fileCount, totalLines: result.totalLines });
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (!entry.isFile()) continue;
|
|
263
|
-
if (IGNORE_FILES.has(name)) continue;
|
|
264
|
-
if (ctx.fileCount >= MAX_FILES_TO_ANALYZE) { ctx.stopped = true; break; }
|
|
265
|
-
|
|
266
|
-
ctx.fileCount++;
|
|
267
|
-
ctx.totalFiles++;
|
|
268
|
-
rootLooseFiles++;
|
|
269
|
-
|
|
270
|
-
const ext = path.extname(name).toLowerCase();
|
|
271
|
-
let size = 0;
|
|
272
|
-
let lines = 0;
|
|
273
|
-
try {
|
|
274
|
-
const st = await fsp.stat(path.join(realCwd, name));
|
|
275
|
-
size = st.size;
|
|
276
|
-
} catch { /* */ }
|
|
277
|
-
if (LANG_LABELS[ext] && ext !== '.svg' && ext !== '.xml' && ext !== '.json') {
|
|
278
|
-
lines = await countLines(path.join(realCwd, name), size);
|
|
279
|
-
}
|
|
280
|
-
rootLooseLines += lines;
|
|
281
|
-
ctx.totalLines += lines;
|
|
282
|
-
const key = languageKey(ext);
|
|
283
|
-
const langBucket = ctx.languages[key] ?? (ctx.languages[key] = { files: 0, lines: 0 });
|
|
284
|
-
langBucket.files++;
|
|
285
|
-
langBucket.lines += lines;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (rootLooseFiles > 0) {
|
|
289
|
-
ctx.topDirs.push({ path: '(root)', fileCount: rootLooseFiles, totalLines: rootLooseLines });
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Sort top dirs by file count desc, then trim. Tie-break by name for stability.
|
|
293
|
-
ctx.topDirs.sort((a, b) => {
|
|
294
|
-
if (b.fileCount !== a.fileCount) return b.fileCount - a.fileCount;
|
|
295
|
-
return a.path.localeCompare(b.path);
|
|
296
|
-
});
|
|
297
|
-
const topDirectories = ctx.topDirs.slice(0, TOP_DIRS_LIMIT);
|
|
298
|
-
|
|
299
|
-
// Materialize the languages map sorted by file count desc.
|
|
300
|
-
const languageBreakdown = Object.create(null);
|
|
301
|
-
const langEntries = Object.entries(ctx.languages).sort((a, b) => b[1].files - a[1].files);
|
|
302
|
-
for (const [k, v] of langEntries) languageBreakdown[k] = v;
|
|
303
|
-
|
|
304
|
-
// Git status — best-effort. Failure (not a git repo / git missing / timeout)
|
|
305
|
-
// returns null which we surface as { uncommitted: 0, branch: null }.
|
|
306
|
-
let gitStatus = { uncommitted: 0, branch: null };
|
|
307
|
-
try {
|
|
308
|
-
const s = await gitMod.getStatus(realCwd);
|
|
309
|
-
if (s) {
|
|
310
|
-
gitStatus = {
|
|
311
|
-
uncommitted: s.uncommittedCount || 0,
|
|
312
|
-
branch: s.branch || null,
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
} catch { /* gitMod swallows errors but be defensive */ }
|
|
316
|
-
|
|
317
|
-
return {
|
|
318
|
-
ok: true,
|
|
319
|
-
cwd: realCwd,
|
|
320
|
-
totalFiles: ctx.totalFiles,
|
|
321
|
-
totalLines: ctx.totalLines,
|
|
322
|
-
languageBreakdown,
|
|
323
|
-
topDirectories,
|
|
324
|
-
gitStatus,
|
|
325
|
-
truncated: ctx.stopped,
|
|
326
|
-
durationMs: Date.now() - startMs,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function register(ipcMain) {
|
|
331
|
-
ipcMain.handle('repo:analyze', async (_e, payload) => {
|
|
332
|
-
const parsed = schemas.repoAnalyze.safeParse(payload);
|
|
333
|
-
if (!parsed.success) {
|
|
334
|
-
return { ok: false, error: 'invalid payload' };
|
|
335
|
-
}
|
|
336
|
-
return analyze(parsed.data.cwd);
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
module.exports = {
|
|
341
|
-
register,
|
|
342
|
-
analyze,
|
|
343
|
-
// Exported for tests.
|
|
344
|
-
LANG_LABELS,
|
|
345
|
-
IGNORE_DIRS,
|
|
346
|
-
};
|
package/src/main/search.cjs
DELETED
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Search IPC — fuzzy file path search + content grep.
|
|
3
|
-
*
|
|
4
|
-
* - Prefers ripgrep (`rg`) when present on PATH for both fast file enumeration
|
|
5
|
-
* (`rg --files`) and content search (`rg --json`). Falls back to a Node
|
|
6
|
-
* fs-based recursive walk capped at depth 10 when rg is missing.
|
|
7
|
-
* - All cwds are routed through `config.cjs`'s `validatePath` so the renderer
|
|
8
|
-
* can never search outside the home directory.
|
|
9
|
-
* - 10s wall-clock ceiling per call — search runs that overflow are killed
|
|
10
|
-
* and return partial results.
|
|
11
|
-
* - Ignored dirs (both modes): .git / node_modules / dist / build / .next /
|
|
12
|
-
* .cache / coverage / .turbo.
|
|
13
|
-
*
|
|
14
|
-
* Designed to back two renderer modals: QuickOpen (Cmd+P) and GlobalSearch
|
|
15
|
-
* (Cmd+Shift+F).
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
const { ipcMain } = require('electron');
|
|
19
|
-
const { spawn } = require('node:child_process');
|
|
20
|
-
const fsp = require('node:fs/promises');
|
|
21
|
-
const path = require('node:path');
|
|
22
|
-
const os = require('node:os');
|
|
23
|
-
const { z } = require('zod');
|
|
24
|
-
|
|
25
|
-
const { validatePath } = require('./config.cjs');
|
|
26
|
-
|
|
27
|
-
const TIMEOUT_MS = 10_000;
|
|
28
|
-
const MAX_FILES = 5000;
|
|
29
|
-
const MAX_TEXT_MATCHES = 500;
|
|
30
|
-
const MAX_WALK_DEPTH = 10;
|
|
31
|
-
const IGNORED_DIRS = new Set([
|
|
32
|
-
'.git', 'node_modules', 'dist', 'build', '.next',
|
|
33
|
-
'.cache', 'coverage', '.turbo', '.parcel-cache', '.pnpm-store',
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
// ── ripgrep detection ─────────────────────────────────────────────────────
|
|
37
|
-
let _rgAvailable = null; // null = unknown, true/false = cached
|
|
38
|
-
function probeRipgrep() {
|
|
39
|
-
if (_rgAvailable !== null) return _rgAvailable;
|
|
40
|
-
return new Promise((resolve) => {
|
|
41
|
-
let resolved = false;
|
|
42
|
-
const done = (val) => { if (!resolved) { resolved = true; _rgAvailable = val; resolve(val); } };
|
|
43
|
-
try {
|
|
44
|
-
const proc = spawn('rg', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
45
|
-
proc.on('error', () => done(false));
|
|
46
|
-
proc.on('exit', (code) => done(code === 0));
|
|
47
|
-
// Hard timeout: probe shouldn't take more than 1s.
|
|
48
|
-
setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } done(false); }, 1000);
|
|
49
|
-
} catch {
|
|
50
|
-
done(false);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// ── helpers ───────────────────────────────────────────────────────────────
|
|
56
|
-
const { expandHome } = require('./lib/expandHome.cjs');
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Spawn rg with argv (no shell), capture stdout/stderr, enforce TIMEOUT_MS.
|
|
60
|
-
* Resolves to { ok, stdout, stderr, exitCode, timedOut }.
|
|
61
|
-
*/
|
|
62
|
-
function runRg(args, cwd) {
|
|
63
|
-
return new Promise((resolve) => {
|
|
64
|
-
let stdout = '';
|
|
65
|
-
let stderr = '';
|
|
66
|
-
let timedOut = false;
|
|
67
|
-
let proc;
|
|
68
|
-
try {
|
|
69
|
-
proc = spawn('rg', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
70
|
-
} catch (e) {
|
|
71
|
-
resolve({ ok: false, stdout: '', stderr: e.message, exitCode: -1, timedOut: false });
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const timer = setTimeout(() => {
|
|
75
|
-
timedOut = true;
|
|
76
|
-
try { proc.kill('SIGTERM'); } catch { /* noop */ }
|
|
77
|
-
setTimeout(() => { try { proc.kill('SIGKILL'); } catch { /* noop */ } }, 500);
|
|
78
|
-
}, TIMEOUT_MS);
|
|
79
|
-
|
|
80
|
-
proc.stdout.on('data', (chunk) => {
|
|
81
|
-
stdout += chunk.toString('utf8');
|
|
82
|
-
// Soft cap on buffer growth — if rg yields more than 10 MB of stdout
|
|
83
|
-
// something is wrong with the query; just kill it.
|
|
84
|
-
if (stdout.length > 10 * 1024 * 1024) {
|
|
85
|
-
try { proc.kill('SIGTERM'); } catch { /* noop */ }
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
proc.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
89
|
-
proc.on('error', (e) => {
|
|
90
|
-
clearTimeout(timer);
|
|
91
|
-
resolve({ ok: false, stdout, stderr: e.message, exitCode: -1, timedOut });
|
|
92
|
-
});
|
|
93
|
-
proc.on('exit', (code) => {
|
|
94
|
-
clearTimeout(timer);
|
|
95
|
-
// rg exits 1 when zero matches found — that's still "ok" for us.
|
|
96
|
-
resolve({ ok: code === 0 || code === 1, stdout, stderr, exitCode: code ?? -1, timedOut });
|
|
97
|
-
});
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Recursive fs walk capped at MAX_WALK_DEPTH and ignoring IGNORED_DIRS.
|
|
103
|
-
* Returns absolute paths. Bails out early at MAX_FILES.
|
|
104
|
-
*/
|
|
105
|
-
async function fsWalk(rootAbs) {
|
|
106
|
-
const out = [];
|
|
107
|
-
const startedAt = Date.now();
|
|
108
|
-
async function walk(dir, depth) {
|
|
109
|
-
if (depth > MAX_WALK_DEPTH) return;
|
|
110
|
-
if (out.length >= MAX_FILES) return;
|
|
111
|
-
if (Date.now() - startedAt > TIMEOUT_MS) return;
|
|
112
|
-
let entries;
|
|
113
|
-
try {
|
|
114
|
-
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
115
|
-
} catch {
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
for (const entry of entries) {
|
|
119
|
-
if (out.length >= MAX_FILES) return;
|
|
120
|
-
if (entry.name.startsWith('.') && entry.name !== '.env') {
|
|
121
|
-
// Skip dotfiles by default — matches rg's defaults closely enough
|
|
122
|
-
// for our use case. .env is a common exception users may want.
|
|
123
|
-
if (entry.isDirectory()) continue;
|
|
124
|
-
}
|
|
125
|
-
if (entry.isDirectory()) {
|
|
126
|
-
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
127
|
-
await walk(path.join(dir, entry.name), depth + 1);
|
|
128
|
-
} else if (entry.isFile()) {
|
|
129
|
-
out.push(path.join(dir, entry.name));
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
await walk(rootAbs, 0);
|
|
134
|
-
return out;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Naive content grep for the rg-fallback path. Reads each file up to 1 MB,
|
|
139
|
-
* splits on newlines, returns the first MAX_TEXT_MATCHES occurrences. Skips
|
|
140
|
-
* binary files heuristically (NUL byte in first 4 KiB).
|
|
141
|
-
*/
|
|
142
|
-
async function fsGrep(rootAbs, needle, opts) {
|
|
143
|
-
const caseSensitive = !!opts.caseSensitive;
|
|
144
|
-
const limit = Math.max(1, Math.min(MAX_TEXT_MATCHES, opts.limit ?? MAX_TEXT_MATCHES));
|
|
145
|
-
const haystack = caseSensitive ? needle : needle.toLowerCase();
|
|
146
|
-
const startedAt = Date.now();
|
|
147
|
-
const files = await fsWalk(rootAbs);
|
|
148
|
-
const matches = [];
|
|
149
|
-
for (const file of files) {
|
|
150
|
-
if (matches.length >= limit) break;
|
|
151
|
-
if (Date.now() - startedAt > TIMEOUT_MS) break;
|
|
152
|
-
let buf;
|
|
153
|
-
try {
|
|
154
|
-
const st = await fsp.stat(file);
|
|
155
|
-
if (st.size > 1024 * 1024) continue;
|
|
156
|
-
buf = await fsp.readFile(file);
|
|
157
|
-
} catch { continue; }
|
|
158
|
-
// Binary sniff.
|
|
159
|
-
const sniff = buf.slice(0, 4096);
|
|
160
|
-
if (sniff.includes(0)) continue;
|
|
161
|
-
const text = buf.toString('utf8');
|
|
162
|
-
const lines = text.split('\n');
|
|
163
|
-
for (let i = 0; i < lines.length; i++) {
|
|
164
|
-
if (matches.length >= limit) break;
|
|
165
|
-
const line = lines[i];
|
|
166
|
-
const target = caseSensitive ? line : line.toLowerCase();
|
|
167
|
-
const idx = target.indexOf(haystack);
|
|
168
|
-
if (idx !== -1) {
|
|
169
|
-
matches.push({
|
|
170
|
-
path: file,
|
|
171
|
-
line: i + 1,
|
|
172
|
-
column: idx + 1,
|
|
173
|
-
text: line.length > 400 ? line.slice(0, 400) + '…' : line,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return matches;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ── public API: search:files ──────────────────────────────────────────────
|
|
182
|
-
async function searchFiles({ cwd, query, opts }) {
|
|
183
|
-
const limit = Math.max(1, Math.min(MAX_FILES, opts?.limit ?? MAX_FILES));
|
|
184
|
-
let resolved;
|
|
185
|
-
try { resolved = validatePath(expandHome(cwd)); }
|
|
186
|
-
catch (e) { return { ok: false, files: [], error: e.message, usedRipgrep: false }; }
|
|
187
|
-
|
|
188
|
-
const usedRipgrep = await probeRipgrep();
|
|
189
|
-
let absPaths = [];
|
|
190
|
-
if (usedRipgrep) {
|
|
191
|
-
// `rg --files` enumerates the tree honoring .gitignore. We add explicit
|
|
192
|
-
// --glob '!dir' exclusions on top in case the repo isn't a git repo.
|
|
193
|
-
const args = [
|
|
194
|
-
'--files', '--hidden', '--follow',
|
|
195
|
-
'--max-filesize', '5M',
|
|
196
|
-
];
|
|
197
|
-
for (const dir of IGNORED_DIRS) {
|
|
198
|
-
args.push('--glob', `!**/${dir}/**`);
|
|
199
|
-
}
|
|
200
|
-
const result = await runRg(args, resolved);
|
|
201
|
-
if (result.timedOut) {
|
|
202
|
-
return { ok: false, files: [], error: 'search timeout (10s)', usedRipgrep: true };
|
|
203
|
-
}
|
|
204
|
-
if (!result.ok) {
|
|
205
|
-
return { ok: false, files: [], error: result.stderr || `rg exit ${result.exitCode}`, usedRipgrep: true };
|
|
206
|
-
}
|
|
207
|
-
absPaths = result.stdout.split('\n')
|
|
208
|
-
.filter((l) => l.length > 0)
|
|
209
|
-
.map((rel) => path.join(resolved, rel));
|
|
210
|
-
} else {
|
|
211
|
-
absPaths = await fsWalk(resolved);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Fuzzy filter / sort happens in renderer. Main returns the candidate set.
|
|
215
|
-
// When query is provided we do a coarse case-insensitive substring filter
|
|
216
|
-
// to cut payload size — renderer's fuzzy ranker handles the precise scoring.
|
|
217
|
-
if (query && query.length > 0) {
|
|
218
|
-
const q = query.toLowerCase();
|
|
219
|
-
absPaths = absPaths.filter((p) => p.toLowerCase().includes(q));
|
|
220
|
-
}
|
|
221
|
-
absPaths = absPaths.slice(0, limit);
|
|
222
|
-
|
|
223
|
-
// Shape each entry like files:list so QuickOpen can reuse FileNode types.
|
|
224
|
-
const files = absPaths.map((abs) => ({
|
|
225
|
-
name: path.basename(abs),
|
|
226
|
-
path: abs,
|
|
227
|
-
isDirectory: false,
|
|
228
|
-
isFile: true,
|
|
229
|
-
}));
|
|
230
|
-
return { ok: true, files, error: null, usedRipgrep };
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// ── public API: search:text ───────────────────────────────────────────────
|
|
234
|
-
async function searchText({ cwd, query, opts }) {
|
|
235
|
-
const limit = Math.max(1, Math.min(MAX_TEXT_MATCHES, opts?.limit ?? MAX_TEXT_MATCHES));
|
|
236
|
-
const caseSensitive = !!opts?.caseSensitive;
|
|
237
|
-
if (!query || query.length === 0) {
|
|
238
|
-
return { ok: true, matches: [], error: null, usedRipgrep: false };
|
|
239
|
-
}
|
|
240
|
-
let resolved;
|
|
241
|
-
try { resolved = validatePath(expandHome(cwd)); }
|
|
242
|
-
catch (e) { return { ok: false, matches: [], error: e.message, usedRipgrep: false }; }
|
|
243
|
-
|
|
244
|
-
const usedRipgrep = await probeRipgrep();
|
|
245
|
-
if (!usedRipgrep) {
|
|
246
|
-
const matches = await fsGrep(resolved, query, { caseSensitive, limit });
|
|
247
|
-
return { ok: true, matches, error: null, usedRipgrep: false };
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// rg --json gives us { type, data: { ... } } lines. We use --fixed-strings
|
|
251
|
-
// so users don't accidentally trigger regex on `.` or `(`. Renderer can
|
|
252
|
-
// toggle regex later if we ever surface that option.
|
|
253
|
-
const args = [
|
|
254
|
-
'--json', '--fixed-strings', '--hidden', '--follow',
|
|
255
|
-
'--max-filesize', '5M',
|
|
256
|
-
'--max-count', String(limit),
|
|
257
|
-
];
|
|
258
|
-
if (!caseSensitive) args.push('--ignore-case');
|
|
259
|
-
for (const dir of IGNORED_DIRS) {
|
|
260
|
-
args.push('--glob', `!**/${dir}/**`);
|
|
261
|
-
}
|
|
262
|
-
args.push('--', query);
|
|
263
|
-
|
|
264
|
-
const result = await runRg(args, resolved);
|
|
265
|
-
if (result.timedOut) {
|
|
266
|
-
return { ok: false, matches: [], error: 'search timeout (10s)', usedRipgrep: true };
|
|
267
|
-
}
|
|
268
|
-
if (!result.ok) {
|
|
269
|
-
return { ok: false, matches: [], error: result.stderr || `rg exit ${result.exitCode}`, usedRipgrep: true };
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const matches = [];
|
|
273
|
-
const lines = result.stdout.split('\n');
|
|
274
|
-
for (const raw of lines) {
|
|
275
|
-
if (matches.length >= limit) break;
|
|
276
|
-
if (!raw) continue;
|
|
277
|
-
let evt;
|
|
278
|
-
try { evt = JSON.parse(raw); } catch { continue; }
|
|
279
|
-
if (evt.type !== 'match') continue;
|
|
280
|
-
const data = evt.data;
|
|
281
|
-
if (!data) continue;
|
|
282
|
-
const rel = data.path?.text;
|
|
283
|
-
if (!rel) continue;
|
|
284
|
-
const abs = path.isAbsolute(rel) ? rel : path.join(resolved, rel);
|
|
285
|
-
const lineNum = data.line_number ?? 0;
|
|
286
|
-
let text = data.lines?.text ?? '';
|
|
287
|
-
if (text.endsWith('\n')) text = text.slice(0, -1);
|
|
288
|
-
if (text.length > 400) text = text.slice(0, 400) + '…';
|
|
289
|
-
const column = (data.submatches && data.submatches[0]?.start != null)
|
|
290
|
-
? data.submatches[0].start + 1
|
|
291
|
-
: 1;
|
|
292
|
-
matches.push({ path: abs, line: lineNum, column, text });
|
|
293
|
-
}
|
|
294
|
-
return { ok: true, matches, error: null, usedRipgrep: true };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// ── IPC registration ──────────────────────────────────────────────────────
|
|
298
|
-
const searchFilesSchema = z.object({
|
|
299
|
-
cwd: z.string().min(1).max(4096),
|
|
300
|
-
query: z.string().max(1024).optional().default(''),
|
|
301
|
-
opts: z.object({
|
|
302
|
-
limit: z.number().int().min(1).max(MAX_FILES).optional(),
|
|
303
|
-
}).optional(),
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
const searchTextSchema = z.object({
|
|
307
|
-
cwd: z.string().min(1).max(4096),
|
|
308
|
-
query: z.string().min(1).max(1024),
|
|
309
|
-
opts: z.object({
|
|
310
|
-
limit: z.number().int().min(1).max(MAX_TEXT_MATCHES).optional(),
|
|
311
|
-
caseSensitive: z.boolean().optional(),
|
|
312
|
-
}).optional(),
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
function registerSearchHandlers() {
|
|
316
|
-
ipcMain.handle('search:files', (_e, payload) => {
|
|
317
|
-
const parsed = searchFilesSchema.parse(payload);
|
|
318
|
-
return searchFiles(parsed);
|
|
319
|
-
});
|
|
320
|
-
ipcMain.handle('search:text', (_e, payload) => {
|
|
321
|
-
const parsed = searchTextSchema.parse(payload);
|
|
322
|
-
return searchText(parsed);
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
module.exports = {
|
|
327
|
-
registerSearchHandlers,
|
|
328
|
-
// exported for tests
|
|
329
|
-
searchFiles,
|
|
330
|
-
searchText,
|
|
331
|
-
probeRipgrep,
|
|
332
|
-
};
|