mstro-app 0.2.0 → 0.3.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/PRIVACY.md +126 -0
- package/README.md +24 -23
- package/bin/commands/login.js +79 -49
- package/bin/mstro.js +240 -37
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker.js +133 -27
- package/dist/server/cli/headless/claude-invoker.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +23 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +3 -1
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +20 -1
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +4 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +30 -24
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +19 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +28 -1
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +221 -29
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/index.js +0 -3
- package/dist/server/index.js.map +1 -1
- package/dist/server/services/analytics.d.ts.map +1 -1
- package/dist/server/services/analytics.js +13 -1
- package/dist/server/services/analytics.js.map +1 -1
- package/dist/server/services/platform.d.ts.map +1 -1
- package/dist/server/services/platform.js +13 -1
- package/dist/server/services/platform.js.map +1 -1
- package/dist/server/services/terminal/pty-manager.d.ts +2 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -1
- package/dist/server/services/terminal/pty-manager.js +50 -3
- package/dist/server/services/terminal/pty-manager.js.map +1 -1
- package/dist/server/services/websocket/file-explorer-handlers.d.ts +5 -0
- package/dist/server/services/websocket/file-explorer-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +518 -0
- package/dist/server/services/websocket/file-explorer-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-handlers.d.ts +36 -0
- package/dist/server/services/websocket/git-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-handlers.js +797 -0
- package/dist/server/services/websocket/git-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-pr-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-pr-handlers.js +299 -0
- package/dist/server/services/websocket/git-pr-handlers.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts +4 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.js +353 -0
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -0
- package/dist/server/services/websocket/handler-context.d.ts +32 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -0
- package/dist/server/services/websocket/handler-context.js +4 -0
- package/dist/server/services/websocket/handler-context.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +27 -359
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +67 -2328
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/index.d.ts +1 -1
- package/dist/server/services/websocket/index.d.ts.map +1 -1
- package/dist/server/services/websocket/index.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +10 -0
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/session-handlers.js +507 -0
- package/dist/server/services/websocket/session-handlers.js.map +1 -0
- package/dist/server/services/websocket/settings-handlers.d.ts +6 -0
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/settings-handlers.js +125 -0
- package/dist/server/services/websocket/settings-handlers.js.map +1 -0
- package/dist/server/services/websocket/tab-handlers.d.ts +10 -0
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/tab-handlers.js +131 -0
- package/dist/server/services/websocket/tab-handlers.js.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts +9 -0
- package/dist/server/services/websocket/terminal-handlers.d.ts.map +1 -0
- package/dist/server/services/websocket/terminal-handlers.js +220 -0
- package/dist/server/services/websocket/terminal-handlers.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +63 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +4 -2
- package/server/README.md +176 -159
- package/server/cli/headless/claude-invoker.ts +155 -31
- package/server/cli/headless/output-utils.test.ts +225 -0
- package/server/cli/headless/runner.ts +25 -0
- package/server/cli/headless/stall-assessor.test.ts +165 -0
- package/server/cli/headless/stall-assessor.ts +25 -0
- package/server/cli/headless/tool-watchdog.test.ts +429 -0
- package/server/cli/headless/tool-watchdog.ts +33 -25
- package/server/cli/headless/types.ts +10 -1
- package/server/cli/improvisation-session-manager.ts +277 -30
- package/server/index.ts +0 -4
- package/server/mcp/README.md +59 -67
- package/server/mcp/bouncer-integration.test.ts +161 -0
- package/server/mcp/security-patterns.test.ts +258 -0
- package/server/services/analytics.ts +13 -1
- package/server/services/platform.ts +12 -1
- package/server/services/terminal/pty-manager.ts +53 -3
- package/server/services/websocket/autocomplete.test.ts +194 -0
- package/server/services/websocket/file-explorer-handlers.ts +587 -0
- package/server/services/websocket/git-handlers.ts +924 -0
- package/server/services/websocket/git-pr-handlers.ts +363 -0
- package/server/services/websocket/git-worktree-handlers.ts +403 -0
- package/server/services/websocket/handler-context.ts +44 -0
- package/server/services/websocket/handler.test.ts +1 -1
- package/server/services/websocket/handler.ts +83 -2678
- package/server/services/websocket/index.ts +1 -1
- package/server/services/websocket/session-handlers.ts +574 -0
- package/server/services/websocket/settings-handlers.ts +150 -0
- package/server/services/websocket/tab-handlers.ts +150 -0
- package/server/services/websocket/terminal-handlers.ts +277 -0
- package/server/services/websocket/types.ts +135 -0
- package/bin/release.sh +0 -110
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { join, relative } from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
createDirectory,
|
|
8
|
+
createFile,
|
|
9
|
+
deleteFile,
|
|
10
|
+
listDirectory,
|
|
11
|
+
renameFile,
|
|
12
|
+
writeFile
|
|
13
|
+
} from '../files.js';
|
|
14
|
+
import { validatePathWithinWorkingDir } from '../pathUtils.js';
|
|
15
|
+
import { readFileContent } from './file-utils.js';
|
|
16
|
+
import type { HandlerContext } from './handler-context.js';
|
|
17
|
+
import type { WebSocketMessage, WebSocketResponse, WSContext } from './types.js';
|
|
18
|
+
|
|
19
|
+
export function handleFileMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
|
|
20
|
+
switch (msg.type) {
|
|
21
|
+
case 'autocomplete':
|
|
22
|
+
if (!msg.data?.partialPath) throw new Error('Partial path is required');
|
|
23
|
+
ctx.send(ws, { type: 'autocomplete', tabId, data: { completions: ctx.autocompleteService.getFileCompletions(msg.data.partialPath, workingDir) } });
|
|
24
|
+
break;
|
|
25
|
+
case 'readFile':
|
|
26
|
+
handleReadFile(ctx, ws, msg, tabId, workingDir, permission);
|
|
27
|
+
break;
|
|
28
|
+
case 'recordSelection':
|
|
29
|
+
if (msg.data?.filePath) ctx.recordFileSelection(msg.data.filePath);
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function handleReadFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
|
|
35
|
+
if (!msg.data?.filePath) throw new Error('File path is required');
|
|
36
|
+
const isSandboxed = permission === 'control' || permission === 'view';
|
|
37
|
+
if (isSandboxed) {
|
|
38
|
+
const validation = validatePathWithinWorkingDir(msg.data.filePath, workingDir);
|
|
39
|
+
if (!validation.valid) {
|
|
40
|
+
ctx.send(ws, { type: 'fileContent', tabId, data: { path: msg.data.filePath, fileName: msg.data.filePath.split('/').pop() || '', content: '', error: 'Sandboxed: path outside project directory' } });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
ctx.send(ws, { type: 'fileContent', tabId, data: readFileContent(msg.data.filePath, workingDir) });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendFileResult(ctx: HandlerContext, ws: WSContext, type: WebSocketResponse['type'], tabId: string, result: any, successData?: Record<string, any>): void {
|
|
48
|
+
const data = result.success
|
|
49
|
+
? { success: true, path: result.path, ...successData }
|
|
50
|
+
: { success: false, path: result.path, error: result.error };
|
|
51
|
+
ctx.send(ws, { type, tabId, data });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function handleFileExplorerMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string, permission?: 'control' | 'view'): void {
|
|
55
|
+
const isSandboxed = permission === 'control' || permission === 'view';
|
|
56
|
+
const handlers: Record<string, () => void> = {
|
|
57
|
+
listDirectory: () => {
|
|
58
|
+
if (isSandboxed && msg.data?.dirPath) {
|
|
59
|
+
const validation = validatePathWithinWorkingDir(msg.data.dirPath, workingDir);
|
|
60
|
+
if (!validation.valid) {
|
|
61
|
+
ctx.send(ws, { type: 'directoryListing', tabId, data: { success: false, path: msg.data.dirPath, error: 'Sandboxed: path outside project directory' } });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
handleListDirectory(ctx, ws, msg, tabId, workingDir);
|
|
66
|
+
},
|
|
67
|
+
writeFile: () => handleWriteFile(ctx, ws, msg, tabId, workingDir),
|
|
68
|
+
createFile: () => handleCreateFile(ctx, ws, msg, tabId, workingDir),
|
|
69
|
+
createDirectory: () => handleCreateDirectory(ctx, ws, msg, tabId, workingDir),
|
|
70
|
+
deleteFile: () => handleDeleteFile(ctx, ws, msg, tabId, workingDir),
|
|
71
|
+
renameFile: () => handleRenameFile(ctx, ws, msg, tabId, workingDir),
|
|
72
|
+
notifyFileOpened: () => handleNotifyFileOpened(ctx, ws, msg, workingDir),
|
|
73
|
+
searchFileContents: () => handleSearchFileContents(ctx, ws, msg, tabId, workingDir),
|
|
74
|
+
cancelSearch: () => handleCancelSearch(ctx, tabId),
|
|
75
|
+
findDefinition: () => handleFindDefinition(ctx, ws, msg, tabId, workingDir),
|
|
76
|
+
};
|
|
77
|
+
handlers[msg.type]?.();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function handleListDirectory(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
81
|
+
if (msg.data?.dirPath === undefined) throw new Error('Directory path is required');
|
|
82
|
+
const result = listDirectory(msg.data.dirPath, workingDir, msg.data.showHidden ?? false);
|
|
83
|
+
ctx.send(ws, { type: 'directoryListing', tabId, data: result.success ? { success: true, path: msg.data.dirPath, entries: result.entries } : { success: false, path: msg.data.dirPath, error: result.error } });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleWriteFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
87
|
+
if (!msg.data?.filePath) throw new Error('File path is required');
|
|
88
|
+
if (msg.data.content === undefined) throw new Error('Content is required');
|
|
89
|
+
const result = writeFile(msg.data.filePath, msg.data.content, workingDir);
|
|
90
|
+
sendFileResult(ctx, ws, 'fileWritten', tabId, result);
|
|
91
|
+
if (result.success) {
|
|
92
|
+
ctx.broadcastToOthers(ws, {
|
|
93
|
+
type: 'fileContentChanged',
|
|
94
|
+
data: { path: result.path, content: msg.data.content }
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleCreateFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
100
|
+
if (!msg.data?.filePath) throw new Error('File path is required');
|
|
101
|
+
const result = createFile(msg.data.filePath, workingDir);
|
|
102
|
+
sendFileResult(ctx, ws, 'fileCreated', tabId, result);
|
|
103
|
+
if (result.success && result.path) {
|
|
104
|
+
const name = result.path.split('/').pop() || 'unknown';
|
|
105
|
+
ctx.broadcastToOthers(ws, {
|
|
106
|
+
type: 'fileCreated',
|
|
107
|
+
data: { path: result.path, name, size: 0, modifiedAt: new Date().toISOString() }
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function handleCreateDirectory(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
113
|
+
if (!msg.data?.dirPath) throw new Error('Directory path is required');
|
|
114
|
+
const result = createDirectory(msg.data.dirPath, workingDir);
|
|
115
|
+
sendFileResult(ctx, ws, 'directoryCreated', tabId, result);
|
|
116
|
+
if (result.success && result.path) {
|
|
117
|
+
const name = result.path.split('/').pop() || 'unknown';
|
|
118
|
+
ctx.broadcastToOthers(ws, {
|
|
119
|
+
type: 'directoryCreated',
|
|
120
|
+
data: { path: result.path, name }
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleDeleteFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
126
|
+
if (!msg.data?.filePath) throw new Error('File path is required');
|
|
127
|
+
const result = deleteFile(msg.data.filePath, workingDir);
|
|
128
|
+
sendFileResult(ctx, ws, 'fileDeleted', tabId, result);
|
|
129
|
+
if (result.success && result.path) {
|
|
130
|
+
ctx.broadcastToOthers(ws, {
|
|
131
|
+
type: 'fileDeleted',
|
|
132
|
+
data: { path: result.path }
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function handleRenameFile(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
138
|
+
if (!msg.data?.oldPath) throw new Error('Old path is required');
|
|
139
|
+
if (!msg.data?.newPath) throw new Error('New path is required');
|
|
140
|
+
const result = renameFile(msg.data.oldPath, msg.data.newPath, workingDir);
|
|
141
|
+
const renamedName = result.path?.split('/').pop() || 'unknown';
|
|
142
|
+
sendFileResult(ctx, ws, 'fileRenamed', tabId, result, { oldPath: msg.data.oldPath, newPath: result.path, name: renamedName });
|
|
143
|
+
if (result.success && result.path) {
|
|
144
|
+
const name = result.path.split('/').pop() || 'unknown';
|
|
145
|
+
ctx.broadcastToOthers(ws, {
|
|
146
|
+
type: 'fileRenamed',
|
|
147
|
+
data: { oldPath: msg.data.oldPath, newPath: result.path, name }
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function handleNotifyFileOpened(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, workingDir: string): void {
|
|
153
|
+
if (!msg.data?.filePath) return;
|
|
154
|
+
const fileData = readFileContent(msg.data.filePath, workingDir);
|
|
155
|
+
if (!fileData.error) {
|
|
156
|
+
ctx.broadcastToOthers(ws, {
|
|
157
|
+
type: 'fileOpened',
|
|
158
|
+
data: {
|
|
159
|
+
path: msg.data.filePath,
|
|
160
|
+
fileName: fileData.fileName,
|
|
161
|
+
content: fileData.content
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function appendGlobArgs(args: string[], globStr: string, prefix: string): void {
|
|
168
|
+
for (const glob of globStr.split(',')) {
|
|
169
|
+
const trimmed = glob.trim();
|
|
170
|
+
if (trimmed) args.push('--glob', `${prefix}${trimmed}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildRgArgs(query: string, options: Record<string, unknown>): string[] {
|
|
175
|
+
const args: string[] = ['--json', '--no-heading'];
|
|
176
|
+
if (!options.caseSensitive) args.push('-i');
|
|
177
|
+
if (options.wholeWord) args.push('-w');
|
|
178
|
+
if (!options.regex) args.push('-F');
|
|
179
|
+
args.push('-C', options.contextLines !== undefined ? String(options.contextLines) : '1');
|
|
180
|
+
if (options.includeGlob) appendGlobArgs(args, options.includeGlob as string, '');
|
|
181
|
+
if (options.excludeGlob) appendGlobArgs(args, options.excludeGlob as string, '!');
|
|
182
|
+
args.push('--', query, '.');
|
|
183
|
+
return args;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type SearchMatch = { filePath: string; line: number; column: number; lineContent: string; contextBefore: string[]; contextAfter: string[] };
|
|
187
|
+
|
|
188
|
+
/** Process a single JSON line from rg output. Returns true if search should stop (maxResults reached). */
|
|
189
|
+
function processRgSearchLine(
|
|
190
|
+
line: string,
|
|
191
|
+
workingDir: string,
|
|
192
|
+
batch: SearchMatch[],
|
|
193
|
+
seenFiles: Set<string>,
|
|
194
|
+
contextMap: Map<string, { before: string[]; after: string[] }>,
|
|
195
|
+
counters: { totalMatches: number; fileCount: number },
|
|
196
|
+
maxResults: number,
|
|
197
|
+
flushBatch: () => void,
|
|
198
|
+
): boolean {
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(line);
|
|
201
|
+
if (parsed.type === 'match') {
|
|
202
|
+
return processRgMatch(parsed, workingDir, batch, seenFiles, contextMap, counters, maxResults, flushBatch);
|
|
203
|
+
}
|
|
204
|
+
if (parsed.type === 'context') {
|
|
205
|
+
appendRgContext(parsed, workingDir, batch);
|
|
206
|
+
}
|
|
207
|
+
} catch {
|
|
208
|
+
// Skip malformed JSON lines
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function processRgMatch(
|
|
214
|
+
parsed: { data: { path: { text: string }; line_number: number; lines: { text: string }; submatches?: Array<{ start: number }> } },
|
|
215
|
+
workingDir: string,
|
|
216
|
+
batch: SearchMatch[],
|
|
217
|
+
seenFiles: Set<string>,
|
|
218
|
+
contextMap: Map<string, { before: string[]; after: string[] }>,
|
|
219
|
+
counters: { totalMatches: number; fileCount: number },
|
|
220
|
+
maxResults: number,
|
|
221
|
+
flushBatch: () => void,
|
|
222
|
+
): boolean {
|
|
223
|
+
const filePath = relative(workingDir, parsed.data.path.text);
|
|
224
|
+
const lineNumber = parsed.data.line_number;
|
|
225
|
+
const lineContent = parsed.data.lines.text.replace(/\n$/, '');
|
|
226
|
+
const column = parsed.data.submatches?.[0]?.start ?? 0;
|
|
227
|
+
|
|
228
|
+
if (!seenFiles.has(filePath)) {
|
|
229
|
+
seenFiles.add(filePath);
|
|
230
|
+
counters.fileCount++;
|
|
231
|
+
}
|
|
232
|
+
counters.totalMatches++;
|
|
233
|
+
|
|
234
|
+
const key = `${filePath}:${lineNumber}`;
|
|
235
|
+
const ctxLines = contextMap.get(key) || { before: [], after: [] };
|
|
236
|
+
batch.push({ filePath, line: lineNumber, column: column + 1, lineContent, contextBefore: ctxLines.before, contextAfter: [] });
|
|
237
|
+
|
|
238
|
+
if (counters.totalMatches >= maxResults) {
|
|
239
|
+
flushBatch();
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (batch.length >= 50) flushBatch();
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function appendRgContext(
|
|
247
|
+
parsed: { data: { path: { text: string }; line_number: number; lines: { text: string } } },
|
|
248
|
+
workingDir: string,
|
|
249
|
+
batch: SearchMatch[],
|
|
250
|
+
): void {
|
|
251
|
+
const filePath = relative(workingDir, parsed.data.path.text);
|
|
252
|
+
const lineNumber = parsed.data.line_number;
|
|
253
|
+
const lineContent = parsed.data.lines.text.replace(/\n$/, '');
|
|
254
|
+
|
|
255
|
+
const lastMatch = batch[batch.length - 1];
|
|
256
|
+
if (!lastMatch || lastMatch.filePath !== filePath) return;
|
|
257
|
+
if (lineNumber < lastMatch.line) {
|
|
258
|
+
lastMatch.contextBefore.push(lineContent);
|
|
259
|
+
} else {
|
|
260
|
+
lastMatch.contextAfter.push(lineContent);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function handleSearchFileContents(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
265
|
+
const query = msg.data?.query;
|
|
266
|
+
if (!query) {
|
|
267
|
+
ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: 'Search query is required' } });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
handleCancelSearch(ctx, tabId);
|
|
272
|
+
|
|
273
|
+
const options = msg.data.options || {};
|
|
274
|
+
const startTime = Date.now();
|
|
275
|
+
let totalMatches = 0;
|
|
276
|
+
let fileCount = 0;
|
|
277
|
+
const seenFiles = new Set<string>();
|
|
278
|
+
const maxResults = options.maxResults || 5000;
|
|
279
|
+
let batch: SearchMatch[] = [];
|
|
280
|
+
|
|
281
|
+
const args = buildRgArgs(query, options);
|
|
282
|
+
|
|
283
|
+
const rgProcess = spawn('rg', args, { cwd: workingDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
284
|
+
ctx.activeSearches.set(tabId, rgProcess);
|
|
285
|
+
|
|
286
|
+
let buffer = '';
|
|
287
|
+
const contextMap = new Map<string, { before: string[]; after: string[] }>();
|
|
288
|
+
|
|
289
|
+
const flushBatch = () => {
|
|
290
|
+
if (batch.length > 0) {
|
|
291
|
+
ctx.send(ws, { type: 'contentSearchResults', tabId, data: { matches: batch, partial: true } });
|
|
292
|
+
batch = [];
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const searchState = { totalMatches, fileCount };
|
|
297
|
+
|
|
298
|
+
rgProcess.stdout?.on('data', (chunk: Buffer) => {
|
|
299
|
+
buffer += chunk.toString();
|
|
300
|
+
const lines = buffer.split('\n');
|
|
301
|
+
buffer = lines.pop() || '';
|
|
302
|
+
|
|
303
|
+
for (const line of lines) {
|
|
304
|
+
if (!line.trim()) continue;
|
|
305
|
+
if (processRgSearchLine(line, workingDir, batch, seenFiles, contextMap, searchState, maxResults, flushBatch)) {
|
|
306
|
+
rgProcess.kill();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
totalMatches = searchState.totalMatches;
|
|
311
|
+
fileCount = searchState.fileCount;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
rgProcess.stderr?.on('data', (chunk: Buffer) => {
|
|
315
|
+
const errText = chunk.toString().trim();
|
|
316
|
+
if (errText && !errText.includes('No files were searched')) {
|
|
317
|
+
console.error(`[Search] rg stderr: ${errText}`);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
rgProcess.on('close', (_code) => {
|
|
322
|
+
ctx.activeSearches.delete(tabId);
|
|
323
|
+
flushBatch();
|
|
324
|
+
|
|
325
|
+
ctx.send(ws, {
|
|
326
|
+
type: 'contentSearchComplete',
|
|
327
|
+
tabId,
|
|
328
|
+
data: {
|
|
329
|
+
totalMatches,
|
|
330
|
+
fileCount,
|
|
331
|
+
truncated: totalMatches >= maxResults,
|
|
332
|
+
durationMs: Date.now() - startTime,
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
rgProcess.on('error', (err) => {
|
|
338
|
+
ctx.activeSearches.delete(tabId);
|
|
339
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
340
|
+
handleSearchFallback(ctx, ws, query, options, tabId, workingDir);
|
|
341
|
+
} else {
|
|
342
|
+
ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: err.message } });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Process a single grep output line. Returns true if search should stop. */
|
|
348
|
+
function processGrepLine(
|
|
349
|
+
line: string,
|
|
350
|
+
batch: SearchMatch[],
|
|
351
|
+
seenFiles: Set<string>,
|
|
352
|
+
counters: { totalMatches: number; fileCount: number },
|
|
353
|
+
maxResults: number,
|
|
354
|
+
flushBatch: () => void,
|
|
355
|
+
): boolean {
|
|
356
|
+
const match = line.match(/^\.\/(.+?):(\d+):(.*)$/);
|
|
357
|
+
if (!match) return false;
|
|
358
|
+
|
|
359
|
+
const filePath = match[1];
|
|
360
|
+
const lineNumber = parseInt(match[2], 10);
|
|
361
|
+
const lineContent = match[3];
|
|
362
|
+
|
|
363
|
+
if (!seenFiles.has(filePath)) {
|
|
364
|
+
seenFiles.add(filePath);
|
|
365
|
+
counters.fileCount++;
|
|
366
|
+
}
|
|
367
|
+
counters.totalMatches++;
|
|
368
|
+
|
|
369
|
+
batch.push({ filePath, line: lineNumber, column: 1, lineContent, contextBefore: [], contextAfter: [] });
|
|
370
|
+
|
|
371
|
+
if (counters.totalMatches >= maxResults) {
|
|
372
|
+
flushBatch();
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
if (batch.length >= 50) flushBatch();
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function handleSearchFallback(ctx: HandlerContext, ws: WSContext, query: string, options: Record<string, unknown>, tabId: string, workingDir: string): void {
|
|
380
|
+
const startTime = Date.now();
|
|
381
|
+
const args: string[] = ['-rn'];
|
|
382
|
+
if (!options.caseSensitive) args.push('-i');
|
|
383
|
+
if (options.includeGlob) {
|
|
384
|
+
for (const glob of String(options.includeGlob).split(',')) {
|
|
385
|
+
const trimmed = glob.trim();
|
|
386
|
+
if (trimmed) args.push(`--include=${trimmed}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
args.push('--', query, '.');
|
|
390
|
+
|
|
391
|
+
const grepProcess = spawn('grep', args, { cwd: workingDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
392
|
+
ctx.activeSearches.set(tabId, grepProcess);
|
|
393
|
+
|
|
394
|
+
let buffer = '';
|
|
395
|
+
let totalMatches = 0;
|
|
396
|
+
let fileCount = 0;
|
|
397
|
+
const seenFiles = new Set<string>();
|
|
398
|
+
const maxResults = (options.maxResults as number) || 5000;
|
|
399
|
+
let batch: SearchMatch[] = [];
|
|
400
|
+
const grepState = { totalMatches, fileCount };
|
|
401
|
+
|
|
402
|
+
const flushGrepBatch = () => {
|
|
403
|
+
if (batch.length > 0) {
|
|
404
|
+
ctx.send(ws, { type: 'contentSearchResults', tabId, data: { matches: batch, partial: true } });
|
|
405
|
+
batch = [];
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
grepProcess.stdout?.on('data', (chunk: Buffer) => {
|
|
410
|
+
buffer += chunk.toString();
|
|
411
|
+
const lines = buffer.split('\n');
|
|
412
|
+
buffer = lines.pop() || '';
|
|
413
|
+
|
|
414
|
+
for (const line of lines) {
|
|
415
|
+
if (!line.trim()) continue;
|
|
416
|
+
if (processGrepLine(line, batch, seenFiles, grepState, maxResults, flushGrepBatch)) {
|
|
417
|
+
grepProcess.kill();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
totalMatches = grepState.totalMatches;
|
|
422
|
+
fileCount = grepState.fileCount;
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
grepProcess.on('close', () => {
|
|
426
|
+
ctx.activeSearches.delete(tabId);
|
|
427
|
+
if (batch.length > 0) {
|
|
428
|
+
ctx.send(ws, { type: 'contentSearchResults', tabId, data: { matches: batch, partial: true } });
|
|
429
|
+
}
|
|
430
|
+
ctx.send(ws, {
|
|
431
|
+
type: 'contentSearchComplete',
|
|
432
|
+
tabId,
|
|
433
|
+
data: { totalMatches, fileCount, truncated: totalMatches >= maxResults, durationMs: Date.now() - startTime },
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
grepProcess.on('error', (err) => {
|
|
438
|
+
ctx.activeSearches.delete(tabId);
|
|
439
|
+
ctx.send(ws, { type: 'contentSearchError', tabId, data: { error: `Search unavailable: ${err.message}` } });
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function handleCancelSearch(ctx: HandlerContext, tabId: string): void {
|
|
444
|
+
const process = ctx.activeSearches.get(tabId);
|
|
445
|
+
if (process) {
|
|
446
|
+
process.kill();
|
|
447
|
+
ctx.activeSearches.delete(tabId);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
type DefinitionEntry = { filePath: string; line: number; column: number; lineContent: string; kind: string };
|
|
452
|
+
|
|
453
|
+
function classifyDefinitionKind(lineContent: string): string {
|
|
454
|
+
if (/\b(function|def|func|fn)\b/.test(lineContent)) return 'function';
|
|
455
|
+
if (/\bclass\b/.test(lineContent)) return 'class';
|
|
456
|
+
if (/\binterface\b/.test(lineContent)) return 'interface';
|
|
457
|
+
if (/\btype\b/.test(lineContent)) return 'type';
|
|
458
|
+
if (/\b(enum|struct|trait)\b/.test(lineContent)) return 'enum';
|
|
459
|
+
return 'variable';
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Parse a single JSON line from rg definition search. Returns true if max definitions reached. */
|
|
463
|
+
function parseDefinitionLine(line: string, workingDir: string, definitions: DefinitionEntry[]): boolean {
|
|
464
|
+
try {
|
|
465
|
+
const parsed = JSON.parse(line);
|
|
466
|
+
if (parsed.type !== 'match') return false;
|
|
467
|
+
|
|
468
|
+
const filePath = relative(workingDir, join(workingDir, parsed.data.path.text));
|
|
469
|
+
const lineContent = parsed.data.lines.text.replace(/\n$/, '');
|
|
470
|
+
const column = parsed.data.submatches?.[0]?.start ?? 0;
|
|
471
|
+
|
|
472
|
+
definitions.push({
|
|
473
|
+
filePath,
|
|
474
|
+
line: parsed.data.line_number,
|
|
475
|
+
column: column + 1,
|
|
476
|
+
lineContent,
|
|
477
|
+
kind: classifyDefinitionKind(lineContent),
|
|
478
|
+
});
|
|
479
|
+
return definitions.length >= 20;
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function sortDefinitionsByProximity(definitions: DefinitionEntry[], currentFile: string): void {
|
|
486
|
+
const currentDir = currentFile ? currentFile.substring(0, currentFile.lastIndexOf('/')) : '';
|
|
487
|
+
definitions.sort((a, b) => {
|
|
488
|
+
const exactDiff = (a.filePath === currentFile ? 0 : 1) - (b.filePath === currentFile ? 0 : 1);
|
|
489
|
+
if (exactDiff !== 0) return exactDiff;
|
|
490
|
+
const dirDiff = (a.filePath.startsWith(`${currentDir}/`) ? 0 : 1) - (b.filePath.startsWith(`${currentDir}/`) ? 0 : 1);
|
|
491
|
+
if (dirDiff !== 0) return dirDiff;
|
|
492
|
+
return a.filePath.split('/').length - b.filePath.split('/').length;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function handleFindDefinition(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): void {
|
|
497
|
+
const symbol = msg.data?.symbol;
|
|
498
|
+
const language = msg.data?.language || 'typescript';
|
|
499
|
+
const currentFile = msg.data?.currentFile || '';
|
|
500
|
+
|
|
501
|
+
if (!symbol) {
|
|
502
|
+
ctx.send(ws, { type: 'definitionResult', tabId, data: { definitions: [], symbol: '' } });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const escapedSymbol = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
507
|
+
|
|
508
|
+
const DEFINITION_PATTERNS: Record<string, (s: string) => string[]> = {
|
|
509
|
+
typescript: (s) => [
|
|
510
|
+
`(function|const|let|var|class|interface|type|enum)\\s+${s}\\b`,
|
|
511
|
+
`export\\s+(default\\s+)?(function|const|let|var|class|interface|type|enum)\\s+${s}\\b`,
|
|
512
|
+
],
|
|
513
|
+
javascript: (s) => [
|
|
514
|
+
`(function|const|let|var|class)\\s+${s}\\b`,
|
|
515
|
+
`export\\s+(default\\s+)?(function|const|let|var|class)\\s+${s}\\b`,
|
|
516
|
+
],
|
|
517
|
+
python: (s) => [
|
|
518
|
+
`(def|class)\\s+${s}\\b`,
|
|
519
|
+
`${s}\\s*=`,
|
|
520
|
+
],
|
|
521
|
+
go: (s) => [
|
|
522
|
+
`func\\s+(\\(\\w+\\s+\\*?\\w+\\)\\s+)?${s}\\b`,
|
|
523
|
+
`type\\s+${s}\\b`,
|
|
524
|
+
`var\\s+${s}\\b`,
|
|
525
|
+
],
|
|
526
|
+
rust: (s) => [
|
|
527
|
+
`(fn|struct|enum|trait|type|const|static|mod)\\s+${s}\\b`,
|
|
528
|
+
`impl\\s+${s}\\b`,
|
|
529
|
+
],
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const LANGUAGE_GLOBS: Record<string, string> = {
|
|
533
|
+
typescript: '*.{ts,tsx}',
|
|
534
|
+
javascript: '*.{js,jsx,mjs,cjs}',
|
|
535
|
+
python: '*.py',
|
|
536
|
+
go: '*.go',
|
|
537
|
+
rust: '*.rs',
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const patterns = DEFINITION_PATTERNS[language] || DEFINITION_PATTERNS.typescript;
|
|
541
|
+
const combinedPattern = patterns(escapedSymbol).join('|');
|
|
542
|
+
const fileGlob = LANGUAGE_GLOBS[language] || LANGUAGE_GLOBS.typescript;
|
|
543
|
+
|
|
544
|
+
const args = [
|
|
545
|
+
'--json', '-n',
|
|
546
|
+
'--glob', fileGlob,
|
|
547
|
+
'--glob', '!node_modules/**',
|
|
548
|
+
'--glob', '!dist/**',
|
|
549
|
+
'--glob', '!build/**',
|
|
550
|
+
'--glob', '!.git/**',
|
|
551
|
+
'--glob', '!*.min.*',
|
|
552
|
+
'--glob', '!*.bundle.*',
|
|
553
|
+
'-e', combinedPattern, '.',
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
const rgProcess = spawn('rg', args, { cwd: workingDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
557
|
+
let rgBuffer = '';
|
|
558
|
+
const definitions: Array<{ filePath: string; line: number; column: number; lineContent: string; kind: string }> = [];
|
|
559
|
+
|
|
560
|
+
rgProcess.stdout?.on('data', (chunk: Buffer) => {
|
|
561
|
+
rgBuffer += chunk.toString();
|
|
562
|
+
const lines = rgBuffer.split('\n');
|
|
563
|
+
rgBuffer = lines.pop() || '';
|
|
564
|
+
|
|
565
|
+
for (const line of lines) {
|
|
566
|
+
if (!line.trim()) continue;
|
|
567
|
+
if (parseDefinitionLine(line, workingDir, definitions)) {
|
|
568
|
+
rgProcess.kill();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
rgProcess.on('close', () => {
|
|
575
|
+
sortDefinitionsByProximity(definitions, currentFile);
|
|
576
|
+
|
|
577
|
+
ctx.send(ws, {
|
|
578
|
+
type: 'definitionResult',
|
|
579
|
+
tabId,
|
|
580
|
+
data: { definitions: definitions.slice(0, 10), symbol },
|
|
581
|
+
});
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
rgProcess.on('error', (_err) => {
|
|
585
|
+
ctx.send(ws, { type: 'definitionResult', tabId, data: { definitions: [], symbol } });
|
|
586
|
+
});
|
|
587
|
+
}
|