@superblocksteam/vite-plugin-file-sync 2.0.6-next.4 → 2.0.6-next.40

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 (112) hide show
  1. package/dist/ai-service/app-interface/shell.d.ts +1 -0
  2. package/dist/ai-service/app-interface/shell.d.ts.map +1 -1
  3. package/dist/ai-service/app-interface/shell.js +8 -1
  4. package/dist/ai-service/app-interface/shell.js.map +1 -1
  5. package/dist/ai-service/const.d.ts +2 -0
  6. package/dist/ai-service/const.d.ts.map +1 -1
  7. package/dist/ai-service/const.js +2 -0
  8. package/dist/ai-service/const.js.map +1 -1
  9. package/dist/ai-service/prompts/generated/library-components/SbButtonPropsDocs.js +1 -1
  10. package/dist/ai-service/prompts/generated/library-components/SbCheckboxPropsDocs.js +1 -1
  11. package/dist/ai-service/prompts/generated/library-components/SbColumnPropsDocs.js +1 -1
  12. package/dist/ai-service/prompts/generated/library-components/SbContainerPropsDocs.js +1 -1
  13. package/dist/ai-service/prompts/generated/library-components/SbDatePickerPropsDocs.js +1 -1
  14. package/dist/ai-service/prompts/generated/library-components/SbDropdownPropsDocs.js +1 -1
  15. package/dist/ai-service/prompts/generated/library-components/SbIconPropsDocs.js +1 -1
  16. package/dist/ai-service/prompts/generated/library-components/SbImagePropsDocs.js +1 -1
  17. package/dist/ai-service/prompts/generated/library-components/SbInputPropsDocs.js +1 -1
  18. package/dist/ai-service/prompts/generated/library-components/SbModalPropsDocs.js +1 -1
  19. package/dist/ai-service/prompts/generated/library-components/SbPagePropsDocs.js +1 -1
  20. package/dist/ai-service/prompts/generated/library-components/SbSectionPropsDocs.js +1 -1
  21. package/dist/ai-service/prompts/generated/library-components/SbSlideoutPropsDocs.js +1 -1
  22. package/dist/ai-service/prompts/generated/library-components/SbSwitchPropsDocs.js +1 -1
  23. package/dist/ai-service/prompts/generated/library-components/SbTablePropsDocs.js +1 -1
  24. package/dist/ai-service/prompts/generated/library-components/SbTextPropsDocs.js +1 -1
  25. package/dist/ai-service/prompts/generated/library-typedefs/Dim.js +1 -1
  26. package/dist/ai-service/prompts/generated/library-typedefs/SbEventFlow.js +1 -1
  27. package/dist/ai-service/prompts/generated/subprompts/full-examples.js +1 -1
  28. package/dist/ai-service/prompts/generated/subprompts/superblocks-api.js +1 -1
  29. package/dist/ai-service/prompts/generated/subprompts/superblocks-components-rules.js +1 -1
  30. package/dist/ai-service/prompts/generated/subprompts/superblocks-custom-components.js +1 -1
  31. package/dist/ai-service/prompts/generated/subprompts/superblocks-data-filtering.js +1 -1
  32. package/dist/ai-service/prompts/generated/subprompts/superblocks-event-flow.js +1 -1
  33. package/dist/ai-service/prompts/generated/subprompts/superblocks-forms.js +1 -1
  34. package/dist/ai-service/prompts/generated/subprompts/superblocks-layouts.js +1 -1
  35. package/dist/ai-service/prompts/generated/subprompts/superblocks-page.js +1 -1
  36. package/dist/ai-service/prompts/generated/subprompts/superblocks-rbac.js +1 -1
  37. package/dist/ai-service/prompts/generated/subprompts/superblocks-routes.js +1 -1
  38. package/dist/ai-service/prompts/generated/subprompts/superblocks-state.js +1 -1
  39. package/dist/ai-service/prompts/generated/subprompts/superblocks-theming.js +1 -1
  40. package/dist/ai-service/prompts/generated/subprompts/system.js +1 -1
  41. package/dist/ai-service/state-machine/clark-fsm.d.ts +1 -0
  42. package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
  43. package/dist/ai-service/state-machine/handlers/agent-planning.d.ts.map +1 -1
  44. package/dist/ai-service/state-machine/handlers/agent-planning.js +2 -0
  45. package/dist/ai-service/state-machine/handlers/agent-planning.js.map +1 -1
  46. package/dist/ai-service/state-machine/handlers/llm-generating.d.ts +1 -1
  47. package/dist/ai-service/state-machine/handlers/llm-generating.d.ts.map +1 -1
  48. package/dist/ai-service/state-machine/handlers/llm-generating.js +40 -8
  49. package/dist/ai-service/state-machine/handlers/llm-generating.js.map +1 -1
  50. package/dist/ai-service/types.d.ts +1 -0
  51. package/dist/ai-service/types.d.ts.map +1 -1
  52. package/dist/ai-service/types.js.map +1 -1
  53. package/dist/binding-extraction/index.d.ts +2 -0
  54. package/dist/binding-extraction/index.d.ts.map +1 -0
  55. package/dist/binding-extraction/index.js +2 -0
  56. package/dist/binding-extraction/index.js.map +1 -0
  57. package/dist/file-sync-vite-plugin.d.ts.map +1 -1
  58. package/dist/file-sync-vite-plugin.js +12 -3
  59. package/dist/file-sync-vite-plugin.js.map +1 -1
  60. package/dist/file-system-helpers.d.ts +4 -0
  61. package/dist/file-system-helpers.d.ts.map +1 -1
  62. package/dist/file-system-helpers.js +10 -0
  63. package/dist/file-system-helpers.js.map +1 -1
  64. package/dist/file-system-manager.d.ts +40 -35
  65. package/dist/file-system-manager.d.ts.map +1 -1
  66. package/dist/file-system-manager.js +534 -471
  67. package/dist/file-system-manager.js.map +1 -1
  68. package/dist/index.d.ts +1 -0
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +1 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/inject-index-vite-plugin.js.map +1 -1
  73. package/dist/injected-index.d.ts +2 -2
  74. package/dist/injected-index.d.ts.map +1 -1
  75. package/dist/injected-index.js.map +1 -1
  76. package/dist/lock-service/index.d.ts.map +1 -1
  77. package/dist/lock-service/index.js +10 -2
  78. package/dist/lock-service/index.js.map +1 -1
  79. package/dist/operations/operation-processor.d.ts +24 -0
  80. package/dist/operations/operation-processor.d.ts.map +1 -0
  81. package/dist/operations/operation-processor.js +80 -0
  82. package/dist/operations/operation-processor.js.map +1 -0
  83. package/dist/operations/types.d.ts +8 -0
  84. package/dist/operations/types.d.ts.map +1 -0
  85. package/dist/operations/types.js +2 -0
  86. package/dist/operations/types.js.map +1 -0
  87. package/dist/parsing/computed/to-code-computed.d.ts.map +1 -1
  88. package/dist/parsing/computed/to-code-computed.js +3 -3
  89. package/dist/parsing/computed/to-code-computed.js.map +1 -1
  90. package/dist/parsing/events/to-code-events.d.ts.map +1 -1
  91. package/dist/parsing/events/to-code-events.js +7 -0
  92. package/dist/parsing/events/to-code-events.js.map +1 -1
  93. package/dist/parsing/events/to-value-events.d.ts.map +1 -1
  94. package/dist/parsing/events/to-value-events.js +28 -0
  95. package/dist/parsing/events/to-value-events.js.map +1 -1
  96. package/dist/parsing/index.d.ts +3 -0
  97. package/dist/parsing/index.d.ts.map +1 -0
  98. package/dist/parsing/index.js +3 -0
  99. package/dist/parsing/index.js.map +1 -0
  100. package/dist/parsing/template/index.js +1 -1
  101. package/dist/parsing/template/index.js.map +1 -1
  102. package/dist/parsing/template/to-code-template.d.ts +2 -1
  103. package/dist/parsing/template/to-code-template.d.ts.map +1 -1
  104. package/dist/parsing/template/to-code-template.js +2 -2
  105. package/dist/parsing/template/to-code-template.js.map +1 -1
  106. package/dist/socket-manager.d.ts +2 -2
  107. package/dist/socket-manager.d.ts.map +1 -1
  108. package/dist/source-tracker.d.ts +20 -20
  109. package/dist/source-tracker.d.ts.map +1 -1
  110. package/dist/source-tracker.js +31 -13
  111. package/dist/source-tracker.js.map +1 -1
  112. package/package.json +14 -6
@@ -12,8 +12,9 @@ import { generateJSXAttribute } from "./codegen.js";
12
12
  import { ComponentsManager } from "./components-manager.js";
13
13
  import { addLegacyCustomComponentVariables, modifyLegacyCustomComponentElements, modifyLegacyCustomComponentImports, } from "./custom-components.js";
14
14
  import { applyErrorHandling, } from "./errors/error-handler.js";
15
- import { getPageFolder, PAGES_DIRECTORY, ROUTES_FILE, SCOPE_FILE, } from "./file-system-helpers.js";
15
+ import { getApiFilePath, getPageFolder, isPageFilePath, PAGES_DIRECTORY, ROUTES_FILE, SCOPE_FILE, } from "./file-system-helpers.js";
16
16
  import { generate } from "./generate.js";
17
+ import { OperationProcessor } from "./operations/operation-processor.js";
17
18
  import { doesElementHaveBinding } from "./parsing/bindings.js";
18
19
  import { getSbElementId } from "./parsing/ids.js";
19
20
  import { makeJSXAttribute } from "./parsing/jsx.js";
@@ -47,24 +48,34 @@ const SUPPORTED_FILETYPES = [
47
48
  },
48
49
  ];
49
50
  const APP_THEME_FILE_NAME = "appTheme.ts";
50
- export class FileSyncManager extends TracedEventEmitter {
51
+ export class FileSystemManager extends TracedEventEmitter {
51
52
  rootDir;
52
53
  tsFiles = {};
53
54
  apiFiles = {};
54
55
  sourceTracker;
55
56
  fsOperationQueue;
57
+ operationProcessor;
56
58
  generationNumberSequence;
57
59
  routes = {};
58
60
  watcher;
59
61
  registeredComponentPaths = {};
60
62
  renameManager = new RenameManager();
61
63
  _tracer;
64
+ transactionNonce = Date.now();
65
+ pendingTransactions = new Set();
66
+ processedTransactions = [];
62
67
  constructor(fsOperationQueue, generationNumberSequence, tracer) {
63
68
  super(tracer, { captureRejections: true });
64
69
  this.rootDir = "/";
65
70
  this.fsOperationQueue = fsOperationQueue;
66
71
  this.generationNumberSequence = generationNumberSequence;
67
72
  this._tracer = tracer;
73
+ // intentionally a new queue here, we don't want to share the queue with the fsOperationQueue
74
+ this.operationProcessor = new OperationProcessor({
75
+ batchWindowMs: 50,
76
+ maxBatchSize: 100,
77
+ });
78
+ this.operationProcessor.disable();
68
79
  applyErrorHandling(this, {
69
80
  watch: { operation: "editing from code" },
70
81
  handleCreatePage: { operation: "creating a page" },
@@ -110,27 +121,7 @@ export class FileSyncManager extends TracedEventEmitter {
110
121
  }
111
122
  return path.join(this.rootDir, "App.tsx");
112
123
  }
113
- updateApi = (content, path) => {
114
- if (!this.rootDir) {
115
- throw new Error("Root directory not set");
116
- }
117
- const { api: apiContents, stepPathMap } = content;
118
- const pageName = getPageName(path);
119
- let scopeId = this.sourceTracker?.getScopeDefinitionForPage(pageName)?.id;
120
- if (!scopeId) {
121
- console.warn("Scope ID not found for API", apiContents.metadata.name);
122
- scopeId = "";
123
- }
124
- const updatedApi = {
125
- apiPb: yaml.parse(JSON.stringify(apiContents)),
126
- pageName,
127
- stepPathMap,
128
- scopeId,
129
- };
130
- const isNewApi = !this.apiFiles[path];
131
- this.apiFiles[path] = updatedApi;
132
- return { updatedApi, pageName, isNewApi };
133
- };
124
+ // MARK: core setup/init
134
125
  async watch(watcher, rootPath) {
135
126
  const logger = getLogger();
136
127
  this.rootDir = rootPath;
@@ -216,178 +207,10 @@ export class FileSyncManager extends TracedEventEmitter {
216
207
  return;
217
208
  const { type, path } = file;
218
209
  if (type === "api") {
219
- this.updateApi(content, path);
220
- }
221
- });
222
- const handleFileChange = async (event, filePath) => {
223
- logger.info(`File changed: ${filePath}, event: ${event}`);
224
- switch (event) {
225
- case "add": {
226
- const fileType = SUPPORTED_FILETYPES.find((f) => filePath.endsWith(f.extension));
227
- if (!fileType || !filePath.startsWith(rootPath)) {
228
- return;
229
- }
230
- const data = await readFile(filePath);
231
- if (typeof data !== "string")
232
- return;
233
- // if we match pages/*/index.tsx, we want to emit an addPage event
234
- if (/pages\/.*\/index\.tsx/.test(filePath)) {
235
- const file = await readFile(filePath);
236
- if (!file) {
237
- logger.error(`Failed to read file: ${filePath}`);
238
- return;
239
- }
240
- if (!(filePath in this.tsFiles)) {
241
- this.tsFiles[filePath] = file;
242
- this.handleNonVisualChangeByDeletingIds(filePath, file);
243
- this.emit("addPage", filePath);
244
- }
245
- }
246
- switch (fileType.type) {
247
- case "api":
248
- case "python-api-step":
249
- case "js-api-step": {
250
- await this.processApiUpdates(filePath, fileType);
251
- break;
252
- }
253
- }
254
- break;
255
- }
256
- case "change": {
257
- // Special routes file handling
258
- if (filePath === routePath) {
259
- const data = await readFile(filePath);
260
- try {
261
- this.routes = JSON.parse(data);
262
- this.emit("routesChanged", this.routes);
263
- }
264
- catch (e) {
265
- logger.error("Error parsing routes file", getErrorMeta(e));
266
- }
267
- return;
268
- }
269
- const fileType = SUPPORTED_FILETYPES.find((f) => filePath.endsWith(f.extension));
270
- if (!fileType || !filePath.startsWith(rootPath)) {
271
- return;
272
- }
273
- const data = await readFile(filePath);
274
- if (typeof data !== "string")
275
- return;
276
- switch (fileType.type) {
277
- case "tsx":
278
- case "scope":
279
- {
280
- if (!(filePath in this.tsFiles && this.tsFiles[filePath] === data)) {
281
- logger.info(`File changed: ${filePath} updating AST tracker`);
282
- this.tsFiles[filePath] = data;
283
- // only update the source tracker if the file is different
284
- this.handleNonVisualChangeByDeletingIds(filePath, data);
285
- this.emit("fileChanged", filePath, data, true);
286
- }
287
- else {
288
- logger.info(`File unchanged from last tracked state: ${filePath}`);
289
- this.emit("fileChanged", filePath, data, false);
290
- }
291
- }
292
- break;
293
- case "api":
294
- case "python-api-step":
295
- case "js-api-step":
296
- {
297
- await this.processApiUpdates(filePath, fileType);
298
- }
299
- break;
300
- }
301
- break;
302
- }
303
- case "unlink": {
304
- if (filePath in this.apiFiles) {
305
- const api = this.apiFiles[filePath];
306
- delete this.apiFiles[filePath];
307
- if (!api || !this.sourceTracker)
308
- break;
309
- const scopeId = await this.sourceTracker.deleteApi({
310
- pageName: api.pageName,
311
- apiName: api.apiPb.metadata.name,
312
- });
313
- const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
314
- await this.writeChanges(changes);
315
- this.emit("apiManualDelete", {
316
- api: {
317
- id: getClientApiId(api.apiPb.metadata.name, api.pageName),
318
- apiName: api.apiPb.metadata.name,
319
- // TODO(saksham): get pagename more defensively
320
- pageName: getPageName(filePath),
321
- scopeId,
322
- },
323
- });
324
- }
325
- if (filePath in this.tsFiles) {
326
- delete this.tsFiles[filePath];
327
- this.sourceTracker?.removeFile(filePath);
328
- this.emit("deletePage", filePath);
329
- }
330
- break;
331
- }
332
- }
333
- };
334
- watcher.on("all", async (event, filePath) => {
335
- const fileType = SUPPORTED_FILETYPES.find((f) => filePath.endsWith(f.extension));
336
- switch (fileType?.type) {
337
- case "tsx":
338
- case "scope":
339
- // these can be awaited to ensure sequential execution
340
- this.fsOperationQueue.enqueue(async () => {
341
- return await handleFileChange(event, filePath);
342
- });
343
- break;
344
- default:
345
- // some files including APIs cannot be awaited currently
346
- this.fsOperationQueue.enqueue(async () => {
347
- void handleFileChange(event, filePath);
348
- });
210
+ this.updateInternalApiData(content, path);
349
211
  }
350
212
  });
351
- }
352
- getApiFiles() {
353
- return this.formatApisToClientApis(this.apiFiles);
354
- }
355
- getTsFilePaths() {
356
- return Object.keys(this.tsFiles);
357
- }
358
- getSourceTracker() {
359
- return this.sourceTracker;
360
- }
361
- async writeFile(path, content, kind) {
362
- if (kind === "ts") {
363
- // happens eagerly regardless of error - possible desync
364
- this.tsFiles[path] = content;
365
- // TODO(george): as an optimization, we could update the memory snapshot of the file that SyncService is holding
366
- await this.fsOperationQueue.enqueue(async () => {
367
- await fs.writeFile(path, content);
368
- });
369
- }
370
- else if (kind === "api") {
371
- const currentApiFile = this.apiFiles[path];
372
- const apiPb = yaml.parse(content);
373
- // Client APIs have id, but server APIs don't
374
- if (apiPb.metadata.id) {
375
- delete apiPb.metadata.id;
376
- }
377
- const stepPathMap = currentApiFile?.stepPathMap ?? {};
378
- this.updateApi({ api: apiPb, stepPathMap }, path);
379
- // TODO(george): as an optimization, we could update the memory snapshot of the file that SyncService is holding
380
- await this.fsOperationQueue.enqueue(async () => {
381
- await writeApiFiles({ apiPb }, "api", path.split("/").slice(0, -1).join("/"), false, [], [], { extractLargeSourceFiles: true, minLinesForExtraction: 1 }, new Set(Object.keys(this.apiFiles)), stepPathMap);
382
- // stepPathMap will be generated after the write when the file doesn't exist
383
- if (this.apiFiles[path]) {
384
- this.apiFiles[path].stepPathMap = stepPathMap;
385
- }
386
- });
387
- }
388
- }
389
- readFile(path) {
390
- return this.tsFiles[path];
213
+ watcher.on("all", this.handleFileChange);
391
214
  }
392
215
  initializeSourceTracker() {
393
216
  this.sourceTracker = new SourceTracker(this._tracer);
@@ -496,6 +319,235 @@ export class FileSyncManager extends TracedEventEmitter {
496
319
  return null;
497
320
  }
498
321
  }
322
+ enableOperationsQueue() {
323
+ this.operationProcessor.enable();
324
+ }
325
+ disableOperationsQueue() {
326
+ this.operationProcessor.disable();
327
+ }
328
+ async flushOperations() {
329
+ await this.operationProcessor.flush();
330
+ }
331
+ // MARK: file change handling
332
+ handleFileChange = async (event, filePath) => {
333
+ const logger = getLogger();
334
+ logger.info(`File changed: ${filePath}, event: ${event}`);
335
+ const rootPath = this.rootDir;
336
+ if (!rootPath) {
337
+ throw new Error("Root directory not set");
338
+ }
339
+ // Skip directory events
340
+ if (event === "addDir" || event === "unlinkDir") {
341
+ return;
342
+ }
343
+ const routePath = path.join(rootPath, ROUTES_FILE);
344
+ const fileType = SUPPORTED_FILETYPES.find((f) => filePath.endsWith(f.extension));
345
+ // Only handle files we care about and that are in our root path
346
+ if (!fileType || !filePath.startsWith(rootPath)) {
347
+ return;
348
+ }
349
+ // Queue the operation based on the event type
350
+ switch (event) {
351
+ case "add": {
352
+ const data = await readFile(filePath);
353
+ if (typeof data !== "string")
354
+ return;
355
+ const isPage = isPageFilePath(filePath);
356
+ if (isPage) {
357
+ void this.operationProcessor.addOperation({
358
+ metadata: {
359
+ filePath,
360
+ },
361
+ execute: async () => {
362
+ const file = await readFile(filePath);
363
+ if (!file) {
364
+ logger.error(`Failed to read file: ${filePath}`);
365
+ return;
366
+ }
367
+ if (!(filePath in this.tsFiles)) {
368
+ this.tsFiles[filePath] = file;
369
+ this.handleNonVisualChangeByDeletingIds(filePath, file);
370
+ this.emit("addPage", filePath);
371
+ }
372
+ },
373
+ });
374
+ }
375
+ else {
376
+ void this.operationProcessor.addOperation({
377
+ metadata: {
378
+ filePath,
379
+ },
380
+ execute: async () => {
381
+ switch (fileType.type) {
382
+ case "api":
383
+ case "python-api-step":
384
+ case "js-api-step": {
385
+ await this.processApiFileUpdates(filePath, fileType);
386
+ break;
387
+ }
388
+ }
389
+ },
390
+ });
391
+ }
392
+ break;
393
+ }
394
+ case "change": {
395
+ if (filePath === routePath) {
396
+ void this.operationProcessor.addOperation({
397
+ metadata: {
398
+ filePath,
399
+ },
400
+ priority: true,
401
+ execute: async () => {
402
+ try {
403
+ const data = JSON.parse((await readFile(filePath)) ?? "{}");
404
+ if (!isEqual(this.routes, data)) {
405
+ this.routes = data;
406
+ this.emit("routesChanged", this.routes);
407
+ }
408
+ }
409
+ catch (e) {
410
+ logger.error("Error parsing routes file", getErrorMeta(e));
411
+ }
412
+ },
413
+ });
414
+ return;
415
+ }
416
+ else {
417
+ void this.operationProcessor.addOperation({
418
+ metadata: {
419
+ filePath,
420
+ },
421
+ execute: async () => {
422
+ const fileType = SUPPORTED_FILETYPES.find((f) => filePath.endsWith(f.extension));
423
+ if (!fileType || !filePath.startsWith(rootPath)) {
424
+ return;
425
+ }
426
+ const data = await readFile(filePath);
427
+ if (typeof data !== "string")
428
+ return;
429
+ switch (fileType.type) {
430
+ case "tsx":
431
+ case "scope":
432
+ {
433
+ if (!(filePath in this.tsFiles &&
434
+ this.tsFiles[filePath] === data)) {
435
+ logger.info(`File changed: ${filePath} updating AST tracker`);
436
+ this.tsFiles[filePath] = data;
437
+ // only update the source tracker if the file is different
438
+ this.handleNonVisualChangeByDeletingIds(filePath, data);
439
+ this.emit("fileChanged", filePath, data, true);
440
+ }
441
+ else {
442
+ logger.info(`File unchanged from last tracked state: ${filePath}`);
443
+ this.emit("fileChanged", filePath, data, false);
444
+ }
445
+ }
446
+ break;
447
+ case "api":
448
+ case "python-api-step":
449
+ case "js-api-step":
450
+ {
451
+ await this.processApiFileUpdates(filePath, fileType);
452
+ }
453
+ break;
454
+ }
455
+ },
456
+ });
457
+ }
458
+ break;
459
+ }
460
+ case "unlink": {
461
+ if (filePath in this.tsFiles) {
462
+ void this.operationProcessor.addOperation({
463
+ metadata: {
464
+ filePath,
465
+ },
466
+ execute: async () => {
467
+ await this.deleteTsFile(filePath);
468
+ },
469
+ });
470
+ }
471
+ else if (filePath in this.apiFiles) {
472
+ void this.operationProcessor.addOperation({
473
+ metadata: {
474
+ filePath,
475
+ },
476
+ execute: async () => {
477
+ await this.removeApiData(filePath);
478
+ },
479
+ });
480
+ }
481
+ break;
482
+ }
483
+ }
484
+ };
485
+ async deleteTsFile(filePath) {
486
+ delete this.tsFiles[filePath];
487
+ this.sourceTracker?.removeFile(filePath);
488
+ if (isPageFilePath(filePath)) {
489
+ this.emit("deletePage", filePath);
490
+ }
491
+ }
492
+ getTsFilePaths() {
493
+ return Object.keys(this.tsFiles);
494
+ }
495
+ getSourceTracker() {
496
+ return this.sourceTracker;
497
+ }
498
+ // MARK: fs read/write
499
+ async writeFile(path, content, kind) {
500
+ if (kind === "ts") {
501
+ // happens eagerly regardless of error - possible desync
502
+ this.tsFiles[path] = content;
503
+ // TODO(george): as an optimization, we could update the memory snapshot of the file that SyncService is holding
504
+ await this.fsOperationQueue.enqueue(async () => {
505
+ await fs.writeFile(path, content);
506
+ });
507
+ }
508
+ else if (kind === "api") {
509
+ const currentApiFile = this.apiFiles[path];
510
+ const apiPb = yaml.parse(content);
511
+ // Client APIs have id, but server APIs don't
512
+ if (apiPb.metadata.id) {
513
+ delete apiPb.metadata.id;
514
+ }
515
+ const stepPathMap = currentApiFile?.stepPathMap ?? {};
516
+ this.updateInternalApiData({ api: apiPb, stepPathMap }, path);
517
+ // TODO(george): as an optimization, we could update the memory snapshot of the file that SyncService is holding
518
+ await this.fsOperationQueue.enqueue(async () => {
519
+ await writeApiFiles({ apiPb }, "api", path.split("/").slice(0, -1).join("/"), false, [], [], { extractLargeSourceFiles: true, minLinesForExtraction: 1 }, new Set(Object.keys(this.apiFiles)), stepPathMap);
520
+ // stepPathMap will be generated after the write when the file doesn't exist
521
+ if (this.apiFiles[path]) {
522
+ this.apiFiles[path].stepPathMap = stepPathMap;
523
+ }
524
+ });
525
+ }
526
+ }
527
+ readFile(path) {
528
+ return this.tsFiles[path];
529
+ }
530
+ async addRoute(route, filePath) {
531
+ if (!this.rootDir) {
532
+ throw new Error("Root directory not set");
533
+ }
534
+ this.routes[route] = { file: this.getRelativeRoutePath(filePath) };
535
+ await this.writeFile(path.join(this.rootDir, ROUTES_FILE), JSON.stringify(this.routes, null, 2));
536
+ }
537
+ async removeRoute(filePath) {
538
+ if (!this.rootDir) {
539
+ throw new Error("Root directory not set");
540
+ }
541
+ const relativeFilePath = this.getRelativeRoutePath(filePath);
542
+ this.routes = Object.fromEntries(Object.entries(this.routes).filter(([_, value]) => value.file !== relativeFilePath));
543
+ await this.writeFile(path.join(this.rootDir, ROUTES_FILE), JSON.stringify(this.routes, null, 2));
544
+ }
545
+ async writeChanges(changes, callback) {
546
+ return Promise.all(changes.map(async ({ fileName, source, kind }) => {
547
+ await this.writeFile(fileName, source, kind);
548
+ return callback?.(fileName, source);
549
+ }));
550
+ }
499
551
  getLocalBindingEntities(path) {
500
552
  const logger = getLogger();
501
553
  const files = this.sourceTracker?.getCurrentFiles();
@@ -523,8 +575,36 @@ export class FileSyncManager extends TracedEventEmitter {
523
575
  });
524
576
  return Array.from(localBindingEntities);
525
577
  }
526
- pendingTransactions = new Set();
527
- processedTransactions = [];
578
+ getPageRoots(filePath) {
579
+ const scopeFilePath = path.join(path.dirname(filePath), "index.tsx");
580
+ const currentFile = this.sourceTracker?.getCurrentFiles()[scopeFilePath];
581
+ if (!currentFile) {
582
+ return null;
583
+ }
584
+ return getPageRoots(filePath, currentFile);
585
+ }
586
+ getScope(filePath) {
587
+ // get the scope.ts file in the same directory as path
588
+ const scopeFilePath = path.join(path.dirname(filePath), SCOPE_FILE);
589
+ const currentFile = this.sourceTracker?.getCurrentFiles()[scopeFilePath];
590
+ if (!currentFile) {
591
+ console.log("File not found", scopeFilePath);
592
+ return null;
593
+ }
594
+ const scope = getScope(scopeFilePath, currentFile);
595
+ return scope;
596
+ }
597
+ getRoutes() {
598
+ const routes = [];
599
+ for (const [path, { file }] of Object.entries(this.routes)) {
600
+ routes.push({
601
+ path,
602
+ component: file,
603
+ });
604
+ }
605
+ return routes;
606
+ }
607
+ // MARK: transaction handling
528
608
  flushTransactions = () => {
529
609
  // TODO do something more sophisticated than this.
530
610
  this.processedTransactions = this.processedTransactions.slice(-20);
@@ -537,13 +617,13 @@ export class FileSyncManager extends TracedEventEmitter {
537
617
  }
538
618
  this.pendingTransactions.add(transactionId);
539
619
  };
540
- transactionNonce = Date.now();
541
620
  // Until we have a sophisticated way to track ALL operations, including non-UI initiated ops, we will use a nonce
542
621
  // TODO https://github.com/superblocksteam/superblocks/pull/11788
543
622
  getProcessedTransactionsWithNonce() {
544
623
  const nonce = `t-${this.transactionNonce++}`;
545
624
  return this.processedTransactions.concat(nonce);
546
625
  }
626
+ // MARK: editor operations
547
627
  handleCreatePage = async (payload) => {
548
628
  const { name } = payload;
549
629
  if (!this.rootDir) {
@@ -560,7 +640,9 @@ function Page() {
560
640
 
561
641
  export default registerPage(Page, { name: "${name}" });
562
642
  `;
563
- await fs.mkdir(pagePath, { recursive: true });
643
+ await this.fsOperationQueue.enqueue(async () => {
644
+ await fs.mkdir(pagePath, { recursive: true });
645
+ });
564
646
  await this.writeFile(pageIndexPath, pageContent, "ts");
565
647
  await this.handleNonVisualChangeByDeletingIds(pageIndexPath, pageContent);
566
648
  this.emit("addPage", pageIndexPath);
@@ -568,11 +650,11 @@ export default registerPage(Page, { name: "${name}" });
568
650
  handleReparent = async (payload, writeFile = true) => {
569
651
  const { from, to, changedProps, transaction } = payload;
570
652
  this.trackTransaction(transaction?.id);
571
- await this.sourceTracker?.setProperties({
653
+ this.sourceTracker?.setProperties({
572
654
  source: from.source,
573
655
  changes: changedProps ?? {},
574
656
  });
575
- await this.sourceTracker?.moveElement({
657
+ this.sourceTracker?.moveElement({
576
658
  from,
577
659
  to,
578
660
  });
@@ -586,7 +668,7 @@ export default registerPage(Page, { name: "${name}" });
586
668
  };
587
669
  handleCreateComponent = async (payload, writeFile = true) => {
588
670
  this.trackTransaction(payload.transaction?.id);
589
- const sourceId = await this.sourceTracker?.addElement(payload);
671
+ const sourceId = this.sourceTracker?.addElement(payload);
590
672
  if (writeFile) {
591
673
  const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
592
674
  await this.writeChanges(changes ?? [], (fileName) => {
@@ -600,7 +682,7 @@ export default registerPage(Page, { name: "${name}" });
600
682
  const { elements, transaction } = payload;
601
683
  this.trackTransaction(transaction?.id);
602
684
  for (const element of elements) {
603
- await this.sourceTracker?.deleteElement({
685
+ this.sourceTracker?.deleteElement({
604
686
  source: element.source,
605
687
  scopeName: element.scopeName,
606
688
  });
@@ -616,7 +698,7 @@ export default registerPage(Page, { name: "${name}" });
616
698
  handleSetProperty = async (payload, writeFile = true) => {
617
699
  const { element: { source }, property, value, transaction, } = payload;
618
700
  this.trackTransaction(transaction?.id);
619
- await this.sourceTracker?.setProperty({
701
+ this.sourceTracker?.setProperty({
620
702
  source,
621
703
  property,
622
704
  info: value,
@@ -631,7 +713,7 @@ export default registerPage(Page, { name: "${name}" });
631
713
  handleSetProperties = async (payload, writeFile = true) => {
632
714
  const { element: { source }, properties, transaction, } = payload;
633
715
  this.trackTransaction(transaction?.id);
634
- await this.sourceTracker?.setProperties({
716
+ this.sourceTracker?.setProperties({
635
717
  source,
636
718
  changes: properties,
637
719
  });
@@ -687,184 +769,9 @@ export default registerPage(Page, { name: "${name}" });
687
769
  this.flushTransactions();
688
770
  return returnValues;
689
771
  };
690
- handleUpdateApi = async (payload) => {
691
- const { api } = payload;
692
- if (!this.sourceTracker) {
693
- throw new Error("Source tracker not initialized");
694
- }
695
- if (!this.rootDir) {
696
- throw new Error("Root directory not set");
697
- }
698
- if (!api.pageName) {
699
- throw new Error("API page name is not set");
700
- }
701
- const apiName = api.apiPb.metadata.name;
702
- if (!apiName) {
703
- throw new Error("API name is not set");
704
- }
705
- const apiDir = path.join(this.rootDir, "pages", api.pageName, "apis", apiName);
706
- const apiPath = path.join(apiDir, "api.yaml");
707
- const isNewApi = !this.getApiFiles()[apiPath];
708
- try {
709
- const stats = await fs.stat(apiDir);
710
- if (!stats.isDirectory()) {
711
- await fs.mkdir(apiDir, { recursive: true });
712
- }
713
- }
714
- catch {
715
- await fs.mkdir(apiDir, { recursive: true });
716
- }
717
- await this.writeFile(apiPath, yaml.stringify(api.apiPb), "api");
718
- const generationNumber = this.generationNumberSequence.next();
719
- const apiDef = this.createClientApi(api);
720
- let scopeId = "";
721
- if (isNewApi) {
722
- scopeId = await this.createScopedApi(api);
723
- }
724
- else {
725
- const scopeDef = this.sourceTracker.getScopeDefinitionForPage(api.pageName);
726
- scopeId = scopeDef?.id ?? "";
727
- }
728
- this.emit("apiUpdate", { api: apiDef, scopeId });
729
- return { api: apiDef, scopeId, generationNumber };
730
- };
731
- createScopedApi = async (api) => {
732
- if (!this.sourceTracker) {
733
- throw new Error("Source tracker not initialized");
734
- }
735
- // We want to add the API entity to our scope, but we do not want to emit entity events, because
736
- // the API update event handles this particular side effect.
737
- const scopeId = await this.sourceTracker.addApi({
738
- pageName: api.pageName,
739
- apiName: api.apiPb.metadata.name,
740
- });
741
- const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
742
- await this.writeChanges(changes);
743
- return scopeId;
744
- };
745
- handleDeleteApi = async (payload) => {
746
- const logger = getLogger();
747
- const { apis } = payload;
748
- if (!this.rootDir) {
749
- throw new Error("Root directory not set");
750
- }
751
- const rootDir = this.rootDir;
752
- const executeDeleteApis = Promise.all(apis.map(({ apiName, pageName }) => {
753
- return new Promise(
754
- // eslint-disable-next-line no-async-promise-executor
755
- async (resolve) => {
756
- const apiFilePath = path.join(rootDir, "pages", pageName, "apis", apiName, "api.yaml");
757
- const api = this.apiFiles[apiFilePath];
758
- if (!api || !this.sourceTracker) {
759
- return resolve(undefined);
760
- }
761
- const apiDir = path.join(rootDir, "pages", pageName, "apis", apiName);
762
- try {
763
- const stats = await fs.stat(apiDir);
764
- if (stats.isDirectory()) {
765
- await fs.rmdir(apiDir, { recursive: true });
766
- }
767
- delete this.apiFiles[apiFilePath];
768
- const scopeId = await this.sourceTracker.deleteApi({
769
- pageName,
770
- apiName,
771
- });
772
- resolve({
773
- apiName,
774
- pageName,
775
- scopeId,
776
- });
777
- }
778
- catch (e) {
779
- logger.warn(`Could not delete api ${apiFilePath} ${JSON.stringify(e)}`);
780
- resolve(undefined);
781
- }
782
- resolve(undefined);
783
- });
784
- }));
785
- const deletedApis = (await executeDeleteApis).filter((api) => api !== undefined);
786
- const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
787
- await this.writeChanges(changes);
788
- this.emit("apiDelete", {
789
- apis: deletedApis.filter((api) => api !== undefined),
790
- });
791
- return { deletedApis };
792
- };
793
- handleRenameApi = async (payload) => {
794
- const { oldName, newName, pageName } = payload;
795
- if (!this.rootDir) {
796
- throw new Error("Root directory not set");
797
- }
798
- const pagePath = getPageFolder(this.rootDir, pageName);
799
- const existingApiFolder = path.join(this.rootDir, "pages", pageName, "apis", oldName);
800
- const newApiFolder = path.join(this.rootDir, "pages", pageName, "apis", newName);
801
- const files = this.sourceTracker?.getCurrentFiles();
802
- const file = files?.[path.join(pagePath, "index.tsx")];
803
- if (!file || !file.ast) {
804
- throw new Error(`Page ${pageName} not found`);
805
- }
806
- const apiFilePath = path.join(existingApiFolder, "api.yaml");
807
- const apiDef = this.apiFiles[apiFilePath];
808
- if (!apiDef) {
809
- throw new Error(`API ${oldName} not found`);
810
- }
811
- const otherPageAPIs = structuredClone(Object.keys(this.apiFiles)
812
- .filter((path) => path.startsWith(pagePath) && path !== apiFilePath)
813
- .map((path) => ({
814
- api: { apiPb: this.apiFiles[path]?.apiPb },
815
- filePath: path,
816
- }))
817
- .filter((api) => !!api.api));
818
- const newApiFolderExists = await fs.stat(newApiFolder).catch(() => false);
819
- if (newApiFolderExists) {
820
- throw new Error(`API ${newName} already exists`);
821
- }
822
- this.watcher?.unwatch(existingApiFolder);
823
- this.watcher?.unwatch(newApiFolder);
824
- apiDef.apiPb.metadata.name = newName;
825
- await fs.rename(existingApiFolder, newApiFolder);
826
- const scopeDef = this.sourceTracker?.getScopeDefinitionForPage(pageName);
827
- if (!scopeDef) {
828
- throw new Error(`Scope definition not found for API`);
829
- }
830
- this.sourceTracker?.renameEntity({
831
- oldName,
832
- newName,
833
- entityId: scopeDef.scopeNameToEntityId[oldName],
834
- });
835
- delete this.apiFiles[apiFilePath];
836
- this.writeFile(path.join(newApiFolder, "api.yaml"), yaml.stringify(apiDef.apiPb), "api");
837
- this.watcher?.add(existingApiFolder);
838
- this.watcher?.add(newApiFolder);
839
- const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
840
- await this.writeChanges(changes);
841
- this.emit("apiManualUpdate", {
842
- api: this.createClientApi(apiDef),
843
- oldName,
844
- pageName,
845
- scopeId: scopeDef?.id ?? "",
846
- }, true);
847
- await this.renameManager.renameEntityInApis({
848
- oldName,
849
- newName,
850
- apis: otherPageAPIs,
851
- });
852
- // only save the APIs that have changed
853
- await Promise.all(otherPageAPIs.map(async ({ api, filePath }) => {
854
- if (isEqual(api?.apiPb, this.apiFiles[filePath]?.apiPb)) {
855
- return Promise.resolve();
856
- }
857
- await this.writeFile(filePath, yaml.stringify(api?.apiPb), "api");
858
- this.emit("apiManualUpdate", {
859
- api: this.createClientApi(api),
860
- pageName,
861
- scopeId: scopeDef?.id ?? "",
862
- }, true);
863
- // TODO: Should I delete here?
864
- }));
865
- };
772
+ // MARK: entity operations
866
773
  handleAddEntity = async (payload) => {
867
- await this.sourceTracker?.addEntity({
774
+ this.sourceTracker?.addEntity({
868
775
  scopeId: payload.scopeId,
869
776
  entity: {
870
777
  type: payload.type,
@@ -878,7 +785,7 @@ export default registerPage(Page, { name: "${name}" });
878
785
  });
879
786
  };
880
787
  handleUpdateEntity = async (payload) => {
881
- await this.sourceTracker?.updateEntity({
788
+ this.sourceTracker?.updateEntity({
882
789
  entityId: payload.entityId,
883
790
  updates: payload.updates,
884
791
  });
@@ -888,7 +795,7 @@ export default registerPage(Page, { name: "${name}" });
888
795
  });
889
796
  };
890
797
  handleDeleteEntity = async (payload) => {
891
- const deletedEntityName = await this.sourceTracker?.deleteEntity({
798
+ const deletedEntityName = this.sourceTracker?.deleteEntity({
892
799
  entityId: payload.entityId,
893
800
  });
894
801
  const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
@@ -904,13 +811,14 @@ export default registerPage(Page, { name: "${name}" });
904
811
  throw new Error("Root directory not set");
905
812
  }
906
813
  const filePath = path.join(this.rootDir, APP_THEME_FILE_NAME);
907
- await this.sourceTracker?.updateTheme({
814
+ this.sourceTracker?.updateTheme({
908
815
  themeFilePath: filePath,
909
816
  theme,
910
817
  });
911
818
  const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
912
819
  await this.writeChanges(changes);
913
820
  };
821
+ // MARK: rename operations
914
822
  handleRenameElement = async (payload) => {
915
823
  if (payload.kind === "component") {
916
824
  return this.handleRenameComponent(payload);
@@ -942,7 +850,7 @@ export default registerPage(Page, { name: "${name}" });
942
850
  };
943
851
  handleRenameEntity = async (payload) => {
944
852
  const { elementId, newName, oldName, scopeName } = payload;
945
- await this.sourceTracker?.renameEntity({
853
+ this.sourceTracker?.renameEntity({
946
854
  entityId: elementId,
947
855
  oldName,
948
856
  newName,
@@ -994,98 +902,204 @@ export default registerPage(Page, { name: "${name}" });
994
902
  this.watcher?.add(newPageFolder);
995
903
  this.watcher?.add(oldPageFolder);
996
904
  };
997
- getPageRoots(filePath) {
998
- const scopeFilePath = path.join(path.dirname(filePath), "index.tsx");
999
- const currentFile = this.sourceTracker?.getCurrentFiles()[scopeFilePath];
1000
- if (!currentFile) {
1001
- return null;
905
+ getRelativeRoutePath(filePath) {
906
+ if (!this.rootDir) {
907
+ throw new Error("Root directory not set");
1002
908
  }
1003
- return getPageRoots(filePath, currentFile);
909
+ // no leading slash
910
+ return path.relative(path.join(this.rootDir, PAGES_DIRECTORY), filePath);
1004
911
  }
1005
- getScope(filePath) {
1006
- // get the scope.ts file in the same directory as path
1007
- const scopeFilePath = path.join(path.dirname(filePath), SCOPE_FILE);
1008
- const currentFile = this.sourceTracker?.getCurrentFiles()[scopeFilePath];
1009
- if (!currentFile) {
1010
- console.log("File not found", scopeFilePath);
1011
- return null;
912
+ // MARK: API operations
913
+ handleUpdateApi = async (payload) => {
914
+ const { api } = payload;
915
+ if (!this.sourceTracker) {
916
+ throw new Error("Source tracker not initialized");
1012
917
  }
1013
- const scope = getScope(scopeFilePath, currentFile);
1014
- return scope;
1015
- }
1016
- getRoutes() {
1017
- const routes = [];
1018
- for (const [path, { file }] of Object.entries(this.routes)) {
1019
- routes.push({
1020
- path,
1021
- component: file,
1022
- });
918
+ if (!this.rootDir) {
919
+ throw new Error("Root directory not set");
1023
920
  }
1024
- return routes;
1025
- }
1026
- async addRoute(route, filePath) {
921
+ if (!api.pageName) {
922
+ throw new Error("API page name is not set");
923
+ }
924
+ const apiName = api.apiPb.metadata.name;
925
+ if (!apiName) {
926
+ throw new Error("API name is not set");
927
+ }
928
+ const apiFilePath = getApiFilePath(this.rootDir, api.pageName, apiName);
929
+ const apiDir = path.dirname(apiFilePath);
930
+ const isNewApi = !this.getApiFiles()[apiFilePath];
931
+ try {
932
+ const stats = await fs.stat(apiDir);
933
+ if (!stats.isDirectory()) {
934
+ await this.fsOperationQueue.enqueue(async () => await fs.mkdir(apiDir, { recursive: true }));
935
+ }
936
+ }
937
+ catch {
938
+ await this.fsOperationQueue.enqueue(async () => await fs.mkdir(apiDir, { recursive: true }));
939
+ }
940
+ await this.writeFile(apiFilePath, yaml.stringify(api.apiPb), "api");
941
+ const generationNumber = this.generationNumberSequence.next();
942
+ const apiDef = this.createClientApi(api);
943
+ let scopeId = "";
944
+ if (isNewApi) {
945
+ scopeId = await this.addApiToScope(api);
946
+ }
947
+ else {
948
+ const scopeDef = this.sourceTracker.getScopeDefinitionForPage(api.pageName);
949
+ scopeId = scopeDef?.id ?? "";
950
+ }
951
+ this.emit("apiUpdate", { api: apiDef, scopeId });
952
+ return { api: apiDef, scopeId, generationNumber };
953
+ };
954
+ handleDeleteApi = async (payload) => {
955
+ const logger = getLogger();
956
+ const { apis } = payload;
1027
957
  if (!this.rootDir) {
1028
958
  throw new Error("Root directory not set");
1029
959
  }
1030
- this.routes[route] = { file: this.getRelativeRoutePath(filePath) };
1031
- await this.writeFile(path.join(this.rootDir, ROUTES_FILE), JSON.stringify(this.routes, null, 2));
1032
- }
1033
- async removeRoute(filePath) {
960
+ const rootDir = this.rootDir;
961
+ const deletedApis = [];
962
+ for (const { apiName, pageName } of apis) {
963
+ const apiFilePath = getApiFilePath(rootDir, pageName, apiName);
964
+ const api = this.apiFiles[apiFilePath];
965
+ if (!api || !this.sourceTracker) {
966
+ continue;
967
+ }
968
+ const apiDir = path.dirname(apiFilePath);
969
+ try {
970
+ const stats = await fs.stat(apiDir);
971
+ if (stats.isDirectory()) {
972
+ await fs.rmdir(apiDir, { recursive: true });
973
+ }
974
+ delete this.apiFiles[apiFilePath];
975
+ const scopeId = this.sourceTracker.deleteApi({
976
+ pageName,
977
+ apiName,
978
+ });
979
+ deletedApis.push({
980
+ apiName,
981
+ pageName,
982
+ scopeId,
983
+ });
984
+ }
985
+ catch (e) {
986
+ logger.warn(`Could not delete api ${apiFilePath} ${JSON.stringify(e)}`);
987
+ }
988
+ }
989
+ const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
990
+ await this.writeChanges(changes);
991
+ this.emit("apiDelete", {
992
+ apis: deletedApis.filter((api) => api !== undefined),
993
+ });
994
+ return { deletedApis };
995
+ };
996
+ handleRenameApi = async (payload) => {
997
+ const { oldName, newName, pageName } = payload;
1034
998
  if (!this.rootDir) {
1035
999
  throw new Error("Root directory not set");
1036
1000
  }
1037
- const relativeFilePath = this.getRelativeRoutePath(filePath);
1038
- this.routes = Object.fromEntries(Object.entries(this.routes).filter(([_, value]) => value.file !== relativeFilePath));
1039
- await this.writeFile(path.join(this.rootDir, ROUTES_FILE), JSON.stringify(this.routes, null, 2));
1040
- }
1041
- async writeChanges(changes, callback) {
1042
- return Promise.all(changes.map(async ({ fileName, source, kind }) => {
1043
- await this.writeFile(fileName, source, kind);
1044
- return callback?.(fileName, source);
1045
- }));
1046
- }
1047
- getNodeForWidgetSourceId(id) {
1048
- return this.sourceTracker?.getElementToLocation(id);
1049
- }
1050
- getAstForWidgetSourceId(id) {
1051
- const filePath = this.sourceTracker?.getElementToFilePath(id);
1052
- if (!filePath) {
1053
- return null;
1001
+ const pagePath = getPageFolder(this.rootDir, pageName);
1002
+ const existingApiFolder = path.join(this.rootDir, "pages", pageName, "apis", oldName);
1003
+ const newApiFolder = path.join(this.rootDir, "pages", pageName, "apis", newName);
1004
+ const files = this.sourceTracker?.getCurrentFiles();
1005
+ const file = files?.[path.join(pagePath, "index.tsx")];
1006
+ if (!file || !file.ast) {
1007
+ throw new Error(`Page ${pageName} not found`);
1054
1008
  }
1055
- return this.sourceTracker?.getCurrentFiles()[filePath]?.ast;
1056
- }
1057
- renameIdentifierInApis = async ({ elementId, oldName, newName, parentBinding, }) => {
1058
- const apisInScope = structuredClone(this.getApisInScope(elementId));
1009
+ const apiFilePath = path.join(existingApiFolder, "api.yaml");
1010
+ const apiDef = this.apiFiles[apiFilePath];
1011
+ if (!apiDef) {
1012
+ throw new Error(`API ${oldName} not found`);
1013
+ }
1014
+ const otherPageAPIs = structuredClone(Object.keys(this.apiFiles)
1015
+ .filter((path) => path.startsWith(pagePath) && path !== apiFilePath)
1016
+ .map((path) => ({
1017
+ api: { apiPb: this.apiFiles[path]?.apiPb },
1018
+ filePath: path,
1019
+ }))
1020
+ .filter((api) => !!api.api));
1021
+ const newApiFolderExists = await fs.stat(newApiFolder).catch(() => false);
1022
+ if (newApiFolderExists) {
1023
+ throw new Error(`API ${newName} already exists`);
1024
+ }
1025
+ this.watcher?.unwatch(existingApiFolder);
1026
+ this.watcher?.unwatch(newApiFolder);
1027
+ apiDef.apiPb.metadata.name = newName;
1028
+ await fs.rename(existingApiFolder, newApiFolder);
1029
+ const scopeDef = this.sourceTracker?.getScopeDefinitionForPage(pageName);
1030
+ if (!scopeDef) {
1031
+ throw new Error(`Scope definition not found for API`);
1032
+ }
1033
+ this.sourceTracker?.renameEntity({
1034
+ oldName,
1035
+ newName,
1036
+ entityId: scopeDef.scopeNameToEntityId[oldName],
1037
+ });
1038
+ delete this.apiFiles[apiFilePath];
1039
+ this.writeFile(path.join(newApiFolder, "api.yaml"), yaml.stringify(apiDef.apiPb), "api");
1040
+ this.watcher?.add(existingApiFolder);
1041
+ this.watcher?.add(newApiFolder);
1042
+ const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
1043
+ await this.writeChanges(changes);
1044
+ this.emit("apiManualUpdate", {
1045
+ api: this.createClientApi(apiDef),
1046
+ oldName,
1047
+ pageName,
1048
+ scopeId: scopeDef?.id ?? "",
1049
+ }, true);
1059
1050
  await this.renameManager.renameEntityInApis({
1060
1051
  oldName,
1061
1052
  newName,
1062
- parentBinding,
1063
- apis: apisInScope,
1053
+ apis: otherPageAPIs,
1064
1054
  });
1065
1055
  // only save the APIs that have changed
1066
- await Promise.all(apisInScope.map(({ api, filePath }) => {
1056
+ await Promise.all(otherPageAPIs.map(async ({ api, filePath }) => {
1067
1057
  if (isEqual(api?.apiPb, this.apiFiles[filePath]?.apiPb)) {
1068
1058
  return Promise.resolve();
1069
1059
  }
1070
- return this.writeFile(filePath, yaml.stringify(api?.apiPb), "api");
1060
+ await this.writeFile(filePath, yaml.stringify(api?.apiPb), "api");
1061
+ this.emit("apiManualUpdate", {
1062
+ api: this.createClientApi(api),
1063
+ pageName,
1064
+ scopeId: scopeDef?.id ?? "",
1065
+ }, true);
1066
+ // TODO: Should I delete here?
1071
1067
  }));
1072
1068
  };
1073
- getApisInScope = (elementId) => {
1074
- const filePath = this.sourceTracker?.getElementToFilePath(elementId);
1075
- if (!filePath) {
1076
- return [];
1077
- }
1078
- const pagePath = path.dirname(filePath);
1079
- return Object.entries(this.apiFiles)
1080
- .filter(([filePath]) => filePath.includes(pagePath))
1081
- .map(([filePath, api]) => ({ api, filePath }));
1082
- };
1083
- getRelativeRoutePath(filePath) {
1084
- if (!this.rootDir) {
1085
- throw new Error("Root directory not set");
1069
+ async removeApiData(filePath) {
1070
+ const api = this.apiFiles[filePath];
1071
+ if (!api) {
1072
+ return;
1086
1073
  }
1087
- // no leading slash
1088
- return path.relative(path.join(this.rootDir, PAGES_DIRECTORY), filePath);
1074
+ delete this.apiFiles[filePath];
1075
+ this.sourceTracker?.deleteApi({
1076
+ pageName: api.pageName,
1077
+ apiName: api.apiPb.metadata.name,
1078
+ });
1079
+ const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
1080
+ await this.writeChanges(changes);
1081
+ const scopeId = this.sourceTracker?.getScopeDefinitionForPage(api.pageName)?.id;
1082
+ this.emit("apiManualDelete", {
1083
+ api: {
1084
+ id: getClientApiId(api.apiPb.metadata.name, api.pageName),
1085
+ apiName: api.apiPb.metadata.name,
1086
+ // TODO(saksham): get pagename more defensively
1087
+ pageName: getPageName(filePath),
1088
+ scopeId,
1089
+ },
1090
+ });
1091
+ }
1092
+ getApiFiles() {
1093
+ return Object.keys(this.apiFiles).reduce((acc, key) => {
1094
+ if (!this.apiFiles[key]) {
1095
+ return acc;
1096
+ }
1097
+ acc[key] = {
1098
+ api: this.createClientApi(this.apiFiles[key]),
1099
+ scopeId: this.apiFiles[key].scopeId,
1100
+ };
1101
+ return acc;
1102
+ }, {});
1089
1103
  }
1090
1104
  // Utilities for converting server API format to Client API format
1091
1105
  // We internally save the API as the server does, but we return should always return it
@@ -1102,19 +1116,21 @@ export default registerPage(Page, { name: "${name}" });
1102
1116
  },
1103
1117
  };
1104
1118
  }
1105
- formatApisToClientApis(serverApis) {
1106
- return Object.keys(serverApis).reduce((acc, key) => {
1107
- if (!serverApis[key]) {
1108
- return acc;
1109
- }
1110
- acc[key] = {
1111
- api: this.createClientApi(serverApis[key]),
1112
- scopeId: serverApis[key].scopeId,
1113
- };
1114
- return acc;
1115
- }, {});
1116
- }
1117
- async processApiUpdates(filePath, fileType) {
1119
+ addApiToScope = async (api) => {
1120
+ if (!this.sourceTracker) {
1121
+ throw new Error("Source tracker not initialized");
1122
+ }
1123
+ // We want to add the API entity to our scope, but we do not want to emit entity events, because
1124
+ // the API update event handles this particular side effect.
1125
+ const scopeId = await this.sourceTracker.addApi({
1126
+ pageName: api.pageName,
1127
+ apiName: api.apiPb.metadata.name,
1128
+ });
1129
+ const changes = (await this.sourceTracker?.getAndFlushChanges()) ?? [];
1130
+ await this.writeChanges(changes);
1131
+ return scopeId;
1132
+ };
1133
+ async processApiFileUpdates(filePath, fileType) {
1118
1134
  let yamlPath = filePath;
1119
1135
  if (fileType.type === "python-api-step" ||
1120
1136
  fileType.type === "js-api-step") {
@@ -1132,7 +1148,7 @@ export default registerPage(Page, { name: "${name}" });
1132
1148
  const parsedData = { apiPb: apiContent?.api };
1133
1149
  if (!(yamlPath in this.apiFiles &&
1134
1150
  isEqual(this.apiFiles[yamlPath]?.apiPb, parsedData.apiPb))) {
1135
- const { updatedApi, pageName, isNewApi } = this.updateApi({
1151
+ const { updatedApi, pageName, isNewApi } = this.updateInternalApiData({
1136
1152
  api: parsedData.apiPb,
1137
1153
  stepPathMap: apiContent.stepPathMap,
1138
1154
  }, yamlPath);
@@ -1143,7 +1159,7 @@ export default registerPage(Page, { name: "${name}" });
1143
1159
  return;
1144
1160
  }
1145
1161
  if (isNewApi) {
1146
- await this.createScopedApi(updatedApi);
1162
+ await this.addApiToScope(updatedApi);
1147
1163
  }
1148
1164
  this.emit("apiManualUpdate", {
1149
1165
  api: this.createClientApi(updatedApi),
@@ -1159,9 +1175,56 @@ export default registerPage(Page, { name: "${name}" });
1159
1175
  logger.error(`Error updating API: ${yamlPath}, error: ${error}`);
1160
1176
  }
1161
1177
  }
1178
+ updateInternalApiData = (content, path) => {
1179
+ if (!this.rootDir) {
1180
+ throw new Error("Root directory not set");
1181
+ }
1182
+ const { api: apiContents, stepPathMap } = content;
1183
+ const pageName = getPageName(path);
1184
+ let scopeId = this.sourceTracker?.getScopeDefinitionForPage(pageName)?.id;
1185
+ if (!scopeId) {
1186
+ console.warn("Scope ID not found for API", apiContents.metadata.name);
1187
+ scopeId = "";
1188
+ }
1189
+ const updatedApi = {
1190
+ apiPb: yaml.parse(JSON.stringify(apiContents)),
1191
+ pageName,
1192
+ stepPathMap,
1193
+ scopeId,
1194
+ };
1195
+ const isNewApi = !this.apiFiles[path];
1196
+ this.apiFiles[path] = updatedApi;
1197
+ return { updatedApi, pageName, isNewApi };
1198
+ };
1199
+ renameIdentifierInApis = async ({ elementId, oldName, newName, parentBinding, }) => {
1200
+ const apisInScope = structuredClone(this.getApisInScope(elementId));
1201
+ await this.renameManager.renameEntityInApis({
1202
+ oldName,
1203
+ newName,
1204
+ parentBinding,
1205
+ apis: apisInScope,
1206
+ });
1207
+ // only save the APIs that have changed
1208
+ await Promise.all(apisInScope.map(({ api, filePath }) => {
1209
+ if (isEqual(api?.apiPb, this.apiFiles[filePath]?.apiPb)) {
1210
+ return Promise.resolve();
1211
+ }
1212
+ return this.writeFile(filePath, yaml.stringify(api?.apiPb), "api");
1213
+ }));
1214
+ };
1215
+ getApisInScope = (elementId) => {
1216
+ const filePath = this.sourceTracker?.getElementToFilePath(elementId);
1217
+ if (!filePath) {
1218
+ return [];
1219
+ }
1220
+ const pagePath = path.dirname(filePath);
1221
+ return Object.entries(this.apiFiles)
1222
+ .filter(([filePath]) => filePath.includes(pagePath))
1223
+ .map(([filePath, api]) => ({ api, filePath }));
1224
+ };
1162
1225
  }
1163
1226
  // Add new mock implementation
1164
- export class MockFileSyncManager extends FileSyncManager {
1227
+ export class MockFileSyncManager extends FileSystemManager {
1165
1228
  tsFiles = {};
1166
1229
  apiFiles = {};
1167
1230
  async watch(_watcher, _path) {
@@ -1237,7 +1300,7 @@ async function readFiles(dir) {
1237
1300
  const getMergedApiContent = async (path) => {
1238
1301
  return readAppApiYamlFile(path.split("/").slice(0, -1).join("/"));
1239
1302
  };
1240
- const getPageName = (path) => {
1303
+ export const getPageName = (path) => {
1241
1304
  const parts = path.split("/");
1242
1305
  const pagesIndex = parts.findIndex((part) => part === "pages");
1243
1306
  if (pagesIndex !== -1 && parts[pagesIndex + 1]) {