@wtdlee/repomap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/dist/analyzers/base-analyzer.d.ts +46 -0
  4. package/dist/analyzers/base-analyzer.d.ts.map +1 -0
  5. package/dist/analyzers/base-analyzer.js +48 -0
  6. package/dist/analyzers/base-analyzer.js.map +1 -0
  7. package/dist/analyzers/dataflow-analyzer.d.ts +30 -0
  8. package/dist/analyzers/dataflow-analyzer.d.ts.map +1 -0
  9. package/dist/analyzers/dataflow-analyzer.js +426 -0
  10. package/dist/analyzers/dataflow-analyzer.js.map +1 -0
  11. package/dist/analyzers/graphql-analyzer.d.ts +23 -0
  12. package/dist/analyzers/graphql-analyzer.d.ts.map +1 -0
  13. package/dist/analyzers/graphql-analyzer.js +387 -0
  14. package/dist/analyzers/graphql-analyzer.js.map +1 -0
  15. package/dist/analyzers/index.d.ts +6 -0
  16. package/dist/analyzers/index.d.ts.map +1 -0
  17. package/dist/analyzers/index.js +6 -0
  18. package/dist/analyzers/index.js.map +1 -0
  19. package/dist/analyzers/pages-analyzer.d.ts +85 -0
  20. package/dist/analyzers/pages-analyzer.d.ts.map +1 -0
  21. package/dist/analyzers/pages-analyzer.js +1696 -0
  22. package/dist/analyzers/pages-analyzer.js.map +1 -0
  23. package/dist/analyzers/rails/index.d.ts +47 -0
  24. package/dist/analyzers/rails/index.d.ts.map +1 -0
  25. package/dist/analyzers/rails/index.js +146 -0
  26. package/dist/analyzers/rails/index.js.map +1 -0
  27. package/dist/analyzers/rails/rails-controller-analyzer.d.ts +83 -0
  28. package/dist/analyzers/rails/rails-controller-analyzer.d.ts.map +1 -0
  29. package/dist/analyzers/rails/rails-controller-analyzer.js +479 -0
  30. package/dist/analyzers/rails/rails-controller-analyzer.js.map +1 -0
  31. package/dist/analyzers/rails/rails-grpc-analyzer.d.ts +45 -0
  32. package/dist/analyzers/rails/rails-grpc-analyzer.d.ts.map +1 -0
  33. package/dist/analyzers/rails/rails-grpc-analyzer.js +263 -0
  34. package/dist/analyzers/rails/rails-grpc-analyzer.js.map +1 -0
  35. package/dist/analyzers/rails/rails-model-analyzer.d.ts +89 -0
  36. package/dist/analyzers/rails/rails-model-analyzer.d.ts.map +1 -0
  37. package/dist/analyzers/rails/rails-model-analyzer.js +494 -0
  38. package/dist/analyzers/rails/rails-model-analyzer.js.map +1 -0
  39. package/dist/analyzers/rails/rails-react-analyzer.d.ts +42 -0
  40. package/dist/analyzers/rails/rails-react-analyzer.d.ts.map +1 -0
  41. package/dist/analyzers/rails/rails-react-analyzer.js +530 -0
  42. package/dist/analyzers/rails/rails-react-analyzer.js.map +1 -0
  43. package/dist/analyzers/rails/rails-routes-analyzer.d.ts +63 -0
  44. package/dist/analyzers/rails/rails-routes-analyzer.d.ts.map +1 -0
  45. package/dist/analyzers/rails/rails-routes-analyzer.js +541 -0
  46. package/dist/analyzers/rails/rails-routes-analyzer.js.map +1 -0
  47. package/dist/analyzers/rails/rails-view-analyzer.d.ts +50 -0
  48. package/dist/analyzers/rails/rails-view-analyzer.d.ts.map +1 -0
  49. package/dist/analyzers/rails/rails-view-analyzer.js +387 -0
  50. package/dist/analyzers/rails/rails-view-analyzer.js.map +1 -0
  51. package/dist/analyzers/rails/ruby-parser.d.ts +64 -0
  52. package/dist/analyzers/rails/ruby-parser.d.ts.map +1 -0
  53. package/dist/analyzers/rails/ruby-parser.js +213 -0
  54. package/dist/analyzers/rails/ruby-parser.js.map +1 -0
  55. package/dist/analyzers/rest-api-analyzer.d.ts +66 -0
  56. package/dist/analyzers/rest-api-analyzer.d.ts.map +1 -0
  57. package/dist/analyzers/rest-api-analyzer.js +480 -0
  58. package/dist/analyzers/rest-api-analyzer.js.map +1 -0
  59. package/dist/cli.d.ts +3 -0
  60. package/dist/cli.d.ts.map +1 -0
  61. package/dist/cli.js +550 -0
  62. package/dist/cli.js.map +1 -0
  63. package/dist/core/cache.d.ts +48 -0
  64. package/dist/core/cache.d.ts.map +1 -0
  65. package/dist/core/cache.js +152 -0
  66. package/dist/core/cache.js.map +1 -0
  67. package/dist/core/engine.d.ts +47 -0
  68. package/dist/core/engine.d.ts.map +1 -0
  69. package/dist/core/engine.js +320 -0
  70. package/dist/core/engine.js.map +1 -0
  71. package/dist/core/index.d.ts +3 -0
  72. package/dist/core/index.d.ts.map +1 -0
  73. package/dist/core/index.js +3 -0
  74. package/dist/core/index.js.map +1 -0
  75. package/dist/generators/assets/common.css +187 -0
  76. package/dist/generators/assets/docs.css +363 -0
  77. package/dist/generators/assets/page-map.css +305 -0
  78. package/dist/generators/assets/rails-map.css +473 -0
  79. package/dist/generators/index.d.ts +4 -0
  80. package/dist/generators/index.d.ts.map +1 -0
  81. package/dist/generators/index.js +4 -0
  82. package/dist/generators/index.js.map +1 -0
  83. package/dist/generators/markdown-generator.d.ts +26 -0
  84. package/dist/generators/markdown-generator.d.ts.map +1 -0
  85. package/dist/generators/markdown-generator.js +783 -0
  86. package/dist/generators/markdown-generator.js.map +1 -0
  87. package/dist/generators/mermaid-generator.d.ts +36 -0
  88. package/dist/generators/mermaid-generator.d.ts.map +1 -0
  89. package/dist/generators/mermaid-generator.js +365 -0
  90. package/dist/generators/mermaid-generator.js.map +1 -0
  91. package/dist/generators/page-map-generator.d.ts +23 -0
  92. package/dist/generators/page-map-generator.d.ts.map +1 -0
  93. package/dist/generators/page-map-generator.js +3563 -0
  94. package/dist/generators/page-map-generator.js.map +1 -0
  95. package/dist/generators/rails-map-generator.d.ts +22 -0
  96. package/dist/generators/rails-map-generator.d.ts.map +1 -0
  97. package/dist/generators/rails-map-generator.js +909 -0
  98. package/dist/generators/rails-map-generator.js.map +1 -0
  99. package/dist/index.d.ts +11 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +12 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/server/doc-server.d.ts +31 -0
  104. package/dist/server/doc-server.d.ts.map +1 -0
  105. package/dist/server/doc-server.js +1233 -0
  106. package/dist/server/doc-server.js.map +1 -0
  107. package/dist/server/index.d.ts +2 -0
  108. package/dist/server/index.d.ts.map +1 -0
  109. package/dist/server/index.js +2 -0
  110. package/dist/server/index.js.map +1 -0
  111. package/dist/types.d.ts +294 -0
  112. package/dist/types.d.ts.map +1 -0
  113. package/dist/types.js +6 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/utils/env-detector.d.ts +32 -0
  116. package/dist/utils/env-detector.d.ts.map +1 -0
  117. package/dist/utils/env-detector.js +189 -0
  118. package/dist/utils/env-detector.js.map +1 -0
  119. package/dist/utils/parallel.d.ts +24 -0
  120. package/dist/utils/parallel.d.ts.map +1 -0
  121. package/dist/utils/parallel.js +71 -0
  122. package/dist/utils/parallel.js.map +1 -0
  123. package/package.json +131 -0
@@ -0,0 +1,3563 @@
1
+ /**
2
+ * Interactive page map generator
3
+ */
4
+ export class PageMapGenerator {
5
+ graphqlOps = [];
6
+ apiCalls = [];
7
+ components = [];
8
+ generatePageMapHtml(report, options) {
9
+ const allPages = [];
10
+ const envResult = options?.envResult;
11
+ const railsAnalysis = options?.railsAnalysis;
12
+ const activeTab = options?.activeTab || 'pages';
13
+ // Get repository name for display
14
+ const repoName = report.repositories[0]?.displayName || report.repositories[0]?.name || 'Repository';
15
+ for (const repoResult of report.repositories) {
16
+ this.graphqlOps.push(...(repoResult.analysis?.graphqlOperations || []));
17
+ this.apiCalls.push(...(repoResult.analysis?.apiCalls || []));
18
+ // Collect component information
19
+ const comps = repoResult.analysis?.components || [];
20
+ for (const comp of comps) {
21
+ this.components.push({
22
+ name: comp.name,
23
+ filePath: comp.filePath,
24
+ type: comp.type,
25
+ dependencies: comp.dependencies || [],
26
+ });
27
+ }
28
+ }
29
+ for (const repoResult of report.repositories) {
30
+ const pages = repoResult.analysis?.pages || [];
31
+ for (const page of pages) {
32
+ allPages.push({
33
+ ...page,
34
+ repo: repoResult.name,
35
+ children: [],
36
+ parent: null,
37
+ depth: 0,
38
+ });
39
+ }
40
+ }
41
+ const { rootPages, relations } = this.buildHierarchy(allPages);
42
+ return this.renderPageMapHtml(allPages, rootPages, relations, repoName, {
43
+ envResult,
44
+ railsAnalysis,
45
+ activeTab,
46
+ });
47
+ }
48
+ buildHierarchy(pages) {
49
+ const pathMap = new Map();
50
+ const relations = [];
51
+ for (const page of pages) {
52
+ pathMap.set(page.path, page);
53
+ }
54
+ for (const page of pages) {
55
+ const segments = page.path.split('/').filter(Boolean);
56
+ for (let i = segments.length - 1; i >= 1; i--) {
57
+ const parentPath = '/' + segments.slice(0, i).join('/');
58
+ const parent = pathMap.get(parentPath);
59
+ if (parent) {
60
+ page.parent = parentPath;
61
+ page.depth = parent.depth + 1;
62
+ if (!parent.children.includes(page.path)) {
63
+ parent.children.push(page.path);
64
+ }
65
+ relations.push({
66
+ from: parentPath,
67
+ to: page.path,
68
+ type: 'parent-child',
69
+ description: `Sub-page of ${parentPath}`,
70
+ });
71
+ break;
72
+ }
73
+ }
74
+ if (!page.parent) {
75
+ // Use segment count for depth when no parent page exists
76
+ // This ensures proper indentation based on URL structure
77
+ page.depth = Math.max(0, segments.length - 1);
78
+ }
79
+ if (page.layout) {
80
+ for (const other of pages) {
81
+ if (other.path !== page.path && other.layout === page.layout) {
82
+ const existing = relations.find((r) => r.type === 'same-layout' &&
83
+ ((r.from === page.path && r.to === other.path) ||
84
+ (r.from === other.path && r.to === page.path)));
85
+ if (!existing) {
86
+ relations.push({
87
+ from: page.path,
88
+ to: other.path,
89
+ type: 'same-layout',
90
+ description: `Both use ${page.layout}`,
91
+ });
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ const rootPages = pages.filter((p) => !p.parent).sort((a, b) => a.path.localeCompare(b.path));
98
+ return { rootPages, relations };
99
+ }
100
+ renderPageMapHtml(allPages, rootPages, relations, repoName, options) {
101
+ const envResult = options?.envResult;
102
+ const railsAnalysis = options?.railsAnalysis;
103
+ const activeTab = options?.activeTab || 'pages';
104
+ const graphqlOpsJson = JSON.stringify(this.graphqlOps.map((op) => ({
105
+ name: op.name,
106
+ type: op.type,
107
+ variables: op.variables,
108
+ fields: op.fields,
109
+ returnType: op.returnType,
110
+ usedIn: op.usedIn,
111
+ })));
112
+ const componentsJson = JSON.stringify(this.components);
113
+ // Rails data for integrated view
114
+ const railsRoutesJson = railsAnalysis ? JSON.stringify(railsAnalysis.routes.routes) : '[]';
115
+ const railsControllersJson = railsAnalysis
116
+ ? JSON.stringify(railsAnalysis.controllers.controllers)
117
+ : '[]';
118
+ const railsModelsJson = railsAnalysis ? JSON.stringify(railsAnalysis.models.models) : '[]';
119
+ const railsViewsJson = railsAnalysis
120
+ ? JSON.stringify(railsAnalysis.views)
121
+ : '{ "views": [], "pages": [], "summary": {} }';
122
+ const railsReactJson = railsAnalysis
123
+ ? JSON.stringify(railsAnalysis.react)
124
+ : '{ "components": [], "entryPoints": [], "summary": {} }';
125
+ const railsGrpcJson = railsAnalysis ? JSON.stringify(railsAnalysis.grpc) : '{ "services": [] }';
126
+ const railsSummaryJson = railsAnalysis ? JSON.stringify(railsAnalysis.summary) : 'null';
127
+ // Environment info
128
+ const hasRails = envResult?.hasRails || false;
129
+ const hasNextjs = envResult?.hasNextjs || false;
130
+ const hasReact = envResult?.hasReact || false;
131
+ // Group by first path segment
132
+ const groups = new Map();
133
+ for (const page of allPages) {
134
+ const seg = page.path.split('/').filter(Boolean)[0] || 'root';
135
+ if (!groups.has(seg))
136
+ groups.set(seg, []);
137
+ groups.get(seg)?.push(page);
138
+ }
139
+ return `<!DOCTYPE html>
140
+ <html lang="en">
141
+ <head>
142
+ <meta charset="UTF-8">
143
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
144
+ <title>Page Map</title>
145
+ <link rel="stylesheet" href="/page-map.css">
146
+ </head>
147
+ <body>
148
+ <header class="header">
149
+ <div style="display:flex;align-items:center;gap:24px">
150
+ <h1 style="cursor:pointer" onclick="location.href='/'">📊 ${repoName}</h1>
151
+ <nav style="display:flex;gap:4px">
152
+ <a href="/page-map" class="nav-link ${activeTab === 'pages' ? 'active' : ''}">Page Map</a>
153
+ ${hasRails ? `<a href="/rails-map" class="nav-link ${activeTab === 'rails' ? 'active' : ''}">Rails Map</a>` : ''}
154
+ <a href="/docs" class="nav-link">Docs</a>
155
+ <a href="/api/report" class="nav-link" target="_blank">API</a>
156
+ </nav>
157
+ </div>
158
+ <div style="display:flex;gap:12px;align-items:center">
159
+ <!-- Environment filter badges -->
160
+ ${hasRails && hasNextjs
161
+ ? `<div class="env-filters" style="display:flex;gap:4px;margin-right:8px">
162
+ <button class="env-badge env-badge-active" data-env="all" onclick="filterByEnv('all')">All</button>
163
+ <button class="env-badge" data-env="nextjs" onclick="filterByEnv('nextjs')">⚛️ Next.js</button>
164
+ <button class="env-badge" data-env="rails" onclick="filterByEnv('rails')">🛤️ Rails</button>
165
+ </div>`
166
+ : ''}
167
+ <input class="search" type="text" placeholder="Search pages, queries..." oninput="filter(this.value)">
168
+ <div class="tabs">
169
+ <button class="tab active" onclick="setView('tree')">List</button>
170
+ <button class="tab" onclick="setView('graph')">Graph</button>
171
+ </div>
172
+ </div>
173
+ </header>
174
+
175
+ <div class="main">
176
+ <aside class="sidebar">
177
+ <h3>Page Types</h3>
178
+ <div class="legend">
179
+ <div class="legend-item"><div class="legend-color" style="background:#22c55e"></div>CREATE</div>
180
+ <div class="legend-item"><div class="legend-color" style="background:#f59e0b"></div>EDIT</div>
181
+ <div class="legend-item"><div class="legend-color" style="background:#3b82f6"></div>DETAIL</div>
182
+ <div class="legend-item"><div class="legend-color" style="background:#06b6d4"></div>LIST</div>
183
+ </div>
184
+
185
+ <h3>Relationships</h3>
186
+ <div class="legend">
187
+ <div class="legend-item"><div class="legend-color" style="background:#3b82f6"></div>PARENT</div>
188
+ <div class="legend-item"><div class="legend-color" style="background:#22c55e"></div>CHILD</div>
189
+ <div class="legend-item"><div class="legend-color" style="background:#8b5cf6"></div>SAME LAYOUT</div>
190
+ </div>
191
+
192
+ <h3>Data</h3>
193
+ <div class="legend">
194
+ <div class="legend-item"><span class="tag tag-query">QUERY</span> fetch data</div>
195
+ <div class="legend-item"><span class="tag tag-mutation">MUTATION</span> update</div>
196
+ </div>
197
+
198
+ <!-- Frontend Stats -->
199
+ <h3 style="margin-top:16px;font-size:10px;text-transform:uppercase;color:var(--text2);letter-spacing:1px">Frontend</h3>
200
+ <div class="stats" id="stats-container">
201
+ <div class="stat" data-filter="pages"><div class="stat-val">${allPages.length}</div><div class="stat-label">Pages</div></div>
202
+ <div class="stat" data-filter="hierarchies"><div class="stat-val">${relations.filter((r) => r.type === 'parent-child').length}</div><div class="stat-label">Hierarchies</div></div>
203
+ <div class="stat" data-filter="graphql"><div class="stat-val">${this.graphqlOps.length}</div><div class="stat-label">GraphQL</div></div>
204
+ <div class="stat" data-filter="restapi"><div class="stat-val">${this.apiCalls.length}</div><div class="stat-label">REST API</div></div>
205
+ </div>
206
+
207
+ ${hasRails && railsAnalysis
208
+ ? `
209
+ <!-- Rails Stats -->
210
+ <h3 style="margin-top:16px;font-size:10px;text-transform:uppercase;color:var(--text2);letter-spacing:1px;cursor:pointer" onclick="switchToRailsTab()">Rails Backend</h3>
211
+ <div class="stats" id="rails-stats">
212
+ <div class="stat" data-filter="rails-routes" onclick="switchToRailsTab()"><div class="stat-val">${railsAnalysis.summary.totalRoutes}</div><div class="stat-label">Routes</div></div>
213
+ <div class="stat" data-filter="rails-controllers" onclick="showRailsControllers(); this.blur();"><div class="stat-val">${railsAnalysis.summary.totalControllers}</div><div class="stat-label">Controllers</div></div>
214
+ <div class="stat" data-filter="rails-models" onclick="showRailsModels(); this.blur();"><div class="stat-val">${railsAnalysis.summary.totalModels}</div><div class="stat-label">Models</div></div>
215
+ <div class="stat" data-filter="rails-grpc" onclick="showRailsGrpc(); this.blur();"><div class="stat-val">${railsAnalysis.summary.totalGrpcServices}</div><div class="stat-label">gRPC</div></div>
216
+ <div class="stat" data-filter="rails-react" onclick="showReactComponents(); this.blur();"><div class="stat-val">${railsAnalysis.summary.totalReactComponents}</div><div class="stat-label">⚛ React</div></div>
217
+ </div>
218
+ `
219
+ : ''}
220
+ </aside>
221
+
222
+ <div class="content">
223
+ <!-- Pages Tree View (for all screens - Next.js/React/Rails) -->
224
+ <div class="tree-view ${activeTab === 'pages' ? 'active' : ''}" id="tree-view" data-tab="pages">
225
+ ${allPages.length > 0 ? this.buildTreeHtml(groups, allPages) : ''}
226
+ <div id="page-map-react-components-section" style="${hasRails ? 'margin-top:20px;border-top:1px solid var(--bg3);padding-top:20px' : ''}">
227
+ </div>
228
+ <div id="page-map-rails-section" style="${allPages.length > 0 && hasRails ? 'margin-top:20px;border-top:1px solid var(--bg3);padding-top:20px' : ''}">
229
+ ${hasRails && allPages.length === 0 ? '<div style="padding:20px;color:var(--text2)">Loading screens...</div>' : ''}
230
+ </div>
231
+ </div>
232
+
233
+ <!-- Rails Routes View (dedicated) -->
234
+ <div class="tree-view ${activeTab === 'rails' ? 'active' : ''}" id="rails-tree-view" data-tab="rails">
235
+ <div id="rails-routes-container">
236
+ ${hasRails ? '<div style="padding:20px;color:var(--text2)">Loading Rails routes...</div>' : '<div style="padding:40px;text-align:center;color:var(--text2)">No Rails environment detected</div>'}
237
+ </div>
238
+ </div>
239
+
240
+ <div class="graph-view" id="graph-view">
241
+ <div class="graph-container">
242
+ <div class="graph-controls">
243
+ <button class="graph-btn" onclick="resetGraph()">Reset</button>
244
+ <button class="graph-btn" onclick="zoomGraph(1.3)">+</button>
245
+ <button class="graph-btn" onclick="zoomGraph(0.7)">-</button>
246
+ </div>
247
+ <div class="graph-info">Drag to pan, scroll to zoom, click node to select</div>
248
+ <canvas id="graph-canvas"></canvas>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </div>
253
+
254
+ <div class="detail" id="detail">
255
+ <div class="detail-header">
256
+ <div class="detail-title" id="detail-title"></div>
257
+ <button class="detail-close" onclick="closeDetail()">×</button>
258
+ </div>
259
+ <div class="detail-body" id="detail-body"></div>
260
+ </div>
261
+
262
+ <div class="modal" id="modal" onclick="if(event.target===this)handleModalOutsideClick()">
263
+ <div class="modal-box">
264
+ <div class="modal-head">
265
+ <div style="display:flex;align-items:center;gap:8px">
266
+ <button id="modal-back" class="modal-back" onclick="modalBack()" style="display:none">←</button>
267
+ <h3 id="modal-title"></h3>
268
+ </div>
269
+ <button class="modal-close" onclick="closeModal()">×</button>
270
+ </div>
271
+ <div class="modal-body" id="modal-body"></div>
272
+ </div>
273
+ </div>
274
+
275
+ <script>
276
+ // Environment detection results
277
+ const envInfo = {
278
+ hasRails: ${hasRails},
279
+ hasNextjs: ${hasNextjs},
280
+ hasReact: ${hasReact}
281
+ };
282
+
283
+ // Frontend data
284
+ const pages = ${JSON.stringify(allPages)};
285
+ const relations = ${JSON.stringify(relations)};
286
+ const graphqlOps = ${graphqlOpsJson};
287
+ const components = ${componentsJson};
288
+ const apiCallsData = ${JSON.stringify(this.apiCalls)};
289
+ window.apiCalls = apiCallsData;
290
+ const pageMap = new Map(pages.map(p => [p.path, p]));
291
+ const gqlMap = new Map(graphqlOps.map(op => [op.name, op]));
292
+ const compMap = new Map(components.map(c => [c.name, c]));
293
+
294
+ // Rails data (if available)
295
+ const railsRoutes = ${railsRoutesJson};
296
+ const railsControllers = ${railsControllersJson};
297
+ const railsModels = ${railsModelsJson};
298
+ const railsViews = ${railsViewsJson};
299
+ const railsReact = ${railsReactJson};
300
+ const railsGrpc = ${railsGrpcJson};
301
+ const railsSummary = ${railsSummaryJson};
302
+
303
+ // Current active tab state
304
+ let currentMainTab = '${activeTab}';
305
+
306
+ // Modal history stack for back navigation
307
+ const modalHistory = [];
308
+
309
+ // Current environment filter
310
+ let currentEnvFilter = 'all';
311
+
312
+ function setView(v) {
313
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
314
+ event.target.classList.add('active');
315
+
316
+ // Hide all tree views
317
+ document.querySelectorAll('.tree-view').forEach(el => el.classList.remove('active'));
318
+ document.getElementById('graph-view').classList.remove('active');
319
+
320
+ if (v === 'tree') {
321
+ // Show appropriate tree view based on current main tab
322
+ if (currentMainTab === 'rails') {
323
+ document.getElementById('rails-tree-view').classList.add('active');
324
+ } else {
325
+ document.getElementById('tree-view').classList.add('active');
326
+ }
327
+ } else if (v === 'graph') {
328
+ document.getElementById('graph-view').classList.add('active');
329
+ setTimeout(initGraph, 100);
330
+ }
331
+ }
332
+
333
+ // Environment filtering
334
+ function filterByEnv(env) {
335
+ currentEnvFilter = env;
336
+
337
+ // Update badge styles
338
+ document.querySelectorAll('.env-badge').forEach(b => {
339
+ b.classList.remove('active', 'env-badge-active');
340
+ if (b.dataset.env === env) {
341
+ b.classList.add('active', 'env-badge-active');
342
+ }
343
+ });
344
+
345
+ // Apply filter to page list
346
+ applyEnvFilter();
347
+ }
348
+
349
+ function applyEnvFilter() {
350
+ // For now, this affects visibility of stats sections
351
+ const frontendStats = document.getElementById('stats-container');
352
+ const railsStats = document.getElementById('rails-stats');
353
+
354
+ if (currentEnvFilter === 'all') {
355
+ if (frontendStats) frontendStats.style.display = '';
356
+ if (railsStats) railsStats.style.display = '';
357
+ } else if (currentEnvFilter === 'nextjs') {
358
+ if (frontendStats) frontendStats.style.display = '';
359
+ if (railsStats) railsStats.style.display = 'none';
360
+ } else if (currentEnvFilter === 'rails') {
361
+ if (frontendStats) frontendStats.style.display = 'none';
362
+ if (railsStats) railsStats.style.display = '';
363
+ }
364
+ }
365
+
366
+ // Switch to Rails tab and render routes tree
367
+ function switchToRailsTab() {
368
+ currentMainTab = 'rails';
369
+ // Hide all tree views
370
+ document.querySelectorAll('.tree-view').forEach(el => el.classList.remove('active'));
371
+ // Show Rails tree view
372
+ const railsTreeView = document.getElementById('rails-tree-view');
373
+ if (railsTreeView) {
374
+ railsTreeView.classList.add('active');
375
+ }
376
+ // Hide graph view
377
+ document.getElementById('graph-view')?.classList.remove('active');
378
+ // Ensure list view mode
379
+ setView('tree');
380
+ // Render Rails routes tree
381
+ renderRailsRoutesTree();
382
+ }
383
+
384
+ // Rails related functions
385
+ function showRailsRoutes() {
386
+ if (!railsRoutes || railsRoutes.length === 0) {
387
+ showModal('Rails Routes', '<div style="color:var(--text2)">No routes found</div>');
388
+ return;
389
+ }
390
+
391
+ const routesByNamespace = new Map();
392
+ railsRoutes.forEach(r => {
393
+ const ns = r.namespace || 'root';
394
+ if (!routesByNamespace.has(ns)) routesByNamespace.set(ns, []);
395
+ routesByNamespace.get(ns).push(r);
396
+ });
397
+
398
+ let html = '<div style="max-height:60vh;overflow-y:auto">';
399
+ for (const [ns, routes] of routesByNamespace) {
400
+ html += '<div style="margin-bottom:16px">';
401
+ html += '<div style="font-weight:600;margin-bottom:8px;color:var(--accent)">📂 ' + ns + ' (' + routes.length + ')</div>';
402
+ html += '<table style="width:100%;border-collapse:collapse;font-size:12px">';
403
+ html += '<tr style="background:var(--bg3)"><th style="padding:6px;text-align:left">Method</th><th style="padding:6px;text-align:left">Path</th><th style="padding:6px;text-align:left">Controller#Action</th></tr>';
404
+ routes.slice(0, 20).forEach(r => {
405
+ const methodColor = {GET:'#22c55e',POST:'#3b82f6',PUT:'#f59e0b',PATCH:'#f59e0b',DELETE:'#ef4444'}[r.method] || '#888';
406
+ html += '<tr style="border-bottom:1px solid var(--border)">';
407
+ html += '<td style="padding:6px"><span style="background:' + methodColor + ';color:white;padding:2px 6px;border-radius:3px;font-size:10px">' + r.method + '</span></td>';
408
+ html += '<td style="padding:6px;font-family:monospace">' + r.path.replace(/:([a-z_]+)/g, '<span style="color:#f59e0b">:$1</span>') + '</td>';
409
+ html += '<td style="padding:6px;color:var(--text2)">' + r.controller + '#' + r.action + '</td>';
410
+ html += '</tr>';
411
+ });
412
+ if (routes.length > 20) {
413
+ html += '<tr><td colspan="3" style="padding:6px;color:var(--text2)">...and ' + (routes.length - 20) + ' more</td></tr>';
414
+ }
415
+ html += '</table></div>';
416
+ }
417
+ html += '</div>';
418
+
419
+ showModal('🛤️ Rails Routes (' + railsRoutes.length + ')', html);
420
+ }
421
+
422
+ function showRailsControllers() {
423
+ if (!railsControllers || railsControllers.length === 0) {
424
+ showModal('Rails Controllers', '<div style="color:var(--text2)">No controllers found</div>');
425
+ return;
426
+ }
427
+
428
+ let html = '<div style="max-height:60vh;overflow-y:auto">';
429
+ railsControllers.forEach(ctrl => {
430
+ html += '<div style="background:var(--bg3);padding:12px;border-radius:6px;margin-bottom:8px">';
431
+ html += '<div style="font-weight:600;margin-bottom:4px">' + ctrl.className + '</div>';
432
+ html += '<div style="font-size:11px;color:var(--text2);margin-bottom:8px">extends ' + ctrl.parentClass + '</div>';
433
+ if (ctrl.actions && ctrl.actions.length > 0) {
434
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
435
+ ctrl.actions.slice(0, 10).forEach(action => {
436
+ const color = action.visibility === 'public' ? '#22c55e' : action.visibility === 'private' ? '#ef4444' : '#f59e0b';
437
+ html += '<span style="background:rgba(255,255,255,0.1);padding:2px 8px;border-radius:4px;font-size:11px;border-left:2px solid ' + color + '">' + action.name + '</span>';
438
+ });
439
+ if (ctrl.actions.length > 10) html += '<span style="color:var(--text2);font-size:11px">+' + (ctrl.actions.length - 10) + ' more</span>';
440
+ html += '</div>';
441
+ }
442
+ html += '</div>';
443
+ });
444
+ html += '</div>';
445
+
446
+ showModal('🎮 Rails Controllers (' + railsControllers.length + ')', html);
447
+ }
448
+
449
+ function showRailsModels() {
450
+ if (!railsModels || railsModels.length === 0) {
451
+ showModal('Rails Models', '<div style="color:var(--text2)">No models found</div>');
452
+ return;
453
+ }
454
+
455
+ let html = '<div style="max-height:60vh;overflow-y:auto">';
456
+ railsModels.forEach(model => {
457
+ html += '<div style="background:var(--bg3);padding:12px;border-radius:6px;margin-bottom:8px">';
458
+ html += '<div style="font-weight:600;margin-bottom:4px">📦 ' + model.className + '</div>';
459
+ html += '<div style="display:flex;gap:16px;font-size:11px;color:var(--text2);margin-bottom:8px">';
460
+ html += '<span>📎 ' + (model.associations?.length || 0) + ' associations</span>';
461
+ html += '<span>✓ ' + (model.validations?.length || 0) + ' validations</span>';
462
+ html += '</div>';
463
+ if (model.associations && model.associations.length > 0) {
464
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
465
+ model.associations.slice(0, 8).forEach(assoc => {
466
+ const typeColor = {belongs_to:'#3b82f6',has_many:'#22c55e',has_one:'#f59e0b'}[assoc.type] || '#888';
467
+ html += '<span style="background:rgba(255,255,255,0.1);padding:2px 8px;border-radius:4px;font-size:10px"><span style="color:' + typeColor + '">' + assoc.type + '</span> :' + assoc.name + '</span>';
468
+ });
469
+ if (model.associations.length > 8) html += '<span style="color:var(--text2);font-size:10px">+' + (model.associations.length - 8) + '</span>';
470
+ html += '</div>';
471
+ }
472
+ html += '</div>';
473
+ });
474
+ html += '</div>';
475
+
476
+ showModal('📦 Rails Models (' + railsModels.length + ')', html);
477
+ }
478
+
479
+ function showReactComponents() {
480
+ if (!railsReact || !railsReact.components || railsReact.components.length === 0) {
481
+ showModal('React Components', '<div style="color:var(--text2)">No React components found</div>');
482
+ return;
483
+ }
484
+
485
+ // Sort by usage count
486
+ const sortedComponents = [...railsReact.components].sort((a, b) =>
487
+ (b.usedIn?.length || 0) - (a.usedIn?.length || 0)
488
+ );
489
+
490
+ let html = '<div style="max-height:60vh;overflow-y:auto">';
491
+
492
+ // Stats
493
+ html += '<div style="display:flex;gap:16px;margin-bottom:16px;padding:12px;background:var(--bg3);border-radius:8px">';
494
+ html += '<div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:var(--accent)">' + railsReact.summary.totalComponents + '</div><div style="font-size:10px;color:var(--text2)">Components</div></div>';
495
+ html += '<div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#22c55e">' + railsReact.summary.ssrComponents + '</div><div style="font-size:10px;color:var(--text2)">SSR</div></div>';
496
+ html += '<div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#3b82f6">' + railsReact.summary.clientComponents + '</div><div style="font-size:10px;color:var(--text2)">Client</div></div>';
497
+ html += '<div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#f59e0b">' + railsReact.summary.totalEntryPoints + '</div><div style="font-size:10px;color:var(--text2)">Entry Points</div></div>';
498
+ html += '</div>';
499
+
500
+ sortedComponents.forEach(comp => {
501
+ const usageCount = comp.usedIn?.length || 0;
502
+ const ssrBadge = comp.ssr ? '<span style="margin-left:6px;font-size:9px;background:#22c55e;color:white;padding:1px 4px;border-radius:2px">SSR</span>' : '';
503
+
504
+ html += '<div style="background:var(--bg3);padding:12px;border-radius:6px;margin-bottom:8px;cursor:pointer" onclick="showReactComponentDetail(\\'' + encodeURIComponent(JSON.stringify(comp)) + '\\')">';
505
+ html += '<div style="display:flex;align-items:center;justify-content:space-between">';
506
+ html += '<div style="font-weight:600;display:flex;align-items:center"><span style="color:#61dafb;margin-right:6px">⚛</span>' + comp.name + ssrBadge + '</div>';
507
+ html += '<span style="font-size:11px;color:var(--text2)">' + usageCount + ' usage' + (usageCount !== 1 ? 's' : '') + '</span>';
508
+ html += '</div>';
509
+
510
+ // Entry point info
511
+ if (comp.entryFile) {
512
+ html += '<div style="font-size:10px;color:var(--text2);margin-top:4px;font-family:monospace">📥 entries/' + comp.entryFile + '</div>';
513
+ }
514
+
515
+ // Source file info
516
+ if (comp.sourceFile) {
517
+ html += '<div style="font-size:10px;color:var(--accent);margin-top:2px;font-family:monospace">📄 ' + comp.sourceFile + '</div>';
518
+ }
519
+
520
+ // Usage preview
521
+ if (comp.usedIn && comp.usedIn.length > 0) {
522
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px">';
523
+ comp.usedIn.slice(0, 3).forEach(usage => {
524
+ const patternColor = usage.pattern === 'render_react_component' ? '#22c55e' : '#3b82f6';
525
+ html += '<span style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:3px;font-size:10px;border-left:2px solid ' + patternColor + '">' + usage.controller + '/' + usage.action + '</span>';
526
+ });
527
+ if (comp.usedIn.length > 3) {
528
+ html += '<span style="font-size:10px;color:var(--text2)">+' + (comp.usedIn.length - 3) + ' more</span>';
529
+ }
530
+ html += '</div>';
531
+ }
532
+
533
+ html += '</div>';
534
+ });
535
+
536
+ html += '</div>';
537
+ showModal('⚛ React Components (' + railsReact.components.length + ')', html);
538
+ }
539
+
540
+ function showReactComponentDetail(encodedData) {
541
+ const comp = JSON.parse(decodeURIComponent(encodedData));
542
+
543
+ let html = '';
544
+
545
+ // Component Info
546
+ html += '<div class="detail-section">';
547
+ html += '<div class="detail-label">⚛ Component Name</div>';
548
+ html += '<div style="display:flex;align-items:center;gap:8px">';
549
+ html += '<span style="font-family:monospace;font-size:16px;font-weight:600">' + comp.name + '</span>';
550
+ if (comp.ssr) {
551
+ html += '<span style="font-size:10px;background:#22c55e;color:white;padding:2px 6px;border-radius:3px">SSR</span>';
552
+ } else {
553
+ html += '<span style="font-size:10px;background:#3b82f6;color:white;padding:2px 6px;border-radius:3px">Client</span>';
554
+ }
555
+ html += '</div></div>';
556
+
557
+ // Entry Point
558
+ if (comp.entryFile) {
559
+ html += '<div class="detail-section">';
560
+ html += '<div class="detail-label">📥 Entry Point</div>';
561
+ html += '<div class="code-path">';
562
+ html += comp.entryFile;
563
+ html += '</div></div>';
564
+ }
565
+
566
+ // Source File
567
+ if (comp.sourceFile || comp.importPath) {
568
+ html += '<div class="detail-section">';
569
+ html += '<div class="detail-label">📄 Source File</div>';
570
+ html += '<div class="code-path" style="color:var(--accent)">';
571
+ html += comp.sourceFile || comp.importPath;
572
+ html += '</div></div>';
573
+ }
574
+
575
+ // Usage in Views
576
+ if (comp.usedIn && comp.usedIn.length > 0) {
577
+ html += '<div class="detail-section">';
578
+ html += '<div class="detail-label">📍 Used in Views (' + comp.usedIn.length + ')</div>';
579
+ html += '<div class="detail-items">';
580
+
581
+ comp.usedIn.forEach(usage => {
582
+ const patternColor = usage.pattern === 'render_react_component' ? '#22c55e' : '#3b82f6';
583
+ const patternLabel = usage.pattern === 'render_react_component' ? 'render' : 'data';
584
+
585
+ html += '<div class="detail-item" style="flex-direction:column;align-items:flex-start;gap:4px">';
586
+ html += '<div style="display:flex;align-items:center;gap:8px;width:100%">';
587
+ html += '<span class="tag" style="background:' + patternColor + ';font-size:9px;flex-shrink:0">' + patternLabel + '</span>';
588
+ html += '<span class="usage-name">' + usage.controller + '#' + usage.action + '</span>';
589
+ if (usage.line) {
590
+ html += '<span class="line-num">L' + usage.line + '</span>';
591
+ }
592
+ html += '</div>';
593
+ html += '<div style="font-size:10px;color:var(--text2);font-family:monospace">app/views/' + usage.viewPath + '</div>';
594
+ if (usage.propsVar) {
595
+ html += '<div style="font-size:10px;color:var(--accent)">props: ' + usage.propsVar + '</div>';
596
+ }
597
+ html += '</div>';
598
+ });
599
+
600
+ html += '</div></div>';
601
+ }
602
+
603
+ showModal('⚛ ' + comp.name, html, true);
604
+ }
605
+
606
+ function showRailsGrpc() {
607
+ if (!railsGrpc || !railsGrpc.services || railsGrpc.services.length === 0) {
608
+ showModal('gRPC Services', '<div style="color:var(--text2)">No gRPC services found</div>');
609
+ return;
610
+ }
611
+
612
+ let html = '<div style="max-height:60vh;overflow-y:auto">';
613
+ railsGrpc.services.forEach(svc => {
614
+ html += '<div style="background:var(--bg3);padding:12px;border-radius:6px;margin-bottom:8px">';
615
+ html += '<div style="font-weight:600;margin-bottom:4px">🔌 ' + svc.className + '</div>';
616
+ if (svc.namespace) {
617
+ html += '<div style="font-size:11px;color:var(--text2);margin-bottom:8px">namespace: ' + svc.namespace + '</div>';
618
+ }
619
+ if (svc.rpcs && svc.rpcs.length > 0) {
620
+ html += '<div style="display:flex;flex-wrap:wrap;gap:4px">';
621
+ svc.rpcs.slice(0, 15).forEach(rpc => {
622
+ html += '<span class="tag" style="background:var(--accent);font-size:10px">' + rpc.name + '</span>';
623
+ });
624
+ if (svc.rpcs.length > 15) html += '<span style="color:var(--text2);font-size:11px">+' + (svc.rpcs.length - 15) + ' more</span>';
625
+ html += '</div>';
626
+ }
627
+ html += '</div>';
628
+ });
629
+ html += '</div>';
630
+
631
+ showModal('🔌 gRPC Services (' + railsGrpc.services.length + ')', html);
632
+ }
633
+
634
+ // Render Rails routes in tree view
635
+ function renderRailsRoutesTree() {
636
+ const container = document.getElementById('rails-routes-container');
637
+ if (!container) return;
638
+
639
+ // Use pages if available, otherwise fall back to routes
640
+ const pages = (railsViews && railsViews.pages) || [];
641
+ const routes = railsRoutes || [];
642
+
643
+ if (pages.length === 0 && routes.length === 0) {
644
+ container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text2)">No Rails pages or routes found</div>';
645
+ return;
646
+ }
647
+
648
+ // Build combined data from routes with page info
649
+ const combinedData = [];
650
+
651
+ // Map routes to include API info from pages and controller action details
652
+ routes.forEach(route => {
653
+ // Skip redirect routes and complex patterns
654
+ if (route.path.includes('=>') || route.path.includes('redirect{') || route.path.includes('redirect {')) {
655
+ return;
656
+ }
657
+ // Skip routes with unmatched parentheses (malformed patterns)
658
+ if (route.path.includes('(') && !route.path.includes(')')) {
659
+ return;
660
+ }
661
+
662
+ const pageInfo = pages.find(p => p.route === route.path && p.method === route.method);
663
+
664
+ // Find controller and action details from railsControllers
665
+ let actionDetails = null;
666
+ let controllerInfo = null;
667
+ if (railsControllers && railsControllers.length > 0) {
668
+ // Multiple matching strategies for better accuracy
669
+ const routeCtrl = route.controller; // e.g., "api/v1/users" or "users"
670
+ const routeCtrlParts = routeCtrl.split('/');
671
+ const routeCtrlName = routeCtrlParts.pop().replace(/_/g, ''); // "users"
672
+ const routeNamespace = routeCtrlParts.join('/'); // "api/v1" or ""
673
+
674
+ controllerInfo = railsControllers.find(c => {
675
+ // Strategy 1: Match by filePath (most accurate)
676
+ // filePath: "api/v1/users_controller.rb" or "users_controller.rb"
677
+ const filePathNormalized = c.filePath.replace(/_controller\.rb$/, '').replace(/_/g, '');
678
+ if (filePathNormalized === routeCtrl.replace(/_/g, '')) return true;
679
+
680
+ // Strategy 2: Match by controller name (without namespace)
681
+ if (c.name === routeCtrlName || c.name.replace(/_/g, '') === routeCtrlName) return true;
682
+
683
+ // Strategy 3: Match by className
684
+ const className = c.className.toLowerCase().replace('controller', '').replace(/::/g, '/');
685
+ if (className === routeCtrl.toLowerCase() || className.endsWith('/' + routeCtrlName)) return true;
686
+
687
+ // Strategy 4: Partial match as fallback
688
+ const classNameSimple = c.className.toLowerCase().replace('controller', '').split('::').pop();
689
+ return classNameSimple === routeCtrlName.toLowerCase();
690
+ });
691
+
692
+ if (controllerInfo) {
693
+ actionDetails = controllerInfo.actions.find(a => a.name === route.action);
694
+ }
695
+ }
696
+
697
+ combinedData.push({
698
+ ...route,
699
+ hasView: !!pageInfo?.view,
700
+ view: pageInfo?.view,
701
+ services: pageInfo?.services || actionDetails?.servicesCalled || [],
702
+ grpcCalls: pageInfo?.grpcCalls || [],
703
+ modelAccess: pageInfo?.modelAccess || actionDetails?.modelsCalled || [],
704
+ apis: pageInfo?.apis || [],
705
+ // Enhanced controller action details
706
+ actionDetails: actionDetails ? {
707
+ rendersJson: actionDetails.rendersJson,
708
+ rendersHtml: actionDetails.rendersHtml,
709
+ redirectsTo: actionDetails.redirectsTo,
710
+ respondsTo: actionDetails.respondsTo,
711
+ servicesCalled: actionDetails.servicesCalled || [],
712
+ modelsCalled: actionDetails.modelsCalled || [],
713
+ methodCalls: actionDetails.methodCalls || [],
714
+ visibility: actionDetails.visibility,
715
+ line: actionDetails.line
716
+ } : null,
717
+ controllerInfo: controllerInfo ? {
718
+ className: controllerInfo.className,
719
+ filePath: controllerInfo.filePath,
720
+ parentClass: controllerInfo.parentClass,
721
+ beforeActions: controllerInfo.beforeActions || [],
722
+ afterActions: controllerInfo.afterActions || [],
723
+ concerns: controllerInfo.concerns || [],
724
+ line: controllerInfo.line
725
+ } : null
726
+ });
727
+ });
728
+
729
+ // Group by namespace (first path segment)
730
+ const routesByNamespace = new Map();
731
+ combinedData.forEach(r => {
732
+ // Extract clean first segment
733
+ let firstSegment = r.path.split('/').filter(s => s && !s.startsWith(':') && !s.includes('('))[0] || '';
734
+ const ns = r.namespace || (r.path.startsWith('/api/') ? 'api' : firstSegment || 'root');
735
+ if (!routesByNamespace.has(ns)) routesByNamespace.set(ns, []);
736
+ routesByNamespace.get(ns).push(r);
737
+ });
738
+
739
+ // Sort namespaces
740
+ const sortedNamespaces = [...routesByNamespace.keys()].sort((a, b) => {
741
+ if (a === 'root') return -1;
742
+ if (b === 'root') return 1;
743
+ return a.localeCompare(b);
744
+ });
745
+
746
+ let html = '';
747
+ const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
748
+
749
+ // Stats summary - enhanced with response type breakdown
750
+ const totalWithView = combinedData.filter(r => r.hasView).length;
751
+ const totalWithServices = combinedData.filter(r => r.services.length > 0).length;
752
+ const totalWithGrpc = combinedData.filter(r => r.grpcCalls.length > 0).length;
753
+ const totalWithModels = combinedData.filter(r => r.modelAccess.length > 0).length;
754
+ const totalJsonApi = combinedData.filter(r => r.actionDetails?.rendersJson).length;
755
+ const totalHtmlPage = combinedData.filter(r => r.actionDetails?.rendersHtml && !r.actionDetails?.rendersJson).length;
756
+ const totalRedirect = combinedData.filter(r => r.actionDetails?.redirectsTo).length;
757
+ const totalWithActionInfo = combinedData.filter(r => r.actionDetails).length;
758
+
759
+ html += '<div class="route-stats-box">';
760
+ // Main stats row - clickable for filtering
761
+ html += '<div class="route-stats-row">';
762
+ html += '<div class="route-stat" data-filter="all"><div class="route-stat-val">' + combinedData.length + '</div><div class="route-stat-label">Total Routes</div></div>';
763
+ html += '<div class="route-stat" data-filter="views"><div class="route-stat-val green">' + totalWithView + '</div><div class="route-stat-label">With Views</div></div>';
764
+ html += '<div class="route-stat" data-filter="json"><div class="route-stat-val blue">' + totalJsonApi + '</div><div class="route-stat-label">JSON APIs</div></div>';
765
+ html += '<div class="route-stat" data-filter="services"><div class="route-stat-val purple">' + totalWithServices + '</div><div class="route-stat-label">With Services</div></div>';
766
+ html += '<div class="route-stat" data-filter="grpc"><div class="route-stat-val cyan">' + totalWithGrpc + '</div><div class="route-stat-label">gRPC</div></div>';
767
+ html += '</div>';
768
+ // Analysis coverage indicator
769
+ if (totalWithActionInfo > 0) {
770
+ const coverage = Math.round((totalWithActionInfo / combinedData.length) * 100);
771
+ const coverageTooltip = 'Percentage of routes successfully matched with controller actions to extract details (JSON/HTML rendering, redirects, etc). This is a tool analysis metric, not a code quality indicator.';
772
+ const coverageClass = coverage > 70 ? 'coverage-high' : coverage > 40 ? 'coverage-mid' : 'coverage-low';
773
+ html += '<div class="coverage-info">';
774
+ html += '<div class="coverage-text" title="' + coverageTooltip + '">Action Details Coverage: <span class="' + coverageClass + '">' + coverage + '%</span> (' + totalWithActionInfo + '/' + combinedData.length + ' routes analyzed) ℹ️</div>';
775
+ html += '</div>';
776
+ }
777
+ html += '</div>';
778
+
779
+ sortedNamespaces.forEach((ns, idx) => {
780
+ const routes = routesByNamespace.get(ns);
781
+ const color = colors[idx % colors.length];
782
+
783
+ html += '<div class="group">';
784
+ html += '<div class="group-header" onclick="toggleGroup(this)" style="border-left-color:' + color + '">';
785
+ html += '<span class="group-toggle">▼</span>';
786
+ html += '<span class="group-name">📂 ' + ns + '</span>';
787
+ html += '<span class="group-count">' + routes.length + '</span>';
788
+ html += '</div>';
789
+ const routeListId = 'routes-' + ns.replace(/[^a-zA-Z0-9]/g, '-');
790
+ const routeLimit = 50;
791
+ const hasMoreRoutes = routes.length > routeLimit;
792
+
793
+ html += '<div class="group-items" id="' + routeListId + '">';
794
+
795
+ // Sort by path, then by method
796
+ routes.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
797
+
798
+ routes.forEach((route, idx) => {
799
+ const methodColor = {GET:'#22c55e',POST:'#3b82f6',PUT:'#f59e0b',PATCH:'#f59e0b',DELETE:'#ef4444'}[route.method] || '#888';
800
+
801
+ // Clean up path - truncate redirect blocks and long paths
802
+ let displayPath = route.path;
803
+ if (displayPath.includes('=>') || displayPath.includes('redirect')) {
804
+ // Extract just the route pattern before any redirect logic
805
+ const match = displayPath.match(/^([^"]+)"/);
806
+ displayPath = match ? match[1].trim() + ' → redirect' : displayPath.slice(0, 60) + '...';
807
+ }
808
+ if (displayPath.length > 80) {
809
+ displayPath = displayPath.slice(0, 77) + '...';
810
+ }
811
+ const pathHighlighted = displayPath.replace(/:([a-z_]+)/g, '<span style="color:#f59e0b">:$1</span>');
812
+
813
+ // Indicators for view, API, and response types
814
+ let indicators = '';
815
+ const action = route.actionDetails;
816
+ // Response type indicators
817
+ if (action) {
818
+ if (action.rendersJson) indicators += '<span class="route-tag route-tag-json" title="Returns JSON">JSON</span>';
819
+ if (action.rendersHtml && !action.rendersJson) indicators += '<span class="route-tag route-tag-html" title="Returns HTML">HTML</span>';
820
+ if (action.redirectsTo) indicators += '<span class="route-tag route-tag-redirect" title="Redirects">→</span>';
821
+ }
822
+ if (route.hasView) indicators += '<span class="route-tag route-tag-view" title="Has View Template">View</span>';
823
+ if (route.services.length > 0) indicators += '<span class="route-tag route-tag-svc" title="Uses Services: ' + route.services.join(', ') + '">Svc</span>';
824
+ if (route.grpcCalls.length > 0) indicators += '<span class="route-tag route-tag-grpc" title="gRPC Calls: ' + route.grpcCalls.join(', ') + '">gRPC</span>';
825
+ if (route.modelAccess.length > 0) indicators += '<span class="route-tag route-tag-db" title="Model Access: ' + route.modelAccess.join(', ') + '">DB</span>';
826
+
827
+ // Search-friendly data-path and filter attributes
828
+ const searchPath = [route.path || '', route.controller || '', route.action || '', route.method || ''].join(' ').toLowerCase();
829
+ const hiddenAttr = idx >= routeLimit ? ' data-hidden="true"' : '';
830
+ const hiddenStyle = idx >= routeLimit ? 'display:none;' : '';
831
+
832
+ // Filter data attributes
833
+ const filterAttrs = [];
834
+ if (route.hasView) filterAttrs.push('data-has-view="true"');
835
+ if (action && action.rendersJson) filterAttrs.push('data-json="true"');
836
+ if (route.services.length > 0) filterAttrs.push('data-services="true"');
837
+ if (route.grpcCalls.length > 0) filterAttrs.push('data-grpc="true"');
838
+
839
+ html += '<div class="page-item rails-route-item" data-path="' + searchPath + '"' + hiddenAttr + ' ' + filterAttrs.join(' ') + ' onclick="showRailsRouteDetail(\\''+encodeURIComponent(JSON.stringify(route))+'\\', true)" style="cursor:pointer;' + hiddenStyle + '">';
840
+ html += '<span class="page-type" style="background:' + methodColor + ';min-width:50px;text-align:center">' + route.method + '</span>';
841
+ html += '<span class="page-path" style="font-family:monospace;font-size:12px;flex:1">' + pathHighlighted + '</span>';
842
+ html += indicators;
843
+ html += '</div>';
844
+ });
845
+
846
+ html += '</div>';
847
+
848
+ if (hasMoreRoutes) {
849
+ html += '<div id="' + routeListId + '-more" style="padding:8px 12px;cursor:pointer;color:var(--accent);font-size:11px" onclick="toggleMoreItems(\\'' + routeListId + '\\', ' + routes.length + ')">▼ Show ' + (routes.length - routeLimit) + ' more routes</div>';
850
+ }
851
+
852
+ html += '</div>';
853
+ });
854
+
855
+ container.innerHTML = html;
856
+
857
+ // Attach route stat filter click handlers
858
+ container.querySelectorAll('.route-stat').forEach(stat => {
859
+ stat.addEventListener('click', function() {
860
+ const filterType = this.dataset.filter;
861
+ applyRouteFilter(filterType);
862
+
863
+ // Update active state
864
+ container.querySelectorAll('.route-stat').forEach(s => s.style.background = 'transparent');
865
+ if (filterType !== 'all') this.style.background = 'var(--bg2)';
866
+ });
867
+ });
868
+ }
869
+
870
+ // Apply route filter
871
+ let currentRouteFilter = 'all';
872
+ function applyRouteFilter(filterType) {
873
+ currentRouteFilter = filterType;
874
+ const routeItems = document.querySelectorAll('.rails-route-item');
875
+
876
+ routeItems.forEach(item => {
877
+ let shouldShow = true;
878
+
879
+ if (filterType === 'views') {
880
+ shouldShow = item.dataset.hasView === 'true';
881
+ } else if (filterType === 'json') {
882
+ shouldShow = item.dataset.json === 'true';
883
+ } else if (filterType === 'services') {
884
+ shouldShow = item.dataset.services === 'true';
885
+ } else if (filterType === 'grpc') {
886
+ shouldShow = item.dataset.grpc === 'true';
887
+ }
888
+ // 'all' shows everything
889
+
890
+ if (shouldShow) {
891
+ item.style.display = '';
892
+ item.removeAttribute('data-filtered');
893
+ } else {
894
+ item.style.display = 'none';
895
+ item.dataset.filtered = 'true';
896
+ }
897
+ });
898
+
899
+ // Update group visibility (hide empty groups)
900
+ document.querySelectorAll('#rails-routes-container .group').forEach(group => {
901
+ const visibleItems = group.querySelectorAll('.rails-route-item:not([data-filtered="true"])');
902
+ group.style.display = visibleItems.length > 0 ? '' : 'none';
903
+ });
904
+ }
905
+
906
+ // Show Rails route detail
907
+ function showRailsRouteDetail(dataOrPath, isFullData) {
908
+ let route;
909
+ if (isFullData) {
910
+ route = JSON.parse(decodeURIComponent(dataOrPath));
911
+ } else {
912
+ // Legacy support
913
+ route = { path: dataOrPath, method: arguments[1], controller: arguments[2], action: arguments[3], line: arguments[4] };
914
+ }
915
+
916
+ const methodColor = {GET:'#22c55e',POST:'#3b82f6',PUT:'#f59e0b',PATCH:'#f59e0b',DELETE:'#ef4444'}[route.method] || '#888';
917
+ const action = route.actionDetails;
918
+ const ctrl = route.controllerInfo;
919
+
920
+ let html = '<div class="detail-section">';
921
+ html += '<div class="detail-label">Method</div>';
922
+ html += '<div class="detail-value"><span style="background:' + methodColor + ';color:white;padding:4px 12px;border-radius:4px;font-weight:600">' + route.method + '</span></div>';
923
+ html += '</div>';
924
+
925
+ html += '<div class="detail-section">';
926
+ html += '<div class="detail-label">Path</div>';
927
+ html += '<div class="detail-value" style="font-family:monospace">' + route.path.replace(/:([a-z_]+)/g, '<span style="color:#f59e0b">:$1</span>') + '</div>';
928
+ html += '</div>';
929
+
930
+ html += '<div class="detail-section">';
931
+ html += '<div class="detail-label">Controller#Action</div>';
932
+ html += '<div class="detail-value">' + route.controller + '#' + route.action + '</div>';
933
+ html += '</div>';
934
+
935
+ // Response Type - NEW
936
+ if (action) {
937
+ html += '<div class="detail-section">';
938
+ html += '<div class="detail-label">📡 Response Type</div>';
939
+ html += '<div class="detail-value">';
940
+ const responseTypes = [];
941
+ if (action.rendersJson) responseTypes.push('<span style="background:#3b82f6;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">JSON</span>');
942
+ if (action.rendersHtml) responseTypes.push('<span style="background:#22c55e;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">HTML</span>');
943
+ if (action.redirectsTo) responseTypes.push('<span style="background:#f59e0b;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">Redirect</span>');
944
+ if (action.respondsTo && action.respondsTo.length > 0) {
945
+ action.respondsTo.forEach(f => {
946
+ responseTypes.push('<span style="background:#8b5cf6;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">' + f.toUpperCase() + '</span>');
947
+ });
948
+ }
949
+ html += responseTypes.length > 0 ? responseTypes.join('') : '<span style="color:var(--text2)">Unknown</span>';
950
+ html += '</div></div>';
951
+
952
+ // Redirect destination if exists
953
+ if (action.redirectsTo) {
954
+ html += '<div class="detail-section">';
955
+ html += '<div class="detail-label">↪️ Redirects To</div>';
956
+ html += '<div class="detail-value" style="font-family:monospace;font-size:12px;background:var(--bg3);padding:8px;border-radius:4px">' + action.redirectsTo + '</div>';
957
+ html += '</div>';
958
+ }
959
+ }
960
+
961
+ // View info
962
+ if (route.hasView && route.view) {
963
+ html += '<div class="detail-section">';
964
+ html += '<div class="detail-label">📄 View Template</div>';
965
+ html += '<div class="detail-value" style="font-family:monospace;font-size:12px">app/views/' + route.view.path + '</div>';
966
+ if (route.view.partials && route.view.partials.length > 0) {
967
+ html += '<div style="margin-top:6px;font-size:11px;color:var(--text2)">Partials: ' + route.view.partials.slice(0, 5).join(', ') + (route.view.partials.length > 5 ? '...' : '') + '</div>';
968
+ }
969
+ if (route.view.instanceVars && route.view.instanceVars.length > 0) {
970
+ html += '<div style="margin-top:4px;font-size:11px;color:var(--text2)">Instance vars: @' + route.view.instanceVars.slice(0, 5).join(', @') + (route.view.instanceVars.length > 5 ? '...' : '') + '</div>';
971
+ }
972
+ html += '</div>';
973
+ }
974
+
975
+ // Before/After Filters - NEW
976
+ if (ctrl && (ctrl.beforeActions.length > 0 || ctrl.afterActions.length > 0)) {
977
+ html += '<div class="detail-section">';
978
+ html += '<div class="detail-label">🔒 Filters Applied to This Action</div>';
979
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px">';
980
+
981
+ // Filter before_actions that apply to this action
982
+ const applicableBeforeFilters = ctrl.beforeActions.filter(f => {
983
+ if (f.only && f.only.length > 0) return f.only.includes(route.action);
984
+ if (f.except && f.except.length > 0) return !f.except.includes(route.action);
985
+ return true;
986
+ });
987
+
988
+ if (applicableBeforeFilters.length > 0) {
989
+ html += '<div style="font-size:11px;margin-bottom:6px"><span style="color:#22c55e;font-weight:600">Before:</span></div>';
990
+ html += '<div class="detail-items" style="margin-left:8px">';
991
+ applicableBeforeFilters.forEach(f => {
992
+ let filterInfo = '<span class="tag" style="background:#22c55e;font-size:10px">before</span><span class="name">' + f.name + '</span>';
993
+ if (f.if) filterInfo += '<span style="font-size:10px;color:var(--text2);margin-left:4px">if: ' + f.if + '</span>';
994
+ if (f.unless) filterInfo += '<span style="font-size:10px;color:var(--text2);margin-left:4px">unless: ' + f.unless + '</span>';
995
+ html += '<div class="detail-item">' + filterInfo + '</div>';
996
+ });
997
+ html += '</div>';
998
+ }
999
+
1000
+ const applicableAfterFilters = ctrl.afterActions.filter(f => {
1001
+ if (f.only && f.only.length > 0) return f.only.includes(route.action);
1002
+ if (f.except && f.except.length > 0) return !f.except.includes(route.action);
1003
+ return true;
1004
+ });
1005
+
1006
+ if (applicableAfterFilters.length > 0) {
1007
+ html += '<div style="font-size:11px;margin-top:8px;margin-bottom:6px"><span style="color:#f59e0b;font-weight:600">After:</span></div>';
1008
+ html += '<div class="detail-items" style="margin-left:8px">';
1009
+ applicableAfterFilters.forEach(f => {
1010
+ let filterInfo = '<span class="tag" style="background:#f59e0b;font-size:10px">after</span><span class="name">' + f.name + '</span>';
1011
+ html += '<div class="detail-item">' + filterInfo + '</div>';
1012
+ });
1013
+ html += '</div>';
1014
+ }
1015
+
1016
+ if (applicableBeforeFilters.length === 0 && applicableAfterFilters.length === 0) {
1017
+ html += '<div style="font-size:11px;color:var(--text2)">No filters applied to this action</div>';
1018
+ }
1019
+ html += '</div></div>';
1020
+ }
1021
+
1022
+ // Services Used - Enhanced
1023
+ const services = route.services && route.services.length > 0 ? route.services : (action?.servicesCalled || []);
1024
+ if (services.length > 0) {
1025
+ html += '<div class="detail-section">';
1026
+ html += '<div class="detail-label">⚙️ Services Called</div>';
1027
+ html += '<div class="detail-items">';
1028
+ services.forEach(s => {
1029
+ html += '<div class="detail-item"><span class="tag" style="background:#8b5cf6">Service</span><span class="name" style="font-family:monospace">' + s + '</span></div>';
1030
+ });
1031
+ html += '</div></div>';
1032
+ }
1033
+
1034
+ // gRPC Calls
1035
+ if (route.grpcCalls && route.grpcCalls.length > 0) {
1036
+ html += '<div class="detail-section">';
1037
+ html += '<div class="detail-label">🔌 gRPC Calls</div>';
1038
+ html += '<div class="detail-items">';
1039
+ route.grpcCalls.forEach(g => {
1040
+ html += '<div class="detail-item"><span class="tag" style="background:#06b6d4">gRPC</span><span class="name" style="font-family:monospace">' + g + '</span></div>';
1041
+ });
1042
+ html += '</div></div>';
1043
+ }
1044
+
1045
+ // Model Access - Enhanced
1046
+ const models = route.modelAccess && route.modelAccess.length > 0 ? route.modelAccess : (action?.modelsCalled || []);
1047
+ if (models.length > 0) {
1048
+ html += '<div class="detail-section">';
1049
+ html += '<div class="detail-label">💾 Models Accessed</div>';
1050
+ html += '<div class="detail-items">';
1051
+ models.forEach(m => {
1052
+ html += '<div class="detail-item"><span class="tag" style="background:#f59e0b">Model</span><span class="name" style="font-family:monospace">' + m + '</span></div>';
1053
+ });
1054
+ html += '</div></div>';
1055
+ }
1056
+
1057
+ // Method Calls Chain - NEW
1058
+ if (action && action.methodCalls && action.methodCalls.length > 0) {
1059
+ // Filter out common Rails internals and show meaningful calls
1060
+ const meaningfulCalls = action.methodCalls.filter(c => {
1061
+ const skip = ['params', 'respond_to', 'render', 'redirect_to', 'head', 'flash', 'session', 'cookies'];
1062
+ return !skip.some(s => c.startsWith(s + '.') || c === s);
1063
+ }).slice(0, 15);
1064
+
1065
+ if (meaningfulCalls.length > 0) {
1066
+ html += '<div class="detail-section">';
1067
+ html += '<div class="detail-label">🔗 Method Calls in Action</div>';
1068
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px;max-height:150px;overflow-y:auto">';
1069
+ html += '<div style="font-family:monospace;font-size:11px;line-height:1.6">';
1070
+ meaningfulCalls.forEach((call, i) => {
1071
+ html += '<div style="padding:2px 0;border-bottom:1px solid var(--bg1)">';
1072
+ html += '<span style="color:var(--text2);margin-right:8px">' + (i+1) + '.</span>';
1073
+ html += '<span style="color:var(--accent)">' + call + '</span>';
1074
+ html += '</div>';
1075
+ });
1076
+ if (action.methodCalls.length > 15) {
1077
+ html += '<div style="padding:4px 0;color:var(--text2);font-style:italic">...and ' + (action.methodCalls.length - 15) + ' more calls</div>';
1078
+ }
1079
+ html += '</div></div></div>';
1080
+ }
1081
+ }
1082
+
1083
+ // Source Files - NEW
1084
+ html += '<div class="detail-section">';
1085
+ html += '<div class="detail-label">📁 Source Files</div>';
1086
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px;font-family:monospace;font-size:11px">';
1087
+
1088
+ if (route.line > 0) {
1089
+ html += '<div style="padding:4px 0;display:flex;align-items:center">';
1090
+ html += '<span style="color:var(--text2);width:80px">Route:</span>';
1091
+ html += '<span>config/routes.rb:<span style="color:#22c55e">' + route.line + '</span></span>';
1092
+ html += '</div>';
1093
+ }
1094
+
1095
+ if (ctrl) {
1096
+ html += '<div style="padding:4px 0;display:flex;align-items:center">';
1097
+ html += '<span style="color:var(--text2);width:80px">Controller:</span>';
1098
+ html += '<span>app/controllers/' + ctrl.filePath;
1099
+ if (action && action.line) html += ':<span style="color:#22c55e">' + action.line + '</span>';
1100
+ html += '</span></div>';
1101
+ }
1102
+
1103
+ if (route.hasView && route.view) {
1104
+ html += '<div style="padding:4px 0;display:flex;align-items:center">';
1105
+ html += '<span style="color:var(--text2);width:80px">View:</span>';
1106
+ html += '<span>app/views/' + route.view.path + '</span>';
1107
+ html += '</div>';
1108
+ }
1109
+ html += '</div></div>';
1110
+
1111
+ // Controller Info Summary
1112
+ if (ctrl) {
1113
+ html += '<div class="detail-section">';
1114
+ html += '<div class="detail-label">📋 Controller Info</div>';
1115
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px">';
1116
+ html += '<div style="font-weight:600;margin-bottom:4px">' + ctrl.className + '</div>';
1117
+ html += '<div style="font-size:11px;color:var(--text2)">extends ' + ctrl.parentClass + '</div>';
1118
+ if (ctrl.concerns && ctrl.concerns.length > 0) {
1119
+ html += '<div style="margin-top:6px;font-size:11px">';
1120
+ html += '<span style="color:var(--text2)">Concerns:</span> ' + ctrl.concerns.join(', ');
1121
+ html += '</div>';
1122
+ }
1123
+ html += '</div></div>';
1124
+ }
1125
+
1126
+ showModal(route.method + ' ' + route.path, html);
1127
+ }
1128
+
1129
+ // Initialize Rails view on page load
1130
+ if (currentMainTab === 'rails') {
1131
+ setTimeout(renderRailsRoutesTree, 100);
1132
+ }
1133
+
1134
+ // For page-map: show Rails views/pages when no Next.js pages
1135
+ if (currentMainTab === 'pages' && envInfo.hasRails) {
1136
+ setTimeout(renderRailsPagesInPageMap, 100);
1137
+ setTimeout(renderReactComponentsInPageMap, 150);
1138
+ }
1139
+
1140
+ // Render React components used in Rails views as a list
1141
+ function renderReactComponentsInPageMap() {
1142
+ const container = document.getElementById('page-map-react-components-section');
1143
+ if (!container) return;
1144
+
1145
+ const components = (railsReact && railsReact.components) || [];
1146
+ if (components.length === 0) {
1147
+ container.innerHTML = '';
1148
+ return;
1149
+ }
1150
+
1151
+ // Sort by usage count
1152
+ const sortedComponents = [...components].sort((a, b) =>
1153
+ (b.usedIn?.length || 0) - (a.usedIn?.length || 0)
1154
+ );
1155
+
1156
+ const ssrCount = components.filter(c => c.ssr).length;
1157
+ const withUsageCount = components.filter(c => c.usedIn && c.usedIn.length > 0).length;
1158
+
1159
+ let html = '';
1160
+ html += '<div style="padding:12px;background:var(--bg3);border-radius:8px;margin-bottom:12px">';
1161
+ html += '<div style="font-weight:600;margin-bottom:8px;display:flex;align-items:center;gap:8px"><span style="color:#61dafb">⚛</span> React Components (from Rails)</div>';
1162
+ html += '<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--text2)">';
1163
+ html += '<span>' + components.length + ' components</span>';
1164
+ html += '<span>•</span>';
1165
+ html += '<span style="color:#22c55e">' + ssrCount + ' SSR</span>';
1166
+ html += '<span>•</span>';
1167
+ html += '<span style="color:#3b82f6">' + (components.length - ssrCount) + ' client</span>';
1168
+ html += '<span>•</span>';
1169
+ html += '<span style="color:#8b5cf6">' + withUsageCount + ' with usage</span>';
1170
+ html += '</div></div>';
1171
+
1172
+ // Group by entry point presence (filter out components without name)
1173
+ const validComponents = sortedComponents.filter(c => c.name && typeof c.name === 'string');
1174
+ const withEntry = validComponents.filter(c => c.entryFile);
1175
+ const withoutEntry = validComponents.filter(c => !c.entryFile);
1176
+
1177
+ // Render components with entry points
1178
+ if (withEntry.length > 0) {
1179
+ html += '<div class="group" data-group="react-with-entry">';
1180
+ html += '<div class="group-header" onclick="toggleGroup(this)">';
1181
+ html += '<span class="group-toggle">▼</span>';
1182
+ html += '<span style="color:#61dafb;margin-right:4px">📥</span>';
1183
+ html += '<span class="group-name">With Entry Points (' + withEntry.length + ')</span>';
1184
+ html += '</div>';
1185
+ html += '<div class="group-items">';
1186
+
1187
+ withEntry.forEach(comp => {
1188
+ const usageCount = comp.usedIn?.length || 0;
1189
+ const tags = [];
1190
+ if (comp.ssr) tags.push('<span class="tag" style="background:#166534;color:#86efac" title="Server-Side Rendering">SSR</span>');
1191
+ if (usageCount > 0) tags.push('<span class="tag" style="background:#5b21b6;color:#c4b5fd" title="Used in ' + usageCount + ' view(s)">View:' + usageCount + '</span>');
1192
+ if (comp.sourceFile) tags.push('<span class="tag" style="background:#1e3a5f;color:#93c5fd" title="Has source file">SRC</span>');
1193
+
1194
+ // Find URL from routes based on controller/action OR infer from entry file
1195
+ let urlInfo = '';
1196
+ if (comp.usedIn && comp.usedIn.length > 0) {
1197
+ const usage = comp.usedIn[0];
1198
+ // Construct proper Rails URL: /controller for index, /controller/action for others
1199
+ if (usage.action === 'index') {
1200
+ urlInfo = '/' + usage.controller.replace(/_/g, '/');
1201
+ } else if (usage.action === 'show') {
1202
+ urlInfo = '/' + usage.controller.replace(/_/g, '/') + '/:id';
1203
+ } else {
1204
+ urlInfo = '/' + usage.controller.replace(/_/g, '/') + '/' + usage.action;
1205
+ }
1206
+ } else if (comp.entryFile) {
1207
+ // Infer URL from entry file name (e.g., tickets.tsx → /tickets)
1208
+ const fileName = comp.entryFile.split('/').pop().replace(/\\.(tsx?|jsx?)$/, '');
1209
+ urlInfo = '/' + fileName.replace(/_/g, '-');
1210
+ }
1211
+
1212
+ html += '<div class="page-item" data-path="' + comp.name.toLowerCase() + '" onclick="showReactComponentDetail(\\'' + encodeURIComponent(JSON.stringify(comp)) + '\\')">';
1213
+ html += '<div class="page-info">';
1214
+ html += '<span class="page-name" style="display:flex;align-items:center"><span style="color:#61dafb;margin-right:6px">⚛</span>' + comp.name + '</span>';
1215
+ html += '<span class="page-path" style="font-size:10px;color:var(--accent)">' + urlInfo + '</span>';
1216
+ html += '</div>';
1217
+ html += '<div class="page-tags">' + tags.join('') + '</div>';
1218
+ html += '</div>';
1219
+ });
1220
+
1221
+ html += '</div></div>';
1222
+ }
1223
+
1224
+ // Render components without entry points (found only in views)
1225
+ if (withoutEntry.length > 0) {
1226
+ html += '<div class="group" data-group="react-view-only">';
1227
+ html += '<div class="group-header" onclick="toggleGroup(this)">';
1228
+ html += '<span class="group-toggle">▼</span>';
1229
+ html += '<span style="color:#f59e0b;margin-right:4px">👁️</span>';
1230
+ html += '<span class="group-name">View-only Components (' + withoutEntry.length + ')</span>';
1231
+ html += '</div>';
1232
+ html += '<div class="group-items">';
1233
+
1234
+ withoutEntry.forEach(comp => {
1235
+ const usageCount = comp.usedIn?.length || 0;
1236
+ const tags = [];
1237
+ if (comp.ssr) tags.push('<span class="tag" style="background:#166534;color:#86efac" title="Server-Side Rendering">SSR</span>');
1238
+ if (usageCount > 0) tags.push('<span class="tag" style="background:#5b21b6;color:#c4b5fd" title="Used in ' + usageCount + ' view(s)">View:' + usageCount + '</span>');
1239
+
1240
+ // Find URL from routes based on controller/action
1241
+ let urlInfo = '';
1242
+ if (comp.usedIn && comp.usedIn.length > 0) {
1243
+ const usage = comp.usedIn[0];
1244
+ // Construct proper Rails URL
1245
+ if (usage.action === 'index') {
1246
+ urlInfo = '/' + usage.controller.replace(/_/g, '/');
1247
+ } else if (usage.action === 'show') {
1248
+ urlInfo = '/' + usage.controller.replace(/_/g, '/') + '/:id';
1249
+ } else {
1250
+ urlInfo = '/' + usage.controller.replace(/_/g, '/') + '/' + usage.action;
1251
+ }
1252
+ }
1253
+
1254
+ html += '<div class="page-item" data-path="' + comp.name.toLowerCase() + '" onclick="showReactComponentDetail(\\'' + encodeURIComponent(JSON.stringify(comp)) + '\\')">';
1255
+ html += '<div class="page-info">';
1256
+ html += '<span class="page-name" style="display:flex;align-items:center"><span style="color:#61dafb;margin-right:6px">⚛</span>' + comp.name + '</span>';
1257
+ html += '<span class="page-path" style="font-size:10px;color:var(--accent)">' + (urlInfo || 'View-only') + '</span>';
1258
+ html += '</div>';
1259
+ html += '<div class="page-tags">' + tags.join('') + '</div>';
1260
+ html += '</div>';
1261
+ });
1262
+
1263
+ html += '</div></div>';
1264
+ }
1265
+
1266
+ container.innerHTML = html;
1267
+ }
1268
+
1269
+ // Render Rails pages in page-map view - based on actual VIEW TEMPLATES (real screens)
1270
+ function renderRailsPagesInPageMap() {
1271
+ const container = document.getElementById('page-map-rails-section');
1272
+ if (!container) return;
1273
+
1274
+ // Use VIEWS as the source of truth for actual screens (not routes)
1275
+ const views = (railsViews && railsViews.views) || [];
1276
+ const routes = railsRoutes || [];
1277
+
1278
+ // Filter to only HTML views (actual screens users see)
1279
+ const htmlViews = views.filter(v => {
1280
+ // Only HTML format views are actual pages
1281
+ if (v.format !== 'html') return false;
1282
+ // Skip partials (they start with _)
1283
+ if (v.name.startsWith('_')) return false;
1284
+ // Skip mailer views
1285
+ if (v.controller.includes('mailer')) return false;
1286
+ return true;
1287
+ });
1288
+
1289
+ if (htmlViews.length === 0) {
1290
+ container.innerHTML = '';
1291
+ return;
1292
+ }
1293
+
1294
+ // Enrich views with route and controller info
1295
+ const enrichedViews = htmlViews.map(view => {
1296
+ // Find matching route for URL path
1297
+ const matchingRoute = routes.find(r =>
1298
+ r.controller === view.controller && r.action === view.action && r.method === 'GET'
1299
+ );
1300
+
1301
+ // Find controller info
1302
+ const ctrlName = view.controller.split('/').pop().replace(/_/g, '');
1303
+ const ctrl = railsControllers?.find(c => {
1304
+ if (c.name === view.controller || c.name === ctrlName) return true;
1305
+ const filePathNormalized = c.filePath.replace(/_controller\.rb$/, '').replace(/_/g, '');
1306
+ return filePathNormalized === view.controller.replace(/_/g, '');
1307
+ });
1308
+ const action = ctrl?.actions?.find(a => a.name === view.action);
1309
+
1310
+ // Find page info from railsViews.pages
1311
+ const pageInfo = (railsViews.pages || []).find(p =>
1312
+ p.controller === view.controller && p.action === view.action
1313
+ );
1314
+
1315
+ return {
1316
+ // View info
1317
+ viewPath: view.path,
1318
+ viewName: view.name,
1319
+ template: view.template,
1320
+ partials: view.partials || [],
1321
+ instanceVars: view.instanceVars || [],
1322
+ helpers: view.helpers || [],
1323
+ reactComponents: view.reactComponents || [],
1324
+ // Route info
1325
+ path: matchingRoute?.path || '/' + view.controller + '/' + view.action,
1326
+ method: matchingRoute?.method || 'GET',
1327
+ controller: view.controller,
1328
+ action: view.action,
1329
+ hasRoute: !!matchingRoute,
1330
+ // Controller action info
1331
+ services: pageInfo?.services || action?.servicesCalled || [],
1332
+ grpcCalls: pageInfo?.grpcCalls || [],
1333
+ modelAccess: pageInfo?.modelAccess || action?.modelsCalled || [],
1334
+ apis: pageInfo?.apis || [],
1335
+ methodCalls: action?.methodCalls || [],
1336
+ redirectsTo: action?.redirectsTo,
1337
+ // Instance variable assignments from controller
1338
+ instanceVarAssignments: action?.instanceVarAssignments || [],
1339
+ // Controller info for detail view
1340
+ controllerInfo: ctrl ? {
1341
+ className: ctrl.className,
1342
+ filePath: ctrl.filePath,
1343
+ beforeActions: ctrl.beforeActions || [],
1344
+ afterActions: ctrl.afterActions || []
1345
+ } : null,
1346
+ actionLine: action?.line
1347
+ };
1348
+ });
1349
+
1350
+ // Group by controller (representing different sections/features)
1351
+ const viewsByController = new Map();
1352
+ enrichedViews.forEach(v => {
1353
+ const ctrl = v.controller.split('/')[0]; // First part of controller path
1354
+ if (!viewsByController.has(ctrl)) viewsByController.set(ctrl, []);
1355
+ viewsByController.get(ctrl).push(v);
1356
+ });
1357
+
1358
+ const sortedControllers = [...viewsByController.keys()].sort();
1359
+ const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
1360
+
1361
+ // Stats
1362
+ const totalWithServices = enrichedViews.filter(v => v.services.length > 0 || v.grpcCalls.length > 0).length;
1363
+ const totalWithPartials = enrichedViews.filter(v => v.partials.length > 0).length;
1364
+
1365
+ let html = '';
1366
+ html += '<div style="padding:12px;background:var(--bg3);border-radius:8px;margin-bottom:12px">';
1367
+ html += '<div style="font-weight:600;margin-bottom:8px">🖼️ Rails Screens (View Templates)</div>';
1368
+ html += '<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--text2)">';
1369
+ html += '<span>' + enrichedViews.length + ' screens</span>';
1370
+ html += '<span>•</span>';
1371
+ html += '<span>' + sortedControllers.length + ' sections</span>';
1372
+ html += '<span>•</span>';
1373
+ html += '<span style="color:#8b5cf6">' + totalWithServices + ' with services</span>';
1374
+ html += '<span>•</span>';
1375
+ html += '<span style="color:#06b6d4">' + totalWithPartials + ' with partials</span>';
1376
+ html += '</div></div>';
1377
+
1378
+ sortedControllers.forEach((ctrl, idx) => {
1379
+ const controllerViews = viewsByController.get(ctrl) || [];
1380
+ const color = colors[idx % colors.length];
1381
+
1382
+ const screenListId = 'screens-' + ctrl.replace(/[^a-zA-Z0-9]/g, '-');
1383
+ const screenLimit = 30;
1384
+ const hasMoreScreens = controllerViews.length > screenLimit;
1385
+
1386
+ html += '<div class="group">';
1387
+ html += '<div class="group-header" onclick="toggleGroup(this)" style="border-left-color:' + color + '">';
1388
+ html += '<span class="group-toggle">▼</span>';
1389
+ html += '<span class="group-name">📁 ' + ctrl + '</span>';
1390
+ html += '<span class="group-count">' + controllerViews.length + ' screens</span>';
1391
+ html += '</div>';
1392
+ html += '<div class="group-items" id="' + screenListId + '">';
1393
+
1394
+ // Sort by action name
1395
+ controllerViews.sort((a, b) => a.action.localeCompare(b.action));
1396
+
1397
+ controllerViews.forEach((view, idx) => {
1398
+ // Build indicators
1399
+ let indicators = '';
1400
+ indicators += '<span class="route-tag route-tag-template" title="' + view.template.toUpperCase() + ' template">' + view.template.toUpperCase() + '</span>';
1401
+ if (view.reactComponents && view.reactComponents.length > 0) {
1402
+ const rcNames = view.reactComponents.map(rc => rc.name).slice(0, 2).join(', ') + (view.reactComponents.length > 2 ? '...' : '');
1403
+ indicators += '<span class="route-tag route-tag-react" title="React: ' + rcNames + '">⚛ ' + view.reactComponents.length + '</span>';
1404
+ }
1405
+ if (view.partials.length > 0) indicators += '<span class="route-tag route-tag-partials" title="Uses partials: ' + view.partials.slice(0,3).join(', ') + (view.partials.length > 3 ? '...' : '') + '">🧩 ' + view.partials.length + '</span>';
1406
+ if (view.instanceVars.length > 0) indicators += '<span class="route-tag route-tag-vars" title="Instance vars: @' + view.instanceVars.slice(0,5).join(', @') + '">📦 ' + view.instanceVars.length + '</span>';
1407
+ if (view.services.length > 0) indicators += '<span class="route-tag route-tag-svc" title="Services: ' + view.services.join(', ') + '">Svc</span>';
1408
+ if (view.grpcCalls.length > 0) indicators += '<span class="route-tag route-tag-grpc" title="gRPC: ' + view.grpcCalls.join(', ') + '">gRPC</span>';
1409
+ if (view.modelAccess.length > 0) indicators += '<span class="route-tag route-tag-db" title="Models: ' + view.modelAccess.join(', ') + '">DB</span>';
1410
+ if (!view.hasRoute) indicators += '<span class="route-tag route-tag-warn" title="No matching route found">⚠️</span>';
1411
+
1412
+ // Display: URL path (if route exists) or controller/action
1413
+ const displayName = view.hasRoute ? view.path.replace(/:([a-z_]+)/g, '<span style="color:#f59e0b">:$1</span>') : view.controller + '#' + view.action;
1414
+
1415
+ // Search-friendly data-path includes path, controller, action
1416
+ const searchPath = [view.path || '', view.controller || '', view.action || '', view.viewPath || ''].join(' ').toLowerCase();
1417
+
1418
+ const hiddenAttr = idx >= screenLimit ? ' data-hidden="true"' : '';
1419
+ const hiddenStyle = idx >= screenLimit ? 'display:none;' : '';
1420
+ html += '<div class="page-item" data-path="' + searchPath + '"' + hiddenAttr + ' onclick="showRailsScreenDetail(\\'' + encodeURIComponent(JSON.stringify(view)) + '\\')" style="cursor:pointer;' + hiddenStyle + '">';
1421
+ html += '<span class="page-type" style="background:#22c55e;min-width:50px;text-align:center">SCREEN</span>';
1422
+ html += '<span class="page-path" style="font-family:monospace;font-size:12px;flex:1">' + displayName + '</span>';
1423
+ html += indicators;
1424
+ html += '</div>';
1425
+ });
1426
+
1427
+ html += '</div>';
1428
+
1429
+ if (hasMoreScreens) {
1430
+ html += '<div id="' + screenListId + '-more" style="padding:8px 12px;cursor:pointer;color:var(--accent);font-size:11px" onclick="toggleMoreItems(\\'' + screenListId + '\\', ' + controllerViews.length + ')">';
1431
+ html += '▼ Show ' + (controllerViews.length - screenLimit) + ' more screens';
1432
+ html += '</div>';
1433
+ }
1434
+
1435
+ html += '</div>';
1436
+ });
1437
+
1438
+ container.innerHTML = html;
1439
+ }
1440
+
1441
+ // Show Rails screen detail (view-centric)
1442
+ function showRailsScreenDetail(encodedData) {
1443
+ const screen = JSON.parse(decodeURIComponent(encodedData));
1444
+
1445
+ let html = '';
1446
+
1447
+ // URL/Route info
1448
+ html += '<div class="detail-section">';
1449
+ html += '<div class="detail-label">🌐 URL Path</div>';
1450
+ if (screen.hasRoute) {
1451
+ html += '<div class="detail-value" style="font-family:monospace">' + screen.path.replace(/:([a-z_]+)/g, '<span style="color:#f59e0b">:$1</span>') + '</div>';
1452
+ } else {
1453
+ html += '<div class="detail-value" style="color:var(--text2)">No route defined (orphan view)</div>';
1454
+ }
1455
+ html += '</div>';
1456
+
1457
+ // View Template info
1458
+ html += '<div class="detail-section">';
1459
+ html += '<div class="detail-label">📄 View Template</div>';
1460
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px;font-family:monospace;font-size:12px">';
1461
+ html += '<div style="color:var(--accent)">app/views/' + screen.viewPath + '</div>';
1462
+ html += '<div style="margin-top:6px;display:flex;gap:8px">';
1463
+ html += '<span style="background:#6b7280;color:white;padding:2px 6px;border-radius:3px;font-size:10px">' + screen.template.toUpperCase() + '</span>';
1464
+ html += '</div></div></div>';
1465
+
1466
+ // Instance Variables (data passed to view) with type info
1467
+ if (screen.instanceVars && screen.instanceVars.length > 0) {
1468
+ html += '<div class="detail-section">';
1469
+ html += '<div class="detail-label">📦 Data Available in View (@variables)</div>';
1470
+
1471
+ // Build assignment map from controller analysis
1472
+ const assignmentMap = {};
1473
+ if (screen.instanceVarAssignments) {
1474
+ screen.instanceVarAssignments.forEach(a => {
1475
+ assignmentMap[a.name] = a;
1476
+ });
1477
+ }
1478
+
1479
+ // Initial display limit
1480
+ const initialLimit = 15;
1481
+ const hasMore = screen.instanceVars.length > initialLimit;
1482
+ const listId = 'ivars-' + Math.random().toString(36).substr(2, 9);
1483
+
1484
+ // Build model name lookup from railsModels
1485
+ const modelNames = new Set((railsModels || []).map(m => m.className));
1486
+ const modelNameLower = new Map((railsModels || []).map(m => [m.className.toLowerCase(), m.className]));
1487
+
1488
+ // Function to find matching model for a variable name
1489
+ function findModelForVar(varName) {
1490
+ // Direct match: @company → Company
1491
+ const pascalCase = varName.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
1492
+ if (modelNames.has(pascalCase)) return { model: pascalCase, confidence: 'exact' };
1493
+
1494
+ // Singular form: @companies → Company
1495
+ const singular = varName.replace(/ies$/, 'y').replace(/s$/, '');
1496
+ const singularPascal = singular.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
1497
+ if (modelNames.has(singularPascal)) return { model: singularPascal, confidence: 'plural' };
1498
+
1499
+ // current_X pattern: @current_user → User
1500
+ if (varName.startsWith('current_')) {
1501
+ const rest = varName.replace('current_', '');
1502
+ const restPascal = rest.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
1503
+ if (modelNames.has(restPascal)) return { model: restPascal, confidence: 'current' };
1504
+ }
1505
+
1506
+ // Fuzzy match
1507
+ const lowered = singularPascal.toLowerCase();
1508
+ if (modelNameLower.has(lowered)) return { model: modelNameLower.get(lowered), confidence: 'fuzzy' };
1509
+
1510
+ return null;
1511
+ }
1512
+
1513
+ html += '<div class="detail-items" id="' + listId + '">';
1514
+ screen.instanceVars.forEach((v, idx) => {
1515
+ const assignment = assignmentMap[v];
1516
+ const hiddenClass = idx >= initialLimit ? ' style="display:none" data-hidden="true"' : '';
1517
+
1518
+ // First try assignment from controller analysis, then infer from model list
1519
+ let linkedModel = null;
1520
+ let linkedType = null;
1521
+ let confidence = '';
1522
+
1523
+ if (assignment && assignment.assignedType) {
1524
+ linkedType = assignment.assignedType;
1525
+ if (linkedType.startsWith('Service:')) {
1526
+ linkedModel = { type: 'service', name: linkedType.replace('Service:', '') };
1527
+ } else if (linkedType.includes('.')) {
1528
+ linkedModel = { type: 'assoc', name: linkedType };
1529
+ } else {
1530
+ linkedModel = { type: 'model', name: linkedType };
1531
+ }
1532
+ confidence = 'analyzed';
1533
+ } else {
1534
+ // Infer from variable name using model list
1535
+ const inferred = findModelForVar(v);
1536
+ if (inferred) {
1537
+ linkedModel = { type: 'model', name: inferred.model };
1538
+ confidence = inferred.confidence;
1539
+ }
1540
+ }
1541
+
1542
+ const tooltip = assignment && assignment.assignedValue ? assignment.assignedValue.replace(/"/g, '&quot;') : '';
1543
+ html += '<div class="detail-item"' + hiddenClass + ' title="' + tooltip + '">';
1544
+ html += '<span class="tag" style="background:#8b5cf6;font-size:10px">@</span>';
1545
+ html += '<span class="name" style="font-family:monospace;font-weight:500">' + v + '</span>';
1546
+
1547
+ if (linkedModel) {
1548
+ let typeColor, typeLabel;
1549
+ if (linkedModel.type === 'service') {
1550
+ typeColor = '#8b5cf6'; typeLabel = 'Service';
1551
+ } else if (linkedModel.type === 'assoc') {
1552
+ typeColor = '#3b82f6'; typeLabel = 'Assoc';
1553
+ } else {
1554
+ typeColor = '#f59e0b'; typeLabel = 'Model';
1555
+ }
1556
+
1557
+ // Show confidence indicator for inferred types
1558
+ const opacityStyle = confidence !== 'analyzed' && confidence !== 'exact' ? 'opacity:0.8;' : '';
1559
+ const confidenceIcon = confidence === 'analyzed' ? '' : (confidence === 'exact' ? '' : ' ?');
1560
+
1561
+ html += '<span style="margin-left:auto;display:flex;align-items:center;gap:4px">';
1562
+ html += '<span style="font-size:9px;color:var(--text2)">' + typeLabel + ':</span>';
1563
+ html += '<span style="font-size:11px;background:' + typeColor + ';color:white;padding:2px 8px;border-radius:4px;font-weight:500;' + opacityStyle + '">' + linkedModel.name + confidenceIcon + '</span>';
1564
+ html += '</span>';
1565
+ }
1566
+
1567
+ html += '</div>';
1568
+ });
1569
+ html += '</div>';
1570
+
1571
+ // "Show more" button
1572
+ if (hasMore) {
1573
+ html += '<div id="' + listId + '-more" style="margin-top:8px;cursor:pointer;color:var(--accent);font-size:11px" onclick="toggleMoreItems(\\'' + listId + '\\', ' + screen.instanceVars.length + ')">';
1574
+ html += '▼ Show ' + (screen.instanceVars.length - initialLimit) + ' more variables';
1575
+ html += '</div>';
1576
+ }
1577
+
1578
+ html += '</div>';
1579
+ }
1580
+
1581
+ // React Components loaded in this view
1582
+ if (screen.reactComponents && screen.reactComponents.length > 0) {
1583
+ html += '<div class="detail-section">';
1584
+ html += '<div class="detail-label">⚛️ React Components</div>';
1585
+ html += '<div class="detail-items">';
1586
+ screen.reactComponents.forEach(rc => {
1587
+ html += '<div class="detail-item">';
1588
+ html += '<span class="tag" style="background:#61dafb;color:#222;font-size:10px;font-weight:600">React</span>';
1589
+ html += '<span class="name" style="font-family:monospace;font-weight:500">' + rc.name + '</span>';
1590
+ if (rc.ssr) {
1591
+ html += '<span style="margin-left:6px;font-size:9px;background:#22c55e;color:white;padding:1px 4px;border-radius:2px">SSR</span>';
1592
+ }
1593
+ if (rc.propsVar) {
1594
+ html += '<span style="margin-left:auto;font-size:10px;color:var(--text2);font-family:monospace">props: ' + rc.propsVar + '</span>';
1595
+ }
1596
+ html += '</div>';
1597
+ });
1598
+ html += '</div></div>';
1599
+ }
1600
+
1601
+ // Partials used
1602
+ if (screen.partials && screen.partials.length > 0) {
1603
+ const partialLimit = 10;
1604
+ const hasMorePartials = screen.partials.length > partialLimit;
1605
+ const partialListId = 'partials-' + Math.random().toString(36).substr(2, 9);
1606
+
1607
+ html += '<div class="detail-section">';
1608
+ html += '<div class="detail-label">🧩 Partials Used (' + screen.partials.length + ')</div>';
1609
+ html += '<div class="detail-items" id="' + partialListId + '">';
1610
+ screen.partials.forEach((p, idx) => {
1611
+ const hiddenClass = idx >= partialLimit ? ' style="display:none" data-hidden="true"' : '';
1612
+ html += '<div class="detail-item"' + hiddenClass + '><span class="tag" style="background:#06b6d4;font-size:10px">PARTIAL</span><span class="name" style="font-family:monospace;font-size:11px">' + p + '</span></div>';
1613
+ });
1614
+ html += '</div>';
1615
+ if (hasMorePartials) {
1616
+ html += '<div id="' + partialListId + '-more" style="margin-top:6px;cursor:pointer;color:var(--accent);font-size:11px" onclick="toggleMoreItems(\\'' + partialListId + '\\', ' + screen.partials.length + ')">▼ Show ' + (screen.partials.length - partialLimit) + ' more</div>';
1617
+ }
1618
+ html += '</div>';
1619
+ }
1620
+
1621
+ // Services Called
1622
+ if (screen.services && screen.services.length > 0) {
1623
+ html += '<div class="detail-section">';
1624
+ html += '<div class="detail-label">⚙️ Services Called</div>';
1625
+ html += '<div class="detail-items">';
1626
+ screen.services.forEach(s => {
1627
+ html += '<div class="detail-item"><span class="tag" style="background:#8b5cf6">Service</span><span class="name" style="font-family:monospace">' + s + '</span></div>';
1628
+ });
1629
+ html += '</div></div>';
1630
+ }
1631
+
1632
+ // gRPC Calls
1633
+ if (screen.grpcCalls && screen.grpcCalls.length > 0) {
1634
+ html += '<div class="detail-section">';
1635
+ html += '<div class="detail-label">🔌 gRPC Calls</div>';
1636
+ html += '<div class="detail-items">';
1637
+ screen.grpcCalls.forEach(g => {
1638
+ html += '<div class="detail-item"><span class="tag" style="background:#06b6d4">gRPC</span><span class="name" style="font-family:monospace">' + g + '</span></div>';
1639
+ });
1640
+ html += '</div></div>';
1641
+ }
1642
+
1643
+ // Model Access
1644
+ if (screen.modelAccess && screen.modelAccess.length > 0) {
1645
+ html += '<div class="detail-section">';
1646
+ html += '<div class="detail-label">💾 Models Used</div>';
1647
+ html += '<div class="detail-items">';
1648
+ screen.modelAccess.forEach(m => {
1649
+ html += '<div class="detail-item"><span class="tag" style="background:#f59e0b">Model</span><span class="name" style="font-family:monospace">' + m + '</span></div>';
1650
+ });
1651
+ html += '</div></div>';
1652
+ }
1653
+
1654
+ // Controller Action info
1655
+ html += '<div class="detail-section">';
1656
+ html += '<div class="detail-label">🎮 Controller Action</div>';
1657
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px">';
1658
+ html += '<div style="font-family:monospace;font-size:12px">' + screen.controller + '#' + screen.action + '</div>';
1659
+ if (screen.controllerInfo) {
1660
+ html += '<div style="margin-top:6px;font-size:11px;color:var(--text2)">app/controllers/' + screen.controllerInfo.filePath;
1661
+ if (screen.actionLine) html += ':' + screen.actionLine;
1662
+ html += '</div>';
1663
+
1664
+ // Before filters
1665
+ if (screen.controllerInfo.beforeActions && screen.controllerInfo.beforeActions.length > 0) {
1666
+ const applicableFilters = screen.controllerInfo.beforeActions.filter(f => {
1667
+ if (f.only && f.only.length > 0) return f.only.includes(screen.action);
1668
+ if (f.except && f.except.length > 0) return !f.except.includes(screen.action);
1669
+ return true;
1670
+ });
1671
+ if (applicableFilters.length > 0) {
1672
+ html += '<div style="margin-top:8px;font-size:11px">';
1673
+ html += '<span style="color:#22c55e">Before filters:</span> ' + applicableFilters.map(f => f.name).join(', ');
1674
+ html += '</div>';
1675
+ }
1676
+ }
1677
+ }
1678
+ html += '</div></div>';
1679
+
1680
+ // Method calls in action
1681
+ if (screen.methodCalls && screen.methodCalls.length > 0) {
1682
+ const meaningfulCalls = screen.methodCalls.filter(c => {
1683
+ const skip = ['params', 'respond_to', 'render', 'redirect_to', 'head', 'flash', 'session', 'cookies'];
1684
+ return !skip.some(s => c.startsWith(s + '.') || c === s);
1685
+ }).slice(0, 10);
1686
+
1687
+ if (meaningfulCalls.length > 0) {
1688
+ html += '<div class="detail-section">';
1689
+ html += '<div class="detail-label">🔗 Method Calls</div>';
1690
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px;font-family:monospace;font-size:11px;max-height:120px;overflow-y:auto">';
1691
+ meaningfulCalls.forEach((call, i) => {
1692
+ html += '<div style="padding:2px 0;color:var(--accent)">' + (i+1) + '. ' + call + '</div>';
1693
+ });
1694
+ html += '</div></div>';
1695
+ }
1696
+ }
1697
+
1698
+ showModal('🖼️ ' + screen.controller + '/' + screen.action, html);
1699
+ }
1700
+
1701
+ // Show Rails page detail with API info
1702
+ function showRailsPageDetail(encodedData) {
1703
+ const route = JSON.parse(decodeURIComponent(encodedData));
1704
+
1705
+ // Find controller and action details (improved matching)
1706
+ let actionDetails = null;
1707
+ let controllerInfo = null;
1708
+ if (railsControllers && railsControllers.length > 0) {
1709
+ const routeCtrl = route.controller;
1710
+ const routeCtrlParts = routeCtrl.split('/');
1711
+ const routeCtrlName = routeCtrlParts.pop().replace(/_/g, '');
1712
+
1713
+ controllerInfo = railsControllers.find(c => {
1714
+ const filePathNormalized = c.filePath.replace(/_controller\.rb$/, '').replace(/_/g, '');
1715
+ if (filePathNormalized === routeCtrl.replace(/_/g, '')) return true;
1716
+ if (c.name === routeCtrlName || c.name.replace(/_/g, '') === routeCtrlName) return true;
1717
+ const className = c.className.toLowerCase().replace('controller', '').replace(/::/g, '/');
1718
+ if (className === routeCtrl.toLowerCase() || className.endsWith('/' + routeCtrlName)) return true;
1719
+ const classNameSimple = c.className.toLowerCase().replace('controller', '').split('::').pop();
1720
+ return classNameSimple === routeCtrlName.toLowerCase();
1721
+ });
1722
+ if (controllerInfo) {
1723
+ actionDetails = controllerInfo.actions.find(a => a.name === route.action);
1724
+ }
1725
+ }
1726
+
1727
+ // Find page info from railsViews.pages
1728
+ const pageInfo = (railsViews && railsViews.pages || []).find(p =>
1729
+ p.controller === route.controller && p.action === route.action
1730
+ );
1731
+
1732
+ const methodColor = {GET:'#22c55e',POST:'#3b82f6',PUT:'#f59e0b',PATCH:'#f59e0b',DELETE:'#ef4444'}[route.method] || '#888';
1733
+
1734
+ let html = '<div class="detail-section">';
1735
+ html += '<div class="detail-label">Method & Path</div>';
1736
+ html += '<div class="detail-value">';
1737
+ html += '<span style="background:' + methodColor + ';color:white;padding:2px 8px;border-radius:4px;font-weight:600;margin-right:8px">' + (route.method || 'GET') + '</span>';
1738
+ html += '<span style="font-family:monospace">' + route.path.replace(/:([a-z_]+)/g, '<span style="color:#f59e0b">:$1</span>') + '</span>';
1739
+ html += '</div></div>';
1740
+
1741
+ html += '<div class="detail-section">';
1742
+ html += '<div class="detail-label">Controller#Action</div>';
1743
+ html += '<div class="detail-value">' + route.controller + '#' + route.action + '</div>';
1744
+ html += '</div>';
1745
+
1746
+ // Response Type
1747
+ if (actionDetails) {
1748
+ html += '<div class="detail-section">';
1749
+ html += '<div class="detail-label">📡 Response Type</div>';
1750
+ html += '<div class="detail-value">';
1751
+ const responseTypes = [];
1752
+ if (actionDetails.rendersJson) responseTypes.push('<span style="background:#3b82f6;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">JSON</span>');
1753
+ if (actionDetails.rendersHtml) responseTypes.push('<span style="background:#22c55e;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">HTML</span>');
1754
+ if (actionDetails.redirectsTo) responseTypes.push('<span style="background:#f59e0b;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">Redirect</span>');
1755
+ if (actionDetails.respondsTo && actionDetails.respondsTo.length > 0) {
1756
+ actionDetails.respondsTo.forEach(f => {
1757
+ responseTypes.push('<span style="background:#8b5cf6;color:white;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:4px">' + f.toUpperCase() + '</span>');
1758
+ });
1759
+ }
1760
+ html += responseTypes.length > 0 ? responseTypes.join('') : '<span style="color:var(--text2)">Unknown</span>';
1761
+ html += '</div></div>';
1762
+
1763
+ if (actionDetails.redirectsTo) {
1764
+ html += '<div class="detail-section">';
1765
+ html += '<div class="detail-label">↪️ Redirects To</div>';
1766
+ html += '<div class="detail-value" style="font-family:monospace;font-size:12px;background:var(--bg3);padding:8px;border-radius:4px">' + actionDetails.redirectsTo + '</div>';
1767
+ html += '</div>';
1768
+ }
1769
+ }
1770
+
1771
+ // View Template
1772
+ const view = pageInfo?.view || route.view;
1773
+ if (view) {
1774
+ html += '<div class="detail-section">';
1775
+ html += '<div class="detail-label">📄 View Template</div>';
1776
+ html += '<div class="detail-value" style="font-family:monospace;font-size:12px">app/views/' + view.path + '</div>';
1777
+ if (view.partials && view.partials.length > 0) {
1778
+ html += '<div style="margin-top:6px;font-size:11px;color:var(--text2)">Partials: ' + view.partials.slice(0, 5).join(', ') + '</div>';
1779
+ }
1780
+ if (view.instanceVars && view.instanceVars.length > 0) {
1781
+ html += '<div style="margin-top:4px;font-size:11px;color:var(--text2)">Instance vars: @' + view.instanceVars.slice(0, 5).join(', @') + '</div>';
1782
+ }
1783
+ html += '</div>';
1784
+ }
1785
+
1786
+ // Before/After Filters
1787
+ if (controllerInfo && (controllerInfo.beforeActions.length > 0 || controllerInfo.afterActions.length > 0)) {
1788
+ html += '<div class="detail-section">';
1789
+ html += '<div class="detail-label">🔒 Filters Applied</div>';
1790
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px">';
1791
+
1792
+ const applicableBeforeFilters = controllerInfo.beforeActions.filter(f => {
1793
+ if (f.only && f.only.length > 0) return f.only.includes(route.action);
1794
+ if (f.except && f.except.length > 0) return !f.except.includes(route.action);
1795
+ return true;
1796
+ });
1797
+
1798
+ if (applicableBeforeFilters.length > 0) {
1799
+ html += '<div style="font-size:11px;margin-bottom:4px"><span style="color:#22c55e;font-weight:600">Before:</span> ';
1800
+ html += applicableBeforeFilters.map(f => {
1801
+ let info = f.name;
1802
+ if (f.if) info += ' <span style="color:var(--text2)">(if: ' + f.if + ')</span>';
1803
+ return info;
1804
+ }).join(', ');
1805
+ html += '</div>';
1806
+ }
1807
+
1808
+ const applicableAfterFilters = controllerInfo.afterActions.filter(f => {
1809
+ if (f.only && f.only.length > 0) return f.only.includes(route.action);
1810
+ if (f.except && f.except.length > 0) return !f.except.includes(route.action);
1811
+ return true;
1812
+ });
1813
+
1814
+ if (applicableAfterFilters.length > 0) {
1815
+ html += '<div style="font-size:11px"><span style="color:#f59e0b;font-weight:600">After:</span> ';
1816
+ html += applicableAfterFilters.map(f => f.name).join(', ');
1817
+ html += '</div>';
1818
+ }
1819
+
1820
+ if (applicableBeforeFilters.length === 0 && applicableAfterFilters.length === 0) {
1821
+ html += '<div style="font-size:11px;color:var(--text2)">No filters for this action</div>';
1822
+ }
1823
+ html += '</div></div>';
1824
+ }
1825
+
1826
+ // Services
1827
+ const services = pageInfo?.services || actionDetails?.servicesCalled || [];
1828
+ if (services.length > 0) {
1829
+ html += '<div class="detail-section">';
1830
+ html += '<div class="detail-label">⚙️ Services Called</div>';
1831
+ html += '<div class="detail-items">';
1832
+ services.forEach(s => {
1833
+ html += '<div class="detail-item"><span class="tag" style="background:#8b5cf6">Service</span><span class="name" style="font-family:monospace">' + s + '</span></div>';
1834
+ });
1835
+ html += '</div></div>';
1836
+ }
1837
+
1838
+ // gRPC Calls
1839
+ const grpcCalls = pageInfo?.grpcCalls || [];
1840
+ if (grpcCalls.length > 0) {
1841
+ html += '<div class="detail-section">';
1842
+ html += '<div class="detail-label">🔌 gRPC Calls</div>';
1843
+ html += '<div class="detail-items">';
1844
+ grpcCalls.forEach(g => {
1845
+ html += '<div class="detail-item"><span class="tag" style="background:#06b6d4">gRPC</span><span class="name" style="font-family:monospace">' + g + '</span></div>';
1846
+ });
1847
+ html += '</div></div>';
1848
+ }
1849
+
1850
+ // Model Access
1851
+ const models = pageInfo?.modelAccess || actionDetails?.modelsCalled || [];
1852
+ if (models.length > 0) {
1853
+ html += '<div class="detail-section">';
1854
+ html += '<div class="detail-label">💾 Models Accessed</div>';
1855
+ html += '<div class="detail-items">';
1856
+ models.forEach(m => {
1857
+ html += '<div class="detail-item"><span class="tag" style="background:#f59e0b">Model</span><span class="name" style="font-family:monospace">' + m + '</span></div>';
1858
+ });
1859
+ html += '</div></div>';
1860
+ }
1861
+
1862
+ // Method Calls
1863
+ if (actionDetails && actionDetails.methodCalls && actionDetails.methodCalls.length > 0) {
1864
+ const meaningfulCalls = actionDetails.methodCalls.filter(c => {
1865
+ const skip = ['params', 'respond_to', 'render', 'redirect_to', 'head', 'flash', 'session', 'cookies'];
1866
+ return !skip.some(s => c.startsWith(s + '.') || c === s);
1867
+ }).slice(0, 15);
1868
+
1869
+ if (meaningfulCalls.length > 0) {
1870
+ html += '<div class="detail-section">';
1871
+ html += '<div class="detail-label">🔗 Method Calls in Action</div>';
1872
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px;max-height:150px;overflow-y:auto">';
1873
+ html += '<div style="font-family:monospace;font-size:11px;line-height:1.6">';
1874
+ meaningfulCalls.forEach((call, i) => {
1875
+ html += '<div style="padding:2px 0;border-bottom:1px solid var(--bg1)">';
1876
+ html += '<span style="color:var(--text2);margin-right:8px">' + (i+1) + '.</span>';
1877
+ html += '<span style="color:var(--accent)">' + call + '</span>';
1878
+ html += '</div>';
1879
+ });
1880
+ if (actionDetails.methodCalls.length > 15) {
1881
+ html += '<div style="padding:4px 0;color:var(--text2);font-style:italic">...and ' + (actionDetails.methodCalls.length - 15) + ' more</div>';
1882
+ }
1883
+ html += '</div></div></div>';
1884
+ }
1885
+ }
1886
+
1887
+ // Source Files
1888
+ html += '<div class="detail-section">';
1889
+ html += '<div class="detail-label">📁 Source Files</div>';
1890
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px;font-family:monospace;font-size:11px">';
1891
+
1892
+ if (controllerInfo) {
1893
+ html += '<div style="padding:4px 0;display:flex;align-items:center">';
1894
+ html += '<span style="color:var(--text2);width:80px">Controller:</span>';
1895
+ html += '<span>app/controllers/' + controllerInfo.filePath;
1896
+ if (actionDetails && actionDetails.line) html += ':<span style="color:#22c55e">' + actionDetails.line + '</span>';
1897
+ html += '</span></div>';
1898
+ }
1899
+
1900
+ if (view) {
1901
+ html += '<div style="padding:4px 0;display:flex;align-items:center">';
1902
+ html += '<span style="color:var(--text2);width:80px">View:</span>';
1903
+ html += '<span>app/views/' + view.path + '</span>';
1904
+ html += '</div>';
1905
+ }
1906
+ html += '</div></div>';
1907
+
1908
+ // Controller Info
1909
+ if (controllerInfo) {
1910
+ html += '<div class="detail-section">';
1911
+ html += '<div class="detail-label">📋 Controller Info</div>';
1912
+ html += '<div style="background:var(--bg3);padding:10px;border-radius:6px;margin-top:6px">';
1913
+ html += '<div style="font-weight:600;margin-bottom:4px">' + controllerInfo.className + '</div>';
1914
+ html += '<div style="font-size:11px;color:var(--text2)">extends ' + controllerInfo.parentClass + '</div>';
1915
+ if (controllerInfo.concerns && controllerInfo.concerns.length > 0) {
1916
+ html += '<div style="margin-top:6px;font-size:11px">';
1917
+ html += '<span style="color:var(--text2)">Concerns:</span> ' + controllerInfo.concerns.join(', ');
1918
+ html += '</div>';
1919
+ }
1920
+ html += '</div></div>';
1921
+ }
1922
+
1923
+ if (!pageInfo && !actionDetails) {
1924
+ html += '<div style="padding:12px;color:var(--text2);font-size:12px;background:var(--bg3);border-radius:6px;margin-top:8px">';
1925
+ html += '⚠️ No detailed action information found. The controller or action may not be analyzed yet.';
1926
+ html += '</div>';
1927
+ }
1928
+
1929
+ showModal(route.path, html);
1930
+ }
1931
+
1932
+ // Show Rails view detail
1933
+ function showRailsViewDetail(encodedData) {
1934
+ const view = JSON.parse(decodeURIComponent(encodedData));
1935
+
1936
+ let html = '<div class="detail-section">';
1937
+ html += '<div class="detail-label">View</div>';
1938
+ html += '<div class="detail-value" style="font-family:monospace">app/views/' + view.path + '</div>';
1939
+ html += '</div>';
1940
+
1941
+ html += '<div class="detail-section">';
1942
+ html += '<div class="detail-label">Controller#Action</div>';
1943
+ html += '<div class="detail-value">' + view.controller + '#' + view.action + '</div>';
1944
+ html += '</div>';
1945
+
1946
+ html += '<div class="detail-section">';
1947
+ html += '<div class="detail-label">Template</div>';
1948
+ html += '<div class="detail-value">' + view.template.toUpperCase() + ' (' + view.format + ')</div>';
1949
+ html += '</div>';
1950
+
1951
+ if (view.partials && view.partials.length > 0) {
1952
+ html += '<div class="detail-section">';
1953
+ html += '<div class="detail-label">Partials Used</div>';
1954
+ html += '<div class="detail-items">';
1955
+ view.partials.forEach(p => {
1956
+ html += '<div class="detail-item"><span class="tag" style="background:#6b7280">PARTIAL</span><span class="name">' + p + '</span></div>';
1957
+ });
1958
+ html += '</div></div>';
1959
+ }
1960
+
1961
+ if (view.instanceVars && view.instanceVars.length > 0) {
1962
+ html += '<div class="detail-section">';
1963
+ html += '<div class="detail-label">Instance Variables</div>';
1964
+ html += '<div class="detail-items">';
1965
+ view.instanceVars.slice(0, 10).forEach(v => {
1966
+ html += '<div class="detail-item"><span class="tag" style="background:#f59e0b">@</span><span class="name">' + v + '</span></div>';
1967
+ });
1968
+ if (view.instanceVars.length > 10) {
1969
+ html += '<div style="padding:4px 8px;color:var(--text2);font-size:11px">...and ' + (view.instanceVars.length - 10) + ' more</div>';
1970
+ }
1971
+ html += '</div></div>';
1972
+ }
1973
+
1974
+ showModal('📄 ' + view.controller + '/' + view.action, html);
1975
+ }
1976
+
1977
+ function toggleGroup(el) {
1978
+ el.closest('.group').classList.toggle('collapsed');
1979
+ }
1980
+
1981
+ function toggleMoreItems(listId, totalCount) {
1982
+ const list = document.getElementById(listId);
1983
+ const moreBtn = document.getElementById(listId + '-more');
1984
+ if (!list || !moreBtn) return;
1985
+
1986
+ const hiddenItems = list.querySelectorAll('[data-hidden="true"]');
1987
+ const isExpanded = moreBtn.getAttribute('data-expanded') === 'true';
1988
+
1989
+ if (isExpanded) {
1990
+ // Collapse: hide items again
1991
+ hiddenItems.forEach(item => {
1992
+ item.style.display = 'none';
1993
+ });
1994
+ moreBtn.innerHTML = '▼ Show ' + hiddenItems.length + ' more variables';
1995
+ moreBtn.setAttribute('data-expanded', 'false');
1996
+ } else {
1997
+ // Expand: show all items
1998
+ hiddenItems.forEach(item => {
1999
+ item.style.display = '';
2000
+ });
2001
+ moreBtn.innerHTML = '▲ Show less';
2002
+ moreBtn.setAttribute('data-expanded', 'true');
2003
+ }
2004
+ }
2005
+
2006
+ function selectPage(path) {
2007
+ document.querySelectorAll('.page-item').forEach(p => p.classList.remove('selected'));
2008
+ document.querySelector('[data-path="'+path+'"]')?.classList.add('selected');
2009
+ showDetail(path);
2010
+ }
2011
+
2012
+ function showDetail(path) {
2013
+ const page = pageMap.get(path);
2014
+ if (!page) return;
2015
+
2016
+ const rels = relations.filter(r => r.from === path || r.to === path);
2017
+ const parent = page.parent ? pageMap.get(page.parent) : null;
2018
+ const children = (page.children || []).map(c => pageMap.get(c)).filter(Boolean);
2019
+ const sameLayout = rels.filter(r => r.type === 'same-layout').map(r => r.from === path ? r.to : r.from).slice(0, 5);
2020
+
2021
+ // Navigation links (from linkedPages)
2022
+ const navLinks = (page.linkedPages || []).filter(lp => {
2023
+ const normalizedPath = lp.startsWith('/') ? lp : '/' + lp;
2024
+ return pageMap.has(normalizedPath) || pageMap.has(normalizedPath.split('?')[0]);
2025
+ }).slice(0, 10);
2026
+
2027
+ let relsHtml = '';
2028
+ if (parent) {
2029
+ relsHtml += '<div class="rel-item" onclick="event.stopPropagation(); selectPage(\\''+parent.path+'\\')">' +
2030
+ '<div class="rel-header"><span class="rel-type rel-type-parent">PARENT</span><span class="rel-path">'+parent.path+'</span></div>' +
2031
+ '<div class="rel-desc">This page is inside '+parent.path+'</div></div>';
2032
+ }
2033
+ children.forEach(c => {
2034
+ relsHtml += '<div class="rel-item" onclick="event.stopPropagation(); selectPage(\\''+c.path+'\\')">' +
2035
+ '<div class="rel-header"><span class="rel-type rel-type-child">CHILD</span><span class="rel-path">'+c.path+'</span></div>' +
2036
+ '<div class="rel-desc">Sub-page of current page</div></div>';
2037
+ });
2038
+
2039
+ // Navigation links
2040
+ navLinks.forEach(link => {
2041
+ const targetPath = link.startsWith('/') ? link.split('?')[0] : '/' + link.split('?')[0];
2042
+ relsHtml += '<div class="rel-item" onclick="event.stopPropagation(); selectPage(\\''+targetPath+'\\')">' +
2043
+ '<div class="rel-header"><span class="rel-type" style="background:#3b82f6;color:white">LINK</span><span class="rel-path">'+link+'</span></div>' +
2044
+ '<div class="rel-desc">Navigation link from this page</div></div>';
2045
+ });
2046
+
2047
+ sameLayout.forEach(p => {
2048
+ relsHtml += '<div class="rel-item" onclick="event.stopPropagation(); selectPage(\\''+p+'\\')">' +
2049
+ '<div class="rel-header"><span class="rel-type rel-type-layout">LAYOUT</span><span class="rel-path">'+p+'</span></div>' +
2050
+ '<div class="rel-desc">Uses same layout: '+(page.layout||'')+'</div></div>';
2051
+ });
2052
+
2053
+ // Steps section
2054
+ let stepsHtml = '';
2055
+ if (page.steps && page.steps.length > 0) {
2056
+ stepsHtml = '<div class="detail-section"><h4>Multi-Step Flow ('+page.steps.length+' steps)</h4>';
2057
+ stepsHtml += '<div style="display:flex;flex-direction:column;gap:6px">';
2058
+ page.steps.forEach((step, idx) => {
2059
+ const stepName = step.name || 'Step ' + step.id;
2060
+ const stepComp = step.component ? '<code style="background:#0f172a;color:#93c5fd;padding:2px 6px;border-radius:3px;font-size:10px;margin-left:8px">'+step.component+'</code>' : '';
2061
+ stepsHtml += '<div style="display:flex;align-items:center;padding:8px;background:#1e293b;border-radius:6px;border-left:3px solid '+(idx===0?'#22c55e':'#3b82f6')+'">' +
2062
+ '<span style="background:#475569;color:white;border-radius:50%;width:20px;height:20px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;margin-right:8px">'+(idx+1)+'</span>' +
2063
+ '<span style="font-size:12px;color:var(--text)">'+stepName+'</span>' +
2064
+ stepComp +
2065
+ '</div>';
2066
+ });
2067
+ stepsHtml += '</div></div>';
2068
+ }
2069
+
2070
+ // Data operations - show grouped by source path, sorted by depth
2071
+ let dataHtml = '';
2072
+ if (page.dataFetching && page.dataFetching.length > 0) {
2073
+ // Separate actual GraphQL operations from component references
2074
+ const graphqlOps = page.dataFetching.filter(df => df.type !== 'component');
2075
+ const componentRefs = page.dataFetching.filter(df => df.type === 'component');
2076
+
2077
+ // Parse operations to extract path info and depth
2078
+ const parsedOps = graphqlOps.map(df => {
2079
+ const rawName = df.operationName || '';
2080
+ // Pattern: "→ QueryName (via HookA)" or "→ → QueryName (via HookA)" etc.
2081
+ const arrowCount = (rawName.match(/→/g) || []).length;
2082
+
2083
+ // Extract query name and path
2084
+ let queryName = rawName.replace(/^[→\\s]+/, '').replace(/^\\u2192\\s*/g, '');
2085
+ let sourcePath = '';
2086
+
2087
+ // Extract "(via X)" for hook
2088
+ const viaMatch = queryName.match(/\\s*\\(via\\s+([^)]+)\\)/);
2089
+ if (viaMatch) {
2090
+ sourcePath = viaMatch[1];
2091
+ queryName = queryName.replace(viaMatch[0], '').trim();
2092
+ }
2093
+
2094
+ // Extract "(ComponentName)" for component - check after removing via
2095
+ const compMatch = queryName.match(/\\s*\\(([A-Z][a-zA-Z0-9]+)\\)$/);
2096
+ if (compMatch) {
2097
+ if (!sourcePath) sourcePath = compMatch[1];
2098
+ queryName = queryName.replace(compMatch[0], '').trim();
2099
+ }
2100
+
2101
+ // Further clean the query name
2102
+ queryName = queryName.replace(/^[→\\s]+/, '').trim();
2103
+
2104
+ return {
2105
+ ...df,
2106
+ queryName,
2107
+ sourcePath: sourcePath || 'Direct',
2108
+ depth: arrowCount
2109
+ };
2110
+ });
2111
+
2112
+ // Sort by depth (lower first) then by source path
2113
+ parsedOps.sort((a, b) => {
2114
+ if (a.depth !== b.depth) return a.depth - b.depth;
2115
+ if (a.sourcePath === 'Direct') return -1;
2116
+ if (b.sourcePath === 'Direct') return 1;
2117
+ return a.sourcePath.localeCompare(b.sourcePath);
2118
+ });
2119
+
2120
+ // Deduplicate by queryName + sourcePath
2121
+ const seen = new Set();
2122
+ const uniqueOps = parsedOps.filter(op => {
2123
+ const key = op.queryName + ':' + op.sourcePath;
2124
+ if (seen.has(key)) return false;
2125
+ seen.add(key);
2126
+ return true;
2127
+ });
2128
+
2129
+ // Group by source path
2130
+ const groupedByPath = new Map();
2131
+ uniqueOps.forEach(op => {
2132
+ const path = op.sourcePath;
2133
+ if (!groupedByPath.has(path)) {
2134
+ groupedByPath.set(path, []);
2135
+ }
2136
+ groupedByPath.get(path).push(op);
2137
+ });
2138
+
2139
+ // Sort groups: Direct first, then by depth
2140
+ const sortedPaths = Array.from(groupedByPath.keys()).sort((a, b) => {
2141
+ if (a === 'Direct') return -1;
2142
+ if (b === 'Direct') return 1;
2143
+ const aDepth = groupedByPath.get(a)[0]?.depth || 0;
2144
+ const bDepth = groupedByPath.get(b)[0]?.depth || 0;
2145
+ return aDepth - bDepth;
2146
+ });
2147
+
2148
+ // Deduplicate component refs
2149
+ const seenComponents = new Set();
2150
+ const uniqueComponentRefs = componentRefs.filter(df => {
2151
+ const name = df.operationName || '';
2152
+ if (seenComponents.has(name)) return false;
2153
+ seenComponents.add(name);
2154
+ return true;
2155
+ });
2156
+
2157
+ dataHtml = '';
2158
+
2159
+ // Show grouped GraphQL operations
2160
+ if (sortedPaths.length > 0) {
2161
+ dataHtml += '<div class="detail-section"><h4>Data Operations</h4>';
2162
+
2163
+ sortedPaths.forEach(pathName => {
2164
+ const ops = groupedByPath.get(pathName);
2165
+ const depthIndicator = pathName === 'Direct' ? '' : '↳ ';
2166
+ const pathLabel = pathName === 'Direct' ? 'Direct (this page)' : pathName;
2167
+
2168
+ // Path header with depth visual
2169
+ dataHtml += '<div class="data-path-group" style="margin:8px 0">' +
2170
+ '<div class="data-path-header" style="font-size:11px;color:var(--text2);margin-bottom:4px;padding-left:'+(ops[0]?.depth * 8)+'px">' +
2171
+ depthIndicator + '<span style="color:var(--accent)">' + pathLabel + '</span> (' + ops.length + ')' +
2172
+ '</div>';
2173
+
2174
+ ops.forEach(op => {
2175
+ const isQ = !op.type?.includes('Mutation');
2176
+ const srcArg = op.sourcePath !== 'Direct' ? ",\\'"+op.sourcePath.replace(/'/g, "\\\\'")+"\\'": '';
2177
+ dataHtml += '<div class="detail-item data-op" style="padding-left:'+(8 + op.depth * 8)+'px" onclick="showDataDetail(\\''+op.queryName.replace(/'/g, "\\\\'")+"\\'"+srcArg+')">' +
2178
+ '<span class="tag '+(isQ?'tag-query':'tag-mutation')+'" style="font-size:10px">'+(isQ?'Q':'M')+'</span> '+op.queryName+'</div>';
2179
+ });
2180
+
2181
+ dataHtml += '</div>';
2182
+ });
2183
+
2184
+ dataHtml += '</div>';
2185
+ }
2186
+
2187
+ // Show component references separately
2188
+ if (uniqueComponentRefs.length > 0) {
2189
+ dataHtml += '<div class="detail-section"><h4>Used Components</h4>';
2190
+ uniqueComponentRefs.forEach(df => {
2191
+ const name = df.operationName || '';
2192
+ dataHtml += '<div class="detail-item" style="cursor:default"><span class="tag" style="background:var(--text2);color:var(--bg)">COMPONENT</span> '+name+'</div>';
2193
+ });
2194
+ dataHtml += '</div>';
2195
+ }
2196
+ }
2197
+
2198
+ // REST API calls for this page
2199
+ let restApiHtml = '';
2200
+ const pageFileName = page.filePath?.split('/').pop() || '';
2201
+ const pageBaseName = pageFileName.replace(/\\.(tsx?|jsx?)$/, '');
2202
+ const pageApis = apiCallsData.filter(api => {
2203
+ if (!api.filePath || !page.filePath) return false;
2204
+ // Match by exact file path or by containing the page file name
2205
+ return api.filePath.includes(page.filePath) ||
2206
+ page.filePath.includes(api.filePath) ||
2207
+ api.filePath.endsWith(pageFileName) ||
2208
+ api.filePath.includes('/' + pageBaseName + '/');
2209
+ });
2210
+
2211
+ if (pageApis.length > 0) {
2212
+ restApiHtml = '<div class="detail-section"><h4>REST API Calls ('+pageApis.length+')</h4>';
2213
+ pageApis.forEach(api => {
2214
+ const methodColors = {GET:'#22c55e',POST:'#3b82f6',PUT:'#f59e0b',DELETE:'#ef4444',PATCH:'#8b5cf6'};
2215
+ const color = methodColors[api.method] || '#6b7280';
2216
+ restApiHtml += '<div class="detail-item api-item" onclick="event.stopPropagation(); showApiDetail(\\''+api.id.replace(/'/g, "\\\\'")+'\\')">' +
2217
+ '<div class="api-row"><span class="tag" style="background:'+color+'">'+api.method+'</span><span class="api-url">'+api.url+'</span></div>' +
2218
+ (api.filePath ? '<div class="api-route">'+api.callType+' in '+api.filePath+'</div>' : '') +
2219
+ '</div>';
2220
+ });
2221
+ restApiHtml += '</div>';
2222
+ }
2223
+
2224
+ const totalRels = (parent ? 1 : 0) + children.length + navLinks.length + sameLayout.length;
2225
+
2226
+ document.getElementById('detail-title').textContent = path;
2227
+ document.getElementById('detail-body').innerHTML =
2228
+ '<div class="detail-section"><h4>Info</h4>' +
2229
+ '<div class="detail-item"><div class="detail-label">FILE</div>'+page.filePath+'</div>' +
2230
+ '<div class="detail-item"><div class="detail-label">AUTH</div>'+(page.authentication?.required?'<span class="tag tag-auth">LOGIN REQUIRED</span>':'No auth required')+'</div>' +
2231
+ (page.layout?'<div class="detail-item"><div class="detail-label">LAYOUT</div>'+page.layout+'</div>':'') +
2232
+ (page.params?.length?'<div class="detail-item"><div class="detail-label">PARAMS</div>'+page.params.map(p=>':'+p).join(', ')+'</div>':'') +
2233
+ '</div>' + stepsHtml + dataHtml + restApiHtml +
2234
+ '<div class="detail-section"><h4>Related Pages ('+totalRels+')</h4>' +
2235
+ (relsHtml || '<div style="color:var(--text2);font-size:12px">No related pages</div>') +
2236
+ '</div>';
2237
+
2238
+ document.getElementById('detail').classList.add('open');
2239
+ }
2240
+
2241
+ function closeDetail() {
2242
+ document.getElementById('detail').classList.remove('open');
2243
+ document.querySelectorAll('.page-item').forEach(p => p.classList.remove('selected'));
2244
+ }
2245
+
2246
+ // Filter by stat type
2247
+ let currentFilter = null;
2248
+
2249
+ // Build sets for filtering
2250
+ const pagesWithGraphQL = new Set(pages.filter(p =>
2251
+ p.dataFetching && p.dataFetching.some(df =>
2252
+ df.type === 'useQuery' || df.type === 'useMutation' || df.type === 'useLazyQuery'
2253
+ )
2254
+ ).map(p => p.path));
2255
+
2256
+ const pagesWithRestApi = new Set(pages.filter(p => {
2257
+ // Check if any API call is in this page's file or related feature directory
2258
+ if (!p.filePath) return false;
2259
+ const pageFileName = p.filePath.split('/').pop() || '';
2260
+ const pageBaseName = pageFileName.replace(/\\.(tsx?|jsx?)$/, '');
2261
+ return apiCallsData.some(api => {
2262
+ if (!api.filePath) return false;
2263
+ return api.filePath.includes(p.filePath) ||
2264
+ p.filePath.includes(api.filePath) ||
2265
+ api.filePath.endsWith(pageFileName) ||
2266
+ api.filePath.includes('/' + pageBaseName + '/');
2267
+ });
2268
+ }).map(p => p.path));
2269
+
2270
+ const pagesWithHierarchy = new Set(pages.filter(p => p.parent || (p.children && p.children.length > 0)).map(p => p.path));
2271
+
2272
+ function handleStatClick(type, el) {
2273
+ // Always reset filter first
2274
+ showAllPages();
2275
+
2276
+ // Toggle filter - if same type clicked, just deactivate
2277
+ if (currentFilter === type) {
2278
+ currentFilter = null;
2279
+ document.querySelectorAll('.stat').forEach(s => s.classList.remove('active'));
2280
+ closeDetail();
2281
+ return;
2282
+ }
2283
+
2284
+ currentFilter = type;
2285
+ document.querySelectorAll('.stat').forEach(s => s.classList.remove('active'));
2286
+ el.classList.add('active');
2287
+
2288
+ // Apply filter to page list
2289
+ filterPageList(type);
2290
+
2291
+ // Show detail panel
2292
+ if (type === 'graphql') {
2293
+ showGraphQLList();
2294
+ } else if (type === 'restapi') {
2295
+ showRestApiList();
2296
+ } else if (type === 'pages') {
2297
+ showPagesSummary();
2298
+ } else if (type === 'hierarchies') {
2299
+ showHierarchiesList();
2300
+ }
2301
+ }
2302
+
2303
+ function filterPageList(type) {
2304
+ let visiblePaths;
2305
+
2306
+ if (type === 'graphql') {
2307
+ visiblePaths = pagesWithGraphQL;
2308
+ } else if (type === 'restapi') {
2309
+ visiblePaths = pagesWithRestApi;
2310
+ } else if (type === 'hierarchies') {
2311
+ visiblePaths = pagesWithHierarchy;
2312
+ } else {
2313
+ // 'pages' - show all
2314
+ return;
2315
+ }
2316
+
2317
+ // Filter page items
2318
+ document.querySelectorAll('.page-item').forEach(el => {
2319
+ const path = el.getAttribute('data-path');
2320
+ if (visiblePaths.has(path)) {
2321
+ el.style.removeProperty('display');
2322
+ el.style.display = 'flex';
2323
+ } else {
2324
+ el.style.display = 'none';
2325
+ }
2326
+ });
2327
+
2328
+ // Hide empty groups
2329
+ document.querySelectorAll('.group').forEach(g => {
2330
+ const hasVisibleItems = Array.from(g.querySelectorAll('.page-item')).some(item => item.style.display !== 'none');
2331
+ if (hasVisibleItems) {
2332
+ g.style.removeProperty('display');
2333
+ g.style.display = 'block';
2334
+ } else {
2335
+ g.style.display = 'none';
2336
+ }
2337
+ });
2338
+ }
2339
+
2340
+ function showPagesSummary() {
2341
+ // Group pages by first segment
2342
+ const groups = {};
2343
+ pages.forEach(p => {
2344
+ const seg = p.path.split('/').filter(Boolean)[0] || 'root';
2345
+ if (!groups[seg]) groups[seg] = [];
2346
+ groups[seg].push(p);
2347
+ });
2348
+
2349
+ const authPages = pages.filter(p => p.authentication?.required);
2350
+ const dynamicPages = pages.filter(p => p.path.includes('[') && p.path.includes(']'));
2351
+
2352
+ let html = '<div class="detail-section"><h4>Pages Summary</h4>';
2353
+ html += '<div class="detail-item"><div class="detail-label">TOTAL</div>'+pages.length+' pages</div>';
2354
+ html += '<div class="detail-item"><div class="detail-label">AUTH REQUIRED</div>'+authPages.length+' pages</div>';
2355
+ html += '<div class="detail-item"><div class="detail-label">DYNAMIC ROUTES</div>'+dynamicPages.length+' pages</div>';
2356
+ html += '</div>';
2357
+
2358
+ html += '<div class="detail-section"><h4>By Route Group</h4>';
2359
+ Object.keys(groups).sort().forEach(g => {
2360
+ const groupPages = groups[g];
2361
+ html += '<div style="margin-bottom:12px">';
2362
+ html += '<div class="detail-item" style="cursor:pointer;background:var(--bg3);border-radius:4px" onclick="toggleGroupList(this)">';
2363
+ html += '<div class="detail-label" style="display:flex;align-items:center;gap:6px"><span class="group-toggle">▸</span>/'+g+'</div>';
2364
+ html += '<span style="color:var(--accent)">'+groupPages.length+' pages</span></div>';
2365
+ html += '<div class="group-page-list" style="display:none;margin-left:16px;margin-top:4px">';
2366
+ groupPages.sort((a,b) => a.path.localeCompare(b.path)).forEach(p => {
2367
+ const isAuth = p.authentication?.required;
2368
+ const isDynamic = p.path.includes('[');
2369
+ html += '<div class="detail-item rel-item" style="cursor:pointer;padding:6px 8px" onclick="event.stopPropagation(); selectPage(\\''+p.path+'\\')">'+
2370
+ '<span style="font-family:monospace;font-size:11px;color:var(--text)">'+p.path+'</span>'+
2371
+ (isAuth ? '<span class="tag tag-auth" style="margin-left:6px;font-size:9px">AUTH</span>' : '')+
2372
+ (isDynamic ? '<span class="tag" style="margin-left:6px;font-size:9px;background:#6366f1">DYNAMIC</span>' : '')+
2373
+ '</div>';
2374
+ });
2375
+ html += '</div></div>';
2376
+ });
2377
+ html += '</div>';
2378
+
2379
+ document.getElementById('detail-title').textContent = 'Pages Overview';
2380
+ document.getElementById('detail-body').innerHTML = html;
2381
+ document.getElementById('detail').classList.add('open');
2382
+ }
2383
+
2384
+ window.toggleGroupList = function(el) {
2385
+ const list = el.nextElementSibling;
2386
+ const toggle = el.querySelector('.group-toggle');
2387
+ if (list.style.display === 'none') {
2388
+ list.style.display = 'block';
2389
+ toggle.textContent = '▾';
2390
+ } else {
2391
+ list.style.display = 'none';
2392
+ toggle.textContent = '▸';
2393
+ }
2394
+ };
2395
+
2396
+ window.filterByGroup = function(group) {
2397
+ document.querySelectorAll('.group').forEach(g => {
2398
+ const name = g.querySelector('.group-name')?.textContent || '';
2399
+ g.style.display = name.includes('/'+group) ? '' : 'none';
2400
+ });
2401
+ };
2402
+
2403
+ function showHierarchiesList() {
2404
+ const hierarchyRels = relations.filter(r => r.type === 'parent-child');
2405
+
2406
+ // Build tree structure
2407
+ const roots = pages.filter(p => !p.parent && p.children && p.children.length > 0);
2408
+
2409
+ let html = '<div class="detail-section"><h4>Page Hierarchies ('+hierarchyRels.length+' relationships)</h4>';
2410
+
2411
+ if (roots.length === 0) {
2412
+ html += '<div style="color:var(--text2);font-size:12px">No hierarchical pages found</div>';
2413
+ } else {
2414
+ roots.forEach(root => {
2415
+ html += renderHierarchyTree(root, 0);
2416
+ });
2417
+ }
2418
+ html += '</div>';
2419
+
2420
+ document.getElementById('detail-title').textContent = 'Page Hierarchies';
2421
+ document.getElementById('detail-body').innerHTML = html;
2422
+ document.getElementById('detail').classList.add('open');
2423
+
2424
+ // Highlight hierarchical pages in the list
2425
+ document.querySelectorAll('.page-item').forEach(item => {
2426
+ const path = item.dataset.path;
2427
+ const page = pageMap.get(path);
2428
+ const hasHierarchy = page && (page.parent || (page.children && page.children.length > 0));
2429
+ item.style.opacity = hasHierarchy ? '1' : '0.4';
2430
+ });
2431
+ }
2432
+
2433
+ function renderHierarchyTree(page, depth) {
2434
+ const indent = depth * 12;
2435
+ let html = '<div class="rel-item" style="padding-left:'+(10+indent)+'px" onclick="event.stopPropagation(); selectPage(\\''+page.path+'\\')">';
2436
+ html += '<div class="rel-header">';
2437
+ html += '<span style="color:var(--text2);font-size:10px">'+'─'.repeat(depth > 0 ? 1 : 0)+(depth > 0 ? ' ' : '')+'</span>';
2438
+ html += '<span class="rel-path">'+page.path+'</span>';
2439
+ if (page.children && page.children.length > 0) {
2440
+ html += '<span style="color:var(--text2);font-size:9px;margin-left:auto">'+page.children.length+' children</span>';
2441
+ }
2442
+ html += '</div></div>';
2443
+
2444
+ if (page.children) {
2445
+ page.children.forEach(childPath => {
2446
+ const child = pageMap.get(childPath);
2447
+ if (child) {
2448
+ html += renderHierarchyTree(child, depth + 1);
2449
+ }
2450
+ });
2451
+ }
2452
+ return html;
2453
+ }
2454
+
2455
+ // Register stat click handlers
2456
+ document.querySelectorAll('.stat[data-filter]').forEach(stat => {
2457
+ stat.addEventListener('click', function(e) {
2458
+ e.stopPropagation();
2459
+ const filterType = this.getAttribute('data-filter');
2460
+ handleStatClick(filterType, this);
2461
+ });
2462
+ });
2463
+
2464
+ function showAllPages() {
2465
+ // Reset all groups and page items to visible
2466
+ document.querySelectorAll('.group').forEach(g => {
2467
+ g.style.removeProperty('display');
2468
+ g.style.display = 'block';
2469
+ });
2470
+ document.querySelectorAll('.page-item').forEach(p => {
2471
+ p.style.removeProperty('display');
2472
+ p.style.display = 'flex';
2473
+ });
2474
+ }
2475
+
2476
+ function showGraphQLList() {
2477
+ // Show GraphQL operations in detail panel
2478
+ let html = '<div class="detail-section"><h4>All GraphQL Operations ('+Object.keys(Object.fromEntries(gqlMap)).length+')</h4>';
2479
+
2480
+ const queries = Array.from(gqlMap.values()).filter(o => o.type === 'query');
2481
+ const mutations = Array.from(gqlMap.values()).filter(o => o.type === 'mutation');
2482
+ const fragments = Array.from(gqlMap.values()).filter(o => o.type === 'fragment');
2483
+
2484
+ if (queries.length > 0) {
2485
+ html += '<div style="margin:8px 0;font-size:11px;color:var(--accent)">Queries ('+queries.length+')</div>';
2486
+ queries.slice(0, 20).forEach(op => {
2487
+ html += '<div class="detail-item data-op" onclick="event.stopPropagation(); showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2488
+ '<span class="tag tag-query">QUERY</span> '+op.name+'</div>';
2489
+ });
2490
+ if (queries.length > 20) {
2491
+ html += '<div style="color:var(--text2);font-size:10px;padding:4px">... and '+(queries.length-20)+' more queries</div>';
2492
+ }
2493
+ }
2494
+
2495
+ if (mutations.length > 0) {
2496
+ html += '<div style="margin:8px 0;font-size:11px;color:var(--accent)">Mutations ('+mutations.length+')</div>';
2497
+ mutations.slice(0, 10).forEach(op => {
2498
+ html += '<div class="detail-item data-op" onclick="event.stopPropagation(); showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2499
+ '<span class="tag tag-mutation">MUTATION</span> '+op.name+'</div>';
2500
+ });
2501
+ if (mutations.length > 10) {
2502
+ html += '<div style="color:var(--text2);font-size:10px;padding:4px">... and '+(mutations.length-10)+' more mutations</div>';
2503
+ }
2504
+ }
2505
+
2506
+ if (fragments.length > 0) {
2507
+ html += '<div style="margin:8px 0;font-size:11px;color:var(--accent)">Fragments ('+fragments.length+')</div>';
2508
+ fragments.slice(0, 5).forEach(op => {
2509
+ html += '<div class="detail-item data-op" onclick="event.stopPropagation(); showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2510
+ '<span class="tag" style="background:#6b7280">FRAGMENT</span> '+op.name+'</div>';
2511
+ });
2512
+ if (fragments.length > 5) {
2513
+ html += '<div style="color:var(--text2);font-size:10px;padding:4px">... and '+(fragments.length-5)+' more fragments</div>';
2514
+ }
2515
+ }
2516
+
2517
+ html += '</div>';
2518
+
2519
+ document.getElementById('detail-title').textContent = 'GraphQL Operations';
2520
+ document.getElementById('detail-body').innerHTML = html;
2521
+ document.getElementById('detail').classList.add('open');
2522
+ }
2523
+
2524
+ function showRestApiList() {
2525
+ const apis = window.apiCalls || [];
2526
+ let html = '<div class="detail-section"><h4>REST API Calls ('+apis.length+')</h4>';
2527
+
2528
+ if (apis.length === 0) {
2529
+ html += '<div style="color:var(--text2);font-size:12px">No REST API calls detected</div>';
2530
+ } else {
2531
+ apis.forEach(api => {
2532
+ const methodColors = {GET:'#22c55e',POST:'#3b82f6',PUT:'#f59e0b',DELETE:'#ef4444',PATCH:'#8b5cf6'};
2533
+ const color = methodColors[api.method] || '#6b7280';
2534
+ html += '<div class="detail-item api-item" onclick="event.stopPropagation(); showApiDetail(\\''+api.id.replace(/'/g, "\\\\'")+'\\')">' +
2535
+ '<div class="api-row"><span class="tag" style="background:'+color+'">'+api.method+'</span><span class="api-url">'+api.url+'</span></div>' +
2536
+ '<div class="api-route">'+api.callType+' in '+api.filePath+'</div>' +
2537
+ '</div>';
2538
+ });
2539
+ }
2540
+
2541
+ html += '</div>';
2542
+
2543
+ document.getElementById('detail-title').textContent = 'REST API Calls';
2544
+ document.getElementById('detail-body').innerHTML = html;
2545
+ document.getElementById('detail').classList.add('open');
2546
+ }
2547
+
2548
+ function showApiDetail(id) {
2549
+ const api = (window.apiCalls || []).find(a => a.id === id);
2550
+ if (!api) return;
2551
+
2552
+ const methodColors = {GET:'#22c55e',POST:'#3b82f6',PUT:'#f59e0b',DELETE:'#ef4444',PATCH:'#8b5cf6'};
2553
+ const color = methodColors[api.method] || '#6b7280';
2554
+
2555
+ let html = '<div class="detail-section"><h4>Method</h4>' +
2556
+ '<span class="tag" style="background:'+color+';color:white;font-size:14px;padding:4px 12px">'+api.method+'</span></div>';
2557
+
2558
+ html += '<div class="detail-section"><h4>URL</h4>' +
2559
+ '<code style="background:#0f172a;color:#93c5fd;padding:8px 12px;border-radius:4px;font-family:monospace;display:block;word-break:break-all">'+api.url+'</code></div>';
2560
+
2561
+ html += '<div class="detail-section"><h4>Details</h4>' +
2562
+ '<div class="detail-item"><div class="detail-label">TYPE</div>'+api.callType+'</div>' +
2563
+ '<div class="detail-item"><div class="detail-label">FILE</div><code style="background:#0f172a;color:#93c5fd;padding:2px 6px;border-radius:3px;font-family:monospace;font-size:11px">'+api.filePath+'</code></div>' +
2564
+ (api.line ? '<div class="detail-item"><div class="detail-label">LINE</div>'+api.line+'</div>' : '') +
2565
+ (api.containingFunction ? '<div class="detail-item"><div class="detail-label">FUNCTION</div><code style="background:#0f172a;color:#93c5fd;padding:2px 6px;border-radius:3px;font-family:monospace">'+api.containingFunction+'</code></div>' : '') +
2566
+ '</div>';
2567
+
2568
+ if (api.category && api.category !== 'internal') {
2569
+ html += '<div class="detail-section"><h4>Category</h4>' +
2570
+ '<span class="tag" style="background:var(--accent);color:white">'+api.category.toUpperCase()+'</span></div>';
2571
+ }
2572
+
2573
+ // Show in modal
2574
+ modalHistory.push({ type: 'api', data: api });
2575
+ updateBackButton();
2576
+
2577
+ document.getElementById('modal-title').textContent = api.method + ' ' + (api.url.length > 40 ? api.url.substring(0, 40) + '...' : api.url);
2578
+ document.getElementById('modal-body').innerHTML = html;
2579
+ document.getElementById('modal').classList.add('open');
2580
+ }
2581
+
2582
+ // Close detail when clicking outside
2583
+ document.addEventListener('click', (e) => {
2584
+ const detail = document.getElementById('detail');
2585
+ const isDetailOpen = detail.classList.contains('open');
2586
+ if (!isDetailOpen) return;
2587
+
2588
+ // Check if click is inside detail panel or on interactive elements
2589
+ if (detail.contains(e.target)) return;
2590
+ if (e.target.closest('.page-item')) return;
2591
+ if (e.target.closest('.node-circle')) return;
2592
+ if (e.target.closest('.modal')) return;
2593
+ if (e.target.closest('.stat')) return;
2594
+ if (e.target.closest('.data-op')) return;
2595
+ if (e.target.closest('#graph-canvas')) return; // Handled by canvas.onmousedown
2596
+
2597
+ closeDetail();
2598
+ });
2599
+
2600
+ // Expand "more" items
2601
+ window.expandMore = function(type, items, btn) {
2602
+ const container = btn.parentElement;
2603
+ let html = '';
2604
+ items.forEach(item => {
2605
+ if (type === 'usedIn') {
2606
+ html += '<div class="detail-item">'+item+'</div>';
2607
+ } else if (type === 'query' || type === 'mutation') {
2608
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+item.name.replace(/'/g, "\\\\'")+'\\')">' +
2609
+ '<span class="tag '+(type==='mutation'?'tag-mutation':'tag-query')+'">'+type.toUpperCase()+'</span> '+item.name+'</div>';
2610
+ } else if (type === 'fragment') {
2611
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+item.name.replace(/'/g, "\\\\'")+'\\')">' +
2612
+ '<span class="tag" style="background:#6b7280">FRAGMENT</span> '+item.name+'</div>';
2613
+ }
2614
+ });
2615
+ container.innerHTML = html;
2616
+ };
2617
+
2618
+ function showDataDetail(rawName, sourcePath) {
2619
+ // Clean up name: remove "→ " prefix and " (ComponentName)" suffix
2620
+ const name = rawName
2621
+ .replace(/^[→\\->\\s]+/, '')
2622
+ .replace(/\\s*\\([^)]+\\)\\s*$/, '');
2623
+
2624
+ // Convert SCREAMING_CASE to PascalCase (e.g., COMPANY_QUERY → CompanyQuery)
2625
+ const toPascalCase = (str) => {
2626
+ if (!/^[A-Z][A-Z0-9_]*$/.test(str)) return str;
2627
+ return str.toLowerCase().split('_').map(word =>
2628
+ word.charAt(0).toUpperCase() + word.slice(1)
2629
+ ).join('');
2630
+ };
2631
+
2632
+ // Try to find GraphQL operation with various name patterns
2633
+ let op = gqlMap.get(name);
2634
+
2635
+ // Try PascalCase conversion for SCREAMING_CASE constants
2636
+ if (!op) {
2637
+ const pascalName = toPascalCase(name);
2638
+ if (pascalName !== name) {
2639
+ op = gqlMap.get(pascalName);
2640
+ }
2641
+ }
2642
+
2643
+ // If not found, try removing common suffixes (Query, Mutation, Document)
2644
+ // But don't remove if it would result in an empty string
2645
+ if (!op) {
2646
+ const baseName = name.replace(/Query$|Mutation$|Document$/, '');
2647
+ if (baseName) { // Only try if baseName is not empty
2648
+ op = gqlMap.get(baseName);
2649
+ }
2650
+ }
2651
+
2652
+ // Also try with suffix if original didn't have one
2653
+ if (!op && !name.match(/Query$|Mutation$/)) {
2654
+ op = gqlMap.get(name + 'Query') || gqlMap.get(name + 'Mutation');
2655
+ }
2656
+
2657
+ // Try PascalCase with suffix
2658
+ if (!op) {
2659
+ const pascalBase = toPascalCase(name.replace(/_QUERY$|_MUTATION$|_DOCUMENT$/i, ''));
2660
+ if (pascalBase !== name) {
2661
+ op = gqlMap.get(pascalBase + 'Query') || gqlMap.get(pascalBase + 'Mutation') || gqlMap.get(pascalBase);
2662
+ }
2663
+ }
2664
+
2665
+ let html = '';
2666
+
2667
+ // Check if this is a known component
2668
+ const comp = compMap.get(rawName) || compMap.get(name);
2669
+
2670
+ if (op) {
2671
+ // Found GraphQL operation
2672
+ html = '<div class="detail-section"><h4>Type</h4><span class="tag '+(op.type==='mutation'?'tag-mutation':'tag-query')+'">'+op.type.toUpperCase()+'</span></div>';
2673
+
2674
+ // Source info
2675
+ if (sourcePath) {
2676
+ const isHook = sourcePath.startsWith('use');
2677
+ html += '<div class="detail-section"><h4>Source</h4><div class="detail-item" style="font-size:12px">via '+(isHook?'Hook':'Component')+': <span style="color:var(--accent)">'+sourcePath+'</span></div></div>';
2678
+ }
2679
+
2680
+ // Operation Name with copy button
2681
+ html += '<div class="detail-section"><h4 style="display:flex;justify-content:space-between;align-items:center">Operation Name<button class="copy-btn" onclick="copyToClipboard(\\''+op.name+'\\', this)" title="Copy operation name">📋</button></h4>';
2682
+ html += '<code style="background:#0f172a;color:#93c5fd;padding:4px 8px;border-radius:4px;font-family:monospace">'+op.name+'</code></div>';
2683
+
2684
+ if (op.returnType) {
2685
+ html += '<div class="detail-section"><h4>Return Type</h4><code style="background:#0f172a;color:#93c5fd;padding:4px 8px;border-radius:4px;font-family:monospace">'+op.returnType+'</code></div>';
2686
+ }
2687
+ if (op.fields?.length) {
2688
+ // Show full GraphQL operation structure
2689
+ const opKeyword = op.type === 'mutation' ? 'mutation' : (op.type === 'fragment' ? 'fragment' : 'query');
2690
+ const varStr = op.variables?.length ? '(' + op.variables.map(v => '$' + v.name + ': ' + v.type).join(', ') + ')' : '';
2691
+ const fragmentOn = op.type === 'fragment' && op.returnType ? ' on ' + op.returnType : '';
2692
+
2693
+ let gqlCode = opKeyword + ' ' + op.name + varStr + fragmentOn + ' {\\n';
2694
+ gqlCode += formatFields(op.fields, 1);
2695
+ gqlCode += '\\n}';
2696
+
2697
+ // Escape for data attribute
2698
+ const gqlCodeEscaped = gqlCode.replace(/'/g, "\\\\'").replace(/"/g, '&quot;');
2699
+
2700
+ html += '<div class="detail-section"><h4 style="display:flex;justify-content:space-between;align-items:center">GraphQL<button class="copy-btn" onclick="copyGqlCode(this)" data-code="'+gqlCodeEscaped+'" title="Copy GraphQL">📋</button></h4>';
2701
+ html += '<pre style="background:#0f172a;color:#e2e8f0;padding:12px;border-radius:6px;font-size:11px;overflow-x:auto;white-space:pre;max-height:300px;overflow-y:auto">' + gqlCode + '</pre></div>';
2702
+ } else if (op.variables?.length) {
2703
+ html += '<div class="detail-section"><h4>Variables</h4>';
2704
+ op.variables.forEach(v => { html += '<div class="detail-item">'+v.name+': <code style="background:#0f172a;color:#93c5fd;padding:2px 6px;border-radius:3px;font-family:monospace">'+v.type+'</code>'+(v.required?' (required)':'')+'</div>'; });
2705
+ html += '</div>';
2706
+ }
2707
+ if (op.usedIn?.length) {
2708
+ html += '<div class="detail-section"><h4>Used In ('+op.usedIn.length+' files)</h4>';
2709
+ op.usedIn.slice(0,8).forEach(f => { html += '<div class="detail-item">'+f+'</div>'; });
2710
+ if (op.usedIn.length > 8) {
2711
+ const remaining = op.usedIn.slice(8);
2712
+ html += '<div class="expand-more" onclick="expandMore(\\'usedIn\\', '+JSON.stringify(remaining).replace(/"/g, '&quot;')+', this)" style="color:var(--accent);font-size:11px;cursor:pointer;padding:4px 0">▸ Show '+(op.usedIn.length-8)+' more files</div>';
2713
+ }
2714
+ html += '</div>';
2715
+ }
2716
+ } else if (comp) {
2717
+ // Found component - find GraphQL operations DIRECTLY used in this component's file
2718
+ const compFile = comp.filePath;
2719
+
2720
+ // Priority 1: Operations directly used in this file
2721
+ const directOps = graphqlOps.filter(op =>
2722
+ op.usedIn?.some(f => f === compFile || f.endsWith('/' + compFile))
2723
+ );
2724
+
2725
+ // Priority 2: Operations with name matching component name pattern
2726
+ const compNameBase = comp.name.replace(/Container$|Page$|Component$|View$/, '');
2727
+ const matchingOps = directOps.length === 0 ? graphqlOps.filter(op =>
2728
+ op.name.includes(compNameBase) || compNameBase.includes(op.name.replace(/Query$|Mutation$/, ''))
2729
+ ) : [];
2730
+
2731
+ const featureOps = directOps.length > 0 ? directOps : matchingOps;
2732
+
2733
+ html = '<div class="detail-section"><h4>Component</h4>' +
2734
+ '<div class="detail-item"><div class="detail-label">NAME</div><strong>'+comp.name+'</strong></div>' +
2735
+ '<div class="detail-item"><div class="detail-label">FILE</div><code style="background:#0f172a;color:#93c5fd;padding:2px 6px;border-radius:3px;font-family:monospace;font-size:11px">'+comp.filePath+'</code></div>' +
2736
+ '<div class="detail-item"><div class="detail-label">TYPE</div>'+comp.type+'</div>' +
2737
+ '</div>';
2738
+
2739
+ if (featureOps.length > 0) {
2740
+ // Group by type
2741
+ const queries = featureOps.filter(o => o.type === 'query');
2742
+ const mutations = featureOps.filter(o => o.type === 'mutation');
2743
+ const fragments = featureOps.filter(o => o.type === 'fragment');
2744
+
2745
+ html += '<div class="detail-section"><h4>GraphQL Operations in Feature ('+featureOps.length+')</h4>';
2746
+
2747
+ if (queries.length > 0) {
2748
+ html += '<div style="margin-bottom:8px;font-size:10px;color:var(--text2)">Queries ('+queries.length+')</div>';
2749
+ queries.slice(0,5).forEach(op => {
2750
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2751
+ '<span class="tag tag-query">QUERY</span> '+op.name+'</div>';
2752
+ });
2753
+ if (queries.length > 5) {
2754
+ const remaining = queries.slice(5).map(o => ({name: o.name}));
2755
+ html += '<div class="expand-more" onclick="expandMore(\\'query\\', '+JSON.stringify(remaining).replace(/"/g, '&quot;')+', this)" style="color:var(--accent);font-size:10px;cursor:pointer;padding:4px 0">▸ Show ' + (queries.length - 5) + ' more queries</div>';
2756
+ }
2757
+ }
2758
+
2759
+ if (mutations.length > 0) {
2760
+ html += '<div style="margin:8px 0;font-size:10px;color:var(--text2)">Mutations ('+mutations.length+')</div>';
2761
+ mutations.slice(0,5).forEach(op => {
2762
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2763
+ '<span class="tag tag-mutation">MUTATION</span> '+op.name+'</div>';
2764
+ });
2765
+ if (mutations.length > 5) {
2766
+ const remaining = mutations.slice(5).map(o => ({name: o.name}));
2767
+ html += '<div class="expand-more" onclick="expandMore(\\'mutation\\', '+JSON.stringify(remaining).replace(/"/g, '&quot;')+', this)" style="color:var(--accent);font-size:10px;cursor:pointer;padding:4px 0">▸ Show ' + (mutations.length - 5) + ' more mutations</div>';
2768
+ }
2769
+ }
2770
+
2771
+ if (fragments.length > 0) {
2772
+ html += '<div style="margin:8px 0;font-size:10px;color:var(--text2)">Fragments ('+fragments.length+')</div>';
2773
+ fragments.slice(0,3).forEach(op => {
2774
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2775
+ '<span class="tag" style="background:#6b7280">FRAGMENT</span> '+op.name+'</div>';
2776
+ });
2777
+ if (fragments.length > 3) {
2778
+ const remaining = fragments.slice(3).map(o => ({name: o.name}));
2779
+ html += '<div class="expand-more" onclick="expandMore(\\'fragment\\', '+JSON.stringify(remaining).replace(/"/g, '&quot;')+', this)" style="color:var(--accent);font-size:10px;cursor:pointer;padding:4px 0">▸ Show ' + (fragments.length - 3) + ' more fragments</div>';
2780
+ }
2781
+ }
2782
+
2783
+ html += '</div>';
2784
+ }
2785
+
2786
+ // Show dependencies if any contain Query/Mutation
2787
+ const gqlDeps = comp.dependencies?.filter(d => /Query|Mutation|Document|Fragment/.test(d)) || [];
2788
+ if (gqlDeps.length > 0) {
2789
+ html += '<div class="detail-section"><h4>Direct Dependencies</h4>';
2790
+ gqlDeps.forEach(d => {
2791
+ const depOp = gqlMap.get(d.replace(/Document$/,''));
2792
+ if (depOp) {
2793
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+depOp.name.replace(/'/g, "\\\\'")+'\\')">' +
2794
+ '<span class="tag '+(depOp.type==='mutation'?'tag-mutation':'tag-query')+'">'+depOp.type.toUpperCase()+'</span> '+depOp.name+'</div>';
2795
+ } else {
2796
+ html += '<div class="detail-item">'+d+'</div>';
2797
+ }
2798
+ });
2799
+ html += '</div>';
2800
+ }
2801
+ } else {
2802
+ // Clean up the name: remove "→ " prefix and " (ComponentName)" suffix
2803
+ let cleanName = name
2804
+ .replace(/^[→\\->\\s]+/, '') // Remove arrow prefix
2805
+ .replace(/\\s*\\([^)]+\\)\\s*$/, ''); // Remove parenthetical component reference
2806
+
2807
+ // Extract the core component name - remove ALL common suffixes iteratively
2808
+ const suffixes = ['Container', 'Page', 'Wrapper', 'Form', 'Component', 'View', 'Modal', 'Dialog', 'Body', 'Content', 'Section', 'Header', 'Footer', 'Root', 'Screen', 'Panel'];
2809
+ let coreName = cleanName;
2810
+ let changed = true;
2811
+ while (changed) {
2812
+ changed = false;
2813
+ for (const suffix of suffixes) {
2814
+ if (coreName.endsWith(suffix) && coreName.length > suffix.length) {
2815
+ coreName = coreName.slice(0, -suffix.length);
2816
+ changed = true;
2817
+ break;
2818
+ }
2819
+ }
2820
+ }
2821
+
2822
+ // Split core name into keywords
2823
+ const rawKeywords = coreName
2824
+ .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase split
2825
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // consecutive caps
2826
+ .split(/\\s+/)
2827
+ .filter(k => k.length > 1); // Minimum 2 chars
2828
+
2829
+ // Build search patterns
2830
+ const strictPattern = coreName.toLowerCase();
2831
+ const kebabPattern = rawKeywords.join('-').toLowerCase();
2832
+ // All keywords >= 4 chars for broader matching
2833
+ const significantKeywords = rawKeywords.filter(k => k.length >= 4).map(k => k.toLowerCase());
2834
+ const primaryKeyword = significantKeywords[0] || '';
2835
+
2836
+ // Priority 1: Exact match in operation name
2837
+ let relatedOps = graphqlOps.filter(op => {
2838
+ const opNameLower = op.name.toLowerCase().replace(/query$|mutation$|fragment$/i, '');
2839
+ return opNameLower === strictPattern ||
2840
+ opNameLower === kebabPattern ||
2841
+ opNameLower.includes(strictPattern) ||
2842
+ strictPattern.includes(opNameLower);
2843
+ });
2844
+
2845
+ // Priority 2: Match in usedIn file path
2846
+ if (relatedOps.length === 0) {
2847
+ relatedOps = graphqlOps.filter(op =>
2848
+ op.usedIn?.some(f => {
2849
+ const fLower = f.toLowerCase();
2850
+ return fLower.includes(kebabPattern) || fLower.includes(strictPattern) ||
2851
+ significantKeywords.some(k => fLower.includes(k));
2852
+ })
2853
+ );
2854
+ }
2855
+
2856
+ // Priority 3: Any significant keyword in operation name
2857
+ if (relatedOps.length === 0 && significantKeywords.length > 0) {
2858
+ relatedOps = graphqlOps.filter(op => {
2859
+ const opLower = op.name.toLowerCase();
2860
+ return significantKeywords.some(k => opLower.includes(k));
2861
+ });
2862
+ }
2863
+
2864
+ // Priority 4: Single keyword match in usedIn paths (most flexible)
2865
+ if (relatedOps.length === 0 && primaryKeyword) {
2866
+ relatedOps = graphqlOps.filter(op =>
2867
+ op.usedIn?.some(f => f.toLowerCase().includes(primaryKeyword))
2868
+ );
2869
+ }
2870
+
2871
+ // Deduplicate and limit
2872
+ const uniqueOps = [...new Map(relatedOps.map(op => [op.name, op])).values()].slice(0, 15);
2873
+ const searchTerms = rawKeywords;
2874
+
2875
+ html = '<div class="detail-section"><h4>Component</h4>' +
2876
+ '<div class="detail-item"><strong>'+name+'</strong></div>' +
2877
+ (searchTerms.length > 0 ? '<div class="detail-item" style="font-size:11px;color:var(--text2)">Keywords: '+searchTerms.join(', ')+'</div>' : '') +
2878
+ '</div>';
2879
+
2880
+ if (uniqueOps.length > 0) {
2881
+ const queries = uniqueOps.filter(o => o.type === 'query');
2882
+ const mutations = uniqueOps.filter(o => o.type === 'mutation');
2883
+ const fragments = uniqueOps.filter(o => o.type === 'fragment');
2884
+
2885
+ html += '<div class="detail-section"><h4>Related GraphQL ('+uniqueOps.length+')</h4>';
2886
+
2887
+ if (queries.length > 0) {
2888
+ html += '<div style="margin-bottom:6px;font-size:10px;color:var(--text2)">Queries ('+queries.length+')</div>';
2889
+ queries.slice(0, 8).forEach(op => {
2890
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2891
+ '<span class="tag tag-query">QUERY</span> '+op.name+'</div>';
2892
+ });
2893
+ if (queries.length > 8) {
2894
+ const remaining = queries.slice(8).map(o => ({name: o.name}));
2895
+ html += '<div class="expand-more" onclick="expandMore(\\'query\\', '+JSON.stringify(remaining).replace(/"/g, '&quot;')+', this)" style="color:var(--accent);font-size:10px;cursor:pointer;padding:4px 0">▸ Show ' + (queries.length - 8) + ' more</div>';
2896
+ }
2897
+ }
2898
+
2899
+ if (mutations.length > 0) {
2900
+ html += '<div style="margin:8px 0 6px;font-size:10px;color:var(--text2)">Mutations ('+mutations.length+')</div>';
2901
+ mutations.slice(0, 5).forEach(op => {
2902
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2903
+ '<span class="tag tag-mutation">MUTATION</span> '+op.name+'</div>';
2904
+ });
2905
+ if (mutations.length > 5) {
2906
+ const remaining = mutations.slice(5).map(o => ({name: o.name}));
2907
+ html += '<div class="expand-more" onclick="expandMore(\\'mutation\\', '+JSON.stringify(remaining).replace(/"/g, '&quot;')+', this)" style="color:var(--accent);font-size:10px;cursor:pointer;padding:4px 0">▸ Show ' + (mutations.length - 5) + ' more</div>';
2908
+ }
2909
+ }
2910
+
2911
+ if (fragments.length > 0) {
2912
+ html += '<div style="margin:8px 0 6px;font-size:10px;color:var(--text2)">Fragments ('+fragments.length+')</div>';
2913
+ fragments.slice(0, 3).forEach(op => {
2914
+ html += '<div class="detail-item data-op" onclick="showDataDetail(\\''+op.name.replace(/'/g, "\\\\'")+'\\')">' +
2915
+ '<span class="tag" style="background:#6b7280">FRAGMENT</span> '+op.name+'</div>';
2916
+ });
2917
+ if (fragments.length > 3) {
2918
+ const remaining = fragments.slice(3).map(o => ({name: o.name}));
2919
+ html += '<div class="expand-more" onclick="expandMore(\\'fragment\\', '+JSON.stringify(remaining).replace(/"/g, '&quot;')+', this)" style="color:var(--accent);font-size:10px;cursor:pointer;padding:4px 0">▸ Show ' + (fragments.length - 3) + ' more</div>';
2920
+ }
2921
+ }
2922
+
2923
+ html += '</div>';
2924
+ } else {
2925
+ html += '<div class="detail-section" style="color:var(--text2);font-size:12px">' +
2926
+ 'No directly related GraphQL operations found.<br>' +
2927
+ 'The component may use operations defined elsewhere or use inline queries.' +
2928
+ '</div>';
2929
+ }
2930
+ }
2931
+
2932
+ // Add to history for back navigation
2933
+ modalHistory.push(name);
2934
+ updateBackButton();
2935
+
2936
+ document.getElementById('modal-title').textContent = name;
2937
+ document.getElementById('modal-body').innerHTML = html;
2938
+ document.getElementById('modal').classList.add('open');
2939
+ }
2940
+
2941
+ // Keep old function for compatibility
2942
+ function showGQL(name) { showDataDetail(name, false); }
2943
+
2944
+ function formatFields(fields, indent) {
2945
+ if (!fields?.length) return '';
2946
+ const lines = [];
2947
+ fields.forEach(f => {
2948
+ const prefix = ' '.repeat(indent);
2949
+ if (f.fields?.length) {
2950
+ lines.push(prefix + f.name + ' {');
2951
+ lines.push(formatFields(f.fields, indent + 1));
2952
+ lines.push(prefix + '}');
2953
+ } else {
2954
+ lines.push(prefix + f.name);
2955
+ }
2956
+ });
2957
+ return lines.join('\\n');
2958
+ }
2959
+
2960
+ function showModal(title, html) {
2961
+ document.getElementById('modal-title').textContent = title;
2962
+ document.getElementById('modal-body').innerHTML = html;
2963
+ document.getElementById('modal').classList.add('open');
2964
+ }
2965
+
2966
+ function closeModal() {
2967
+ document.getElementById('modal').classList.remove('open');
2968
+ modalHistory.length = 0; // Clear history when closing
2969
+ document.getElementById('modal-back').style.display = 'none';
2970
+ }
2971
+
2972
+ // Copy functions
2973
+ window.copyToClipboard = function(text, btn) {
2974
+ navigator.clipboard.writeText(text).then(() => {
2975
+ const originalText = btn.textContent;
2976
+ btn.textContent = '✓';
2977
+ btn.classList.add('copied');
2978
+ setTimeout(() => {
2979
+ btn.textContent = originalText;
2980
+ btn.classList.remove('copied');
2981
+ }, 1500);
2982
+ }).catch(err => {
2983
+ console.error('Failed to copy:', err);
2984
+ });
2985
+ };
2986
+
2987
+ window.copyGqlCode = function(btn) {
2988
+ const code = btn.getAttribute('data-code')
2989
+ .replace(/&quot;/g, '"')
2990
+ .replace(/\\\\'/g, "'")
2991
+ .replace(/\\\\n/g, '\\n');
2992
+ copyToClipboard(code, btn);
2993
+ };
2994
+
2995
+ function handleModalOutsideClick() {
2996
+ // If there's history, go back instead of closing
2997
+ if (modalHistory.length > 1) {
2998
+ modalBack();
2999
+ } else {
3000
+ closeModal();
3001
+ }
3002
+ }
3003
+
3004
+ function modalBack() {
3005
+ if (modalHistory.length > 1) {
3006
+ modalHistory.pop(); // Remove current
3007
+ const prevName = modalHistory.pop(); // Get previous (will be re-added by showDataDetail)
3008
+ showDataDetail(prevName);
3009
+ } else {
3010
+ closeModal();
3011
+ }
3012
+ }
3013
+
3014
+ function updateBackButton() {
3015
+ document.getElementById('modal-back').style.display = modalHistory.length > 1 ? 'block' : 'none';
3016
+ }
3017
+
3018
+ function filter(q) {
3019
+ q = q.toLowerCase().trim();
3020
+
3021
+ // Filter ALL page items (including hidden "load more" items)
3022
+ document.querySelectorAll('.page-item').forEach(el => {
3023
+ const path = (el.dataset.path || '').toLowerCase();
3024
+ const text = el.textContent.toLowerCase();
3025
+ const matches = !q || path.includes(q) || text.includes(q);
3026
+
3027
+ if (matches) {
3028
+ // Show matching items (even if they were hidden by "load more")
3029
+ el.style.display = '';
3030
+ el.removeAttribute('data-hidden');
3031
+ } else {
3032
+ el.style.display = 'none';
3033
+ }
3034
+ });
3035
+
3036
+ // Show/hide groups based on whether they have visible items
3037
+ document.querySelectorAll('.group').forEach(group => {
3038
+ const hasVisible = Array.from(group.querySelectorAll('.page-item')).some(el => el.style.display !== 'none');
3039
+ group.style.display = hasVisible || !q ? '' : 'none';
3040
+
3041
+ // If searching, expand collapsed groups that have matches
3042
+ if (q && hasVisible) {
3043
+ group.classList.remove('collapsed');
3044
+ }
3045
+ });
3046
+
3047
+ // Hide/show "load more" buttons based on search state
3048
+ document.querySelectorAll('[id$="-more"]').forEach(btn => {
3049
+ btn.style.display = q ? 'none' : ''; // Hide load more buttons when searching
3050
+ });
3051
+
3052
+ // If search is cleared, restore hidden items state
3053
+ if (!q) {
3054
+ restoreLoadMoreState();
3055
+ }
3056
+ }
3057
+
3058
+ // Restore the "load more" hidden state when search is cleared
3059
+ function restoreLoadMoreState() {
3060
+ document.querySelectorAll('.group-items, .detail-items').forEach(list => {
3061
+ const moreBtn = document.getElementById(list.id + '-more');
3062
+ if (moreBtn && moreBtn.getAttribute('data-expanded') !== 'true') {
3063
+ // Find items that should be hidden (based on initial limit)
3064
+ const items = list.querySelectorAll('.page-item, .detail-item');
3065
+ const limit = list.classList.contains('detail-items') ? 15 : 30;
3066
+ items.forEach((item, idx) => {
3067
+ if (idx >= limit) {
3068
+ item.style.display = 'none';
3069
+ item.setAttribute('data-hidden', 'true');
3070
+ }
3071
+ });
3072
+ // Show the "load more" button again
3073
+ moreBtn.style.display = '';
3074
+ }
3075
+ });
3076
+ }
3077
+
3078
+ // Improved Graph View with proper layout
3079
+ let canvas, ctx;
3080
+ let graphState = { zoom: 1, panX: 0, panY: 0, nodes: [], edges: [] };
3081
+ let dragging = false, lastX = 0, lastY = 0;
3082
+ let selectedNode = null;
3083
+
3084
+ function initGraph() {
3085
+ canvas = document.getElementById('graph-canvas');
3086
+ ctx = canvas.getContext('2d');
3087
+
3088
+ // Set canvas size
3089
+ const rect = canvas.parentElement.getBoundingClientRect();
3090
+ canvas.width = rect.width * 2;
3091
+ canvas.height = rect.height * 2;
3092
+ canvas.style.width = rect.width + 'px';
3093
+ canvas.style.height = rect.height + 'px';
3094
+ ctx.scale(2, 2);
3095
+
3096
+ // Combine Frontend pages and Rails routes for graph
3097
+ const allGraphPages = [...pages];
3098
+
3099
+ // Add Rails routes as graph nodes (limit to GET routes with views for better visualization)
3100
+ if (typeof railsRoutes !== 'undefined' && railsRoutes.length > 0) {
3101
+ const viewRoutes = railsRoutes.filter(r => r.method === 'GET' && r.path && !r.path.includes('.:format'));
3102
+ const uniqueRoutes = new Map();
3103
+ viewRoutes.forEach(r => {
3104
+ const key = r.path.replace(/:[^/]+/g, ':param');
3105
+ if (!uniqueRoutes.has(key)) {
3106
+ uniqueRoutes.set(key, r);
3107
+ }
3108
+ });
3109
+ Array.from(uniqueRoutes.values()).slice(0, 200).forEach(r => {
3110
+ allGraphPages.push({
3111
+ path: 'rails:' + r.path,
3112
+ isRails: true,
3113
+ controller: r.controller,
3114
+ action: r.action,
3115
+ authentication: { required: false }
3116
+ });
3117
+ });
3118
+ }
3119
+
3120
+ // Build nodes - initial placement by category
3121
+ const groups = new Map();
3122
+ allGraphPages.forEach(p => {
3123
+ const pathStr = p.path || '';
3124
+ const cat = pathStr.startsWith('rails:')
3125
+ ? 'rails/' + (p.controller?.split('/')[0] || 'other')
3126
+ : pathStr.split('/').filter(Boolean)[0] || 'root';
3127
+ if (!groups.has(cat)) groups.set(cat, []);
3128
+ groups.get(cat).push(p);
3129
+ });
3130
+
3131
+ graphState.nodes = [];
3132
+ const catColors = ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#8b5cf6','#ec4899'];
3133
+ let catIdx = 0;
3134
+ const centerX = rect.width / 2;
3135
+ const centerY = rect.height / 2;
3136
+ const catRadius = Math.min(rect.width, rect.height) * 0.3;
3137
+
3138
+ Array.from(groups.entries()).forEach(([cat, catPages], gIdx) => {
3139
+ const catAngle = (gIdx / groups.size) * Math.PI * 2 - Math.PI / 2;
3140
+ const catX = centerX + Math.cos(catAngle) * catRadius;
3141
+ const catY = centerY + Math.sin(catAngle) * catRadius;
3142
+ const color = catColors[catIdx++ % catColors.length];
3143
+
3144
+ // Initial spread with some randomness
3145
+ catPages.forEach((p, pIdx) => {
3146
+ const pageAngle = (pIdx / catPages.length) * Math.PI * 2;
3147
+ const spread = 50 + catPages.length * 5;
3148
+ const x = catX + Math.cos(pageAngle) * spread + (Math.random() - 0.5) * 30;
3149
+ const y = catY + Math.sin(pageAngle) * spread + (Math.random() - 0.5) * 30;
3150
+ const pathStr = p.path || '';
3151
+ const isRails = p.isRails || pathStr.startsWith('rails:');
3152
+ const displayPath = isRails ? pathStr.replace('rails:', '') : pathStr;
3153
+ const label = displayPath.split('/').filter(Boolean).pop() || '/';
3154
+
3155
+ graphState.nodes.push({
3156
+ path: p.path,
3157
+ x, y,
3158
+ vx: 0, vy: 0, // velocity for force simulation
3159
+ radius: isRails ? 6 : 8,
3160
+ color: isRails ? '#f59e0b' : (p.authentication?.required ? '#dc2626' : '#22c55e'),
3161
+ label: label.length > 12 ? label.substring(0,10)+'...' : label,
3162
+ category: cat,
3163
+ catColor: color,
3164
+ isRails: isRails
3165
+ });
3166
+ });
3167
+ });
3168
+
3169
+ // Build edges - parent-child hierarchy
3170
+ graphState.edges = relations.filter(r => r.type === 'parent-child').map(r => ({
3171
+ from: r.from,
3172
+ to: r.to,
3173
+ color: '#475569',
3174
+ type: 'hierarchy'
3175
+ }));
3176
+
3177
+ // Add linkedPages edges (actual navigation links)
3178
+ const nodePathSet = new Set(graphState.nodes.map(n => n.path));
3179
+ pages.forEach(p => {
3180
+ if (p.linkedPages && p.linkedPages.length > 0) {
3181
+ p.linkedPages.forEach(linked => {
3182
+ // Normalize linked path
3183
+ const normalizedLinked = linked.startsWith('/') ? linked : '/' + linked;
3184
+ // Check if target exists in our pages (exact match or prefix match for dynamic routes)
3185
+ const targetExists = nodePathSet.has(normalizedLinked) ||
3186
+ Array.from(nodePathSet).some(path => {
3187
+ // Handle dynamic routes: /user/[id] matches /user/123
3188
+ const pathPattern = path.replace(/\\[\\w+\\]/g, '[^/]+');
3189
+ return new RegExp('^' + pathPattern + '$').test(normalizedLinked);
3190
+ });
3191
+
3192
+ if (targetExists || nodePathSet.has(normalizedLinked.split('?')[0])) {
3193
+ const targetPath = normalizedLinked.split('?')[0];
3194
+ // Avoid duplicate edges
3195
+ const existingEdge = graphState.edges.find(e =>
3196
+ (e.from === p.path && e.to === targetPath) ||
3197
+ (e.from === targetPath && e.to === p.path && e.type === 'link')
3198
+ );
3199
+ if (!existingEdge && p.path !== targetPath) {
3200
+ graphState.edges.push({
3201
+ from: p.path,
3202
+ to: targetPath,
3203
+ color: '#3b82f6', // Blue for navigation links
3204
+ type: 'link'
3205
+ });
3206
+ }
3207
+ }
3208
+ });
3209
+ }
3210
+ });
3211
+
3212
+ // Build connection map for force simulation
3213
+ const connections = new Map();
3214
+ graphState.nodes.forEach(n => connections.set(n.path, new Set()));
3215
+ graphState.edges.forEach(e => {
3216
+ connections.get(e.from)?.add(e.to);
3217
+ connections.get(e.to)?.add(e.from);
3218
+ });
3219
+
3220
+ // Calculate category centers for strong grouping
3221
+ const categoryCenters = new Map();
3222
+ Array.from(groups.entries()).forEach(([cat], gIdx) => {
3223
+ const catAngle = (gIdx / groups.size) * Math.PI * 2 - Math.PI / 2;
3224
+ categoryCenters.set(cat, {
3225
+ x: centerX + Math.cos(catAngle) * catRadius,
3226
+ y: centerY + Math.sin(catAngle) * catRadius
3227
+ });
3228
+ });
3229
+
3230
+ // Force-directed layout simulation
3231
+ const minDistance = 40; // Minimum distance between nodes
3232
+ const iterations = 80;
3233
+
3234
+ for (let iter = 0; iter < iterations; iter++) {
3235
+ const alpha = 1 - iter / iterations; // Cooling factor
3236
+
3237
+ // Apply forces
3238
+ for (let i = 0; i < graphState.nodes.length; i++) {
3239
+ const nodeA = graphState.nodes[i];
3240
+ let fx = 0, fy = 0;
3241
+
3242
+ // Strong attraction to category center (keeps nodes in their group)
3243
+ const catCenter = categoryCenters.get(nodeA.category);
3244
+ if (catCenter) {
3245
+ const toCatX = catCenter.x - nodeA.x;
3246
+ const toCatY = catCenter.y - nodeA.y;
3247
+ const catDist = Math.sqrt(toCatX * toCatX + toCatY * toCatY);
3248
+ // Always pull toward category center
3249
+ fx += toCatX * 0.08 * alpha;
3250
+ fy += toCatY * 0.08 * alpha;
3251
+ }
3252
+
3253
+ for (let j = 0; j < graphState.nodes.length; j++) {
3254
+ if (i === j) continue;
3255
+ const nodeB = graphState.nodes[j];
3256
+
3257
+ const dx = nodeA.x - nodeB.x;
3258
+ const dy = nodeA.y - nodeB.y;
3259
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
3260
+
3261
+ // Repulsion force (only very close nodes)
3262
+ if (dist < minDistance * 2) {
3263
+ const repulsion = (minDistance * 2 - dist) / dist * 0.8;
3264
+ fx += dx * repulsion * alpha;
3265
+ fy += dy * repulsion * alpha;
3266
+ }
3267
+
3268
+ // Attraction force (connected nodes pull each other)
3269
+ const isConnected = connections.get(nodeA.path)?.has(nodeB.path);
3270
+ if (isConnected && dist > minDistance) {
3271
+ const attraction = (dist - minDistance) / dist * 0.2;
3272
+ fx -= dx * attraction * alpha;
3273
+ fy -= dy * attraction * alpha;
3274
+ }
3275
+ }
3276
+
3277
+ // Apply velocity with damping
3278
+ nodeA.vx = (nodeA.vx + fx) * 0.7;
3279
+ nodeA.vy = (nodeA.vy + fy) * 0.7;
3280
+ }
3281
+
3282
+ // Update positions
3283
+ graphState.nodes.forEach(n => {
3284
+ n.x += n.vx;
3285
+ n.y += n.vy;
3286
+ // Keep within bounds
3287
+ n.x = Math.max(50, Math.min(rect.width - 50, n.x));
3288
+ n.y = Math.max(50, Math.min(rect.height - 50, n.y));
3289
+ });
3290
+ }
3291
+
3292
+ // Setup event handlers
3293
+ canvas.onmousedown = e => {
3294
+ const rect = canvas.getBoundingClientRect();
3295
+ const x = (e.clientX - rect.left - graphState.panX) / graphState.zoom;
3296
+ const y = (e.clientY - rect.top - graphState.panY) / graphState.zoom;
3297
+
3298
+ // Check if clicked on a node
3299
+ const clicked = graphState.nodes.find(n => {
3300
+ const dx = n.x - x, dy = n.y - y;
3301
+ return Math.sqrt(dx*dx + dy*dy) < n.radius + 5;
3302
+ });
3303
+
3304
+ if (clicked) {
3305
+ selectPage(clicked.path);
3306
+ selectedNode = clicked;
3307
+ drawGraph(); // Immediately reflect selection
3308
+ } else {
3309
+ // Clicked on empty space - close detail panel and start dragging
3310
+ closeDetail();
3311
+ selectedNode = null;
3312
+ drawGraph();
3313
+ dragging = true;
3314
+ lastX = e.clientX;
3315
+ lastY = e.clientY;
3316
+ }
3317
+ };
3318
+
3319
+ canvas.onmousemove = e => {
3320
+ const rect = canvas.getBoundingClientRect();
3321
+ const x = (e.clientX - rect.left - graphState.panX) / graphState.zoom;
3322
+ const y = (e.clientY - rect.top - graphState.panY) / graphState.zoom;
3323
+
3324
+ // Check if hovering over a node
3325
+ const hovered = graphState.nodes.find(n => {
3326
+ const dx = n.x - x, dy = n.y - y;
3327
+ return Math.sqrt(dx*dx + dy*dy) < n.radius + 5;
3328
+ });
3329
+
3330
+ // Update cursor style
3331
+ canvas.style.cursor = hovered ? 'pointer' : (dragging ? 'grabbing' : 'grab');
3332
+
3333
+ if (!dragging) return;
3334
+ graphState.panX += e.clientX - lastX;
3335
+ graphState.panY += e.clientY - lastY;
3336
+ lastX = e.clientX;
3337
+ lastY = e.clientY;
3338
+ drawGraph();
3339
+ };
3340
+
3341
+ canvas.onmouseup = () => dragging = false;
3342
+ canvas.onmouseleave = () => dragging = false;
3343
+
3344
+ canvas.onwheel = e => {
3345
+ e.preventDefault();
3346
+ const delta = e.deltaY > 0 ? 0.9 : 1.1;
3347
+ graphState.zoom = Math.max(0.3, Math.min(3, graphState.zoom * delta));
3348
+ drawGraph();
3349
+ };
3350
+
3351
+ graphState.panX = 0;
3352
+ graphState.panY = 0;
3353
+ graphState.zoom = 1;
3354
+
3355
+ drawGraph();
3356
+ }
3357
+
3358
+ function drawGraph() {
3359
+ if (!ctx) return;
3360
+ const w = canvas.width / 2;
3361
+ const h = canvas.height / 2;
3362
+
3363
+ ctx.clearRect(0, 0, w, h);
3364
+ ctx.save();
3365
+ ctx.translate(graphState.panX, graphState.panY);
3366
+ ctx.scale(graphState.zoom, graphState.zoom);
3367
+
3368
+ // Draw category labels
3369
+ const drawnCats = new Set();
3370
+ graphState.nodes.forEach(n => {
3371
+ if (!drawnCats.has(n.category)) {
3372
+ drawnCats.add(n.category);
3373
+ // Find center of category
3374
+ const catNodes = graphState.nodes.filter(x => x.category === n.category);
3375
+ const avgX = catNodes.reduce((s,x) => s+x.x, 0) / catNodes.length;
3376
+ const avgY = catNodes.reduce((s,x) => s+x.y, 0) / catNodes.length;
3377
+
3378
+ ctx.fillStyle = n.catColor + '40';
3379
+ ctx.beginPath();
3380
+ ctx.arc(avgX, avgY, 60 + catNodes.length * 10, 0, Math.PI * 2);
3381
+ ctx.fill();
3382
+
3383
+ ctx.fillStyle = n.catColor;
3384
+ ctx.font = 'bold 12px system-ui';
3385
+ ctx.textAlign = 'center';
3386
+ ctx.fillText('/' + n.category, avgX, avgY - 50 - catNodes.length * 5);
3387
+ }
3388
+ });
3389
+
3390
+ // Draw edges
3391
+ graphState.edges.forEach(e => {
3392
+ const from = graphState.nodes.find(n => n.path === e.from);
3393
+ const to = graphState.nodes.find(n => n.path === e.to);
3394
+ if (from && to) {
3395
+ ctx.strokeStyle = e.color;
3396
+ ctx.lineWidth = e.type === 'link' ? 1.5 : 1;
3397
+
3398
+ // Dashed line for navigation links, solid for hierarchy
3399
+ if (e.type === 'link') {
3400
+ ctx.setLineDash([4, 4]);
3401
+ } else {
3402
+ ctx.setLineDash([]);
3403
+ }
3404
+
3405
+ ctx.beginPath();
3406
+ ctx.moveTo(from.x, from.y);
3407
+ ctx.lineTo(to.x, to.y);
3408
+ ctx.stroke();
3409
+ ctx.setLineDash([]); // Reset
3410
+
3411
+ // Arrow
3412
+ const angle = Math.atan2(to.y - from.y, to.x - from.x);
3413
+ const arrowX = to.x - Math.cos(angle) * (to.radius + 3);
3414
+ const arrowY = to.y - Math.sin(angle) * (to.radius + 3);
3415
+ ctx.fillStyle = e.color;
3416
+ ctx.beginPath();
3417
+ ctx.moveTo(arrowX, arrowY);
3418
+ ctx.lineTo(arrowX - 6 * Math.cos(angle - 0.4), arrowY - 6 * Math.sin(angle - 0.4));
3419
+ ctx.lineTo(arrowX - 6 * Math.cos(angle + 0.4), arrowY - 6 * Math.sin(angle + 0.4));
3420
+ ctx.closePath();
3421
+ ctx.fill();
3422
+ }
3423
+ });
3424
+
3425
+ // Draw nodes
3426
+ graphState.nodes.forEach(n => {
3427
+ const isSelected = selectedNode?.path === n.path;
3428
+
3429
+ // Node circle
3430
+ ctx.fillStyle = n.color;
3431
+ ctx.strokeStyle = isSelected ? '#fff' : n.catColor;
3432
+ ctx.lineWidth = isSelected ? 3 : 1;
3433
+ ctx.beginPath();
3434
+ ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
3435
+ ctx.fill();
3436
+ ctx.stroke();
3437
+
3438
+ // Label
3439
+ ctx.fillStyle = '#f8fafc';
3440
+ ctx.font = '9px system-ui';
3441
+ ctx.textAlign = 'center';
3442
+ ctx.fillText(n.label, n.x, n.y + n.radius + 12);
3443
+ });
3444
+
3445
+ ctx.restore();
3446
+ }
3447
+
3448
+ function zoomGraph(f) {
3449
+ graphState.zoom = Math.max(0.3, Math.min(3, graphState.zoom * f));
3450
+ drawGraph();
3451
+ }
3452
+
3453
+ function resetGraph() {
3454
+ graphState.zoom = 1;
3455
+ graphState.panX = 0;
3456
+ graphState.panY = 0;
3457
+ selectedNode = null;
3458
+ drawGraph();
3459
+ }
3460
+ </script>
3461
+ </body>
3462
+ </html>`;
3463
+ }
3464
+ buildTreeHtml(groups, allPages) {
3465
+ const colors = [
3466
+ '#ef4444',
3467
+ '#f97316',
3468
+ '#eab308',
3469
+ '#22c55e',
3470
+ '#14b8a6',
3471
+ '#3b82f6',
3472
+ '#8b5cf6',
3473
+ '#ec4899',
3474
+ ];
3475
+ let idx = 0;
3476
+ return Array.from(groups.entries())
3477
+ .sort((a, b) => a[0].localeCompare(b[0]))
3478
+ .map(([name, pages]) => {
3479
+ const color = colors[idx++ % colors.length];
3480
+ const sorted = pages.sort((a, b) => a.path.localeCompare(b.path));
3481
+ // Build depth map based on actual parent-child relationships
3482
+ const pathSet = new Set(sorted.map((p) => p.path));
3483
+ const depthMap = new Map();
3484
+ // Calculate depth for each page based on closest existing ancestor
3485
+ for (const p of sorted) {
3486
+ const segments = p.path.split('/').filter(Boolean);
3487
+ let depth = 0;
3488
+ // Find closest existing ancestor
3489
+ for (let i = segments.length - 1; i >= 1; i--) {
3490
+ const ancestorPath = '/' + segments.slice(0, i).join('/');
3491
+ if (pathSet.has(ancestorPath)) {
3492
+ depth = (depthMap.get(ancestorPath) ?? 0) + 1;
3493
+ break;
3494
+ }
3495
+ }
3496
+ depthMap.set(p.path, depth);
3497
+ }
3498
+ const pagesHtml = sorted
3499
+ .map((p) => {
3500
+ const type = this.getPageType(p.path);
3501
+ const queries = (p.dataFetching || []).filter((d) => !d.type?.includes('Mutation')).length;
3502
+ const mutations = (p.dataFetching || []).filter((d) => d.type?.includes('Mutation')).length;
3503
+ const depth = depthMap.get(p.path) ?? 0;
3504
+ const pageNode = p;
3505
+ const repoName = pageNode.repo || '';
3506
+ // Only show repo tag if there are multiple repositories
3507
+ const showRepoTag = allPages.some((pg) => pg.repo && pg.repo !== repoName);
3508
+ // Create short name: take last part or abbreviate long names
3509
+ const shortRepoName = repoName
3510
+ .split('/')
3511
+ .pop()
3512
+ ?.split('-')
3513
+ .map((s) => s.substring(0, 4))
3514
+ .join('-') || repoName.substring(0, 8);
3515
+ const repoTag = showRepoTag && repoName
3516
+ ? `<span class="tag tag-repo" title="${repoName}">${shortRepoName}</span>`
3517
+ : '';
3518
+ // Detect SPA component pages (PascalCase path or in components/pages)
3519
+ const isSpaComponent = /^\/[A-Z]/.test(p.path) || (p.filePath && p.filePath.includes('components/pages'));
3520
+ const displayPath = isSpaComponent && p.filePath
3521
+ ? p.filePath.replace(/\.tsx?$/, '').replace(/^(frontend\/src\/|src\/)/, '')
3522
+ : p.path;
3523
+ const spaTag = isSpaComponent
3524
+ ? '<span class="tag" style="background:#6366f1;color:white" title="SPA Component Page">SPA</span>'
3525
+ : '';
3526
+ return `<div class="page-item" data-path="${p.path}" data-repo="${repoName}" onclick="selectPage('${p.path}')" style="--depth:${depth}">
3527
+ <span class="page-type" style="--type-color:${type.color}">${type.label}</span>
3528
+ <span class="page-path">${displayPath}</span>
3529
+ <div class="page-tags">
3530
+ ${repoTag}
3531
+ ${spaTag}
3532
+ ${p.authentication?.required ? '<span class="tag tag-auth">AUTH</span>' : ''}
3533
+ ${queries > 0 ? `<span class="tag tag-query">Q:${queries}</span>` : ''}
3534
+ ${mutations > 0 ? `<span class="tag tag-mutation">M:${mutations}</span>` : ''}
3535
+ </div>
3536
+ </div>`;
3537
+ })
3538
+ .join('');
3539
+ return `<div class="group">
3540
+ <div class="group-header" onclick="toggleGroup(this)" style="--group-color:${color}">
3541
+ <span class="group-arrow">▼</span>
3542
+ <span class="group-name">/${name}</span>
3543
+ <span class="group-count">${pages.length}</span>
3544
+ </div>
3545
+ <div class="group-content">${pagesHtml}</div>
3546
+ </div>`;
3547
+ })
3548
+ .join('');
3549
+ }
3550
+ getPageType(path) {
3551
+ const last = path.split('/').filter(Boolean).pop() || '';
3552
+ if (last === 'new' || path.endsWith('/new'))
3553
+ return { label: 'CREATE', color: '#22c55e' };
3554
+ if (last === 'edit' || path.includes('/edit'))
3555
+ return { label: 'EDIT', color: '#f59e0b' };
3556
+ if (last.startsWith('[') || last.startsWith(':'))
3557
+ return { label: 'DETAIL', color: '#3b82f6' };
3558
+ if (path.includes('setting'))
3559
+ return { label: 'SETTINGS', color: '#6b7280' };
3560
+ return { label: 'LIST', color: '#06b6d4' };
3561
+ }
3562
+ }
3563
+ //# sourceMappingURL=page-map-generator.js.map