vscode-pi-companion 0.0.10
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/.github/workflows/publish.yml +62 -0
- package/.github/workflows/release.yml +19 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/tasks.json +12 -0
- package/.vscodeignore +12 -0
- package/LICENSE +1 -0
- package/README.md +83 -0
- package/extensions/index.ts +370 -0
- package/extensions/package.json +9 -0
- package/package.json +88 -0
- package/screenshot.png +0 -0
- package/src/extension.ts +84 -0
- package/src/ide-server.ts +381 -0
- package/src/open-files-manager.ts +249 -0
- package/src/types.ts +66 -0
- package/tsconfig.json +16 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import { IDEServer } from './ide-server';
|
|
3
|
+
|
|
4
|
+
let ideServer: IDEServer | undefined;
|
|
5
|
+
let outputChannel: vscode.OutputChannel;
|
|
6
|
+
let statusBarItem: vscode.StatusBarItem;
|
|
7
|
+
|
|
8
|
+
export function activate(context: vscode.ExtensionContext) {
|
|
9
|
+
outputChannel = vscode.window.createOutputChannel('PI Companion');
|
|
10
|
+
log('Extension activated');
|
|
11
|
+
|
|
12
|
+
const server = new IDEServer(log, outputChannel);
|
|
13
|
+
ideServer = server;
|
|
14
|
+
|
|
15
|
+
// Start server immediately
|
|
16
|
+
server.start(context).then(() => {
|
|
17
|
+
log(`Server started on port ${server.getPort()}`);
|
|
18
|
+
}).catch((err) => {
|
|
19
|
+
log(`Failed to start server: ${err}`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Create status bar button
|
|
23
|
+
statusBarItem = vscode.window.createStatusBarItem(
|
|
24
|
+
vscode.StatusBarAlignment.Right,
|
|
25
|
+
100
|
|
26
|
+
);
|
|
27
|
+
statusBarItem.text = '$(terminal) Run PI';
|
|
28
|
+
statusBarItem.tooltip = 'Run PI in terminal';
|
|
29
|
+
statusBarItem.command = 'pi-companion.run';
|
|
30
|
+
statusBarItem.show();
|
|
31
|
+
|
|
32
|
+
// Register commands
|
|
33
|
+
context.subscriptions.push(
|
|
34
|
+
statusBarItem,
|
|
35
|
+
|
|
36
|
+
vscode.commands.registerCommand('pi-companion.run', () => {
|
|
37
|
+
runPiInTerminal();
|
|
38
|
+
}),
|
|
39
|
+
|
|
40
|
+
vscode.commands.registerCommand('pi-companion.start', async () => {
|
|
41
|
+
if (ideServer?.isRunning()) {
|
|
42
|
+
vscode.window.showInformationMessage('PI Companion: Server already running');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await ideServer?.start(context);
|
|
46
|
+
vscode.window.showInformationMessage(`PI Companion: Server started on port ${ideServer?.getPort()}`);
|
|
47
|
+
}),
|
|
48
|
+
|
|
49
|
+
vscode.commands.registerCommand('pi-companion.stop', async () => {
|
|
50
|
+
await ideServer?.stop();
|
|
51
|
+
vscode.window.showInformationMessage('PI Companion: Server stopped');
|
|
52
|
+
}),
|
|
53
|
+
|
|
54
|
+
vscode.commands.registerCommand('pi-companion.status', () => {
|
|
55
|
+
const running = ideServer?.isRunning();
|
|
56
|
+
const port = ideServer?.getPort();
|
|
57
|
+
vscode.window.showInformationMessage(
|
|
58
|
+
`PI Companion: ${running ? `Running on port ${port}` : 'Stopped'}`
|
|
59
|
+
);
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function runPiInTerminal() {
|
|
65
|
+
// Create new terminal so shell rc files (including direnv hook) are sourced
|
|
66
|
+
const terminal = vscode.window.createTerminal({
|
|
67
|
+
name: 'PI',
|
|
68
|
+
isTransient: true
|
|
69
|
+
});
|
|
70
|
+
terminal.show();
|
|
71
|
+
// Ensure direnv is loaded, then run pi
|
|
72
|
+
terminal.sendText('eval "$(direnv export json 2>/dev/null)" 2>/dev/null; pi');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function deactivate() {
|
|
76
|
+
log('Extension deactivated');
|
|
77
|
+
ideServer?.stop();
|
|
78
|
+
statusBarItem?.dispose();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function log(message: string) {
|
|
82
|
+
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
83
|
+
outputChannel?.appendLine(`[${timestamp}] ${message}`);
|
|
84
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import express, { Request, Response, NextFunction } from 'express';
|
|
9
|
+
import cors from 'cors';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
|
|
12
|
+
const IDE_SERVER_PORT_ENV_VAR = 'PI_COMPANION_PORT';
|
|
13
|
+
const IDE_WORKSPACE_PATH_ENV_VAR = 'PI_COMPANION_WORKSPACE_PATH';
|
|
14
|
+
const IDE_AUTH_TOKEN_ENV_VAR = 'PI_COMPANION_AUTH_TOKEN';
|
|
15
|
+
|
|
16
|
+
export class IDEServer {
|
|
17
|
+
private server: ReturnType<typeof express> | undefined;
|
|
18
|
+
private httpServer: ReturnType<ReturnType<typeof express>['listen']> | undefined;
|
|
19
|
+
private port: number = 0;
|
|
20
|
+
private authToken: string = '';
|
|
21
|
+
private context: vscode.ExtensionContext | undefined;
|
|
22
|
+
private transports: Map<string, StreamableHTTPServerTransport> = new Map();
|
|
23
|
+
private mcpServer: McpServer | undefined;
|
|
24
|
+
private openFilesManager: OpenFilesManager | undefined;
|
|
25
|
+
private log: (message: string) => void = console.log;
|
|
26
|
+
|
|
27
|
+
constructor(log: (message: string) => void, _outputChannel: vscode.OutputChannel) {
|
|
28
|
+
this.log = log;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getPort(): number {
|
|
32
|
+
return this.port;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isRunning(): boolean {
|
|
36
|
+
return this.httpServer !== undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async start(context: vscode.ExtensionContext): Promise<void> {
|
|
40
|
+
this.context = context;
|
|
41
|
+
this.authToken = randomUUID();
|
|
42
|
+
|
|
43
|
+
// Create MCP server
|
|
44
|
+
this.mcpServer = this.createMcpServer();
|
|
45
|
+
|
|
46
|
+
// Create OpenFilesManager
|
|
47
|
+
this.openFilesManager = new OpenFilesManager(context);
|
|
48
|
+
this.openFilesManager.onDidChange(() => {
|
|
49
|
+
this.broadcastIdeContextUpdate();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Create Express app
|
|
53
|
+
const app = express();
|
|
54
|
+
app.use(express.json({ limit: '10mb' }));
|
|
55
|
+
app.use(cors());
|
|
56
|
+
|
|
57
|
+
// Context endpoints (no auth required)
|
|
58
|
+
app.get('/context', (_req, res) => {
|
|
59
|
+
const context = this.openFilesManager?.getState();
|
|
60
|
+
res.json(context);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// SSE endpoint for real-time context updates (no auth required)
|
|
64
|
+
const sseClients = new Set<{ res: Response }>();
|
|
65
|
+
app.get('/context/stream', (_req, res) => {
|
|
66
|
+
this.log(`SSE client connected. Total: ${sseClients.size + 1}`);
|
|
67
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
68
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
69
|
+
res.setHeader('Connection', 'keep-alive');
|
|
70
|
+
res.flushHeaders();
|
|
71
|
+
|
|
72
|
+
const client = { res };
|
|
73
|
+
sseClients.add(client);
|
|
74
|
+
|
|
75
|
+
// Send initial context
|
|
76
|
+
const context = this.openFilesManager?.getState();
|
|
77
|
+
res.write(`data: ${JSON.stringify(context)}\n\n`);
|
|
78
|
+
|
|
79
|
+
_req.on('close', () => {
|
|
80
|
+
sseClients.delete(client);
|
|
81
|
+
this.log(`SSE client disconnected. Total: ${sseClients.size}`);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Store SSE clients for broadcasting
|
|
86
|
+
(this as any)._sseClients = sseClients;
|
|
87
|
+
|
|
88
|
+
// Auth middleware (only for MCP endpoint)
|
|
89
|
+
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
90
|
+
const authHeader = req.headers.authorization;
|
|
91
|
+
if (!authHeader) {
|
|
92
|
+
res.status(401).send('Unauthorized');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const parts = authHeader.split(' ');
|
|
96
|
+
if (parts.length !== 2 || parts[0] !== 'Bearer' || parts[1] !== this.authToken) {
|
|
97
|
+
res.status(401).send('Unauthorized');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
next();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// MCP endpoint
|
|
104
|
+
app.post('/mcp', async (req: Request, res: Response) => {
|
|
105
|
+
let transport: StreamableHTTPServerTransport;
|
|
106
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
107
|
+
|
|
108
|
+
if (sessionId && this.transports.has(sessionId)) {
|
|
109
|
+
transport = this.transports.get(sessionId)!;
|
|
110
|
+
} else if (!sessionId && req.body?.method === 'initialize') {
|
|
111
|
+
transport = new StreamableHTTPServerTransport({
|
|
112
|
+
sessionIdGenerator: () => randomUUID(),
|
|
113
|
+
onsessioninitialized: (newSessionId) => {
|
|
114
|
+
this.log(`Session initialized: ${newSessionId}`);
|
|
115
|
+
this.transports.set(newSessionId, transport);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
transport.onclose = () => {
|
|
120
|
+
if (transport.sessionId) {
|
|
121
|
+
this.transports.delete(transport.sessionId);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
await this.mcpServer!.connect(transport);
|
|
126
|
+
} else {
|
|
127
|
+
res.status(400).json({
|
|
128
|
+
jsonrpc: '2.0',
|
|
129
|
+
error: { code: -32000, message: 'Invalid request' },
|
|
130
|
+
id: null,
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await transport.handleRequest(req, res, req.body);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
this.log(`Error handling request: ${error}`);
|
|
139
|
+
if (!res.headersSent) {
|
|
140
|
+
res.status(500).json({
|
|
141
|
+
jsonrpc: '2.0',
|
|
142
|
+
error: { code: -32603, message: 'Internal error' },
|
|
143
|
+
id: null,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
app.get('/mcp', async (req: Request, res: Response) => {
|
|
150
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
151
|
+
if (!sessionId || !this.transports.has(sessionId)) {
|
|
152
|
+
res.status(400).send('Invalid or missing session ID');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const transport = this.transports.get(sessionId)!;
|
|
157
|
+
try {
|
|
158
|
+
await transport.handleRequest(req, res);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.log(`Error handling session request: ${error}`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Start HTTP server
|
|
165
|
+
return new Promise((resolve, reject) => {
|
|
166
|
+
this.httpServer = app.listen(0, '127.0.0.1', async () => {
|
|
167
|
+
const address = this.httpServer!.address();
|
|
168
|
+
if (address && typeof address !== 'string') {
|
|
169
|
+
this.port = address.port;
|
|
170
|
+
this.log(`IDE server listening on http://127.0.0.1:${this.port}`);
|
|
171
|
+
|
|
172
|
+
// Write connection info to file
|
|
173
|
+
await this.writeConnectionInfo();
|
|
174
|
+
|
|
175
|
+
// Set environment variables
|
|
176
|
+
if (this.context) {
|
|
177
|
+
this.context.environmentVariableCollection.replace(IDE_SERVER_PORT_ENV_VAR, this.port.toString());
|
|
178
|
+
this.context.environmentVariableCollection.replace(IDE_WORKSPACE_PATH_ENV_VAR, this.getWorkspacePath());
|
|
179
|
+
this.context.environmentVariableCollection.replace(IDE_AUTH_TOKEN_ENV_VAR, this.authToken);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
resolve();
|
|
183
|
+
} else {
|
|
184
|
+
reject(new Error('Failed to get server address'));
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
this.httpServer.on('error', (error: Error) => {
|
|
189
|
+
this.log(`Server error: ${error}`);
|
|
190
|
+
reject(error);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async writeConnectionInfo(): Promise<void> {
|
|
196
|
+
const configDir = path.join(os.tmpdir(), 'pi-companion');
|
|
197
|
+
const pid = process.ppid || process.pid;
|
|
198
|
+
const infoFile = path.join(configDir, `connection-${pid}.json`);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await fs.promises.mkdir(configDir, { recursive: true });
|
|
202
|
+
const info = {
|
|
203
|
+
port: this.port,
|
|
204
|
+
workspacePath: this.getWorkspacePath(),
|
|
205
|
+
authToken: this.authToken,
|
|
206
|
+
};
|
|
207
|
+
await fs.promises.writeFile(infoFile, JSON.stringify(info, null, 2));
|
|
208
|
+
await fs.promises.chmod(infoFile, 0o600);
|
|
209
|
+
this.log(`Wrote connection info to ${infoFile}`);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
this.log(`Failed to write connection info: ${error}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private getWorkspacePath(): string {
|
|
216
|
+
const folders = vscode.workspace.workspaceFolders;
|
|
217
|
+
if (folders && folders.length > 0) {
|
|
218
|
+
return folders.map(f => f.uri.fsPath).join(path.delimiter);
|
|
219
|
+
}
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private createMcpServer(): McpServer {
|
|
224
|
+
const server = new McpServer(
|
|
225
|
+
{
|
|
226
|
+
name: 'pi-companion',
|
|
227
|
+
version: '1.0.0',
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
capabilities: {
|
|
231
|
+
tools: {},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Tool: get_open_files
|
|
237
|
+
// Using 'tool' method with type assertion to avoid TypeScript/Zod compatibility issues
|
|
238
|
+
(server as any).tool(
|
|
239
|
+
'get_open_files',
|
|
240
|
+
'Get all open files with their content, cursor position, and selection',
|
|
241
|
+
{},
|
|
242
|
+
async () => {
|
|
243
|
+
const context = this.openFilesManager?.getState();
|
|
244
|
+
return {
|
|
245
|
+
content: [
|
|
246
|
+
{
|
|
247
|
+
type: 'text',
|
|
248
|
+
text: JSON.stringify(context, null, 2),
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
// Tool: read_file
|
|
256
|
+
(server as any).tool(
|
|
257
|
+
'read_file',
|
|
258
|
+
'Read the contents of a file',
|
|
259
|
+
{ path: { type: 'string', description: 'Absolute path to the file' } },
|
|
260
|
+
async (args: { path: string }) => {
|
|
261
|
+
try {
|
|
262
|
+
const content = await fs.promises.readFile(args.path, 'utf-8');
|
|
263
|
+
return {
|
|
264
|
+
content: [
|
|
265
|
+
{
|
|
266
|
+
type: 'text',
|
|
267
|
+
text: content,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return {
|
|
273
|
+
content: [
|
|
274
|
+
{
|
|
275
|
+
type: 'text',
|
|
276
|
+
text: `Error reading file: ${error}`,
|
|
277
|
+
},
|
|
278
|
+
],
|
|
279
|
+
isError: true,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Tool: goto
|
|
286
|
+
(server as any).tool(
|
|
287
|
+
'goto',
|
|
288
|
+
'Open a file at a specific location',
|
|
289
|
+
{
|
|
290
|
+
path: { type: 'string', description: 'Absolute path to the file' },
|
|
291
|
+
line: { type: 'number', description: 'Line number (1-based)' },
|
|
292
|
+
character: { type: 'number', description: 'Character position (1-based)' },
|
|
293
|
+
},
|
|
294
|
+
async (args: { path: string; line?: number; character?: number }) => {
|
|
295
|
+
try {
|
|
296
|
+
const uri = vscode.Uri.file(args.path);
|
|
297
|
+
const document = await vscode.workspace.openTextDocument(uri);
|
|
298
|
+
const editor = await vscode.window.showTextDocument(document, {
|
|
299
|
+
viewColumn: vscode.ViewColumn.Active,
|
|
300
|
+
preserveFocus: false,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
if (args.line !== undefined) {
|
|
304
|
+
const position = new vscode.Position(
|
|
305
|
+
Math.max(0, args.line - 1),
|
|
306
|
+
Math.max(0, (args.character || 1) - 1),
|
|
307
|
+
);
|
|
308
|
+
editor.selection = new vscode.Selection(position, position);
|
|
309
|
+
editor.revealRange(
|
|
310
|
+
new vscode.Range(position, position),
|
|
311
|
+
vscode.TextEditorRevealType.InCenter,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
content: [
|
|
317
|
+
{
|
|
318
|
+
type: 'text',
|
|
319
|
+
text: `Opened ${args.path}${args.line ? `:${args.line}` : ''}`,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
content: [
|
|
326
|
+
{
|
|
327
|
+
type: 'text',
|
|
328
|
+
text: `Error opening file: ${error}`,
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
isError: true,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
return server;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private broadcastIdeContextUpdate(): void {
|
|
341
|
+
if (!this.openFilesManager) return;
|
|
342
|
+
|
|
343
|
+
const context = this.openFilesManager.getState();
|
|
344
|
+
|
|
345
|
+
// Send to SSE clients
|
|
346
|
+
const sseClients = (this as any)._sseClients as Set<{ res: Response }> | undefined;
|
|
347
|
+
if (sseClients) {
|
|
348
|
+
for (const client of sseClients) {
|
|
349
|
+
try {
|
|
350
|
+
client.res.write(`data: ${JSON.stringify(context)}\n\n`);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
this.log(`SSE write error: ${error}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async stop(): Promise<void> {
|
|
359
|
+
if (this.httpServer) {
|
|
360
|
+
await new Promise<void>((resolve) => {
|
|
361
|
+
this.httpServer!.close(() => resolve());
|
|
362
|
+
});
|
|
363
|
+
this.httpServer = undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (this.context) {
|
|
367
|
+
this.context.environmentVariableCollection.clear();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Clean up connection file
|
|
371
|
+
const configDir = path.join(os.tmpdir(), 'pi-companion');
|
|
372
|
+
const pid = process.ppid || process.pid;
|
|
373
|
+
const infoFile = path.join(configDir, `connection-${pid}.json`);
|
|
374
|
+
fs.promises.unlink(infoFile).catch(() => {});
|
|
375
|
+
|
|
376
|
+
this.log('Server stopped');
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// OpenFilesManager must be imported at runtime to avoid circular dependency
|
|
381
|
+
import { OpenFilesManager } from './open-files-manager';
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
const MAX_FILES = 10;
|
|
5
|
+
const MAX_SELECTED_TEXT_LENGTH = 16384;
|
|
6
|
+
|
|
7
|
+
interface File {
|
|
8
|
+
path: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
isActive?: boolean;
|
|
11
|
+
selectedText?: string;
|
|
12
|
+
cursor?: {
|
|
13
|
+
line: number;
|
|
14
|
+
character: number;
|
|
15
|
+
};
|
|
16
|
+
selectionStart?: {
|
|
17
|
+
line: number;
|
|
18
|
+
character: number;
|
|
19
|
+
};
|
|
20
|
+
selectionEnd?: {
|
|
21
|
+
line: number;
|
|
22
|
+
character: number;
|
|
23
|
+
};
|
|
24
|
+
content?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface IdeContext {
|
|
28
|
+
workspaceState: {
|
|
29
|
+
openFiles: File[];
|
|
30
|
+
isTrusted: boolean;
|
|
31
|
+
workspacePath: string;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Keeps track of the workspace state: open files, cursor position, and selected text.
|
|
37
|
+
*/
|
|
38
|
+
export class OpenFilesManager {
|
|
39
|
+
private readonly onDidChangeEmitter = new vscode.EventEmitter<void>();
|
|
40
|
+
readonly onDidChange = this.onDidChangeEmitter.event;
|
|
41
|
+
private debounceTimer: NodeJS.Timeout | undefined;
|
|
42
|
+
private openFiles: File[] = [];
|
|
43
|
+
|
|
44
|
+
constructor(private readonly context: vscode.ExtensionContext) {
|
|
45
|
+
// Watch for active editor changes
|
|
46
|
+
const editorWatcher = vscode.window.onDidChangeActiveTextEditor((editor) => {
|
|
47
|
+
if (editor && this.isFileUri(editor.document.uri)) {
|
|
48
|
+
this.addOrMoveToFront(editor);
|
|
49
|
+
this.fireWithDebounce();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Watch for selection changes
|
|
54
|
+
const selectionWatcher = vscode.window.onDidChangeTextEditorSelection((event) => {
|
|
55
|
+
if (this.isFileUri(event.textEditor.document.uri)) {
|
|
56
|
+
this.updateActiveContext(event.textEditor);
|
|
57
|
+
this.fireWithDebounce();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Watch for file close
|
|
62
|
+
const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => {
|
|
63
|
+
if (this.isFileUri(document.uri)) {
|
|
64
|
+
this.remove(document.uri);
|
|
65
|
+
this.fireWithDebounce();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Watch for file delete
|
|
70
|
+
const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => {
|
|
71
|
+
for (const uri of event.files) {
|
|
72
|
+
if (this.isFileUri(uri)) {
|
|
73
|
+
this.remove(uri);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.fireWithDebounce();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Watch for file rename
|
|
80
|
+
const renameWatcher = vscode.workspace.onDidRenameFiles((event) => {
|
|
81
|
+
for (const { oldUri, newUri } of event.files) {
|
|
82
|
+
if (this.isFileUri(oldUri)) {
|
|
83
|
+
if (this.isFileUri(newUri)) {
|
|
84
|
+
this.rename(oldUri, newUri);
|
|
85
|
+
} else {
|
|
86
|
+
this.remove(oldUri);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.fireWithDebounce();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Watch for file save (to update content)
|
|
94
|
+
const saveWatcher = vscode.workspace.onDidSaveTextDocument((document) => {
|
|
95
|
+
if (this.isFileUri(document.uri)) {
|
|
96
|
+
const file = this.openFiles.find((f) => f.path === document.uri.fsPath);
|
|
97
|
+
if (file) {
|
|
98
|
+
file.content = document.getText();
|
|
99
|
+
}
|
|
100
|
+
this.fireWithDebounce();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
context.subscriptions.push(
|
|
105
|
+
editorWatcher,
|
|
106
|
+
selectionWatcher,
|
|
107
|
+
closeWatcher,
|
|
108
|
+
deleteWatcher,
|
|
109
|
+
renameWatcher,
|
|
110
|
+
saveWatcher,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// Add current active file on startup
|
|
114
|
+
if (
|
|
115
|
+
vscode.window.activeTextEditor &&
|
|
116
|
+
this.isFileUri(vscode.window.activeTextEditor.document.uri)
|
|
117
|
+
) {
|
|
118
|
+
this.addOrMoveToFront(vscode.window.activeTextEditor);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private isFileUri(uri: vscode.Uri): boolean {
|
|
123
|
+
return uri.scheme === 'file';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private addOrMoveToFront(editor: vscode.TextEditor) {
|
|
127
|
+
// Deactivate previous active file
|
|
128
|
+
const currentActive = this.openFiles.find((f) => f.isActive);
|
|
129
|
+
if (currentActive) {
|
|
130
|
+
currentActive.isActive = false;
|
|
131
|
+
currentActive.cursor = undefined;
|
|
132
|
+
currentActive.selectedText = undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Remove if exists
|
|
136
|
+
const index = this.openFiles.findIndex(
|
|
137
|
+
(f) => f.path === editor.document.uri.fsPath,
|
|
138
|
+
);
|
|
139
|
+
if (index !== -1) {
|
|
140
|
+
this.openFiles.splice(index, 1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add to front as active
|
|
144
|
+
this.openFiles.unshift({
|
|
145
|
+
path: editor.document.uri.fsPath,
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
isActive: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Enforce max files
|
|
151
|
+
if (this.openFiles.length > MAX_FILES) {
|
|
152
|
+
this.openFiles.pop();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.updateActiveContext(editor);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private remove(uri: vscode.Uri) {
|
|
159
|
+
const index = this.openFiles.findIndex((f) => f.path === uri.fsPath);
|
|
160
|
+
if (index !== -1) {
|
|
161
|
+
this.openFiles.splice(index, 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private rename(oldUri: vscode.Uri, newUri: vscode.Uri) {
|
|
166
|
+
const file = this.openFiles.find((f) => f.path === oldUri.fsPath);
|
|
167
|
+
if (file) {
|
|
168
|
+
file.path = newUri.fsPath;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async updateActiveContext(editor: vscode.TextEditor) {
|
|
173
|
+
const file = this.openFiles.find(
|
|
174
|
+
(f) => f.path === editor.document.uri.fsPath,
|
|
175
|
+
);
|
|
176
|
+
if (!file || !file.isActive) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Update cursor position (where cursor is)
|
|
181
|
+
if (editor.selection.active) {
|
|
182
|
+
file.cursor = {
|
|
183
|
+
line: editor.selection.active.line + 1,
|
|
184
|
+
character: editor.selection.active.character + 1,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Update selection boundaries
|
|
189
|
+
if (!editor.selection.isEmpty) {
|
|
190
|
+
const startLine = Math.min(editor.selection.anchor.line, editor.selection.active.line) + 1;
|
|
191
|
+
const endLine = Math.max(editor.selection.anchor.line, editor.selection.active.line) + 1;
|
|
192
|
+
const startChar = Math.min(editor.selection.anchor.character, editor.selection.active.character) + 1;
|
|
193
|
+
const endChar = Math.max(editor.selection.anchor.character, editor.selection.active.character) + 1;
|
|
194
|
+
|
|
195
|
+
file.selectionStart = { line: startLine, character: startChar };
|
|
196
|
+
file.selectionEnd = { line: endLine, character: endChar };
|
|
197
|
+
} else {
|
|
198
|
+
file.selectionStart = undefined;
|
|
199
|
+
file.selectionEnd = undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Update selected text
|
|
203
|
+
let selectedText = editor.document.getText(editor.selection) || undefined;
|
|
204
|
+
if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) {
|
|
205
|
+
selectedText = selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH);
|
|
206
|
+
}
|
|
207
|
+
file.selectedText = selectedText;
|
|
208
|
+
|
|
209
|
+
// Update content
|
|
210
|
+
file.content = editor.document.getText();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private fireWithDebounce() {
|
|
214
|
+
if (this.debounceTimer) {
|
|
215
|
+
clearTimeout(this.debounceTimer);
|
|
216
|
+
}
|
|
217
|
+
this.debounceTimer = setTimeout(() => {
|
|
218
|
+
this.onDidChangeEmitter.fire();
|
|
219
|
+
}, 50);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getState(): IdeContext {
|
|
223
|
+
const folders = vscode.workspace.workspaceFolders;
|
|
224
|
+
const workspacePath = folders?.map((f) => f.uri.fsPath).join(',') || '';
|
|
225
|
+
|
|
226
|
+
// Only return files that are actually open in editors
|
|
227
|
+
const openTextEditors = vscode.window.tabGroups.all
|
|
228
|
+
.flatMap((group) => group.tabs)
|
|
229
|
+
.map((tab) => {
|
|
230
|
+
if (tab.input instanceof vscode.TabInputText) {
|
|
231
|
+
return tab.input.uri.fsPath;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
})
|
|
235
|
+
.filter((p): p is string => p !== null);
|
|
236
|
+
|
|
237
|
+
const actualOpenFiles = this.openFiles.filter((f) =>
|
|
238
|
+
openTextEditors.includes(f.path)
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
workspaceState: {
|
|
243
|
+
openFiles: actualOpenFiles,
|
|
244
|
+
isTrusted: vscode.workspace.isTrusted,
|
|
245
|
+
workspacePath,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|