azul-sync 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/README.md +142 -0
- package/dist/build.d.ts +19 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +92 -0
- package/dist/build.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +397 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +105 -0
- package/dist/config.js.map +1 -0
- package/dist/fs/fileWriter.d.ts +100 -0
- package/dist/fs/fileWriter.d.ts.map +1 -0
- package/dist/fs/fileWriter.js +342 -0
- package/dist/fs/fileWriter.js.map +1 -0
- package/dist/fs/treeManager.d.ts +84 -0
- package/dist/fs/treeManager.d.ts.map +1 -0
- package/dist/fs/treeManager.js +365 -0
- package/dist/fs/treeManager.js.map +1 -0
- package/dist/fs/watcher.d.ts +39 -0
- package/dist/fs/watcher.d.ts.map +1 -0
- package/dist/fs/watcher.js +120 -0
- package/dist/fs/watcher.js.map +1 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/httpPolling.d.ts +56 -0
- package/dist/ipc/httpPolling.d.ts.map +1 -0
- package/dist/ipc/httpPolling.js +171 -0
- package/dist/ipc/httpPolling.js.map +1 -0
- package/dist/ipc/messages.d.ts +112 -0
- package/dist/ipc/messages.d.ts.map +1 -0
- package/dist/ipc/messages.js +5 -0
- package/dist/ipc/messages.js.map +1 -0
- package/dist/ipc/server.d.ts +50 -0
- package/dist/ipc/server.d.ts.map +1 -0
- package/dist/ipc/server.js +168 -0
- package/dist/ipc/server.js.map +1 -0
- package/dist/pack.d.ts +19 -0
- package/dist/pack.d.ts.map +1 -0
- package/dist/pack.js +225 -0
- package/dist/pack.js.map +1 -0
- package/dist/push.d.ts +43 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +532 -0
- package/dist/push.js.map +1 -0
- package/dist/rojo.d.ts +9 -0
- package/dist/rojo.d.ts.map +1 -0
- package/dist/rojo.js +114 -0
- package/dist/rojo.js.map +1 -0
- package/dist/snapshot/rojo.d.ts +39 -0
- package/dist/snapshot/rojo.d.ts.map +1 -0
- package/dist/snapshot/rojo.js +364 -0
- package/dist/snapshot/rojo.js.map +1 -0
- package/dist/snapshot.d.ts +23 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +132 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/sourcemap/generator.d.ts +78 -0
- package/dist/sourcemap/generator.d.ts.map +1 -0
- package/dist/sourcemap/generator.js +351 -0
- package/dist/sourcemap/generator.js.map +1 -0
- package/dist/sourcemap/propertyLoader.d.ts +19 -0
- package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
- package/dist/sourcemap/propertyLoader.js +131 -0
- package/dist/sourcemap/propertyLoader.js.map +1 -0
- package/dist/util/id.d.ts +9 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +14 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/log.d.ts +13 -0
- package/dist/util/log.d.ts.map +1 -0
- package/dist/util/log.js +51 -0
- package/dist/util/log.js.map +1 -0
- package/docs/assets/azul-logo.pdn +0 -0
- package/docs/assets/logo-200px.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/plugin/toolbox.png +0 -0
- package/docs/assets/synced.png +0 -0
- package/package.json +41 -0
- package/plugin/README.md +54 -0
- package/plugin/sourcemap.json +264 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
- package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
- package/src/build.ts +120 -0
- package/src/cli.ts +496 -0
- package/src/config.ts +170 -0
- package/src/fs/fileWriter.ts +414 -0
- package/src/fs/treeManager.ts +458 -0
- package/src/fs/watcher.ts +142 -0
- package/src/index.ts +450 -0
- package/src/ipc/httpPolling.ts +214 -0
- package/src/ipc/messages.ts +159 -0
- package/src/ipc/server.ts +196 -0
- package/src/pack.ts +309 -0
- package/src/push.ts +726 -0
- package/src/snapshot/rojo.ts +467 -0
- package/src/snapshot.ts +161 -0
- package/src/sourcemap/generator.ts +504 -0
- package/src/sourcemap/propertyLoader.ts +195 -0
- package/src/util/id.ts +15 -0
- package/src/util/log.ts +94 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { InstanceData } from "../ipc/messages.js";
|
|
2
|
+
import { log } from "../util/log.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Represents a node in the virtual DataModel tree
|
|
6
|
+
*/
|
|
7
|
+
export interface TreeNode {
|
|
8
|
+
guid: string;
|
|
9
|
+
className: string;
|
|
10
|
+
name: string;
|
|
11
|
+
path: string[];
|
|
12
|
+
parentGuid?: string | null;
|
|
13
|
+
source?: string;
|
|
14
|
+
children: Map<string, TreeNode>;
|
|
15
|
+
parent?: TreeNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Manages the in-memory representation of Studio's DataModel
|
|
20
|
+
*/
|
|
21
|
+
export class TreeManager {
|
|
22
|
+
private nodes: Map<string, TreeNode> = new Map();
|
|
23
|
+
private pathIndex: Map<string, Set<TreeNode>> = new Map(); // pathKey → TreeNodes (same name siblings supported)
|
|
24
|
+
private root: TreeNode | null = null;
|
|
25
|
+
|
|
26
|
+
private pathKey(path: string[]): string {
|
|
27
|
+
return path.join("\u0000");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private addToPathIndex(node: TreeNode): void {
|
|
31
|
+
const key = this.pathKey(node.path);
|
|
32
|
+
const bucket = this.pathIndex.get(key) ?? new Set<TreeNode>();
|
|
33
|
+
bucket.add(node);
|
|
34
|
+
this.pathIndex.set(key, bucket);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private removeFromPathIndex(node: TreeNode): void {
|
|
38
|
+
const key = this.pathKey(node.path);
|
|
39
|
+
const bucket = this.pathIndex.get(key);
|
|
40
|
+
if (!bucket) return;
|
|
41
|
+
|
|
42
|
+
bucket.delete(node);
|
|
43
|
+
if (bucket.size === 0) {
|
|
44
|
+
this.pathIndex.delete(key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private registerSubtree(node: TreeNode): void {
|
|
49
|
+
const stack: TreeNode[] = [node];
|
|
50
|
+
|
|
51
|
+
while (stack.length > 0) {
|
|
52
|
+
const current = stack.pop()!;
|
|
53
|
+
this.addToPathIndex(current);
|
|
54
|
+
|
|
55
|
+
for (const child of current.children.values()) {
|
|
56
|
+
stack.push(child);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private unregisterSubtree(node: TreeNode): void {
|
|
62
|
+
const stack: TreeNode[] = [node];
|
|
63
|
+
|
|
64
|
+
while (stack.length > 0) {
|
|
65
|
+
const current = stack.pop()!;
|
|
66
|
+
this.removeFromPathIndex(current);
|
|
67
|
+
|
|
68
|
+
for (const child of current.children.values()) {
|
|
69
|
+
stack.push(child);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public updateInstance(instance: InstanceData): {
|
|
75
|
+
node: TreeNode;
|
|
76
|
+
pathChanged: boolean;
|
|
77
|
+
nameChanged: boolean;
|
|
78
|
+
parentChanged: boolean;
|
|
79
|
+
isNew: boolean;
|
|
80
|
+
prevPath?: string[];
|
|
81
|
+
prevName?: string;
|
|
82
|
+
} | null {
|
|
83
|
+
const existing = this.nodes.get(instance.guid);
|
|
84
|
+
const hasParentHint = instance.parentGuid !== undefined;
|
|
85
|
+
const incomingParentGuid = hasParentHint
|
|
86
|
+
? (instance.parentGuid ?? null)
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
if (existing) {
|
|
90
|
+
const prevPath = [...existing.path];
|
|
91
|
+
const prevName = existing.name;
|
|
92
|
+
const pathChanged = !this.pathsEqual(existing.path, instance.path);
|
|
93
|
+
const nameChanged = existing.name !== instance.name;
|
|
94
|
+
const currentParentGuid =
|
|
95
|
+
existing.parent?.guid ?? existing.parentGuid ?? null;
|
|
96
|
+
const nextParentGuid = hasParentHint
|
|
97
|
+
? incomingParentGuid
|
|
98
|
+
: currentParentGuid;
|
|
99
|
+
const parentChanged =
|
|
100
|
+
hasParentHint && nextParentGuid !== currentParentGuid;
|
|
101
|
+
|
|
102
|
+
const nextSource =
|
|
103
|
+
instance.source !== undefined ? instance.source : existing.source;
|
|
104
|
+
|
|
105
|
+
if (pathChanged) {
|
|
106
|
+
this.unregisterSubtree(existing);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
existing.className = instance.className;
|
|
110
|
+
existing.name = instance.name;
|
|
111
|
+
existing.path = instance.path;
|
|
112
|
+
existing.parentGuid = nextParentGuid;
|
|
113
|
+
existing.source = nextSource;
|
|
114
|
+
|
|
115
|
+
if (pathChanged || nameChanged || parentChanged) {
|
|
116
|
+
this.reparentNode(existing, instance.path, nextParentGuid);
|
|
117
|
+
this.recalculateChildPaths(existing);
|
|
118
|
+
this.registerSubtree(existing);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
log.script(`Updated instance: ${instance.path.join("/")}`, "updated");
|
|
122
|
+
return {
|
|
123
|
+
node: existing,
|
|
124
|
+
pathChanged,
|
|
125
|
+
nameChanged,
|
|
126
|
+
parentChanged,
|
|
127
|
+
isNew: false,
|
|
128
|
+
prevPath,
|
|
129
|
+
prevName,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const node: TreeNode = {
|
|
134
|
+
guid: instance.guid,
|
|
135
|
+
className: instance.className,
|
|
136
|
+
name: instance.name,
|
|
137
|
+
path: instance.path,
|
|
138
|
+
parentGuid: incomingParentGuid,
|
|
139
|
+
source: instance.source,
|
|
140
|
+
children: new Map(),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
this.nodes.set(instance.guid, node);
|
|
144
|
+
this.reparentNode(node, instance.path, incomingParentGuid);
|
|
145
|
+
this.recalculateChildPaths(node);
|
|
146
|
+
this.registerSubtree(node);
|
|
147
|
+
|
|
148
|
+
log.script(`Created instance: ${instance.path.join("/")}`, "created");
|
|
149
|
+
return {
|
|
150
|
+
node,
|
|
151
|
+
pathChanged: false,
|
|
152
|
+
nameChanged: false,
|
|
153
|
+
parentChanged: false,
|
|
154
|
+
isNew: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Process a full snapshot from Studio
|
|
160
|
+
*/
|
|
161
|
+
public applyFullSnapshot(instances: InstanceData[]): void {
|
|
162
|
+
log.info(`Processing full snapshot: ${instances.length} instances`);
|
|
163
|
+
|
|
164
|
+
// Clear existing tree
|
|
165
|
+
this.nodes.clear();
|
|
166
|
+
this.pathIndex.clear();
|
|
167
|
+
this.root = null;
|
|
168
|
+
|
|
169
|
+
// First pass: create all nodes
|
|
170
|
+
for (const instance of instances) {
|
|
171
|
+
const node: TreeNode = {
|
|
172
|
+
guid: instance.guid,
|
|
173
|
+
className: instance.className,
|
|
174
|
+
name: instance.name,
|
|
175
|
+
path: instance.path,
|
|
176
|
+
parentGuid: instance.parentGuid ?? null,
|
|
177
|
+
source: instance.source,
|
|
178
|
+
children: new Map(),
|
|
179
|
+
};
|
|
180
|
+
this.nodes.set(instance.guid, node);
|
|
181
|
+
this.addToPathIndex(node);
|
|
182
|
+
log.debug(`Created node: ${instance.path.join("/")}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Second pass: build hierarchy
|
|
186
|
+
for (const instance of instances) {
|
|
187
|
+
const node = this.nodes.get(instance.guid);
|
|
188
|
+
if (!node) continue;
|
|
189
|
+
|
|
190
|
+
if (instance.path.length === 1) {
|
|
191
|
+
// This is a root service
|
|
192
|
+
if (!this.root) {
|
|
193
|
+
this.root = {
|
|
194
|
+
guid: "root",
|
|
195
|
+
className: "DataModel",
|
|
196
|
+
name: "game",
|
|
197
|
+
path: [],
|
|
198
|
+
parentGuid: null,
|
|
199
|
+
children: new Map(),
|
|
200
|
+
};
|
|
201
|
+
this.nodes.set("root", this.root);
|
|
202
|
+
this.addToPathIndex(this.root);
|
|
203
|
+
}
|
|
204
|
+
this.root.children.set(node.guid, node);
|
|
205
|
+
node.parent = this.root;
|
|
206
|
+
node.parentGuid = this.root.guid;
|
|
207
|
+
log.debug(`Assigned root parent for: ${instance.path.join("/")}`);
|
|
208
|
+
} else {
|
|
209
|
+
// Find parent by matching path
|
|
210
|
+
const parentPath = instance.path.slice(0, -1);
|
|
211
|
+
const explicitParentGuid = instance.parentGuid ?? null;
|
|
212
|
+
let parent: TreeNode | undefined;
|
|
213
|
+
|
|
214
|
+
if (explicitParentGuid) {
|
|
215
|
+
parent = this.nodes.get(explicitParentGuid);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!parent) {
|
|
219
|
+
parent = this.findNodeByPath(parentPath);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (parent) {
|
|
223
|
+
parent.children.set(node.guid, node);
|
|
224
|
+
node.parent = parent;
|
|
225
|
+
node.parentGuid = parent.guid;
|
|
226
|
+
log.debug(`Assigned parent for: ${instance.path.join("/")}`);
|
|
227
|
+
} else {
|
|
228
|
+
log.warn(`Parent not found for ${instance.path.join("/")}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
log.success(`Tree built: ${this.nodes.size} nodes`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Update child paths iteratively
|
|
238
|
+
*/
|
|
239
|
+
private recalculateChildPaths(node: TreeNode): void {
|
|
240
|
+
const queue: TreeNode[] = [...node.children.values()];
|
|
241
|
+
|
|
242
|
+
while (queue.length > 0) {
|
|
243
|
+
const child = queue.shift()!;
|
|
244
|
+
child.path = [...child.parent!.path, child.name];
|
|
245
|
+
|
|
246
|
+
for (const grandchild of child.children.values()) {
|
|
247
|
+
queue.push(grandchild);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
public getDescendantScripts(guid: string): TreeNode[] {
|
|
253
|
+
const start = this.nodes.get(guid);
|
|
254
|
+
if (!start) {
|
|
255
|
+
return [];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const scripts: TreeNode[] = [];
|
|
259
|
+
const stack: TreeNode[] = [...start.children.values()];
|
|
260
|
+
|
|
261
|
+
while (stack.length > 0) {
|
|
262
|
+
const node = stack.pop()!;
|
|
263
|
+
|
|
264
|
+
if (this.isScriptNode(node)) {
|
|
265
|
+
scripts.push(node);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const child of node.children.values()) {
|
|
269
|
+
stack.push(child);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return scripts;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private isScriptNode(node: TreeNode): boolean {
|
|
277
|
+
return (
|
|
278
|
+
node.className === "Script" ||
|
|
279
|
+
node.className === "LocalScript" ||
|
|
280
|
+
node.className === "ModuleScript"
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private pathsEqual(a: string[], b: string[]): boolean {
|
|
285
|
+
if (a.length !== b.length) return false;
|
|
286
|
+
return a.every((segment, idx) => segment === b[idx]);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Delete an instance by GUID
|
|
291
|
+
*/
|
|
292
|
+
public deleteInstance(guid: string): TreeNode | null {
|
|
293
|
+
const node = this.nodes.get(guid);
|
|
294
|
+
if (!node) {
|
|
295
|
+
log.debug(`Delete ignored for missing node: ${guid}`);
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Detach from parent first so no one references this subtree
|
|
300
|
+
if (node.parent) {
|
|
301
|
+
node.parent.children.delete(guid);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Iterative delete to avoid repeated recursion work on large subtrees
|
|
305
|
+
const stack: TreeNode[] = [node];
|
|
306
|
+
while (stack.length > 0) {
|
|
307
|
+
const current = stack.pop()!;
|
|
308
|
+
for (const child of current.children.values()) {
|
|
309
|
+
stack.push(child);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.removeFromPathIndex(current);
|
|
313
|
+
this.nodes.delete(current.guid);
|
|
314
|
+
|
|
315
|
+
// Break references to help GC and prevent accidental reuse
|
|
316
|
+
current.children.clear();
|
|
317
|
+
current.parent = undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
log.script(`Deleted instance: ${node.path.join("/")}`, "deleted");
|
|
321
|
+
return node;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Update script source only
|
|
326
|
+
*/
|
|
327
|
+
public updateScriptSource(guid: string, source: string): void {
|
|
328
|
+
const node = this.nodes.get(guid);
|
|
329
|
+
if (node) {
|
|
330
|
+
node.source = source;
|
|
331
|
+
log.debug(`Updated script source: ${node.path.join("/")}`);
|
|
332
|
+
} else {
|
|
333
|
+
log.warn(`Script not found for GUID: ${guid}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Get a node by GUID
|
|
339
|
+
*/
|
|
340
|
+
public getNode(guid: string): TreeNode | undefined {
|
|
341
|
+
return this.nodes.get(guid);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Get all nodes
|
|
346
|
+
*/
|
|
347
|
+
public getAllNodes(): Map<string, TreeNode> {
|
|
348
|
+
return this.nodes;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Get all script nodes
|
|
353
|
+
*/
|
|
354
|
+
public getScriptNodes(): TreeNode[] {
|
|
355
|
+
return Array.from(this.nodes.values()).filter((node) =>
|
|
356
|
+
this.isScriptNode(node),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Find a node by its path
|
|
362
|
+
*/
|
|
363
|
+
private findNodeByPath(path: string[]): TreeNode | undefined {
|
|
364
|
+
const bucket = this.pathIndex.get(this.pathKey(path));
|
|
365
|
+
if (!bucket || bucket.size === 0) {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (bucket.size === 1) {
|
|
370
|
+
return bucket.values().next().value;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Ambiguous path (same-name siblings); caller should use parent GUIDs instead
|
|
374
|
+
log.debug(
|
|
375
|
+
`Multiple nodes share path ${path.join("/")}, skipping path lookup`,
|
|
376
|
+
);
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Re-parent a node based on its path
|
|
382
|
+
*/
|
|
383
|
+
private reparentNode(
|
|
384
|
+
node: TreeNode,
|
|
385
|
+
path: string[],
|
|
386
|
+
parentGuid?: string | null,
|
|
387
|
+
): void {
|
|
388
|
+
// Remove from old parent
|
|
389
|
+
if (node.parent) {
|
|
390
|
+
node.parent.children.delete(node.guid);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Find new parent (prefer explicit parent GUID when present)
|
|
394
|
+
let parent: TreeNode | undefined;
|
|
395
|
+
|
|
396
|
+
if (parentGuid) {
|
|
397
|
+
parent = this.nodes.get(parentGuid);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!parent) {
|
|
401
|
+
if (path.length === 1) {
|
|
402
|
+
// Root service
|
|
403
|
+
if (!this.root) {
|
|
404
|
+
this.root = {
|
|
405
|
+
guid: "root",
|
|
406
|
+
className: "DataModel",
|
|
407
|
+
name: "game",
|
|
408
|
+
path: [],
|
|
409
|
+
parentGuid: null,
|
|
410
|
+
children: new Map(),
|
|
411
|
+
};
|
|
412
|
+
this.nodes.set("root", this.root);
|
|
413
|
+
this.addToPathIndex(this.root);
|
|
414
|
+
}
|
|
415
|
+
parent = this.root;
|
|
416
|
+
} else {
|
|
417
|
+
const parentPath = path.slice(0, -1);
|
|
418
|
+
parent = this.findNodeByPath(parentPath);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (parent) {
|
|
423
|
+
parent.children.set(node.guid, node);
|
|
424
|
+
node.parent = parent;
|
|
425
|
+
node.parentGuid = parent.guid;
|
|
426
|
+
} else {
|
|
427
|
+
log.debug(`Parent not found for re-parenting: ${path.join("/")}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get tree statistics
|
|
433
|
+
*/
|
|
434
|
+
public getStats(): {
|
|
435
|
+
totalNodes: number;
|
|
436
|
+
scriptNodes: number;
|
|
437
|
+
maxDepth: number;
|
|
438
|
+
} {
|
|
439
|
+
let scriptCount = 0;
|
|
440
|
+
let maxDepth = 0;
|
|
441
|
+
|
|
442
|
+
for (const node of this.nodes.values()) {
|
|
443
|
+
if (this.isScriptNode(node)) {
|
|
444
|
+
scriptCount += 1;
|
|
445
|
+
}
|
|
446
|
+
const depth = node.path.length;
|
|
447
|
+
if (depth > maxDepth) {
|
|
448
|
+
maxDepth = depth;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
totalNodes: this.nodes.size,
|
|
454
|
+
scriptNodes: scriptCount,
|
|
455
|
+
maxDepth,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as chokidar from "chokidar";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { log } from "../util/log.js";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
|
|
7
|
+
export type FileChangeHandler = (filePath: string, source: string) => void;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Watches the filesystem for changes and notifies handlers
|
|
11
|
+
*/
|
|
12
|
+
export class FileWatcher {
|
|
13
|
+
private watcher: chokidar.FSWatcher | null = null;
|
|
14
|
+
private changeHandler: FileChangeHandler | null = null;
|
|
15
|
+
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
16
|
+
private suppressedUntil: Map<string, number> = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Start watching a directory
|
|
20
|
+
*/
|
|
21
|
+
public watch(directory: string): void {
|
|
22
|
+
if (this.watcher) {
|
|
23
|
+
log.warn("Watcher already running, stopping it first");
|
|
24
|
+
this.stop();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
log.info(`Starting file watcher on: ${directory}`);
|
|
28
|
+
|
|
29
|
+
this.watcher = chokidar.watch(directory, {
|
|
30
|
+
persistent: true,
|
|
31
|
+
ignoreInitial: true,
|
|
32
|
+
awaitWriteFinish: {
|
|
33
|
+
stabilityThreshold: 100,
|
|
34
|
+
pollInterval: 50,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
this.watcher.on("change", (filePath) => {
|
|
39
|
+
this.handleFileChange(filePath);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
this.watcher.on("error", (error) => {
|
|
43
|
+
log.error("File watcher error:", error);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.watcher.on("ready", () => {
|
|
47
|
+
log.success("File watcher ready");
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handle a file change with debouncing
|
|
53
|
+
*/
|
|
54
|
+
private handleFileChange(filePath: string): void {
|
|
55
|
+
// Clear existing timer for this file
|
|
56
|
+
const existingTimer = this.debounceTimers.get(filePath);
|
|
57
|
+
if (existingTimer) {
|
|
58
|
+
clearTimeout(existingTimer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Set new debounced timer
|
|
62
|
+
const timer = setTimeout(() => {
|
|
63
|
+
this.processFileChange(filePath);
|
|
64
|
+
this.debounceTimers.delete(filePath);
|
|
65
|
+
}, config.fileWatchDebounce);
|
|
66
|
+
|
|
67
|
+
this.debounceTimers.set(filePath, timer);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Process a file change after debouncing
|
|
72
|
+
*/
|
|
73
|
+
private processFileChange(filePath: string): void {
|
|
74
|
+
const normalizedPath = path.resolve(filePath);
|
|
75
|
+
|
|
76
|
+
// Skip if this change was produced by a Studio-originated write
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const suppressUntil = this.suppressedUntil.get(normalizedPath);
|
|
79
|
+
if (suppressUntil && suppressUntil > now) {
|
|
80
|
+
log.debug(
|
|
81
|
+
`File change suppressed (Studio-originated): ${normalizedPath}`
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Only process script files
|
|
87
|
+
if (!this.isScriptFile(filePath)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
93
|
+
log.debug(`File changed: ${normalizedPath}`);
|
|
94
|
+
|
|
95
|
+
if (this.changeHandler) {
|
|
96
|
+
this.changeHandler(normalizedPath, source);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
log.error(`Failed to read changed file ${filePath}:`, error);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a file is a script file
|
|
105
|
+
*/
|
|
106
|
+
private isScriptFile(filePath: string): boolean {
|
|
107
|
+
return filePath.endsWith(".lua") || filePath.endsWith(".luau");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Register a handler for file changes
|
|
112
|
+
*/
|
|
113
|
+
public onChange(handler: FileChangeHandler): void {
|
|
114
|
+
this.changeHandler = handler;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Suppress the next change event for a specific file path (normalized)
|
|
119
|
+
*/
|
|
120
|
+
public suppressNextChange(filePath: string): void {
|
|
121
|
+
const normalizedPath = path.resolve(filePath);
|
|
122
|
+
const until = Date.now() + 1000; // 1s window to absorb duplicate events
|
|
123
|
+
this.suppressedUntil.set(normalizedPath, until);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Stop watching
|
|
128
|
+
*/
|
|
129
|
+
public async stop(): Promise<void> {
|
|
130
|
+
if (this.watcher) {
|
|
131
|
+
await this.watcher.close();
|
|
132
|
+
this.watcher = null;
|
|
133
|
+
log.info("File watcher stopped");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Clear all pending timers
|
|
137
|
+
for (const timer of this.debounceTimers.values()) {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
this.debounceTimers.clear();
|
|
141
|
+
}
|
|
142
|
+
}
|