zelune 0.0.0-stage → 1.4.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.
@@ -0,0 +1,877 @@
1
+ import fs from 'node:fs/promises';
2
+ import fsSync from 'node:fs';
3
+ import path from 'node:path';
4
+ import { parse } from '@babel/parser';
5
+ import _traverse, { type NodePath } from '@babel/traverse';
6
+ import _generate from '@babel/generator';
7
+ import * as t from '@babel/types';
8
+ import { getJsxTagName } from '../../plugins/viteRnSourceInject';
9
+
10
+ const traverse = (_traverse as unknown as { default?: typeof _traverse }).default ?? _traverse;
11
+ const generate = (_generate as unknown as { default?: typeof _generate }).default ?? _generate;
12
+
13
+ export interface NavigationEdge {
14
+ id: string;
15
+ fromScreen: string;
16
+ toScreen: string;
17
+ triggerLabel: string;
18
+ line: number;
19
+ }
20
+
21
+ export interface ScreenMetadata {
22
+ id: string;
23
+ name: string;
24
+ routeName: string;
25
+ filePath: string;
26
+ elementCount: number;
27
+ outgoingFlows: NavigationEdge[];
28
+ parseError?: string;
29
+ }
30
+
31
+ export type StyleValue = string | number;
32
+
33
+ export interface InspectedElement {
34
+ file: string;
35
+ astPath: string;
36
+ tagName: string;
37
+ line: number;
38
+ col: number;
39
+ textContent: string | null;
40
+ hasDynamicText: boolean;
41
+ isTextEditable: boolean;
42
+ styleSource: 'stylesheet' | 'inline' | 'none';
43
+ styleSheetKey: string | null;
44
+ styles: Record<string, StyleValue>;
45
+ codeSnippet: string;
46
+ navigationTarget: string | null;
47
+ }
48
+
49
+ export interface PatchPayload {
50
+ file: string;
51
+ astPath: string;
52
+ line?: number;
53
+ col?: number;
54
+ text?: string;
55
+ styles?: Record<string, StyleValue | null | undefined>;
56
+ }
57
+
58
+ interface HistoryEntry {
59
+ file: string;
60
+ previousContent: string;
61
+ nextContent: string;
62
+ description: string;
63
+ timestamp: number;
64
+ }
65
+
66
+ const MAX_HISTORY = 50;
67
+ const undoStack: HistoryEntry[] = [];
68
+ const redoStack: HistoryEntry[] = [];
69
+
70
+ // Per-file sequential write queue to prevent race conditions during rapid slider scrubbing
71
+ const fileWriteLocks = new Map<string, Promise<unknown>>();
72
+
73
+ function withFileLock<T>(filePath: string, task: () => Promise<T>): Promise<T> {
74
+ const prev = fileWriteLocks.get(filePath) ?? Promise.resolve();
75
+ const next = prev.then(task, task);
76
+ fileWriteLocks.set(
77
+ filePath,
78
+ next.catch(() => undefined)
79
+ );
80
+ return next;
81
+ }
82
+
83
+ /**
84
+ * Resolves the active target project root (supports ANTIGRAVITY_TARGET_PROJECT for external projects).
85
+ */
86
+ export function getActiveProjectRoot(fallbackRoot: string): string {
87
+ const envTarget = process.env.ANTIGRAVITY_TARGET_PROJECT;
88
+ if (envTarget && fsSync.existsSync(envTarget)) {
89
+ return path.resolve(envTarget);
90
+ }
91
+ return path.resolve(fallbackRoot);
92
+ }
93
+
94
+ /**
95
+ * Finds the active screens directory inside a React Native / Expo project.
96
+ * Checks `src/screens`, `screens`, and `app` (Expo Router).
97
+ */
98
+ export function resolveScreensDirectory(projectRoot: string): {
99
+ dirPath: string;
100
+ relDir: string;
101
+ } {
102
+ const normalizedRoot = path.resolve(projectRoot);
103
+ const candidates = ['src/screens', 'screens', 'app', 'src/app'];
104
+
105
+ for (const rel of candidates) {
106
+ const full = path.join(normalizedRoot, rel);
107
+ if (fsSync.existsSync(full) && fsSync.statSync(full).isDirectory()) {
108
+ return { dirPath: full, relDir: rel };
109
+ }
110
+ }
111
+
112
+ return {
113
+ dirPath: path.join(normalizedRoot, 'src/screens'),
114
+ relDir: 'src/screens',
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Validates that a relative file path stays strictly inside the project's screens directory
120
+ * (`src/screens`, `screens`, or `app`) and ends with `.tsx` or `.jsx`. Prevents path traversal attacks.
121
+ */
122
+ export function resolveSafeScreenPath(workspaceRoot: string, relativeFile: string): string {
123
+ const normalizedRoot = getActiveProjectRoot(workspaceRoot);
124
+ const cleanRel = relativeFile.replace(/^[/\\]+/, '');
125
+ const resolved = path.resolve(normalizedRoot, cleanRel);
126
+
127
+ const allowedDirs = ['src/screens', 'screens', 'app', 'src/app'].map((d) =>
128
+ path.resolve(normalizedRoot, d)
129
+ );
130
+
131
+ const isInsideAllowed = allowedDirs.some(
132
+ (dir) => resolved.startsWith(dir + path.sep) || resolved === dir
133
+ );
134
+
135
+ if (!isInsideAllowed) {
136
+ throw new Error(
137
+ `Security violation: File path "${relativeFile}" is outside allowed screen directories (src/screens, screens, app)`
138
+ );
139
+ }
140
+ if (!resolved.endsWith('.tsx') && !resolved.endsWith('.jsx')) {
141
+ throw new Error(`Invalid file extension for "${relativeFile}". Only .tsx and .jsx are allowed.`);
142
+ }
143
+ return resolved;
144
+ }
145
+
146
+ /**
147
+ * Normalizes a route target string (e.g. '/(tabs)/product-detail' or 'ProductDetailScreen') to screen ID.
148
+ */
149
+ export function normalizeRouteTarget(rawTarget: string): string {
150
+ const trimmed = rawTarget
151
+ .replace(/^\/+/, '')
152
+ .replace(/\([^)]+\)\/?/g, '')
153
+ .replace(/Screen$/, '')
154
+ .replace(/\/index$/, '');
155
+ if (!trimmed || trimmed === 'index') return 'Home';
156
+ if (trimmed.includes('-') || trimmed.includes('/') || trimmed === trimmed.toLowerCase()) {
157
+ return trimmed
158
+ .split(/[-_/]/)
159
+ .filter(Boolean)
160
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
161
+ .join('');
162
+ }
163
+ return trimmed;
164
+ }
165
+
166
+ /**
167
+ * Extracts primitive value (string or number) from an AST expression node.
168
+ */
169
+ function extractLiteralValue(node: t.Node | null | undefined): StyleValue | undefined {
170
+ if (!node) return undefined;
171
+ if (t.isStringLiteral(node)) return node.value;
172
+ if (t.isNumericLiteral(node)) return node.value;
173
+ if (t.isUnaryExpression(node) && node.operator === '-' && t.isNumericLiteral(node.argument)) {
174
+ return -node.argument.value;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * Collects all `StyleSheet.create({ ... })` style definitions in an AST.
181
+ */
182
+ function collectStyleSheetMap(ast: t.File): {
183
+ identifierName: string;
184
+ styleObjects: Map<string, t.ObjectExpression>;
185
+ valuesMap: Map<string, Record<string, StyleValue>>;
186
+ } {
187
+ let identifierName = 'styles';
188
+ const styleObjects = new Map<string, t.ObjectExpression>();
189
+ const valuesMap = new Map<string, Record<string, StyleValue>>();
190
+
191
+ traverse(ast, {
192
+ VariableDeclarator(nodePath) {
193
+ const { id, init } = nodePath.node;
194
+ if (!t.isIdentifier(id) || !init) return;
195
+
196
+ const isStyleSheetCreate =
197
+ t.isCallExpression(init) &&
198
+ t.isMemberExpression(init.callee) &&
199
+ t.isIdentifier(init.callee.object, { name: 'StyleSheet' }) &&
200
+ t.isIdentifier(init.callee.property, { name: 'create' }) &&
201
+ init.arguments.length > 0 &&
202
+ t.isObjectExpression(init.arguments[0]);
203
+
204
+ if (!isStyleSheetCreate) return;
205
+
206
+ identifierName = id.name;
207
+ const rootObj = init.arguments[0] as t.ObjectExpression;
208
+
209
+ for (const prop of rootObj.properties) {
210
+ if (t.isObjectProperty(prop) && t.isObjectExpression(prop.value)) {
211
+ const keyName = t.isIdentifier(prop.key)
212
+ ? prop.key.name
213
+ : t.isStringLiteral(prop.key)
214
+ ? prop.key.value
215
+ : null;
216
+ if (!keyName) continue;
217
+
218
+ styleObjects.set(keyName, prop.value);
219
+ const styleRecord: Record<string, StyleValue> = {};
220
+
221
+ for (const rule of prop.value.properties) {
222
+ if (t.isObjectProperty(rule)) {
223
+ const ruleKey = t.isIdentifier(rule.key)
224
+ ? rule.key.name
225
+ : t.isStringLiteral(rule.key)
226
+ ? rule.key.value
227
+ : null;
228
+ const val = extractLiteralValue(rule.value);
229
+ if (ruleKey && val !== undefined) {
230
+ styleRecord[ruleKey] = val;
231
+ }
232
+ }
233
+ }
234
+ valuesMap.set(keyName, styleRecord);
235
+ }
236
+ }
237
+ },
238
+ });
239
+
240
+ return { identifierName, styleObjects, valuesMap };
241
+ }
242
+
243
+ /**
244
+ * Extracts static text content and checks if a JSXElement contains dynamic expressions.
245
+ */
246
+ function inspectJsxTextChildren(jsxElement: t.JSXElement): {
247
+ textContent: string | null;
248
+ hasDynamicText: boolean;
249
+ isTextEditable: boolean;
250
+ } {
251
+ const tagName = getJsxTagName(jsxElement.openingElement.name);
252
+ const isTextComponent = tagName === 'Text' || tagName.endsWith('.Text');
253
+
254
+ let hasDynamicText = false;
255
+ const textParts: string[] = [];
256
+ let hasChildElements = false;
257
+
258
+ for (const child of jsxElement.children) {
259
+ if (t.isJSXText(child)) {
260
+ const cleaned = child.value.replace(/\s+/g, ' ').trim();
261
+ if (cleaned) textParts.push(cleaned);
262
+ } else if (t.isJSXExpressionContainer(child)) {
263
+ if (t.isStringLiteral(child.expression)) {
264
+ textParts.push(child.expression.value);
265
+ } else if (t.isNumericLiteral(child.expression)) {
266
+ textParts.push(String(child.expression.value));
267
+ } else if (!t.isJSXEmptyExpression(child.expression)) {
268
+ hasDynamicText = true;
269
+ }
270
+ } else if (t.isJSXElement(child) || t.isJSXFragment(child)) {
271
+ hasChildElements = true;
272
+ }
273
+ }
274
+
275
+ if (hasChildElements && !isTextComponent) {
276
+ return {
277
+ textContent: null,
278
+ hasDynamicText: false,
279
+ isTextEditable: false,
280
+ };
281
+ }
282
+
283
+ const joined = textParts.join(' ').trim();
284
+ const isTextEditable = (isTextComponent || joined.length > 0) && !hasDynamicText && !hasChildElements;
285
+
286
+ return {
287
+ textContent: joined.length > 0 || isTextComponent ? joined : null,
288
+ hasDynamicText,
289
+ isTextEditable,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Extracts `navigation.navigate('Target')` or `router.push('/target')` target on an element's `onPress`.
295
+ */
296
+ function extractElementNavigationTarget(openingElement: t.JSXOpeningElement): string | null {
297
+ for (const attr of openingElement.attributes) {
298
+ if (
299
+ t.isJSXAttribute(attr) &&
300
+ t.isJSXIdentifier(attr.name, { name: 'onPress' }) &&
301
+ t.isJSXExpressionContainer(attr.value)
302
+ ) {
303
+ const expr = attr.value.expression;
304
+ if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
305
+ const codeStr = generate(expr).code;
306
+ const match = codeStr.match(/(?:navigate|push|replace)\(\s*['"]([^'"]+)['"]/);
307
+ if (match) return normalizeRouteTarget(match[1]);
308
+ }
309
+ }
310
+ }
311
+ return null;
312
+ }
313
+
314
+ /**
315
+ * Resolves the style properties and style source for a given JSXOpeningElement.
316
+ */
317
+ function resolveElementStyles(
318
+ openingElement: t.JSXOpeningElement,
319
+ styleSheetInfo: ReturnType<typeof collectStyleSheetMap>
320
+ ): {
321
+ styleSource: 'stylesheet' | 'inline' | 'none';
322
+ styleSheetKey: string | null;
323
+ inlineObject: t.ObjectExpression | null;
324
+ styles: Record<string, StyleValue>;
325
+ } {
326
+ let styleSource: 'stylesheet' | 'inline' | 'none' = 'none';
327
+ let styleSheetKey: string | null = null;
328
+ let inlineObject: t.ObjectExpression | null = null;
329
+ const styles: Record<string, StyleValue> = {};
330
+
331
+ const styleAttr = openingElement.attributes.find(
332
+ (attr): attr is t.JSXAttribute =>
333
+ t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'style' })
334
+ );
335
+
336
+ if (!styleAttr || !t.isJSXExpressionContainer(styleAttr.value)) {
337
+ return { styleSource, styleSheetKey, inlineObject, styles };
338
+ }
339
+
340
+ const expr = styleAttr.value.expression;
341
+
342
+ const processExpression = (node: t.Expression | t.SpreadElement | t.JSXEmptyExpression) => {
343
+ if (
344
+ t.isMemberExpression(node) &&
345
+ t.isIdentifier(node.object, { name: styleSheetInfo.identifierName }) &&
346
+ t.isIdentifier(node.property)
347
+ ) {
348
+ const key = node.property.name;
349
+ styleSheetKey = key;
350
+ styleSource = 'stylesheet';
351
+ const sheetStyles = styleSheetInfo.valuesMap.get(key);
352
+ if (sheetStyles) {
353
+ Object.assign(styles, sheetStyles);
354
+ }
355
+ } else if (t.isObjectExpression(node)) {
356
+ inlineObject = node;
357
+ if (styleSource === 'none') {
358
+ styleSource = 'inline';
359
+ }
360
+ for (const prop of node.properties) {
361
+ if (t.isObjectProperty(prop)) {
362
+ const propKey = t.isIdentifier(prop.key)
363
+ ? prop.key.name
364
+ : t.isStringLiteral(prop.key)
365
+ ? prop.key.value
366
+ : null;
367
+ const val = extractLiteralValue(prop.value);
368
+ if (propKey && val !== undefined) {
369
+ styles[propKey] = val;
370
+ }
371
+ }
372
+ }
373
+ } else if (t.isArrayExpression(node)) {
374
+ for (const el of node.elements) {
375
+ if (el && (t.isExpression(el) || t.isSpreadElement(el))) {
376
+ processExpression(el);
377
+ }
378
+ }
379
+ }
380
+ };
381
+
382
+ processExpression(expr);
383
+
384
+ return { styleSource, styleSheetKey, inlineObject, styles };
385
+ }
386
+
387
+ export interface DiscoveredScreenFile {
388
+ id: string;
389
+ name: string;
390
+ routeName: string;
391
+ relPath: string;
392
+ fullPath: string;
393
+ }
394
+
395
+ /**
396
+ * Synchronously discovers all screen files in the active React Native / Expo project
397
+ * (`src/screens`, `screens`, `app`, or `src/app`), supporting nested route folders.
398
+ */
399
+ export function discoverScreenFilesSync(workspaceRoot: string): DiscoveredScreenFile[] {
400
+ const activeRoot = getActiveProjectRoot(workspaceRoot);
401
+ const { dirPath: screensDir, relDir } = resolveScreensDirectory(activeRoot);
402
+
403
+ const collectedRelFromScreensDir: string[] = [];
404
+
405
+ function walk(currDir: string, relPrefix: string, depth: number) {
406
+ if (depth > 4) return;
407
+ let dirents: fsSync.Dirent[] = [];
408
+ try {
409
+ dirents = fsSync.readdirSync(currDir, { withFileTypes: true });
410
+ } catch {
411
+ return;
412
+ }
413
+
414
+ for (const entry of dirents) {
415
+ if (entry.name.startsWith('.')) continue;
416
+ if (entry.isDirectory()) {
417
+ if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name === 'components') {
418
+ continue;
419
+ }
420
+ walk(
421
+ path.join(currDir, entry.name),
422
+ relPrefix ? `${relPrefix}/${entry.name}` : entry.name,
423
+ depth + 1
424
+ );
425
+ } else if (entry.isFile()) {
426
+ const f = entry.name;
427
+ if (f.startsWith('_') || f.startsWith('+')) continue;
428
+ if (f === 'index.ts' || f === 'index.js') continue;
429
+ if (f.includes('.test.') || f.includes('.spec.') || f.endsWith('.d.ts')) continue;
430
+ if (!f.endsWith('.tsx') && !f.endsWith('.jsx')) continue;
431
+
432
+ collectedRelFromScreensDir.push(relPrefix ? `${relPrefix}/${f}` : f);
433
+ }
434
+ }
435
+ }
436
+
437
+ walk(screensDir, '', 0);
438
+ collectedRelFromScreensDir.sort();
439
+
440
+ const seenIds = new Set<string>();
441
+ const files: DiscoveredScreenFile[] = [];
442
+
443
+ for (const subRel of collectedRelFromScreensDir) {
444
+ const relPath = `${relDir}/${subRel}`.replace(/\\/g, '/');
445
+ const fullPath = path.join(screensDir, subRel);
446
+ const noExt = subRel.replace(/\.(tsx|jsx)$/, '');
447
+ const baseFileName = path.basename(noExt);
448
+
449
+ let routeName = normalizeRouteTarget(noExt);
450
+ if (seenIds.has(routeName)) {
451
+ routeName = normalizeRouteTarget(noExt.replace(/[()]/g, ''));
452
+ }
453
+ seenIds.add(routeName);
454
+
455
+ const displayName = baseFileName.endsWith('Screen') ? baseFileName : `${routeName}Screen`;
456
+
457
+ files.push({
458
+ id: routeName,
459
+ name: displayName,
460
+ routeName,
461
+ relPath,
462
+ fullPath,
463
+ });
464
+ }
465
+
466
+ return files;
467
+ }
468
+
469
+ /**
470
+ * Discovers all screens in the active React Native / Expo project (`src/screens`, `screens`, or `app`)
471
+ * and extracts their navigation flow graph.
472
+ */
473
+ export async function discoverScreens(workspaceRoot: string): Promise<ScreenMetadata[]> {
474
+ const screenFiles = discoverScreenFilesSync(workspaceRoot);
475
+ const results: ScreenMetadata[] = [];
476
+
477
+ for (const item of screenFiles) {
478
+ const { id: routeName, name, relPath, fullPath } = item;
479
+
480
+ try {
481
+ const code = await fs.readFile(fullPath, 'utf-8');
482
+ const ast = parse(code, {
483
+ sourceType: 'module',
484
+ plugins: ['typescript', 'jsx'],
485
+ });
486
+
487
+ let elementCount = 0;
488
+ const outgoingFlows: NavigationEdge[] = [];
489
+
490
+ traverse(ast, {
491
+ JSXOpeningElement(nodePath) {
492
+ const tagName = getJsxTagName(nodePath.node.name);
493
+ if (tagName !== 'Fragment' && tagName !== 'React.Fragment' && tagName !== '') {
494
+ elementCount++;
495
+ }
496
+ },
497
+ CallExpression(nodePath) {
498
+ const { callee, arguments: args } = nodePath.node;
499
+ // Match navigation.navigate('Route') or router.push('/route') / router.replace('/route')
500
+ const isNavigateCall =
501
+ t.isMemberExpression(callee) &&
502
+ t.isIdentifier(callee.property) &&
503
+ ['navigate', 'push', 'replace'].includes(callee.property.name) &&
504
+ args.length > 0 &&
505
+ t.isStringLiteral(args[0]);
506
+
507
+ if (!isNavigateCall) return;
508
+
509
+ const targetRoute = normalizeRouteTarget((args[0] as t.StringLiteral).value);
510
+ const line = nodePath.node.loc?.start.line ?? 1;
511
+
512
+ let triggerLabel = `Navigate to ${targetRoute}`;
513
+ const jsxParent = nodePath.findParent((p) => p.isJSXElement()) as NodePath<t.JSXElement> | null;
514
+ if (jsxParent) {
515
+ const rawCode = generate(jsxParent.node).code;
516
+ const textMatches = Array.from(rawCode.matchAll(/>([^<>{}\n]+)</g))
517
+ .map((m) => m[1].trim())
518
+ .filter(Boolean);
519
+ if (textMatches.length > 0) {
520
+ triggerLabel = textMatches.join(' · ');
521
+ }
522
+ }
523
+
524
+ const edgeId = `${routeName}->${targetRoute}:${line}`;
525
+ if (!outgoingFlows.some((e) => e.fromScreen === routeName && e.toScreen === targetRoute)) {
526
+ outgoingFlows.push({
527
+ id: edgeId,
528
+ fromScreen: routeName,
529
+ toScreen: targetRoute,
530
+ triggerLabel,
531
+ line,
532
+ });
533
+ }
534
+ },
535
+ });
536
+
537
+ results.push({
538
+ id: routeName,
539
+ name,
540
+ routeName,
541
+ filePath: relPath,
542
+ elementCount,
543
+ outgoingFlows,
544
+ });
545
+ } catch (err) {
546
+ const msg = err instanceof Error ? err.message : String(err);
547
+ results.push({
548
+ id: routeName,
549
+ name,
550
+ routeName,
551
+ filePath: relPath,
552
+ elementCount: 0,
553
+ outgoingFlows: [],
554
+ parseError: msg,
555
+ });
556
+ }
557
+ }
558
+
559
+ return results;
560
+ }
561
+
562
+ /**
563
+ * Inspects a specific JSX element inside a screen file by its deterministic `astPath`
564
+ * (or fallback `line` number) and returns its resolved styles, copy, and code snippet.
565
+ */
566
+ export async function inspectElement(
567
+ workspaceRoot: string,
568
+ params: { file: string; astPath: string; line?: number; col?: number }
569
+ ): Promise<InspectedElement> {
570
+ const fullPath = resolveSafeScreenPath(workspaceRoot, params.file);
571
+ const code = await fs.readFile(fullPath, 'utf-8');
572
+
573
+ const ast = parse(code, {
574
+ sourceType: 'module',
575
+ plugins: ['typescript', 'jsx'],
576
+ });
577
+
578
+ const styleSheetInfo = collectStyleSheetMap(ast);
579
+
580
+ let matchedElement: t.JSXElement | null = null;
581
+ let matchedIndex = -1;
582
+ let currentIndex = 0;
583
+
584
+ const targetIndex = Number.parseInt(params.astPath, 10);
585
+
586
+ traverse(ast, {
587
+ JSXOpeningElement(nodePath) {
588
+ const tagName = getJsxTagName(nodePath.node.name);
589
+ if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
590
+ return;
591
+ }
592
+
593
+ const idx = currentIndex++;
594
+ const parentJsx = nodePath.parentPath.isJSXElement() ? nodePath.parentPath.node : null;
595
+ if (!parentJsx) return;
596
+
597
+ if (!Number.isNaN(targetIndex) && idx === targetIndex) {
598
+ matchedElement = parentJsx;
599
+ matchedIndex = idx;
600
+ } else if (!matchedElement && params.line && nodePath.node.loc?.start.line === params.line) {
601
+ matchedElement = parentJsx;
602
+ matchedIndex = idx;
603
+ }
604
+ },
605
+ });
606
+
607
+ if (!matchedElement) {
608
+ throw new Error(`Element at astPath=${params.astPath} (line=${params.line ?? '?'}) not found in ${params.file}`);
609
+ }
610
+
611
+ const element = matchedElement as t.JSXElement;
612
+ const opening = element.openingElement;
613
+ const tagName = getJsxTagName(opening.name);
614
+ const line = opening.loc?.start.line ?? 1;
615
+ const col = opening.loc?.start.column ?? 0;
616
+
617
+ const textInfo = inspectJsxTextChildren(element);
618
+ const styleInfo = resolveElementStyles(opening, styleSheetInfo);
619
+ const navigationTarget = extractElementNavigationTarget(opening);
620
+
621
+ const snippetRaw = generate(element, { concise: false }).code;
622
+ const codeSnippet = snippetRaw.length > 600 ? `${snippetRaw.slice(0, 600)}\n /* ... */` : snippetRaw;
623
+
624
+ return {
625
+ file: params.file,
626
+ astPath: String(matchedIndex),
627
+ tagName,
628
+ line,
629
+ col,
630
+ textContent: textInfo.textContent,
631
+ hasDynamicText: textInfo.hasDynamicText,
632
+ isTextEditable: textInfo.isTextEditable,
633
+ styleSource: styleInfo.styleSource,
634
+ styleSheetKey: styleInfo.styleSheetKey,
635
+ styles: styleInfo.styles,
636
+ codeSnippet,
637
+ navigationTarget,
638
+ };
639
+ }
640
+
641
+ function upsertObjectProperty(
642
+ objExpr: t.ObjectExpression,
643
+ key: string,
644
+ val: StyleValue | null | undefined
645
+ ) {
646
+ const existingIndex = objExpr.properties.findIndex((prop) => {
647
+ if (!t.isObjectProperty(prop)) return false;
648
+ const k = t.isIdentifier(prop.key)
649
+ ? prop.key.name
650
+ : t.isStringLiteral(prop.key)
651
+ ? prop.key.value
652
+ : null;
653
+ return k === key;
654
+ });
655
+
656
+ if (val === null || val === undefined || val === '') {
657
+ if (existingIndex !== -1) {
658
+ objExpr.properties.splice(existingIndex, 1);
659
+ }
660
+ return;
661
+ }
662
+
663
+ const valueNode =
664
+ typeof val === 'number' ? t.numericLiteral(val) : t.stringLiteral(String(val));
665
+
666
+ if (existingIndex !== -1) {
667
+ const existingProp = objExpr.properties[existingIndex] as t.ObjectProperty;
668
+ existingProp.value = valueNode;
669
+ } else {
670
+ const keyNode = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
671
+ ? t.identifier(key)
672
+ : t.stringLiteral(key);
673
+ objExpr.properties.push(t.objectProperty(keyNode, valueNode));
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Surgically patches a JSX element's text copy and/or StyleSheet/inline styles in the `.tsx` file.
679
+ */
680
+ export async function patchElement(
681
+ workspaceRoot: string,
682
+ payload: PatchPayload
683
+ ): Promise<{
684
+ element: InspectedElement;
685
+ undoAvailable: number;
686
+ redoAvailable: number;
687
+ }> {
688
+ const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
689
+
690
+ return withFileLock(fullPath, async () => {
691
+ const previousContent = await fs.readFile(fullPath, 'utf-8');
692
+
693
+ const ast = parse(previousContent, {
694
+ sourceType: 'module',
695
+ plugins: ['typescript', 'jsx'],
696
+ });
697
+
698
+ const styleSheetInfo = collectStyleSheetMap(ast);
699
+ const targetIndex = Number.parseInt(payload.astPath, 10);
700
+
701
+ let matchedElement: t.JSXElement | null = null;
702
+ let currentIndex = 0;
703
+
704
+ traverse(ast, {
705
+ JSXOpeningElement(nodePath) {
706
+ const tagName = getJsxTagName(nodePath.node.name);
707
+ if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
708
+ return;
709
+ }
710
+ const idx = currentIndex++;
711
+ const parentJsx = nodePath.parentPath.isJSXElement() ? nodePath.parentPath.node : null;
712
+ if (!parentJsx) return;
713
+
714
+ if (!Number.isNaN(targetIndex) && idx === targetIndex) {
715
+ matchedElement = parentJsx;
716
+ } else if (!matchedElement && payload.line && nodePath.node.loc?.start.line === payload.line) {
717
+ matchedElement = parentJsx;
718
+ }
719
+ },
720
+ });
721
+
722
+ if (!matchedElement) {
723
+ throw new Error(`Target JSX element (astPath=${payload.astPath}) not found in ${payload.file}`);
724
+ }
725
+
726
+ const element = matchedElement as t.JSXElement;
727
+ const opening = element.openingElement;
728
+
729
+ if (payload.text !== undefined) {
730
+ const textCheck = inspectJsxTextChildren(element);
731
+ if (textCheck.hasDynamicText) {
732
+ throw new Error(
733
+ 'Cannot overwrite dynamic JSX expression inside <Text>. Use "Ask Antigravity" for structural/expression edits.'
734
+ );
735
+ }
736
+ element.children = [t.jsxText(payload.text)];
737
+ }
738
+
739
+ if (payload.styles && Object.keys(payload.styles).length > 0) {
740
+ const styleInfo = resolveElementStyles(opening, styleSheetInfo);
741
+
742
+ let targetObject: t.ObjectExpression | null = null;
743
+
744
+ if (styleInfo.styleSheetKey && styleSheetInfo.styleObjects.has(styleInfo.styleSheetKey)) {
745
+ targetObject = styleSheetInfo.styleObjects.get(styleInfo.styleSheetKey)!;
746
+ } else if (styleInfo.inlineObject) {
747
+ targetObject = styleInfo.inlineObject;
748
+ } else {
749
+ const newObj = t.objectExpression([]);
750
+ const existingStyleAttrIndex = opening.attributes.findIndex(
751
+ (attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'style' })
752
+ );
753
+
754
+ if (existingStyleAttrIndex !== -1) {
755
+ const existingAttr = opening.attributes[existingStyleAttrIndex] as t.JSXAttribute;
756
+ if (t.isJSXExpressionContainer(existingAttr.value) && t.isExpression(existingAttr.value.expression)) {
757
+ existingAttr.value = t.jsxExpressionContainer(
758
+ t.arrayExpression([existingAttr.value.expression, newObj])
759
+ );
760
+ }
761
+ } else {
762
+ opening.attributes.push(
763
+ t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(newObj))
764
+ );
765
+ }
766
+ targetObject = newObj;
767
+ }
768
+
769
+ for (const [styleKey, styleVal] of Object.entries(payload.styles)) {
770
+ upsertObjectProperty(targetObject, styleKey, styleVal);
771
+ }
772
+ }
773
+
774
+ const output = generate(ast, {
775
+ retainLines: false,
776
+ comments: true,
777
+ jsescOption: { minimal: true },
778
+ });
779
+
780
+ const nextContent = output.code + '\n';
781
+
782
+ if (nextContent !== previousContent) {
783
+ await fs.writeFile(fullPath, nextContent, 'utf-8');
784
+ undoStack.push({
785
+ file: payload.file,
786
+ previousContent,
787
+ nextContent,
788
+ description: `Updated ${getJsxTagName(opening.name)} in ${path.basename(payload.file)}`,
789
+ timestamp: Date.now(),
790
+ });
791
+ if (undoStack.length > MAX_HISTORY) {
792
+ undoStack.shift();
793
+ }
794
+ redoStack.length = 0;
795
+ }
796
+
797
+ const updatedElement = await inspectElement(workspaceRoot, {
798
+ file: payload.file,
799
+ astPath: payload.astPath,
800
+ });
801
+
802
+ return {
803
+ element: updatedElement,
804
+ undoAvailable: undoStack.length,
805
+ redoAvailable: redoStack.length,
806
+ };
807
+ });
808
+ }
809
+
810
+ export async function undoLastPatch(workspaceRoot: string): Promise<{
811
+ restoredFile: string | null;
812
+ description: string | null;
813
+ undoAvailable: number;
814
+ redoAvailable: number;
815
+ }> {
816
+ const entry = undoStack.pop();
817
+ if (!entry) {
818
+ return {
819
+ restoredFile: null,
820
+ description: null,
821
+ undoAvailable: 0,
822
+ redoAvailable: redoStack.length,
823
+ };
824
+ }
825
+
826
+ const fullPath = resolveSafeScreenPath(workspaceRoot, entry.file);
827
+ await withFileLock(fullPath, async () => {
828
+ await fs.writeFile(fullPath, entry.previousContent, 'utf-8');
829
+ });
830
+
831
+ redoStack.push(entry);
832
+
833
+ return {
834
+ restoredFile: entry.file,
835
+ description: entry.description,
836
+ undoAvailable: undoStack.length,
837
+ redoAvailable: redoStack.length,
838
+ };
839
+ }
840
+
841
+ export async function redoLastPatch(workspaceRoot: string): Promise<{
842
+ restoredFile: string | null;
843
+ description: string | null;
844
+ undoAvailable: number;
845
+ redoAvailable: number;
846
+ }> {
847
+ const entry = redoStack.pop();
848
+ if (!entry) {
849
+ return {
850
+ restoredFile: null,
851
+ description: null,
852
+ undoAvailable: undoStack.length,
853
+ redoAvailable: 0,
854
+ };
855
+ }
856
+
857
+ const fullPath = resolveSafeScreenPath(workspaceRoot, entry.file);
858
+ await withFileLock(fullPath, async () => {
859
+ await fs.writeFile(fullPath, entry.nextContent, 'utf-8');
860
+ });
861
+
862
+ undoStack.push(entry);
863
+
864
+ return {
865
+ restoredFile: entry.file,
866
+ description: entry.description,
867
+ undoAvailable: undoStack.length,
868
+ redoAvailable: redoStack.length,
869
+ };
870
+ }
871
+
872
+ export function getHistoryStatus(): { undoAvailable: number; redoAvailable: number } {
873
+ return {
874
+ undoAvailable: undoStack.length,
875
+ redoAvailable: redoStack.length,
876
+ };
877
+ }