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.
Files changed (133) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  4. package/README.md +142 -0
  5. package/dist/build.d.ts +19 -0
  6. package/dist/build.d.ts.map +1 -0
  7. package/dist/build.js +92 -0
  8. package/dist/build.js.map +1 -0
  9. package/dist/cli.d.ts +3 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +397 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/config.d.ts +26 -0
  14. package/dist/config.d.ts.map +1 -0
  15. package/dist/config.js +105 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/fs/fileWriter.d.ts +100 -0
  18. package/dist/fs/fileWriter.d.ts.map +1 -0
  19. package/dist/fs/fileWriter.js +342 -0
  20. package/dist/fs/fileWriter.js.map +1 -0
  21. package/dist/fs/treeManager.d.ts +84 -0
  22. package/dist/fs/treeManager.d.ts.map +1 -0
  23. package/dist/fs/treeManager.js +365 -0
  24. package/dist/fs/treeManager.js.map +1 -0
  25. package/dist/fs/watcher.d.ts +39 -0
  26. package/dist/fs/watcher.d.ts.map +1 -0
  27. package/dist/fs/watcher.js +120 -0
  28. package/dist/fs/watcher.js.map +1 -0
  29. package/dist/index.d.ts +61 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +349 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/ipc/httpPolling.d.ts +56 -0
  34. package/dist/ipc/httpPolling.d.ts.map +1 -0
  35. package/dist/ipc/httpPolling.js +171 -0
  36. package/dist/ipc/httpPolling.js.map +1 -0
  37. package/dist/ipc/messages.d.ts +112 -0
  38. package/dist/ipc/messages.d.ts.map +1 -0
  39. package/dist/ipc/messages.js +5 -0
  40. package/dist/ipc/messages.js.map +1 -0
  41. package/dist/ipc/server.d.ts +50 -0
  42. package/dist/ipc/server.d.ts.map +1 -0
  43. package/dist/ipc/server.js +168 -0
  44. package/dist/ipc/server.js.map +1 -0
  45. package/dist/pack.d.ts +19 -0
  46. package/dist/pack.d.ts.map +1 -0
  47. package/dist/pack.js +225 -0
  48. package/dist/pack.js.map +1 -0
  49. package/dist/push.d.ts +43 -0
  50. package/dist/push.d.ts.map +1 -0
  51. package/dist/push.js +532 -0
  52. package/dist/push.js.map +1 -0
  53. package/dist/rojo.d.ts +9 -0
  54. package/dist/rojo.d.ts.map +1 -0
  55. package/dist/rojo.js +114 -0
  56. package/dist/rojo.js.map +1 -0
  57. package/dist/snapshot/rojo.d.ts +39 -0
  58. package/dist/snapshot/rojo.d.ts.map +1 -0
  59. package/dist/snapshot/rojo.js +364 -0
  60. package/dist/snapshot/rojo.js.map +1 -0
  61. package/dist/snapshot.d.ts +23 -0
  62. package/dist/snapshot.d.ts.map +1 -0
  63. package/dist/snapshot.js +132 -0
  64. package/dist/snapshot.js.map +1 -0
  65. package/dist/sourcemap/generator.d.ts +78 -0
  66. package/dist/sourcemap/generator.d.ts.map +1 -0
  67. package/dist/sourcemap/generator.js +351 -0
  68. package/dist/sourcemap/generator.js.map +1 -0
  69. package/dist/sourcemap/propertyLoader.d.ts +19 -0
  70. package/dist/sourcemap/propertyLoader.d.ts.map +1 -0
  71. package/dist/sourcemap/propertyLoader.js +131 -0
  72. package/dist/sourcemap/propertyLoader.js.map +1 -0
  73. package/dist/util/id.d.ts +9 -0
  74. package/dist/util/id.d.ts.map +1 -0
  75. package/dist/util/id.js +14 -0
  76. package/dist/util/id.js.map +1 -0
  77. package/dist/util/log.d.ts +13 -0
  78. package/dist/util/log.d.ts.map +1 -0
  79. package/dist/util/log.js +51 -0
  80. package/dist/util/log.js.map +1 -0
  81. package/docs/assets/azul-logo.pdn +0 -0
  82. package/docs/assets/logo-200px.png +0 -0
  83. package/docs/assets/logo.png +0 -0
  84. package/docs/assets/plugin/toolbox.png +0 -0
  85. package/docs/assets/synced.png +0 -0
  86. package/package.json +41 -0
  87. package/plugin/README.md +54 -0
  88. package/plugin/sourcemap.json +264 -0
  89. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Actor/AzulSync.server.luau +905 -0
  90. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/AzulService.luau +1010 -0
  91. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Config.luau +29 -0
  92. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/Enums.luau +11 -0
  93. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CollapsibleTitledSection.luau +214 -0
  94. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ColorPicker.luau +360 -0
  95. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/CustomTextButton.luau +170 -0
  96. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/DropdownMenu.luau +363 -0
  97. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/HorizontalLine.luau +43 -0
  98. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/ImageButtonWithText.luau +181 -0
  99. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledCheckbox.luau +295 -0
  100. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledColorInputPicker.luau +294 -0
  101. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledMultiChoice.luau +163 -0
  102. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledNumberInput.luau +312 -0
  103. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledRadioButton.luau +55 -0
  104. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledSlider.luau +151 -0
  105. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledTextInput.luau +222 -0
  106. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/LabeledToggleButton.luau +73 -0
  107. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/StatefulImageButton.luau +125 -0
  108. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalScrollingFrame.luau +100 -0
  109. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticalSpacer.luau +35 -0
  110. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/Components/VerticallyScalingListFrame.luau +107 -0
  111. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/GuiUtilities.luau +429 -0
  112. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/StudioWidgets/RbxGui.luau +4363 -0
  113. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/UI.luau +425 -0
  114. package/plugin/sync/ReplicatedFirst/AzulCompanionPlugin/WebSocketClient.luau +161 -0
  115. package/src/build.ts +120 -0
  116. package/src/cli.ts +496 -0
  117. package/src/config.ts +170 -0
  118. package/src/fs/fileWriter.ts +414 -0
  119. package/src/fs/treeManager.ts +458 -0
  120. package/src/fs/watcher.ts +142 -0
  121. package/src/index.ts +450 -0
  122. package/src/ipc/httpPolling.ts +214 -0
  123. package/src/ipc/messages.ts +159 -0
  124. package/src/ipc/server.ts +196 -0
  125. package/src/pack.ts +309 -0
  126. package/src/push.ts +726 -0
  127. package/src/snapshot/rojo.ts +467 -0
  128. package/src/snapshot.ts +161 -0
  129. package/src/sourcemap/generator.ts +504 -0
  130. package/src/sourcemap/propertyLoader.ts +195 -0
  131. package/src/util/id.ts +15 -0
  132. package/src/util/log.ts +94 -0
  133. 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
+ }