gopeak 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +224 -75
- package/build/addon/godot_mcp_editor/mcp_client.gd +178 -0
- package/build/addon/godot_mcp_editor/plugin.cfg +6 -0
- package/build/addon/godot_mcp_editor/plugin.gd +84 -0
- package/build/addon/godot_mcp_editor/tool_executor.gd +114 -0
- package/build/addon/godot_mcp_editor/tools/animation_tools.gd +502 -0
- package/build/addon/godot_mcp_editor/tools/resource_tools.gd +425 -0
- package/build/addon/godot_mcp_editor/tools/scene_tools.gd +710 -0
- package/build/cli/check.js +77 -0
- package/build/cli/notify.js +88 -0
- package/build/cli/setup.js +115 -0
- package/build/cli/star.js +51 -0
- package/build/cli/uninstall.js +26 -0
- package/build/cli/utils.js +149 -0
- package/build/cli.js +91 -0
- package/build/gdscript_parser.js +828 -0
- package/build/godot-bridge.js +556 -0
- package/build/index.js +2761 -2064
- package/build/prompts.js +163 -0
- package/build/visualizer/canvas.js +832 -0
- package/build/visualizer/events.js +814 -0
- package/build/visualizer/layout.js +304 -0
- package/build/visualizer/main.js +245 -0
- package/build/visualizer/modals.js +239 -0
- package/build/visualizer/panel.js +1091 -0
- package/build/visualizer/state.js +210 -0
- package/build/visualizer/syntax.js +106 -0
- package/build/visualizer/usages.js +352 -0
- package/build/visualizer/websocket.js +85 -0
- package/build/visualizer-server.js +375 -0
- package/build/visualizer.html +6395 -0
- package/package.json +15 -6
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GDScript Project Parser
|
|
3
|
+
*
|
|
4
|
+
* TypeScript port of visualizer_tools.gd from tomyud1/godot-mcp.
|
|
5
|
+
* Crawls a Godot project directory and parses all GDScript files to build
|
|
6
|
+
* a structural project map for the interactive visualizer.
|
|
7
|
+
*
|
|
8
|
+
* Key difference from the original: this runs on the filesystem directly
|
|
9
|
+
* (Node.js fs) instead of using Godot's DirAccess/FileAccess APIs.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, readdirSync, statSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import { join, basename, dirname, relative } from 'path';
|
|
14
|
+
// Dynamic color palette — assigned to categories as they are discovered.
|
|
15
|
+
const CATEGORY_PALETTE = [
|
|
16
|
+
'#f38ba8', '#fab387', '#89dceb', '#a6e3a1', '#cba6f7',
|
|
17
|
+
'#f9e2af', '#94e2d5', '#7aa2f7', '#89b4fa', '#eba0ac',
|
|
18
|
+
'#b4befe', '#74c7ec', '#f5c2e7', '#a6adc8', '#f2cdcd',
|
|
19
|
+
'#cdd6f4', '#bac2de', '#94e2d5', '#fab387', '#89dceb',
|
|
20
|
+
];
|
|
21
|
+
const FALLBACK_COLOR = '#6c7086';
|
|
22
|
+
// Folder names that are generic containers, not meaningful categories.
|
|
23
|
+
// When a script sits under one of these, skip to the next subfolder.
|
|
24
|
+
const CONTAINER_FOLDERS = new Set([
|
|
25
|
+
'scripts', 'src', 'code', 'source', 'gdscript', 'gd', 'lib', 'core',
|
|
26
|
+
]);
|
|
27
|
+
// ─── Main Entry ──────────────────────────────────────────────────────
|
|
28
|
+
/**
|
|
29
|
+
* Crawl a Godot project and build a structural map of all scripts.
|
|
30
|
+
* @param projectPath Absolute filesystem path to the Godot project root
|
|
31
|
+
* @param rootRes res:// sub-path to start from (default: "res://")
|
|
32
|
+
* @param includeAddons Whether to include scripts in addons/ (default: false)
|
|
33
|
+
*/
|
|
34
|
+
export function mapProject(projectPath, rootRes = 'res://', includeAddons = false) {
|
|
35
|
+
// Normalise rootRes
|
|
36
|
+
if (!rootRes.startsWith('res://')) {
|
|
37
|
+
rootRes = 'res://' + rootRes;
|
|
38
|
+
}
|
|
39
|
+
// Convert res:// to absolute path
|
|
40
|
+
const rootAbsolute = resToAbsolute(projectPath, rootRes);
|
|
41
|
+
if (!existsSync(rootAbsolute)) {
|
|
42
|
+
return { ok: false, error: `Directory not found: ${rootAbsolute}` };
|
|
43
|
+
}
|
|
44
|
+
// 1. Collect all .gd files
|
|
45
|
+
const scriptPaths = [];
|
|
46
|
+
collectScripts(rootAbsolute, projectPath, scriptPaths, includeAddons);
|
|
47
|
+
if (scriptPaths.length === 0) {
|
|
48
|
+
return { ok: false, error: `No GDScript files found in ${rootRes}` };
|
|
49
|
+
}
|
|
50
|
+
// 2. Parse each script
|
|
51
|
+
const nodes = [];
|
|
52
|
+
const classMap = {}; // class_name -> res:// path
|
|
53
|
+
for (const absPath of scriptPaths) {
|
|
54
|
+
const resPath = absoluteToRes(projectPath, absPath);
|
|
55
|
+
const info = parseScript(absPath, resPath);
|
|
56
|
+
nodes.push(info);
|
|
57
|
+
if (info.class_name) {
|
|
58
|
+
classMap[info.class_name] = resPath;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const categoryCounts = {};
|
|
62
|
+
for (const node of nodes) {
|
|
63
|
+
node.category = categorizeScript(node);
|
|
64
|
+
categoryCounts[node.category] = (categoryCounts[node.category] || 0) + 1;
|
|
65
|
+
}
|
|
66
|
+
// Build categories dynamically — colors assigned by descending count
|
|
67
|
+
let colorIdx = 0;
|
|
68
|
+
const categories = Object.entries(categoryCounts)
|
|
69
|
+
.sort((a, b) => b[1] - a[1])
|
|
70
|
+
.map(([id, count]) => ({
|
|
71
|
+
id,
|
|
72
|
+
label: prettifyLabel(id),
|
|
73
|
+
color: CATEGORY_PALETTE[colorIdx++ % CATEGORY_PALETTE.length] || FALLBACK_COLOR,
|
|
74
|
+
count,
|
|
75
|
+
}));
|
|
76
|
+
const edges = [];
|
|
77
|
+
for (const node of nodes) {
|
|
78
|
+
const fromPath = node.path;
|
|
79
|
+
// extends → class_name resolution OR direct res:// path
|
|
80
|
+
if (node.extends) {
|
|
81
|
+
if (node.extends.startsWith('res://')) {
|
|
82
|
+
// File-based extends: extends "res://scripts/base.gd"
|
|
83
|
+
edges.push({ from: fromPath, to: node.extends, type: 'extends' });
|
|
84
|
+
}
|
|
85
|
+
else if (classMap[node.extends]) {
|
|
86
|
+
// Class name-based extends: extends MyBaseClass
|
|
87
|
+
edges.push({ from: fromPath, to: classMap[node.extends], type: 'extends' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// preload/load references (all resource types, not just .gd)
|
|
91
|
+
for (const ref of node.preloads) {
|
|
92
|
+
edges.push({ from: fromPath, to: ref, type: 'preload' });
|
|
93
|
+
}
|
|
94
|
+
// signal connections
|
|
95
|
+
for (const conn of node.connections) {
|
|
96
|
+
if (conn.target && classMap[conn.target]) {
|
|
97
|
+
edges.push({
|
|
98
|
+
from: fromPath,
|
|
99
|
+
to: classMap[conn.target],
|
|
100
|
+
type: 'signal',
|
|
101
|
+
signal_name: conn.signal,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// ── Git status detection ────────────────────────────────────────────
|
|
107
|
+
// Tag each node with git status: modified, added, untracked, or null.
|
|
108
|
+
try {
|
|
109
|
+
const gitDir = join(projectPath, '.git');
|
|
110
|
+
if (existsSync(gitDir)) {
|
|
111
|
+
const raw = execSync('git status --porcelain', {
|
|
112
|
+
cwd: projectPath,
|
|
113
|
+
encoding: 'utf-8',
|
|
114
|
+
timeout: 5000,
|
|
115
|
+
});
|
|
116
|
+
const gitFileMap = new Map();
|
|
117
|
+
for (const line of raw.split('\n')) {
|
|
118
|
+
if (!line.trim())
|
|
119
|
+
continue;
|
|
120
|
+
const xy = line.substring(0, 2);
|
|
121
|
+
const filePath = line.substring(3).trim();
|
|
122
|
+
// Handle renamed files: "R old -> new"
|
|
123
|
+
const actualPath = filePath.includes(' -> ') ? filePath.split(' -> ')[1] : filePath;
|
|
124
|
+
const resFilePath = 'res://' + actualPath;
|
|
125
|
+
if (xy === '??') {
|
|
126
|
+
gitFileMap.set(resFilePath, 'untracked');
|
|
127
|
+
}
|
|
128
|
+
else if (xy.includes('A')) {
|
|
129
|
+
gitFileMap.set(resFilePath, 'added');
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// M, MM, AM, etc. — treat as modified
|
|
133
|
+
gitFileMap.set(resFilePath, 'modified');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const node of nodes) {
|
|
137
|
+
node.gitStatus = gitFileMap.get(node.path) ?? null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Git not available or command failed — leave all gitStatus as null
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
project_map: {
|
|
147
|
+
nodes,
|
|
148
|
+
edges,
|
|
149
|
+
categories,
|
|
150
|
+
total_scripts: nodes.length,
|
|
151
|
+
total_connections: edges.length,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// ─── File Collection ─────────────────────────────────────────────────
|
|
156
|
+
function collectScripts(dirPath, projectRoot, results, includeAddons) {
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = readdirSync(dirPath);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
for (const name of entries) {
|
|
165
|
+
if (name.startsWith('.'))
|
|
166
|
+
continue;
|
|
167
|
+
const fullPath = join(dirPath, name);
|
|
168
|
+
let st;
|
|
169
|
+
try {
|
|
170
|
+
st = statSync(fullPath);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (st.isDirectory()) {
|
|
176
|
+
if (name === 'addons' && !includeAddons)
|
|
177
|
+
continue;
|
|
178
|
+
collectScripts(fullPath, projectRoot, results, includeAddons);
|
|
179
|
+
}
|
|
180
|
+
else if (name.endsWith('.gd')) {
|
|
181
|
+
results.push(fullPath);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ─── Script Parser ───────────────────────────────────────────────────
|
|
186
|
+
function parseScript(absolutePath, resPath) {
|
|
187
|
+
let content;
|
|
188
|
+
try {
|
|
189
|
+
content = readFileSync(absolutePath, 'utf-8');
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return emptyNode(resPath);
|
|
193
|
+
}
|
|
194
|
+
const lines = content.split('\n');
|
|
195
|
+
const lineCount = lines.length;
|
|
196
|
+
let description = '';
|
|
197
|
+
let extendsClass = '';
|
|
198
|
+
let classNameStr = '';
|
|
199
|
+
const variables = [];
|
|
200
|
+
const functions = [];
|
|
201
|
+
const signalsList = [];
|
|
202
|
+
const preloads = [];
|
|
203
|
+
const connections = [];
|
|
204
|
+
// Regex patterns (JS equivalents of the GDScript RegEx)
|
|
205
|
+
const reDesc = /^##\s*@desc:\s*(.+)/;
|
|
206
|
+
const reExtends = /^extends\s+(\w+)/;
|
|
207
|
+
const reExtendsPath = /^extends\s+"(res:\/\/[^"]+)"/;
|
|
208
|
+
const reClassName = /^class_name\s+(\w+)/;
|
|
209
|
+
const reVar = /^(@export(?:\([^)]*\))?\s+)?(@onready\s+)?var\s+(\w+)\s*(?::\s*(\w+))?(?:\s*=\s*(.+))?/;
|
|
210
|
+
const reFunc = /^func\s+(\w+)\s*\(([^)]*)\)\s*(?:->\s*(\w+))?/;
|
|
211
|
+
const reSignal = /^signal\s+(\w+)(?:\(([^)]*)\))?/;
|
|
212
|
+
const rePreload = /(?:preload|load)\s*\(\s*"(res:\/\/[^"]+)"\s*\)/;
|
|
213
|
+
const reConnectObj = /(\w+)\.(\w+)\.connect\s*\(/;
|
|
214
|
+
const reConnectDirect = /^\s*(\w+)\.connect\s*\(/;
|
|
215
|
+
// Variable type map for signal connection resolution
|
|
216
|
+
const varTypeMap = {};
|
|
217
|
+
// Track function start positions
|
|
218
|
+
const funcStarts = [];
|
|
219
|
+
// ── First pass: extract metadata ──
|
|
220
|
+
for (let i = 0; i < lineCount; i++) {
|
|
221
|
+
const line = lines[i];
|
|
222
|
+
const stripped = line.trim();
|
|
223
|
+
// Description tag (first 15 lines)
|
|
224
|
+
if (i < 15 && !description) {
|
|
225
|
+
const m = stripped.match(reDesc);
|
|
226
|
+
if (m) {
|
|
227
|
+
description = m[1];
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// extends (class name or file path)
|
|
232
|
+
if (!extendsClass) {
|
|
233
|
+
const mPath = stripped.match(reExtendsPath);
|
|
234
|
+
if (mPath) {
|
|
235
|
+
extendsClass = mPath[1];
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const m = stripped.match(reExtends);
|
|
239
|
+
if (m) {
|
|
240
|
+
extendsClass = m[1];
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// class_name
|
|
245
|
+
if (!classNameStr) {
|
|
246
|
+
const m = stripped.match(reClassName);
|
|
247
|
+
if (m) {
|
|
248
|
+
classNameStr = m[1];
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Variables — only top-level (not indented)
|
|
253
|
+
if (!line.startsWith('\t') && !line.startsWith(' ')) {
|
|
254
|
+
const mVar = stripped.match(reVar);
|
|
255
|
+
if (mVar) {
|
|
256
|
+
const exported = (mVar[1] || '').trim() !== '';
|
|
257
|
+
const onready = (mVar[2] || '').trim() !== '';
|
|
258
|
+
const varName = mVar[3];
|
|
259
|
+
let varType = (mVar[4] || '').trim();
|
|
260
|
+
const defaultVal = (mVar[5] || '').trim();
|
|
261
|
+
if (!varType && defaultVal) {
|
|
262
|
+
varType = inferType(defaultVal);
|
|
263
|
+
}
|
|
264
|
+
if (varType) {
|
|
265
|
+
varTypeMap[varName] = varType;
|
|
266
|
+
}
|
|
267
|
+
variables.push({
|
|
268
|
+
name: varName,
|
|
269
|
+
type: varType,
|
|
270
|
+
exported,
|
|
271
|
+
onready,
|
|
272
|
+
default: defaultVal,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Functions
|
|
277
|
+
const mFunc = stripped.match(reFunc);
|
|
278
|
+
if (mFunc) {
|
|
279
|
+
const funcName = mFunc[1];
|
|
280
|
+
const returnType = (mFunc[3] || '').trim();
|
|
281
|
+
funcStarts.push({ lineIdx: i, name: funcName });
|
|
282
|
+
functions.push({
|
|
283
|
+
name: funcName,
|
|
284
|
+
params: (mFunc[2] || '').trim(),
|
|
285
|
+
return_type: returnType,
|
|
286
|
+
line: i + 1,
|
|
287
|
+
body: '',
|
|
288
|
+
body_lines: 0,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
// Signals
|
|
292
|
+
const mSig = stripped.match(reSignal);
|
|
293
|
+
if (mSig) {
|
|
294
|
+
signalsList.push({
|
|
295
|
+
name: mSig[1],
|
|
296
|
+
params: (mSig[2] || '').trim(),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
// Preload / load references
|
|
300
|
+
const mPreload = stripped.match(rePreload);
|
|
301
|
+
if (mPreload) {
|
|
302
|
+
preloads.push(mPreload[1]);
|
|
303
|
+
}
|
|
304
|
+
// Signal connections (Godot 4 style)
|
|
305
|
+
const mConnObj = stripped.match(reConnectObj);
|
|
306
|
+
if (mConnObj) {
|
|
307
|
+
const objName = mConnObj[1];
|
|
308
|
+
const signalName = mConnObj[2];
|
|
309
|
+
const targetType = varTypeMap[objName] || '';
|
|
310
|
+
connections.push({
|
|
311
|
+
object: objName,
|
|
312
|
+
signal: signalName,
|
|
313
|
+
target: targetType,
|
|
314
|
+
line: i + 1,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const mConnDirect = stripped.match(reConnectDirect);
|
|
319
|
+
if (mConnDirect) {
|
|
320
|
+
connections.push({
|
|
321
|
+
signal: mConnDirect[1],
|
|
322
|
+
target: extendsClass,
|
|
323
|
+
line: i + 1,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// ── Second pass: extract function bodies ──
|
|
329
|
+
for (let fi = 0; fi < funcStarts.length; fi++) {
|
|
330
|
+
const startIdx = funcStarts[fi].lineIdx;
|
|
331
|
+
let endIdx = fi + 1 < funcStarts.length
|
|
332
|
+
? funcStarts[fi + 1].lineIdx
|
|
333
|
+
: lineCount;
|
|
334
|
+
// Trim trailing blank lines
|
|
335
|
+
while (endIdx > startIdx + 1 && lines[endIdx - 1].trim() === '') {
|
|
336
|
+
endIdx--;
|
|
337
|
+
}
|
|
338
|
+
// Check for top-level declarations that end the function body
|
|
339
|
+
for (let ci = startIdx + 1; ci < endIdx; ci++) {
|
|
340
|
+
const checkLine = lines[ci];
|
|
341
|
+
if (checkLine !== '' &&
|
|
342
|
+
!checkLine.startsWith('\t') &&
|
|
343
|
+
!checkLine.startsWith(' ') &&
|
|
344
|
+
!checkLine.startsWith('#')) {
|
|
345
|
+
endIdx = ci;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
let body = lines.slice(startIdx, endIdx).join('\n');
|
|
350
|
+
if (body.length > 3000) {
|
|
351
|
+
body = body.substring(0, 3000) + '\n# ... (truncated)';
|
|
352
|
+
}
|
|
353
|
+
functions[fi].body = body;
|
|
354
|
+
functions[fi].body_lines = endIdx - startIdx;
|
|
355
|
+
}
|
|
356
|
+
// Folder / filename
|
|
357
|
+
const folder = dirname(resPath);
|
|
358
|
+
const filename = basename(resPath);
|
|
359
|
+
return {
|
|
360
|
+
path: resPath,
|
|
361
|
+
filename,
|
|
362
|
+
folder,
|
|
363
|
+
category: 'other',
|
|
364
|
+
class_name: classNameStr,
|
|
365
|
+
extends: extendsClass,
|
|
366
|
+
description,
|
|
367
|
+
line_count: lineCount,
|
|
368
|
+
variables,
|
|
369
|
+
functions,
|
|
370
|
+
signals: signalsList,
|
|
371
|
+
preloads,
|
|
372
|
+
connections,
|
|
373
|
+
gitStatus: null,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
// ─── Type Inference ──────────────────────────────────────────────────
|
|
377
|
+
function inferType(defaultVal) {
|
|
378
|
+
if (defaultVal === 'true' || defaultVal === 'false')
|
|
379
|
+
return 'bool';
|
|
380
|
+
if (/^-?\d+$/.test(defaultVal))
|
|
381
|
+
return 'int';
|
|
382
|
+
if (/^-?\d+\.\d+$/.test(defaultVal))
|
|
383
|
+
return 'float';
|
|
384
|
+
if (defaultVal.startsWith('"') || defaultVal.startsWith("'"))
|
|
385
|
+
return 'String';
|
|
386
|
+
if (defaultVal.startsWith('Vector2'))
|
|
387
|
+
return 'Vector2';
|
|
388
|
+
if (defaultVal.startsWith('Vector3'))
|
|
389
|
+
return 'Vector3';
|
|
390
|
+
if (defaultVal.startsWith('Color'))
|
|
391
|
+
return 'Color';
|
|
392
|
+
if (defaultVal.startsWith('['))
|
|
393
|
+
return 'Array';
|
|
394
|
+
if (defaultVal.startsWith('{'))
|
|
395
|
+
return 'Dictionary';
|
|
396
|
+
if (defaultVal === 'null')
|
|
397
|
+
return 'Variant';
|
|
398
|
+
if (defaultVal.endsWith('.new()'))
|
|
399
|
+
return defaultVal.replace('.new()', '');
|
|
400
|
+
return '';
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Categorise a script by its folder structure.
|
|
404
|
+
* Uses the first meaningful subfolder (skipping generic containers like scripts/, src/).
|
|
405
|
+
* Works for ANY Godot project — no hardcoded game-specific keywords.
|
|
406
|
+
*/
|
|
407
|
+
function categorizeScript(node) {
|
|
408
|
+
const resPath = node.path.replace(/^res:\/\//, '');
|
|
409
|
+
const segments = resPath.split('/').filter(Boolean);
|
|
410
|
+
// Remove filename (last segment)
|
|
411
|
+
const folders = segments.slice(0, -1);
|
|
412
|
+
if (folders.length === 0)
|
|
413
|
+
return 'root';
|
|
414
|
+
// Find first folder that is NOT a generic container
|
|
415
|
+
for (const folder of folders) {
|
|
416
|
+
if (!CONTAINER_FOLDERS.has(folder.toLowerCase())) {
|
|
417
|
+
return folder.toLowerCase();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
// All folders were containers — use the deepest one
|
|
421
|
+
return folders[folders.length - 1].toLowerCase();
|
|
422
|
+
}
|
|
423
|
+
/** Turn a folder slug into a readable label: my_cool_scripts → My Cool Scripts */
|
|
424
|
+
function prettifyLabel(slug) {
|
|
425
|
+
return slug
|
|
426
|
+
.replace(/[_-]/g, ' ')
|
|
427
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
428
|
+
}
|
|
429
|
+
// ─── Path Helpers ────────────────────────────────────────────────────
|
|
430
|
+
function resToAbsolute(projectRoot, resPath) {
|
|
431
|
+
const relPath = resPath.replace(/^res:\/\//, '');
|
|
432
|
+
return join(projectRoot, relPath);
|
|
433
|
+
}
|
|
434
|
+
function absoluteToRes(projectRoot, absPath) {
|
|
435
|
+
const rel = relative(projectRoot, absPath).replace(/\\/g, '/');
|
|
436
|
+
return 'res://' + rel;
|
|
437
|
+
}
|
|
438
|
+
function emptyNode(resPath) {
|
|
439
|
+
return {
|
|
440
|
+
path: resPath,
|
|
441
|
+
filename: basename(resPath),
|
|
442
|
+
folder: dirname(resPath),
|
|
443
|
+
category: 'other',
|
|
444
|
+
class_name: '',
|
|
445
|
+
extends: '',
|
|
446
|
+
description: '',
|
|
447
|
+
line_count: 0,
|
|
448
|
+
variables: [],
|
|
449
|
+
functions: [],
|
|
450
|
+
signals: [],
|
|
451
|
+
preloads: [],
|
|
452
|
+
connections: [],
|
|
453
|
+
gitStatus: null,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// ─── Internal Edit Commands (filesystem-based) ───────────────────────
|
|
457
|
+
// These replace the GodotBridge-based commands from tomyud1.
|
|
458
|
+
// The browser sends WebSocket commands → visualizer-server routes here.
|
|
459
|
+
/**
|
|
460
|
+
* Convert a res:// path to an absolute filesystem path.
|
|
461
|
+
*/
|
|
462
|
+
export function resolvePath(projectPath, resOrRelPath) {
|
|
463
|
+
if (resOrRelPath.startsWith('res://')) {
|
|
464
|
+
return resToAbsolute(projectPath, resOrRelPath);
|
|
465
|
+
}
|
|
466
|
+
return join(projectPath, resOrRelPath);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Refresh the project map (re-crawl).
|
|
470
|
+
*/
|
|
471
|
+
export function refreshMap(projectPath, args) {
|
|
472
|
+
const root = args.root || 'res://';
|
|
473
|
+
const includeAddons = args.include_addons || false;
|
|
474
|
+
return mapProject(projectPath, root, includeAddons);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Create a new script file.
|
|
478
|
+
*/
|
|
479
|
+
export function createScriptFile(projectPath, args) {
|
|
480
|
+
let scriptPath = args.path || '';
|
|
481
|
+
const extendsType = args.extends || 'Node';
|
|
482
|
+
const classNameStr = args.class_name || '';
|
|
483
|
+
if (!scriptPath)
|
|
484
|
+
return { ok: false, error: 'No path provided' };
|
|
485
|
+
if (!scriptPath.startsWith('res://')) {
|
|
486
|
+
scriptPath = 'res://' + scriptPath;
|
|
487
|
+
}
|
|
488
|
+
if (!scriptPath.endsWith('.gd')) {
|
|
489
|
+
scriptPath += '.gd';
|
|
490
|
+
}
|
|
491
|
+
const absPath = resToAbsolute(projectPath, scriptPath);
|
|
492
|
+
if (existsSync(absPath)) {
|
|
493
|
+
return { ok: false, error: `File already exists: ${scriptPath}` };
|
|
494
|
+
}
|
|
495
|
+
// Create directory if needed
|
|
496
|
+
const dir = dirname(absPath);
|
|
497
|
+
if (!existsSync(dir)) {
|
|
498
|
+
mkdirSync(dir, { recursive: true });
|
|
499
|
+
}
|
|
500
|
+
let content = '';
|
|
501
|
+
if (classNameStr)
|
|
502
|
+
content += `class_name ${classNameStr}\n`;
|
|
503
|
+
content += `extends ${extendsType}\n\n\n`;
|
|
504
|
+
content += `func _ready() -> void:\n\tpass\n`;
|
|
505
|
+
writeFileSync(absPath, content, 'utf-8');
|
|
506
|
+
return { ok: true, path: scriptPath };
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Add, update, or delete a variable in a script file.
|
|
510
|
+
*/
|
|
511
|
+
export function modifyVariable(projectPath, args) {
|
|
512
|
+
const scriptPath = args.path;
|
|
513
|
+
const action = args.action; // "add" | "update" | "delete"
|
|
514
|
+
const oldName = args.old_name || '';
|
|
515
|
+
const newName = args.name || '';
|
|
516
|
+
const varType = args.type || '';
|
|
517
|
+
const defaultVal = args.default || '';
|
|
518
|
+
const exported = args.exported || false;
|
|
519
|
+
const onready = args.onready || false;
|
|
520
|
+
if (!scriptPath)
|
|
521
|
+
return { ok: false, error: 'No script path provided' };
|
|
522
|
+
const absPath = resolvePath(projectPath, scriptPath);
|
|
523
|
+
let content;
|
|
524
|
+
try {
|
|
525
|
+
content = readFileSync(absPath, 'utf-8');
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
return { ok: false, error: `Cannot open file: ${scriptPath}` };
|
|
529
|
+
}
|
|
530
|
+
const lines = content.split('\n');
|
|
531
|
+
let modified = false;
|
|
532
|
+
if (action === 'delete') {
|
|
533
|
+
const pattern = new RegExp(`^(@export(?:\\([^)]*\\))?\\s+)?(?:@onready\\s+)?var\\s+${oldName}\\s*(?::|=|$)`);
|
|
534
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
535
|
+
if (pattern.test(lines[i].trim())) {
|
|
536
|
+
lines.splice(i, 1);
|
|
537
|
+
modified = true;
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
else if (action === 'update') {
|
|
543
|
+
const pattern = new RegExp(`^(@export(?:\\([^)]*\\))?\\s+)?(@onready\\s+)?var\\s+${oldName}\\s*(?::\\s*\\w+)?(?:\\s*=\\s*.+)?$`);
|
|
544
|
+
for (let i = 0; i < lines.length; i++) {
|
|
545
|
+
if (pattern.test(lines[i].trim())) {
|
|
546
|
+
lines[i] = buildVarLine(newName, varType, defaultVal, exported, onready);
|
|
547
|
+
modified = true;
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
else if (action === 'add') {
|
|
553
|
+
const insertPos = findVarInsertPosition(lines, exported);
|
|
554
|
+
lines.splice(insertPos, 0, buildVarLine(newName, varType, defaultVal, exported, false));
|
|
555
|
+
modified = true;
|
|
556
|
+
}
|
|
557
|
+
if (modified) {
|
|
558
|
+
writeFileSync(absPath, lines.join('\n'), 'utf-8');
|
|
559
|
+
return { ok: true, action, variable: newName || oldName };
|
|
560
|
+
}
|
|
561
|
+
return { ok: false, error: `Variable not found: ${oldName}` };
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Add, update, or delete a signal in a script file.
|
|
565
|
+
*/
|
|
566
|
+
export function modifySignal(projectPath, args) {
|
|
567
|
+
const scriptPath = args.path;
|
|
568
|
+
const action = args.action;
|
|
569
|
+
const oldName = args.old_name || '';
|
|
570
|
+
const newName = args.name || '';
|
|
571
|
+
const params = args.params || '';
|
|
572
|
+
if (!scriptPath)
|
|
573
|
+
return { ok: false, error: 'No script path provided' };
|
|
574
|
+
const absPath = resolvePath(projectPath, scriptPath);
|
|
575
|
+
let content;
|
|
576
|
+
try {
|
|
577
|
+
content = readFileSync(absPath, 'utf-8');
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
return { ok: false, error: `Cannot open file: ${scriptPath}` };
|
|
581
|
+
}
|
|
582
|
+
const lines = content.split('\n');
|
|
583
|
+
let modified = false;
|
|
584
|
+
if (action === 'delete') {
|
|
585
|
+
const pattern = new RegExp(`^signal\\s+${oldName}(?:\\s*\\(|$)`);
|
|
586
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
587
|
+
if (pattern.test(lines[i].trim())) {
|
|
588
|
+
lines.splice(i, 1);
|
|
589
|
+
modified = true;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
else if (action === 'update') {
|
|
595
|
+
const pattern = new RegExp(`^signal\\s+${oldName}(?:\\s*\\([^)]*\\))?$`);
|
|
596
|
+
for (let i = 0; i < lines.length; i++) {
|
|
597
|
+
if (pattern.test(lines[i].trim())) {
|
|
598
|
+
let newLine = `signal ${newName}`;
|
|
599
|
+
if (params)
|
|
600
|
+
newLine += `(${params})`;
|
|
601
|
+
lines[i] = newLine;
|
|
602
|
+
modified = true;
|
|
603
|
+
break;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
else if (action === 'add') {
|
|
608
|
+
const insertPos = findSignalInsertPosition(lines);
|
|
609
|
+
let newLine = `signal ${newName}`;
|
|
610
|
+
if (params)
|
|
611
|
+
newLine += `(${params})`;
|
|
612
|
+
lines.splice(insertPos, 0, newLine);
|
|
613
|
+
modified = true;
|
|
614
|
+
}
|
|
615
|
+
if (modified) {
|
|
616
|
+
writeFileSync(absPath, lines.join('\n'), 'utf-8');
|
|
617
|
+
return { ok: true, action, signal: newName || oldName };
|
|
618
|
+
}
|
|
619
|
+
return { ok: false, error: `Signal not found: ${oldName}` };
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Update a function's body in a script file.
|
|
623
|
+
*/
|
|
624
|
+
export function modifyFunction(projectPath, args) {
|
|
625
|
+
const scriptPath = args.path;
|
|
626
|
+
const funcName = args.name;
|
|
627
|
+
const newBody = args.body;
|
|
628
|
+
if (!scriptPath || !funcName) {
|
|
629
|
+
return { ok: false, error: 'Missing path or function name' };
|
|
630
|
+
}
|
|
631
|
+
const absPath = resolvePath(projectPath, scriptPath);
|
|
632
|
+
let content;
|
|
633
|
+
try {
|
|
634
|
+
content = readFileSync(absPath, 'utf-8');
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return { ok: false, error: `Cannot open file: ${scriptPath}` };
|
|
638
|
+
}
|
|
639
|
+
const lines = content.split('\n');
|
|
640
|
+
const reFuncStart = new RegExp(`^func\\s+${funcName}\\s*\\(`);
|
|
641
|
+
let funcStart = -1;
|
|
642
|
+
let funcEnd = -1;
|
|
643
|
+
for (let i = 0; i < lines.length; i++) {
|
|
644
|
+
if (funcStart === -1) {
|
|
645
|
+
if (reFuncStart.test(lines[i].trim())) {
|
|
646
|
+
funcStart = i;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
const stripped = lines[i].trim();
|
|
651
|
+
if (stripped !== '' &&
|
|
652
|
+
!lines[i].startsWith('\t') &&
|
|
653
|
+
!lines[i].startsWith(' ') &&
|
|
654
|
+
!stripped.startsWith('#')) {
|
|
655
|
+
funcEnd = i;
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (funcStart === -1) {
|
|
661
|
+
return { ok: false, error: `Function not found: ${funcName}` };
|
|
662
|
+
}
|
|
663
|
+
if (funcEnd === -1)
|
|
664
|
+
funcEnd = lines.length;
|
|
665
|
+
// Trim trailing blanks
|
|
666
|
+
while (funcEnd > funcStart + 1 && lines[funcEnd - 1].trim() === '') {
|
|
667
|
+
funcEnd--;
|
|
668
|
+
}
|
|
669
|
+
// Replace
|
|
670
|
+
const newLines = newBody.split('\n');
|
|
671
|
+
lines.splice(funcStart, funcEnd - funcStart, ...newLines);
|
|
672
|
+
writeFileSync(absPath, lines.join('\n'), 'utf-8');
|
|
673
|
+
return { ok: true, function: funcName };
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Delete a function from a script file.
|
|
677
|
+
*/
|
|
678
|
+
export function deleteFunction(projectPath, args) {
|
|
679
|
+
const scriptPath = args.path;
|
|
680
|
+
const funcName = args.name;
|
|
681
|
+
if (!scriptPath || !funcName) {
|
|
682
|
+
return { ok: false, error: 'Missing path or function name' };
|
|
683
|
+
}
|
|
684
|
+
const absPath = resolvePath(projectPath, scriptPath);
|
|
685
|
+
let content;
|
|
686
|
+
try {
|
|
687
|
+
content = readFileSync(absPath, 'utf-8');
|
|
688
|
+
}
|
|
689
|
+
catch {
|
|
690
|
+
return { ok: false, error: `Cannot open file: ${scriptPath}` };
|
|
691
|
+
}
|
|
692
|
+
const lines = content.split('\n');
|
|
693
|
+
const reFuncStart = new RegExp(`^func\\s+${funcName}\\s*\\(`);
|
|
694
|
+
let funcStart = -1;
|
|
695
|
+
let funcEnd = -1;
|
|
696
|
+
for (let i = 0; i < lines.length; i++) {
|
|
697
|
+
if (funcStart === -1) {
|
|
698
|
+
if (reFuncStart.test(lines[i].trim())) {
|
|
699
|
+
funcStart = i;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
const stripped = lines[i].trim();
|
|
704
|
+
if (stripped !== '' &&
|
|
705
|
+
!lines[i].startsWith('\t') &&
|
|
706
|
+
!lines[i].startsWith(' ') &&
|
|
707
|
+
!stripped.startsWith('#')) {
|
|
708
|
+
funcEnd = i;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (funcStart === -1) {
|
|
714
|
+
return { ok: false, error: `Function not found: ${funcName}` };
|
|
715
|
+
}
|
|
716
|
+
if (funcEnd === -1)
|
|
717
|
+
funcEnd = lines.length;
|
|
718
|
+
while (funcEnd > funcStart + 1 && lines[funcEnd - 1].trim() === '') {
|
|
719
|
+
funcEnd--;
|
|
720
|
+
}
|
|
721
|
+
lines.splice(funcStart, funcEnd - funcStart);
|
|
722
|
+
writeFileSync(absPath, lines.join('\n'), 'utf-8');
|
|
723
|
+
return { ok: true, deleted: funcName };
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Find all usages of a symbol across all scripts.
|
|
727
|
+
*/
|
|
728
|
+
export function findUsages(projectPath, args) {
|
|
729
|
+
const name = args.name;
|
|
730
|
+
const itemType = args.type || ''; // "variable", "signal", "function"
|
|
731
|
+
const rootRes = args.root || 'res://';
|
|
732
|
+
if (!name)
|
|
733
|
+
return { ok: false, error: 'No name provided' };
|
|
734
|
+
const rootAbsolute = resToAbsolute(projectPath, rootRes);
|
|
735
|
+
const scriptPaths = [];
|
|
736
|
+
collectScripts(rootAbsolute, projectPath, scriptPaths, false);
|
|
737
|
+
const usages = [];
|
|
738
|
+
const pattern = new RegExp(`\\b${name}\\b`);
|
|
739
|
+
for (const absPath of scriptPaths) {
|
|
740
|
+
let content;
|
|
741
|
+
try {
|
|
742
|
+
content = readFileSync(absPath, 'utf-8');
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const fileLines = content.split('\n');
|
|
748
|
+
const resPath = absoluteToRes(projectPath, absPath);
|
|
749
|
+
for (let i = 0; i < fileLines.length; i++) {
|
|
750
|
+
const line = fileLines[i];
|
|
751
|
+
if (!pattern.test(line))
|
|
752
|
+
continue;
|
|
753
|
+
// Skip declaration lines
|
|
754
|
+
if (itemType === 'variable' && new RegExp(`^\\s*(@export)?\\s*var\\s+${name}\\b`).test(line))
|
|
755
|
+
continue;
|
|
756
|
+
if (itemType === 'signal' && new RegExp(`^\\s*signal\\s+${name}\\b`).test(line))
|
|
757
|
+
continue;
|
|
758
|
+
if (itemType === 'function' && new RegExp(`^\\s*func\\s+${name}\\s*\\(`).test(line))
|
|
759
|
+
continue;
|
|
760
|
+
usages.push({
|
|
761
|
+
file: resPath,
|
|
762
|
+
line: i + 1,
|
|
763
|
+
code: line.trim(),
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return { ok: true, usages, count: usages.length };
|
|
768
|
+
}
|
|
769
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
770
|
+
function buildVarLine(name, type, defaultVal, exported, onready) {
|
|
771
|
+
let line = '';
|
|
772
|
+
if (exported)
|
|
773
|
+
line += '@export ';
|
|
774
|
+
if (onready)
|
|
775
|
+
line += '@onready ';
|
|
776
|
+
line += `var ${name}`;
|
|
777
|
+
if (type)
|
|
778
|
+
line += `: ${type}`;
|
|
779
|
+
if (defaultVal)
|
|
780
|
+
line += ` = ${defaultVal}`;
|
|
781
|
+
return line;
|
|
782
|
+
}
|
|
783
|
+
function findVarInsertPosition(lines, _exported) {
|
|
784
|
+
let lastVarLine = -1;
|
|
785
|
+
let firstFuncLine = -1;
|
|
786
|
+
let afterClassDecl = 0;
|
|
787
|
+
const reVar = /^(@export)?\s*(@onready)?\s*var\s+/;
|
|
788
|
+
const reFuncLine = /^func\s+/;
|
|
789
|
+
const reClass = /^(class_name|extends)\s+/;
|
|
790
|
+
for (let i = 0; i < lines.length; i++) {
|
|
791
|
+
const stripped = lines[i].trim();
|
|
792
|
+
if (reClass.test(stripped))
|
|
793
|
+
afterClassDecl = i + 1;
|
|
794
|
+
if (reVar.test(stripped))
|
|
795
|
+
lastVarLine = i;
|
|
796
|
+
if (reFuncLine.test(stripped) && firstFuncLine === -1) {
|
|
797
|
+
firstFuncLine = i;
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
if (lastVarLine !== -1)
|
|
802
|
+
return lastVarLine + 1;
|
|
803
|
+
if (firstFuncLine !== -1)
|
|
804
|
+
return firstFuncLine;
|
|
805
|
+
return Math.max(afterClassDecl, 2);
|
|
806
|
+
}
|
|
807
|
+
function findSignalInsertPosition(lines) {
|
|
808
|
+
let lastSignalLine = -1;
|
|
809
|
+
let firstVarLine = -1;
|
|
810
|
+
let afterClassDecl = 0;
|
|
811
|
+
const reSignalLine = /^signal\s+/;
|
|
812
|
+
const reVar = /^(@export)?\s*var\s+/;
|
|
813
|
+
const reClass = /^(class_name|extends)\s+/;
|
|
814
|
+
for (let i = 0; i < lines.length; i++) {
|
|
815
|
+
const stripped = lines[i].trim();
|
|
816
|
+
if (reClass.test(stripped))
|
|
817
|
+
afterClassDecl = i + 1;
|
|
818
|
+
if (reSignalLine.test(stripped))
|
|
819
|
+
lastSignalLine = i;
|
|
820
|
+
if (reVar.test(stripped) && firstVarLine === -1)
|
|
821
|
+
firstVarLine = i;
|
|
822
|
+
}
|
|
823
|
+
if (lastSignalLine !== -1)
|
|
824
|
+
return lastSignalLine + 1;
|
|
825
|
+
if (firstVarLine !== -1)
|
|
826
|
+
return firstVarLine;
|
|
827
|
+
return Math.max(afterClassDecl, 2);
|
|
828
|
+
}
|