zelune 1.5.7 → 1.6.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.
@@ -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, { name: 'onPress' }) &&
503
- t.isJSXExpressionContainer(attr.value)
531
+ t.isJSXIdentifier(attr.name) &&
532
+ ['onPress', 'href', 'to'].includes(attr.name.name)
504
533
  ) {
505
- const expr = attr.value.expression;
506
- if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
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
- // Match navigation.navigate('Route') or router.push('/route') / router.replace('/route')
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
- if (!isNavigateCall) return;
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
- const targetRoute = normalizeRouteTarget((args[0] as t.StringLiteral).value);
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
- let triggerLabel = `Navigate to ${targetRoute}`;
722
- const jsxParent = nodePath.findParent((p) => p.isJSXElement()) as NodePath<t.JSXElement> | null;
723
- if (jsxParent) {
724
- const rawCode = generate(jsxParent.node).code;
725
- const textMatches = Array.from(rawCode.matchAll(/>([^<>{}\n]+)</g))
726
- .map((m) => m[1].trim())
727
- .filter(Boolean);
728
- if (textMatches.length > 0) {
729
- triggerLabel = textMatches.join(' · ');
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
- const edgeId = `${routeName}->${targetRoute}:${line}`;
734
- if (!outgoingFlows.some((e) => e.fromScreen === routeName && e.toScreen === targetRoute)) {
735
- outgoingFlows.push({
736
- id: edgeId,
737
- fromScreen: routeName,
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;