zelune 1.6.2 → 1.6.4

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 (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/cli.mjs +13 -54
  3. package/dist/assets/CheckoutScreen-6IdrpKgm.js +229 -0
  4. package/dist/assets/HomeScreen-Dxe9L4I2.js +316 -0
  5. package/dist/assets/LoginScreen-Dp1awI4F.js +189 -0
  6. package/dist/assets/ProductDetailScreen-Wa5_nlRP.js +196 -0
  7. package/dist/assets/ProfileScreen-riAU6vVh.js +185 -0
  8. package/dist/assets/index-hEzd4JVy.js +44697 -0
  9. package/{index.html → dist/index.html} +3 -7
  10. package/mcp/server.mjs +1370 -34
  11. package/package.json +10 -17
  12. package/plugin.json +1 -1
  13. package/socket.yml +11 -0
  14. package/mcp/server.ts +0 -446
  15. package/plugins/viteReactNativeFallbackPlugin.ts +0 -519
  16. package/plugins/viteRnSourceInject.ts +0 -117
  17. package/plugins/viteVirtualScreensPlugin.ts +0 -216
  18. package/src/canvas/CanvasApp.tsx +0 -1655
  19. package/src/canvas/FlowArrowsLayer.tsx +0 -239
  20. package/src/canvas/InspectorPanel.tsx +0 -1494
  21. package/src/canvas/ScreenArtboard.tsx +0 -1491
  22. package/src/canvas/types.ts +0 -23
  23. package/src/main.tsx +0 -80
  24. package/src/runtime/MockNavigationContext.tsx +0 -1795
  25. package/src/runtime/ScreenErrorBoundary.tsx +0 -86
  26. package/src/runtime/nativewindShim.ts +0 -997
  27. package/src/runtime/reactNativeShim.ts +0 -240
  28. package/src/screens/CheckoutScreen.tsx +0 -229
  29. package/src/screens/HomeScreen.tsx +0 -288
  30. package/src/screens/LoginScreen.tsx +0 -176
  31. package/src/screens/ProductDetailScreen.tsx +0 -207
  32. package/src/screens/ProfileScreen.tsx +0 -172
  33. package/src/screens/index.ts +0 -1
  34. package/src/server/apiMiddleware.ts +0 -375
  35. package/src/server/astEngine.ts +0 -2553
  36. package/src/vite-env.d.ts +0 -7
  37. package/tsconfig.json +0 -23
  38. package/vite.config.ts +0 -340
  39. /package/{src/styles.css → dist/assets/index-C7nrS82N.css} +0 -0
@@ -1,2553 +0,0 @@
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
- export interface ReorderPayload {
59
- file: string;
60
- astPath: string;
61
- targetAstPath?: string;
62
- placement?: 'before' | 'after';
63
- direction?: 'up' | 'down';
64
- }
65
-
66
- export type InsertElementPreset =
67
- | 'button'
68
- | 'section'
69
- | 'row'
70
- | 'heading'
71
- | 'text'
72
- | 'input'
73
- | 'badge'
74
- | 'divider';
75
-
76
- export interface InsertElementPayload {
77
- file: string;
78
- astPath?: string;
79
- placement?: 'inside' | 'after' | 'before';
80
- preset: InsertElementPreset;
81
- label?: string;
82
- navigateTo?: string;
83
- }
84
-
85
- export interface DuplicateElementPayload {
86
- file: string;
87
- astPath: string;
88
- }
89
-
90
- export interface DeleteElementPayload {
91
- file: string;
92
- astPath: string;
93
- }
94
-
95
- interface HistoryEntry {
96
- file: string;
97
- previousContent: string;
98
- nextContent: string;
99
- description: string;
100
- timestamp: number;
101
- }
102
-
103
- const MAX_HISTORY = 50;
104
- const undoStack: HistoryEntry[] = [];
105
- const redoStack: HistoryEntry[] = [];
106
-
107
- // Per-file sequential write queue to prevent race conditions during rapid slider scrubbing
108
- const fileWriteLocks = new Map<string, Promise<unknown>>();
109
-
110
- function withFileLock<T>(filePath: string, task: () => Promise<T>): Promise<T> {
111
- const prev = fileWriteLocks.get(filePath) ?? Promise.resolve();
112
- const next = prev.then(task, task);
113
- fileWriteLocks.set(
114
- filePath,
115
- next.catch(() => undefined)
116
- );
117
- return next;
118
- }
119
-
120
- const SCREEN_DIR_CANDIDATES = ['src/screens', 'screens', 'app', 'src/app'] as const;
121
- const IGNORED_SCAN_DIRS = new Set([
122
- 'node_modules',
123
- '.git',
124
- 'dist',
125
- 'build',
126
- '.next',
127
- '.expo',
128
- '.turbo',
129
- 'ios',
130
- 'android',
131
- 'vendor',
132
- 'coverage',
133
- '.agents',
134
- '.gemini',
135
- ]);
136
-
137
- function countScreenFilesInDirectory(screensDir: string, maxDepth = 4): number {
138
- let count = 0;
139
-
140
- function walk(curr: string, depth: number) {
141
- if (depth > maxDepth) return;
142
- let entries: fsSync.Dirent[] = [];
143
- try {
144
- entries = fsSync.readdirSync(curr, { withFileTypes: true });
145
- } catch {
146
- return;
147
- }
148
-
149
- for (const entry of entries) {
150
- if (entry.name.startsWith('.')) continue;
151
- if (entry.isDirectory()) {
152
- if (
153
- entry.name === 'node_modules' ||
154
- entry.name === '__tests__' ||
155
- entry.name === 'components'
156
- ) {
157
- continue;
158
- }
159
- walk(path.join(curr, entry.name), depth + 1);
160
- } else if (entry.isFile()) {
161
- const f = entry.name;
162
- if (f.startsWith('_') || f.startsWith('+')) continue;
163
- if (f === 'index.ts' || f === 'index.js') continue;
164
- if (f.includes('.test.') || f.includes('.spec.') || f.endsWith('.d.ts')) continue;
165
- if (f.endsWith('.tsx') || f.endsWith('.jsx')) {
166
- count++;
167
- }
168
- }
169
- }
170
- }
171
-
172
- walk(screensDir, 0);
173
- return count;
174
- }
175
-
176
- function hasDirectScreenFiles(dirPath: string): { found: boolean; screenCount: number } {
177
- for (const rel of SCREEN_DIR_CANDIDATES) {
178
- const full = path.join(dirPath, rel);
179
- try {
180
- if (fsSync.existsSync(full) && fsSync.statSync(full).isDirectory()) {
181
- const count = countScreenFilesInDirectory(full);
182
- if (count > 0) {
183
- return { found: true, screenCount: count };
184
- }
185
- }
186
- } catch {
187
- // Ignore unreadable directories
188
- }
189
- }
190
- return { found: false, screenCount: 0 };
191
- }
192
-
193
- function scoreMobileCandidate(dirPath: string, screenCount: number): number {
194
- let score = screenCount;
195
- const folderName = path.basename(dirPath).toLowerCase();
196
- if (/mobile|native|expo|app|client/.test(folderName)) {
197
- score += 10;
198
- }
199
- if (fsSync.existsSync(path.join(dirPath, 'app.json')) || fsSync.existsSync(path.join(dirPath, 'eas.json'))) {
200
- score += 25;
201
- }
202
- try {
203
- const pkgPath = path.join(dirPath, 'package.json');
204
- if (fsSync.existsSync(pkgPath)) {
205
- const pkgRaw = fsSync.readFileSync(pkgPath, 'utf-8');
206
- if (pkgRaw.includes('"react-native"') || pkgRaw.includes('"expo"')) {
207
- score += 20;
208
- }
209
- }
210
- } catch {
211
- // Ignore malformed package.json
212
- }
213
- return score;
214
- }
215
-
216
- /**
217
- * Automatically locates the React Native / Expo mobile project root even when invoked
218
- * from a top-level monorepo or parent workspace folder (e.g., containing `mobile/` or `apps/mobile/`).
219
- */
220
- export function findMobileAppRoot(rawRoot: string): string {
221
- const normalizedRoot = path.resolve(rawRoot);
222
- if (!fsSync.existsSync(normalizedRoot)) {
223
- return normalizedRoot;
224
- }
225
-
226
- // 1. Check if rawRoot itself directly contains React Native / Expo screens
227
- const direct = hasDirectScreenFiles(normalizedRoot);
228
- if (direct.found) {
229
- return normalizedRoot;
230
- }
231
-
232
- // 2. Scan up to 2 levels of subdirectories for a nested mobile app
233
- let bestCandidate: string | null = null;
234
- let bestScore = -1;
235
-
236
- function scanSubdirs(parentDir: string, depth: number) {
237
- if (depth > 2) return;
238
- let entries: fsSync.Dirent[] = [];
239
- try {
240
- entries = fsSync.readdirSync(parentDir, { withFileTypes: true });
241
- } catch {
242
- return;
243
- }
244
-
245
- for (const entry of entries) {
246
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
247
- if (IGNORED_SCAN_DIRS.has(entry.name)) continue;
248
-
249
- const subPath = path.join(parentDir, entry.name);
250
- const check = hasDirectScreenFiles(subPath);
251
- if (check.found) {
252
- const score = scoreMobileCandidate(subPath, check.screenCount);
253
- if (score > bestScore) {
254
- bestScore = score;
255
- bestCandidate = subPath;
256
- }
257
- } else if (depth < 2) {
258
- scanSubdirs(subPath, depth + 1);
259
- }
260
- }
261
- }
262
-
263
- scanSubdirs(normalizedRoot, 1);
264
- return bestCandidate ?? normalizedRoot;
265
- }
266
-
267
- /**
268
- * Resolves the active target project root (supports ANTIGRAVITY_TARGET_PROJECT and nested mobile folders).
269
- */
270
- export function getActiveProjectRoot(fallbackRoot: string): string {
271
- const envTarget = process.env.ANTIGRAVITY_TARGET_PROJECT;
272
- const baseRoot =
273
- envTarget && fsSync.existsSync(envTarget)
274
- ? path.resolve(envTarget)
275
- : path.resolve(fallbackRoot);
276
- return findMobileAppRoot(baseRoot);
277
- }
278
-
279
- /**
280
- * Finds the active screens directory inside a React Native / Expo project.
281
- * Checks `src/screens`, `screens`, `app` (Expo Router), and `src/app`.
282
- */
283
- export function resolveScreensDirectory(projectRoot: string): {
284
- dirPath: string;
285
- relDir: string;
286
- } {
287
- const normalizedRoot = findMobileAppRoot(projectRoot);
288
-
289
- // Prefer a candidate directory that actually contains .tsx/.jsx screen files
290
- for (const rel of SCREEN_DIR_CANDIDATES) {
291
- const full = path.join(normalizedRoot, rel);
292
- if (
293
- fsSync.existsSync(full) &&
294
- fsSync.statSync(full).isDirectory() &&
295
- countScreenFilesInDirectory(full) > 0
296
- ) {
297
- return { dirPath: full, relDir: rel };
298
- }
299
- }
300
-
301
- for (const rel of SCREEN_DIR_CANDIDATES) {
302
- const full = path.join(normalizedRoot, rel);
303
- if (fsSync.existsSync(full) && fsSync.statSync(full).isDirectory()) {
304
- return { dirPath: full, relDir: rel };
305
- }
306
- }
307
-
308
- return {
309
- dirPath: path.join(normalizedRoot, 'src/screens'),
310
- relDir: 'src/screens',
311
- };
312
- }
313
-
314
- const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
315
-
316
- function safeRealpath(targetPath: string): string {
317
- try {
318
- return fsSync.realpathSync(targetPath);
319
- } catch {
320
- return path.resolve(targetPath);
321
- }
322
- }
323
-
324
- /**
325
- * Validates that a relative file path stays strictly inside the project's screens directory
326
- * (`src/screens`, `screens`, or `app`) and ends with `.tsx` or `.jsx`.
327
- * Prevents lexical path traversal (`../`) and symbolic link escape attacks.
328
- */
329
- export function resolveSafeScreenPath(workspaceRoot: string, relativeFile: string): string {
330
- const normalizedRoot = safeRealpath(getActiveProjectRoot(workspaceRoot));
331
- const cleanRel = String(relativeFile ?? '').replace(/^[/\\]+/, '');
332
- const resolved = path.resolve(normalizedRoot, cleanRel);
333
-
334
- const allowedDirs = ['src/screens', 'screens', 'app', 'src/app'].map((d) => {
335
- const candidate = path.resolve(normalizedRoot, d);
336
- return fsSync.existsSync(candidate) ? safeRealpath(candidate) : candidate;
337
- });
338
-
339
- const isLexicallyAllowed = allowedDirs.some(
340
- (dir) => resolved.startsWith(dir + path.sep) || resolved === dir
341
- );
342
-
343
- if (!isLexicallyAllowed) {
344
- throw new Error(
345
- `Security violation: File path "${relativeFile}" is outside allowed screen directories (src/screens, screens, app)`
346
- );
347
- }
348
- if (!resolved.endsWith('.tsx') && !resolved.endsWith('.jsx')) {
349
- throw new Error(`Invalid file extension for "${relativeFile}". Only .tsx and .jsx are allowed.`);
350
- }
351
-
352
- // Reject symbolic links or symlinked ancestor directories that escape the allowed screens directories
353
- if (fsSync.existsSync(resolved)) {
354
- const stat = fsSync.lstatSync(resolved);
355
- if (stat.isSymbolicLink()) {
356
- throw new Error(
357
- `Security violation: Symbolic link "${relativeFile}" is not permitted inside screen directories`
358
- );
359
- }
360
- const realTarget = safeRealpath(resolved);
361
- const isRealInsideAllowed = allowedDirs.some(
362
- (dir) => realTarget.startsWith(dir + path.sep) || realTarget === dir
363
- );
364
- if (!isRealInsideAllowed) {
365
- throw new Error(
366
- `Security violation: Canonical path for "${relativeFile}" resolves outside allowed screen directories`
367
- );
368
- }
369
- return realTarget;
370
- }
371
-
372
- return resolved;
373
- }
374
-
375
- /**
376
- * Normalizes a route target string (e.g. '/(tabs)/product-detail' or 'ProductDetailScreen') to screen ID.
377
- */
378
- export function normalizeRouteTarget(rawTarget: string): string {
379
- const trimmed = rawTarget
380
- .replace(/^\/+/, '')
381
- .replace(/\([^)]+\)\/?/g, '')
382
- .replace(/\[\.\.\.([^\]]+)\]/g, 'CatchAll_$1')
383
- .replace(/\[([^\]]+)\]/g, 'By_$1')
384
- .replace(/Screen$/, '')
385
- .replace(/\/index$/, '');
386
- if (!trimmed || trimmed === 'index') return 'Home';
387
- if (trimmed.includes('-') || trimmed.includes('/') || trimmed.includes('_') || trimmed === trimmed.toLowerCase()) {
388
- return trimmed
389
- .split(/[-_/]/)
390
- .filter(Boolean)
391
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
392
- .join('');
393
- }
394
- return trimmed;
395
- }
396
-
397
- /**
398
- * Extracts primitive value (string or number) from an AST expression node.
399
- */
400
- function extractLiteralValue(node: t.Node | null | undefined): StyleValue | undefined {
401
- if (!node) return undefined;
402
- if (t.isStringLiteral(node)) return node.value;
403
- if (t.isNumericLiteral(node)) return node.value;
404
- if (t.isUnaryExpression(node) && node.operator === '-' && t.isNumericLiteral(node.argument)) {
405
- return -node.argument.value;
406
- }
407
- return undefined;
408
- }
409
-
410
- /**
411
- * Collects all `StyleSheet.create({ ... })` style definitions in an AST.
412
- */
413
- function collectStyleSheetMap(ast: t.File): {
414
- identifierName: string;
415
- styleObjects: Map<string, t.ObjectExpression>;
416
- valuesMap: Map<string, Record<string, StyleValue>>;
417
- } {
418
- let identifierName = 'styles';
419
- const styleObjects = new Map<string, t.ObjectExpression>();
420
- const valuesMap = new Map<string, Record<string, StyleValue>>();
421
-
422
- traverse(ast, {
423
- VariableDeclarator(nodePath) {
424
- const { id, init } = nodePath.node;
425
- if (!t.isIdentifier(id) || !init) return;
426
-
427
- const isStyleSheetCreate =
428
- t.isCallExpression(init) &&
429
- t.isMemberExpression(init.callee) &&
430
- t.isIdentifier(init.callee.object, { name: 'StyleSheet' }) &&
431
- t.isIdentifier(init.callee.property, { name: 'create' }) &&
432
- init.arguments.length > 0 &&
433
- t.isObjectExpression(init.arguments[0]);
434
-
435
- if (!isStyleSheetCreate) return;
436
-
437
- identifierName = id.name;
438
- const rootObj = init.arguments[0] as t.ObjectExpression;
439
-
440
- for (const prop of rootObj.properties) {
441
- if (t.isObjectProperty(prop) && t.isObjectExpression(prop.value)) {
442
- const keyName = t.isIdentifier(prop.key)
443
- ? prop.key.name
444
- : t.isStringLiteral(prop.key)
445
- ? prop.key.value
446
- : null;
447
- if (!keyName) continue;
448
-
449
- styleObjects.set(keyName, prop.value);
450
- const styleRecord: Record<string, StyleValue> = {};
451
-
452
- for (const rule of prop.value.properties) {
453
- if (t.isObjectProperty(rule)) {
454
- const ruleKey = t.isIdentifier(rule.key)
455
- ? rule.key.name
456
- : t.isStringLiteral(rule.key)
457
- ? rule.key.value
458
- : null;
459
- const val = extractLiteralValue(rule.value);
460
- if (ruleKey && val !== undefined) {
461
- styleRecord[ruleKey] = val;
462
- }
463
- }
464
- }
465
- valuesMap.set(keyName, styleRecord);
466
- }
467
- }
468
- },
469
- });
470
-
471
- return { identifierName, styleObjects, valuesMap };
472
- }
473
-
474
- /**
475
- * Extracts static text content and checks if a JSXElement contains dynamic expressions.
476
- */
477
- function inspectJsxTextChildren(jsxElement: t.JSXElement): {
478
- textContent: string | null;
479
- hasDynamicText: boolean;
480
- isTextEditable: boolean;
481
- } {
482
- const tagName = getJsxTagName(jsxElement.openingElement.name);
483
- const isTextComponent = tagName === 'Text' || tagName.endsWith('.Text');
484
-
485
- let hasDynamicText = false;
486
- const textParts: string[] = [];
487
- let hasChildElements = false;
488
-
489
- for (const child of jsxElement.children) {
490
- if (t.isJSXText(child)) {
491
- const cleaned = child.value.replace(/\s+/g, ' ').trim();
492
- if (cleaned) textParts.push(cleaned);
493
- } else if (t.isJSXExpressionContainer(child)) {
494
- if (t.isStringLiteral(child.expression)) {
495
- textParts.push(child.expression.value);
496
- } else if (t.isNumericLiteral(child.expression)) {
497
- textParts.push(String(child.expression.value));
498
- } else if (!t.isJSXEmptyExpression(child.expression)) {
499
- hasDynamicText = true;
500
- }
501
- } else if (t.isJSXElement(child) || t.isJSXFragment(child)) {
502
- hasChildElements = true;
503
- }
504
- }
505
-
506
- if (hasChildElements && !isTextComponent) {
507
- return {
508
- textContent: null,
509
- hasDynamicText: false,
510
- isTextEditable: false,
511
- };
512
- }
513
-
514
- const joined = textParts.join(' ').trim();
515
- const isTextEditable = (isTextComponent || joined.length > 0) && !hasDynamicText && !hasChildElements;
516
-
517
- return {
518
- textContent: joined.length > 0 || isTextComponent ? joined : null,
519
- hasDynamicText,
520
- isTextEditable,
521
- };
522
- }
523
-
524
- /**
525
- * Extracts `navigation.navigate('Target')` or `router.push('/target')` target on an element's `onPress`.
526
- */
527
- function extractElementNavigationTarget(openingElement: t.JSXOpeningElement): string | null {
528
- for (const attr of openingElement.attributes) {
529
- if (
530
- t.isJSXAttribute(attr) &&
531
- t.isJSXIdentifier(attr.name) &&
532
- ['onPress', 'href', 'to'].includes(attr.name.name)
533
- ) {
534
- if (t.isStringLiteral(attr.value)) {
535
- return normalizeRouteTarget(attr.value.value);
536
- }
537
- if (t.isJSXExpressionContainer(attr.value)) {
538
- const expr = attr.value.expression;
539
- const codeStr = generate(expr).code;
540
- const match = codeStr.match(/(?:navigate|push|replace|jumpTo)\(\s*['"`]([^'"`$]+)['"`]/);
541
- if (match) return normalizeRouteTarget(match[1]);
542
- const objMatch = codeStr.match(/(?:pathname|screen|route|name)\s*:\s*['"`]([^'"`$]+)['"`]/);
543
- if (objMatch) return normalizeRouteTarget(objMatch[1]);
544
- }
545
- }
546
- }
547
- return null;
548
- }
549
-
550
- /**
551
- * Resolves the style properties and style source for a given JSXOpeningElement.
552
- */
553
- function resolveElementStyles(
554
- openingElement: t.JSXOpeningElement,
555
- styleSheetInfo: ReturnType<typeof collectStyleSheetMap>
556
- ): {
557
- styleSource: 'stylesheet' | 'inline' | 'none';
558
- styleSheetKey: string | null;
559
- inlineObject: t.ObjectExpression | null;
560
- styles: Record<string, StyleValue>;
561
- } {
562
- let styleSource: 'stylesheet' | 'inline' | 'none' = 'none';
563
- let styleSheetKey: string | null = null;
564
- let inlineObject: t.ObjectExpression | null = null;
565
- const styles: Record<string, StyleValue> = {};
566
-
567
- const styleAttr = openingElement.attributes.find(
568
- (attr): attr is t.JSXAttribute =>
569
- t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'style' })
570
- );
571
-
572
- if (!styleAttr || !t.isJSXExpressionContainer(styleAttr.value)) {
573
- return { styleSource, styleSheetKey, inlineObject, styles };
574
- }
575
-
576
- const expr = styleAttr.value.expression;
577
-
578
- const processExpression = (node: t.Expression | t.SpreadElement | t.JSXEmptyExpression) => {
579
- if (
580
- t.isMemberExpression(node) &&
581
- t.isIdentifier(node.object, { name: styleSheetInfo.identifierName }) &&
582
- t.isIdentifier(node.property)
583
- ) {
584
- const key = node.property.name;
585
- styleSheetKey = key;
586
- styleSource = 'stylesheet';
587
- const sheetStyles = styleSheetInfo.valuesMap.get(key);
588
- if (sheetStyles) {
589
- Object.assign(styles, sheetStyles);
590
- }
591
- } else if (t.isObjectExpression(node)) {
592
- inlineObject = node;
593
- if (styleSource === 'none') {
594
- styleSource = 'inline';
595
- }
596
- for (const prop of node.properties) {
597
- if (t.isObjectProperty(prop)) {
598
- const propKey = t.isIdentifier(prop.key)
599
- ? prop.key.name
600
- : t.isStringLiteral(prop.key)
601
- ? prop.key.value
602
- : null;
603
- const val = extractLiteralValue(prop.value);
604
- if (propKey && val !== undefined) {
605
- styles[propKey] = val;
606
- }
607
- }
608
- }
609
- } else if (t.isArrayExpression(node)) {
610
- for (const el of node.elements) {
611
- if (el && (t.isExpression(el) || t.isSpreadElement(el))) {
612
- processExpression(el);
613
- }
614
- }
615
- }
616
- };
617
-
618
- processExpression(expr);
619
-
620
- return { styleSource, styleSheetKey, inlineObject, styles };
621
- }
622
-
623
- export interface DiscoveredScreenFile {
624
- id: string;
625
- name: string;
626
- routeName: string;
627
- relPath: string;
628
- fullPath: string;
629
- }
630
-
631
- /**
632
- * Synchronously discovers all screen files in the active React Native / Expo project
633
- * (`src/screens`, `screens`, `app`, or `src/app`), supporting nested route folders.
634
- */
635
- export function discoverScreenFilesSync(workspaceRoot: string): DiscoveredScreenFile[] {
636
- const activeRoot = getActiveProjectRoot(workspaceRoot);
637
- const { dirPath: screensDir, relDir } = resolveScreensDirectory(activeRoot);
638
-
639
- const collectedRelFromScreensDir: string[] = [];
640
-
641
- function walk(currDir: string, relPrefix: string, depth: number) {
642
- if (depth > 4) return;
643
- let dirents: fsSync.Dirent[] = [];
644
- try {
645
- dirents = fsSync.readdirSync(currDir, { withFileTypes: true });
646
- } catch {
647
- return;
648
- }
649
-
650
- for (const entry of dirents) {
651
- if (entry.name.startsWith('.')) continue;
652
- if (entry.isDirectory()) {
653
- if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name === 'components') {
654
- continue;
655
- }
656
- walk(
657
- path.join(currDir, entry.name),
658
- relPrefix ? `${relPrefix}/${entry.name}` : entry.name,
659
- depth + 1
660
- );
661
- } else if (entry.isFile()) {
662
- const f = entry.name;
663
- if (f.startsWith('_') || f.startsWith('+')) continue;
664
- if (f === 'index.ts' || f === 'index.js') continue;
665
- if (f.includes('.test.') || f.includes('.spec.') || f.endsWith('.d.ts')) continue;
666
- if (!f.endsWith('.tsx') && !f.endsWith('.jsx')) continue;
667
-
668
- collectedRelFromScreensDir.push(relPrefix ? `${relPrefix}/${f}` : f);
669
- }
670
- }
671
- }
672
-
673
- walk(screensDir, '', 0);
674
- collectedRelFromScreensDir.sort();
675
-
676
- const seenIds = new Set<string>();
677
- const files: DiscoveredScreenFile[] = [];
678
-
679
- for (const subRel of collectedRelFromScreensDir) {
680
- const relPath = `${relDir}/${subRel}`.replace(/\\/g, '/');
681
- const fullPath = path.join(screensDir, subRel);
682
- const noExt = subRel.replace(/\.(tsx|jsx)$/, '');
683
- const baseFileName = path.basename(noExt);
684
-
685
- let routeName = normalizeRouteTarget(noExt);
686
- if (seenIds.has(routeName)) {
687
- routeName = normalizeRouteTarget(noExt.replace(/[()]/g, ''));
688
- }
689
- if (seenIds.has(routeName)) {
690
- let suffix = 2;
691
- while (seenIds.has(`${routeName}${suffix}`)) {
692
- suffix++;
693
- }
694
- routeName = `${routeName}${suffix}`;
695
- }
696
- seenIds.add(routeName);
697
-
698
- const displayName = baseFileName.endsWith('Screen') ? baseFileName : `${routeName}Screen`;
699
-
700
- files.push({
701
- id: routeName,
702
- name: displayName,
703
- routeName,
704
- relPath,
705
- fullPath,
706
- });
707
- }
708
-
709
- return files;
710
- }
711
-
712
- function computeScreenStem(idOrName: string): string {
713
- return idOrName
714
- .toLowerCase()
715
- .replace(/(?:screen|page|view|tab|modal|stack|details|detail|list)$/i, '')
716
- .replace(/[-_/\s]+/g, '')
717
- .replace(/s$/, '');
718
- }
719
-
720
- function unwrapAstNode(node: t.Node | null | undefined): t.Node | null {
721
- let curr = node ?? null;
722
- while (curr) {
723
- if (
724
- t.isTSAsExpression(curr) ||
725
- t.isTSTypeAssertion(curr) ||
726
- t.isTSNonNullExpression(curr) ||
727
- t.isTSSatisfiesExpression(curr) ||
728
- t.isParenthesizedExpression(curr)
729
- ) {
730
- curr = curr.expression;
731
- } else {
732
- break;
733
- }
734
- }
735
- return curr;
736
- }
737
-
738
- function extractRouteStringsFromAstNode(node: t.Node | null | undefined, depth = 0): string[] {
739
- if (depth > 4) return [];
740
- const unwrapped = unwrapAstNode(node);
741
- if (!unwrapped) return [];
742
-
743
- if (t.isStringLiteral(unwrapped)) {
744
- return [unwrapped.value];
745
- }
746
- if (t.isTemplateLiteral(unwrapped)) {
747
- const joined = unwrapped.quasis.map((q) => q.value.cooked ?? q.value.raw).join('id');
748
- return joined ? [joined] : [];
749
- }
750
- if (t.isConditionalExpression(unwrapped)) {
751
- return [
752
- ...extractRouteStringsFromAstNode(unwrapped.consequent, depth + 1),
753
- ...extractRouteStringsFromAstNode(unwrapped.alternate, depth + 1),
754
- ];
755
- }
756
- if (t.isLogicalExpression(unwrapped)) {
757
- return [
758
- ...extractRouteStringsFromAstNode(unwrapped.left, depth + 1),
759
- ...extractRouteStringsFromAstNode(unwrapped.right, depth + 1),
760
- ];
761
- }
762
- if (t.isArrayExpression(unwrapped)) {
763
- const out: string[] = [];
764
- for (const el of unwrapped.elements) {
765
- if (el) out.push(...extractRouteStringsFromAstNode(el, depth + 1));
766
- }
767
- return out;
768
- }
769
- if (t.isObjectExpression(unwrapped)) {
770
- const out: string[] = [];
771
- const routeKeys = new Set([
772
- 'screen',
773
- 'pathname',
774
- 'route',
775
- 'href',
776
- 'name',
777
- 'path',
778
- 'target',
779
- 'to',
780
- 'destination',
781
- 'params',
782
- 'routes',
783
- ]);
784
- for (const prop of unwrapped.properties) {
785
- if (t.isObjectProperty(prop)) {
786
- const keyName = t.isIdentifier(prop.key)
787
- ? prop.key.name
788
- : t.isStringLiteral(prop.key)
789
- ? prop.key.value
790
- : '';
791
- if (routeKeys.has(keyName)) {
792
- out.push(...extractRouteStringsFromAstNode(prop.value, depth + 1));
793
- }
794
- }
795
- }
796
- return out;
797
- }
798
- return [];
799
- }
800
-
801
- function resolveCandidateToScreenId(
802
- rawCandidate: string,
803
- screenFiles: DiscoveredScreenFile[],
804
- currentScreenId: string,
805
- allowUnmatchedFallback = false
806
- ): string | null {
807
- const trimmed = rawCandidate.trim();
808
- if (!trimmed || trimmed.length > 80) return null;
809
- if (
810
- trimmed.startsWith('http://') ||
811
- trimmed.startsWith('https://') ||
812
- trimmed.startsWith('mailto:') ||
813
- trimmed.startsWith('tel:') ||
814
- trimmed.startsWith('#') ||
815
- /\.(png|jpg|jpeg|svg|webp|gif|json|css)$/i.test(trimmed)
816
- ) {
817
- return null;
818
- }
819
-
820
- const normalized = normalizeRouteTarget(trimmed);
821
- if (!normalized || normalized.toLowerCase() === currentScreenId.toLowerCase()) {
822
- return null;
823
- }
824
-
825
- // 1. Exact ID or Screen Name match (case-insensitive)
826
- for (const s of screenFiles) {
827
- if (s.id.toLowerCase() === currentScreenId.toLowerCase()) continue;
828
- const sIdLower = s.id.toLowerCase();
829
- const sNameLower = s.name.toLowerCase();
830
- const normLower = normalized.toLowerCase();
831
- if (
832
- sIdLower === normLower ||
833
- sNameLower === normLower ||
834
- sNameLower === `${normLower}screen`
835
- ) {
836
- return s.id;
837
- }
838
- }
839
-
840
- // 2. Path segment match (from right to left, ignoring group/param/id segments)
841
- const segments = trimmed
842
- .split(/[/.]/)
843
- .map((seg) => seg.trim())
844
- .filter(
845
- (seg) =>
846
- seg.length > 0 &&
847
- !seg.startsWith('(') &&
848
- !seg.startsWith('[') &&
849
- seg.toLowerCase() !== 'id' &&
850
- seg.toLowerCase() !== 'index' &&
851
- !/^\d+$/.test(seg)
852
- );
853
-
854
- for (let i = segments.length - 1; i >= 0; i--) {
855
- const segNorm = normalizeRouteTarget(segments[i]).toLowerCase();
856
- for (const s of screenFiles) {
857
- if (s.id.toLowerCase() === currentScreenId.toLowerCase()) continue;
858
- if (s.id.toLowerCase() === segNorm || s.name.toLowerCase() === `${segNorm}screen`) {
859
- return s.id;
860
- }
861
- }
862
- }
863
-
864
- // 3. Stem / singular-plural / prefix match (e.g. 'dealer'/'dealers' -> 'DealerDetails', 'spare-parts' -> 'Parts')
865
- const candidateStems = [
866
- computeScreenStem(normalized),
867
- ...segments.map((seg) => computeScreenStem(seg)),
868
- ].filter((st) => st.length >= 4);
869
-
870
- for (const candStem of candidateStems) {
871
- for (const s of screenFiles) {
872
- if (s.id.toLowerCase() === currentScreenId.toLowerCase()) continue;
873
- const targetStem = computeScreenStem(s.id);
874
- if (targetStem.length < 3) continue;
875
- if (
876
- targetStem === candStem ||
877
- (candStem.length >= 4 && s.id.toLowerCase().startsWith(candStem)) ||
878
- (targetStem.length >= 4 && candStem.endsWith(targetStem))
879
- ) {
880
- return s.id;
881
- }
882
- }
883
- }
884
-
885
- return allowUnmatchedFallback ? normalized : null;
886
- }
887
-
888
- /**
889
- * Discovers all screens in the active React Native / Expo project (`src/screens`, `screens`, or `app`)
890
- * and extracts their navigation flow graph (both forward flows and return/back flows).
891
- */
892
- export async function discoverScreens(workspaceRoot: string): Promise<ScreenMetadata[]> {
893
- const screenFiles = discoverScreenFilesSync(workspaceRoot);
894
- const results: ScreenMetadata[] = [];
895
- const backNavigationMeta = new Map<string, { hasBack: boolean; line: number }>();
896
-
897
- for (const item of screenFiles) {
898
- const { id: routeName, name, relPath, fullPath } = item;
899
-
900
- try {
901
- const code = await fs.readFile(fullPath, 'utf-8');
902
- const ast = parse(code, {
903
- sourceType: 'module',
904
- plugins: ['typescript', 'jsx'],
905
- });
906
-
907
- let elementCount = 0;
908
- const outgoingFlows: NavigationEdge[] = [];
909
- let hasBackNavigation = false;
910
- let backLine = 1;
911
-
912
- const addFlowEdge = (targetScreenId: string, line: number, customLabel?: string) => {
913
- if (!targetScreenId || targetScreenId === routeName) return;
914
- if (outgoingFlows.some((e) => e.fromScreen === routeName && e.toScreen === targetScreenId)) {
915
- return;
916
- }
917
- outgoingFlows.push({
918
- id: `${routeName}->${targetScreenId}:${line}`,
919
- fromScreen: routeName,
920
- toScreen: targetScreenId,
921
- triggerLabel: customLabel || `Navigate to ${targetScreenId}`,
922
- line,
923
- });
924
- };
925
-
926
- const extractTriggerLabelFromAncestry = (nodePath: NodePath<t.Node>, fallbackTarget: string): string => {
927
- const jsxParent = nodePath.findParent((p) => p.isJSXElement()) as NodePath<t.JSXElement> | null;
928
- if (jsxParent) {
929
- const rawCode = generate(jsxParent.node).code;
930
- const textMatches = Array.from(rawCode.matchAll(/>([^<>{}\n]+)</g))
931
- .map((m) => m[1].trim())
932
- .filter(Boolean);
933
- if (textMatches.length > 0) {
934
- return textMatches.slice(0, 2).join(' · ');
935
- }
936
- }
937
- return `Navigate to ${fallbackTarget}`;
938
- };
939
-
940
- traverse(ast, {
941
- JSXOpeningElement(nodePath) {
942
- const tagName = getJsxTagName(nodePath.node.name);
943
- if (tagName !== 'Fragment' && tagName !== 'React.Fragment' && tagName !== '') {
944
- elementCount++;
945
- }
946
-
947
- const line = nodePath.node.loc?.start.line ?? 1;
948
-
949
- // Check <Link href="..."> or route/screen/to attributes on JSX elements
950
- for (const attr of nodePath.node.attributes) {
951
- if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name)) continue;
952
- const attrName = attr.name.name;
953
-
954
- if (['href', 'to', 'route', 'screen', 'target', 'pathname'].includes(attrName)) {
955
- const valNode = t.isJSXExpressionContainer(attr.value) ? attr.value.expression : attr.value;
956
- const candidates = extractRouteStringsFromAstNode(valNode);
957
- for (const raw of candidates) {
958
- const resolved = resolveCandidateToScreenId(raw, screenFiles, routeName, false);
959
- if (resolved) {
960
- addFlowEdge(resolved, line, extractTriggerLabelFromAncestry(nodePath, resolved));
961
- }
962
- }
963
- }
964
-
965
- // Check onPress callback identifiers referencing another screen (e.g. onForgotPassword, onSignup)
966
- if (attrName === 'onPress' && t.isJSXExpressionContainer(attr.value)) {
967
- const exprCode = generate(attr.value.expression).code;
968
- if (/\b(?:goBack|back|pop|popToTop)\s*\(/.test(exprCode)) {
969
- hasBackNavigation = true;
970
- backLine = line;
971
- }
972
- if (/\b(?:signOut|logout|logOut)\b/i.test(exprCode)) {
973
- const loginScreen = screenFiles.find((s) => s.id.toLowerCase() === 'login');
974
- if (loginScreen && loginScreen.id !== routeName) {
975
- addFlowEdge(loginScreen.id, line, 'Sign Out');
976
- }
977
- }
978
- for (const s of screenFiles) {
979
- if (s.id === routeName) continue;
980
- const stem = computeScreenStem(s.id);
981
- if (stem.length >= 4) {
982
- const actionRegex = new RegExp(
983
- `(?:on|handle|goTo|navigateTo|open|view|show|select)${s.id}|${s.id}(?:Press|Click|Action|Screen|Route)`,
984
- 'i'
985
- );
986
- if (actionRegex.test(exprCode)) {
987
- addFlowEdge(s.id, line, extractTriggerLabelFromAncestry(nodePath, s.id));
988
- }
989
- }
990
- }
991
- }
992
- }
993
- },
994
-
995
- CallExpression(nodePath) {
996
- const { callee, arguments: args } = nodePath.node;
997
- const line = nodePath.node.loc?.start.line ?? 1;
998
-
999
- let methodName = '';
1000
- if (t.isMemberExpression(callee) && t.isIdentifier(callee.property)) {
1001
- methodName = callee.property.name;
1002
- } else if (t.isIdentifier(callee)) {
1003
- methodName = callee.name;
1004
- }
1005
-
1006
- if (['goBack', 'back', 'pop', 'popToTop'].includes(methodName)) {
1007
- hasBackNavigation = true;
1008
- backLine = line;
1009
- return;
1010
- }
1011
-
1012
- if (/^(?:signOut|logout|logOut)$/i.test(methodName)) {
1013
- const loginScreen = screenFiles.find((s) => s.id.toLowerCase() === 'login');
1014
- if (loginScreen && loginScreen.id !== routeName) {
1015
- addFlowEdge(loginScreen.id, line, 'Sign Out');
1016
- }
1017
- return;
1018
- }
1019
-
1020
- const isDirectNavMethod = ['navigate', 'push', 'replace', 'jumpTo', 'reset'].includes(methodName);
1021
- const isHelperNavCall = /^(?:navigateTo|goTo|openScreen|handleNavigate|onNavigate)$/i.test(methodName);
1022
-
1023
- if (!isDirectNavMethod && !isHelperNavCall) return;
1024
-
1025
- const extractedCandidates: string[] = [];
1026
- for (const arg of args) {
1027
- extractedCandidates.push(...extractRouteStringsFromAstNode(arg));
1028
- }
1029
-
1030
- // Prefer candidates that match an actual discovered screen in the project (e.g. 2nd arg { screen: 'Marketplace' })
1031
- let matchedAny = false;
1032
- for (const raw of extractedCandidates) {
1033
- const resolved = resolveCandidateToScreenId(raw, screenFiles, routeName, false);
1034
- if (resolved) {
1035
- matchedAny = true;
1036
- addFlowEdge(resolved, line, extractTriggerLabelFromAncestry(nodePath, resolved));
1037
- }
1038
- }
1039
-
1040
- // Fallback to normalized literal target for direct navigate/push/replace calls
1041
- if (!matchedAny && isDirectNavMethod && extractedCandidates.length > 0) {
1042
- const fallbackTarget = normalizeRouteTarget(extractedCandidates[0]);
1043
- if (fallbackTarget && fallbackTarget !== routeName) {
1044
- addFlowEdge(fallbackTarget, line, extractTriggerLabelFromAncestry(nodePath, fallbackTarget));
1045
- }
1046
- }
1047
- },
1048
-
1049
- ObjectProperty(nodePath) {
1050
- const { key, value } = nodePath.node;
1051
- const keyName = t.isIdentifier(key)
1052
- ? key.name
1053
- : t.isStringLiteral(key)
1054
- ? key.value
1055
- : '';
1056
- if (!keyName) return;
1057
-
1058
- const isExplicitRouteProp = [
1059
- 'route',
1060
- 'screen',
1061
- 'href',
1062
- 'pathname',
1063
- 'path',
1064
- 'target',
1065
- 'destination',
1066
- 'screenName',
1067
- 'nav',
1068
- 'tab',
1069
- ].includes(keyName);
1070
-
1071
- const isItemActionProp = ['id', 'key', 'slug', 'action'].includes(keyName);
1072
-
1073
- if (!isExplicitRouteProp && !isItemActionProp) return;
1074
-
1075
- const candidates = extractRouteStringsFromAstNode(value);
1076
- const line = nodePath.node.loc?.start.line ?? 1;
1077
-
1078
- for (const raw of candidates) {
1079
- if (isExplicitRouteProp) {
1080
- const resolved = resolveCandidateToScreenId(raw, screenFiles, routeName, false);
1081
- if (resolved) {
1082
- addFlowEdge(resolved, line, `Navigate to ${resolved}`);
1083
- }
1084
- } else if (isItemActionProp) {
1085
- // For id/key/slug inside data arrays, require an exact screen ID match (min 4 chars)
1086
- const norm = normalizeRouteTarget(raw);
1087
- const exactScreen = screenFiles.find(
1088
- (s) =>
1089
- s.id !== routeName &&
1090
- s.id.length >= 4 &&
1091
- s.id.toLowerCase() === norm.toLowerCase()
1092
- );
1093
- if (exactScreen) {
1094
- addFlowEdge(exactScreen.id, line, `Open ${exactScreen.id}`);
1095
- }
1096
- }
1097
- }
1098
- },
1099
-
1100
- JSXText(nodePath) {
1101
- const txt = nodePath.node.value.trim();
1102
- if (/^(?:←\s*)?Back$/i.test(txt)) {
1103
- hasBackNavigation = true;
1104
- backLine = nodePath.node.loc?.start.line ?? 1;
1105
- }
1106
- },
1107
- });
1108
-
1109
- backNavigationMeta.set(routeName, { hasBack: hasBackNavigation, line: backLine });
1110
-
1111
- results.push({
1112
- id: routeName,
1113
- name,
1114
- routeName,
1115
- filePath: relPath,
1116
- elementCount,
1117
- outgoingFlows,
1118
- });
1119
- } catch (err) {
1120
- const msg = err instanceof Error ? err.message : String(err);
1121
- results.push({
1122
- id: routeName,
1123
- name,
1124
- routeName,
1125
- filePath: relPath,
1126
- elementCount: 0,
1127
- outgoingFlows: [],
1128
- parseError: msg,
1129
- });
1130
- }
1131
- }
1132
-
1133
- // Pass 2: Connect Hub / Auth / Tab screens and synthesize Return / Back flows (router.back() / navigation.goBack())
1134
- const screenMap = new Map<string, ScreenMetadata>(results.map((s) => [s.id, s]));
1135
- const homeScreen =
1136
- results.find((s) => s.id.toLowerCase() === 'home') ??
1137
- results.find((s) => ['dashboard', 'main', 'index', 'feed'].includes(s.id.toLowerCase()));
1138
- const loginScreen = results.find((s) => ['login', 'signin', 'auth'].includes(s.id.toLowerCase()));
1139
- const signupScreen = results.find((s) => ['signup', 'register', 'createaccount'].includes(s.id.toLowerCase()));
1140
- const forgotScreen = results.find((s) => s.id.toLowerCase().includes('forgot'));
1141
- const profileScreen = results.find((s) => s.id.toLowerCase() === 'profile');
1142
-
1143
- const ensureEdge = (fromId: string, toId: string, label: string, isReturn = false, line = 1) => {
1144
- if (!fromId || !toId || fromId === toId) return;
1145
- const fromScr = screenMap.get(fromId);
1146
- if (!fromScr || !screenMap.has(toId)) return;
1147
- if (fromScr.outgoingFlows.some((e) => e.toScreen === toId)) return;
1148
- fromScr.outgoingFlows.push({
1149
- id: `${fromId}->${toId}${isReturn ? ':return' : ''}:${line}`,
1150
- fromScreen: fromId,
1151
- toScreen: toId,
1152
- triggerLabel: label,
1153
- line,
1154
- });
1155
- };
1156
-
1157
- // Standard Auth <-> Home & Auth <-> Auth flows when screens exist in project
1158
- if (loginScreen && homeScreen) {
1159
- ensureEdge(loginScreen.id, homeScreen.id, `Sign In → ${homeScreen.id}`);
1160
- }
1161
- if (signupScreen && loginScreen) {
1162
- ensureEdge(signupScreen.id, loginScreen.id, `Back to ${loginScreen.id}`, true);
1163
- }
1164
- if (signupScreen && homeScreen) {
1165
- ensureEdge(signupScreen.id, homeScreen.id, `Continue → ${homeScreen.id}`);
1166
- }
1167
- if (forgotScreen && loginScreen) {
1168
- ensureEdge(forgotScreen.id, loginScreen.id, `Back to ${loginScreen.id}`, true);
1169
- }
1170
-
1171
- // Connect Profile sub-screens (Settings, Wallet) and Marketplace sub-screens (DealerDetails) if present
1172
- for (const scr of results) {
1173
- const lower = scr.id.toLowerCase();
1174
- if (profileScreen && (lower === 'settings' || lower === 'wallet' || lower === 'editprofile')) {
1175
- ensureEdge(profileScreen.id, scr.id, `Open ${scr.id}`);
1176
- }
1177
- if (lower.includes('dealer') || lower.includes('listing') || lower.includes('vehicle')) {
1178
- const marketScreen = results.find((s) =>
1179
- ['marketplace', 'catalog', 'shop', 'explore', 'search'].includes(s.id.toLowerCase())
1180
- );
1181
- if (marketScreen && marketScreen.id !== scr.id) {
1182
- ensureEdge(marketScreen.id, scr.id, `View ${scr.id}`);
1183
- }
1184
- }
1185
- }
1186
-
1187
- // Ensure all top-level feature/tab screens are reachable from Home (if they currently have 0 incoming flows)
1188
- if (homeScreen) {
1189
- for (const scr of results) {
1190
- if (scr.id === homeScreen.id) continue;
1191
- if (scr.id === loginScreen?.id || scr.id === signupScreen?.id || scr.id === forgotScreen?.id) {
1192
- continue;
1193
- }
1194
- const hasIncoming = results.some((other) =>
1195
- other.outgoingFlows.some((e) => e.toScreen === scr.id)
1196
- );
1197
- if (!hasIncoming) {
1198
- ensureEdge(homeScreen.id, scr.id, `${homeScreen.id} → ${scr.id}`);
1199
- }
1200
- }
1201
- }
1202
-
1203
- // Synthesize Return / Back wires (green dashed return loops) for screens that have Back buttons / goBack()
1204
- const forwardSnapshot = results.map((s) => ({
1205
- id: s.id,
1206
- targets: s.outgoingFlows.map((e) => e.toScreen),
1207
- }));
1208
-
1209
- for (const scr of results) {
1210
- const backMeta = backNavigationMeta.get(scr.id);
1211
- if (!backMeta?.hasBack) continue;
1212
-
1213
- const callers = forwardSnapshot
1214
- .filter((caller) => caller.id !== scr.id && caller.targets.includes(scr.id))
1215
- .map((caller) => caller.id);
1216
-
1217
- if (callers.length > 0) {
1218
- const primaryCaller =
1219
- callers.find((c) => c !== homeScreen?.id) ?? callers[0];
1220
- ensureEdge(
1221
- scr.id,
1222
- primaryCaller,
1223
- `Back to ${primaryCaller}`,
1224
- true,
1225
- backMeta.line
1226
- );
1227
- } else if (homeScreen && scr.id !== homeScreen.id) {
1228
- ensureEdge(scr.id, homeScreen.id, `Back to ${homeScreen.id}`, true, backMeta.line);
1229
- }
1230
- }
1231
-
1232
- return results;
1233
- }
1234
-
1235
- /**
1236
- * Inspects a specific JSX element inside a screen file by its deterministic `astPath`
1237
- * (or fallback `line` number) and returns its resolved styles, copy, and code snippet.
1238
- */
1239
- export async function inspectElement(
1240
- workspaceRoot: string,
1241
- params: { file: string; astPath: string; line?: number; col?: number }
1242
- ): Promise<InspectedElement> {
1243
- const fullPath = resolveSafeScreenPath(workspaceRoot, params.file);
1244
- const code = await fs.readFile(fullPath, 'utf-8');
1245
-
1246
- const ast = parse(code, {
1247
- sourceType: 'module',
1248
- plugins: ['typescript', 'jsx'],
1249
- });
1250
-
1251
- const styleSheetInfo = collectStyleSheetMap(ast);
1252
-
1253
- let matchedElement: t.JSXElement | null = null;
1254
- let matchedIndex = -1;
1255
- let currentIndex = 0;
1256
-
1257
- const targetIndex = Number.parseInt(params.astPath, 10);
1258
-
1259
- traverse(ast, {
1260
- JSXOpeningElement(nodePath) {
1261
- const tagName = getJsxTagName(nodePath.node.name);
1262
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
1263
- return;
1264
- }
1265
-
1266
- const idx = currentIndex++;
1267
- const parentJsx = nodePath.parentPath.isJSXElement() ? nodePath.parentPath.node : null;
1268
- if (!parentJsx) return;
1269
-
1270
- if (!Number.isNaN(targetIndex) && idx === targetIndex) {
1271
- matchedElement = parentJsx;
1272
- matchedIndex = idx;
1273
- } else if (!matchedElement && params.line && nodePath.node.loc?.start.line === params.line) {
1274
- matchedElement = parentJsx;
1275
- matchedIndex = idx;
1276
- }
1277
- },
1278
- });
1279
-
1280
- if (!matchedElement) {
1281
- throw new Error(`Element at astPath=${params.astPath} (line=${params.line ?? '?'}) not found in ${params.file}`);
1282
- }
1283
-
1284
- const element = matchedElement as t.JSXElement;
1285
- const opening = element.openingElement;
1286
- const tagName = getJsxTagName(opening.name);
1287
- const line = opening.loc?.start.line ?? 1;
1288
- const col = opening.loc?.start.column ?? 0;
1289
-
1290
- const textInfo = inspectJsxTextChildren(element);
1291
- const styleInfo = resolveElementStyles(opening, styleSheetInfo);
1292
- const navigationTarget = extractElementNavigationTarget(opening);
1293
-
1294
- const snippetRaw = generate(element, { concise: false }).code;
1295
- const codeSnippet = snippetRaw.length > 600 ? `${snippetRaw.slice(0, 600)}\n /* ... */` : snippetRaw;
1296
-
1297
- return {
1298
- file: params.file,
1299
- astPath: String(matchedIndex),
1300
- tagName,
1301
- line,
1302
- col,
1303
- textContent: textInfo.textContent,
1304
- hasDynamicText: textInfo.hasDynamicText,
1305
- isTextEditable: textInfo.isTextEditable,
1306
- styleSource: styleInfo.styleSource,
1307
- styleSheetKey: styleInfo.styleSheetKey,
1308
- styles: styleInfo.styles,
1309
- codeSnippet,
1310
- navigationTarget,
1311
- };
1312
- }
1313
-
1314
- function createSafeJsxTextChild(text: string): t.JSXText | t.JSXExpressionContainer {
1315
- // @babel/generator prints JSXText.value verbatim without escaping '{', '}', '<', or '>'.
1316
- // Wrap any string containing JSX metacharacters, newlines, or leading/trailing whitespace
1317
- // inside a JSXExpressionContainer(StringLiteral) so Babel safely escapes it as {"..."}.
1318
- if (/[{}<>]|[\r\n]|^\s|\s$/.test(text)) {
1319
- return t.jsxExpressionContainer(t.stringLiteral(text));
1320
- }
1321
- return t.jsxText(text);
1322
- }
1323
-
1324
- function upsertObjectProperty(
1325
- objExpr: t.ObjectExpression,
1326
- key: string,
1327
- val: StyleValue | null | undefined
1328
- ) {
1329
- if (!key || FORBIDDEN_OBJECT_KEYS.has(key) || key.length > 80) {
1330
- throw new Error(`Security violation: Invalid style property name "${key}"`);
1331
- }
1332
-
1333
- const existingIndex = objExpr.properties.findIndex((prop) => {
1334
- if (!t.isObjectProperty(prop)) return false;
1335
- const k = t.isIdentifier(prop.key)
1336
- ? prop.key.name
1337
- : t.isStringLiteral(prop.key)
1338
- ? prop.key.value
1339
- : null;
1340
- return k === key;
1341
- });
1342
-
1343
- if (val === null || val === undefined || val === '') {
1344
- if (existingIndex !== -1) {
1345
- objExpr.properties.splice(existingIndex, 1);
1346
- }
1347
- return;
1348
- }
1349
-
1350
- if (typeof val !== 'number' && typeof val !== 'string') {
1351
- throw new Error(`Security violation: Style value for "${key}" must be a string or finite number`);
1352
- }
1353
- if (typeof val === 'number' && !Number.isFinite(val)) {
1354
- throw new Error(`Security violation: Style value for "${key}" must be a finite number`);
1355
- }
1356
- if (typeof val === 'string' && val.length > 500) {
1357
- throw new Error(`Security violation: Style value for "${key}" exceeds maximum length`);
1358
- }
1359
-
1360
- const valueNode =
1361
- typeof val === 'number'
1362
- ? val < 0
1363
- ? t.unaryExpression('-', t.numericLiteral(Math.abs(val)))
1364
- : t.numericLiteral(val)
1365
- : t.stringLiteral(val);
1366
-
1367
- if (existingIndex !== -1) {
1368
- const existingProp = objExpr.properties[existingIndex] as t.ObjectProperty;
1369
- existingProp.value = valueNode;
1370
- } else {
1371
- const keyNode = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
1372
- ? t.identifier(key)
1373
- : t.stringLiteral(key);
1374
- objExpr.properties.push(t.objectProperty(keyNode, valueNode));
1375
- }
1376
- }
1377
-
1378
- /**
1379
- * Surgically patches a JSX element's text copy and/or StyleSheet/inline styles in the `.tsx` file.
1380
- */
1381
- export async function patchElement(
1382
- workspaceRoot: string,
1383
- payload: PatchPayload
1384
- ): Promise<{
1385
- element: InspectedElement;
1386
- undoAvailable: number;
1387
- redoAvailable: number;
1388
- }> {
1389
- const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
1390
-
1391
- return withFileLock(fullPath, async () => {
1392
- const previousContent = await fs.readFile(fullPath, 'utf-8');
1393
-
1394
- const ast = parse(previousContent, {
1395
- sourceType: 'module',
1396
- plugins: ['typescript', 'jsx'],
1397
- });
1398
-
1399
- const styleSheetInfo = collectStyleSheetMap(ast);
1400
- const targetIndex = Number.parseInt(payload.astPath, 10);
1401
-
1402
- let matchedElement: t.JSXElement | null = null;
1403
- let currentIndex = 0;
1404
-
1405
- traverse(ast, {
1406
- JSXOpeningElement(nodePath) {
1407
- const tagName = getJsxTagName(nodePath.node.name);
1408
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
1409
- return;
1410
- }
1411
- const idx = currentIndex++;
1412
- const parentJsx = nodePath.parentPath.isJSXElement() ? nodePath.parentPath.node : null;
1413
- if (!parentJsx) return;
1414
-
1415
- if (!Number.isNaN(targetIndex) && idx === targetIndex) {
1416
- matchedElement = parentJsx;
1417
- } else if (!matchedElement && payload.line && nodePath.node.loc?.start.line === payload.line) {
1418
- matchedElement = parentJsx;
1419
- }
1420
- },
1421
- });
1422
-
1423
- if (!matchedElement) {
1424
- throw new Error(`Target JSX element (astPath=${payload.astPath}) not found in ${payload.file}`);
1425
- }
1426
-
1427
- const element = matchedElement as t.JSXElement;
1428
- const opening = element.openingElement;
1429
-
1430
- if (payload.text !== undefined) {
1431
- if (typeof payload.text !== 'string') {
1432
- throw new Error('Invalid text payload: must be a string.');
1433
- }
1434
- const textCheck = inspectJsxTextChildren(element);
1435
- if (textCheck.hasDynamicText) {
1436
- throw new Error(
1437
- 'Cannot overwrite dynamic JSX expression inside <Text>. Use "Ask Antigravity" for structural/expression edits.'
1438
- );
1439
- }
1440
- element.children = [createSafeJsxTextChild(payload.text)];
1441
- }
1442
-
1443
- if (payload.styles && Object.keys(payload.styles).length > 0) {
1444
- const styleInfo = resolveElementStyles(opening, styleSheetInfo);
1445
-
1446
- let targetObject: t.ObjectExpression | null = null;
1447
-
1448
- if (styleInfo.styleSheetKey && styleSheetInfo.styleObjects.has(styleInfo.styleSheetKey)) {
1449
- targetObject = styleSheetInfo.styleObjects.get(styleInfo.styleSheetKey)!;
1450
- } else if (styleInfo.inlineObject) {
1451
- targetObject = styleInfo.inlineObject;
1452
- } else {
1453
- const newObj = t.objectExpression([]);
1454
- const existingStyleAttrIndex = opening.attributes.findIndex(
1455
- (attr) => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name, { name: 'style' })
1456
- );
1457
-
1458
- if (existingStyleAttrIndex !== -1) {
1459
- const existingAttr = opening.attributes[existingStyleAttrIndex] as t.JSXAttribute;
1460
- if (t.isJSXExpressionContainer(existingAttr.value) && t.isExpression(existingAttr.value.expression)) {
1461
- existingAttr.value = t.jsxExpressionContainer(
1462
- t.arrayExpression([existingAttr.value.expression, newObj])
1463
- );
1464
- }
1465
- } else {
1466
- opening.attributes.push(
1467
- t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(newObj))
1468
- );
1469
- }
1470
- targetObject = newObj;
1471
- }
1472
-
1473
- for (const [styleKey, styleVal] of Object.entries(payload.styles)) {
1474
- upsertObjectProperty(targetObject, styleKey, styleVal);
1475
- }
1476
- }
1477
-
1478
- const output = generate(ast, {
1479
- retainLines: false,
1480
- comments: true,
1481
- jsescOption: { minimal: true },
1482
- });
1483
-
1484
- const nextContent = output.code + '\n';
1485
-
1486
- if (nextContent !== previousContent) {
1487
- await fs.writeFile(fullPath, nextContent, 'utf-8');
1488
- undoStack.push({
1489
- file: payload.file,
1490
- previousContent,
1491
- nextContent,
1492
- description: `Updated ${getJsxTagName(opening.name)} in ${path.basename(payload.file)}`,
1493
- timestamp: Date.now(),
1494
- });
1495
- if (undoStack.length > MAX_HISTORY) {
1496
- undoStack.shift();
1497
- }
1498
- redoStack.length = 0;
1499
- }
1500
-
1501
- const updatedElement = await inspectElement(workspaceRoot, {
1502
- file: payload.file,
1503
- astPath: payload.astPath,
1504
- });
1505
-
1506
- return {
1507
- element: updatedElement,
1508
- undoAvailable: undoStack.length,
1509
- redoAvailable: redoStack.length,
1510
- };
1511
- });
1512
- }
1513
-
1514
- /**
1515
- * Surgically reorders a JSXElement among its Flexbox siblings in the `.tsx` AST.
1516
- * Supports either explicit target sibling (`targetAstPath` + `placement: 'before' | 'after'`)
1517
- * or relative movement (`direction: 'up' | 'down'`).
1518
- */
1519
- export async function reorderElement(
1520
- workspaceRoot: string,
1521
- payload: ReorderPayload
1522
- ): Promise<{
1523
- element: InspectedElement;
1524
- undoAvailable: number;
1525
- redoAvailable: number;
1526
- }> {
1527
- const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
1528
-
1529
- return withFileLock(fullPath, async () => {
1530
- const previousContent = await fs.readFile(fullPath, 'utf-8');
1531
- const ast = parse(previousContent, {
1532
- sourceType: 'module',
1533
- plugins: ['typescript', 'jsx'],
1534
- });
1535
-
1536
- interface IndexedElementInfo {
1537
- index: number;
1538
- element: t.JSXElement;
1539
- parent: t.JSXElement | t.JSXFragment | null;
1540
- }
1541
-
1542
- const byIndex = new Map<number, IndexedElementInfo>();
1543
- const parentByElement = new Map<t.JSXElement, t.JSXElement | t.JSXFragment | null>();
1544
- let currentIndex = 0;
1545
-
1546
- traverse(ast, {
1547
- JSXOpeningElement(nodePath) {
1548
- const tagName = getJsxTagName(nodePath.node.name);
1549
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
1550
- return;
1551
- }
1552
- const idx = currentIndex++;
1553
- const jsxElPath = nodePath.parentPath;
1554
- if (!jsxElPath.isJSXElement()) return;
1555
-
1556
- const element = jsxElPath.node;
1557
- const parentNode = jsxElPath.parentPath?.node;
1558
- const parent =
1559
- t.isJSXElement(parentNode) || t.isJSXFragment(parentNode) ? parentNode : null;
1560
-
1561
- byIndex.set(idx, { index: idx, element, parent });
1562
- parentByElement.set(element, parent);
1563
- },
1564
- });
1565
-
1566
- const sourceIdx = Number.parseInt(payload.astPath, 10);
1567
- const sourceInfo = byIndex.get(sourceIdx);
1568
- if (!sourceInfo || !sourceInfo.parent) {
1569
- throw new Error(`Element at astPath=${payload.astPath} cannot be reordered (no JSX parent container).`);
1570
- }
1571
-
1572
- const parentContainer = sourceInfo.parent;
1573
- const sourceElement = sourceInfo.element;
1574
-
1575
- const jsxSiblings = parentContainer.children.filter((c): c is t.JSXElement =>
1576
- t.isJSXElement(c)
1577
- );
1578
- if (jsxSiblings.length < 2) {
1579
- throw new Error('Element has no sibling elements inside its parent container to reorder with.');
1580
- }
1581
-
1582
- let targetSibling: t.JSXElement | null = null;
1583
- let placement: 'before' | 'after' = payload.placement === 'after' ? 'after' : 'before';
1584
-
1585
- if (payload.direction === 'up' || payload.direction === 'down') {
1586
- const currPos = jsxSiblings.indexOf(sourceElement);
1587
- if (currPos === -1) {
1588
- throw new Error('Could not locate element inside parent children list.');
1589
- }
1590
- if (payload.direction === 'up') {
1591
- if (currPos === 0) {
1592
- throw new Error('Element is already first in its container.');
1593
- }
1594
- targetSibling = jsxSiblings[currPos - 1];
1595
- placement = 'before';
1596
- } else {
1597
- if (currPos >= jsxSiblings.length - 1) {
1598
- throw new Error('Element is already last in its container.');
1599
- }
1600
- targetSibling = jsxSiblings[currPos + 1];
1601
- placement = 'after';
1602
- }
1603
- } else if (payload.targetAstPath !== undefined) {
1604
- const targetIdx = Number.parseInt(payload.targetAstPath, 10);
1605
- const rawTargetInfo = byIndex.get(targetIdx);
1606
- if (!rawTargetInfo) {
1607
- throw new Error(`Target sibling at astPath=${payload.targetAstPath} not found.`);
1608
- }
1609
-
1610
- // Walk up from rawTargetInfo.element until we find the direct child of parentContainer
1611
- let candidate: t.JSXElement | null = rawTargetInfo.element;
1612
- while (candidate && parentByElement.get(candidate) !== parentContainer) {
1613
- const p = parentByElement.get(candidate);
1614
- candidate = p && t.isJSXElement(p) ? p : null;
1615
- }
1616
- if (!candidate || candidate === sourceElement) {
1617
- throw new Error('Target drop element is not a sibling inside the same Flexbox container.');
1618
- }
1619
- targetSibling = candidate;
1620
- }
1621
-
1622
- if (!targetSibling || targetSibling === sourceElement) {
1623
- throw new Error('Invalid reorder target sibling.');
1624
- }
1625
-
1626
- // Rebuild parentContainer.children preserving non-whitespace nodes in the new order
1627
- const meaningfulChildren = parentContainer.children.filter(
1628
- (child) => !(t.isJSXText(child) && child.value.trim() === '')
1629
- );
1630
-
1631
- const withoutSource = meaningfulChildren.filter((child) => child !== sourceElement);
1632
- const targetPos = withoutSource.indexOf(targetSibling);
1633
- if (targetPos === -1) {
1634
- throw new Error('Target sibling not found in parent children.');
1635
- }
1636
-
1637
- const insertIdx = placement === 'after' ? targetPos + 1 : targetPos;
1638
- withoutSource.splice(insertIdx, 0, sourceElement);
1639
- parentContainer.children = withoutSource;
1640
-
1641
- // Re-index the mutated AST to find the updated pre-order astPath of sourceElement
1642
- let newSourceAstPath = payload.astPath;
1643
- let reindexCounter = 0;
1644
- traverse(ast, {
1645
- JSXOpeningElement(nodePath) {
1646
- const tagName = getJsxTagName(nodePath.node.name);
1647
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
1648
- return;
1649
- }
1650
- const idx = reindexCounter++;
1651
- if (nodePath.parentPath.isJSXElement() && nodePath.parentPath.node === sourceElement) {
1652
- newSourceAstPath = String(idx);
1653
- }
1654
- },
1655
- });
1656
-
1657
- const output = generate(ast, {
1658
- retainLines: false,
1659
- comments: true,
1660
- jsescOption: { minimal: true },
1661
- });
1662
-
1663
- const nextContent = output.code + '\n';
1664
- if (nextContent !== previousContent) {
1665
- await fs.writeFile(fullPath, nextContent, 'utf-8');
1666
- undoStack.push({
1667
- file: payload.file,
1668
- previousContent,
1669
- nextContent,
1670
- description: `Reordered ${getJsxTagName(sourceElement.openingElement.name)} in ${path.basename(payload.file)}`,
1671
- timestamp: Date.now(),
1672
- });
1673
- if (undoStack.length > MAX_HISTORY) {
1674
- undoStack.shift();
1675
- }
1676
- redoStack.length = 0;
1677
- }
1678
-
1679
- const updatedElement = await inspectElement(workspaceRoot, {
1680
- file: payload.file,
1681
- astPath: newSourceAstPath,
1682
- });
1683
-
1684
- return {
1685
- element: updatedElement,
1686
- undoAvailable: undoStack.length,
1687
- redoAvailable: redoStack.length,
1688
- };
1689
- });
1690
- }
1691
-
1692
- const CONTAINER_TAG_NAMES = new Set([
1693
- 'View',
1694
- 'ScrollView',
1695
- 'SafeAreaView',
1696
- 'KeyboardAvoidingView',
1697
- 'Pressable',
1698
- 'TouchableOpacity',
1699
- 'ImageBackground',
1700
- ]);
1701
-
1702
- function buildStyleObjectExpression(styles: Record<string, string | number>): t.ObjectExpression {
1703
- return t.objectExpression(
1704
- Object.entries(styles).map(([k, v]) =>
1705
- t.objectProperty(
1706
- t.identifier(k),
1707
- typeof v === 'number' ? t.numericLiteral(v) : t.stringLiteral(v)
1708
- )
1709
- )
1710
- );
1711
- }
1712
-
1713
- function buildInlineStyleAttr(styles: Record<string, string | number>): t.JSXAttribute {
1714
- return t.jsxAttribute(
1715
- t.jsxIdentifier('style'),
1716
- t.jsxExpressionContainer(buildStyleObjectExpression(styles))
1717
- );
1718
- }
1719
-
1720
- function ensureReactNativeImports(ast: t.File, requiredComponents: string[]): void {
1721
- const needed = new Set(requiredComponents);
1722
- if (needed.size === 0) return;
1723
-
1724
- let rnImportDecl: t.ImportDeclaration | null = null;
1725
- for (const stmt of ast.program.body) {
1726
- if (
1727
- t.isImportDeclaration(stmt) &&
1728
- stmt.source.value === 'react-native' &&
1729
- stmt.importKind !== 'type'
1730
- ) {
1731
- rnImportDecl = stmt;
1732
- break;
1733
- }
1734
- }
1735
-
1736
- if (rnImportDecl) {
1737
- const existing = new Set<string>();
1738
- for (const spec of rnImportDecl.specifiers) {
1739
- if (t.isImportSpecifier(spec)) {
1740
- const importedName = t.isIdentifier(spec.imported)
1741
- ? spec.imported.name
1742
- : spec.imported.value;
1743
- existing.add(importedName);
1744
- existing.add(spec.local.name);
1745
- }
1746
- }
1747
- for (const comp of needed) {
1748
- if (!existing.has(comp)) {
1749
- rnImportDecl.specifiers.push(
1750
- t.importSpecifier(t.identifier(comp), t.identifier(comp))
1751
- );
1752
- }
1753
- }
1754
- } else {
1755
- const specifiers = Array.from(needed).map((comp) =>
1756
- t.importSpecifier(t.identifier(comp), t.identifier(comp))
1757
- );
1758
- ast.program.body.unshift(
1759
- t.importDeclaration(specifiers, t.stringLiteral('react-native'))
1760
- );
1761
- }
1762
- }
1763
-
1764
- function buildPresetJsxElement(
1765
- preset: InsertElementPreset,
1766
- options: {
1767
- label?: string;
1768
- navigateTo?: string;
1769
- hasNavigationInScope: boolean;
1770
- hasRouterInScope: boolean;
1771
- }
1772
- ): { element: t.JSXElement; requiredImports: string[] } {
1773
- const cleanLabel = (options.label ?? '').trim();
1774
- const cleanNav = (options.navigateTo ?? '').trim();
1775
-
1776
- switch (preset) {
1777
- case 'button': {
1778
- const btnText = cleanLabel || (cleanNav ? `Go to ${cleanNav}` : 'Continue');
1779
- const attrs: t.JSXAttribute[] = [
1780
- t.jsxAttribute(
1781
- t.jsxIdentifier('activeOpacity'),
1782
- t.jsxExpressionContainer(t.numericLiteral(0.8))
1783
- ),
1784
- buildInlineStyleAttr({
1785
- backgroundColor: '#0F3460',
1786
- paddingVertical: 14,
1787
- paddingHorizontal: 20,
1788
- borderRadius: 12,
1789
- alignItems: 'center',
1790
- justifyContent: 'center',
1791
- marginTop: 12,
1792
- }),
1793
- ];
1794
-
1795
- if (cleanNav) {
1796
- if (options.hasNavigationInScope) {
1797
- attrs.push(
1798
- t.jsxAttribute(
1799
- t.jsxIdentifier('onPress'),
1800
- t.jsxExpressionContainer(
1801
- t.arrowFunctionExpression(
1802
- [],
1803
- t.callExpression(
1804
- t.memberExpression(t.identifier('navigation'), t.identifier('navigate')),
1805
- [t.stringLiteral(cleanNav)]
1806
- )
1807
- )
1808
- )
1809
- )
1810
- );
1811
- } else if (options.hasRouterInScope) {
1812
- attrs.push(
1813
- t.jsxAttribute(
1814
- t.jsxIdentifier('onPress'),
1815
- t.jsxExpressionContainer(
1816
- t.arrowFunctionExpression(
1817
- [],
1818
- t.callExpression(
1819
- t.memberExpression(t.identifier('router'), t.identifier('push')),
1820
- [t.stringLiteral(`/${cleanNav.toLowerCase()}`)]
1821
- )
1822
- )
1823
- )
1824
- )
1825
- );
1826
- } else {
1827
- attrs.push(t.jsxAttribute(t.jsxIdentifier('route'), t.stringLiteral(cleanNav)));
1828
- }
1829
- }
1830
-
1831
- const textChild = t.jsxElement(
1832
- t.jsxOpeningElement(
1833
- t.jsxIdentifier('Text'),
1834
- [
1835
- buildInlineStyleAttr({
1836
- color: '#FFFFFF',
1837
- fontSize: 14,
1838
- fontWeight: '700',
1839
- }),
1840
- ],
1841
- false
1842
- ),
1843
- t.jsxClosingElement(t.jsxIdentifier('Text')),
1844
- [t.jsxText(btnText)],
1845
- false
1846
- );
1847
-
1848
- return {
1849
- element: t.jsxElement(
1850
- t.jsxOpeningElement(t.jsxIdentifier('TouchableOpacity'), attrs, false),
1851
- t.jsxClosingElement(t.jsxIdentifier('TouchableOpacity')),
1852
- [textChild],
1853
- false
1854
- ),
1855
- requiredImports: ['TouchableOpacity', 'Text'],
1856
- };
1857
- }
1858
-
1859
- case 'section': {
1860
- const sectionTitle = cleanLabel || 'Featured Section';
1861
- const titleNode = t.jsxElement(
1862
- t.jsxOpeningElement(
1863
- t.jsxIdentifier('Text'),
1864
- [
1865
- buildInlineStyleAttr({
1866
- fontSize: 16,
1867
- fontWeight: '700',
1868
- color: '#0F172A',
1869
- marginBottom: 4,
1870
- }),
1871
- ],
1872
- false
1873
- ),
1874
- t.jsxClosingElement(t.jsxIdentifier('Text')),
1875
- [t.jsxText(sectionTitle)],
1876
- false
1877
- );
1878
-
1879
- const subtitleNode = t.jsxElement(
1880
- t.jsxOpeningElement(
1881
- t.jsxIdentifier('Text'),
1882
- [
1883
- buildInlineStyleAttr({
1884
- fontSize: 13,
1885
- lineHeight: 19,
1886
- color: '#64748B',
1887
- }),
1888
- ],
1889
- false
1890
- ),
1891
- t.jsxClosingElement(t.jsxIdentifier('Text')),
1892
- [t.jsxText('Add details, metrics, or actions inside this section card.')],
1893
- false
1894
- );
1895
-
1896
- return {
1897
- element: t.jsxElement(
1898
- t.jsxOpeningElement(
1899
- t.jsxIdentifier('View'),
1900
- [
1901
- buildInlineStyleAttr({
1902
- backgroundColor: '#F8FAFC',
1903
- borderRadius: 16,
1904
- padding: 16,
1905
- marginTop: 12,
1906
- borderWidth: 1,
1907
- borderColor: '#E2E8F0',
1908
- }),
1909
- ],
1910
- false
1911
- ),
1912
- t.jsxClosingElement(t.jsxIdentifier('View')),
1913
- [titleNode, subtitleNode],
1914
- false
1915
- ),
1916
- requiredImports: ['View', 'Text'],
1917
- };
1918
- }
1919
-
1920
- case 'row': {
1921
- const leftLabel = cleanLabel || 'Row Item Title';
1922
- const leftText = t.jsxElement(
1923
- t.jsxOpeningElement(
1924
- t.jsxIdentifier('Text'),
1925
- [
1926
- buildInlineStyleAttr({
1927
- fontSize: 14,
1928
- fontWeight: '600',
1929
- color: '#0F172A',
1930
- }),
1931
- ],
1932
- false
1933
- ),
1934
- t.jsxClosingElement(t.jsxIdentifier('Text')),
1935
- [t.jsxText(leftLabel)],
1936
- false
1937
- );
1938
-
1939
- const rightText = t.jsxElement(
1940
- t.jsxOpeningElement(
1941
- t.jsxIdentifier('Text'),
1942
- [
1943
- buildInlineStyleAttr({
1944
- fontSize: 13,
1945
- fontWeight: '600',
1946
- color: '#6366F1',
1947
- }),
1948
- ],
1949
- false
1950
- ),
1951
- t.jsxClosingElement(t.jsxIdentifier('Text')),
1952
- [t.jsxText('Details →')],
1953
- false
1954
- );
1955
-
1956
- return {
1957
- element: t.jsxElement(
1958
- t.jsxOpeningElement(
1959
- t.jsxIdentifier('View'),
1960
- [
1961
- buildInlineStyleAttr({
1962
- flexDirection: 'row',
1963
- alignItems: 'center',
1964
- justifyContent: 'space-between',
1965
- backgroundColor: '#F8FAFC',
1966
- paddingVertical: 12,
1967
- paddingHorizontal: 14,
1968
- borderRadius: 12,
1969
- marginTop: 8,
1970
- borderWidth: 1,
1971
- borderColor: '#E2E8F0',
1972
- }),
1973
- ],
1974
- false
1975
- ),
1976
- t.jsxClosingElement(t.jsxIdentifier('View')),
1977
- [leftText, rightText],
1978
- false
1979
- ),
1980
- requiredImports: ['View', 'Text'],
1981
- };
1982
- }
1983
-
1984
- case 'heading': {
1985
- const headingText = cleanLabel || 'New Heading';
1986
- return {
1987
- element: t.jsxElement(
1988
- t.jsxOpeningElement(
1989
- t.jsxIdentifier('Text'),
1990
- [
1991
- buildInlineStyleAttr({
1992
- fontSize: 20,
1993
- fontWeight: '700',
1994
- color: '#0F172A',
1995
- marginTop: 12,
1996
- marginBottom: 4,
1997
- }),
1998
- ],
1999
- false
2000
- ),
2001
- t.jsxClosingElement(t.jsxIdentifier('Text')),
2002
- [t.jsxText(headingText)],
2003
- false
2004
- ),
2005
- requiredImports: ['Text'],
2006
- };
2007
- }
2008
-
2009
- case 'text': {
2010
- const bodyCopy = cleanLabel || 'Add your supporting paragraph description here.';
2011
- return {
2012
- element: t.jsxElement(
2013
- t.jsxOpeningElement(
2014
- t.jsxIdentifier('Text'),
2015
- [
2016
- buildInlineStyleAttr({
2017
- fontSize: 14,
2018
- lineHeight: 20,
2019
- color: '#475569',
2020
- marginTop: 6,
2021
- }),
2022
- ],
2023
- false
2024
- ),
2025
- t.jsxClosingElement(t.jsxIdentifier('Text')),
2026
- [t.jsxText(bodyCopy)],
2027
- false
2028
- ),
2029
- requiredImports: ['Text'],
2030
- };
2031
- }
2032
-
2033
- case 'input': {
2034
- const placeholderText = cleanLabel || 'Enter value...';
2035
- return {
2036
- element: t.jsxElement(
2037
- t.jsxOpeningElement(
2038
- t.jsxIdentifier('TextInput'),
2039
- [
2040
- t.jsxAttribute(t.jsxIdentifier('placeholder'), t.stringLiteral(placeholderText)),
2041
- t.jsxAttribute(t.jsxIdentifier('placeholderTextColor'), t.stringLiteral('#94A3B8')),
2042
- buildInlineStyleAttr({
2043
- backgroundColor: '#F1F5F9',
2044
- borderWidth: 1,
2045
- borderColor: '#CBD5E1',
2046
- borderRadius: 10,
2047
- paddingHorizontal: 14,
2048
- paddingVertical: 12,
2049
- fontSize: 14,
2050
- color: '#0F172A',
2051
- marginTop: 10,
2052
- }),
2053
- ],
2054
- true
2055
- ),
2056
- null,
2057
- [],
2058
- true
2059
- ),
2060
- requiredImports: ['TextInput'],
2061
- };
2062
- }
2063
-
2064
- case 'badge': {
2065
- const badgeLabel = cleanLabel || 'NEW BADGE';
2066
- const badgeText = t.jsxElement(
2067
- t.jsxOpeningElement(
2068
- t.jsxIdentifier('Text'),
2069
- [
2070
- buildInlineStyleAttr({
2071
- fontSize: 11,
2072
- fontWeight: '700',
2073
- color: '#4F46E5',
2074
- letterSpacing: 0.4,
2075
- }),
2076
- ],
2077
- false
2078
- ),
2079
- t.jsxClosingElement(t.jsxIdentifier('Text')),
2080
- [t.jsxText(badgeLabel)],
2081
- false
2082
- );
2083
-
2084
- return {
2085
- element: t.jsxElement(
2086
- t.jsxOpeningElement(
2087
- t.jsxIdentifier('View'),
2088
- [
2089
- buildInlineStyleAttr({
2090
- alignSelf: 'flex-start',
2091
- backgroundColor: '#EEF2FF',
2092
- paddingHorizontal: 10,
2093
- paddingVertical: 4,
2094
- borderRadius: 999,
2095
- marginTop: 8,
2096
- }),
2097
- ],
2098
- false
2099
- ),
2100
- t.jsxClosingElement(t.jsxIdentifier('View')),
2101
- [badgeText],
2102
- false
2103
- ),
2104
- requiredImports: ['View', 'Text'],
2105
- };
2106
- }
2107
-
2108
- case 'divider':
2109
- default: {
2110
- return {
2111
- element: t.jsxElement(
2112
- t.jsxOpeningElement(
2113
- t.jsxIdentifier('View'),
2114
- [
2115
- buildInlineStyleAttr({
2116
- height: 1,
2117
- backgroundColor: '#E2E8F0',
2118
- marginVertical: 14,
2119
- width: '100%',
2120
- }),
2121
- ],
2122
- true
2123
- ),
2124
- null,
2125
- [],
2126
- true
2127
- ),
2128
- requiredImports: ['View'],
2129
- };
2130
- }
2131
- }
2132
- }
2133
-
2134
- /**
2135
- * Surgically inserts a new React Native component/section preset into a screen's `.tsx` AST.
2136
- * Automatically injects any missing `react-native` imports (`TouchableOpacity`, `TextInput`, `View`, `Text`).
2137
- */
2138
- export async function insertElement(
2139
- workspaceRoot: string,
2140
- payload: InsertElementPayload
2141
- ): Promise<{
2142
- element: InspectedElement;
2143
- undoAvailable: number;
2144
- redoAvailable: number;
2145
- }> {
2146
- const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
2147
-
2148
- return withFileLock(fullPath, async () => {
2149
- const previousContent = await fs.readFile(fullPath, 'utf-8');
2150
- const ast = parse(previousContent, {
2151
- sourceType: 'module',
2152
- plugins: ['typescript', 'jsx'],
2153
- });
2154
-
2155
- interface IndexedNodeInfo {
2156
- index: number;
2157
- tagName: string;
2158
- element: t.JSXElement;
2159
- parent: t.JSXElement | t.JSXFragment | null;
2160
- }
2161
-
2162
- const byIndex = new Map<number, IndexedNodeInfo>();
2163
- let currentIndex = 0;
2164
- let hasNavigationInScope = false;
2165
- let hasRouterInScope = false;
2166
-
2167
- traverse(ast, {
2168
- Identifier(nodePath) {
2169
- if (nodePath.node.name === 'navigation') hasNavigationInScope = true;
2170
- if (nodePath.node.name === 'router') hasRouterInScope = true;
2171
- },
2172
- JSXOpeningElement(nodePath) {
2173
- const tagName = getJsxTagName(nodePath.node.name);
2174
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
2175
- return;
2176
- }
2177
- const idx = currentIndex++;
2178
- const jsxElPath = nodePath.parentPath;
2179
- if (!jsxElPath.isJSXElement()) return;
2180
-
2181
- const element = jsxElPath.node;
2182
- const parentNode = jsxElPath.parentPath?.node;
2183
- const parent =
2184
- t.isJSXElement(parentNode) || t.isJSXFragment(parentNode) ? parentNode : null;
2185
-
2186
- byIndex.set(idx, { index: idx, tagName, element, parent });
2187
- },
2188
- });
2189
-
2190
- if (byIndex.size === 0) {
2191
- throw new Error(`No JSX elements found in ${payload.file} to insert into.`);
2192
- }
2193
-
2194
- const { element: newElement, requiredImports } = buildPresetJsxElement(payload.preset, {
2195
- label: payload.label,
2196
- navigateTo: payload.navigateTo,
2197
- hasNavigationInScope,
2198
- hasRouterInScope,
2199
- });
2200
-
2201
- ensureReactNativeImports(ast, requiredImports);
2202
-
2203
- const ensureOpenContainer = (container: t.JSXElement) => {
2204
- if (container.openingElement.selfClosing) {
2205
- container.openingElement.selfClosing = false;
2206
- container.selfClosing = false;
2207
- container.closingElement = t.jsxClosingElement(
2208
- t.cloneNode(container.openingElement.name, true)
2209
- );
2210
- }
2211
- };
2212
-
2213
- const targetIdx =
2214
- payload.astPath !== undefined && payload.astPath !== ''
2215
- ? Number.parseInt(payload.astPath, 10)
2216
- : -1;
2217
- const targetInfo = targetIdx >= 0 ? byIndex.get(targetIdx) : undefined;
2218
- const requestedPlacement = payload.placement ?? 'after';
2219
-
2220
- if (targetInfo) {
2221
- const baseTag = targetInfo.tagName.split('.').pop() ?? targetInfo.tagName;
2222
- const isContainer = CONTAINER_TAG_NAMES.has(baseTag);
2223
-
2224
- if (
2225
- (requestedPlacement === 'inside' && isContainer) ||
2226
- !targetInfo.parent
2227
- ) {
2228
- ensureOpenContainer(targetInfo.element);
2229
- targetInfo.element.children.push(newElement);
2230
- } else {
2231
- const parentContainer = targetInfo.parent;
2232
- const pos = parentContainer.children.indexOf(targetInfo.element);
2233
- if (pos === -1) {
2234
- parentContainer.children.push(newElement);
2235
- } else {
2236
- const insertAt = requestedPlacement === 'before' ? pos : pos + 1;
2237
- parentContainer.children.splice(insertAt, 0, newElement);
2238
- }
2239
- }
2240
- } else {
2241
- // No specific element selected: append into the screen's primary ScrollView or root container
2242
- let defaultContainer = byIndex.get(0)!.element;
2243
- for (const info of byIndex.values()) {
2244
- const base = info.tagName.split('.').pop() ?? info.tagName;
2245
- if (base === 'ScrollView') {
2246
- defaultContainer = info.element;
2247
- break;
2248
- }
2249
- }
2250
- ensureOpenContainer(defaultContainer);
2251
- defaultContainer.children.push(newElement);
2252
- }
2253
-
2254
- // Re-index the mutated AST to locate the pre-order astPath of newElement
2255
- let insertedAstPath = '0';
2256
- let reindexCounter = 0;
2257
- traverse(ast, {
2258
- JSXOpeningElement(nodePath) {
2259
- const tagName = getJsxTagName(nodePath.node.name);
2260
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
2261
- return;
2262
- }
2263
- const idx = reindexCounter++;
2264
- if (nodePath.parentPath.isJSXElement() && nodePath.parentPath.node === newElement) {
2265
- insertedAstPath = String(idx);
2266
- }
2267
- },
2268
- });
2269
-
2270
- const output = generate(ast, {
2271
- retainLines: false,
2272
- comments: true,
2273
- jsescOption: { minimal: true },
2274
- });
2275
-
2276
- const nextContent = output.code + '\n';
2277
- await fs.writeFile(fullPath, nextContent, 'utf-8');
2278
-
2279
- undoStack.push({
2280
- file: payload.file,
2281
- previousContent,
2282
- nextContent,
2283
- description: `Inserted ${payload.preset} in ${path.basename(payload.file)}`,
2284
- timestamp: Date.now(),
2285
- });
2286
- if (undoStack.length > MAX_HISTORY) {
2287
- undoStack.shift();
2288
- }
2289
- redoStack.length = 0;
2290
-
2291
- const inspected = await inspectElement(workspaceRoot, {
2292
- file: payload.file,
2293
- astPath: insertedAstPath,
2294
- });
2295
-
2296
- return {
2297
- element: inspected,
2298
- undoAvailable: undoStack.length,
2299
- redoAvailable: redoStack.length,
2300
- };
2301
- });
2302
- }
2303
-
2304
- /**
2305
- * Duplicates a JSXElement immediately after itself inside its parent container.
2306
- */
2307
- export async function duplicateElement(
2308
- workspaceRoot: string,
2309
- payload: DuplicateElementPayload
2310
- ): Promise<{
2311
- element: InspectedElement;
2312
- undoAvailable: number;
2313
- redoAvailable: number;
2314
- }> {
2315
- const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
2316
-
2317
- return withFileLock(fullPath, async () => {
2318
- const previousContent = await fs.readFile(fullPath, 'utf-8');
2319
- const ast = parse(previousContent, {
2320
- sourceType: 'module',
2321
- plugins: ['typescript', 'jsx'],
2322
- });
2323
-
2324
- let targetElement: t.JSXElement | null = null;
2325
- let parentContainer: t.JSXElement | t.JSXFragment | null = null;
2326
- let currentIndex = 0;
2327
- const requestedIdx = Number.parseInt(payload.astPath, 10);
2328
-
2329
- traverse(ast, {
2330
- JSXOpeningElement(nodePath) {
2331
- const tagName = getJsxTagName(nodePath.node.name);
2332
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
2333
- return;
2334
- }
2335
- const idx = currentIndex++;
2336
- if (idx === requestedIdx && nodePath.parentPath.isJSXElement()) {
2337
- targetElement = nodePath.parentPath.node;
2338
- const p = nodePath.parentPath.parentPath?.node;
2339
- parentContainer = t.isJSXElement(p) || t.isJSXFragment(p) ? p : null;
2340
- }
2341
- },
2342
- });
2343
-
2344
- if (!targetElement || !parentContainer) {
2345
- throw new Error('Cannot duplicate the root screen container.');
2346
- }
2347
-
2348
- const cloned = t.cloneNode(targetElement, true);
2349
- const siblings = (parentContainer as t.JSXElement).children;
2350
- const pos = siblings.indexOf(targetElement);
2351
- siblings.splice(pos + 1, 0, cloned);
2352
-
2353
- let clonedAstPath = payload.astPath;
2354
- let reindexCounter = 0;
2355
- traverse(ast, {
2356
- JSXOpeningElement(nodePath) {
2357
- const tagName = getJsxTagName(nodePath.node.name);
2358
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
2359
- return;
2360
- }
2361
- const idx = reindexCounter++;
2362
- if (nodePath.parentPath.isJSXElement() && nodePath.parentPath.node === cloned) {
2363
- clonedAstPath = String(idx);
2364
- }
2365
- },
2366
- });
2367
-
2368
- const output = generate(ast, {
2369
- retainLines: false,
2370
- comments: true,
2371
- jsescOption: { minimal: true },
2372
- });
2373
-
2374
- const nextContent = output.code + '\n';
2375
- await fs.writeFile(fullPath, nextContent, 'utf-8');
2376
-
2377
- undoStack.push({
2378
- file: payload.file,
2379
- previousContent,
2380
- nextContent,
2381
- description: `Duplicated <${getJsxTagName((targetElement as t.JSXElement).openingElement.name)}> in ${path.basename(payload.file)}`,
2382
- timestamp: Date.now(),
2383
- });
2384
- if (undoStack.length > MAX_HISTORY) {
2385
- undoStack.shift();
2386
- }
2387
- redoStack.length = 0;
2388
-
2389
- const inspected = await inspectElement(workspaceRoot, {
2390
- file: payload.file,
2391
- astPath: clonedAstPath,
2392
- });
2393
-
2394
- return {
2395
- element: inspected,
2396
- undoAvailable: undoStack.length,
2397
- redoAvailable: redoStack.length,
2398
- };
2399
- });
2400
- }
2401
-
2402
- /**
2403
- * Surgically deletes a non-root JSXElement from a screen's `.tsx` AST.
2404
- */
2405
- export async function deleteElement(
2406
- workspaceRoot: string,
2407
- payload: DeleteElementPayload
2408
- ): Promise<{
2409
- deletedAstPath: string;
2410
- undoAvailable: number;
2411
- redoAvailable: number;
2412
- }> {
2413
- const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
2414
-
2415
- return withFileLock(fullPath, async () => {
2416
- const previousContent = await fs.readFile(fullPath, 'utf-8');
2417
- const ast = parse(previousContent, {
2418
- sourceType: 'module',
2419
- plugins: ['typescript', 'jsx'],
2420
- });
2421
-
2422
- let targetElement: t.JSXElement | null = null;
2423
- let parentContainer: t.JSXElement | t.JSXFragment | null = null;
2424
- let currentIndex = 0;
2425
- const requestedIdx = Number.parseInt(payload.astPath, 10);
2426
-
2427
- if (requestedIdx === 0) {
2428
- throw new Error('Cannot delete the root screen container element.');
2429
- }
2430
-
2431
- traverse(ast, {
2432
- JSXOpeningElement(nodePath) {
2433
- const tagName = getJsxTagName(nodePath.node.name);
2434
- if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
2435
- return;
2436
- }
2437
- const idx = currentIndex++;
2438
- if (idx === requestedIdx && nodePath.parentPath.isJSXElement()) {
2439
- targetElement = nodePath.parentPath.node;
2440
- const p = nodePath.parentPath.parentPath?.node;
2441
- parentContainer = t.isJSXElement(p) || t.isJSXFragment(p) ? p : null;
2442
- }
2443
- },
2444
- });
2445
-
2446
- if (!targetElement || !parentContainer) {
2447
- throw new Error(`Element at astPath=${payload.astPath} cannot be deleted.`);
2448
- }
2449
-
2450
- const siblings = (parentContainer as t.JSXElement).children;
2451
- const pos = siblings.indexOf(targetElement);
2452
- if (pos === -1) {
2453
- throw new Error('Could not find element inside parent children.');
2454
- }
2455
- siblings.splice(pos, 1);
2456
-
2457
- const output = generate(ast, {
2458
- retainLines: false,
2459
- comments: true,
2460
- jsescOption: { minimal: true },
2461
- });
2462
-
2463
- const nextContent = output.code + '\n';
2464
- await fs.writeFile(fullPath, nextContent, 'utf-8');
2465
-
2466
- undoStack.push({
2467
- file: payload.file,
2468
- previousContent,
2469
- nextContent,
2470
- description: `Deleted <${getJsxTagName((targetElement as t.JSXElement).openingElement.name)}> in ${path.basename(payload.file)}`,
2471
- timestamp: Date.now(),
2472
- });
2473
- if (undoStack.length > MAX_HISTORY) {
2474
- undoStack.shift();
2475
- }
2476
- redoStack.length = 0;
2477
-
2478
- return {
2479
- deletedAstPath: payload.astPath,
2480
- undoAvailable: undoStack.length,
2481
- redoAvailable: redoStack.length,
2482
- };
2483
- });
2484
- }
2485
-
2486
- export async function undoLastPatch(workspaceRoot: string): Promise<{
2487
- restoredFile: string | null;
2488
- description: string | null;
2489
- undoAvailable: number;
2490
- redoAvailable: number;
2491
- }> {
2492
- const entry = undoStack.pop();
2493
- if (!entry) {
2494
- return {
2495
- restoredFile: null,
2496
- description: null,
2497
- undoAvailable: 0,
2498
- redoAvailable: redoStack.length,
2499
- };
2500
- }
2501
-
2502
- const fullPath = resolveSafeScreenPath(workspaceRoot, entry.file);
2503
- await withFileLock(fullPath, async () => {
2504
- await fs.writeFile(fullPath, entry.previousContent, 'utf-8');
2505
- });
2506
-
2507
- redoStack.push(entry);
2508
-
2509
- return {
2510
- restoredFile: entry.file,
2511
- description: entry.description,
2512
- undoAvailable: undoStack.length,
2513
- redoAvailable: redoStack.length,
2514
- };
2515
- }
2516
-
2517
- export async function redoLastPatch(workspaceRoot: string): Promise<{
2518
- restoredFile: string | null;
2519
- description: string | null;
2520
- undoAvailable: number;
2521
- redoAvailable: number;
2522
- }> {
2523
- const entry = redoStack.pop();
2524
- if (!entry) {
2525
- return {
2526
- restoredFile: null,
2527
- description: null,
2528
- undoAvailable: undoStack.length,
2529
- redoAvailable: 0,
2530
- };
2531
- }
2532
-
2533
- const fullPath = resolveSafeScreenPath(workspaceRoot, entry.file);
2534
- await withFileLock(fullPath, async () => {
2535
- await fs.writeFile(fullPath, entry.nextContent, 'utf-8');
2536
- });
2537
-
2538
- undoStack.push(entry);
2539
-
2540
- return {
2541
- restoredFile: entry.file,
2542
- description: entry.description,
2543
- undoAvailable: undoStack.length,
2544
- redoAvailable: redoStack.length,
2545
- };
2546
- }
2547
-
2548
- export function getHistoryStatus(): { undoAvailable: number; redoAvailable: number } {
2549
- return {
2550
- undoAvailable: undoStack.length,
2551
- redoAvailable: redoStack.length,
2552
- };
2553
- }