@gettrace/cli 1.1.6 → 1.2.7
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/dist/index.js +1075 -815
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,13 +5,88 @@
|
|
|
5
5
|
// ============================================
|
|
6
6
|
import { program } from 'commander';
|
|
7
7
|
import chalk from 'chalk';
|
|
8
|
-
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
|
-
import { exec } from 'child_process';
|
|
11
|
+
import { exec, spawn } from 'child_process';
|
|
12
12
|
import { promisify } from 'util';
|
|
13
13
|
const execAsync = promisify(exec);
|
|
14
|
-
|
|
14
|
+
// ============================================
|
|
15
|
+
// TERMINAL BUFFER
|
|
16
|
+
// Rolling 500-line ring buffer of terminal output.
|
|
17
|
+
// Shared across all WebSocket clients so every
|
|
18
|
+
// connected extension gets the same live stream.
|
|
19
|
+
// ============================================
|
|
20
|
+
class TerminalBuffer {
|
|
21
|
+
lines = [];
|
|
22
|
+
MAX_LINES = 500;
|
|
23
|
+
push(chunk) {
|
|
24
|
+
// Strip ANSI escape codes so agents get clean text
|
|
25
|
+
const clean = chunk.replace(/\x1B\[[0-9;]*[mGKHFABCDEJsu]/g, '');
|
|
26
|
+
const newLines = clean.split('\n');
|
|
27
|
+
for (const line of newLines) {
|
|
28
|
+
if (line.trim() === '')
|
|
29
|
+
continue;
|
|
30
|
+
this.lines.push(line);
|
|
31
|
+
if (this.lines.length > this.MAX_LINES) {
|
|
32
|
+
this.lines.shift();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
getLast(n = 100) {
|
|
37
|
+
return this.lines.slice(-n).join('\n');
|
|
38
|
+
}
|
|
39
|
+
clear() {
|
|
40
|
+
this.lines = [];
|
|
41
|
+
}
|
|
42
|
+
get length() {
|
|
43
|
+
return this.lines.length;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Singleton — shared across all WS connections for `trace dev`
|
|
47
|
+
const globalTerminalBuffer = new TerminalBuffer();
|
|
48
|
+
let devProcess = null;
|
|
49
|
+
// ============================================
|
|
50
|
+
// DEV COMMAND DETECTION
|
|
51
|
+
// Reads package.json to find the right script
|
|
52
|
+
// and detects package manager from lockfiles.
|
|
53
|
+
// ============================================
|
|
54
|
+
function detectDevCommand(projectPath, override) {
|
|
55
|
+
// 1. If user passed an explicit command, use it directly
|
|
56
|
+
if (override) {
|
|
57
|
+
return { command: override, pm: 'custom', script: override };
|
|
58
|
+
}
|
|
59
|
+
// 2. Detect package manager from lockfiles
|
|
60
|
+
let pm = 'npm';
|
|
61
|
+
if (fs.existsSync(path.join(projectPath, 'bun.lockb')))
|
|
62
|
+
pm = 'bun';
|
|
63
|
+
else if (fs.existsSync(path.join(projectPath, 'pnpm-lock.yaml')))
|
|
64
|
+
pm = 'pnpm';
|
|
65
|
+
else if (fs.existsSync(path.join(projectPath, 'yarn.lock')))
|
|
66
|
+
pm = 'yarn';
|
|
67
|
+
// 3. Read package.json scripts
|
|
68
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
69
|
+
let scripts = {};
|
|
70
|
+
if (fs.existsSync(pkgPath)) {
|
|
71
|
+
try {
|
|
72
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
73
|
+
scripts = pkg.scripts || {};
|
|
74
|
+
}
|
|
75
|
+
catch (e) { /* malformed package.json */ }
|
|
76
|
+
}
|
|
77
|
+
// 4. Priority order: dev > start > serve > preview
|
|
78
|
+
const candidates = ['dev', 'start', 'serve', 'preview'];
|
|
79
|
+
const script = candidates.find(c => !!scripts[c]) || 'dev';
|
|
80
|
+
// 5. Build the final command
|
|
81
|
+
const runCmd = {
|
|
82
|
+
npm: `npm run ${script}`,
|
|
83
|
+
pnpm: `pnpm run ${script}`,
|
|
84
|
+
yarn: `yarn ${script}`,
|
|
85
|
+
bun: `bun run ${script}`,
|
|
86
|
+
};
|
|
87
|
+
return { command: runCmd[pm] ?? `npm run ${script}`, pm, script };
|
|
88
|
+
}
|
|
89
|
+
const VERSION = '1.2.7';
|
|
15
90
|
/** Levenshtein distance for fuzzy block matching */
|
|
16
91
|
function levenshtein(a, b) {
|
|
17
92
|
if (a === '' || b === '')
|
|
@@ -373,919 +448,1104 @@ async function autoFormat(filePath, projectPath) {
|
|
|
373
448
|
return null;
|
|
374
449
|
}
|
|
375
450
|
program
|
|
376
|
-
.name('trace
|
|
377
|
-
.description('IDE Bridge
|
|
451
|
+
.name('trace')
|
|
452
|
+
.description('Trace IDE Bridge — connect your codebase and dev server to the Trace extension')
|
|
378
453
|
.version(VERSION);
|
|
454
|
+
// ============================================
|
|
455
|
+
// COMMAND: trace dev
|
|
456
|
+
// Starts your dev server AND the IDE bridge in
|
|
457
|
+
// one command. Terminal output is streamed to
|
|
458
|
+
// the extension so Trace agents have live context.
|
|
459
|
+
// ============================================
|
|
379
460
|
program
|
|
380
|
-
.command('
|
|
381
|
-
.
|
|
382
|
-
.
|
|
383
|
-
.option('-p, --port <port>', 'WebSocket port', '8765')
|
|
384
|
-
.action(async (options) => {
|
|
461
|
+
.command('dev')
|
|
462
|
+
.description('Start dev server + IDE bridge together (recommended)')
|
|
463
|
+
.argument('[command]', 'Override the dev command (e.g. "npm run start:staging")')
|
|
464
|
+
.option('-p, --port <port>', 'WebSocket port for IDE bridge', '8765')
|
|
465
|
+
.action(async (commandOverride, options) => {
|
|
385
466
|
const port = parseInt(options.port);
|
|
386
467
|
const projectPath = process.cwd();
|
|
468
|
+
const { command, pm, script } = detectDevCommand(projectPath, commandOverride);
|
|
469
|
+
console.log();
|
|
470
|
+
console.log(chalk.bold.cyan('⚡ Trace Dev'));
|
|
471
|
+
console.log(chalk.gray('─'.repeat(55)));
|
|
472
|
+
console.log();
|
|
473
|
+
console.log(`📁 Project: ${chalk.green(projectPath)}`);
|
|
474
|
+
console.log(`📦 Package Mgr: ${chalk.yellow(pm)}`);
|
|
475
|
+
console.log(`🚀 Dev Command: ${chalk.cyan(command)}`);
|
|
476
|
+
console.log(`🌐 Bridge Port: ${chalk.cyan(port)}`);
|
|
387
477
|
console.log();
|
|
388
|
-
console.log(chalk.bold.cyan('🔗 Trace IDE Bridge'));
|
|
389
478
|
console.log(chalk.gray('─'.repeat(55)));
|
|
390
479
|
console.log();
|
|
391
|
-
|
|
392
|
-
|
|
480
|
+
// Track all connected WebSocket clients so we can push STREAM_CHUNK events
|
|
481
|
+
const connectedClients = new Set();
|
|
482
|
+
const broadcast = (payload) => {
|
|
483
|
+
const msg = JSON.stringify(payload);
|
|
484
|
+
for (const client of connectedClients) {
|
|
485
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
486
|
+
client.send(msg);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
// ── Start the dev server ──────────────────────────────────
|
|
491
|
+
console.log(chalk.dim('Starting dev server...'));
|
|
393
492
|
console.log();
|
|
394
|
-
|
|
493
|
+
devProcess = spawn(command, [], {
|
|
494
|
+
cwd: projectPath,
|
|
495
|
+
shell: true,
|
|
496
|
+
env: { ...process.env, FORCE_COLOR: '1' },
|
|
497
|
+
});
|
|
498
|
+
devProcess.stdout?.on('data', (chunk) => {
|
|
499
|
+
const text = chunk.toString();
|
|
500
|
+
// Print to terminal so the developer still sees their logs
|
|
501
|
+
process.stdout.write(text);
|
|
502
|
+
// Store in rolling buffer (ANSI stripped)
|
|
503
|
+
globalTerminalBuffer.push(text);
|
|
504
|
+
// Push raw chunk to extension for live terminal panel
|
|
505
|
+
broadcast({ type: 'STREAM_CHUNK', stream: 'stdout', chunk: text });
|
|
506
|
+
});
|
|
507
|
+
devProcess.stderr?.on('data', (chunk) => {
|
|
508
|
+
const text = chunk.toString();
|
|
509
|
+
process.stderr.write(text);
|
|
510
|
+
globalTerminalBuffer.push(text);
|
|
511
|
+
broadcast({ type: 'STREAM_CHUNK', stream: 'stderr', chunk: text });
|
|
512
|
+
});
|
|
513
|
+
devProcess.on('close', (code) => {
|
|
514
|
+
const msg = `\n[Trace] Dev server exited with code ${code}\n`;
|
|
515
|
+
process.stdout.write(chalk.yellow(msg));
|
|
516
|
+
globalTerminalBuffer.push(msg);
|
|
517
|
+
broadcast({ type: 'STREAM_END', exitCode: code });
|
|
518
|
+
devProcess = null;
|
|
519
|
+
});
|
|
520
|
+
devProcess.on('error', (err) => {
|
|
521
|
+
const msg = `[Trace] Failed to start dev server: ${err.message}\n`;
|
|
522
|
+
process.stderr.write(chalk.red(msg));
|
|
523
|
+
console.error(chalk.red('\n✗ Could not start dev server.'));
|
|
524
|
+
console.error(chalk.dim(` Command: ${command}`));
|
|
525
|
+
console.error(chalk.dim(` Make sure the script exists in your package.json`));
|
|
526
|
+
});
|
|
527
|
+
// ── Start the WebSocket IDE bridge ────────────────────────
|
|
528
|
+
const wss = new WebSocketServer({ port });
|
|
529
|
+
let clientCount = 0;
|
|
530
|
+
wss.on('listening', () => {
|
|
531
|
+
console.log();
|
|
532
|
+
console.log(chalk.gray('─'.repeat(55)));
|
|
533
|
+
console.log(chalk.green('✓') + ' IDE Bridge listening on port ' + chalk.cyan(port));
|
|
534
|
+
console.log(chalk.dim('Waiting for Trace extension to connect...'));
|
|
535
|
+
console.log(chalk.dim('Press Ctrl+C to stop both'));
|
|
536
|
+
console.log(chalk.gray('─'.repeat(55)));
|
|
537
|
+
console.log();
|
|
538
|
+
});
|
|
539
|
+
wss.on('connection', (ws) => {
|
|
540
|
+
clientCount++;
|
|
541
|
+
connectedClients.add(ws);
|
|
542
|
+
console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
|
|
543
|
+
// Wire up the full IDE bridge protocol (file read/write, detect project, etc.)
|
|
544
|
+
attachMessageHandler(ws, projectPath);
|
|
545
|
+
// Send the last 100 lines immediately so the extension has terminal context
|
|
546
|
+
const catchup = globalTerminalBuffer.getLast(100);
|
|
547
|
+
if (catchup) {
|
|
548
|
+
ws.send(JSON.stringify({ type: 'TERMINAL_CATCHUP', lines: catchup }));
|
|
549
|
+
}
|
|
550
|
+
ws.on('close', () => {
|
|
551
|
+
clientCount--;
|
|
552
|
+
connectedClients.delete(ws);
|
|
553
|
+
console.log(chalk.yellow('●') + ` Extension disconnected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
|
|
554
|
+
});
|
|
555
|
+
ws.on('error', (err) => {
|
|
556
|
+
console.error(chalk.red('WebSocket error:'), err.message);
|
|
557
|
+
connectedClients.delete(ws);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
wss.on('error', (error) => {
|
|
561
|
+
if (error.code === 'EADDRINUSE') {
|
|
562
|
+
console.error(chalk.red(`✗ Port ${port} is already in use. Try: trace dev --port 8766`));
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
console.error(chalk.red('Bridge error:'), error.message);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
// ── Graceful shutdown ─────────────────────────────────────
|
|
569
|
+
const shutdown = () => {
|
|
570
|
+
console.log();
|
|
571
|
+
console.log(chalk.yellow('\nShutting down...'));
|
|
572
|
+
if (devProcess && !devProcess.killed) {
|
|
573
|
+
devProcess.kill('SIGTERM');
|
|
574
|
+
}
|
|
575
|
+
wss.close();
|
|
576
|
+
process.exit(0);
|
|
577
|
+
};
|
|
578
|
+
process.on('SIGINT', shutdown);
|
|
579
|
+
process.on('SIGTERM', shutdown);
|
|
580
|
+
});
|
|
581
|
+
// ============================================
|
|
582
|
+
// SHARED MESSAGE HANDLER
|
|
583
|
+
// Called by both `trace dev` and `trace connect`.
|
|
584
|
+
// Attaches the full IDE bridge protocol to a WS.
|
|
585
|
+
// ============================================
|
|
586
|
+
function attachMessageHandler(ws, projectPath) {
|
|
587
|
+
// Per-connection undo stack
|
|
588
|
+
const undoStack = [];
|
|
589
|
+
// Build project info for GET_PROJECT_INFO responses
|
|
395
590
|
let projectInfo = { projectPath };
|
|
396
591
|
try {
|
|
397
592
|
const pkgPath = path.join(projectPath, 'package.json');
|
|
398
593
|
if (fs.existsSync(pkgPath)) {
|
|
399
594
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
400
|
-
projectInfo = {
|
|
401
|
-
...projectInfo,
|
|
402
|
-
name: pkg.name,
|
|
403
|
-
version: pkg.version,
|
|
404
|
-
description: pkg.description
|
|
405
|
-
};
|
|
406
|
-
console.log(`📦 Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
|
|
595
|
+
projectInfo = { ...projectInfo, name: pkg.name, version: pkg.version, description: pkg.description };
|
|
407
596
|
}
|
|
408
597
|
}
|
|
409
|
-
catch (
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
case 'READ_FILE':
|
|
437
|
-
try {
|
|
438
|
-
const filePath = path.resolve(projectPath, message.filePath);
|
|
439
|
-
if (!filePath.startsWith(projectPath)) {
|
|
440
|
-
response.error = 'Access denied';
|
|
441
|
-
}
|
|
442
|
-
else if (fs.existsSync(filePath)) {
|
|
443
|
-
const stat = fs.statSync(filePath);
|
|
444
|
-
// Binary file detection (check first 4KB for null bytes)
|
|
445
|
-
if (stat.size > 0) {
|
|
446
|
-
const fd = fs.openSync(filePath, 'r');
|
|
447
|
-
const sample = Buffer.alloc(Math.min(4096, stat.size));
|
|
448
|
-
fs.readSync(fd, sample, 0, sample.length, 0);
|
|
449
|
-
fs.closeSync(fd);
|
|
450
|
-
if (sample.includes(0)) {
|
|
451
|
-
response.error = `Cannot read binary file: ${message.filePath}`;
|
|
452
|
-
break;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
456
|
-
const allLines = rawContent.split('\n');
|
|
457
|
-
const offset = message.offset || 1;
|
|
458
|
-
const limit = message.limit || 2000;
|
|
459
|
-
const startIdx = Math.max(0, offset - 1);
|
|
460
|
-
const endIdx = Math.min(allLines.length, startIdx + limit);
|
|
461
|
-
const sliced = allLines.slice(startIdx, endIdx);
|
|
462
|
-
// Line-number prefixed output (like OpenCode)
|
|
463
|
-
const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
|
|
464
|
-
const truncated = endIdx < allLines.length;
|
|
465
|
-
response.data = {
|
|
466
|
-
content: numbered,
|
|
467
|
-
rawContent: rawContent,
|
|
468
|
-
exists: true,
|
|
469
|
-
path: filePath,
|
|
470
|
-
totalLines: allLines.length,
|
|
471
|
-
showing: { from: offset, to: endIdx, truncated },
|
|
472
|
-
};
|
|
473
|
-
}
|
|
474
|
-
else {
|
|
475
|
-
// Suggest similar files if not found
|
|
476
|
-
const dir = path.dirname(filePath);
|
|
477
|
-
const base = path.basename(filePath);
|
|
478
|
-
let suggestions = [];
|
|
479
|
-
try {
|
|
480
|
-
suggestions = fs.readdirSync(dir)
|
|
481
|
-
.filter(e => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase()))
|
|
482
|
-
.slice(0, 3)
|
|
483
|
-
.map(e => path.relative(projectPath, path.join(dir, e)));
|
|
598
|
+
catch (_) { }
|
|
599
|
+
ws.on('message', async (data) => {
|
|
600
|
+
try {
|
|
601
|
+
const message = JSON.parse(data.toString());
|
|
602
|
+
const { id, type } = message;
|
|
603
|
+
let response = { id };
|
|
604
|
+
switch (type) {
|
|
605
|
+
case 'GET_PROJECT_INFO':
|
|
606
|
+
response.data = projectInfo;
|
|
607
|
+
break;
|
|
608
|
+
case 'READ_FILE':
|
|
609
|
+
try {
|
|
610
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
611
|
+
if (!filePath.startsWith(projectPath)) {
|
|
612
|
+
response.error = 'Access denied';
|
|
613
|
+
}
|
|
614
|
+
else if (fs.existsSync(filePath)) {
|
|
615
|
+
const stat = fs.statSync(filePath);
|
|
616
|
+
// Binary file detection (check first 4KB for null bytes)
|
|
617
|
+
if (stat.size > 0) {
|
|
618
|
+
const fd = fs.openSync(filePath, 'r');
|
|
619
|
+
const sample = Buffer.alloc(Math.min(4096, stat.size));
|
|
620
|
+
fs.readSync(fd, sample, 0, sample.length, 0);
|
|
621
|
+
fs.closeSync(fd);
|
|
622
|
+
if (sample.includes(0)) {
|
|
623
|
+
response.error = `Cannot read binary file: ${message.filePath}`;
|
|
624
|
+
break;
|
|
484
625
|
}
|
|
485
|
-
catch (_) { }
|
|
486
|
-
response.data = { exists: false, suggestions };
|
|
487
626
|
}
|
|
627
|
+
const rawContent = fs.readFileSync(filePath, 'utf-8');
|
|
628
|
+
const allLines = rawContent.split('\n');
|
|
629
|
+
const offset = message.offset || 1;
|
|
630
|
+
const limit = message.limit || 2000;
|
|
631
|
+
const startIdx = Math.max(0, offset - 1);
|
|
632
|
+
const endIdx = Math.min(allLines.length, startIdx + limit);
|
|
633
|
+
const sliced = allLines.slice(startIdx, endIdx);
|
|
634
|
+
// Line-number prefixed output (like OpenCode)
|
|
635
|
+
const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
|
|
636
|
+
const truncated = endIdx < allLines.length;
|
|
637
|
+
response.data = {
|
|
638
|
+
content: numbered,
|
|
639
|
+
rawContent: rawContent,
|
|
640
|
+
exists: true,
|
|
641
|
+
path: filePath,
|
|
642
|
+
totalLines: allLines.length,
|
|
643
|
+
showing: { from: offset, to: endIdx, truncated },
|
|
644
|
+
};
|
|
488
645
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
else if (fs.existsSync(filePath)) {
|
|
500
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
501
|
-
const lines = content.split('\n');
|
|
502
|
-
const start = Math.max(0, (message.lineStart || 1) - 1);
|
|
503
|
-
const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
|
|
504
|
-
response.data = {
|
|
505
|
-
lines: lines.slice(start, end),
|
|
506
|
-
startLine: start + 1,
|
|
507
|
-
endLine: end,
|
|
508
|
-
totalLines: lines.length
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
else {
|
|
512
|
-
response.error = 'File not found';
|
|
646
|
+
else {
|
|
647
|
+
// Suggest similar files if not found
|
|
648
|
+
const dir = path.dirname(filePath);
|
|
649
|
+
const base = path.basename(filePath);
|
|
650
|
+
let suggestions = [];
|
|
651
|
+
try {
|
|
652
|
+
suggestions = fs.readdirSync(dir)
|
|
653
|
+
.filter(e => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase()))
|
|
654
|
+
.slice(0, 3)
|
|
655
|
+
.map(e => path.relative(projectPath, path.join(dir, e)));
|
|
513
656
|
}
|
|
657
|
+
catch (_) { }
|
|
658
|
+
response.data = { exists: false, suggestions };
|
|
514
659
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
else if (fs.existsSync(filePath)) {
|
|
526
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
527
|
-
const lines = content.split('\n');
|
|
528
|
-
const targetLine = message.line || 1;
|
|
529
|
-
const contextLines = message.contextLines || 5;
|
|
530
|
-
const start = Math.max(0, targetLine - contextLines - 1);
|
|
531
|
-
const end = Math.min(lines.length, targetLine + contextLines);
|
|
532
|
-
response.data = {
|
|
533
|
-
lines: lines.slice(start, end).map((line, i) => ({
|
|
534
|
-
number: start + i + 1,
|
|
535
|
-
content: line,
|
|
536
|
-
isError: start + i + 1 === targetLine
|
|
537
|
-
})),
|
|
538
|
-
errorLine: targetLine,
|
|
539
|
-
filePath: message.filePath
|
|
540
|
-
};
|
|
541
|
-
}
|
|
542
|
-
else {
|
|
543
|
-
response.error = 'File not found';
|
|
544
|
-
}
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
response.error = e.message;
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
case 'GET_SOURCE':
|
|
666
|
+
try {
|
|
667
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
668
|
+
if (!filePath.startsWith(projectPath)) {
|
|
669
|
+
response.error = 'Access denied';
|
|
545
670
|
}
|
|
546
|
-
|
|
547
|
-
|
|
671
|
+
else if (fs.existsSync(filePath)) {
|
|
672
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
673
|
+
const lines = content.split('\n');
|
|
674
|
+
const start = Math.max(0, (message.lineStart || 1) - 1);
|
|
675
|
+
const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
|
|
676
|
+
response.data = {
|
|
677
|
+
lines: lines.slice(start, end),
|
|
678
|
+
startLine: start + 1,
|
|
679
|
+
endLine: end,
|
|
680
|
+
totalLines: lines.length
|
|
681
|
+
};
|
|
548
682
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
try {
|
|
552
|
-
const depth = message.depth || 3;
|
|
553
|
-
const tree = getFileTree(projectPath, depth);
|
|
554
|
-
response.data = { tree };
|
|
683
|
+
else {
|
|
684
|
+
response.error = 'File not found';
|
|
555
685
|
}
|
|
556
|
-
|
|
557
|
-
|
|
686
|
+
}
|
|
687
|
+
catch (e) {
|
|
688
|
+
response.error = e.message;
|
|
689
|
+
}
|
|
690
|
+
break;
|
|
691
|
+
case 'GET_ERROR_CONTEXT':
|
|
692
|
+
try {
|
|
693
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
694
|
+
if (!filePath.startsWith(projectPath)) {
|
|
695
|
+
response.error = 'Access denied';
|
|
558
696
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
const
|
|
564
|
-
|
|
697
|
+
else if (fs.existsSync(filePath)) {
|
|
698
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
699
|
+
const lines = content.split('\n');
|
|
700
|
+
const targetLine = message.line || 1;
|
|
701
|
+
const contextLines = message.contextLines || 5;
|
|
702
|
+
const start = Math.max(0, targetLine - contextLines - 1);
|
|
703
|
+
const end = Math.min(lines.length, targetLine + contextLines);
|
|
704
|
+
response.data = {
|
|
705
|
+
lines: lines.slice(start, end).map((line, i) => ({
|
|
706
|
+
number: start + i + 1,
|
|
707
|
+
content: line,
|
|
708
|
+
isError: start + i + 1 === targetLine
|
|
709
|
+
})),
|
|
710
|
+
errorLine: targetLine,
|
|
711
|
+
filePath: message.filePath
|
|
712
|
+
};
|
|
565
713
|
}
|
|
566
|
-
|
|
567
|
-
response.error =
|
|
714
|
+
else {
|
|
715
|
+
response.error = 'File not found';
|
|
568
716
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
717
|
+
}
|
|
718
|
+
catch (e) {
|
|
719
|
+
response.error = e.message;
|
|
720
|
+
}
|
|
721
|
+
break;
|
|
722
|
+
case 'GET_FILE_TREE':
|
|
723
|
+
try {
|
|
724
|
+
const depth = message.depth || 3;
|
|
725
|
+
const tree = getFileTree(projectPath, depth);
|
|
726
|
+
response.data = { tree };
|
|
727
|
+
}
|
|
728
|
+
catch (e) {
|
|
729
|
+
response.error = e.message;
|
|
730
|
+
}
|
|
731
|
+
break;
|
|
732
|
+
case 'SEARCH_CODE':
|
|
733
|
+
try {
|
|
734
|
+
const query = message.query;
|
|
735
|
+
const matches = searchInFiles(projectPath, query, 20);
|
|
736
|
+
response.data = { matches };
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
response.error = e.message;
|
|
740
|
+
}
|
|
741
|
+
break;
|
|
742
|
+
case 'FIND_FILES':
|
|
743
|
+
try {
|
|
744
|
+
const pattern = message.pattern || '';
|
|
745
|
+
// Extract the filename from a glob like **/Services.css
|
|
746
|
+
const fileName = pattern.split('/').pop().replace(/\*\*/g, '').replace(/\*/g, '');
|
|
747
|
+
const maxResults = message.maxResults || 20;
|
|
748
|
+
const ignorePatterns = ['node_modules', '.git', 'dist', 'build', '.next', '.cache'];
|
|
749
|
+
const found = [];
|
|
750
|
+
function findFiles(dir) {
|
|
751
|
+
if (found.length >= maxResults)
|
|
752
|
+
return;
|
|
753
|
+
try {
|
|
754
|
+
const items = fs.readdirSync(dir);
|
|
755
|
+
for (const item of items) {
|
|
756
|
+
if (found.length >= maxResults)
|
|
757
|
+
break;
|
|
758
|
+
if (ignorePatterns.includes(item) || item.startsWith('.'))
|
|
759
|
+
continue;
|
|
760
|
+
const fullPath = path.join(dir, item);
|
|
761
|
+
try {
|
|
762
|
+
const stat = fs.statSync(fullPath);
|
|
763
|
+
if (stat.isDirectory()) {
|
|
764
|
+
findFiles(fullPath);
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
// Match by filename (with optional wildcard)
|
|
768
|
+
const matchByName = !fileName || item === fileName ||
|
|
769
|
+
(pattern.includes('*') && item.endsWith(fileName));
|
|
770
|
+
if (matchByName) {
|
|
771
|
+
found.push(path.relative(projectPath, fullPath));
|
|
601
772
|
}
|
|
602
773
|
}
|
|
603
|
-
catch (e) { /* skip */ }
|
|
604
774
|
}
|
|
775
|
+
catch (e) { /* skip */ }
|
|
605
776
|
}
|
|
606
|
-
catch (e) { /* skip */ }
|
|
607
777
|
}
|
|
608
|
-
|
|
609
|
-
response.data = found;
|
|
778
|
+
catch (e) { /* skip */ }
|
|
610
779
|
}
|
|
611
|
-
|
|
612
|
-
|
|
780
|
+
findFiles(projectPath);
|
|
781
|
+
response.data = found;
|
|
782
|
+
}
|
|
783
|
+
catch (e) {
|
|
784
|
+
response.error = e.message;
|
|
785
|
+
}
|
|
786
|
+
// ========== PROJECT DETECTION (Code Export Pipeline) ==========
|
|
787
|
+
case 'DETECT_PROJECT':
|
|
788
|
+
try {
|
|
789
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
790
|
+
let deps = {};
|
|
791
|
+
let devDeps = {};
|
|
792
|
+
let pkgName = '';
|
|
793
|
+
if (fs.existsSync(pkgPath)) {
|
|
794
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
795
|
+
deps = pkg.dependencies || {};
|
|
796
|
+
devDeps = pkg.devDependencies || {};
|
|
797
|
+
pkgName = pkg.name || '';
|
|
613
798
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
799
|
+
const allDeps = { ...deps, ...devDeps };
|
|
800
|
+
// --- Framework detection ---
|
|
801
|
+
let framework = 'html';
|
|
802
|
+
if (allDeps['next'])
|
|
803
|
+
framework = 'next.js';
|
|
804
|
+
else if (allDeps['nuxt'] || allDeps['nuxt3'])
|
|
805
|
+
framework = 'nuxt';
|
|
806
|
+
else if (allDeps['gatsby'])
|
|
807
|
+
framework = 'gatsby';
|
|
808
|
+
else if (allDeps['@remix-run/react'])
|
|
809
|
+
framework = 'remix';
|
|
810
|
+
else if (allDeps['svelte'] || allDeps['@sveltejs/kit'])
|
|
811
|
+
framework = 'svelte';
|
|
812
|
+
else if (allDeps['vue'])
|
|
813
|
+
framework = 'vue';
|
|
814
|
+
else if (allDeps['@angular/core'])
|
|
815
|
+
framework = 'angular';
|
|
816
|
+
else if (allDeps['react'])
|
|
817
|
+
framework = 'react';
|
|
818
|
+
// --- TypeScript detection ---
|
|
819
|
+
const typescript = !!allDeps['typescript'] || fs.existsSync(path.join(projectPath, 'tsconfig.json'));
|
|
820
|
+
const ext = typescript ? '.tsx' : '.jsx';
|
|
821
|
+
// --- Styling detection ---
|
|
822
|
+
let styling = 'plain-css';
|
|
823
|
+
if (allDeps['tailwindcss'])
|
|
824
|
+
styling = 'tailwind';
|
|
825
|
+
else if (allDeps['styled-components'])
|
|
826
|
+
styling = 'styled-components';
|
|
827
|
+
else if (allDeps['@emotion/react'] || allDeps['@emotion/styled'])
|
|
828
|
+
styling = 'emotion';
|
|
829
|
+
else if (allDeps['sass'] || allDeps['node-sass'])
|
|
830
|
+
styling = 'sass';
|
|
831
|
+
// CSS Modules: detected by file presence later
|
|
832
|
+
// --- Router detection (Next.js specific) ---
|
|
833
|
+
let router = null;
|
|
834
|
+
if (framework === 'next.js') {
|
|
835
|
+
if (fs.existsSync(path.join(projectPath, 'src/app')) || fs.existsSync(path.join(projectPath, 'app'))) {
|
|
836
|
+
router = 'app';
|
|
626
837
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
let framework = 'html';
|
|
630
|
-
if (allDeps['next'])
|
|
631
|
-
framework = 'next.js';
|
|
632
|
-
else if (allDeps['nuxt'] || allDeps['nuxt3'])
|
|
633
|
-
framework = 'nuxt';
|
|
634
|
-
else if (allDeps['gatsby'])
|
|
635
|
-
framework = 'gatsby';
|
|
636
|
-
else if (allDeps['@remix-run/react'])
|
|
637
|
-
framework = 'remix';
|
|
638
|
-
else if (allDeps['svelte'] || allDeps['@sveltejs/kit'])
|
|
639
|
-
framework = 'svelte';
|
|
640
|
-
else if (allDeps['vue'])
|
|
641
|
-
framework = 'vue';
|
|
642
|
-
else if (allDeps['@angular/core'])
|
|
643
|
-
framework = 'angular';
|
|
644
|
-
else if (allDeps['react'])
|
|
645
|
-
framework = 'react';
|
|
646
|
-
// --- TypeScript detection ---
|
|
647
|
-
const typescript = !!allDeps['typescript'] || fs.existsSync(path.join(projectPath, 'tsconfig.json'));
|
|
648
|
-
const ext = typescript ? '.tsx' : '.jsx';
|
|
649
|
-
// --- Styling detection ---
|
|
650
|
-
let styling = 'plain-css';
|
|
651
|
-
if (allDeps['tailwindcss'])
|
|
652
|
-
styling = 'tailwind';
|
|
653
|
-
else if (allDeps['styled-components'])
|
|
654
|
-
styling = 'styled-components';
|
|
655
|
-
else if (allDeps['@emotion/react'] || allDeps['@emotion/styled'])
|
|
656
|
-
styling = 'emotion';
|
|
657
|
-
else if (allDeps['sass'] || allDeps['node-sass'])
|
|
658
|
-
styling = 'sass';
|
|
659
|
-
// CSS Modules: detected by file presence later
|
|
660
|
-
// --- Router detection (Next.js specific) ---
|
|
661
|
-
let router = null;
|
|
662
|
-
if (framework === 'next.js') {
|
|
663
|
-
if (fs.existsSync(path.join(projectPath, 'src/app')) || fs.existsSync(path.join(projectPath, 'app'))) {
|
|
664
|
-
router = 'app';
|
|
665
|
-
}
|
|
666
|
-
else if (fs.existsSync(path.join(projectPath, 'src/pages')) || fs.existsSync(path.join(projectPath, 'pages'))) {
|
|
667
|
-
router = 'pages';
|
|
668
|
-
}
|
|
838
|
+
else if (fs.existsSync(path.join(projectPath, 'src/pages')) || fs.existsSync(path.join(projectPath, 'pages'))) {
|
|
839
|
+
router = 'pages';
|
|
669
840
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
return null;
|
|
677
|
-
};
|
|
678
|
-
const layoutFile = findFirst([
|
|
679
|
-
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js',
|
|
680
|
-
'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
|
|
681
|
-
'src/pages/_app.tsx', 'src/pages/_app.jsx', 'pages/_app.tsx', 'pages/_app.jsx',
|
|
682
|
-
'src/App.tsx', 'src/App.jsx', 'src/App.js',
|
|
683
|
-
'src/main.tsx', 'src/main.jsx', 'src/main.js',
|
|
684
|
-
'index.html'
|
|
685
|
-
]);
|
|
686
|
-
const globalStyleFile = findFirst([
|
|
687
|
-
'src/app/globals.css', 'src/app/global.css',
|
|
688
|
-
'app/globals.css', 'app/global.css',
|
|
689
|
-
'src/index.css', 'src/styles/globals.css', 'src/styles/global.css',
|
|
690
|
-
'src/App.css', 'src/styles.css',
|
|
691
|
-
'styles/globals.css', 'styles/global.css',
|
|
692
|
-
'css/style.css', 'css/main.css',
|
|
693
|
-
'style.css', 'styles.css'
|
|
694
|
-
]);
|
|
695
|
-
// Initial guess via disk — overridden after tree scan with real component count
|
|
696
|
-
let componentsDir = findFirst([
|
|
697
|
-
'src/components', 'src/app/components', 'components',
|
|
698
|
-
'src/ui', 'src/app/ui'
|
|
699
|
-
]);
|
|
700
|
-
// --- Tailwind config ---
|
|
701
|
-
let tailwindConfig = null;
|
|
702
|
-
let tailwindVersion = null;
|
|
703
|
-
if (styling === 'tailwind') {
|
|
704
|
-
tailwindConfig = findFirst([
|
|
705
|
-
'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
|
|
706
|
-
]);
|
|
707
|
-
tailwindVersion = allDeps['tailwindcss'] || null;
|
|
841
|
+
}
|
|
842
|
+
// --- Key file location finder ---
|
|
843
|
+
const findFirst = (candidates) => {
|
|
844
|
+
for (const c of candidates) {
|
|
845
|
+
if (fs.existsSync(path.join(projectPath, c)))
|
|
846
|
+
return c;
|
|
708
847
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
848
|
+
return null;
|
|
849
|
+
};
|
|
850
|
+
const layoutFile = findFirst([
|
|
851
|
+
'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js',
|
|
852
|
+
'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
|
|
853
|
+
'src/pages/_app.tsx', 'src/pages/_app.jsx', 'pages/_app.tsx', 'pages/_app.jsx',
|
|
854
|
+
'src/App.tsx', 'src/App.jsx', 'src/App.js',
|
|
855
|
+
'src/main.tsx', 'src/main.jsx', 'src/main.js',
|
|
856
|
+
'index.html'
|
|
857
|
+
]);
|
|
858
|
+
const globalStyleFile = findFirst([
|
|
859
|
+
'src/app/globals.css', 'src/app/global.css',
|
|
860
|
+
'app/globals.css', 'app/global.css',
|
|
861
|
+
'src/index.css', 'src/styles/globals.css', 'src/styles/global.css',
|
|
862
|
+
'src/App.css', 'src/styles.css',
|
|
863
|
+
'styles/globals.css', 'styles/global.css',
|
|
864
|
+
'css/style.css', 'css/main.css',
|
|
865
|
+
'style.css', 'styles.css'
|
|
866
|
+
]);
|
|
867
|
+
// Initial guess via disk — overridden after tree scan with real component count
|
|
868
|
+
let componentsDir = findFirst([
|
|
869
|
+
'src/components', 'src/app/components', 'components',
|
|
870
|
+
'src/ui', 'src/app/ui'
|
|
871
|
+
]);
|
|
872
|
+
// --- Tailwind config ---
|
|
873
|
+
let tailwindConfig = null;
|
|
874
|
+
let tailwindVersion = null;
|
|
875
|
+
if (styling === 'tailwind') {
|
|
876
|
+
tailwindConfig = findFirst([
|
|
877
|
+
'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
|
|
878
|
+
]);
|
|
879
|
+
tailwindVersion = allDeps['tailwindcss'] || null;
|
|
880
|
+
}
|
|
881
|
+
// --- Detect code conventions from existing components ---
|
|
882
|
+
let componentPattern = 'arrow-function';
|
|
883
|
+
let exportPattern = 'default';
|
|
884
|
+
let sampleComponentFile = null;
|
|
885
|
+
let sampleComponentCode = null;
|
|
886
|
+
if (componentsDir) {
|
|
887
|
+
const compDir = path.join(projectPath, componentsDir);
|
|
888
|
+
try {
|
|
889
|
+
const files = fs.readdirSync(compDir).filter(f => f.endsWith('.tsx') || f.endsWith('.jsx') || f.endsWith('.js') || f.endsWith('.vue') || f.endsWith('.svelte'));
|
|
890
|
+
if (files.length > 0) {
|
|
891
|
+
sampleComponentFile = path.join(componentsDir, files[0]);
|
|
892
|
+
const code = fs.readFileSync(path.join(compDir, files[0]), 'utf-8');
|
|
893
|
+
// Only include first 80 lines as a pattern reference
|
|
894
|
+
sampleComponentCode = code.split('\n').slice(0, 80).join('\n');
|
|
895
|
+
// Detect patterns
|
|
896
|
+
if (/^export default function /m.test(code)) {
|
|
897
|
+
componentPattern = 'function-declaration';
|
|
898
|
+
exportPattern = 'default';
|
|
743
899
|
}
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
if (/
|
|
900
|
+
else if (/^export function /m.test(code)) {
|
|
901
|
+
componentPattern = 'function-declaration';
|
|
902
|
+
exportPattern = 'named';
|
|
903
|
+
}
|
|
904
|
+
else if (/const \w+ = \(/.test(code) || /const \w+ = \(\) =>/.test(code)) {
|
|
905
|
+
componentPattern = 'arrow-function';
|
|
906
|
+
}
|
|
907
|
+
if (/^export default /m.test(code))
|
|
908
|
+
exportPattern = 'default';
|
|
909
|
+
else if (/^export \{/m.test(code) || /^export const/m.test(code))
|
|
910
|
+
exportPattern = 'named';
|
|
911
|
+
// CSS Modules detection from component imports
|
|
912
|
+
if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(code)) {
|
|
752
913
|
styling = 'css-modules';
|
|
753
914
|
}
|
|
754
915
|
}
|
|
755
|
-
catch (e) { /* skip */ }
|
|
756
916
|
}
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
917
|
+
catch (e) { /* skip */ }
|
|
918
|
+
}
|
|
919
|
+
// --- Also check layout file for CSS Modules if not yet detected ---
|
|
920
|
+
if (styling === 'plain-css' && layoutFile) {
|
|
921
|
+
try {
|
|
922
|
+
const layoutCode = fs.readFileSync(path.join(projectPath, layoutFile), 'utf-8');
|
|
923
|
+
if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(layoutCode)) {
|
|
924
|
+
styling = 'css-modules';
|
|
763
925
|
}
|
|
764
|
-
catch (e) { /* skip */ }
|
|
765
926
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
927
|
+
catch (e) { /* skip */ }
|
|
928
|
+
}
|
|
929
|
+
// --- Read a snippet of global CSS for context ---
|
|
930
|
+
let globalStylePreview = null;
|
|
931
|
+
if (globalStyleFile) {
|
|
932
|
+
try {
|
|
933
|
+
const css = fs.readFileSync(path.join(projectPath, globalStyleFile), 'utf-8');
|
|
934
|
+
globalStylePreview = css.split('\n').slice(0, 40).join('\n');
|
|
935
|
+
}
|
|
936
|
+
catch (e) { /* skip */ }
|
|
937
|
+
}
|
|
938
|
+
// --- Build filtered scaffolding tree (UI-relevant files only) ---
|
|
939
|
+
const SKIP_DIRS = new Set([
|
|
940
|
+
'node_modules', '.git', '.next', '.nuxt', '.svelte-kit',
|
|
941
|
+
'dist', 'build', '.cache', '.turbo', 'coverage',
|
|
942
|
+
'__pycache__', '.vercel', '.output', '.parcel-cache'
|
|
943
|
+
]);
|
|
944
|
+
const UI_EXTENSIONS = new Set([
|
|
945
|
+
'.tsx', '.jsx', '.js', '.ts', '.vue', '.svelte',
|
|
946
|
+
'.css', '.scss', '.sass', '.less', '.module.css', '.module.scss',
|
|
947
|
+
'.html', '.astro'
|
|
948
|
+
]);
|
|
949
|
+
const treeLines = [];
|
|
950
|
+
const MAX_TREE_LINES = 120;
|
|
951
|
+
// Track dir → count of Pascal-case component files for smart detection
|
|
952
|
+
const dirComponentCount = new Map();
|
|
953
|
+
const buildTree = (dir, prefix, depth) => {
|
|
954
|
+
if (depth > 4 || treeLines.length >= MAX_TREE_LINES)
|
|
955
|
+
return;
|
|
956
|
+
try {
|
|
957
|
+
const entries = fs.readdirSync(path.join(projectPath, dir), { withFileTypes: true });
|
|
958
|
+
// Sort: directories first, then files
|
|
959
|
+
const sorted = entries
|
|
960
|
+
.filter(e => !e.name.startsWith('.') || e.name === '.env')
|
|
961
|
+
.sort((a, b) => {
|
|
962
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
963
|
+
return -1;
|
|
964
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
965
|
+
return 1;
|
|
966
|
+
return a.name.localeCompare(b.name);
|
|
967
|
+
});
|
|
968
|
+
for (let i = 0; i < sorted.length && treeLines.length < MAX_TREE_LINES; i++) {
|
|
969
|
+
const entry = sorted[i];
|
|
970
|
+
const isLast = i === sorted.length - 1;
|
|
971
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
972
|
+
const childPrefix = isLast ? ' ' : '│ ';
|
|
973
|
+
const childPath = dir ? `${dir}/${entry.name}` : entry.name;
|
|
974
|
+
if (entry.isDirectory()) {
|
|
975
|
+
if (SKIP_DIRS.has(entry.name))
|
|
976
|
+
continue;
|
|
977
|
+
treeLines.push(`${prefix}${connector}${entry.name}/`);
|
|
978
|
+
buildTree(childPath, prefix + childPrefix, depth + 1);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
982
|
+
// Count Pascal-case component files per dir (Footer.tsx, Button.tsx, etc.)
|
|
983
|
+
const COMPONENT_EXTS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
|
|
984
|
+
if (COMPONENT_EXTS.has(ext) && /^[A-Z]/.test(entry.name)) {
|
|
985
|
+
dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
|
|
807
986
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
|
|
814
|
-
}
|
|
815
|
-
// Include UI-relevant files + config files at root
|
|
816
|
-
if (UI_EXTENSIONS.has(ext) || (depth <= 1 && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
|
|
817
|
-
entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
|
|
818
|
-
entry.name.startsWith('vite.config')))) {
|
|
819
|
-
treeLines.push(`${prefix}${connector}${entry.name}`);
|
|
820
|
-
}
|
|
987
|
+
// Include UI-relevant files + config files at root
|
|
988
|
+
if (UI_EXTENSIONS.has(ext) || (depth <= 1 && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
|
|
989
|
+
entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
|
|
990
|
+
entry.name.startsWith('vite.config')))) {
|
|
991
|
+
treeLines.push(`${prefix}${connector}${entry.name}`);
|
|
821
992
|
}
|
|
822
993
|
}
|
|
823
994
|
}
|
|
824
|
-
catch (e) { /* permission error, skip */ }
|
|
825
|
-
};
|
|
826
|
-
// Only scan UI-relevant root dirs (not the entire project)
|
|
827
|
-
const scanRoots = ['src', 'app', 'pages', 'components', 'styles', 'public', 'lib', 'utils'];
|
|
828
|
-
const existingRoots = [];
|
|
829
|
-
for (const root of scanRoots) {
|
|
830
|
-
if (fs.existsSync(path.join(projectPath, root))) {
|
|
831
|
-
existingRoots.push(root);
|
|
832
|
-
}
|
|
833
995
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
996
|
+
catch (e) { /* permission error, skip */ }
|
|
997
|
+
};
|
|
998
|
+
// Only scan UI-relevant root dirs (not the entire project)
|
|
999
|
+
const scanRoots = ['src', 'app', 'pages', 'components', 'styles', 'public', 'lib', 'utils'];
|
|
1000
|
+
const existingRoots = [];
|
|
1001
|
+
for (const root of scanRoots) {
|
|
1002
|
+
if (fs.existsSync(path.join(projectPath, root))) {
|
|
1003
|
+
existingRoots.push(root);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
// Also add root-level config files
|
|
1007
|
+
try {
|
|
1008
|
+
const rootEntries = fs.readdirSync(projectPath, { withFileTypes: true });
|
|
1009
|
+
for (const entry of rootEntries) {
|
|
1010
|
+
if (!entry.isDirectory() && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
|
|
1011
|
+
entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
|
|
1012
|
+
entry.name.startsWith('vite.config') || entry.name === 'index.html')) {
|
|
1013
|
+
treeLines.push(entry.name);
|
|
843
1014
|
}
|
|
844
1015
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1016
|
+
}
|
|
1017
|
+
catch (e) { /* skip */ }
|
|
1018
|
+
// Build tree for each UI root
|
|
1019
|
+
for (const root of existingRoots) {
|
|
1020
|
+
treeLines.push(`${root}/`);
|
|
1021
|
+
buildTree(root, '', 1);
|
|
1022
|
+
}
|
|
1023
|
+
const projectTree = treeLines.length > 0 ? treeLines.join('\n') : null;
|
|
1024
|
+
// --- Override componentsDir with real scan result ---
|
|
1025
|
+
// Pick the dir with the most Pascal-case component files.
|
|
1026
|
+
// This beats any hardcoded list: works for src/features, src/modules,
|
|
1027
|
+
// src/views, src/app/components — whatever the project uses.
|
|
1028
|
+
if (dirComponentCount.size > 0) {
|
|
1029
|
+
let bestDir = '';
|
|
1030
|
+
let bestCount = 0;
|
|
1031
|
+
for (const [dir, count] of dirComponentCount.entries()) {
|
|
1032
|
+
if (count > bestCount) {
|
|
1033
|
+
bestCount = count;
|
|
1034
|
+
bestDir = dir;
|
|
1035
|
+
}
|
|
850
1036
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1037
|
+
if (bestCount > 0)
|
|
1038
|
+
componentsDir = bestDir;
|
|
1039
|
+
}
|
|
1040
|
+
response.data = {
|
|
1041
|
+
framework,
|
|
1042
|
+
typescript,
|
|
1043
|
+
styling,
|
|
1044
|
+
router,
|
|
1045
|
+
ext,
|
|
1046
|
+
pkgName,
|
|
1047
|
+
// Key file locations
|
|
1048
|
+
layoutFile,
|
|
1049
|
+
globalStyleFile,
|
|
1050
|
+
componentsDir,
|
|
1051
|
+
tailwindConfig,
|
|
1052
|
+
tailwindVersion,
|
|
1053
|
+
// Code conventions
|
|
1054
|
+
componentPattern,
|
|
1055
|
+
exportPattern,
|
|
1056
|
+
sampleComponentFile,
|
|
1057
|
+
sampleComponentCode,
|
|
1058
|
+
// CSS context
|
|
1059
|
+
globalStylePreview,
|
|
1060
|
+
// Scaffolding tree
|
|
1061
|
+
projectTree,
|
|
1062
|
+
};
|
|
1063
|
+
console.log(chalk.blue('ℹ') + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(' (TS)') : ''}${router ? chalk.dim(` [${router} router]`) : ''}`);
|
|
1064
|
+
}
|
|
1065
|
+
catch (e) {
|
|
1066
|
+
response.error = e.message;
|
|
1067
|
+
}
|
|
1068
|
+
break;
|
|
1069
|
+
// ========== FILE WRITING (UI Design Export) ==========
|
|
1070
|
+
// --- Undo Stack ---
|
|
1071
|
+
// Snapshots file contents before every write/append/edit.
|
|
1072
|
+
// UNDO_LAST pops the most recent snapshot and restores it.
|
|
1073
|
+
// Max 20 entries to avoid unbounded memory growth.
|
|
1074
|
+
case 'WRITE_FILE':
|
|
1075
|
+
try {
|
|
1076
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1077
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1078
|
+
response.error = 'Access denied: Path outside project';
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
// Snapshot before write
|
|
1082
|
+
const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
|
|
1083
|
+
undoStack.push({ filePath, prevContent, operation: 'WRITE_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
1084
|
+
if (undoStack.length > 20)
|
|
1085
|
+
undoStack.shift();
|
|
1086
|
+
// --- CSS safety: validate balanced braces ---
|
|
1087
|
+
if (filePath.endsWith('.css')) {
|
|
1088
|
+
const writeContent = message.content || '';
|
|
1089
|
+
const opens = (writeContent.match(/\{/g) || []).length;
|
|
1090
|
+
const closes = (writeContent.match(/\}/g) || []).length;
|
|
1091
|
+
if (opens !== closes) {
|
|
1092
|
+
// Remove snapshot since we're rejecting the write
|
|
1093
|
+
undoStack.pop();
|
|
1094
|
+
response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before writing.`;
|
|
1095
|
+
break;
|
|
864
1096
|
}
|
|
865
|
-
if (bestCount > 0)
|
|
866
|
-
componentsDir = bestDir;
|
|
867
1097
|
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
globalStyleFile,
|
|
878
|
-
componentsDir,
|
|
879
|
-
tailwindConfig,
|
|
880
|
-
tailwindVersion,
|
|
881
|
-
// Code conventions
|
|
882
|
-
componentPattern,
|
|
883
|
-
exportPattern,
|
|
884
|
-
sampleComponentFile,
|
|
885
|
-
sampleComponentCode,
|
|
886
|
-
// CSS context
|
|
887
|
-
globalStylePreview,
|
|
888
|
-
// Scaffolding tree
|
|
889
|
-
projectTree,
|
|
890
|
-
};
|
|
891
|
-
console.log(chalk.blue('ℹ') + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(' (TS)') : ''}${router ? chalk.dim(` [${router} router]`) : ''}`);
|
|
1098
|
+
const dirPath = path.dirname(filePath);
|
|
1099
|
+
if (!fs.existsSync(dirPath)) {
|
|
1100
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
1101
|
+
}
|
|
1102
|
+
fs.writeFileSync(filePath, message.content, 'utf-8');
|
|
1103
|
+
// Auto-format after write
|
|
1104
|
+
const formatter = await autoFormat(filePath, projectPath);
|
|
1105
|
+
response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
|
|
1106
|
+
console.log(chalk.blue('ℹ') + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
|
|
892
1107
|
}
|
|
893
|
-
|
|
894
|
-
|
|
1108
|
+
}
|
|
1109
|
+
catch (e) {
|
|
1110
|
+
response.error = e.message;
|
|
1111
|
+
}
|
|
1112
|
+
break;
|
|
1113
|
+
case 'APPEND_FILE':
|
|
1114
|
+
try {
|
|
1115
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1116
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1117
|
+
response.error = 'Access denied: Path outside project';
|
|
895
1118
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1119
|
+
else {
|
|
1120
|
+
const isCssFile = filePath.endsWith('.css');
|
|
1121
|
+
const newContent = message.content || '';
|
|
1122
|
+
// --- CSS safety: validate balanced braces ---
|
|
1123
|
+
if (isCssFile) {
|
|
1124
|
+
const opens = (newContent.match(/\{/g) || []).length;
|
|
1125
|
+
const closes = (newContent.match(/\}/g) || []).length;
|
|
1126
|
+
if (opens !== closes) {
|
|
1127
|
+
response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before appending.`;
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
907
1130
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1131
|
+
// Snapshot before append
|
|
1132
|
+
const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
|
|
1133
|
+
undoStack.push({ filePath, prevContent, operation: 'APPEND_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
1134
|
+
if (undoStack.length > 20)
|
|
1135
|
+
undoStack.shift();
|
|
1136
|
+
// --- CSS safety: hoist @import/@charset to file top ---
|
|
1137
|
+
if (isCssFile && prevContent !== null) {
|
|
1138
|
+
// Extract @import and @charset from the NEW content
|
|
1139
|
+
const importRegex = /^@(?:import|charset)\s+[^;]+;\s*$/gm;
|
|
1140
|
+
const newImports = [];
|
|
1141
|
+
const bodyContent = newContent.replace(importRegex, (match) => {
|
|
1142
|
+
newImports.push(match.trim());
|
|
1143
|
+
return '';
|
|
1144
|
+
}).trim();
|
|
1145
|
+
if (newImports.length > 0) {
|
|
1146
|
+
// Find where existing imports end in the original file
|
|
1147
|
+
const existingLines = prevContent.split('\n');
|
|
1148
|
+
let lastImportLineIdx = -1;
|
|
1149
|
+
for (let i = 0; i < existingLines.length; i++) {
|
|
1150
|
+
const trimmed = existingLines[i].trim();
|
|
1151
|
+
if (trimmed.startsWith('@import') || trimmed.startsWith('@charset')) {
|
|
1152
|
+
lastImportLineIdx = i;
|
|
1153
|
+
}
|
|
1154
|
+
// Stop scanning after first non-import, non-comment, non-empty line
|
|
1155
|
+
if (trimmed && !trimmed.startsWith('@import') && !trimmed.startsWith('@charset') && !trimmed.startsWith('/*') && !trimmed.startsWith('*') && !trimmed.startsWith('//')) {
|
|
1156
|
+
break;
|
|
1157
|
+
}
|
|
924
1158
|
}
|
|
1159
|
+
// Deduplicate: only add imports that don't already exist
|
|
1160
|
+
const dedupedImports = newImports.filter(imp => !prevContent.includes(imp));
|
|
1161
|
+
// Build new file: existing imports + new imports + rest of existing + new body
|
|
1162
|
+
const insertIdx = lastImportLineIdx + 1;
|
|
1163
|
+
const topLines = existingLines.slice(0, insertIdx);
|
|
1164
|
+
const restLines = existingLines.slice(insertIdx);
|
|
1165
|
+
const merged = [
|
|
1166
|
+
...topLines,
|
|
1167
|
+
...dedupedImports,
|
|
1168
|
+
...restLines,
|
|
1169
|
+
'', // separator
|
|
1170
|
+
bodyContent
|
|
1171
|
+
].join('\n');
|
|
1172
|
+
fs.writeFileSync(filePath, merged, 'utf-8');
|
|
1173
|
+
console.log(chalk.blue('ℹ') + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
|
|
925
1174
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
fs.
|
|
1175
|
+
else {
|
|
1176
|
+
// No imports in new content — simple append
|
|
1177
|
+
fs.appendFileSync(filePath, '\n\n' + newContent, 'utf-8');
|
|
929
1178
|
}
|
|
930
|
-
fs.writeFileSync(filePath, message.content, 'utf-8');
|
|
931
|
-
// Auto-format after write
|
|
932
|
-
const formatter = await autoFormat(filePath, projectPath);
|
|
933
|
-
response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
|
|
934
|
-
console.log(chalk.blue('ℹ') + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
|
|
935
1179
|
}
|
|
1180
|
+
else {
|
|
1181
|
+
// Non-CSS file or new file — simple append
|
|
1182
|
+
fs.appendFileSync(filePath, '\n' + newContent, 'utf-8');
|
|
1183
|
+
}
|
|
1184
|
+
// Auto-format after append
|
|
1185
|
+
const formatter = await autoFormat(filePath, projectPath);
|
|
1186
|
+
response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
|
|
1187
|
+
console.log(chalk.blue('ℹ') + ` Appended file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
|
|
936
1188
|
}
|
|
937
|
-
|
|
938
|
-
|
|
1189
|
+
}
|
|
1190
|
+
catch (e) {
|
|
1191
|
+
response.error = e.message;
|
|
1192
|
+
}
|
|
1193
|
+
break;
|
|
1194
|
+
case 'EDIT_FILE':
|
|
1195
|
+
try {
|
|
1196
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1197
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1198
|
+
response.error = 'Access denied: Path outside project';
|
|
939
1199
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
// Snapshot before append
|
|
960
|
-
const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
|
|
961
|
-
undoStack.push({ filePath, prevContent, operation: 'APPEND_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
962
|
-
if (undoStack.length > 20)
|
|
963
|
-
undoStack.shift();
|
|
964
|
-
// --- CSS safety: hoist @import/@charset to file top ---
|
|
965
|
-
if (isCssFile && prevContent !== null) {
|
|
966
|
-
// Extract @import and @charset from the NEW content
|
|
967
|
-
const importRegex = /^@(?:import|charset)\s+[^;]+;\s*$/gm;
|
|
968
|
-
const newImports = [];
|
|
969
|
-
const bodyContent = newContent.replace(importRegex, (match) => {
|
|
970
|
-
newImports.push(match.trim());
|
|
971
|
-
return '';
|
|
972
|
-
}).trim();
|
|
973
|
-
if (newImports.length > 0) {
|
|
974
|
-
// Find where existing imports end in the original file
|
|
975
|
-
const existingLines = prevContent.split('\n');
|
|
976
|
-
let lastImportLineIdx = -1;
|
|
977
|
-
for (let i = 0; i < existingLines.length; i++) {
|
|
978
|
-
const trimmed = existingLines[i].trim();
|
|
979
|
-
if (trimmed.startsWith('@import') || trimmed.startsWith('@charset')) {
|
|
980
|
-
lastImportLineIdx = i;
|
|
981
|
-
}
|
|
982
|
-
// Stop scanning after first non-import, non-comment, non-empty line
|
|
983
|
-
if (trimmed && !trimmed.startsWith('@import') && !trimmed.startsWith('@charset') && !trimmed.startsWith('/*') && !trimmed.startsWith('*') && !trimmed.startsWith('//')) {
|
|
984
|
-
break;
|
|
1200
|
+
else if (!fs.existsSync(filePath)) {
|
|
1201
|
+
// Find similarly-named files to help the LLM self-correct
|
|
1202
|
+
const requestedBase = path.basename(message.filePath);
|
|
1203
|
+
const requestedExt = path.extname(requestedBase);
|
|
1204
|
+
const requestedStem = requestedBase.replace(requestedExt, '');
|
|
1205
|
+
// Walk shallow project tree to find candidates
|
|
1206
|
+
const suggestions = [];
|
|
1207
|
+
const dirsToSearch = ['src/app', 'app', 'src/pages', 'pages', 'src/components', 'components', 'src'];
|
|
1208
|
+
for (const dir of dirsToSearch) {
|
|
1209
|
+
const absDir = path.join(projectPath, dir);
|
|
1210
|
+
if (fs.existsSync(absDir)) {
|
|
1211
|
+
try {
|
|
1212
|
+
const files = fs.readdirSync(absDir, { recursive: true });
|
|
1213
|
+
for (const f of files.slice(0, 100)) {
|
|
1214
|
+
const fBase = path.basename(f);
|
|
1215
|
+
// Same stem, any extension (page.tsx → page.js, page.jsx)
|
|
1216
|
+
if (fBase.startsWith(requestedStem + '.') ||
|
|
1217
|
+
fBase === requestedBase) {
|
|
1218
|
+
suggestions.push(path.join(dir, f).replace(/\\/g, '/'));
|
|
985
1219
|
}
|
|
986
1220
|
}
|
|
987
|
-
// Deduplicate: only add imports that don't already exist
|
|
988
|
-
const dedupedImports = newImports.filter(imp => !prevContent.includes(imp));
|
|
989
|
-
// Build new file: existing imports + new imports + rest of existing + new body
|
|
990
|
-
const insertIdx = lastImportLineIdx + 1;
|
|
991
|
-
const topLines = existingLines.slice(0, insertIdx);
|
|
992
|
-
const restLines = existingLines.slice(insertIdx);
|
|
993
|
-
const merged = [
|
|
994
|
-
...topLines,
|
|
995
|
-
...dedupedImports,
|
|
996
|
-
...restLines,
|
|
997
|
-
'', // separator
|
|
998
|
-
bodyContent
|
|
999
|
-
].join('\n');
|
|
1000
|
-
fs.writeFileSync(filePath, merged, 'utf-8');
|
|
1001
|
-
console.log(chalk.blue('ℹ') + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
|
|
1002
|
-
}
|
|
1003
|
-
else {
|
|
1004
|
-
// No imports in new content — simple append
|
|
1005
|
-
fs.appendFileSync(filePath, '\n\n' + newContent, 'utf-8');
|
|
1006
1221
|
}
|
|
1222
|
+
catch (e) { /* skip unreadable dirs */ }
|
|
1007
1223
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1224
|
+
}
|
|
1225
|
+
const hint = suggestions.length > 0
|
|
1226
|
+
? ' Did you mean: ' + suggestions.slice(0, 3).join(' or ') + '?'
|
|
1227
|
+
: ' Double-check the path against detect_project() output (layoutFile, globalStyleFile) or projectTree.';
|
|
1228
|
+
response.error = 'File not found: "' + message.filePath + '".' + hint;
|
|
1229
|
+
}
|
|
1230
|
+
else {
|
|
1231
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1232
|
+
// Snapshot before edit
|
|
1233
|
+
undoStack.push({ filePath, prevContent: content, operation: 'EDIT_FILE', timestamp: Date.now(), relativePath: message.filePath });
|
|
1234
|
+
if (undoStack.length > 20)
|
|
1235
|
+
undoStack.shift();
|
|
1236
|
+
// Use the 9-layer fuzzy replacer cascade (ported from OpenCode)
|
|
1237
|
+
const result = fuzzyReplace(content, message.target, message.replacement, message.replaceAll || false);
|
|
1238
|
+
if ('error' in result) {
|
|
1239
|
+
// Edit failed — remove the snapshot we just pushed
|
|
1240
|
+
undoStack.pop();
|
|
1241
|
+
response.error = result.error;
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
fs.writeFileSync(filePath, result.result, 'utf-8');
|
|
1245
|
+
// Auto-format after edit
|
|
1013
1246
|
const formatter = await autoFormat(filePath, projectPath);
|
|
1014
|
-
response.data = {
|
|
1015
|
-
|
|
1247
|
+
response.data = {
|
|
1248
|
+
success: true,
|
|
1249
|
+
path: filePath,
|
|
1250
|
+
strategy: result.strategy,
|
|
1251
|
+
formatted: formatter,
|
|
1252
|
+
undoAvailable: true,
|
|
1253
|
+
};
|
|
1254
|
+
const strategyLabel = result.strategy === 'exact' ? '' : chalk.dim(` [${result.strategy}]`);
|
|
1255
|
+
const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
|
|
1256
|
+
console.log(chalk.blue('ℹ') + ` Edited file: ${message.filePath}${strategyLabel}${formatLabel}` + chalk.dim(' [undo saved]'));
|
|
1016
1257
|
}
|
|
1017
1258
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1259
|
+
}
|
|
1260
|
+
catch (e) {
|
|
1261
|
+
response.error = e.message;
|
|
1262
|
+
}
|
|
1263
|
+
break;
|
|
1264
|
+
case 'REPLACE_LINES':
|
|
1265
|
+
try {
|
|
1266
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1267
|
+
if (!filePath.startsWith(projectPath)) {
|
|
1268
|
+
response.error = 'Access denied: Path outside project';
|
|
1269
|
+
}
|
|
1270
|
+
else if (!fs.existsSync(filePath)) {
|
|
1271
|
+
response.error = 'File not found: ' + message.filePath;
|
|
1020
1272
|
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1273
|
+
else {
|
|
1274
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1275
|
+
const lines = content.split('\n');
|
|
1276
|
+
const startLine = Math.max(1, message.startLine || 1);
|
|
1277
|
+
const endLine = Math.min(lines.length, message.endLine || startLine);
|
|
1278
|
+
if (startLine > lines.length) {
|
|
1279
|
+
response.error = `Start line ${startLine} is beyond file end (${lines.length} lines)`;
|
|
1027
1280
|
}
|
|
1028
|
-
else if (
|
|
1029
|
-
response.error =
|
|
1281
|
+
else if (startLine > endLine) {
|
|
1282
|
+
response.error = `Invalid range: start (${startLine}) > end (${endLine})`;
|
|
1030
1283
|
}
|
|
1031
1284
|
else {
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1285
|
+
// Snapshot for undo
|
|
1286
|
+
undoStack.push({
|
|
1287
|
+
filePath,
|
|
1288
|
+
prevContent: content,
|
|
1289
|
+
operation: 'REPLACE_LINES',
|
|
1290
|
+
timestamp: Date.now(),
|
|
1291
|
+
relativePath: message.filePath
|
|
1292
|
+
});
|
|
1035
1293
|
if (undoStack.length > 20)
|
|
1036
1294
|
undoStack.shift();
|
|
1037
|
-
|
|
1038
|
-
const
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1295
|
+
const oldLineCount = endLine - startLine + 1;
|
|
1296
|
+
const newLines = (message.newContent || '').split('\n');
|
|
1297
|
+
// splice: remove old range, insert new lines
|
|
1298
|
+
lines.splice(startLine - 1, oldLineCount, ...newLines);
|
|
1299
|
+
const newContent = lines.join('\n');
|
|
1300
|
+
fs.writeFileSync(filePath, newContent, 'utf-8');
|
|
1301
|
+
// Auto-format
|
|
1302
|
+
const formatter = await autoFormat(filePath, projectPath);
|
|
1303
|
+
const lineDelta = newLines.length - oldLineCount;
|
|
1304
|
+
response.data = {
|
|
1305
|
+
success: true,
|
|
1306
|
+
path: filePath,
|
|
1307
|
+
linesReplaced: `${startLine}-${endLine}`,
|
|
1308
|
+
oldLineCount,
|
|
1309
|
+
newLineCount: newLines.length,
|
|
1310
|
+
lineDelta: lineDelta,
|
|
1311
|
+
totalLines: lines.length,
|
|
1312
|
+
formatted: formatter,
|
|
1313
|
+
undoAvailable: true,
|
|
1314
|
+
hint: lineDelta !== 0
|
|
1315
|
+
? `Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. Adjust subsequent line numbers if making more edits.`
|
|
1316
|
+
: null
|
|
1317
|
+
};
|
|
1318
|
+
const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
|
|
1319
|
+
console.log(chalk.blue('ℹ') + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}→${newLines.length} lines)${formatLabel}` + chalk.dim(' [undo saved]'));
|
|
1059
1320
|
}
|
|
1060
1321
|
}
|
|
1061
|
-
|
|
1062
|
-
|
|
1322
|
+
}
|
|
1323
|
+
catch (e) {
|
|
1324
|
+
response.error = e.message;
|
|
1325
|
+
}
|
|
1326
|
+
break;
|
|
1327
|
+
case 'UNDO_LAST':
|
|
1328
|
+
try {
|
|
1329
|
+
if (undoStack.length === 0) {
|
|
1330
|
+
response.error = 'Nothing to undo — no file changes recorded in this session.';
|
|
1063
1331
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
}
|
|
1071
|
-
else if (!fs.existsSync(filePath)) {
|
|
1072
|
-
response.error = 'File not found: ' + message.filePath;
|
|
1073
|
-
}
|
|
1074
|
-
else {
|
|
1075
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1076
|
-
const lines = content.split('\n');
|
|
1077
|
-
const startLine = Math.max(1, message.startLine || 1);
|
|
1078
|
-
const endLine = Math.min(lines.length, message.endLine || startLine);
|
|
1079
|
-
if (startLine > lines.length) {
|
|
1080
|
-
response.error = `Start line ${startLine} is beyond file end (${lines.length} lines)`;
|
|
1081
|
-
}
|
|
1082
|
-
else if (startLine > endLine) {
|
|
1083
|
-
response.error = `Invalid range: start (${startLine}) > end (${endLine})`;
|
|
1084
|
-
}
|
|
1085
|
-
else {
|
|
1086
|
-
// Snapshot for undo
|
|
1087
|
-
undoStack.push({
|
|
1088
|
-
filePath,
|
|
1089
|
-
prevContent: content,
|
|
1090
|
-
operation: 'REPLACE_LINES',
|
|
1091
|
-
timestamp: Date.now(),
|
|
1092
|
-
relativePath: message.filePath
|
|
1093
|
-
});
|
|
1094
|
-
if (undoStack.length > 20)
|
|
1095
|
-
undoStack.shift();
|
|
1096
|
-
const oldLineCount = endLine - startLine + 1;
|
|
1097
|
-
const newLines = (message.newContent || '').split('\n');
|
|
1098
|
-
// splice: remove old range, insert new lines
|
|
1099
|
-
lines.splice(startLine - 1, oldLineCount, ...newLines);
|
|
1100
|
-
const newContent = lines.join('\n');
|
|
1101
|
-
fs.writeFileSync(filePath, newContent, 'utf-8');
|
|
1102
|
-
// Auto-format
|
|
1103
|
-
const formatter = await autoFormat(filePath, projectPath);
|
|
1104
|
-
const lineDelta = newLines.length - oldLineCount;
|
|
1332
|
+
else {
|
|
1333
|
+
const snapshot = undoStack.pop();
|
|
1334
|
+
if (snapshot.prevContent === null) {
|
|
1335
|
+
// File was created from scratch — delete it
|
|
1336
|
+
if (fs.existsSync(snapshot.filePath)) {
|
|
1337
|
+
fs.unlinkSync(snapshot.filePath);
|
|
1105
1338
|
response.data = {
|
|
1106
1339
|
success: true,
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
lineDelta: lineDelta,
|
|
1112
|
-
totalLines: lines.length,
|
|
1113
|
-
formatted: formatter,
|
|
1114
|
-
undoAvailable: true,
|
|
1115
|
-
hint: lineDelta !== 0
|
|
1116
|
-
? `Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. Adjust subsequent line numbers if making more edits.`
|
|
1117
|
-
: null
|
|
1340
|
+
undone: snapshot.operation,
|
|
1341
|
+
file: snapshot.relativePath,
|
|
1342
|
+
action: 'deleted (was new file)',
|
|
1343
|
+
remaining: undoStack.length,
|
|
1118
1344
|
};
|
|
1119
|
-
|
|
1120
|
-
console.log(chalk.blue('ℹ') + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}→${newLines.length} lines)${formatLabel}` + chalk.dim(' [undo saved]'));
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
catch (e) {
|
|
1125
|
-
response.error = e.message;
|
|
1126
|
-
}
|
|
1127
|
-
break;
|
|
1128
|
-
case 'UNDO_LAST':
|
|
1129
|
-
try {
|
|
1130
|
-
if (undoStack.length === 0) {
|
|
1131
|
-
response.error = 'Nothing to undo — no file changes recorded in this session.';
|
|
1132
|
-
}
|
|
1133
|
-
else {
|
|
1134
|
-
const snapshot = undoStack.pop();
|
|
1135
|
-
if (snapshot.prevContent === null) {
|
|
1136
|
-
// File was created from scratch — delete it
|
|
1137
|
-
if (fs.existsSync(snapshot.filePath)) {
|
|
1138
|
-
fs.unlinkSync(snapshot.filePath);
|
|
1139
|
-
response.data = {
|
|
1140
|
-
success: true,
|
|
1141
|
-
undone: snapshot.operation,
|
|
1142
|
-
file: snapshot.relativePath,
|
|
1143
|
-
action: 'deleted (was new file)',
|
|
1144
|
-
remaining: undoStack.length,
|
|
1145
|
-
};
|
|
1146
|
-
console.log(chalk.yellow('↩') + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
|
|
1147
|
-
}
|
|
1148
|
-
else {
|
|
1149
|
-
response.data = {
|
|
1150
|
-
success: true,
|
|
1151
|
-
undone: snapshot.operation,
|
|
1152
|
-
file: snapshot.relativePath,
|
|
1153
|
-
action: 'already gone',
|
|
1154
|
-
remaining: undoStack.length,
|
|
1155
|
-
};
|
|
1156
|
-
}
|
|
1345
|
+
console.log(chalk.yellow('↩') + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
|
|
1157
1346
|
}
|
|
1158
1347
|
else {
|
|
1159
|
-
// Restore previous content
|
|
1160
|
-
fs.writeFileSync(snapshot.filePath, snapshot.prevContent, 'utf-8');
|
|
1161
1348
|
response.data = {
|
|
1162
1349
|
success: true,
|
|
1163
1350
|
undone: snapshot.operation,
|
|
1164
1351
|
file: snapshot.relativePath,
|
|
1165
|
-
action: '
|
|
1352
|
+
action: 'already gone',
|
|
1166
1353
|
remaining: undoStack.length,
|
|
1167
1354
|
};
|
|
1168
|
-
console.log(chalk.yellow('↩') + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
catch (e) {
|
|
1173
|
-
response.error = e.message;
|
|
1174
|
-
}
|
|
1175
|
-
break;
|
|
1176
|
-
// ========== GIT & ENVIRONMENT TOOLS ==========
|
|
1177
|
-
case 'GIT_BLAME':
|
|
1178
|
-
try {
|
|
1179
|
-
const filePath = message.filePath;
|
|
1180
|
-
const line = message.line;
|
|
1181
|
-
const { stdout } = await execAsync(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
|
|
1182
|
-
const lines = stdout.split('\n');
|
|
1183
|
-
const commitHash = lines[0].split(' ')[0];
|
|
1184
|
-
const author = lines.find((l) => l.startsWith('author '))?.substring(7);
|
|
1185
|
-
const email = lines.find((l) => l.startsWith('author-mail '))?.substring(12);
|
|
1186
|
-
const date = lines.find((l) => l.startsWith('author-time '))?.substring(12);
|
|
1187
|
-
const summary = lines.find((l) => l.startsWith('summary '))?.substring(8);
|
|
1188
|
-
response.data = {
|
|
1189
|
-
commit: commitHash,
|
|
1190
|
-
author,
|
|
1191
|
-
email,
|
|
1192
|
-
date: new Date(parseInt(date) * 1000).toISOString().split('T')[0],
|
|
1193
|
-
message: summary
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
catch (e) {
|
|
1197
|
-
response.error = `Git blame failed: ${e.message}`;
|
|
1198
|
-
}
|
|
1199
|
-
break;
|
|
1200
|
-
case 'GIT_RECENT_CHANGES':
|
|
1201
|
-
try {
|
|
1202
|
-
const filePath = message.filePath;
|
|
1203
|
-
const days = message.days || 7;
|
|
1204
|
-
const { stdout } = await execAsync(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
|
|
1205
|
-
response.data = {
|
|
1206
|
-
history: stdout.split('\n').filter(Boolean).map((line) => {
|
|
1207
|
-
const [hash, author, date, message] = line.split('|');
|
|
1208
|
-
return { hash, author, date, message };
|
|
1209
|
-
})
|
|
1210
|
-
};
|
|
1211
|
-
}
|
|
1212
|
-
catch (e) {
|
|
1213
|
-
response.error = `Git log failed: ${e.message}`;
|
|
1214
|
-
}
|
|
1215
|
-
break;
|
|
1216
|
-
case 'GET_IMPORTS':
|
|
1217
|
-
try {
|
|
1218
|
-
const filePath = path.resolve(projectPath, message.filePath);
|
|
1219
|
-
if (fs.existsSync(filePath)) {
|
|
1220
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1221
|
-
const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
|
|
1222
|
-
const imports = [];
|
|
1223
|
-
let match;
|
|
1224
|
-
while ((match = importRegex.exec(content)) !== null) {
|
|
1225
|
-
imports.push(match[1]);
|
|
1226
1355
|
}
|
|
1227
|
-
response.data = { imports };
|
|
1228
1356
|
}
|
|
1229
1357
|
else {
|
|
1230
|
-
|
|
1358
|
+
// Restore previous content
|
|
1359
|
+
fs.writeFileSync(snapshot.filePath, snapshot.prevContent, 'utf-8');
|
|
1360
|
+
response.data = {
|
|
1361
|
+
success: true,
|
|
1362
|
+
undone: snapshot.operation,
|
|
1363
|
+
file: snapshot.relativePath,
|
|
1364
|
+
action: 'restored',
|
|
1365
|
+
remaining: undoStack.length,
|
|
1366
|
+
};
|
|
1367
|
+
console.log(chalk.yellow('↩') + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
|
|
1231
1368
|
}
|
|
1232
1369
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1370
|
+
}
|
|
1371
|
+
catch (e) {
|
|
1372
|
+
response.error = e.message;
|
|
1373
|
+
}
|
|
1374
|
+
break;
|
|
1375
|
+
// ========== GIT & ENVIRONMENT TOOLS ==========
|
|
1376
|
+
case 'GIT_BLAME':
|
|
1377
|
+
try {
|
|
1378
|
+
const filePath = message.filePath;
|
|
1379
|
+
const line = message.line;
|
|
1380
|
+
const { stdout } = await execAsync(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
|
|
1381
|
+
const lines = stdout.split('\n');
|
|
1382
|
+
const commitHash = lines[0].split(' ')[0];
|
|
1383
|
+
const author = lines.find((l) => l.startsWith('author '))?.substring(7);
|
|
1384
|
+
const email = lines.find((l) => l.startsWith('author-mail '))?.substring(12);
|
|
1385
|
+
const date = lines.find((l) => l.startsWith('author-time '))?.substring(12);
|
|
1386
|
+
const summary = lines.find((l) => l.startsWith('summary '))?.substring(8);
|
|
1387
|
+
response.data = {
|
|
1388
|
+
commit: commitHash,
|
|
1389
|
+
author,
|
|
1390
|
+
email,
|
|
1391
|
+
date: new Date(parseInt(date) * 1000).toISOString().split('T')[0],
|
|
1392
|
+
message: summary
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
catch (e) {
|
|
1396
|
+
response.error = `Git blame failed: ${e.message}`;
|
|
1397
|
+
}
|
|
1398
|
+
break;
|
|
1399
|
+
case 'GIT_RECENT_CHANGES':
|
|
1400
|
+
try {
|
|
1401
|
+
const filePath = message.filePath;
|
|
1402
|
+
const days = message.days || 7;
|
|
1403
|
+
const { stdout } = await execAsync(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
|
|
1404
|
+
response.data = {
|
|
1405
|
+
history: stdout.split('\n').filter(Boolean).map((line) => {
|
|
1406
|
+
const [hash, author, date, message] = line.split('|');
|
|
1407
|
+
return { hash, author, date, message };
|
|
1408
|
+
})
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
catch (e) {
|
|
1412
|
+
response.error = `Git log failed: ${e.message}`;
|
|
1413
|
+
}
|
|
1414
|
+
break;
|
|
1415
|
+
case 'GET_IMPORTS':
|
|
1416
|
+
try {
|
|
1417
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1418
|
+
if (fs.existsSync(filePath)) {
|
|
1419
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1420
|
+
const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
|
|
1421
|
+
const imports = [];
|
|
1422
|
+
let match;
|
|
1423
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
1424
|
+
imports.push(match[1]);
|
|
1425
|
+
}
|
|
1426
|
+
response.data = { imports };
|
|
1251
1427
|
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
response.data = { usages: [] };
|
|
1255
|
-
else
|
|
1256
|
-
response.error = `Grep failed: ${e.message}`;
|
|
1428
|
+
else {
|
|
1429
|
+
response.error = 'File not found';
|
|
1257
1430
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1431
|
+
}
|
|
1432
|
+
catch (e) {
|
|
1433
|
+
response.error = e.message;
|
|
1434
|
+
}
|
|
1435
|
+
break;
|
|
1436
|
+
case 'FIND_USAGES':
|
|
1437
|
+
try {
|
|
1438
|
+
const query = message.query;
|
|
1439
|
+
const { stdout } = await execAsync(`git grep -n "${query}"`, { cwd: projectPath });
|
|
1440
|
+
response.data = {
|
|
1441
|
+
usages: stdout.split('\n').filter(Boolean).slice(0, 20).map((line) => {
|
|
1442
|
+
const parts = line.split(':');
|
|
1443
|
+
return {
|
|
1444
|
+
file: parts[0],
|
|
1445
|
+
line: parseInt(parts[1]),
|
|
1446
|
+
content: parts.slice(2).join(':').trim()
|
|
1447
|
+
};
|
|
1448
|
+
})
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
catch (e) {
|
|
1452
|
+
if (e.code === 1)
|
|
1453
|
+
response.data = { usages: [] };
|
|
1454
|
+
else
|
|
1455
|
+
response.error = `Grep failed: ${e.message}`;
|
|
1456
|
+
}
|
|
1457
|
+
break;
|
|
1458
|
+
case 'GET_ENV_VARS':
|
|
1459
|
+
try {
|
|
1460
|
+
const filePath = path.resolve(projectPath, message.filePath);
|
|
1461
|
+
if (fs.existsSync(filePath)) {
|
|
1462
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1463
|
+
const envRegex = /(?:process\.env\.|import\.meta\.env\.)([A-Z_][A-Z0-9_]*)/g;
|
|
1464
|
+
const vars = new Set();
|
|
1465
|
+
let match;
|
|
1466
|
+
while ((match = envRegex.exec(content)) !== null) {
|
|
1467
|
+
vars.add(match[1]);
|
|
1274
1468
|
}
|
|
1469
|
+
response.data = { envVars: Array.from(vars) };
|
|
1275
1470
|
}
|
|
1276
|
-
|
|
1277
|
-
response.error =
|
|
1471
|
+
else {
|
|
1472
|
+
response.error = 'File not found';
|
|
1278
1473
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
response.error =
|
|
1474
|
+
}
|
|
1475
|
+
catch (e) {
|
|
1476
|
+
response.error = e.message;
|
|
1477
|
+
}
|
|
1478
|
+
break;
|
|
1479
|
+
// ========== TERMINAL BUFFER ACCESS ==========
|
|
1480
|
+
case 'GET_TERMINAL_BUFFER': {
|
|
1481
|
+
// Returns the last N lines of the dev server terminal output.
|
|
1482
|
+
// Agents use this to get compiler errors without running a build.
|
|
1483
|
+
const lines = parseInt(message.lines) || 100;
|
|
1484
|
+
const buffer = globalTerminalBuffer.getLast(lines);
|
|
1485
|
+
response.data = {
|
|
1486
|
+
lines: buffer,
|
|
1487
|
+
totalLines: globalTerminalBuffer.length,
|
|
1488
|
+
hasDevProcess: devProcess !== null && !devProcess.killed,
|
|
1489
|
+
};
|
|
1490
|
+
break;
|
|
1282
1491
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1492
|
+
case 'GET_DEV_STATUS': {
|
|
1493
|
+
response.data = {
|
|
1494
|
+
running: devProcess !== null && !devProcess.killed,
|
|
1495
|
+
pid: devProcess?.pid ?? null,
|
|
1496
|
+
};
|
|
1497
|
+
break;
|
|
1498
|
+
}
|
|
1499
|
+
default:
|
|
1500
|
+
response.error = `Unknown message type: ${type}`;
|
|
1287
1501
|
}
|
|
1288
|
-
|
|
1502
|
+
ws.send(JSON.stringify(response));
|
|
1503
|
+
}
|
|
1504
|
+
catch (e) {
|
|
1505
|
+
console.error(chalk.red('Parse error:'), e.message);
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
// ============================================
|
|
1510
|
+
// COMMAND: trace connect (legacy / IDE-only)
|
|
1511
|
+
// ============================================
|
|
1512
|
+
program
|
|
1513
|
+
.command('connect', { isDefault: true })
|
|
1514
|
+
.alias('c')
|
|
1515
|
+
.description('Start IDE bridge only (use "trace dev" to also start your dev server)')
|
|
1516
|
+
.option('-p, --port <port>', 'WebSocket port', '8765')
|
|
1517
|
+
.action(async (options) => {
|
|
1518
|
+
const port = parseInt(options.port);
|
|
1519
|
+
const projectPath = process.cwd();
|
|
1520
|
+
console.log();
|
|
1521
|
+
console.log(chalk.bold.cyan('🔗 Trace IDE Bridge'));
|
|
1522
|
+
console.log(chalk.gray('─'.repeat(55)));
|
|
1523
|
+
console.log();
|
|
1524
|
+
console.log(`Project: ${chalk.green(projectPath)}`);
|
|
1525
|
+
console.log(`Port: ${chalk.cyan(port)}`);
|
|
1526
|
+
console.log();
|
|
1527
|
+
try {
|
|
1528
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
1529
|
+
if (fs.existsSync(pkgPath)) {
|
|
1530
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
1531
|
+
console.log(`📦 Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
catch (_) { }
|
|
1535
|
+
const wss = new WebSocketServer({ port });
|
|
1536
|
+
let clientCount = 0;
|
|
1537
|
+
console.log();
|
|
1538
|
+
console.log(chalk.green('✓') + ' WebSocket server started');
|
|
1539
|
+
console.log(chalk.dim('Waiting for extension to connect...'));
|
|
1540
|
+
console.log();
|
|
1541
|
+
console.log(chalk.gray('─'.repeat(55)));
|
|
1542
|
+
console.log(chalk.dim('Press Ctrl+C to stop'));
|
|
1543
|
+
console.log();
|
|
1544
|
+
wss.on('connection', (ws) => {
|
|
1545
|
+
clientCount++;
|
|
1546
|
+
console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
|
|
1547
|
+
// Wire up the full IDE bridge protocol
|
|
1548
|
+
attachMessageHandler(ws, projectPath);
|
|
1289
1549
|
ws.on('close', () => {
|
|
1290
1550
|
clientCount--;
|
|
1291
1551
|
console.log(chalk.yellow('●') + ` Extension disconnected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
|