zelune 1.5.7 → 1.6.1
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 +27 -0
- package/bin/cli.mjs +22 -42
- package/mcp/server.mjs +2228 -0
- package/mcp/server.ts +367 -117
- package/package.json +3 -5
- package/plugin.json +1 -1
- package/plugins/viteReactNativeFallbackPlugin.ts +2 -11
- package/src/canvas/CanvasApp.tsx +174 -3
- package/src/canvas/FlowArrowsLayer.tsx +84 -45
- package/src/canvas/InspectorPanel.tsx +529 -23
- package/src/runtime/MockNavigationContext.tsx +59 -0
- package/src/server/apiMiddleware.ts +48 -0
- package/src/server/astEngine.ts +1291 -33
- package/vite.config.ts +177 -115
package/src/server/astEngine.ts
CHANGED
|
@@ -63,6 +63,35 @@ export interface ReorderPayload {
|
|
|
63
63
|
direction?: 'up' | 'down';
|
|
64
64
|
}
|
|
65
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
|
+
|
|
66
95
|
interface HistoryEntry {
|
|
67
96
|
file: string;
|
|
68
97
|
previousContent: string;
|
|
@@ -499,14 +528,19 @@ function extractElementNavigationTarget(openingElement: t.JSXOpeningElement): st
|
|
|
499
528
|
for (const attr of openingElement.attributes) {
|
|
500
529
|
if (
|
|
501
530
|
t.isJSXAttribute(attr) &&
|
|
502
|
-
t.isJSXIdentifier(attr.name
|
|
503
|
-
|
|
531
|
+
t.isJSXIdentifier(attr.name) &&
|
|
532
|
+
['onPress', 'href', 'to'].includes(attr.name.name)
|
|
504
533
|
) {
|
|
505
|
-
|
|
506
|
-
|
|
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;
|
|
507
539
|
const codeStr = generate(expr).code;
|
|
508
|
-
const match = codeStr.match(/(?:navigate|push|replace)\(\s*['"]([^'"]+)['"]/);
|
|
540
|
+
const match = codeStr.match(/(?:navigate|push|replace|jumpTo)\(\s*['"`]([^'"`$]+)['"`]/);
|
|
509
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]);
|
|
510
544
|
}
|
|
511
545
|
}
|
|
512
546
|
}
|
|
@@ -675,13 +709,190 @@ export function discoverScreenFilesSync(workspaceRoot: string): DiscoveredScreen
|
|
|
675
709
|
return files;
|
|
676
710
|
}
|
|
677
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
|
+
|
|
678
888
|
/**
|
|
679
889
|
* Discovers all screens in the active React Native / Expo project (`src/screens`, `screens`, or `app`)
|
|
680
|
-
* and extracts their navigation flow graph.
|
|
890
|
+
* and extracts their navigation flow graph (both forward flows and return/back flows).
|
|
681
891
|
*/
|
|
682
892
|
export async function discoverScreens(workspaceRoot: string): Promise<ScreenMetadata[]> {
|
|
683
893
|
const screenFiles = discoverScreenFilesSync(workspaceRoot);
|
|
684
894
|
const results: ScreenMetadata[] = [];
|
|
895
|
+
const backNavigationMeta = new Map<string, { hasBack: boolean; line: number }>();
|
|
685
896
|
|
|
686
897
|
for (const item of screenFiles) {
|
|
687
898
|
const { id: routeName, name, relPath, fullPath } = item;
|
|
@@ -695,6 +906,36 @@ export async function discoverScreens(workspaceRoot: string): Promise<ScreenMeta
|
|
|
695
906
|
|
|
696
907
|
let elementCount = 0;
|
|
697
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
|
+
};
|
|
698
939
|
|
|
699
940
|
traverse(ast, {
|
|
700
941
|
JSXOpeningElement(nodePath) {
|
|
@@ -702,47 +943,171 @@ export async function discoverScreens(workspaceRoot: string): Promise<ScreenMeta
|
|
|
702
943
|
if (tagName !== 'Fragment' && tagName !== 'React.Fragment' && tagName !== '') {
|
|
703
944
|
elementCount++;
|
|
704
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
|
+
}
|
|
705
993
|
},
|
|
994
|
+
|
|
706
995
|
CallExpression(nodePath) {
|
|
707
996
|
const { callee, arguments: args } = nodePath.node;
|
|
708
|
-
|
|
709
|
-
const isNavigateCall =
|
|
710
|
-
t.isMemberExpression(callee) &&
|
|
711
|
-
t.isIdentifier(callee.property) &&
|
|
712
|
-
['navigate', 'push', 'replace'].includes(callee.property.name) &&
|
|
713
|
-
args.length > 0 &&
|
|
714
|
-
t.isStringLiteral(args[0]);
|
|
997
|
+
const line = nodePath.node.loc?.start.line ?? 1;
|
|
715
998
|
|
|
716
|
-
|
|
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
|
+
}
|
|
717
1011
|
|
|
718
|
-
|
|
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);
|
|
719
1076
|
const line = nodePath.node.loc?.start.line ?? 1;
|
|
720
1077
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
+
}
|
|
730
1096
|
}
|
|
731
1097
|
}
|
|
1098
|
+
},
|
|
732
1099
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
toScreen: targetRoute,
|
|
739
|
-
triggerLabel,
|
|
740
|
-
line,
|
|
741
|
-
});
|
|
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;
|
|
742
1105
|
}
|
|
743
1106
|
},
|
|
744
1107
|
});
|
|
745
1108
|
|
|
1109
|
+
backNavigationMeta.set(routeName, { hasBack: hasBackNavigation, line: backLine });
|
|
1110
|
+
|
|
746
1111
|
results.push({
|
|
747
1112
|
id: routeName,
|
|
748
1113
|
name,
|
|
@@ -765,6 +1130,105 @@ export async function discoverScreens(workspaceRoot: string): Promise<ScreenMeta
|
|
|
765
1130
|
}
|
|
766
1131
|
}
|
|
767
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
|
+
|
|
768
1232
|
return results;
|
|
769
1233
|
}
|
|
770
1234
|
|
|
@@ -1225,6 +1689,800 @@ export async function reorderElement(
|
|
|
1225
1689
|
});
|
|
1226
1690
|
}
|
|
1227
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
|
+
|
|
1228
2486
|
export async function undoLastPatch(workspaceRoot: string): Promise<{
|
|
1229
2487
|
restoredFile: string | null;
|
|
1230
2488
|
description: string | null;
|