zelune 1.4.0 → 1.5.2
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 +29 -0
- package/README.md +8 -15
- package/bin/cli.mjs +215 -31
- package/mcp/server.ts +57 -1
- package/package.json +1 -1
- package/src/canvas/CanvasApp.tsx +89 -4
- package/src/canvas/ScreenArtboard.tsx +1008 -43
- package/src/server/apiMiddleware.ts +185 -12
- package/src/server/astEngine.ts +428 -19
- package/vite.config.ts +91 -24
package/src/server/astEngine.ts
CHANGED
|
@@ -55,6 +55,14 @@ export interface PatchPayload {
|
|
|
55
55
|
styles?: Record<string, StyleValue | null | undefined>;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface ReorderPayload {
|
|
59
|
+
file: string;
|
|
60
|
+
astPath: string;
|
|
61
|
+
targetAstPath?: string;
|
|
62
|
+
placement?: 'before' | 'after';
|
|
63
|
+
direction?: 'up' | 'down';
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
interface HistoryEntry {
|
|
59
67
|
file: string;
|
|
60
68
|
previousContent: string;
|
|
@@ -80,29 +88,188 @@ function withFileLock<T>(filePath: string, task: () => Promise<T>): Promise<T> {
|
|
|
80
88
|
return next;
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
const SCREEN_DIR_CANDIDATES = ['src/screens', 'screens', 'app', 'src/app'] as const;
|
|
92
|
+
const IGNORED_SCAN_DIRS = new Set([
|
|
93
|
+
'node_modules',
|
|
94
|
+
'.git',
|
|
95
|
+
'dist',
|
|
96
|
+
'build',
|
|
97
|
+
'.next',
|
|
98
|
+
'.expo',
|
|
99
|
+
'.turbo',
|
|
100
|
+
'ios',
|
|
101
|
+
'android',
|
|
102
|
+
'vendor',
|
|
103
|
+
'coverage',
|
|
104
|
+
'.agents',
|
|
105
|
+
'.gemini',
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
function countScreenFilesInDirectory(screensDir: string, maxDepth = 4): number {
|
|
109
|
+
let count = 0;
|
|
110
|
+
|
|
111
|
+
function walk(curr: string, depth: number) {
|
|
112
|
+
if (depth > maxDepth) return;
|
|
113
|
+
let entries: fsSync.Dirent[] = [];
|
|
114
|
+
try {
|
|
115
|
+
entries = fsSync.readdirSync(curr, { withFileTypes: true });
|
|
116
|
+
} catch {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.name.startsWith('.')) continue;
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
if (
|
|
124
|
+
entry.name === 'node_modules' ||
|
|
125
|
+
entry.name === '__tests__' ||
|
|
126
|
+
entry.name === 'components'
|
|
127
|
+
) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
walk(path.join(curr, entry.name), depth + 1);
|
|
131
|
+
} else if (entry.isFile()) {
|
|
132
|
+
const f = entry.name;
|
|
133
|
+
if (f.startsWith('_') || f.startsWith('+')) continue;
|
|
134
|
+
if (f === 'index.ts' || f === 'index.js') continue;
|
|
135
|
+
if (f.includes('.test.') || f.includes('.spec.') || f.endsWith('.d.ts')) continue;
|
|
136
|
+
if (f.endsWith('.tsx') || f.endsWith('.jsx')) {
|
|
137
|
+
count++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
walk(screensDir, 0);
|
|
144
|
+
return count;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasDirectScreenFiles(dirPath: string): { found: boolean; screenCount: number } {
|
|
148
|
+
for (const rel of SCREEN_DIR_CANDIDATES) {
|
|
149
|
+
const full = path.join(dirPath, rel);
|
|
150
|
+
try {
|
|
151
|
+
if (fsSync.existsSync(full) && fsSync.statSync(full).isDirectory()) {
|
|
152
|
+
const count = countScreenFilesInDirectory(full);
|
|
153
|
+
if (count > 0) {
|
|
154
|
+
return { found: true, screenCount: count };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Ignore unreadable directories
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { found: false, screenCount: 0 };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function scoreMobileCandidate(dirPath: string, screenCount: number): number {
|
|
165
|
+
let score = screenCount;
|
|
166
|
+
const folderName = path.basename(dirPath).toLowerCase();
|
|
167
|
+
if (/mobile|native|expo|app|client/.test(folderName)) {
|
|
168
|
+
score += 10;
|
|
169
|
+
}
|
|
170
|
+
if (fsSync.existsSync(path.join(dirPath, 'app.json')) || fsSync.existsSync(path.join(dirPath, 'eas.json'))) {
|
|
171
|
+
score += 25;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const pkgPath = path.join(dirPath, 'package.json');
|
|
175
|
+
if (fsSync.existsSync(pkgPath)) {
|
|
176
|
+
const pkgRaw = fsSync.readFileSync(pkgPath, 'utf-8');
|
|
177
|
+
if (pkgRaw.includes('"react-native"') || pkgRaw.includes('"expo"')) {
|
|
178
|
+
score += 20;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore malformed package.json
|
|
183
|
+
}
|
|
184
|
+
return score;
|
|
185
|
+
}
|
|
186
|
+
|
|
83
187
|
/**
|
|
84
|
-
*
|
|
188
|
+
* Automatically locates the React Native / Expo mobile project root even when invoked
|
|
189
|
+
* from a top-level monorepo or parent workspace folder (e.g., containing `mobile/` or `apps/mobile/`).
|
|
190
|
+
*/
|
|
191
|
+
export function findMobileAppRoot(rawRoot: string): string {
|
|
192
|
+
const normalizedRoot = path.resolve(rawRoot);
|
|
193
|
+
if (!fsSync.existsSync(normalizedRoot)) {
|
|
194
|
+
return normalizedRoot;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 1. Check if rawRoot itself directly contains React Native / Expo screens
|
|
198
|
+
const direct = hasDirectScreenFiles(normalizedRoot);
|
|
199
|
+
if (direct.found) {
|
|
200
|
+
return normalizedRoot;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 2. Scan up to 2 levels of subdirectories for a nested mobile app
|
|
204
|
+
let bestCandidate: string | null = null;
|
|
205
|
+
let bestScore = -1;
|
|
206
|
+
|
|
207
|
+
function scanSubdirs(parentDir: string, depth: number) {
|
|
208
|
+
if (depth > 2) return;
|
|
209
|
+
let entries: fsSync.Dirent[] = [];
|
|
210
|
+
try {
|
|
211
|
+
entries = fsSync.readdirSync(parentDir, { withFileTypes: true });
|
|
212
|
+
} catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
218
|
+
if (IGNORED_SCAN_DIRS.has(entry.name)) continue;
|
|
219
|
+
|
|
220
|
+
const subPath = path.join(parentDir, entry.name);
|
|
221
|
+
const check = hasDirectScreenFiles(subPath);
|
|
222
|
+
if (check.found) {
|
|
223
|
+
const score = scoreMobileCandidate(subPath, check.screenCount);
|
|
224
|
+
if (score > bestScore) {
|
|
225
|
+
bestScore = score;
|
|
226
|
+
bestCandidate = subPath;
|
|
227
|
+
}
|
|
228
|
+
} else if (depth < 2) {
|
|
229
|
+
scanSubdirs(subPath, depth + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
scanSubdirs(normalizedRoot, 1);
|
|
235
|
+
return bestCandidate ?? normalizedRoot;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolves the active target project root (supports ANTIGRAVITY_TARGET_PROJECT and nested mobile folders).
|
|
85
240
|
*/
|
|
86
241
|
export function getActiveProjectRoot(fallbackRoot: string): string {
|
|
87
242
|
const envTarget = process.env.ANTIGRAVITY_TARGET_PROJECT;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
243
|
+
const baseRoot =
|
|
244
|
+
envTarget && fsSync.existsSync(envTarget)
|
|
245
|
+
? path.resolve(envTarget)
|
|
246
|
+
: path.resolve(fallbackRoot);
|
|
247
|
+
return findMobileAppRoot(baseRoot);
|
|
92
248
|
}
|
|
93
249
|
|
|
94
250
|
/**
|
|
95
251
|
* Finds the active screens directory inside a React Native / Expo project.
|
|
96
|
-
* Checks `src/screens`, `screens`,
|
|
252
|
+
* Checks `src/screens`, `screens`, `app` (Expo Router), and `src/app`.
|
|
97
253
|
*/
|
|
98
254
|
export function resolveScreensDirectory(projectRoot: string): {
|
|
99
255
|
dirPath: string;
|
|
100
256
|
relDir: string;
|
|
101
257
|
} {
|
|
102
|
-
const normalizedRoot =
|
|
103
|
-
const candidates = ['src/screens', 'screens', 'app', 'src/app'];
|
|
258
|
+
const normalizedRoot = findMobileAppRoot(projectRoot);
|
|
104
259
|
|
|
105
|
-
|
|
260
|
+
// Prefer a candidate directory that actually contains .tsx/.jsx screen files
|
|
261
|
+
for (const rel of SCREEN_DIR_CANDIDATES) {
|
|
262
|
+
const full = path.join(normalizedRoot, rel);
|
|
263
|
+
if (
|
|
264
|
+
fsSync.existsSync(full) &&
|
|
265
|
+
fsSync.statSync(full).isDirectory() &&
|
|
266
|
+
countScreenFilesInDirectory(full) > 0
|
|
267
|
+
) {
|
|
268
|
+
return { dirPath: full, relDir: rel };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const rel of SCREEN_DIR_CANDIDATES) {
|
|
106
273
|
const full = path.join(normalizedRoot, rel);
|
|
107
274
|
if (fsSync.existsSync(full) && fsSync.statSync(full).isDirectory()) {
|
|
108
275
|
return { dirPath: full, relDir: rel };
|
|
@@ -115,24 +282,36 @@ export function resolveScreensDirectory(projectRoot: string): {
|
|
|
115
282
|
};
|
|
116
283
|
}
|
|
117
284
|
|
|
285
|
+
const FORBIDDEN_OBJECT_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
286
|
+
|
|
287
|
+
function safeRealpath(targetPath: string): string {
|
|
288
|
+
try {
|
|
289
|
+
return fsSync.realpathSync(targetPath);
|
|
290
|
+
} catch {
|
|
291
|
+
return path.resolve(targetPath);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
118
295
|
/**
|
|
119
296
|
* Validates that a relative file path stays strictly inside the project's screens directory
|
|
120
|
-
* (`src/screens`, `screens`, or `app`) and ends with `.tsx` or `.jsx`.
|
|
297
|
+
* (`src/screens`, `screens`, or `app`) and ends with `.tsx` or `.jsx`.
|
|
298
|
+
* Prevents lexical path traversal (`../`) and symbolic link escape attacks.
|
|
121
299
|
*/
|
|
122
300
|
export function resolveSafeScreenPath(workspaceRoot: string, relativeFile: string): string {
|
|
123
|
-
const normalizedRoot = getActiveProjectRoot(workspaceRoot);
|
|
124
|
-
const cleanRel = relativeFile.replace(/^[/\\]+/, '');
|
|
301
|
+
const normalizedRoot = safeRealpath(getActiveProjectRoot(workspaceRoot));
|
|
302
|
+
const cleanRel = String(relativeFile ?? '').replace(/^[/\\]+/, '');
|
|
125
303
|
const resolved = path.resolve(normalizedRoot, cleanRel);
|
|
126
304
|
|
|
127
|
-
const allowedDirs = ['src/screens', 'screens', 'app', 'src/app'].map((d) =>
|
|
128
|
-
path.resolve(normalizedRoot, d)
|
|
129
|
-
|
|
305
|
+
const allowedDirs = ['src/screens', 'screens', 'app', 'src/app'].map((d) => {
|
|
306
|
+
const candidate = path.resolve(normalizedRoot, d);
|
|
307
|
+
return fsSync.existsSync(candidate) ? safeRealpath(candidate) : candidate;
|
|
308
|
+
});
|
|
130
309
|
|
|
131
|
-
const
|
|
310
|
+
const isLexicallyAllowed = allowedDirs.some(
|
|
132
311
|
(dir) => resolved.startsWith(dir + path.sep) || resolved === dir
|
|
133
312
|
);
|
|
134
313
|
|
|
135
|
-
if (!
|
|
314
|
+
if (!isLexicallyAllowed) {
|
|
136
315
|
throw new Error(
|
|
137
316
|
`Security violation: File path "${relativeFile}" is outside allowed screen directories (src/screens, screens, app)`
|
|
138
317
|
);
|
|
@@ -140,6 +319,27 @@ export function resolveSafeScreenPath(workspaceRoot: string, relativeFile: strin
|
|
|
140
319
|
if (!resolved.endsWith('.tsx') && !resolved.endsWith('.jsx')) {
|
|
141
320
|
throw new Error(`Invalid file extension for "${relativeFile}". Only .tsx and .jsx are allowed.`);
|
|
142
321
|
}
|
|
322
|
+
|
|
323
|
+
// Reject symbolic links or symlinked ancestor directories that escape the allowed screens directories
|
|
324
|
+
if (fsSync.existsSync(resolved)) {
|
|
325
|
+
const stat = fsSync.lstatSync(resolved);
|
|
326
|
+
if (stat.isSymbolicLink()) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Security violation: Symbolic link "${relativeFile}" is not permitted inside screen directories`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const realTarget = safeRealpath(resolved);
|
|
332
|
+
const isRealInsideAllowed = allowedDirs.some(
|
|
333
|
+
(dir) => realTarget.startsWith(dir + path.sep) || realTarget === dir
|
|
334
|
+
);
|
|
335
|
+
if (!isRealInsideAllowed) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Security violation: Canonical path for "${relativeFile}" resolves outside allowed screen directories`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return realTarget;
|
|
341
|
+
}
|
|
342
|
+
|
|
143
343
|
return resolved;
|
|
144
344
|
}
|
|
145
345
|
|
|
@@ -638,11 +838,25 @@ export async function inspectElement(
|
|
|
638
838
|
};
|
|
639
839
|
}
|
|
640
840
|
|
|
841
|
+
function createSafeJsxTextChild(text: string): t.JSXText | t.JSXExpressionContainer {
|
|
842
|
+
// @babel/generator prints JSXText.value verbatim without escaping '{', '}', '<', or '>'.
|
|
843
|
+
// Wrap any string containing JSX metacharacters, newlines, or leading/trailing whitespace
|
|
844
|
+
// inside a JSXExpressionContainer(StringLiteral) so Babel safely escapes it as {"..."}.
|
|
845
|
+
if (/[{}<>]|[\r\n]|^\s|\s$/.test(text)) {
|
|
846
|
+
return t.jsxExpressionContainer(t.stringLiteral(text));
|
|
847
|
+
}
|
|
848
|
+
return t.jsxText(text);
|
|
849
|
+
}
|
|
850
|
+
|
|
641
851
|
function upsertObjectProperty(
|
|
642
852
|
objExpr: t.ObjectExpression,
|
|
643
853
|
key: string,
|
|
644
854
|
val: StyleValue | null | undefined
|
|
645
855
|
) {
|
|
856
|
+
if (!key || FORBIDDEN_OBJECT_KEYS.has(key) || key.length > 80) {
|
|
857
|
+
throw new Error(`Security violation: Invalid style property name "${key}"`);
|
|
858
|
+
}
|
|
859
|
+
|
|
646
860
|
const existingIndex = objExpr.properties.findIndex((prop) => {
|
|
647
861
|
if (!t.isObjectProperty(prop)) return false;
|
|
648
862
|
const k = t.isIdentifier(prop.key)
|
|
@@ -660,8 +874,22 @@ function upsertObjectProperty(
|
|
|
660
874
|
return;
|
|
661
875
|
}
|
|
662
876
|
|
|
877
|
+
if (typeof val !== 'number' && typeof val !== 'string') {
|
|
878
|
+
throw new Error(`Security violation: Style value for "${key}" must be a string or finite number`);
|
|
879
|
+
}
|
|
880
|
+
if (typeof val === 'number' && !Number.isFinite(val)) {
|
|
881
|
+
throw new Error(`Security violation: Style value for "${key}" must be a finite number`);
|
|
882
|
+
}
|
|
883
|
+
if (typeof val === 'string' && val.length > 500) {
|
|
884
|
+
throw new Error(`Security violation: Style value for "${key}" exceeds maximum length`);
|
|
885
|
+
}
|
|
886
|
+
|
|
663
887
|
const valueNode =
|
|
664
|
-
typeof val === 'number'
|
|
888
|
+
typeof val === 'number'
|
|
889
|
+
? val < 0
|
|
890
|
+
? t.unaryExpression('-', t.numericLiteral(Math.abs(val)))
|
|
891
|
+
: t.numericLiteral(val)
|
|
892
|
+
: t.stringLiteral(val);
|
|
665
893
|
|
|
666
894
|
if (existingIndex !== -1) {
|
|
667
895
|
const existingProp = objExpr.properties[existingIndex] as t.ObjectProperty;
|
|
@@ -727,13 +955,16 @@ export async function patchElement(
|
|
|
727
955
|
const opening = element.openingElement;
|
|
728
956
|
|
|
729
957
|
if (payload.text !== undefined) {
|
|
958
|
+
if (typeof payload.text !== 'string') {
|
|
959
|
+
throw new Error('Invalid text payload: must be a string.');
|
|
960
|
+
}
|
|
730
961
|
const textCheck = inspectJsxTextChildren(element);
|
|
731
962
|
if (textCheck.hasDynamicText) {
|
|
732
963
|
throw new Error(
|
|
733
964
|
'Cannot overwrite dynamic JSX expression inside <Text>. Use "Ask Antigravity" for structural/expression edits.'
|
|
734
965
|
);
|
|
735
966
|
}
|
|
736
|
-
element.children = [
|
|
967
|
+
element.children = [createSafeJsxTextChild(payload.text)];
|
|
737
968
|
}
|
|
738
969
|
|
|
739
970
|
if (payload.styles && Object.keys(payload.styles).length > 0) {
|
|
@@ -807,6 +1038,184 @@ export async function patchElement(
|
|
|
807
1038
|
});
|
|
808
1039
|
}
|
|
809
1040
|
|
|
1041
|
+
/**
|
|
1042
|
+
* Surgically reorders a JSXElement among its Flexbox siblings in the `.tsx` AST.
|
|
1043
|
+
* Supports either explicit target sibling (`targetAstPath` + `placement: 'before' | 'after'`)
|
|
1044
|
+
* or relative movement (`direction: 'up' | 'down'`).
|
|
1045
|
+
*/
|
|
1046
|
+
export async function reorderElement(
|
|
1047
|
+
workspaceRoot: string,
|
|
1048
|
+
payload: ReorderPayload
|
|
1049
|
+
): Promise<{
|
|
1050
|
+
element: InspectedElement;
|
|
1051
|
+
undoAvailable: number;
|
|
1052
|
+
redoAvailable: number;
|
|
1053
|
+
}> {
|
|
1054
|
+
const fullPath = resolveSafeScreenPath(workspaceRoot, payload.file);
|
|
1055
|
+
|
|
1056
|
+
return withFileLock(fullPath, async () => {
|
|
1057
|
+
const previousContent = await fs.readFile(fullPath, 'utf-8');
|
|
1058
|
+
const ast = parse(previousContent, {
|
|
1059
|
+
sourceType: 'module',
|
|
1060
|
+
plugins: ['typescript', 'jsx'],
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
interface IndexedElementInfo {
|
|
1064
|
+
index: number;
|
|
1065
|
+
element: t.JSXElement;
|
|
1066
|
+
parent: t.JSXElement | t.JSXFragment | null;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const byIndex = new Map<number, IndexedElementInfo>();
|
|
1070
|
+
const parentByElement = new Map<t.JSXElement, t.JSXElement | t.JSXFragment | null>();
|
|
1071
|
+
let currentIndex = 0;
|
|
1072
|
+
|
|
1073
|
+
traverse(ast, {
|
|
1074
|
+
JSXOpeningElement(nodePath) {
|
|
1075
|
+
const tagName = getJsxTagName(nodePath.node.name);
|
|
1076
|
+
if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const idx = currentIndex++;
|
|
1080
|
+
const jsxElPath = nodePath.parentPath;
|
|
1081
|
+
if (!jsxElPath.isJSXElement()) return;
|
|
1082
|
+
|
|
1083
|
+
const element = jsxElPath.node;
|
|
1084
|
+
const parentNode = jsxElPath.parentPath?.node;
|
|
1085
|
+
const parent =
|
|
1086
|
+
t.isJSXElement(parentNode) || t.isJSXFragment(parentNode) ? parentNode : null;
|
|
1087
|
+
|
|
1088
|
+
byIndex.set(idx, { index: idx, element, parent });
|
|
1089
|
+
parentByElement.set(element, parent);
|
|
1090
|
+
},
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const sourceIdx = Number.parseInt(payload.astPath, 10);
|
|
1094
|
+
const sourceInfo = byIndex.get(sourceIdx);
|
|
1095
|
+
if (!sourceInfo || !sourceInfo.parent) {
|
|
1096
|
+
throw new Error(`Element at astPath=${payload.astPath} cannot be reordered (no JSX parent container).`);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const parentContainer = sourceInfo.parent;
|
|
1100
|
+
const sourceElement = sourceInfo.element;
|
|
1101
|
+
|
|
1102
|
+
const jsxSiblings = parentContainer.children.filter((c): c is t.JSXElement =>
|
|
1103
|
+
t.isJSXElement(c)
|
|
1104
|
+
);
|
|
1105
|
+
if (jsxSiblings.length < 2) {
|
|
1106
|
+
throw new Error('Element has no sibling elements inside its parent container to reorder with.');
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
let targetSibling: t.JSXElement | null = null;
|
|
1110
|
+
let placement: 'before' | 'after' = payload.placement === 'after' ? 'after' : 'before';
|
|
1111
|
+
|
|
1112
|
+
if (payload.direction === 'up' || payload.direction === 'down') {
|
|
1113
|
+
const currPos = jsxSiblings.indexOf(sourceElement);
|
|
1114
|
+
if (currPos === -1) {
|
|
1115
|
+
throw new Error('Could not locate element inside parent children list.');
|
|
1116
|
+
}
|
|
1117
|
+
if (payload.direction === 'up') {
|
|
1118
|
+
if (currPos === 0) {
|
|
1119
|
+
throw new Error('Element is already first in its container.');
|
|
1120
|
+
}
|
|
1121
|
+
targetSibling = jsxSiblings[currPos - 1];
|
|
1122
|
+
placement = 'before';
|
|
1123
|
+
} else {
|
|
1124
|
+
if (currPos >= jsxSiblings.length - 1) {
|
|
1125
|
+
throw new Error('Element is already last in its container.');
|
|
1126
|
+
}
|
|
1127
|
+
targetSibling = jsxSiblings[currPos + 1];
|
|
1128
|
+
placement = 'after';
|
|
1129
|
+
}
|
|
1130
|
+
} else if (payload.targetAstPath !== undefined) {
|
|
1131
|
+
const targetIdx = Number.parseInt(payload.targetAstPath, 10);
|
|
1132
|
+
const rawTargetInfo = byIndex.get(targetIdx);
|
|
1133
|
+
if (!rawTargetInfo) {
|
|
1134
|
+
throw new Error(`Target sibling at astPath=${payload.targetAstPath} not found.`);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Walk up from rawTargetInfo.element until we find the direct child of parentContainer
|
|
1138
|
+
let candidate: t.JSXElement | null = rawTargetInfo.element;
|
|
1139
|
+
while (candidate && parentByElement.get(candidate) !== parentContainer) {
|
|
1140
|
+
const p = parentByElement.get(candidate);
|
|
1141
|
+
candidate = p && t.isJSXElement(p) ? p : null;
|
|
1142
|
+
}
|
|
1143
|
+
if (!candidate || candidate === sourceElement) {
|
|
1144
|
+
throw new Error('Target drop element is not a sibling inside the same Flexbox container.');
|
|
1145
|
+
}
|
|
1146
|
+
targetSibling = candidate;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (!targetSibling || targetSibling === sourceElement) {
|
|
1150
|
+
throw new Error('Invalid reorder target sibling.');
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Rebuild parentContainer.children preserving non-whitespace nodes in the new order
|
|
1154
|
+
const meaningfulChildren = parentContainer.children.filter(
|
|
1155
|
+
(child) => !(t.isJSXText(child) && child.value.trim() === '')
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
const withoutSource = meaningfulChildren.filter((child) => child !== sourceElement);
|
|
1159
|
+
const targetPos = withoutSource.indexOf(targetSibling);
|
|
1160
|
+
if (targetPos === -1) {
|
|
1161
|
+
throw new Error('Target sibling not found in parent children.');
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const insertIdx = placement === 'after' ? targetPos + 1 : targetPos;
|
|
1165
|
+
withoutSource.splice(insertIdx, 0, sourceElement);
|
|
1166
|
+
parentContainer.children = withoutSource;
|
|
1167
|
+
|
|
1168
|
+
// Re-index the mutated AST to find the updated pre-order astPath of sourceElement
|
|
1169
|
+
let newSourceAstPath = payload.astPath;
|
|
1170
|
+
let reindexCounter = 0;
|
|
1171
|
+
traverse(ast, {
|
|
1172
|
+
JSXOpeningElement(nodePath) {
|
|
1173
|
+
const tagName = getJsxTagName(nodePath.node.name);
|
|
1174
|
+
if (tagName === 'Fragment' || tagName === 'React.Fragment' || tagName === '') {
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
const idx = reindexCounter++;
|
|
1178
|
+
if (nodePath.parentPath.isJSXElement() && nodePath.parentPath.node === sourceElement) {
|
|
1179
|
+
newSourceAstPath = String(idx);
|
|
1180
|
+
}
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
const output = generate(ast, {
|
|
1185
|
+
retainLines: false,
|
|
1186
|
+
comments: true,
|
|
1187
|
+
jsescOption: { minimal: true },
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
const nextContent = output.code + '\n';
|
|
1191
|
+
if (nextContent !== previousContent) {
|
|
1192
|
+
await fs.writeFile(fullPath, nextContent, 'utf-8');
|
|
1193
|
+
undoStack.push({
|
|
1194
|
+
file: payload.file,
|
|
1195
|
+
previousContent,
|
|
1196
|
+
nextContent,
|
|
1197
|
+
description: `Reordered ${getJsxTagName(sourceElement.openingElement.name)} in ${path.basename(payload.file)}`,
|
|
1198
|
+
timestamp: Date.now(),
|
|
1199
|
+
});
|
|
1200
|
+
if (undoStack.length > MAX_HISTORY) {
|
|
1201
|
+
undoStack.shift();
|
|
1202
|
+
}
|
|
1203
|
+
redoStack.length = 0;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const updatedElement = await inspectElement(workspaceRoot, {
|
|
1207
|
+
file: payload.file,
|
|
1208
|
+
astPath: newSourceAstPath,
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
return {
|
|
1212
|
+
element: updatedElement,
|
|
1213
|
+
undoAvailable: undoStack.length,
|
|
1214
|
+
redoAvailable: redoStack.length,
|
|
1215
|
+
};
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
810
1219
|
export async function undoLastPatch(workspaceRoot: string): Promise<{
|
|
811
1220
|
restoredFile: string | null;
|
|
812
1221
|
description: string | null;
|