@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.
- package/LICENSE +21 -0
- package/README.md +527 -0
- package/dist/analyzers/base-analyzer.d.ts +46 -0
- package/dist/analyzers/base-analyzer.d.ts.map +1 -0
- package/dist/analyzers/base-analyzer.js +48 -0
- package/dist/analyzers/base-analyzer.js.map +1 -0
- package/dist/analyzers/dataflow-analyzer.d.ts +30 -0
- package/dist/analyzers/dataflow-analyzer.d.ts.map +1 -0
- package/dist/analyzers/dataflow-analyzer.js +426 -0
- package/dist/analyzers/dataflow-analyzer.js.map +1 -0
- package/dist/analyzers/graphql-analyzer.d.ts +23 -0
- package/dist/analyzers/graphql-analyzer.d.ts.map +1 -0
- package/dist/analyzers/graphql-analyzer.js +387 -0
- package/dist/analyzers/graphql-analyzer.js.map +1 -0
- package/dist/analyzers/index.d.ts +6 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/index.js +6 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/analyzers/pages-analyzer.d.ts +85 -0
- package/dist/analyzers/pages-analyzer.d.ts.map +1 -0
- package/dist/analyzers/pages-analyzer.js +1696 -0
- package/dist/analyzers/pages-analyzer.js.map +1 -0
- package/dist/analyzers/rails/index.d.ts +47 -0
- package/dist/analyzers/rails/index.d.ts.map +1 -0
- package/dist/analyzers/rails/index.js +146 -0
- package/dist/analyzers/rails/index.js.map +1 -0
- package/dist/analyzers/rails/rails-controller-analyzer.d.ts +83 -0
- package/dist/analyzers/rails/rails-controller-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-controller-analyzer.js +479 -0
- package/dist/analyzers/rails/rails-controller-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.d.ts +45 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.js +263 -0
- package/dist/analyzers/rails/rails-grpc-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-model-analyzer.d.ts +89 -0
- package/dist/analyzers/rails/rails-model-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-model-analyzer.js +494 -0
- package/dist/analyzers/rails/rails-model-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-react-analyzer.d.ts +42 -0
- package/dist/analyzers/rails/rails-react-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-react-analyzer.js +530 -0
- package/dist/analyzers/rails/rails-react-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-routes-analyzer.d.ts +63 -0
- package/dist/analyzers/rails/rails-routes-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-routes-analyzer.js +541 -0
- package/dist/analyzers/rails/rails-routes-analyzer.js.map +1 -0
- package/dist/analyzers/rails/rails-view-analyzer.d.ts +50 -0
- package/dist/analyzers/rails/rails-view-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rails/rails-view-analyzer.js +387 -0
- package/dist/analyzers/rails/rails-view-analyzer.js.map +1 -0
- package/dist/analyzers/rails/ruby-parser.d.ts +64 -0
- package/dist/analyzers/rails/ruby-parser.d.ts.map +1 -0
- package/dist/analyzers/rails/ruby-parser.js +213 -0
- package/dist/analyzers/rails/ruby-parser.js.map +1 -0
- package/dist/analyzers/rest-api-analyzer.d.ts +66 -0
- package/dist/analyzers/rest-api-analyzer.d.ts.map +1 -0
- package/dist/analyzers/rest-api-analyzer.js +480 -0
- package/dist/analyzers/rest-api-analyzer.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +550 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/cache.d.ts +48 -0
- package/dist/core/cache.d.ts.map +1 -0
- package/dist/core/cache.js +152 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/engine.d.ts +47 -0
- package/dist/core/engine.d.ts.map +1 -0
- package/dist/core/engine.js +320 -0
- package/dist/core/engine.js.map +1 -0
- package/dist/core/index.d.ts +3 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +3 -0
- package/dist/core/index.js.map +1 -0
- package/dist/generators/assets/common.css +187 -0
- package/dist/generators/assets/docs.css +363 -0
- package/dist/generators/assets/page-map.css +305 -0
- package/dist/generators/assets/rails-map.css +473 -0
- package/dist/generators/index.d.ts +4 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/index.js +4 -0
- package/dist/generators/index.js.map +1 -0
- package/dist/generators/markdown-generator.d.ts +26 -0
- package/dist/generators/markdown-generator.d.ts.map +1 -0
- package/dist/generators/markdown-generator.js +783 -0
- package/dist/generators/markdown-generator.js.map +1 -0
- package/dist/generators/mermaid-generator.d.ts +36 -0
- package/dist/generators/mermaid-generator.d.ts.map +1 -0
- package/dist/generators/mermaid-generator.js +365 -0
- package/dist/generators/mermaid-generator.js.map +1 -0
- package/dist/generators/page-map-generator.d.ts +23 -0
- package/dist/generators/page-map-generator.d.ts.map +1 -0
- package/dist/generators/page-map-generator.js +3563 -0
- package/dist/generators/page-map-generator.js.map +1 -0
- package/dist/generators/rails-map-generator.d.ts +22 -0
- package/dist/generators/rails-map-generator.d.ts.map +1 -0
- package/dist/generators/rails-map-generator.js +909 -0
- package/dist/generators/rails-map-generator.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/server/doc-server.d.ts +31 -0
- package/dist/server/doc-server.d.ts.map +1 -0
- package/dist/server/doc-server.js +1233 -0
- package/dist/server/doc-server.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/dist/server/index.js.map +1 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/env-detector.d.ts +32 -0
- package/dist/utils/env-detector.d.ts.map +1 -0
- package/dist/utils/env-detector.js +189 -0
- package/dist/utils/env-detector.js.map +1 -0
- package/dist/utils/parallel.d.ts +24 -0
- package/dist/utils/parallel.d.ts.map +1 -0
- package/dist/utils/parallel.js +71 -0
- package/dist/utils/parallel.js.map +1 -0
- 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, '"') : '';
|
|
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, '"');
|
|
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, '"')+', 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, '"')+', 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, '"')+', 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, '"')+', 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, '"')+', 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, '"')+', 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, '"')+', 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(/"/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
|