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.
- package/CHANGELOG.md +16 -0
- package/bin/cli.mjs +13 -54
- package/dist/assets/CheckoutScreen-6IdrpKgm.js +229 -0
- package/dist/assets/HomeScreen-Dxe9L4I2.js +316 -0
- package/dist/assets/LoginScreen-Dp1awI4F.js +189 -0
- package/dist/assets/ProductDetailScreen-Wa5_nlRP.js +196 -0
- package/dist/assets/ProfileScreen-riAU6vVh.js +185 -0
- package/dist/assets/index-hEzd4JVy.js +44697 -0
- package/{index.html → dist/index.html} +3 -7
- package/mcp/server.mjs +1370 -34
- package/package.json +10 -17
- package/plugin.json +1 -1
- package/socket.yml +11 -0
- package/mcp/server.ts +0 -446
- package/plugins/viteReactNativeFallbackPlugin.ts +0 -519
- package/plugins/viteRnSourceInject.ts +0 -117
- package/plugins/viteVirtualScreensPlugin.ts +0 -216
- package/src/canvas/CanvasApp.tsx +0 -1655
- package/src/canvas/FlowArrowsLayer.tsx +0 -239
- package/src/canvas/InspectorPanel.tsx +0 -1494
- package/src/canvas/ScreenArtboard.tsx +0 -1491
- package/src/canvas/types.ts +0 -23
- package/src/main.tsx +0 -80
- package/src/runtime/MockNavigationContext.tsx +0 -1795
- package/src/runtime/ScreenErrorBoundary.tsx +0 -86
- package/src/runtime/nativewindShim.ts +0 -997
- package/src/runtime/reactNativeShim.ts +0 -240
- package/src/screens/CheckoutScreen.tsx +0 -229
- package/src/screens/HomeScreen.tsx +0 -288
- package/src/screens/LoginScreen.tsx +0 -176
- package/src/screens/ProductDetailScreen.tsx +0 -207
- package/src/screens/ProfileScreen.tsx +0 -172
- package/src/screens/index.ts +0 -1
- package/src/server/apiMiddleware.ts +0 -375
- package/src/server/astEngine.ts +0 -2553
- package/src/vite-env.d.ts +0 -7
- package/tsconfig.json +0 -23
- package/vite.config.ts +0 -340
- /package/{src/styles.css → dist/assets/index-C7nrS82N.css} +0 -0
package/src/server/astEngine.ts
DELETED
|
@@ -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
|
-
}
|