codemap-python 0.1.0__py3-none-any.whl
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.
- analysis/__init__.py +1 -0
- analysis/architecture/__init__.py +1 -0
- analysis/architecture/architecture_engine.py +155 -0
- analysis/architecture/dependency_cycles.py +103 -0
- analysis/architecture/risk_radar.py +220 -0
- analysis/call_graph/__init__.py +1 -0
- analysis/call_graph/call_extractor.py +91 -0
- analysis/call_graph/call_graph_builder.py +1 -0
- analysis/call_graph/call_resolver.py +56 -0
- analysis/call_graph/context_models.py +1 -0
- analysis/call_graph/cross_file_resolver.py +122 -0
- analysis/call_graph/execution_tracker.py +1 -0
- analysis/call_graph/flow_builder.py +1 -0
- analysis/call_graph/models.py +1 -0
- analysis/core/__init__.py +1 -0
- analysis/core/ast_context.py +1 -0
- analysis/core/ast_parser.py +8 -0
- analysis/core/class_extractor.py +35 -0
- analysis/core/function_extractor.py +16 -0
- analysis/core/import_extractor.py +43 -0
- analysis/explain/__init__.py +1 -0
- analysis/explain/docstring_extractor.py +45 -0
- analysis/explain/explain_runner.py +177 -0
- analysis/explain/repo_summary_generator.py +138 -0
- analysis/explain/return_analyzer.py +114 -0
- analysis/explain/risk_flags.py +1 -0
- analysis/explain/signature_extractor.py +104 -0
- analysis/explain/summary_generator.py +282 -0
- analysis/graph/__init__.py +1 -0
- analysis/graph/callgraph_index.py +117 -0
- analysis/graph/entrypoint_detector.py +1 -0
- analysis/graph/impact_analyzer.py +210 -0
- analysis/indexing/__init__.py +1 -0
- analysis/indexing/import_resolver.py +156 -0
- analysis/indexing/symbol_index.py +150 -0
- analysis/runners/__init__.py +1 -0
- analysis/runners/phase4_runner.py +137 -0
- analysis/utils/__init__.py +1 -0
- analysis/utils/ast_helpers.py +1 -0
- analysis/utils/cache_manager.py +659 -0
- analysis/utils/path_resolver.py +1 -0
- analysis/utils/repo_fetcher.py +469 -0
- cli.py +1728 -0
- codemap_cli.py +11 -0
- codemap_python-0.1.0.dist-info/METADATA +399 -0
- codemap_python-0.1.0.dist-info/RECORD +58 -0
- codemap_python-0.1.0.dist-info/WHEEL +5 -0
- codemap_python-0.1.0.dist-info/entry_points.txt +2 -0
- codemap_python-0.1.0.dist-info/top_level.txt +5 -0
- security_utils.py +51 -0
- ui/__init__.py +1 -0
- ui/app.py +2160 -0
- ui/device_id.py +27 -0
- ui/static/app.js +2703 -0
- ui/static/styles.css +1268 -0
- ui/templates/index.html +231 -0
- ui/utils/__init__.py +1 -0
- ui/utils/registry_manager.py +190 -0
ui/static/app.js
ADDED
|
@@ -0,0 +1,2703 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const metaEl = document.getElementById("meta");
|
|
3
|
+
const repoNameEl = document.getElementById("repo-name");
|
|
4
|
+
const repoPrivateBadgeEl = document.getElementById("repo-private-badge");
|
|
5
|
+
const repoSelectEl = document.getElementById("repo-select");
|
|
6
|
+
const addRepoBtnEl = document.getElementById("add-repo-btn");
|
|
7
|
+
const aiSettingsBtnEl = document.getElementById("ai-settings-btn");
|
|
8
|
+
const repoListModePillEl = document.getElementById("repo-list-mode-pill");
|
|
9
|
+
const treeStatusEl = document.getElementById("tree-status");
|
|
10
|
+
const treeEl = document.getElementById("tree");
|
|
11
|
+
const fileViewEl = document.getElementById("file-view");
|
|
12
|
+
const symbolViewEl = document.getElementById("symbol-view");
|
|
13
|
+
const searchInputEl = document.getElementById("symbol-search-input");
|
|
14
|
+
const searchResultsEl = document.getElementById("symbol-search-results");
|
|
15
|
+
const tabDetailsEl = document.getElementById("tab-details");
|
|
16
|
+
const tabImpactEl = document.getElementById("tab-impact");
|
|
17
|
+
const tabGraphEl = document.getElementById("tab-graph");
|
|
18
|
+
const tabArchitectureEl = document.getElementById("tab-architecture");
|
|
19
|
+
const graphControlsEl = document.getElementById("graph-controls");
|
|
20
|
+
const impactControlsEl = document.getElementById("impact-controls");
|
|
21
|
+
const impactDepthEl = document.getElementById("impact-depth");
|
|
22
|
+
const impactMaxNodesEl = document.getElementById("impact-max-nodes");
|
|
23
|
+
const impactViewEl = document.getElementById("impact-view");
|
|
24
|
+
const graphViewEl = document.getElementById("graph-view");
|
|
25
|
+
const architectureViewEl = document.getElementById("architecture-view");
|
|
26
|
+
const graphModeEl = document.getElementById("graph-mode");
|
|
27
|
+
const graphDepthEl = document.getElementById("graph-depth");
|
|
28
|
+
const graphHideBuiltinsEl = document.getElementById("graph-hide-builtins");
|
|
29
|
+
const graphHideExternalEl = document.getElementById("graph-hide-external");
|
|
30
|
+
const graphSearchEl = document.getElementById("graph-search");
|
|
31
|
+
const recentSymbolsEl = document.getElementById("recent-symbols");
|
|
32
|
+
const recentFilesEl = document.getElementById("recent-files");
|
|
33
|
+
const recentSymbolsWrapEl = document.getElementById("recent-symbols-wrap");
|
|
34
|
+
const recentFilesWrapEl = document.getElementById("recent-files-wrap");
|
|
35
|
+
const repoListContentEl = document.getElementById("repo-list-content");
|
|
36
|
+
const addRepoInlineEl = document.getElementById("add-repo-inline");
|
|
37
|
+
const repoInlineCloseEl = document.getElementById("repo-inline-close");
|
|
38
|
+
const repoTabLocalEl = document.getElementById("repo-tab-local");
|
|
39
|
+
const repoTabGithubEl = document.getElementById("repo-tab-github");
|
|
40
|
+
const repoFormLocalEl = document.getElementById("repo-form-local");
|
|
41
|
+
const repoFormGithubEl = document.getElementById("repo-form-github");
|
|
42
|
+
const localRepoPathEl = document.getElementById("local-repo-path");
|
|
43
|
+
const localDisplayNameEl = document.getElementById("local-display-name");
|
|
44
|
+
const ghRepoUrlEl = document.getElementById("gh-repo-url");
|
|
45
|
+
const ghRefEl = document.getElementById("gh-ref");
|
|
46
|
+
const ghModeEl = document.getElementById("gh-mode");
|
|
47
|
+
const ghTokenEl = document.getElementById("gh-token");
|
|
48
|
+
const ghPrivateModeEl = document.getElementById("gh-private-mode");
|
|
49
|
+
const privateModeIndicatorEl = document.getElementById("private-mode-indicator");
|
|
50
|
+
const repoModalErrorEl = document.getElementById("repo-modal-error");
|
|
51
|
+
const repoAddBtnEl = document.getElementById("repo-add-btn");
|
|
52
|
+
const repoCancelBtnEl = document.getElementById("repo-cancel-btn");
|
|
53
|
+
const toastEl = document.getElementById("toast");
|
|
54
|
+
const privacySummaryEl = document.getElementById("privacy-summary");
|
|
55
|
+
const privacyRepoListModeEl = document.getElementById("privacy-repo-list-mode");
|
|
56
|
+
const privacyExpiringEl = document.getElementById("privacy-expiring");
|
|
57
|
+
const privacyPrivateBannerEl = document.getElementById("privacy-private-banner");
|
|
58
|
+
const privacyResultEl = document.getElementById("privacy-result");
|
|
59
|
+
const privacyConfirmEl = document.getElementById("privacy-confirm");
|
|
60
|
+
const repoRetentionSelectEl = document.getElementById("repo-retention-select");
|
|
61
|
+
const repoRetentionSaveBtnEl = document.getElementById("repo-retention-save-btn");
|
|
62
|
+
const cleanupDryBtnEl = document.getElementById("cleanup-dry-btn");
|
|
63
|
+
const cleanupNowBtnEl = document.getElementById("cleanup-now-btn");
|
|
64
|
+
const deleteRepoCacheBtnEl = document.getElementById("delete-repo-cache-btn");
|
|
65
|
+
const deleteAllCachesBtnEl = document.getElementById("delete-all-caches-btn");
|
|
66
|
+
const autoCleanOnRemoveEl = document.getElementById("auto-clean-on-remove");
|
|
67
|
+
const autoCleanNoteEl = document.getElementById("auto-clean-note");
|
|
68
|
+
const confirmModalEl = document.getElementById("confirm-modal");
|
|
69
|
+
const confirmTitleEl = document.getElementById("confirm-title");
|
|
70
|
+
const confirmMessageEl = document.getElementById("confirm-message");
|
|
71
|
+
const confirmYesEl = document.getElementById("confirm-yes");
|
|
72
|
+
const confirmNoEl = document.getElementById("confirm-no");
|
|
73
|
+
const aiSettingsModalEl = document.getElementById("ai-settings-modal");
|
|
74
|
+
const aiSettingsKeyEl = null;
|
|
75
|
+
const aiSettingsRememberReposEl = document.getElementById("ai-settings-remember-repos");
|
|
76
|
+
const aiSettingsClearReposEl = document.getElementById("ai-settings-clear-repos");
|
|
77
|
+
const aiSettingsCancelEl = document.getElementById("ai-settings-cancel");
|
|
78
|
+
const aiSettingsStatusEl = document.getElementById("ai-settings-status");
|
|
79
|
+
|
|
80
|
+
const fileCache = new Map();
|
|
81
|
+
const symbolCache = new Map();
|
|
82
|
+
const symbolAiSummaryCache = new Map();
|
|
83
|
+
let repoDir = "";
|
|
84
|
+
let repoName = "";
|
|
85
|
+
let activeSymbolFqn = "";
|
|
86
|
+
let activeFilePath = "";
|
|
87
|
+
let searchTimer = null;
|
|
88
|
+
let currentSearchResults = [];
|
|
89
|
+
let workspaceRepos = [];
|
|
90
|
+
let activeRepoHash = "";
|
|
91
|
+
let recentSymbols = [];
|
|
92
|
+
let recentFiles = [];
|
|
93
|
+
let lastSymbol = "";
|
|
94
|
+
let activeTab = "details";
|
|
95
|
+
let graphDataCache = new Map();
|
|
96
|
+
let impactDataCache = new Map();
|
|
97
|
+
let architectureCache = null;
|
|
98
|
+
let repoSummary = null;
|
|
99
|
+
let repoSummaryUpdatedAt = "";
|
|
100
|
+
let repoSummaryStatus = "idle"; // idle|loading|ready|missing|error
|
|
101
|
+
let repoSummaryError = "";
|
|
102
|
+
let aiEnabled = false;
|
|
103
|
+
let aiProvider = "";
|
|
104
|
+
let aiModel = "";
|
|
105
|
+
let aiStatusMessage = "AI is optional. Configure a provider and key to enable summaries and explanations.";
|
|
106
|
+
let riskRadar = null;
|
|
107
|
+
let riskRadarUpdatedAt = "";
|
|
108
|
+
let riskRadarStatus = "idle"; // idle|loading|ready|missing|error
|
|
109
|
+
let riskRadarError = "";
|
|
110
|
+
let dataPrivacyCache = null;
|
|
111
|
+
let repoRegistry = [];
|
|
112
|
+
let rememberRepos = false;
|
|
113
|
+
let autoCleanOnRemove = false;
|
|
114
|
+
|
|
115
|
+
function withRepo(path) {
|
|
116
|
+
return new URL(path, window.location.origin).toString();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchJson(path, options) {
|
|
120
|
+
const res = await fetch(withRepo(path), options);
|
|
121
|
+
const data = await res.json();
|
|
122
|
+
if (!res.ok || data.ok === false) throw data;
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function esc(v) {
|
|
127
|
+
return String(v ?? "").replace(/[&<>"']/g, (ch) => ({
|
|
128
|
+
"&": "&", "<": "<", ">": ">", "\"": """, "'": "'",
|
|
129
|
+
})[ch]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function redactSecrets(v) {
|
|
133
|
+
let value = String(v || "");
|
|
134
|
+
value = value.replace(/\bgh[pousr]_[A-Za-z0-9_]{8,}\b/g, (m) => `${m.slice(0, 4)}************`);
|
|
135
|
+
value = value.replace(/\bBearer\s+[^\s]+/gi, "Bearer ********");
|
|
136
|
+
value = value.replace(/\bBasic\s+[^\s]+/gi, "Basic ********");
|
|
137
|
+
value = value.replace(/(https?:\/\/)([^/\s:@]+):([^@\s/]+)@/gi, "$1***:***@");
|
|
138
|
+
value = value.replace(/\b(token|api[_-]?key|password)\s*[:=]\s*[^\s'"`]+/gi, "$1=[REDACTED]");
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatBytes(v) {
|
|
143
|
+
const n = Number(v || 0);
|
|
144
|
+
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
|
145
|
+
if (n < 1024) return `${Math.round(n)} B`;
|
|
146
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
147
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
148
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function stripMarkdown(text) {
|
|
152
|
+
return String(text || "")
|
|
153
|
+
.replace(/```/g, "")
|
|
154
|
+
.replace(/[*#`]/g, "")
|
|
155
|
+
.replace(/\s+/g, " ")
|
|
156
|
+
.trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _summaryFromMarkdown(content) {
|
|
160
|
+
const lines = String(content || "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
161
|
+
const oneLiner = lines.length ? lines[0].replace(/^\-\s*/, "") : "";
|
|
162
|
+
const bullets = lines.slice(1, 8).map((x) => x.replace(/^\-\s*/, "")).filter(Boolean);
|
|
163
|
+
return {
|
|
164
|
+
one_liner: oneLiner,
|
|
165
|
+
bullets,
|
|
166
|
+
notes: [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function relPath(p) {
|
|
171
|
+
const path = String(p || "").replace(/\\/g, "/");
|
|
172
|
+
const root = String(repoDir || "").replace(/\\/g, "/");
|
|
173
|
+
if (root && path.toLowerCase().startsWith(root.toLowerCase() + "/")) {
|
|
174
|
+
return path.slice(root.length + 1);
|
|
175
|
+
}
|
|
176
|
+
return path;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeTree(raw) {
|
|
180
|
+
if (!raw) return null;
|
|
181
|
+
if (raw.tree) return raw.tree;
|
|
182
|
+
if (raw.name && raw.type) return raw;
|
|
183
|
+
if (Array.isArray(raw)) return { name: "repo", type: "directory", path: "", children: raw };
|
|
184
|
+
if (raw.root) return raw.root;
|
|
185
|
+
return { name: "repo", type: "directory", path: "", children: [] };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseSymbolParts(fqn) {
|
|
189
|
+
const parts = String(fqn || "").split(".");
|
|
190
|
+
const symbol = parts[parts.length - 1] || "";
|
|
191
|
+
const prev = parts[parts.length - 2] || "";
|
|
192
|
+
const className = prev && prev[0] === prev[0].toUpperCase() ? prev : "";
|
|
193
|
+
const display = className ? `${className}.${symbol}` : symbol;
|
|
194
|
+
return { className, symbol, display };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function currentRepoEntry() {
|
|
198
|
+
return (repoRegistry || []).find((r) => String(r.repo_hash) === String(activeRepoHash)) || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function selectedRepoEntry() {
|
|
202
|
+
const fromWorkspace = (workspaceRepos || []).find((r) => String(r.repo_hash) === String(activeRepoHash));
|
|
203
|
+
if (fromWorkspace) return fromWorkspace;
|
|
204
|
+
return currentRepoEntry();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function syncRepoHeader(fallbackName) {
|
|
208
|
+
const entry = selectedRepoEntry();
|
|
209
|
+
const displayName = String((entry && entry.name) || fallbackName || "").trim();
|
|
210
|
+
repoName = displayName;
|
|
211
|
+
if (repoNameEl) {
|
|
212
|
+
repoNameEl.textContent = displayName || "No repo selected";
|
|
213
|
+
}
|
|
214
|
+
if (repoPrivateBadgeEl) {
|
|
215
|
+
const privateMode = !!(entry && entry.private_mode);
|
|
216
|
+
repoPrivateBadgeEl.classList.toggle("hidden", !privateMode);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function analyzeCommandForRepo(repo) {
|
|
221
|
+
const r = repo || currentRepoEntry();
|
|
222
|
+
if (!r) return "python cli.py api analyze --path <repo>";
|
|
223
|
+
if (String(r.source || "filesystem") === "github" && r.repo_url) {
|
|
224
|
+
const ref = r.ref || "main";
|
|
225
|
+
const mode = r.mode || "zip";
|
|
226
|
+
return `python cli.py api analyze --github ${r.repo_url} --ref ${ref} --mode ${mode}`;
|
|
227
|
+
}
|
|
228
|
+
return `python cli.py api analyze --path ${r.repo_path || "<repo>"}`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function updateAiModeUi() {
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function updateRepoListModeUi() {
|
|
235
|
+
const label = rememberRepos ? "Remembering Repos" : "Session Mode";
|
|
236
|
+
if (repoListModePillEl) repoListModePillEl.textContent = label;
|
|
237
|
+
if (privacyRepoListModeEl) {
|
|
238
|
+
privacyRepoListModeEl.textContent = rememberRepos
|
|
239
|
+
? "Repository list: Remembered on this machine"
|
|
240
|
+
: "Repository list: Session-only";
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function loadAiStatus() {
|
|
245
|
+
aiEnabled = false;
|
|
246
|
+
aiProvider = "";
|
|
247
|
+
aiModel = "";
|
|
248
|
+
aiStatusMessage = "CodeMap runs fully without AI. Deterministic summaries are always available.";
|
|
249
|
+
updateAiModeUi();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function loadRegistryMode() {
|
|
253
|
+
try {
|
|
254
|
+
const data = await fetchJson("/api/registry");
|
|
255
|
+
rememberRepos = !!data.remember_repos;
|
|
256
|
+
} catch (_e) {
|
|
257
|
+
rememberRepos = false;
|
|
258
|
+
}
|
|
259
|
+
updateRepoListModeUi();
|
|
260
|
+
if (aiSettingsRememberReposEl) aiSettingsRememberReposEl.checked = !!rememberRepos;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
function setAiSettingsStatus(message, isError) {
|
|
265
|
+
if (!aiSettingsStatusEl) return;
|
|
266
|
+
aiSettingsStatusEl.textContent = redactSecrets(String(message || ""));
|
|
267
|
+
aiSettingsStatusEl.classList.toggle("error", !!isError);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function closeAiSettingsModal() {
|
|
271
|
+
if (!aiSettingsModalEl) return;
|
|
272
|
+
aiSettingsModalEl.classList.add("hidden");
|
|
273
|
+
if (aiSettingsKeyEl) aiSettingsKeyEl.value = "";
|
|
274
|
+
setAiSettingsStatus("", false);
|
|
275
|
+
document.body.classList.remove("modal-open");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function openAiSettingsModal() {
|
|
279
|
+
if (!aiSettingsModalEl) return;
|
|
280
|
+
aiSettingsModalEl.classList.remove("hidden");
|
|
281
|
+
document.body.classList.add("modal-open");
|
|
282
|
+
if (aiSettingsRememberReposEl) aiSettingsRememberReposEl.checked = !!rememberRepos;
|
|
283
|
+
if (aiSettingsKeyEl) aiSettingsKeyEl.value = "";
|
|
284
|
+
setAiSettingsStatus("Loading settings...", false);
|
|
285
|
+
try {
|
|
286
|
+
await loadRegistryMode();
|
|
287
|
+
if (aiSettingsRememberReposEl) aiSettingsRememberReposEl.checked = !!rememberRepos;
|
|
288
|
+
setAiSettingsStatus("CodeMap runs fully without AI. Deterministic summaries are always available.", false);
|
|
289
|
+
} catch (e) {
|
|
290
|
+
setAiSettingsStatus(redactSecrets((e && (e.message || e.error)) || "Failed to load settings."), true);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
async function setRememberRepos(value) {
|
|
298
|
+
const remember = !!value;
|
|
299
|
+
try {
|
|
300
|
+
await fetchJson("/api/registry/settings", {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: { "Content-Type": "application/json" },
|
|
303
|
+
body: JSON.stringify({ remember_repos: remember }),
|
|
304
|
+
});
|
|
305
|
+
rememberRepos = remember;
|
|
306
|
+
updateRepoListModeUi();
|
|
307
|
+
if (aiSettingsRememberReposEl) aiSettingsRememberReposEl.checked = remember;
|
|
308
|
+
if (!remember) {
|
|
309
|
+
workspaceRepos = [];
|
|
310
|
+
repoRegistry = [];
|
|
311
|
+
activeRepoHash = "";
|
|
312
|
+
renderWorkspaceSelect();
|
|
313
|
+
renderRepoRegistry();
|
|
314
|
+
clearWorkspaceView("No repositories added yet. Add a local path or GitHub repo to begin.");
|
|
315
|
+
await loadDataPrivacy();
|
|
316
|
+
showToast("Session Mode enabled. Repo list will reset when you close.", "success");
|
|
317
|
+
} else {
|
|
318
|
+
await loadWorkspace();
|
|
319
|
+
await refreshForActiveRepo();
|
|
320
|
+
}
|
|
321
|
+
} catch (e) {
|
|
322
|
+
showToast(redactSecrets((e && (e.message || e.error)) || "Failed to update registry mode"), "error");
|
|
323
|
+
if (aiSettingsRememberReposEl) aiSettingsRememberReposEl.checked = !!rememberRepos;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function clearRepositoryList() {
|
|
328
|
+
const sessionOnly = !rememberRepos;
|
|
329
|
+
await openConfirmModal({
|
|
330
|
+
title: "Clear repository list",
|
|
331
|
+
message: sessionOnly
|
|
332
|
+
? "This will clear the in-memory session repo list only."
|
|
333
|
+
: "This will clear remembered repositories. Cache files are not deleted.",
|
|
334
|
+
confirmText: "Yes",
|
|
335
|
+
cancelText: "Cancel",
|
|
336
|
+
actionType: "clear_repo_list",
|
|
337
|
+
payload: { session_only: sessionOnly },
|
|
338
|
+
onConfirm: async () => {
|
|
339
|
+
await fetchJson("/api/registry/repos/clear", {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: { "Content-Type": "application/json" },
|
|
342
|
+
body: JSON.stringify({ session_only: sessionOnly }),
|
|
343
|
+
});
|
|
344
|
+
workspaceRepos = [];
|
|
345
|
+
repoRegistry = [];
|
|
346
|
+
activeRepoHash = "";
|
|
347
|
+
renderWorkspaceSelect();
|
|
348
|
+
renderRepoRegistry();
|
|
349
|
+
clearWorkspaceView("No repositories added yet. Add a local path or GitHub repo to begin.");
|
|
350
|
+
await loadDataPrivacy();
|
|
351
|
+
showToast("Repository list cleared", "success");
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function analysisErrorMessage(errPayload) {
|
|
357
|
+
const payload = (errPayload && errPayload.analyze_result) ? errPayload.analyze_result : (errPayload || {});
|
|
358
|
+
const code = String(payload.error || (errPayload && errPayload.error) || "");
|
|
359
|
+
if (code === "GITHUB_AUTH_REQUIRED") {
|
|
360
|
+
return "Private repo detected. Please provide a GitHub token and run analysis again.";
|
|
361
|
+
}
|
|
362
|
+
return redactSecrets(String(payload.message || payload.error || (errPayload && errPayload.message) || (errPayload && errPayload.error) || "Analyze failed"));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function renderMissingAnalysisCta(msg) {
|
|
366
|
+
const repo = currentRepoEntry();
|
|
367
|
+
const cmd = analyzeCommandForRepo();
|
|
368
|
+
const isGithub = String((repo && repo.source) || "filesystem") === "github";
|
|
369
|
+
return `
|
|
370
|
+
<div class="missing-analysis-cta">
|
|
371
|
+
<div class="symbol-name">Analysis not found</div>
|
|
372
|
+
<div>No analysis data found for this repo. Click "Run Analysis Now" or run:</div>
|
|
373
|
+
<div class="impact-command analysis-cli-command">${esc(cmd)}</div>
|
|
374
|
+
${isGithub ? `
|
|
375
|
+
<label class="path">GitHub token (optional for private repos)
|
|
376
|
+
<input class="missing-analysis-token" type="password" placeholder="Used for this run only" />
|
|
377
|
+
</label>
|
|
378
|
+
` : ""}
|
|
379
|
+
<div class="repo-row-actions">
|
|
380
|
+
<button class="repo-btn small run-analysis-now-btn" type="button">Run Analysis Now</button>
|
|
381
|
+
<button class="repo-btn small copy-analysis-cli-btn" type="button">Copy CLI command</button>
|
|
382
|
+
</div>
|
|
383
|
+
<div class="analysis-run-status path hidden"></div>
|
|
384
|
+
<div class="analysis-run-error hidden"></div>
|
|
385
|
+
<div class="path">${esc(msg || "After it finishes, refresh this page.")}</div>
|
|
386
|
+
</div>
|
|
387
|
+
`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function clearWorkspaceView(message) {
|
|
391
|
+
fileCache.clear();
|
|
392
|
+
symbolCache.clear();
|
|
393
|
+
symbolAiSummaryCache.clear();
|
|
394
|
+
activeFilePath = "";
|
|
395
|
+
activeSymbolFqn = "";
|
|
396
|
+
closeSearchDropdown();
|
|
397
|
+
treeEl.innerHTML = "";
|
|
398
|
+
treeStatusEl.textContent = "";
|
|
399
|
+
fileViewEl.classList.add("muted");
|
|
400
|
+
symbolViewEl.classList.add("muted");
|
|
401
|
+
impactViewEl.classList.add("muted");
|
|
402
|
+
graphViewEl.classList.add("muted");
|
|
403
|
+
architectureViewEl.classList.add("muted");
|
|
404
|
+
fileViewEl.textContent = message || "Select a file from the tree.";
|
|
405
|
+
symbolViewEl.textContent = "Select a symbol to view summary and usages.";
|
|
406
|
+
impactViewEl.textContent = "Select a symbol to view impact.";
|
|
407
|
+
graphViewEl.textContent = "Select a symbol to view graph.";
|
|
408
|
+
architectureViewEl.textContent = "Select a repository to view architecture insights.";
|
|
409
|
+
recentSymbols = [];
|
|
410
|
+
recentFiles = [];
|
|
411
|
+
lastSymbol = "";
|
|
412
|
+
architectureCache = null;
|
|
413
|
+
repoSummary = null;
|
|
414
|
+
repoSummaryUpdatedAt = "";
|
|
415
|
+
repoSummaryStatus = "idle";
|
|
416
|
+
repoSummaryError = "";
|
|
417
|
+
riskRadar = null;
|
|
418
|
+
riskRadarUpdatedAt = "";
|
|
419
|
+
riskRadarStatus = "idle";
|
|
420
|
+
riskRadarError = "";
|
|
421
|
+
renderRecents();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function setMissingAnalysisCtaState(state) {
|
|
425
|
+
const loading = !!(state && state.loading);
|
|
426
|
+
const status = String((state && state.status) || "").trim();
|
|
427
|
+
const error = String((state && state.error) || "").trim();
|
|
428
|
+
|
|
429
|
+
document.querySelectorAll(".run-analysis-now-btn").forEach((btn) => {
|
|
430
|
+
btn.disabled = loading;
|
|
431
|
+
btn.classList.toggle("is-loading", loading);
|
|
432
|
+
btn.textContent = loading ? "Analyzing..." : "Run Analysis Now";
|
|
433
|
+
});
|
|
434
|
+
document.querySelectorAll(".copy-analysis-cli-btn").forEach((btn) => {
|
|
435
|
+
btn.disabled = loading;
|
|
436
|
+
});
|
|
437
|
+
document.querySelectorAll(".analysis-run-status").forEach((el) => {
|
|
438
|
+
el.textContent = status;
|
|
439
|
+
el.classList.toggle("hidden", !status);
|
|
440
|
+
});
|
|
441
|
+
document.querySelectorAll(".analysis-run-error").forEach((el) => {
|
|
442
|
+
el.textContent = error;
|
|
443
|
+
el.classList.toggle("hidden", !error);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function bindRunAnalysisNowButton() {
|
|
448
|
+
document.querySelectorAll(".run-analysis-now-btn").forEach((btn) => {
|
|
449
|
+
if (btn.dataset.bound === "1") return;
|
|
450
|
+
btn.dataset.bound = "1";
|
|
451
|
+
btn.addEventListener("click", async () => {
|
|
452
|
+
if (!activeRepoHash) return;
|
|
453
|
+
const container = btn.closest(".missing-analysis-cta");
|
|
454
|
+
const tokenInput = container ? container.querySelector(".missing-analysis-token") : null;
|
|
455
|
+
const token = tokenInput ? String(tokenInput.value || "").trim() : "";
|
|
456
|
+
setMissingAnalysisCtaState({ loading: true, status: "Analyzing..." });
|
|
457
|
+
const activeRepo = currentRepoEntry();
|
|
458
|
+
const result = await analyzeRepoByHash(activeRepoHash, {
|
|
459
|
+
token,
|
|
460
|
+
showErrors: false,
|
|
461
|
+
privateModeHint: !!(activeRepo && activeRepo.private_mode),
|
|
462
|
+
});
|
|
463
|
+
if (tokenInput) tokenInput.value = "";
|
|
464
|
+
if (ghTokenEl) ghTokenEl.value = "";
|
|
465
|
+
updatePrivateModeIndicator();
|
|
466
|
+
if (!result || !result.ok) {
|
|
467
|
+
const err = analysisErrorMessage(result ? result.error : {});
|
|
468
|
+
setMissingAnalysisCtaState({ loading: false, status: "", error: err });
|
|
469
|
+
showToast(err, "error");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
showToast("Analysis completed.", "success");
|
|
473
|
+
setMissingAnalysisCtaState({ loading: false, status: "", error: "" });
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
document.querySelectorAll(".copy-analysis-cli-btn").forEach((btn) => {
|
|
478
|
+
if (btn.dataset.bound === "1") return;
|
|
479
|
+
btn.dataset.bound = "1";
|
|
480
|
+
btn.addEventListener("click", async () => {
|
|
481
|
+
const container = btn.closest(".missing-analysis-cta");
|
|
482
|
+
const cmdEl = container ? container.querySelector(".analysis-cli-command") : null;
|
|
483
|
+
const command = String((cmdEl && cmdEl.textContent) || analyzeCommandForRepo()).trim();
|
|
484
|
+
if (!command) return;
|
|
485
|
+
try {
|
|
486
|
+
await navigator.clipboard.writeText(command);
|
|
487
|
+
showToast("CLI command copied.", "success");
|
|
488
|
+
} catch (_e) {
|
|
489
|
+
showToast("Failed to copy command.", "error");
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function showMissingAnalysisState(message) {
|
|
496
|
+
const cta = renderMissingAnalysisCta(message);
|
|
497
|
+
fileViewEl.classList.remove("muted");
|
|
498
|
+
symbolViewEl.classList.remove("muted");
|
|
499
|
+
impactViewEl.classList.remove("muted");
|
|
500
|
+
graphViewEl.classList.remove("muted");
|
|
501
|
+
fileViewEl.innerHTML = cta;
|
|
502
|
+
symbolViewEl.innerHTML = cta;
|
|
503
|
+
impactViewEl.innerHTML = cta;
|
|
504
|
+
graphViewEl.innerHTML = cta;
|
|
505
|
+
bindRunAnalysisNowButton();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function graphParams() {
|
|
509
|
+
return {
|
|
510
|
+
mode: String(graphModeEl && graphModeEl.value ? graphModeEl.value : "symbol"),
|
|
511
|
+
depth: Number(graphDepthEl && graphDepthEl.value ? graphDepthEl.value : 1),
|
|
512
|
+
hideBuiltins: !!(graphHideBuiltinsEl && graphHideBuiltinsEl.checked),
|
|
513
|
+
hideExternal: !!(graphHideExternalEl && graphHideExternalEl.checked),
|
|
514
|
+
search: String(graphSearchEl && graphSearchEl.value ? graphSearchEl.value : "").trim().toLowerCase(),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function setActiveTab(tab) {
|
|
519
|
+
activeTab = tab === "graph" || tab === "architecture" || tab === "impact" ? tab : "details";
|
|
520
|
+
const isImpact = activeTab === "impact";
|
|
521
|
+
const isGraph = activeTab === "graph";
|
|
522
|
+
const isArchitecture = activeTab === "architecture";
|
|
523
|
+
if (tabDetailsEl) tabDetailsEl.classList.toggle("active", activeTab === "details");
|
|
524
|
+
if (tabImpactEl) tabImpactEl.classList.toggle("active", isImpact);
|
|
525
|
+
if (tabGraphEl) tabGraphEl.classList.toggle("active", isGraph);
|
|
526
|
+
if (tabArchitectureEl) tabArchitectureEl.classList.toggle("active", isArchitecture);
|
|
527
|
+
if (symbolViewEl) symbolViewEl.classList.toggle("hidden", activeTab !== "details");
|
|
528
|
+
if (impactViewEl) impactViewEl.classList.toggle("hidden", !isImpact);
|
|
529
|
+
if (graphViewEl) graphViewEl.classList.toggle("hidden", !isGraph);
|
|
530
|
+
if (architectureViewEl) architectureViewEl.classList.toggle("hidden", !isArchitecture);
|
|
531
|
+
if (graphControlsEl) graphControlsEl.classList.toggle("hidden", !isGraph);
|
|
532
|
+
if (impactControlsEl) impactControlsEl.classList.toggle("hidden", !isImpact);
|
|
533
|
+
if (isGraph) {
|
|
534
|
+
loadGraph();
|
|
535
|
+
}
|
|
536
|
+
if (isImpact) {
|
|
537
|
+
loadImpact();
|
|
538
|
+
}
|
|
539
|
+
if (isArchitecture) {
|
|
540
|
+
loadArchitecture();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function shortLabel(fqn) {
|
|
545
|
+
const parts = String(fqn || "").split(".");
|
|
546
|
+
if (parts.length >= 2) return `${parts[parts.length - 2]}.${parts[parts.length - 1]}`;
|
|
547
|
+
return parts[parts.length - 1] || fqn;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function basename(path) {
|
|
551
|
+
return String(path || "").replace(/\\/g, "/").split("/").pop() || "";
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function renderSymbolList(rows, symbolsMap, emptyText) {
|
|
555
|
+
if (!rows.length) return `<div class="muted">${esc(emptyText)}</div>`;
|
|
556
|
+
return rows.slice(0, 25).map((entry) => {
|
|
557
|
+
const fqn = typeof entry === "string" ? entry : entry.fqn;
|
|
558
|
+
const info = symbolsMap[fqn] || {};
|
|
559
|
+
const location = info.location || {};
|
|
560
|
+
return `<button class="arch-row" data-fqn="${esc(fqn)}">
|
|
561
|
+
<span class="arch-name">${esc(shortLabel(fqn))}</span>
|
|
562
|
+
<span class="path">in:${esc(info.fan_in ?? 0)} out:${esc(info.fan_out ?? 0)} ${esc(basename(location.file || ""))}</span>
|
|
563
|
+
</button>`;
|
|
564
|
+
}).join("");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function repoSummarySection() {
|
|
568
|
+
const cmd = `python cli.py api repo_summary --repo ${repoName || "<repo>"}`;
|
|
569
|
+
const controls = `
|
|
570
|
+
<div class="repo-row-actions">
|
|
571
|
+
<button id='repo-summary-view' class='repo-refresh-btn' type='button'>View summary</button>
|
|
572
|
+
<button id='repo-summary-regen' class='repo-refresh-btn' type='button'>Regenerate summary</button>
|
|
573
|
+
</div>
|
|
574
|
+
`;
|
|
575
|
+
if (repoSummaryStatus === "loading") {
|
|
576
|
+
return `<div class="card"><div class="section-title">Repo Summary</div>${controls}<div class="path">Loading summary...</div></div>`;
|
|
577
|
+
}
|
|
578
|
+
if (repoSummaryStatus === "disabled") {
|
|
579
|
+
return `<div class="card arch-missing"><div class="section-title">Repo Summary</div>${controls}<div>Summary unavailable.</div></div>`;
|
|
580
|
+
}
|
|
581
|
+
if (repoSummaryStatus === "missing") {
|
|
582
|
+
return `<div class="card arch-missing"><div class="section-title">Repo Summary</div>${controls}<div>No cached summary for current analysis. Click Regenerate.</div><div class="path">${esc(cmd)}</div></div>`;
|
|
583
|
+
}
|
|
584
|
+
if (repoSummaryStatus === "stale") {
|
|
585
|
+
return `<div class="card arch-missing"><div class="section-title">Repo Summary</div>${controls}<div><span class="repo-badge expiring">Outdated (repo changed)</span></div><div class="path">No cached summary for current analysis. Click Regenerate.</div></div>`;
|
|
586
|
+
}
|
|
587
|
+
if (repoSummaryStatus === "error") {
|
|
588
|
+
return `<div class="card arch-missing"><div class="section-title">Repo Summary</div>${controls}<div>${esc(repoSummaryError || "Failed to load repo summary.")}</div><div class="path">Run analyze, then: ${esc(cmd)}</div></div>`;
|
|
589
|
+
}
|
|
590
|
+
if (repoSummaryStatus !== "ready" || !repoSummary) {
|
|
591
|
+
return `<div class="card"><div class="section-title">Repo Summary</div>${controls}<div class="path">Summary is idle. View cached or regenerate.</div></div>`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const payload = repoSummary || {};
|
|
595
|
+
const summary = payload.summary || {};
|
|
596
|
+
const bullets = Array.isArray(summary.bullets) ? summary.bullets.slice(0, 7) : [];
|
|
597
|
+
const notes = Array.isArray(summary.notes) ? summary.notes.slice(0, 5) : [];
|
|
598
|
+
return `
|
|
599
|
+
<div class="card">
|
|
600
|
+
<div class="section-title">Repo Summary</div>
|
|
601
|
+
${controls}
|
|
602
|
+
<div class="arch-one-liner">${esc(summary.one_liner || "")}</div>
|
|
603
|
+
${bullets.length ? `<ul class="arch-bullets">${bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : "<div class='muted'>No bullets available.</div>"}
|
|
604
|
+
${notes.length ? `<div class="section-title">Notes</div><ul class="arch-bullets">${notes.map((n) => `<li>${esc(n)}</li>`).join("")}</ul>` : ""}
|
|
605
|
+
<div class="path">source: deterministic | cached: ${esc(String(payload.cached))} | updated: ${esc(payload.cached_at || payload.generated_at || repoSummaryUpdatedAt || "unknown")}</div>
|
|
606
|
+
</div>
|
|
607
|
+
`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function loadRepoSummary(force, generate) {
|
|
611
|
+
const shouldGenerate = !!generate;
|
|
612
|
+
if (!force && !activeRepoHash) {
|
|
613
|
+
repoSummaryStatus = "missing";
|
|
614
|
+
repoSummary = null;
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
repoSummaryStatus = "loading";
|
|
618
|
+
repoSummaryError = "";
|
|
619
|
+
try {
|
|
620
|
+
if (!shouldGenerate) {
|
|
621
|
+
const state = await fetchJson(`/api/repo_summary?repo=${encodeURIComponent(repoDir || "")}`);
|
|
622
|
+
if (state.exists && state.repo_summary) {
|
|
623
|
+
const cachedSummary = state.repo_summary || {};
|
|
624
|
+
repoSummary = {
|
|
625
|
+
provider: String(cachedSummary.provider || ""),
|
|
626
|
+
model: String(cachedSummary.model || ""),
|
|
627
|
+
cached: true,
|
|
628
|
+
generated_at: String(cachedSummary.generated_at || ""),
|
|
629
|
+
summary: _summaryFromMarkdown(String(cachedSummary.content_markdown || "")),
|
|
630
|
+
};
|
|
631
|
+
repoSummaryUpdatedAt = String(cachedSummary.generated_at || "");
|
|
632
|
+
repoSummaryStatus = "ready";
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (state.outdated) {
|
|
636
|
+
repoSummary = null;
|
|
637
|
+
repoSummaryUpdatedAt = "";
|
|
638
|
+
repoSummaryStatus = "stale";
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
repoSummary = null;
|
|
642
|
+
repoSummaryUpdatedAt = "";
|
|
643
|
+
repoSummaryStatus = "missing";
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const data = await fetchJson(`/api/repo_summary/generate?force=${force ? "1" : "0"}`, {
|
|
648
|
+
method: "POST",
|
|
649
|
+
headers: { "Content-Type": "application/json" },
|
|
650
|
+
body: JSON.stringify({ repo_hash: activeRepoHash || "", repo: repoDir || "", force: !!force }),
|
|
651
|
+
});
|
|
652
|
+
const generated = data.repo_summary || {};
|
|
653
|
+
repoSummary = {
|
|
654
|
+
provider: String(generated.provider || ""),
|
|
655
|
+
model: String(generated.model || ""),
|
|
656
|
+
cached: !!data.cached,
|
|
657
|
+
generated_at: String(generated.generated_at || ""),
|
|
658
|
+
summary: _summaryFromMarkdown(String(generated.content_markdown || "")),
|
|
659
|
+
};
|
|
660
|
+
repoSummaryUpdatedAt = String(generated.generated_at || "");
|
|
661
|
+
repoSummaryStatus = "ready";
|
|
662
|
+
showToast(data.cached ? "Using cached summary" : "Summary generated", "success");
|
|
663
|
+
} catch (e) {
|
|
664
|
+
repoSummary = null;
|
|
665
|
+
repoSummaryUpdatedAt = "";
|
|
666
|
+
if (e && e.error === "MISSING_ANALYSIS") {
|
|
667
|
+
repoSummaryStatus = "missing";
|
|
668
|
+
repoSummaryError = "";
|
|
669
|
+
} else if (e && e.error === "AI_DISABLED") {
|
|
670
|
+
repoSummaryStatus = "disabled";
|
|
671
|
+
} else {
|
|
672
|
+
repoSummaryStatus = "error";
|
|
673
|
+
repoSummaryError = redactSecrets((e && (e.message || e.error)) || "Repo summary load failed");
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function riskPillClass(risk) {
|
|
679
|
+
const v = String(risk || "").toLowerCase();
|
|
680
|
+
if (v === "high") return "risk-pill high";
|
|
681
|
+
if (v === "medium") return "risk-pill medium";
|
|
682
|
+
return "risk-pill low";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function riskRadarSection() {
|
|
686
|
+
const cmd = `python cli.py api risk_radar --repo ${repoName || "<repo>"}`;
|
|
687
|
+
if (riskRadarStatus === "loading") {
|
|
688
|
+
return `<div class="card"><div class="section-title">Risk Radar</div><div class="path">Loading risk radar...</div></div>`;
|
|
689
|
+
}
|
|
690
|
+
if (riskRadarStatus === "missing") {
|
|
691
|
+
return `<div class="card arch-missing"><div class="section-title">Risk Radar</div><div>Risk radar not generated yet.</div><div class="path">${esc(cmd)}</div></div>`;
|
|
692
|
+
}
|
|
693
|
+
if (riskRadarStatus === "error") {
|
|
694
|
+
return `<div class="card arch-missing"><div class="section-title">Risk Radar</div><div>${esc(riskRadarError || "Failed to load risk radar.")}</div><div class="path">Run analyze, then: ${esc(cmd)}</div></div>`;
|
|
695
|
+
}
|
|
696
|
+
if (riskRadarStatus !== "ready" || !riskRadar) {
|
|
697
|
+
return `<div class="card"><div class="section-title">Risk Radar</div><div class="path">No risk data loaded.</div></div>`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const payload = riskRadar || {};
|
|
701
|
+
const hotspots = Array.isArray(payload.hotspots) ? payload.hotspots.slice(0, 5) : [];
|
|
702
|
+
const riskyFiles = Array.isArray(payload.risky_files) ? payload.risky_files.slice(0, 5) : [];
|
|
703
|
+
const refactors = Array.isArray(payload.refactor_targets) ? payload.refactor_targets.slice(0, 6) : [];
|
|
704
|
+
|
|
705
|
+
const hotspotsHtml = hotspots.length
|
|
706
|
+
? hotspots.map((h) => `
|
|
707
|
+
<button class="arch-row risk-hotspot-row" data-fqn="${esc(h.fqn)}">
|
|
708
|
+
<span class="arch-name">${esc(shortLabel(h.fqn))}</span>
|
|
709
|
+
<span class="${riskPillClass(h.risk)}">${esc(h.risk)}</span>
|
|
710
|
+
<span class="path">score:${esc(h.score)} in:${esc(h.fan_in)} out:${esc(h.fan_out)}</span>
|
|
711
|
+
${Array.isArray(h.reasons) && h.reasons.length ? `<span class="path">${esc(h.reasons[0])}</span>` : ""}
|
|
712
|
+
</button>
|
|
713
|
+
`).join("")
|
|
714
|
+
: "<div class='muted'>No hotspots detected.</div>";
|
|
715
|
+
|
|
716
|
+
const filesHtml = riskyFiles.length
|
|
717
|
+
? riskyFiles.map((f) => `
|
|
718
|
+
<div class="risk-file-row">
|
|
719
|
+
<span class="arch-name">${esc(basename(f.file || ""))}</span>
|
|
720
|
+
<span class="${riskPillClass(f.risk)}">${esc(f.risk)}</span>
|
|
721
|
+
<span class="path">score:${esc(f.score)} edges:${esc(f.edges)}</span>
|
|
722
|
+
</div>
|
|
723
|
+
`).join("")
|
|
724
|
+
: "<div class='muted'>No risky files detected.</div>";
|
|
725
|
+
|
|
726
|
+
const refactorHtml = refactors.length
|
|
727
|
+
? refactors.map((r) => `
|
|
728
|
+
<div class="risk-target">
|
|
729
|
+
<div class="arch-name">${esc(r.title || "")}</div>
|
|
730
|
+
<div class="path">${esc(r.why || "")}</div>
|
|
731
|
+
${(Array.isArray(r.targets) && r.targets.length) ? `<div class="path">targets: ${esc(r.targets.join(", "))}</div>` : ""}
|
|
732
|
+
</div>
|
|
733
|
+
`).join("")
|
|
734
|
+
: "<div class='muted'>No refactor targets suggested.</div>";
|
|
735
|
+
|
|
736
|
+
return `
|
|
737
|
+
<div class="card">
|
|
738
|
+
<div class="section-title">Risk Radar</div>
|
|
739
|
+
<div class="path">updated: ${esc(riskRadarUpdatedAt || "unknown")}</div>
|
|
740
|
+
<div class="divider"></div>
|
|
741
|
+
<div class="section-title">Top Hotspots</div>
|
|
742
|
+
${hotspotsHtml}
|
|
743
|
+
<div class="divider"></div>
|
|
744
|
+
<div class="section-title">Top Risky Files</div>
|
|
745
|
+
${filesHtml}
|
|
746
|
+
<div class="divider"></div>
|
|
747
|
+
<div class="section-title">Refactor Targets</div>
|
|
748
|
+
${refactorHtml}
|
|
749
|
+
</div>
|
|
750
|
+
`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function loadRiskRadar(force) {
|
|
754
|
+
if (!force && riskRadarStatus === "ready" && riskRadar) return;
|
|
755
|
+
riskRadarStatus = "loading";
|
|
756
|
+
riskRadarError = "";
|
|
757
|
+
try {
|
|
758
|
+
const data = await fetchJson("/api/risk_radar");
|
|
759
|
+
riskRadar = data.risk_radar || null;
|
|
760
|
+
riskRadarUpdatedAt = data.updated_at || "";
|
|
761
|
+
riskRadarStatus = "ready";
|
|
762
|
+
} catch (e) {
|
|
763
|
+
riskRadar = null;
|
|
764
|
+
riskRadarUpdatedAt = "";
|
|
765
|
+
if (e && e.error === "MISSING_RISK_RADAR") {
|
|
766
|
+
riskRadarStatus = "missing";
|
|
767
|
+
riskRadarError = "";
|
|
768
|
+
} else {
|
|
769
|
+
riskRadarStatus = "error";
|
|
770
|
+
riskRadarError = redactSecrets((e && (e.message || e.error)) || "Risk radar load failed");
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function loadArchitecture() {
|
|
776
|
+
if (!architectureViewEl) return;
|
|
777
|
+
architectureViewEl.classList.remove("muted");
|
|
778
|
+
architectureViewEl.innerHTML = "<div class='card'>Loading architecture insights...</div>";
|
|
779
|
+
try {
|
|
780
|
+
if (!architectureCache) architectureCache = await fetchJson("/api/architecture");
|
|
781
|
+
await loadRepoSummary(false, false);
|
|
782
|
+
await loadRiskRadar(false);
|
|
783
|
+
const arch = architectureCache.architecture_metrics || {};
|
|
784
|
+
const dep = architectureCache.dependency_cycles || {};
|
|
785
|
+
const repo = arch.repo || {};
|
|
786
|
+
const symbolsMap = arch.symbols || {};
|
|
787
|
+
|
|
788
|
+
const orchestrators = (repo.orchestrators && repo.orchestrators.length ? repo.orchestrators : (repo.top_fan_out || []).map((x) => x.fqn || x)).filter(Boolean);
|
|
789
|
+
const critical = (repo.critical_symbols && repo.critical_symbols.length ? repo.critical_symbols : (repo.top_fan_in || []).map((x) => x.fqn || x)).filter(Boolean);
|
|
790
|
+
const dead = (repo.dead_symbols || []).filter(Boolean);
|
|
791
|
+
const cycles = dep.cycles || [];
|
|
792
|
+
|
|
793
|
+
architectureViewEl.innerHTML = `
|
|
794
|
+
${repoSummarySection()}
|
|
795
|
+
${riskRadarSection()}
|
|
796
|
+
<div class="arch-grid">
|
|
797
|
+
<div class="kpi-card"><div class="kpi-label">Orchestrators</div><div class="kpi-value">${orchestrators.length}</div></div>
|
|
798
|
+
<div class="kpi-card"><div class="kpi-label">Critical APIs</div><div class="kpi-value">${critical.length}</div></div>
|
|
799
|
+
<div class="kpi-card"><div class="kpi-label">Dead Symbols</div><div class="kpi-value">${dead.length}</div></div>
|
|
800
|
+
<div class="kpi-card"><div class="kpi-label">Dependency Cycles</div><div class="kpi-value">${dep.cycle_count || 0}</div></div>
|
|
801
|
+
</div>
|
|
802
|
+
<div class="card">
|
|
803
|
+
<div class="section-title">Top Orchestrators</div>
|
|
804
|
+
${renderSymbolList(orchestrators, symbolsMap, "No orchestrators detected.")}
|
|
805
|
+
</div>
|
|
806
|
+
<div class="card">
|
|
807
|
+
<div class="section-title">Top Critical Symbols</div>
|
|
808
|
+
${renderSymbolList(critical, symbolsMap, "No critical symbols detected.")}
|
|
809
|
+
</div>
|
|
810
|
+
<div class="card">
|
|
811
|
+
<div class="section-title">Dead Symbols</div>
|
|
812
|
+
${renderSymbolList(dead, symbolsMap, "No dead symbols detected.")}
|
|
813
|
+
</div>
|
|
814
|
+
<div class="card">
|
|
815
|
+
<div class="section-title">Dependency Cycles</div>
|
|
816
|
+
${dep.cycle_count ? cycles.slice(0, 50).map((c, i) => `<div class="cycle-row"><span>${esc(c.join(" -> "))}</span><button class="copy-cycle" data-cycle="${esc(c.join(" -> "))}">Copy</button></div>`).join("") : "<div class='ok-cycle'>No cycles detected OK</div>"}
|
|
817
|
+
</div>
|
|
818
|
+
`;
|
|
819
|
+
|
|
820
|
+
const viewBtn = architectureViewEl.querySelector("#repo-summary-view");
|
|
821
|
+
if (viewBtn) {
|
|
822
|
+
viewBtn.addEventListener("click", async () => {
|
|
823
|
+
await loadRepoSummary(false, false);
|
|
824
|
+
await loadArchitecture();
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
const regenBtn = architectureViewEl.querySelector("#repo-summary-regen");
|
|
828
|
+
if (regenBtn) {
|
|
829
|
+
regenBtn.addEventListener("click", async () => {
|
|
830
|
+
const forceEl = architectureViewEl.querySelector("#repo-summary-force");
|
|
831
|
+
const force = !!(forceEl && forceEl.checked);
|
|
832
|
+
await loadRepoSummary(force, true);
|
|
833
|
+
await loadArchitecture();
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
architectureViewEl.querySelectorAll(".arch-row").forEach((el) => {
|
|
837
|
+
el.addEventListener("click", async () => {
|
|
838
|
+
const fqn = el.getAttribute("data-fqn");
|
|
839
|
+
if (!fqn) return;
|
|
840
|
+
setActiveTab("details");
|
|
841
|
+
await loadSymbol(fqn);
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
architectureViewEl.querySelectorAll(".copy-cycle[data-cycle]").forEach((el) => {
|
|
845
|
+
el.addEventListener("click", async () => {
|
|
846
|
+
const text = el.getAttribute("data-cycle") || "";
|
|
847
|
+
try {
|
|
848
|
+
await navigator.clipboard.writeText(text);
|
|
849
|
+
el.textContent = "Copied";
|
|
850
|
+
window.setTimeout(() => { el.textContent = "Copy"; }, 800);
|
|
851
|
+
} catch (_e) {
|
|
852
|
+
// noop
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
} catch (e) {
|
|
857
|
+
const errCode = String((e && e.error) || "");
|
|
858
|
+
if (errCode === "MISSING_ARCHITECTURE_CACHE" || errCode === "CACHE_NOT_FOUND") {
|
|
859
|
+
architectureViewEl.classList.remove("muted");
|
|
860
|
+
architectureViewEl.innerHTML = renderMissingAnalysisCta("Run Analyze first to unlock architecture insights.");
|
|
861
|
+
bindRunAnalysisNowButton();
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
architectureViewEl.classList.remove("muted");
|
|
865
|
+
architectureViewEl.innerHTML = `
|
|
866
|
+
<div class="card arch-missing">
|
|
867
|
+
<div class="section-title">Architecture Insights Unavailable</div>
|
|
868
|
+
<div>${esc((e && (e.message || e.error)) || "Missing architecture cache artifacts.")}</div>
|
|
869
|
+
<div class="path">Run: python cli.py api analyze --path <repo></div>
|
|
870
|
+
</div>
|
|
871
|
+
`;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function renderGraphData(data) {
|
|
876
|
+
const p = graphParams();
|
|
877
|
+
const nodes = (data.nodes || []).slice();
|
|
878
|
+
const edges = (data.edges || []).slice();
|
|
879
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
880
|
+
const center = data.center || activeSymbolFqn;
|
|
881
|
+
const mode = data.mode || "symbol";
|
|
882
|
+
const seedNodes = new Set(data.seed_nodes || []);
|
|
883
|
+
|
|
884
|
+
const matches = new Set(
|
|
885
|
+
!p.search
|
|
886
|
+
? []
|
|
887
|
+
: nodes.filter((n) => n.id.toLowerCase().includes(p.search) || String(n.label || "").toLowerCase().includes(p.search)).map((n) => n.id)
|
|
888
|
+
);
|
|
889
|
+
|
|
890
|
+
const incoming = [];
|
|
891
|
+
const outgoing = [];
|
|
892
|
+
const internal = [];
|
|
893
|
+
for (const e of edges) {
|
|
894
|
+
if (mode === "file") {
|
|
895
|
+
const fromSeed = seedNodes.has(e.from);
|
|
896
|
+
const toSeed = seedNodes.has(e.to);
|
|
897
|
+
if (toSeed && !fromSeed) incoming.push(e);
|
|
898
|
+
else if (fromSeed && !toSeed) outgoing.push(e);
|
|
899
|
+
else if (fromSeed && toSeed) internal.push(e);
|
|
900
|
+
} else {
|
|
901
|
+
if (e.to === center) incoming.push(e);
|
|
902
|
+
if (e.from === center) outgoing.push(e);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function nodePill(nodeId) {
|
|
907
|
+
const n = byId.get(nodeId) || { id: nodeId, label: nodeId, kind: "external", clickable: false };
|
|
908
|
+
const cls = `graph-node kind-${n.kind} ${matches.has(nodeId) ? "graph-match" : ""} ${n.clickable ? "graph-clickable" : ""}`;
|
|
909
|
+
const subtitle = n.subtitle ? `<div class="path">${esc(n.subtitle)}</div>` : "";
|
|
910
|
+
return `<div class="${cls}" data-node-id="${esc(nodeId)}">
|
|
911
|
+
<div>${esc(n.label || nodeId)}</div>
|
|
912
|
+
${subtitle}
|
|
913
|
+
</div>`;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
graphViewEl.classList.remove("muted");
|
|
917
|
+
graphViewEl.innerHTML = `
|
|
918
|
+
<div class="card graph-card">
|
|
919
|
+
<div class="graph-legend">
|
|
920
|
+
<span class="legend-item"><span class="dot local"></span>Local</span>
|
|
921
|
+
<span class="legend-item"><span class="dot builtin"></span>Builtins</span>
|
|
922
|
+
<span class="legend-item"><span class="dot external"></span>External</span>
|
|
923
|
+
</div>
|
|
924
|
+
<div class="section-title">${mode === "file" ? "Center File" : "Center"}</div>
|
|
925
|
+
${mode === "file" ? `<div class="path">${esc(center)}</div>` : nodePill(center)}
|
|
926
|
+
${mode === "file" ? `<div class="path">Seed symbols: ${seedNodes.size}</div>` : ""}
|
|
927
|
+
<div class="divider"></div>
|
|
928
|
+
<div class="section-title">Incoming Callers (${incoming.length})</div>
|
|
929
|
+
${incoming.length ? incoming.slice(0, 200).map((e) => `<div class="graph-edge-row">${nodePill(e.from)} <span class="edge-arrow">-></span> <span class="path">${esc(e.count)}x</span></div>`).join("") : "<div class='muted'>No incoming callers in current depth/filter.</div>"}
|
|
930
|
+
<div class="divider"></div>
|
|
931
|
+
<div class="section-title">Outgoing Callees (${outgoing.length})</div>
|
|
932
|
+
${outgoing.length ? outgoing.slice(0, 200).map((e) => `<div class="graph-edge-row">${nodePill(e.to)} <span class="path">${esc(e.count)}x</span></div>`).join("") : "<div class='muted'>No outgoing callees in current depth/filter.</div>"}
|
|
933
|
+
${mode === "file" ? `<div class="divider"></div><div class="section-title">Internal File Edges (${internal.length})</div>${internal.length ? internal.slice(0, 200).map((e) => `<div class="graph-edge-row">${nodePill(e.from)} <span class="edge-arrow">-></span> ${nodePill(e.to)} <span class="path">${esc(e.count)}x</span></div>`).join("") : "<div class='muted'>No internal edges in current depth/filter.</div>"}` : ""}
|
|
934
|
+
<div class="divider"></div>
|
|
935
|
+
<div class="section-title">Subgraph Stats</div>
|
|
936
|
+
<div class="path">Nodes: ${nodes.length} | Edges: ${edges.length} | Depth: ${data.depth}</div>
|
|
937
|
+
</div>
|
|
938
|
+
`;
|
|
939
|
+
|
|
940
|
+
graphViewEl.querySelectorAll(".graph-node.graph-clickable").forEach((el) => {
|
|
941
|
+
el.addEventListener("click", () => {
|
|
942
|
+
const next = el.getAttribute("data-node-id");
|
|
943
|
+
if (next) loadSymbol(next);
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function renderImpactList(nodes, emptyText) {
|
|
949
|
+
if (!nodes || !nodes.length) return `<div class="muted">${esc(emptyText)}</div>`;
|
|
950
|
+
return nodes.slice(0, 200).map((n) => {
|
|
951
|
+
const isLocal = !String(n.fqn || "").startsWith("builtins.") && !String(n.fqn || "").startsWith("external::");
|
|
952
|
+
if (isLocal) {
|
|
953
|
+
return `
|
|
954
|
+
<button class="arch-row impact-node-row" data-fqn="${esc(n.fqn)}">
|
|
955
|
+
<span class="arch-name">${esc(shortLabel(n.fqn))}</span>
|
|
956
|
+
<span class="impact-distance">d${esc(n.distance)}</span>
|
|
957
|
+
<span class="path">in:${esc(n.fan_in)} out:${esc(n.fan_out)} ${esc(n.file ? relPath(n.file) : "")}:${esc(n.line)}</span>
|
|
958
|
+
</button>
|
|
959
|
+
`;
|
|
960
|
+
}
|
|
961
|
+
return `
|
|
962
|
+
<div class="risk-file-row">
|
|
963
|
+
<span class="arch-name">${esc(shortLabel(n.fqn))}</span>
|
|
964
|
+
<span class="impact-distance">d${esc(n.distance)}</span>
|
|
965
|
+
<span class="path">in:${esc(n.fan_in)} out:${esc(n.fan_out)} ${esc(n.file ? relPath(n.file) : "")}:${esc(n.line)}</span>
|
|
966
|
+
</div>
|
|
967
|
+
`;
|
|
968
|
+
}).join("");
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function renderImpactedFiles(items) {
|
|
972
|
+
if (!items || !items.length) return "<div class='muted'>No impacted files.</div>";
|
|
973
|
+
return items.slice(0, 15).map((x) => `
|
|
974
|
+
<div class="impact-file-row">
|
|
975
|
+
<span class="path">${esc(relPath(x.file || ""))}</span>
|
|
976
|
+
<span class="impact-distance">${esc(x.count)}</span>
|
|
977
|
+
</div>
|
|
978
|
+
`).join("");
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function loadImpact(fqn) {
|
|
982
|
+
if (!impactViewEl) return;
|
|
983
|
+
const anchor = fqn || activeSymbolFqn;
|
|
984
|
+
if (!anchor) {
|
|
985
|
+
impactViewEl.classList.add("muted");
|
|
986
|
+
impactViewEl.textContent = "Select a symbol to view impact.";
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const depth = Number(impactDepthEl && impactDepthEl.value ? impactDepthEl.value : 2);
|
|
990
|
+
const maxNodes = Number(impactMaxNodesEl && impactMaxNodesEl.value ? impactMaxNodesEl.value : 200);
|
|
991
|
+
const key = `${anchor}|${depth}|${maxNodes}`;
|
|
992
|
+
impactViewEl.classList.remove("muted");
|
|
993
|
+
impactViewEl.innerHTML = "<div class='card'>Loading impact...</div>";
|
|
994
|
+
try {
|
|
995
|
+
let data = impactDataCache.get(key);
|
|
996
|
+
if (!data) {
|
|
997
|
+
data = await fetchJson(`/api/impact?target=${encodeURIComponent(anchor)}&depth=${depth}&max_nodes=${maxNodes}`);
|
|
998
|
+
impactDataCache.set(key, data);
|
|
999
|
+
}
|
|
1000
|
+
const up = data.upstream || { nodes: [], truncated: false };
|
|
1001
|
+
const down = data.downstream || { nodes: [], truncated: false };
|
|
1002
|
+
const files = data.impacted_files || { upstream: [], downstream: [] };
|
|
1003
|
+
const truncated = !!(up.truncated || down.truncated);
|
|
1004
|
+
const upstreamBadge = up.truncated ? " <span class='impact-section-badge'>TRUNCATED</span>" : "";
|
|
1005
|
+
const downstreamBadge = down.truncated ? " <span class='impact-section-badge'>TRUNCATED</span>" : "";
|
|
1006
|
+
|
|
1007
|
+
impactViewEl.innerHTML = `
|
|
1008
|
+
<div class="card impact-card">
|
|
1009
|
+
<div class="section-title">Impact</div>
|
|
1010
|
+
<div class="path">Target: ${esc(anchor)} | depth: ${esc(data.depth)} | max_nodes: ${esc(data.max_nodes)}</div>
|
|
1011
|
+
${truncated ? `<div class='impact-truncated-banner'>Warning: Results truncated (max_nodes=${esc(data.max_nodes)}). Displaying partial results.</div>` : ""}
|
|
1012
|
+
<div class="divider"></div>
|
|
1013
|
+
<div class="section-title">Upstream${upstreamBadge}</div>
|
|
1014
|
+
${renderImpactList(up.nodes || [], "No upstream dependents in selected depth.")}
|
|
1015
|
+
<div class="divider"></div>
|
|
1016
|
+
<div class="section-title">Downstream${downstreamBadge}</div>
|
|
1017
|
+
${renderImpactList(down.nodes || [], "No downstream dependencies in selected depth.")}
|
|
1018
|
+
<div class="divider"></div>
|
|
1019
|
+
<div class="section-title">Impacted Files (Upstream)</div>
|
|
1020
|
+
${renderImpactedFiles(files.upstream || [])}
|
|
1021
|
+
<div class="divider"></div>
|
|
1022
|
+
<div class="section-title">Impacted Files (Downstream)</div>
|
|
1023
|
+
${renderImpactedFiles(files.downstream || [])}
|
|
1024
|
+
</div>
|
|
1025
|
+
`;
|
|
1026
|
+
|
|
1027
|
+
impactViewEl.querySelectorAll(".impact-node-row").forEach((el) => {
|
|
1028
|
+
el.addEventListener("click", async () => {
|
|
1029
|
+
const next = el.getAttribute("data-fqn");
|
|
1030
|
+
if (!next) return;
|
|
1031
|
+
setActiveTab("details");
|
|
1032
|
+
await loadSymbol(next);
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
} catch (e) {
|
|
1036
|
+
const errCode = String((e && e.error) || "");
|
|
1037
|
+
if (errCode === "MISSING_ANALYSIS" || errCode === "CACHE_NOT_FOUND") {
|
|
1038
|
+
impactViewEl.classList.remove("muted");
|
|
1039
|
+
impactViewEl.innerHTML = renderMissingAnalysisCta("After it finishes, impact will load automatically.");
|
|
1040
|
+
bindRunAnalysisNowButton();
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
impactViewEl.classList.add("muted");
|
|
1044
|
+
impactViewEl.textContent = redactSecrets((e && (e.error || e.message)) || "Impact unavailable.");
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
async function loadGraph(fqn) {
|
|
1049
|
+
if (!graphViewEl) return;
|
|
1050
|
+
const p = graphParams();
|
|
1051
|
+
const graphMode = p.mode === "file" ? "file" : "symbol";
|
|
1052
|
+
const anchor = graphMode === "file" ? activeFilePath : (fqn || activeSymbolFqn);
|
|
1053
|
+
if (!anchor) {
|
|
1054
|
+
graphViewEl.classList.add("muted");
|
|
1055
|
+
graphViewEl.textContent = graphMode === "file"
|
|
1056
|
+
? "Select a file to view file graph."
|
|
1057
|
+
: "Select a symbol to view graph.";
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const key = `${graphMode}|${anchor}|${p.depth}|${p.hideBuiltins}|${p.hideExternal}`;
|
|
1061
|
+
graphViewEl.classList.remove("muted");
|
|
1062
|
+
graphViewEl.innerHTML = "<div class='card'>Loading graph...</div>";
|
|
1063
|
+
try {
|
|
1064
|
+
let data = graphDataCache.get(key);
|
|
1065
|
+
if (!data) {
|
|
1066
|
+
const targetParam = graphMode === "file"
|
|
1067
|
+
? `file=${encodeURIComponent(anchor)}`
|
|
1068
|
+
: `fqn=${encodeURIComponent(anchor)}`;
|
|
1069
|
+
data = await fetchJson(`/api/graph?${targetParam}&depth=${p.depth}&hide_builtins=${p.hideBuiltins ? "true" : "false"}&hide_external=${p.hideExternal ? "true" : "false"}`);
|
|
1070
|
+
graphDataCache.set(key, data);
|
|
1071
|
+
}
|
|
1072
|
+
renderGraphData(data);
|
|
1073
|
+
} catch (e) {
|
|
1074
|
+
const errCode = String((e && e.error) || "");
|
|
1075
|
+
if (errCode === "MISSING_ANALYSIS" || errCode === "CACHE_NOT_FOUND") {
|
|
1076
|
+
graphViewEl.classList.remove("muted");
|
|
1077
|
+
graphViewEl.innerHTML = renderMissingAnalysisCta("After it finishes, graph will load automatically.");
|
|
1078
|
+
bindRunAnalysisNowButton();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
graphViewEl.classList.add("muted");
|
|
1082
|
+
graphViewEl.textContent = redactSecrets((e && (e.error || e.message)) || "Graph unavailable.");
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function renderRecentSymbols() {
|
|
1087
|
+
if (!recentSymbolsEl || !recentSymbolsWrapEl) return;
|
|
1088
|
+
const items = recentSymbols.slice(0, 8);
|
|
1089
|
+
if (!items.length) {
|
|
1090
|
+
recentSymbolsWrapEl.classList.add("hidden");
|
|
1091
|
+
recentSymbolsEl.className = "content muted";
|
|
1092
|
+
recentSymbolsEl.textContent = "";
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
recentSymbolsWrapEl.classList.remove("hidden");
|
|
1096
|
+
recentSymbolsEl.className = "content";
|
|
1097
|
+
recentSymbolsEl.innerHTML = items
|
|
1098
|
+
.map((fqn) => {
|
|
1099
|
+
const p = parseSymbolParts(fqn);
|
|
1100
|
+
return `<div><span class="symbol-link recent-link" data-fqn="${esc(fqn)}">${esc(p.display)}</span> <span class="path">${esc(fqn)}</span></div>`;
|
|
1101
|
+
})
|
|
1102
|
+
.join("");
|
|
1103
|
+
recentSymbolsEl.querySelectorAll(".recent-link").forEach((el) => {
|
|
1104
|
+
el.addEventListener("click", () => loadSymbol(el.getAttribute("data-fqn")));
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function renderRecentFiles() {
|
|
1109
|
+
if (!recentFilesEl || !recentFilesWrapEl) return;
|
|
1110
|
+
const items = recentFiles.slice(0, 8);
|
|
1111
|
+
if (!items.length) {
|
|
1112
|
+
recentFilesWrapEl.classList.add("hidden");
|
|
1113
|
+
recentFilesEl.className = "content muted";
|
|
1114
|
+
recentFilesEl.textContent = "";
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
recentFilesWrapEl.classList.remove("hidden");
|
|
1118
|
+
recentFilesEl.className = "content";
|
|
1119
|
+
recentFilesEl.innerHTML = items
|
|
1120
|
+
.map((file) => `<div><span class="symbol-link recent-file-link" data-file="${esc(file)}">${esc(file)}</span></div>`)
|
|
1121
|
+
.join("");
|
|
1122
|
+
recentFilesEl.querySelectorAll(".recent-file-link").forEach((el) => {
|
|
1123
|
+
el.addEventListener("click", () => loadFile(el.getAttribute("data-file")));
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function renderRecents() {
|
|
1128
|
+
renderRecentSymbols();
|
|
1129
|
+
renderRecentFiles();
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async function loadUiState() {
|
|
1133
|
+
try {
|
|
1134
|
+
const data = await fetchJson("/api/ui_state");
|
|
1135
|
+
const state = data.state || {};
|
|
1136
|
+
recentSymbols = Array.isArray(state.recent_symbols) ? state.recent_symbols : [];
|
|
1137
|
+
recentFiles = Array.isArray(state.recent_files) ? state.recent_files : [];
|
|
1138
|
+
lastSymbol = String(state.last_symbol || "");
|
|
1139
|
+
renderRecents();
|
|
1140
|
+
return true;
|
|
1141
|
+
} catch (_e) {
|
|
1142
|
+
recentSymbols = [];
|
|
1143
|
+
recentFiles = [];
|
|
1144
|
+
lastSymbol = "";
|
|
1145
|
+
renderRecents();
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async function updateUiState(payload) {
|
|
1151
|
+
try {
|
|
1152
|
+
await fetchJson("/api/ui_state/update", {
|
|
1153
|
+
method: "POST",
|
|
1154
|
+
headers: { "Content-Type": "application/json" },
|
|
1155
|
+
body: JSON.stringify(payload || {}),
|
|
1156
|
+
});
|
|
1157
|
+
await loadUiState();
|
|
1158
|
+
} catch (_e) {
|
|
1159
|
+
// Keep UI responsive even if persistence fails.
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function renderWorkspaceSelect() {
|
|
1164
|
+
if (!repoSelectEl) return;
|
|
1165
|
+
const placeholder = !activeRepoHash
|
|
1166
|
+
? '<option value="" selected>Select repo...</option>'
|
|
1167
|
+
: "";
|
|
1168
|
+
repoSelectEl.innerHTML = placeholder + workspaceRepos
|
|
1169
|
+
.map((r) => `<option value="${esc(r.repo_hash)}" ${r.repo_hash === activeRepoHash ? "selected" : ""}>${esc(r.name)}</option>`)
|
|
1170
|
+
.join("");
|
|
1171
|
+
syncRepoHeader();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function loadWorkspace() {
|
|
1175
|
+
try {
|
|
1176
|
+
const reg = await fetchJson("/api/registry");
|
|
1177
|
+
rememberRepos = !!reg.remember_repos;
|
|
1178
|
+
if (aiSettingsRememberReposEl) aiSettingsRememberReposEl.checked = !!rememberRepos;
|
|
1179
|
+
updateRepoListModeUi();
|
|
1180
|
+
} catch (_e) {
|
|
1181
|
+
rememberRepos = false;
|
|
1182
|
+
updateRepoListModeUi();
|
|
1183
|
+
}
|
|
1184
|
+
const ws = await fetchJson("/api/workspace");
|
|
1185
|
+
workspaceRepos = ws.repos || [];
|
|
1186
|
+
activeRepoHash = ws.active_repo_hash || "";
|
|
1187
|
+
if (!rememberRepos) {
|
|
1188
|
+
workspaceRepos = [];
|
|
1189
|
+
activeRepoHash = "";
|
|
1190
|
+
}
|
|
1191
|
+
renderWorkspaceSelect();
|
|
1192
|
+
syncRepoHeader();
|
|
1193
|
+
await loadRepoRegistry();
|
|
1194
|
+
await loadDataPrivacy();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async function selectWorkspace(repoHash) {
|
|
1198
|
+
if (!repoHash) return;
|
|
1199
|
+
try {
|
|
1200
|
+
await fetchJson("/api/workspace/select", {
|
|
1201
|
+
method: "POST",
|
|
1202
|
+
headers: { "Content-Type": "application/json" },
|
|
1203
|
+
body: JSON.stringify({ repo_hash: repoHash }),
|
|
1204
|
+
});
|
|
1205
|
+
} catch (_e) {
|
|
1206
|
+
const candidate = (repoRegistry || []).find((r) => String(r.repo_hash) === String(repoHash));
|
|
1207
|
+
if (candidate && candidate.repo_path) {
|
|
1208
|
+
await fetchJson("/api/workspace/add", {
|
|
1209
|
+
method: "POST",
|
|
1210
|
+
headers: { "Content-Type": "application/json" },
|
|
1211
|
+
body: JSON.stringify({ path: candidate.repo_path }),
|
|
1212
|
+
});
|
|
1213
|
+
await fetchJson("/api/workspace/select", {
|
|
1214
|
+
method: "POST",
|
|
1215
|
+
headers: { "Content-Type": "application/json" },
|
|
1216
|
+
body: JSON.stringify({ repo_hash: repoHash }),
|
|
1217
|
+
});
|
|
1218
|
+
} else {
|
|
1219
|
+
throw _e;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
activeRepoHash = repoHash;
|
|
1223
|
+
syncRepoHeader();
|
|
1224
|
+
await refreshForActiveRepo();
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function addWorkspaceRepo() {
|
|
1228
|
+
if (!addRepoInlineEl) return;
|
|
1229
|
+
if (addRepoInlineEl.classList.contains("hidden")) {
|
|
1230
|
+
openRepoPanel("local");
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
closeRepoPanel(false);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function repoBadge(repo) {
|
|
1237
|
+
if (!repo) return "";
|
|
1238
|
+
if (!repo.has_analysis) return `<span class="repo-badge not-analyzed">Not analyzed</span>`;
|
|
1239
|
+
const daysLeft = Number(repo && repo.retention ? repo.retention.days_left : NaN);
|
|
1240
|
+
if (Number.isFinite(daysLeft) && daysLeft <= 3 && daysLeft >= 0) {
|
|
1241
|
+
return `<span class="repo-badge expiring">Expiring in ${Math.ceil(daysLeft)}d</span>`;
|
|
1242
|
+
}
|
|
1243
|
+
return `<span class="repo-badge analyzed">Analyzed</span>`;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function sourceLabel(repo) {
|
|
1247
|
+
if (!repo) return "filesystem";
|
|
1248
|
+
if (String(repo.source || "") === "github") return `github ${repo.mode || "zip"}`;
|
|
1249
|
+
return "filesystem";
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function repoPolicyValue(repo) {
|
|
1253
|
+
const r = (repo && repo.retention) || {};
|
|
1254
|
+
const mode = String(r.mode || "ttl");
|
|
1255
|
+
const ttl = Number(r.ttl_days || 30);
|
|
1256
|
+
if (mode === "pinned" || ttl <= 0) return "never";
|
|
1257
|
+
if (ttl <= 1) return "24h";
|
|
1258
|
+
if (ttl <= 7) return "7d";
|
|
1259
|
+
if (ttl <= 14) return "14d";
|
|
1260
|
+
if (ttl <= 30) return "30d";
|
|
1261
|
+
return "90d";
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function isExpiringSoon(repo) {
|
|
1265
|
+
const daysLeft = Number(repo && repo.retention ? repo.retention.days_left : NaN);
|
|
1266
|
+
return Number.isFinite(daysLeft) && daysLeft >= 0 && daysLeft < 2;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function renderRepoRegistry() {
|
|
1270
|
+
if (!repoListContentEl) return;
|
|
1271
|
+
const rows = Array.isArray(repoRegistry) ? repoRegistry : [];
|
|
1272
|
+
if (!rows.length) {
|
|
1273
|
+
repoListContentEl.innerHTML = "<div class='muted'>No repositories added yet. Add a local path or GitHub repo to begin.</div>";
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
repoListContentEl.innerHTML = rows.map((r) => `
|
|
1277
|
+
<div class="repo-row" data-repo-hash="${esc(r.repo_hash)}">
|
|
1278
|
+
<div class="repo-row-header">
|
|
1279
|
+
<div class="repo-row-name">${esc(r.name || r.repo_hash)} ${r.private_mode ? "<span class='repo-badge private'>PRIVATE MODE</span>" : ""}</div>
|
|
1280
|
+
<div class="repo-row-actions">
|
|
1281
|
+
${repoBadge(r)}
|
|
1282
|
+
${isExpiringSoon(r) ? "<span class='expires-chip'>Expires soon</span>" : ""}
|
|
1283
|
+
</div>
|
|
1284
|
+
</div>
|
|
1285
|
+
<div class="path">${esc(r.source === "github" ? (r.repo_url || r.repo_path || "") : (r.repo_path || ""))}</div>
|
|
1286
|
+
<div class="path">Source: ${esc(sourceLabel(r))}</div>
|
|
1287
|
+
<div class="path">Cache size: ${esc(formatBytes(r.size_bytes || 0))} | Last analyzed: ${esc(r.last_updated || "unknown")}</div>
|
|
1288
|
+
<div class="path">${esc(r.cache_dir || "")}</div>
|
|
1289
|
+
${r.source === "github" ? `<div class="path">Workspace: ${esc(r.repo_path || "")}</div>` : ""}
|
|
1290
|
+
<div class="repo-row-actions">
|
|
1291
|
+
<select class="repo-policy-select" data-repo-hash="${esc(r.repo_hash)}">
|
|
1292
|
+
<option value="1d" ${repoPolicyValue(r) === "24h" ? "selected" : ""}>Delete after 1 day</option>
|
|
1293
|
+
<option value="7d" ${repoPolicyValue(r) === "7d" ? "selected" : ""}>Delete after 7 days</option>
|
|
1294
|
+
<option value="14d" ${repoPolicyValue(r) === "14d" ? "selected" : ""}>Delete after 14 days</option>
|
|
1295
|
+
<option value="30d" ${repoPolicyValue(r) === "30d" ? "selected" : ""}>Delete after 30 days</option>
|
|
1296
|
+
<option value="90d" ${repoPolicyValue(r) === "90d" ? "selected" : ""}>Delete after 90 days</option>
|
|
1297
|
+
<option value="never" ${repoPolicyValue(r) === "never" ? "selected" : ""}>Never auto-delete</option>
|
|
1298
|
+
</select>
|
|
1299
|
+
<button class="repo-btn small repo-policy-save-btn" type="button" data-repo-hash="${esc(r.repo_hash)}">Set Auto-Delete Policy</button>
|
|
1300
|
+
</div>
|
|
1301
|
+
<div class="repo-row-actions">
|
|
1302
|
+
<button class="repo-btn small repo-open-btn" type="button" data-repo-hash="${esc(r.repo_hash)}">Open</button>
|
|
1303
|
+
<button class="repo-btn small repo-analyze-btn" type="button" data-repo-hash="${esc(r.repo_hash)}">${r.has_analysis ? "Re-analyze" : "Analyze"}</button>
|
|
1304
|
+
<button class="repo-btn small danger repo-clear-btn" type="button" data-repo-hash="${esc(r.repo_hash)}">Delete analysis data</button>
|
|
1305
|
+
${isExpiringSoon(r) ? `<button class="repo-btn small danger repo-delete-now-btn" type="button" data-repo-hash="${esc(r.repo_hash)}">Delete now</button>` : ""}
|
|
1306
|
+
<button class="repo-btn small danger repo-delete-btn" type="button" data-repo-hash="${esc(r.repo_hash)}">Delete Repo Completely</button>
|
|
1307
|
+
<button class="repo-btn small repo-remove-btn" type="button" data-repo-hash="${esc(r.repo_hash)}">Remove Repo from list</button>
|
|
1308
|
+
</div>
|
|
1309
|
+
</div>
|
|
1310
|
+
`).join("");
|
|
1311
|
+
|
|
1312
|
+
repoListContentEl.querySelectorAll(".repo-open-btn").forEach((el) => {
|
|
1313
|
+
el.addEventListener("click", async () => {
|
|
1314
|
+
const repoHash = el.getAttribute("data-repo-hash");
|
|
1315
|
+
if (!repoHash) return;
|
|
1316
|
+
await selectWorkspace(repoHash);
|
|
1317
|
+
});
|
|
1318
|
+
});
|
|
1319
|
+
repoListContentEl.querySelectorAll(".repo-analyze-btn").forEach((el) => {
|
|
1320
|
+
el.addEventListener("click", async () => {
|
|
1321
|
+
const repoHash = el.getAttribute("data-repo-hash");
|
|
1322
|
+
if (!repoHash) return;
|
|
1323
|
+
await analyzeRepoByHash(repoHash);
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
repoListContentEl.querySelectorAll(".repo-clear-btn").forEach((el) => {
|
|
1327
|
+
el.addEventListener("click", async () => {
|
|
1328
|
+
const repoHash = el.getAttribute("data-repo-hash");
|
|
1329
|
+
if (!repoHash) return;
|
|
1330
|
+
await clearRepoByHash(repoHash);
|
|
1331
|
+
});
|
|
1332
|
+
});
|
|
1333
|
+
repoListContentEl.querySelectorAll(".repo-delete-now-btn").forEach((el) => {
|
|
1334
|
+
el.addEventListener("click", async () => {
|
|
1335
|
+
const repoHash = el.getAttribute("data-repo-hash");
|
|
1336
|
+
if (!repoHash) return;
|
|
1337
|
+
await clearRepoByHash(repoHash);
|
|
1338
|
+
});
|
|
1339
|
+
});
|
|
1340
|
+
repoListContentEl.querySelectorAll(".repo-delete-btn").forEach((el) => {
|
|
1341
|
+
el.addEventListener("click", async () => {
|
|
1342
|
+
const repoHash = el.getAttribute("data-repo-hash");
|
|
1343
|
+
if (!repoHash) return;
|
|
1344
|
+
await deleteRepoByHash(repoHash);
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
repoListContentEl.querySelectorAll(".repo-remove-btn").forEach((el) => {
|
|
1348
|
+
el.addEventListener("click", async () => {
|
|
1349
|
+
const repoHash = el.getAttribute("data-repo-hash");
|
|
1350
|
+
if (!repoHash) return;
|
|
1351
|
+
await removeRepoFromList(repoHash);
|
|
1352
|
+
});
|
|
1353
|
+
});
|
|
1354
|
+
repoListContentEl.querySelectorAll(".repo-policy-save-btn").forEach((el) => {
|
|
1355
|
+
el.addEventListener("click", async () => {
|
|
1356
|
+
const repoHash = el.getAttribute("data-repo-hash");
|
|
1357
|
+
if (!repoHash) return;
|
|
1358
|
+
const select = repoListContentEl.querySelector(`.repo-policy-select[data-repo-hash="${CSS.escape(repoHash)}"]`);
|
|
1359
|
+
const policy = select ? String(select.value || "30d") : "30d";
|
|
1360
|
+
await setRepoPolicy(repoHash, policy);
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function loadRepoRegistry() {
|
|
1366
|
+
try {
|
|
1367
|
+
const data = await fetchJson("/api/repo_registry");
|
|
1368
|
+
repoRegistry = Array.isArray(data.repos) ? data.repos : [];
|
|
1369
|
+
renderRepoRegistry();
|
|
1370
|
+
} catch (_e) {
|
|
1371
|
+
repoRegistry = [];
|
|
1372
|
+
if (repoListContentEl) repoListContentEl.innerHTML = "<div class='muted'>Failed to load repo registry.</div>";
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
async function maybeApplyPrivateDefaultRetention(repoHash, shouldApply) {
|
|
1377
|
+
if (!repoHash || !shouldApply) return;
|
|
1378
|
+
try {
|
|
1379
|
+
await fetchJson("/api/cache/retention", {
|
|
1380
|
+
method: "POST",
|
|
1381
|
+
headers: { "Content-Type": "application/json" },
|
|
1382
|
+
body: JSON.stringify({ repo_hash: repoHash, days: 7 }),
|
|
1383
|
+
});
|
|
1384
|
+
} catch (_e) {
|
|
1385
|
+
// Best-effort; analysis result should not fail if retention update fails.
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async function analyzeRepoByHash(repoHash, options) {
|
|
1390
|
+
if (!repoHash) return { ok: false, error: { message: "INVALID_REPO_HASH" } };
|
|
1391
|
+
const opts = options || {};
|
|
1392
|
+
const token = String(opts.token || "").trim();
|
|
1393
|
+
const showErrors = opts.showErrors !== false;
|
|
1394
|
+
const repoBefore = (repoRegistry || []).find((r) => String(r.repo_hash) === String(repoHash));
|
|
1395
|
+
const shouldApplyPrivateDefault = !!(
|
|
1396
|
+
opts.privateModeHint
|
|
1397
|
+
|| token
|
|
1398
|
+
|| (repoBefore && repoBefore.private_mode && !repoBefore.has_analysis)
|
|
1399
|
+
);
|
|
1400
|
+
try {
|
|
1401
|
+
const data = await fetchJson("/api/repo_analyze", {
|
|
1402
|
+
method: "POST",
|
|
1403
|
+
headers: { "Content-Type": "application/json" },
|
|
1404
|
+
body: JSON.stringify({ repo_hash: repoHash, token, private_mode: !!opts.privateModeHint }),
|
|
1405
|
+
});
|
|
1406
|
+
await maybeApplyPrivateDefaultRetention(repoHash, shouldApplyPrivateDefault);
|
|
1407
|
+
await loadWorkspace();
|
|
1408
|
+
if (repoHash) {
|
|
1409
|
+
await selectWorkspace(repoHash);
|
|
1410
|
+
}
|
|
1411
|
+
return { ok: true, data };
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
const message = analysisErrorMessage(e || {});
|
|
1414
|
+
if (showErrors) {
|
|
1415
|
+
window.alert(message);
|
|
1416
|
+
}
|
|
1417
|
+
return { ok: false, error: e, message };
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
async function clearRepoByHash(repoHash) {
|
|
1422
|
+
if (!repoHash) return;
|
|
1423
|
+
await openConfirmModal({
|
|
1424
|
+
title: "Delete analysis data",
|
|
1425
|
+
message: `This will remove cache artifacts for ${repoHash}.`,
|
|
1426
|
+
confirmText: "Yes",
|
|
1427
|
+
cancelText: "Cancel",
|
|
1428
|
+
actionType: "delete_analysis",
|
|
1429
|
+
payload: { repo_hash: repoHash },
|
|
1430
|
+
onConfirm: async () => {
|
|
1431
|
+
await fetchJson("/api/cache/clear", {
|
|
1432
|
+
method: "POST",
|
|
1433
|
+
headers: { "Content-Type": "application/json" },
|
|
1434
|
+
body: JSON.stringify({ repo_hash: repoHash, dry_run: false }),
|
|
1435
|
+
});
|
|
1436
|
+
await loadRepoRegistry();
|
|
1437
|
+
await loadDataPrivacy();
|
|
1438
|
+
if (String(activeRepoHash) === String(repoHash)) {
|
|
1439
|
+
await refreshForActiveRepo();
|
|
1440
|
+
}
|
|
1441
|
+
},
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
async function deleteRepoByHash(repoHash) {
|
|
1446
|
+
if (!repoHash) return;
|
|
1447
|
+
await openConfirmModal({
|
|
1448
|
+
title: "Delete repo completely",
|
|
1449
|
+
message: "This will permanently remove analysis data and cloned/downloaded source.",
|
|
1450
|
+
confirmText: "Yes",
|
|
1451
|
+
cancelText: "Cancel",
|
|
1452
|
+
actionType: "delete_repo_completely",
|
|
1453
|
+
payload: { repo_hash: repoHash },
|
|
1454
|
+
onConfirm: async () => {
|
|
1455
|
+
await fetchJson("/api/cache/clear", {
|
|
1456
|
+
method: "POST",
|
|
1457
|
+
headers: { "Content-Type": "application/json" },
|
|
1458
|
+
body: JSON.stringify({ repo_hash: repoHash, dry_run: false }),
|
|
1459
|
+
});
|
|
1460
|
+
await loadWorkspace();
|
|
1461
|
+
await refreshForActiveRepo();
|
|
1462
|
+
await loadDataPrivacy();
|
|
1463
|
+
},
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function removeRepoFromList(repoHash) {
|
|
1468
|
+
if (!repoHash) return;
|
|
1469
|
+
const msg = autoCleanOnRemove
|
|
1470
|
+
? `Remove repo ${repoHash} from UI list and delete its local cache?`
|
|
1471
|
+
: `Remove repo ${repoHash} from UI list only?`;
|
|
1472
|
+
await openConfirmModal({
|
|
1473
|
+
title: "Remove repo from list",
|
|
1474
|
+
message: msg,
|
|
1475
|
+
confirmText: "Yes",
|
|
1476
|
+
cancelText: "Cancel",
|
|
1477
|
+
actionType: "remove_repo_from_list",
|
|
1478
|
+
payload: { repo_hash: repoHash, auto_clean: !!autoCleanOnRemove },
|
|
1479
|
+
onConfirm: async () => {
|
|
1480
|
+
await fetchJson("/api/registry/repos/remove", {
|
|
1481
|
+
method: "POST",
|
|
1482
|
+
headers: { "Content-Type": "application/json" },
|
|
1483
|
+
body: JSON.stringify({ repo_hash: repoHash }),
|
|
1484
|
+
});
|
|
1485
|
+
if (autoCleanOnRemove) {
|
|
1486
|
+
await fetchJson("/api/cache/clear", {
|
|
1487
|
+
method: "POST",
|
|
1488
|
+
headers: { "Content-Type": "application/json" },
|
|
1489
|
+
body: JSON.stringify({ repo_hash: repoHash, dry_run: false }),
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
await loadWorkspace();
|
|
1493
|
+
await refreshForActiveRepo();
|
|
1494
|
+
},
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async function setRepoPolicy(repoHash, policyValue) {
|
|
1499
|
+
let days = 30;
|
|
1500
|
+
if (policyValue === "never") days = 0;
|
|
1501
|
+
if (policyValue === "24h" || policyValue === "1d") days = 1;
|
|
1502
|
+
if (policyValue === "7d") days = 7;
|
|
1503
|
+
if (policyValue === "14d") days = 14;
|
|
1504
|
+
try {
|
|
1505
|
+
if (policyValue === "90d") days = 90;
|
|
1506
|
+
await fetchJson("/api/cache/retention", {
|
|
1507
|
+
method: "POST",
|
|
1508
|
+
headers: { "Content-Type": "application/json" },
|
|
1509
|
+
body: JSON.stringify({ repo_hash: repoHash, days }),
|
|
1510
|
+
});
|
|
1511
|
+
await loadRepoRegistry();
|
|
1512
|
+
await loadDataPrivacy();
|
|
1513
|
+
} catch (e) {
|
|
1514
|
+
window.alert(redactSecrets((e && (e.message || e.error)) || "Policy update failed"));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function loadDataPrivacy() {
|
|
1519
|
+
if (!privacySummaryEl) return;
|
|
1520
|
+
privacySummaryEl.textContent = "Loading data retention...";
|
|
1521
|
+
privacyExpiringEl.innerHTML = "";
|
|
1522
|
+
if (privacyConfirmEl) {
|
|
1523
|
+
privacyConfirmEl.classList.add("hidden");
|
|
1524
|
+
privacyConfirmEl.innerHTML = "";
|
|
1525
|
+
}
|
|
1526
|
+
try {
|
|
1527
|
+
const data = await fetchJson("/api/cache/list");
|
|
1528
|
+
dataPrivacyCache = data;
|
|
1529
|
+
const caches = Array.isArray(data.caches) ? data.caches : [];
|
|
1530
|
+
const totalSize = caches.reduce((acc, c) => acc + Number(c.size_bytes || 0), 0);
|
|
1531
|
+
const activeRepo = currentRepoEntry();
|
|
1532
|
+
const activeCache = activeRepo ? caches.find((c) => String(c.repo_hash) === String(activeRepo.repo_hash)) : null;
|
|
1533
|
+
const retentionMode = String(activeCache && activeCache.retention ? activeCache.retention.mode : "ttl");
|
|
1534
|
+
const ttlDays = Number(activeCache && activeCache.retention ? activeCache.retention.ttl_days : 14);
|
|
1535
|
+
const retentionLabel = (retentionMode === "pinned" || ttlDays <= 0) ? "Never" : `${Math.max(1, Math.floor(ttlDays))} days`;
|
|
1536
|
+
const lastAnalyzed = String((activeCache && activeCache.last_updated) || "never");
|
|
1537
|
+
const autoDeleteOn = !((retentionMode === "pinned") || ttlDays <= 0);
|
|
1538
|
+
privacySummaryEl.textContent = `Stored locally in .codemap_cache | Retention: ${retentionLabel} | Last analyzed: ${lastAnalyzed} | Auto-delete policy: ${autoDeleteOn ? "ON" : "OFF"}`;
|
|
1539
|
+
if (privacyResultEl) {
|
|
1540
|
+
privacyResultEl.textContent = `Repos cached: ${caches.length} | Total size: ${formatBytes(totalSize)}`;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (activeRepo && repoRetentionSelectEl) {
|
|
1544
|
+
const ttl = Number(activeCache && activeCache.retention ? activeCache.retention.ttl_days : 14);
|
|
1545
|
+
if (!Number.isFinite(ttl) || ttl <= 0) repoRetentionSelectEl.value = "0";
|
|
1546
|
+
else if (ttl <= 1) repoRetentionSelectEl.value = "1";
|
|
1547
|
+
else if (ttl <= 7) repoRetentionSelectEl.value = "7";
|
|
1548
|
+
else if (ttl <= 14) repoRetentionSelectEl.value = "14";
|
|
1549
|
+
else if (ttl <= 30) repoRetentionSelectEl.value = "30";
|
|
1550
|
+
else repoRetentionSelectEl.value = "90";
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const activePrivate = !!(activeRepo && activeRepo.private_mode);
|
|
1554
|
+
if (privacyPrivateBannerEl) {
|
|
1555
|
+
privacyPrivateBannerEl.classList.toggle("hidden", !activePrivate);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const expiring = caches.filter((c) => {
|
|
1559
|
+
const days = Number(c && c.retention ? c.retention.days_left : NaN);
|
|
1560
|
+
return c && c.retention && c.retention.mode !== "pinned" && Number.isFinite(days) && days <= 3;
|
|
1561
|
+
});
|
|
1562
|
+
if (expiring.length) {
|
|
1563
|
+
privacyExpiringEl.innerHTML = expiring
|
|
1564
|
+
.slice(0, 5)
|
|
1565
|
+
.map((x) => {
|
|
1566
|
+
const target = String(x.repo_path || x.repo_hash || "repo");
|
|
1567
|
+
const days = Number(x.retention ? x.retention.days_left : NaN);
|
|
1568
|
+
const label = Number.isFinite(days) ? `${Math.max(0, days).toFixed(1)} days` : "soon";
|
|
1569
|
+
return `<div class="privacy-warning">This repo cache will be auto-deleted in ${esc(label)}: ${esc(target)}</div>`;
|
|
1570
|
+
})
|
|
1571
|
+
.join("");
|
|
1572
|
+
} else {
|
|
1573
|
+
privacyExpiringEl.innerHTML = "<div class='muted'>No caches near expiration.</div>";
|
|
1574
|
+
}
|
|
1575
|
+
} catch (e) {
|
|
1576
|
+
const msg = redactSecrets((e && (e.message || e.error)) || "Failed to load data privacy status");
|
|
1577
|
+
privacySummaryEl.textContent = msg;
|
|
1578
|
+
privacyExpiringEl.innerHTML = "";
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function showPrivacyConfirm(message, onConfirm) {
|
|
1583
|
+
if (!privacyConfirmEl) return;
|
|
1584
|
+
privacyConfirmEl.classList.remove("hidden");
|
|
1585
|
+
privacyConfirmEl.innerHTML = `
|
|
1586
|
+
<div class="privacy-warning">${esc(message)}</div>
|
|
1587
|
+
<div class="repo-row-actions">
|
|
1588
|
+
<button id="privacy-confirm-yes" class="repo-btn danger" type="button">Confirm</button>
|
|
1589
|
+
<button id="privacy-confirm-no" class="repo-btn" type="button">Cancel</button>
|
|
1590
|
+
</div>
|
|
1591
|
+
`;
|
|
1592
|
+
const yesBtn = document.getElementById("privacy-confirm-yes");
|
|
1593
|
+
const noBtn = document.getElementById("privacy-confirm-no");
|
|
1594
|
+
if (yesBtn) {
|
|
1595
|
+
yesBtn.addEventListener("click", async () => {
|
|
1596
|
+
privacyConfirmEl.classList.add("hidden");
|
|
1597
|
+
privacyConfirmEl.innerHTML = "";
|
|
1598
|
+
await onConfirm();
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
if (noBtn) {
|
|
1602
|
+
noBtn.addEventListener("click", () => {
|
|
1603
|
+
privacyConfirmEl.classList.add("hidden");
|
|
1604
|
+
privacyConfirmEl.innerHTML = "";
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
async function setActiveRepoRetention() {
|
|
1610
|
+
const repo = currentRepoEntry();
|
|
1611
|
+
if (!repo) {
|
|
1612
|
+
if (privacyResultEl) privacyResultEl.textContent = "No active repo selected.";
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const days = Number(repoRetentionSelectEl && repoRetentionSelectEl.value ? repoRetentionSelectEl.value : 14);
|
|
1616
|
+
if (!Number.isFinite(days) || days < 0) {
|
|
1617
|
+
if (privacyResultEl) privacyResultEl.textContent = "Select a valid retention value.";
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
try {
|
|
1621
|
+
const data = await fetchJson("/api/cache/retention", {
|
|
1622
|
+
method: "POST",
|
|
1623
|
+
headers: { "Content-Type": "application/json" },
|
|
1624
|
+
body: JSON.stringify({
|
|
1625
|
+
repo_hash: repo.repo_hash,
|
|
1626
|
+
days: Math.floor(days),
|
|
1627
|
+
}),
|
|
1628
|
+
});
|
|
1629
|
+
if (privacyResultEl) {
|
|
1630
|
+
const ttl = Number(data.days);
|
|
1631
|
+
privacyResultEl.textContent = `Retention updated: ${repo.name || repo.repo_hash} -> ${ttl === 0 ? "Never" : `${ttl} days`}`;
|
|
1632
|
+
}
|
|
1633
|
+
await loadDataPrivacy();
|
|
1634
|
+
await loadRepoRegistry();
|
|
1635
|
+
} catch (e) {
|
|
1636
|
+
if (privacyResultEl) privacyResultEl.textContent = redactSecrets((e && (e.message || e.error)) || "Retention update failed.");
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
async function runRetentionCleanup(dryRun, confirmAfterPreview) {
|
|
1641
|
+
try {
|
|
1642
|
+
const data = await fetchJson("/api/cache/sweep", {
|
|
1643
|
+
method: "POST",
|
|
1644
|
+
headers: { "Content-Type": "application/json" },
|
|
1645
|
+
body: JSON.stringify({
|
|
1646
|
+
dry_run: !!dryRun,
|
|
1647
|
+
}),
|
|
1648
|
+
});
|
|
1649
|
+
if (!dryRun) {
|
|
1650
|
+
if (privacyResultEl) {
|
|
1651
|
+
privacyResultEl.textContent = `Cleanup executed: removed ${(data.caches_removed || []).length} caches, freed ~${formatBytes(data.freed_bytes_estimate || 0)}.`;
|
|
1652
|
+
}
|
|
1653
|
+
await loadDataPrivacy();
|
|
1654
|
+
await loadWorkspace();
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const count = (data.would_delete || []).length;
|
|
1658
|
+
if (privacyResultEl) {
|
|
1659
|
+
privacyResultEl.textContent = `Dry run: ${count} path(s) would be deleted, freed ~${formatBytes(data.freed_bytes_estimate || 0)}.`;
|
|
1660
|
+
}
|
|
1661
|
+
if (confirmAfterPreview) {
|
|
1662
|
+
showPrivacyConfirm(`Proceed with cleanup of ${count} path(s)?`, async () => {
|
|
1663
|
+
await runRetentionCleanup(false, false);
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
await loadDataPrivacy();
|
|
1667
|
+
} catch (e) {
|
|
1668
|
+
if (privacyResultEl) privacyResultEl.textContent = redactSecrets((e && (e.message || e.error)) || "Cleanup failed.");
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
async function deleteAllCaches() {
|
|
1673
|
+
showPrivacyConfirm("Delete ALL caches and related workspaces?", async () => {
|
|
1674
|
+
try {
|
|
1675
|
+
const data = await fetchJson("/api/cache/clear", {
|
|
1676
|
+
method: "POST",
|
|
1677
|
+
headers: { "Content-Type": "application/json" },
|
|
1678
|
+
body: JSON.stringify({ all: true, dry_run: false }),
|
|
1679
|
+
});
|
|
1680
|
+
if (privacyResultEl) {
|
|
1681
|
+
privacyResultEl.textContent = `Deleted all caches. Freed ~${formatBytes(data.freed_bytes_estimate || 0)}.`;
|
|
1682
|
+
}
|
|
1683
|
+
await loadWorkspace();
|
|
1684
|
+
await refreshForActiveRepo();
|
|
1685
|
+
await loadDataPrivacy();
|
|
1686
|
+
} catch (e) {
|
|
1687
|
+
if (privacyResultEl) privacyResultEl.textContent = redactSecrets((e && (e.message || e.error)) || "Delete all failed.");
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async function deleteActiveRepoCache() {
|
|
1693
|
+
if (!activeRepoHash) {
|
|
1694
|
+
if (privacyResultEl) privacyResultEl.textContent = "No active repo selected.";
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
showPrivacyConfirm(`Delete cached data for repo ${activeRepoHash}?`, async () => {
|
|
1698
|
+
try {
|
|
1699
|
+
const data = await fetchJson("/api/cache/clear", {
|
|
1700
|
+
method: "POST",
|
|
1701
|
+
headers: { "Content-Type": "application/json" },
|
|
1702
|
+
body: JSON.stringify({ repo_hash: activeRepoHash, dry_run: false }),
|
|
1703
|
+
});
|
|
1704
|
+
if (privacyResultEl) {
|
|
1705
|
+
privacyResultEl.textContent = `Deleted cache for ${data.repo_hash}. Freed~${formatBytes(data.freed_bytes_estimate || 0)}`;
|
|
1706
|
+
}
|
|
1707
|
+
fileCache.clear();
|
|
1708
|
+
symbolCache.clear();
|
|
1709
|
+
await loadWorkspace();
|
|
1710
|
+
await refreshForActiveRepo();
|
|
1711
|
+
await loadDataPrivacy();
|
|
1712
|
+
} catch (e) {
|
|
1713
|
+
if (privacyResultEl) privacyResultEl.textContent = redactSecrets((e && (e.message || e.error)) || "Delete failed.");
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function renderTreeNode(node, parentEl) {
|
|
1719
|
+
const li = document.createElement("li");
|
|
1720
|
+
const label = document.createElement("span");
|
|
1721
|
+
label.className = `tree-item ${node.type === "file" ? "file" : "dir"}`;
|
|
1722
|
+
label.textContent = node.type === "file" ? node.name : `${node.name}/`;
|
|
1723
|
+
if (node.type === "file") {
|
|
1724
|
+
label.setAttribute("data-file-path", node.path || "");
|
|
1725
|
+
}
|
|
1726
|
+
li.appendChild(label);
|
|
1727
|
+
parentEl.appendChild(li);
|
|
1728
|
+
|
|
1729
|
+
if (node.type === "file") {
|
|
1730
|
+
label.addEventListener("click", () => loadFile(node.path));
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const children = node.children || [];
|
|
1734
|
+
if (!children.length) return;
|
|
1735
|
+
|
|
1736
|
+
const ul = document.createElement("ul");
|
|
1737
|
+
li.appendChild(ul);
|
|
1738
|
+
children.forEach((child) => renderTreeNode(child, ul));
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
async function loadMeta() {
|
|
1742
|
+
try {
|
|
1743
|
+
const meta = await fetchJson("/api/meta");
|
|
1744
|
+
repoDir = meta.repo_dir || "";
|
|
1745
|
+
const fallbackName = String(repoDir || "").replace(/\\/g, "/").split("/").filter(Boolean).pop() || "";
|
|
1746
|
+
syncRepoHeader(fallbackName);
|
|
1747
|
+
const counts = meta.counts || {};
|
|
1748
|
+
metaEl.textContent = `${meta.repo_hash} | symbols ${counts.symbols || 0} | calls ${counts.resolved_calls || 0}`;
|
|
1749
|
+
return true;
|
|
1750
|
+
} catch (e) {
|
|
1751
|
+
const msg = redactSecrets((e && (e.message || e.error)) || "Metadata unavailable");
|
|
1752
|
+
syncRepoHeader();
|
|
1753
|
+
metaEl.textContent = msg;
|
|
1754
|
+
recentSymbols = [];
|
|
1755
|
+
recentFiles = [];
|
|
1756
|
+
lastSymbol = "";
|
|
1757
|
+
renderRecents();
|
|
1758
|
+
clearWorkspaceView(msg);
|
|
1759
|
+
if (e && String(e.error || "") === "CACHE_NOT_FOUND") {
|
|
1760
|
+
showMissingAnalysisState(msg);
|
|
1761
|
+
}
|
|
1762
|
+
return false;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
async function loadTree() {
|
|
1767
|
+
treeStatusEl.textContent = "";
|
|
1768
|
+
try {
|
|
1769
|
+
const data = await fetchJson("/api/tree");
|
|
1770
|
+
const tree = normalizeTree(data);
|
|
1771
|
+
treeEl.innerHTML = "";
|
|
1772
|
+
const ul = document.createElement("ul");
|
|
1773
|
+
treeEl.appendChild(ul);
|
|
1774
|
+
renderTreeNode(tree, ul);
|
|
1775
|
+
highlightActiveFile();
|
|
1776
|
+
return true;
|
|
1777
|
+
} catch (e) {
|
|
1778
|
+
treeEl.innerHTML = "";
|
|
1779
|
+
treeStatusEl.textContent = redactSecrets((e && (e.error || e.message)) || "Failed to load tree");
|
|
1780
|
+
if (e && String(e.error || "") === "CACHE_NOT_FOUND") {
|
|
1781
|
+
showMissingAnalysisState(redactSecrets((e && (e.message || e.error)) || "Missing analysis"));
|
|
1782
|
+
}
|
|
1783
|
+
return false;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function renderSymbolGroup(symbols) {
|
|
1788
|
+
const classes = (symbols && symbols.classes) || [];
|
|
1789
|
+
const functions = (symbols && symbols.functions) || [];
|
|
1790
|
+
const moduleScope = (symbols && symbols.module_scope) || null;
|
|
1791
|
+
const hasScriptOnly = moduleScope && classes.length === 0 && functions.length === 0;
|
|
1792
|
+
|
|
1793
|
+
const moduleHtml = moduleScope
|
|
1794
|
+
? `<div class="block">
|
|
1795
|
+
<div class="section-title">Module Scope</div>
|
|
1796
|
+
<div>
|
|
1797
|
+
<span class="symbol-link ${moduleScope.fqn === activeSymbolFqn ? "active" : ""}" data-fqn="${esc(moduleScope.fqn)}"><module></span>
|
|
1798
|
+
<span class="path">(${esc(moduleScope.outgoing_calls_count)} outgoing calls)</span>
|
|
1799
|
+
</div>
|
|
1800
|
+
${hasScriptOnly ? "<div class='muted'>This file is a script with module-level code. Select <module> to inspect calls.</div>" : ""}
|
|
1801
|
+
</div>`
|
|
1802
|
+
: "";
|
|
1803
|
+
|
|
1804
|
+
const classHtml = classes.length
|
|
1805
|
+
? classes.map((c) => `
|
|
1806
|
+
<div class="symbol-class" data-class-name="${esc(c.name)}">
|
|
1807
|
+
<div class="symbol-name symbol-class-name">${esc(c.name)}</div>
|
|
1808
|
+
<div class="symbol-methods">
|
|
1809
|
+
${(c.methods || []).map((m) => `
|
|
1810
|
+
<div class="symbol-method-row">
|
|
1811
|
+
<span class="method-prefix">|-</span>
|
|
1812
|
+
<span class="symbol-link ${m === activeSymbolFqn ? "active" : ""}" data-fqn="${esc(m)}">${esc(m.split(".").slice(-1)[0])}</span>
|
|
1813
|
+
</div>
|
|
1814
|
+
`).join("")}
|
|
1815
|
+
</div>
|
|
1816
|
+
</div>`).join("")
|
|
1817
|
+
: "<div class='muted'>None</div>";
|
|
1818
|
+
|
|
1819
|
+
const fnHtml = functions.length
|
|
1820
|
+
? functions.map((f) => `<div><span class="symbol-link ${f === activeSymbolFqn ? "active" : ""}" data-fqn="${esc(f)}">${esc(f.split(".").slice(-1)[0])}</span></div>`).join("")
|
|
1821
|
+
: "<div class='muted'>None</div>";
|
|
1822
|
+
|
|
1823
|
+
return `
|
|
1824
|
+
${moduleHtml}
|
|
1825
|
+
<div class="section-title">Classes</div>
|
|
1826
|
+
${classHtml}
|
|
1827
|
+
<div class="section-title">Functions</div>
|
|
1828
|
+
${fnHtml}
|
|
1829
|
+
`;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function bindSymbolLinks(container) {
|
|
1833
|
+
container.querySelectorAll(".symbol-link").forEach((el) => {
|
|
1834
|
+
el.addEventListener("click", () => loadSymbol(el.getAttribute("data-fqn")));
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function bindConnectionLinks(container) {
|
|
1839
|
+
container.querySelectorAll(".connection-link").forEach((el) => {
|
|
1840
|
+
el.addEventListener("click", () => loadSymbol(el.getAttribute("data-fqn")));
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function bindBreadcrumbs(container, currentFqn) {
|
|
1845
|
+
container.querySelectorAll(".crumb-link").forEach((el) => {
|
|
1846
|
+
const action = el.getAttribute("data-action");
|
|
1847
|
+
const value = el.getAttribute("data-value");
|
|
1848
|
+
el.addEventListener("click", async () => {
|
|
1849
|
+
if (action === "file" && value) {
|
|
1850
|
+
await loadFile(value);
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (action === "class" && value) {
|
|
1854
|
+
scrollClassIntoView(value);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
if (action === "symbol" && value) {
|
|
1858
|
+
await loadSymbol(value);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (action === "repo" && activeFilePath) {
|
|
1862
|
+
await loadFile(activeFilePath);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if (currentFqn) {
|
|
1866
|
+
await loadSymbol(currentFqn);
|
|
1867
|
+
}
|
|
1868
|
+
});
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function bindConnectionChips(container) {
|
|
1873
|
+
container.querySelectorAll(".chip").forEach((el) => {
|
|
1874
|
+
el.addEventListener("click", () => {
|
|
1875
|
+
const targetId = el.getAttribute("data-target");
|
|
1876
|
+
const target = targetId ? container.querySelector(`#${targetId}`) : null;
|
|
1877
|
+
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
1878
|
+
});
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function scrollClassIntoView(className) {
|
|
1883
|
+
if (!className) return;
|
|
1884
|
+
const target = fileViewEl.querySelector(`.symbol-class[data-class-name="${CSS.escape(className)}"]`);
|
|
1885
|
+
if (target) {
|
|
1886
|
+
target.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
1887
|
+
const classLabel = target.querySelector(".symbol-class-name");
|
|
1888
|
+
if (classLabel) {
|
|
1889
|
+
classLabel.classList.add("active-class");
|
|
1890
|
+
window.setTimeout(() => classLabel.classList.remove("active-class"), 800);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function highlightActiveSymbol() {
|
|
1896
|
+
const links = fileViewEl.querySelectorAll(".symbol-link");
|
|
1897
|
+
links.forEach((el) => {
|
|
1898
|
+
const isActive = el.getAttribute("data-fqn") === activeSymbolFqn;
|
|
1899
|
+
el.classList.toggle("active", isActive);
|
|
1900
|
+
if (isActive) {
|
|
1901
|
+
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
1902
|
+
}
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function highlightActiveFile() {
|
|
1907
|
+
const links = treeEl.querySelectorAll(".tree-item.file");
|
|
1908
|
+
links.forEach((el) => {
|
|
1909
|
+
const isActive = el.getAttribute("data-file-path") === activeFilePath;
|
|
1910
|
+
el.classList.toggle("active-file", isActive);
|
|
1911
|
+
if (isActive) {
|
|
1912
|
+
el.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function closeSearchDropdown() {
|
|
1918
|
+
searchResultsEl.classList.add("hidden");
|
|
1919
|
+
searchResultsEl.innerHTML = "";
|
|
1920
|
+
currentSearchResults = [];
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function renderSearchResults(results, truncated) {
|
|
1924
|
+
currentSearchResults = results || [];
|
|
1925
|
+
if (!currentSearchResults.length) {
|
|
1926
|
+
searchResultsEl.classList.add("hidden");
|
|
1927
|
+
searchResultsEl.innerHTML = "";
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const rows = currentSearchResults.map((r) => `
|
|
1932
|
+
<button class="search-row" data-fqn="${esc(r.fqn)}" data-file="${esc(r.file)}">
|
|
1933
|
+
<div class="search-primary">${esc(r.display)}</div>
|
|
1934
|
+
<div class="search-secondary">${esc(r.module)}${r.file ? ` | ${esc(r.file)}:${esc(r.line)}` : ""}</div>
|
|
1935
|
+
</button>
|
|
1936
|
+
`).join("");
|
|
1937
|
+
|
|
1938
|
+
searchResultsEl.innerHTML = `
|
|
1939
|
+
${rows}
|
|
1940
|
+
${truncated ? "<div class='search-more'>Showing first 20...</div>" : ""}
|
|
1941
|
+
`;
|
|
1942
|
+
searchResultsEl.classList.remove("hidden");
|
|
1943
|
+
|
|
1944
|
+
searchResultsEl.querySelectorAll(".search-row").forEach((row) => {
|
|
1945
|
+
row.addEventListener("click", async () => {
|
|
1946
|
+
await selectSearchResult({
|
|
1947
|
+
fqn: row.getAttribute("data-fqn"),
|
|
1948
|
+
file: row.getAttribute("data-file"),
|
|
1949
|
+
});
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
async function runSymbolSearch(query) {
|
|
1955
|
+
const q = String(query || "").trim();
|
|
1956
|
+
if (!q) {
|
|
1957
|
+
closeSearchDropdown();
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
try {
|
|
1961
|
+
const data = await fetchJson(`/api/search?q=${encodeURIComponent(q)}&limit=20`);
|
|
1962
|
+
renderSearchResults(data.results || [], !!data.truncated);
|
|
1963
|
+
} catch (_e) {
|
|
1964
|
+
closeSearchDropdown();
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
async function selectSearchResult(item) {
|
|
1969
|
+
closeSearchDropdown();
|
|
1970
|
+
if (!item || !item.fqn) return;
|
|
1971
|
+
if (item.file) {
|
|
1972
|
+
await loadFile(item.file);
|
|
1973
|
+
}
|
|
1974
|
+
await loadSymbol(item.fqn);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function bindSearchInput() {
|
|
1978
|
+
if (!searchInputEl) return;
|
|
1979
|
+
searchInputEl.addEventListener("input", () => {
|
|
1980
|
+
if (searchTimer) clearTimeout(searchTimer);
|
|
1981
|
+
searchTimer = setTimeout(() => {
|
|
1982
|
+
runSymbolSearch(searchInputEl.value);
|
|
1983
|
+
}, 200);
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
searchInputEl.addEventListener("keydown", async (e) => {
|
|
1987
|
+
if (e.key === "Escape") {
|
|
1988
|
+
closeSearchDropdown();
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
if (e.key === "Enter") {
|
|
1992
|
+
e.preventDefault();
|
|
1993
|
+
if (currentSearchResults.length > 0) {
|
|
1994
|
+
await selectSearchResult(currentSearchResults[0]);
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
function bindWorkspaceControls() {
|
|
2001
|
+
if (repoSelectEl) {
|
|
2002
|
+
repoSelectEl.addEventListener("change", async () => {
|
|
2003
|
+
const selected = repoSelectEl.value;
|
|
2004
|
+
if (selected && selected !== activeRepoHash) {
|
|
2005
|
+
await selectWorkspace(selected);
|
|
2006
|
+
}
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
if (addRepoBtnEl) {
|
|
2010
|
+
addRepoBtnEl.addEventListener("click", async () => {
|
|
2011
|
+
await addWorkspaceRepo();
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function bindAiSettingsControls() {
|
|
2017
|
+
if (aiSettingsBtnEl) {
|
|
2018
|
+
aiSettingsBtnEl.addEventListener("click", async () => {
|
|
2019
|
+
await openAiSettingsModal();
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
if (aiSettingsCancelEl) {
|
|
2023
|
+
aiSettingsCancelEl.addEventListener("click", () => closeAiSettingsModal());
|
|
2024
|
+
}
|
|
2025
|
+
if (aiSettingsRememberReposEl) {
|
|
2026
|
+
aiSettingsRememberReposEl.addEventListener("change", async () => {
|
|
2027
|
+
await setRememberRepos(!!aiSettingsRememberReposEl.checked);
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
if (aiSettingsClearReposEl) {
|
|
2031
|
+
aiSettingsClearReposEl.addEventListener("click", async () => {
|
|
2032
|
+
await clearRepositoryList();
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
if (aiSettingsModalEl) {
|
|
2036
|
+
aiSettingsModalEl.addEventListener("click", (e) => {
|
|
2037
|
+
if (e.target === aiSettingsModalEl) closeAiSettingsModal();
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
document.addEventListener("keydown", (e) => {
|
|
2041
|
+
if (e.key === "Escape" && aiSettingsModalEl && !aiSettingsModalEl.classList.contains("hidden")) {
|
|
2042
|
+
closeAiSettingsModal();
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
let repoInlineBound = false;
|
|
2048
|
+
let repoAddInFlight = false;
|
|
2049
|
+
let repoAddAbortController = null;
|
|
2050
|
+
const DEBUG_CONFIRM = false;
|
|
2051
|
+
if (!window.confirmState || typeof window.confirmState !== "object") {
|
|
2052
|
+
window.confirmState = { open: false, actionType: null, payload: null };
|
|
2053
|
+
}
|
|
2054
|
+
let confirmResolve = null;
|
|
2055
|
+
let confirmRunAction = null;
|
|
2056
|
+
let confirmKeydownHandler = null;
|
|
2057
|
+
let confirmBackdropHandler = null;
|
|
2058
|
+
let confirmPanelHandler = null;
|
|
2059
|
+
let confirmYesHandler = null;
|
|
2060
|
+
let confirmNoHandler = null;
|
|
2061
|
+
|
|
2062
|
+
function showToast(message, type) {
|
|
2063
|
+
if (!toastEl) return;
|
|
2064
|
+
toastEl.textContent = redactSecrets(String(message || ""));
|
|
2065
|
+
toastEl.classList.remove("hidden", "error");
|
|
2066
|
+
if (String(type || "") === "error") toastEl.classList.add("error");
|
|
2067
|
+
window.setTimeout(() => {
|
|
2068
|
+
toastEl.classList.add("hidden");
|
|
2069
|
+
}, 2200);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
function confirmLog(label, payload) {
|
|
2073
|
+
if (!DEBUG_CONFIRM) return;
|
|
2074
|
+
try {
|
|
2075
|
+
console.log(`[confirm] ${label}`, payload || {});
|
|
2076
|
+
} catch (_e) {
|
|
2077
|
+
// no-op
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function teardownConfirmModalHandlers() {
|
|
2082
|
+
if (!confirmModalEl) return;
|
|
2083
|
+
const panel = confirmModalEl.querySelector(".confirm-card");
|
|
2084
|
+
const yesBtn = confirmModalEl.querySelector("#confirm-yes");
|
|
2085
|
+
const noBtn = confirmModalEl.querySelector("#confirm-no");
|
|
2086
|
+
if (confirmBackdropHandler) {
|
|
2087
|
+
confirmModalEl.removeEventListener("click", confirmBackdropHandler);
|
|
2088
|
+
confirmBackdropHandler = null;
|
|
2089
|
+
}
|
|
2090
|
+
if (panel && confirmPanelHandler) {
|
|
2091
|
+
panel.removeEventListener("click", confirmPanelHandler);
|
|
2092
|
+
confirmPanelHandler = null;
|
|
2093
|
+
}
|
|
2094
|
+
if (yesBtn && confirmYesHandler) {
|
|
2095
|
+
yesBtn.removeEventListener("click", confirmYesHandler);
|
|
2096
|
+
confirmYesHandler = null;
|
|
2097
|
+
}
|
|
2098
|
+
if (noBtn && confirmNoHandler) {
|
|
2099
|
+
noBtn.removeEventListener("click", confirmNoHandler);
|
|
2100
|
+
confirmNoHandler = null;
|
|
2101
|
+
}
|
|
2102
|
+
if (confirmKeydownHandler) {
|
|
2103
|
+
document.removeEventListener("keydown", confirmKeydownHandler);
|
|
2104
|
+
confirmKeydownHandler = null;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
function renderConfirmModal(title, message) {
|
|
2109
|
+
if (!confirmModalEl || !confirmTitleEl || !confirmMessageEl) return;
|
|
2110
|
+
confirmTitleEl.textContent = String(title || "Confirm action");
|
|
2111
|
+
confirmMessageEl.textContent = redactSecrets(String(message || ""));
|
|
2112
|
+
const open = !!(window.confirmState && window.confirmState.open);
|
|
2113
|
+
confirmModalEl.classList.toggle("hidden", !open);
|
|
2114
|
+
document.body.classList.toggle("modal-open", open);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function closeConfirmModal(result) {
|
|
2118
|
+
confirmLog("close", { result });
|
|
2119
|
+
if (!confirmModalEl) return;
|
|
2120
|
+
teardownConfirmModalHandlers();
|
|
2121
|
+
window.confirmState = { open: false, actionType: null, payload: null };
|
|
2122
|
+
confirmRunAction = null;
|
|
2123
|
+
renderConfirmModal("", "");
|
|
2124
|
+
const resolver = confirmResolve;
|
|
2125
|
+
confirmResolve = null;
|
|
2126
|
+
if (typeof resolver === "function") resolver(!!result);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function openConfirmModal({ title, message, confirmText, cancelText, actionType, payload, onConfirm }) {
|
|
2130
|
+
if (!confirmModalEl) {
|
|
2131
|
+
return Promise.resolve(window.confirm(String(message || "Are you sure?")));
|
|
2132
|
+
}
|
|
2133
|
+
window.confirmState = {
|
|
2134
|
+
open: true,
|
|
2135
|
+
actionType: actionType || null,
|
|
2136
|
+
payload: payload || null,
|
|
2137
|
+
};
|
|
2138
|
+
confirmRunAction = typeof onConfirm === "function" ? onConfirm : null;
|
|
2139
|
+
renderConfirmModal(title, message);
|
|
2140
|
+
teardownConfirmModalHandlers();
|
|
2141
|
+
|
|
2142
|
+
const panel = confirmModalEl.querySelector(".confirm-card");
|
|
2143
|
+
const yesBtn = confirmModalEl.querySelector("#confirm-yes");
|
|
2144
|
+
const noBtn = confirmModalEl.querySelector("#confirm-no");
|
|
2145
|
+
if (yesBtn) yesBtn.textContent = String(confirmText || "Yes");
|
|
2146
|
+
if (noBtn) noBtn.textContent = String(cancelText || "Cancel");
|
|
2147
|
+
|
|
2148
|
+
confirmNoHandler = () => {
|
|
2149
|
+
confirmLog("cancel", { actionType: window.confirmState.actionType });
|
|
2150
|
+
closeConfirmModal(false);
|
|
2151
|
+
};
|
|
2152
|
+
confirmYesHandler = async () => {
|
|
2153
|
+
confirmLog("yes", { actionType: window.confirmState.actionType });
|
|
2154
|
+
let ok = true;
|
|
2155
|
+
try {
|
|
2156
|
+
if (confirmRunAction) {
|
|
2157
|
+
await confirmRunAction();
|
|
2158
|
+
}
|
|
2159
|
+
} catch (e) {
|
|
2160
|
+
ok = false;
|
|
2161
|
+
showToast(redactSecrets((e && (e.message || e.error)) || "Action failed"), "error");
|
|
2162
|
+
} finally {
|
|
2163
|
+
closeConfirmModal(ok);
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
2166
|
+
confirmBackdropHandler = (e) => {
|
|
2167
|
+
if (e.target === confirmModalEl) {
|
|
2168
|
+
confirmLog("backdrop", { actionType: window.confirmState.actionType });
|
|
2169
|
+
closeConfirmModal(false);
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
confirmPanelHandler = (e) => {
|
|
2173
|
+
e.stopPropagation();
|
|
2174
|
+
};
|
|
2175
|
+
confirmKeydownHandler = (e) => {
|
|
2176
|
+
if (e.key === "Escape" && window.confirmState.open) {
|
|
2177
|
+
confirmLog("escape", { actionType: window.confirmState.actionType });
|
|
2178
|
+
closeConfirmModal(false);
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
|
|
2182
|
+
if (noBtn) noBtn.addEventListener("click", confirmNoHandler);
|
|
2183
|
+
if (yesBtn) yesBtn.addEventListener("click", confirmYesHandler);
|
|
2184
|
+
confirmModalEl.addEventListener("click", confirmBackdropHandler);
|
|
2185
|
+
if (panel) panel.addEventListener("click", confirmPanelHandler);
|
|
2186
|
+
document.addEventListener("keydown", confirmKeydownHandler);
|
|
2187
|
+
|
|
2188
|
+
confirmLog("open", {
|
|
2189
|
+
actionType: window.confirmState.actionType,
|
|
2190
|
+
payload: window.confirmState.payload || {},
|
|
2191
|
+
});
|
|
2192
|
+
|
|
2193
|
+
return new Promise((resolve) => {
|
|
2194
|
+
// Replace any pending unresolved confirm with a safe cancel.
|
|
2195
|
+
if (typeof confirmResolve === "function") {
|
|
2196
|
+
try { confirmResolve(false); } catch (_e) {}
|
|
2197
|
+
}
|
|
2198
|
+
confirmResolve = resolve;
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function updatePrivateModeIndicator() {
|
|
2203
|
+
if (!privateModeIndicatorEl) return;
|
|
2204
|
+
const hasToken = !!(ghTokenEl && String(ghTokenEl.value || "").trim());
|
|
2205
|
+
const checked = !!(ghPrivateModeEl && ghPrivateModeEl.checked);
|
|
2206
|
+
privateModeIndicatorEl.classList.toggle("hidden", !(hasToken || checked));
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
function setRepoPanelTab(tab) {
|
|
2210
|
+
const local = tab !== "github";
|
|
2211
|
+
if (repoTabLocalEl) repoTabLocalEl.classList.toggle("active", local);
|
|
2212
|
+
if (repoTabGithubEl) repoTabGithubEl.classList.toggle("active", !local);
|
|
2213
|
+
if (repoFormLocalEl) repoFormLocalEl.classList.toggle("hidden", !local);
|
|
2214
|
+
if (repoFormGithubEl) repoFormGithubEl.classList.toggle("hidden", local);
|
|
2215
|
+
validateRepoPanel();
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function resetRepoPanelState() {
|
|
2219
|
+
if (localRepoPathEl) localRepoPathEl.value = "";
|
|
2220
|
+
if (localDisplayNameEl) localDisplayNameEl.value = "";
|
|
2221
|
+
if (ghRepoUrlEl) ghRepoUrlEl.value = "";
|
|
2222
|
+
if (ghRefEl) ghRefEl.value = "main";
|
|
2223
|
+
if (ghModeEl) ghModeEl.value = "zip";
|
|
2224
|
+
if (ghTokenEl) ghTokenEl.value = "";
|
|
2225
|
+
if (ghPrivateModeEl) ghPrivateModeEl.checked = false;
|
|
2226
|
+
updatePrivateModeIndicator();
|
|
2227
|
+
if (repoModalErrorEl) repoModalErrorEl.textContent = "";
|
|
2228
|
+
repoAddInFlight = false;
|
|
2229
|
+
repoAddAbortController = null;
|
|
2230
|
+
setRepoPanelLoading(false);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
function setRepoPanelLoading(isLoading) {
|
|
2234
|
+
if (repoAddBtnEl) {
|
|
2235
|
+
repoAddBtnEl.disabled = !!isLoading;
|
|
2236
|
+
repoAddBtnEl.textContent = isLoading ? "Adding..." : "Add repo";
|
|
2237
|
+
repoAddBtnEl.classList.toggle("is-loading", !!isLoading);
|
|
2238
|
+
}
|
|
2239
|
+
if (repoCancelBtnEl) repoCancelBtnEl.disabled = false;
|
|
2240
|
+
if (repoInlineCloseEl) repoInlineCloseEl.disabled = false;
|
|
2241
|
+
validateRepoPanel();
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function validateRepoPanel() {
|
|
2245
|
+
const githubActive = repoFormGithubEl && !repoFormGithubEl.classList.contains("hidden");
|
|
2246
|
+
let valid = false;
|
|
2247
|
+
let message = "";
|
|
2248
|
+
if (githubActive) {
|
|
2249
|
+
const url = String(ghRepoUrlEl && ghRepoUrlEl.value ? ghRepoUrlEl.value : "").trim();
|
|
2250
|
+
const ok = /^https:\/\/github\.com\/[^\/\s]+\/[^\/\s]+\/?(\.git)?$/i.test(url) || /^https:\/\/github\.com\/[^\/\s]+\/[^\/\s]+(\.git)?$/i.test(url);
|
|
2251
|
+
valid = !!url && ok;
|
|
2252
|
+
if (url && !ok) message = "Invalid GitHub URL.";
|
|
2253
|
+
} else {
|
|
2254
|
+
const path = String(localRepoPathEl && localRepoPathEl.value ? localRepoPathEl.value : "").trim();
|
|
2255
|
+
valid = !!path;
|
|
2256
|
+
if (!path) message = "Local path is required.";
|
|
2257
|
+
}
|
|
2258
|
+
if (repoModalErrorEl && !repoAddInFlight) repoModalErrorEl.textContent = redactSecrets(message);
|
|
2259
|
+
if (repoAddBtnEl) repoAddBtnEl.disabled = repoAddInFlight || !valid;
|
|
2260
|
+
return valid;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function openRepoPanel(tab) {
|
|
2264
|
+
if (!addRepoInlineEl) return;
|
|
2265
|
+
resetRepoPanelState();
|
|
2266
|
+
addRepoInlineEl.classList.remove("hidden");
|
|
2267
|
+
setRepoPanelTab(tab || "local");
|
|
2268
|
+
if (localRepoPathEl) localRepoPathEl.focus();
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function closeRepoPanel(force) {
|
|
2272
|
+
if (!addRepoInlineEl) return;
|
|
2273
|
+
if (repoAddInFlight && !force) {
|
|
2274
|
+
const shouldClose = window.confirm("Request in progress. Close anyway?");
|
|
2275
|
+
if (!shouldClose) return;
|
|
2276
|
+
if (repoAddAbortController) {
|
|
2277
|
+
try { repoAddAbortController.abort(); } catch (_e) {}
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
addRepoInlineEl.classList.add("hidden");
|
|
2281
|
+
resetRepoPanelState();
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
async function runAddRepository() {
|
|
2285
|
+
if (!validateRepoPanel()) return;
|
|
2286
|
+
const githubActive = repoFormGithubEl && !repoFormGithubEl.classList.contains("hidden");
|
|
2287
|
+
|
|
2288
|
+
repoAddInFlight = true;
|
|
2289
|
+
repoAddAbortController = new AbortController();
|
|
2290
|
+
setRepoPanelLoading(true);
|
|
2291
|
+
if (repoModalErrorEl) repoModalErrorEl.textContent = "";
|
|
2292
|
+
|
|
2293
|
+
try {
|
|
2294
|
+
let data;
|
|
2295
|
+
let privateModeRequested = false;
|
|
2296
|
+
if (githubActive) {
|
|
2297
|
+
const repoUrl = String(ghRepoUrlEl && ghRepoUrlEl.value ? ghRepoUrlEl.value : "").trim();
|
|
2298
|
+
const ref = String(ghRefEl && ghRefEl.value ? ghRefEl.value : "main").trim() || "main";
|
|
2299
|
+
const mode = String(ghModeEl && ghModeEl.value ? ghModeEl.value : "zip").trim() || "zip";
|
|
2300
|
+
const token = String(ghTokenEl && ghTokenEl.value ? ghTokenEl.value : "").trim();
|
|
2301
|
+
privateModeRequested = !!(token || (ghPrivateModeEl && ghPrivateModeEl.checked));
|
|
2302
|
+
const displayName = "";
|
|
2303
|
+
data = await fetchJson("/api/registry/repos/add", {
|
|
2304
|
+
method: "POST",
|
|
2305
|
+
headers: { "Content-Type": "application/json" },
|
|
2306
|
+
body: JSON.stringify({
|
|
2307
|
+
source: "github",
|
|
2308
|
+
repo_url: repoUrl,
|
|
2309
|
+
ref,
|
|
2310
|
+
mode,
|
|
2311
|
+
display_name: displayName,
|
|
2312
|
+
open_after_add: true,
|
|
2313
|
+
private_mode: privateModeRequested,
|
|
2314
|
+
}),
|
|
2315
|
+
signal: repoAddAbortController.signal,
|
|
2316
|
+
});
|
|
2317
|
+
} else {
|
|
2318
|
+
const repoPath = String(localRepoPathEl && localRepoPathEl.value ? localRepoPathEl.value : "").trim();
|
|
2319
|
+
const displayName = String(localDisplayNameEl && localDisplayNameEl.value ? localDisplayNameEl.value : "").trim();
|
|
2320
|
+
data = await fetchJson("/api/registry/repos/add", {
|
|
2321
|
+
method: "POST",
|
|
2322
|
+
headers: { "Content-Type": "application/json" },
|
|
2323
|
+
body: JSON.stringify({ source: "filesystem", repo_path: repoPath, display_name: displayName, open_after_add: true }),
|
|
2324
|
+
signal: repoAddAbortController.signal,
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const addedName = (data && data.repo && data.repo.name) ? data.repo.name : "Repository";
|
|
2329
|
+
showToast(`Repo added: ${addedName}`, "success");
|
|
2330
|
+
closeRepoPanel(true);
|
|
2331
|
+
|
|
2332
|
+
// Refresh/selection updates run after close so modal never blocks app interaction.
|
|
2333
|
+
try {
|
|
2334
|
+
await loadWorkspace();
|
|
2335
|
+
await loadRepoRegistry();
|
|
2336
|
+
if (data.repo_hash) await selectWorkspace(data.repo_hash);
|
|
2337
|
+
setActiveTab("details");
|
|
2338
|
+
if (data.repo_hash && privateModeRequested) {
|
|
2339
|
+
showToast("Private mode enabled. Default retention set to 7 days after analysis.", "success");
|
|
2340
|
+
}
|
|
2341
|
+
} catch (_postSuccessErr) {
|
|
2342
|
+
// Keep app usable; repo add already succeeded.
|
|
2343
|
+
}
|
|
2344
|
+
} catch (e) {
|
|
2345
|
+
if (repoModalErrorEl) repoModalErrorEl.textContent = redactSecrets((e && (e.message || e.error)) || "Failed to add repository.");
|
|
2346
|
+
showToast(redactSecrets((e && (e.message || e.error)) || "Failed to add repository."), "error");
|
|
2347
|
+
} finally {
|
|
2348
|
+
if (ghTokenEl) ghTokenEl.value = "";
|
|
2349
|
+
updatePrivateModeIndicator();
|
|
2350
|
+
repoAddInFlight = false;
|
|
2351
|
+
repoAddAbortController = null;
|
|
2352
|
+
setRepoPanelLoading(false);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
function bindDataPrivacyControls() {
|
|
2357
|
+
try {
|
|
2358
|
+
autoCleanOnRemove = window.localStorage.getItem("codemap_auto_clean_on_remove") === "1";
|
|
2359
|
+
} catch (_e) {
|
|
2360
|
+
autoCleanOnRemove = false;
|
|
2361
|
+
}
|
|
2362
|
+
if (autoCleanOnRemoveEl) {
|
|
2363
|
+
autoCleanOnRemoveEl.checked = !!autoCleanOnRemove;
|
|
2364
|
+
if (autoCleanNoteEl) autoCleanNoteEl.classList.toggle("hidden", !autoCleanOnRemove);
|
|
2365
|
+
autoCleanOnRemoveEl.addEventListener("change", () => {
|
|
2366
|
+
autoCleanOnRemove = !!autoCleanOnRemoveEl.checked;
|
|
2367
|
+
if (autoCleanNoteEl) autoCleanNoteEl.classList.toggle("hidden", !autoCleanOnRemove);
|
|
2368
|
+
try {
|
|
2369
|
+
window.localStorage.setItem("codemap_auto_clean_on_remove", autoCleanOnRemove ? "1" : "0");
|
|
2370
|
+
} catch (_e) {
|
|
2371
|
+
// ignore
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
if (repoRetentionSaveBtnEl) {
|
|
2376
|
+
repoRetentionSaveBtnEl.addEventListener("click", () => {
|
|
2377
|
+
setActiveRepoRetention();
|
|
2378
|
+
});
|
|
2379
|
+
}
|
|
2380
|
+
if (cleanupDryBtnEl) {
|
|
2381
|
+
cleanupDryBtnEl.addEventListener("click", () => {
|
|
2382
|
+
runRetentionCleanup(true, false);
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
if (cleanupNowBtnEl) {
|
|
2386
|
+
cleanupNowBtnEl.addEventListener("click", () => {
|
|
2387
|
+
runRetentionCleanup(true, true);
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
if (deleteRepoCacheBtnEl) {
|
|
2391
|
+
deleteRepoCacheBtnEl.addEventListener("click", () => {
|
|
2392
|
+
deleteActiveRepoCache();
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
if (deleteAllCachesBtnEl) {
|
|
2396
|
+
deleteAllCachesBtnEl.addEventListener("click", () => {
|
|
2397
|
+
deleteAllCaches();
|
|
2398
|
+
});
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function bindRepoInlineControls() {
|
|
2403
|
+
if (repoInlineBound) return;
|
|
2404
|
+
repoInlineBound = true;
|
|
2405
|
+
if (repoInlineCloseEl) repoInlineCloseEl.addEventListener("click", (e) => {
|
|
2406
|
+
e.preventDefault();
|
|
2407
|
+
e.stopPropagation();
|
|
2408
|
+
closeRepoPanel(false);
|
|
2409
|
+
});
|
|
2410
|
+
if (repoCancelBtnEl) repoCancelBtnEl.addEventListener("click", (e) => {
|
|
2411
|
+
e.preventDefault();
|
|
2412
|
+
closeRepoPanel(false);
|
|
2413
|
+
});
|
|
2414
|
+
document.addEventListener("keydown", (e) => {
|
|
2415
|
+
if (e.key === "Escape" && addRepoInlineEl && !addRepoInlineEl.classList.contains("hidden")) {
|
|
2416
|
+
closeRepoPanel(false);
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
if (repoTabLocalEl) repoTabLocalEl.addEventListener("click", () => setRepoPanelTab("local"));
|
|
2420
|
+
if (repoTabGithubEl) repoTabGithubEl.addEventListener("click", () => setRepoPanelTab("github"));
|
|
2421
|
+
if (repoAddBtnEl) repoAddBtnEl.addEventListener("click", () => runAddRepository());
|
|
2422
|
+
if (localRepoPathEl) localRepoPathEl.addEventListener("input", () => validateRepoPanel());
|
|
2423
|
+
if (ghRepoUrlEl) ghRepoUrlEl.addEventListener("input", () => validateRepoPanel());
|
|
2424
|
+
if (ghTokenEl) ghTokenEl.addEventListener("input", () => updatePrivateModeIndicator());
|
|
2425
|
+
if (ghPrivateModeEl) ghPrivateModeEl.addEventListener("change", () => updatePrivateModeIndicator());
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function bindGraphControls() {
|
|
2429
|
+
if (tabDetailsEl) tabDetailsEl.addEventListener("click", () => setActiveTab("details"));
|
|
2430
|
+
if (tabImpactEl) tabImpactEl.addEventListener("click", () => setActiveTab("impact"));
|
|
2431
|
+
if (tabGraphEl) tabGraphEl.addEventListener("click", () => setActiveTab("graph"));
|
|
2432
|
+
if (tabArchitectureEl) tabArchitectureEl.addEventListener("click", () => setActiveTab("architecture"));
|
|
2433
|
+
const rerender = () => {
|
|
2434
|
+
graphDataCache.clear();
|
|
2435
|
+
if (activeTab === "graph") loadGraph();
|
|
2436
|
+
};
|
|
2437
|
+
if (graphModeEl) graphModeEl.addEventListener("change", rerender);
|
|
2438
|
+
if (graphDepthEl) graphDepthEl.addEventListener("change", rerender);
|
|
2439
|
+
if (graphHideBuiltinsEl) graphHideBuiltinsEl.addEventListener("change", rerender);
|
|
2440
|
+
if (graphHideExternalEl) graphHideExternalEl.addEventListener("change", rerender);
|
|
2441
|
+
if (graphSearchEl) graphSearchEl.addEventListener("input", () => {
|
|
2442
|
+
if (activeTab === "graph") {
|
|
2443
|
+
const p = graphParams();
|
|
2444
|
+
const graphMode = p.mode === "file" ? "file" : "symbol";
|
|
2445
|
+
const anchor = graphMode === "file" ? activeFilePath : activeSymbolFqn;
|
|
2446
|
+
const key = `${graphMode}|${anchor}|${p.depth}|${p.hideBuiltins}|${p.hideExternal}`;
|
|
2447
|
+
const cached = graphDataCache.get(key);
|
|
2448
|
+
if (cached) renderGraphData(cached);
|
|
2449
|
+
}
|
|
2450
|
+
});
|
|
2451
|
+
const rerenderImpact = () => {
|
|
2452
|
+
impactDataCache.clear();
|
|
2453
|
+
if (activeTab === "impact") loadImpact();
|
|
2454
|
+
};
|
|
2455
|
+
if (impactDepthEl) impactDepthEl.addEventListener("change", rerenderImpact);
|
|
2456
|
+
if (impactMaxNodesEl) impactMaxNodesEl.addEventListener("change", rerenderImpact);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
async function loadFile(relFilePath) {
|
|
2460
|
+
activeFilePath = relFilePath;
|
|
2461
|
+
highlightActiveFile();
|
|
2462
|
+
graphDataCache.clear();
|
|
2463
|
+
impactDataCache.clear();
|
|
2464
|
+
fileViewEl.classList.remove("muted");
|
|
2465
|
+
fileViewEl.textContent = "Loading file intelligence...";
|
|
2466
|
+
try {
|
|
2467
|
+
let data = fileCache.get(relFilePath);
|
|
2468
|
+
if (!data) {
|
|
2469
|
+
data = await fetchJson(`/api/file?path=${encodeURIComponent(relFilePath)}`);
|
|
2470
|
+
fileCache.set(relFilePath, data);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
fileViewEl.innerHTML = `
|
|
2474
|
+
<div class="card">
|
|
2475
|
+
<div class="line"><span class="label">File</span><span class="path">${esc(data.file)}</span></div>
|
|
2476
|
+
<div class="line"><span class="label">Incoming usages</span><span>${data.incoming_usages_count}</span></div>
|
|
2477
|
+
<div class="line"><span class="label">Outgoing calls</span><span>${data.outgoing_calls_count}</span></div>
|
|
2478
|
+
</div>
|
|
2479
|
+
<div class="card">
|
|
2480
|
+
${renderSymbolGroup(data.symbols)}
|
|
2481
|
+
</div>
|
|
2482
|
+
`;
|
|
2483
|
+
|
|
2484
|
+
bindSymbolLinks(fileViewEl);
|
|
2485
|
+
highlightActiveSymbol();
|
|
2486
|
+
highlightActiveFile();
|
|
2487
|
+
await updateUiState({ opened_file: relFilePath });
|
|
2488
|
+
if (activeTab === "graph" && graphParams().mode === "file") {
|
|
2489
|
+
await loadGraph();
|
|
2490
|
+
}
|
|
2491
|
+
} catch (e) {
|
|
2492
|
+
const errCode = String((e && e.error) || "");
|
|
2493
|
+
if (errCode === "MISSING_ANALYSIS" || errCode === "CACHE_NOT_FOUND") {
|
|
2494
|
+
fileViewEl.classList.remove("muted");
|
|
2495
|
+
fileViewEl.innerHTML = renderMissingAnalysisCta("Run Analyze first to load file intelligence.");
|
|
2496
|
+
bindRunAnalysisNowButton();
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
fileViewEl.classList.add("muted");
|
|
2500
|
+
fileViewEl.textContent = redactSecrets((e && (e.error || e.message)) || "Failed to load file intelligence");
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function delay(ms) {
|
|
2505
|
+
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function renderEmptyState(text) {
|
|
2509
|
+
return `<div class="empty-state">OK ${esc(text)}</div>`;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function showSymbolLoading() {
|
|
2513
|
+
symbolViewEl.classList.remove("muted");
|
|
2514
|
+
symbolViewEl.innerHTML = `
|
|
2515
|
+
<div class="card shimmer-card">
|
|
2516
|
+
<div class="shimmer-line w60"></div>
|
|
2517
|
+
<div class="shimmer-line w90"></div>
|
|
2518
|
+
<div class="shimmer-line w75"></div>
|
|
2519
|
+
</div>
|
|
2520
|
+
`;
|
|
2521
|
+
const panel = symbolViewEl.closest(".panel");
|
|
2522
|
+
if (panel) panel.scrollTo({ top: 0, behavior: "smooth" });
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function renderConnectionBlock(items, renderer, emptyText) {
|
|
2526
|
+
if (!items || !items.length) {
|
|
2527
|
+
return renderEmptyState(emptyText);
|
|
2528
|
+
}
|
|
2529
|
+
return items.map(renderer).join("");
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
async function loadSymbol(fqn) {
|
|
2534
|
+
activeSymbolFqn = fqn;
|
|
2535
|
+
highlightActiveSymbol();
|
|
2536
|
+
graphDataCache.clear();
|
|
2537
|
+
impactDataCache.clear();
|
|
2538
|
+
showSymbolLoading();
|
|
2539
|
+
try {
|
|
2540
|
+
const symbolPromise = (async () => {
|
|
2541
|
+
let symbolData = symbolCache.get(fqn);
|
|
2542
|
+
if (!symbolData) {
|
|
2543
|
+
symbolData = await fetchJson(`/api/symbol?fqn=${encodeURIComponent(fqn)}`);
|
|
2544
|
+
symbolCache.set(fqn, symbolData);
|
|
2545
|
+
}
|
|
2546
|
+
return symbolData;
|
|
2547
|
+
})();
|
|
2548
|
+
|
|
2549
|
+
const [symbolData] = await Promise.all([symbolPromise, delay(150)]);
|
|
2550
|
+
|
|
2551
|
+
const result = symbolData.result || {};
|
|
2552
|
+
const loc = result.location || {};
|
|
2553
|
+
const relFile = relPath(loc.file || "");
|
|
2554
|
+
if (relFile && relFile !== activeFilePath) {
|
|
2555
|
+
await loadFile(relFile);
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const summary = stripMarkdown(result.one_liner || "");
|
|
2559
|
+
const notes = (result.details || []).filter((d) => String(d).startsWith("Returns:")).slice(0, 3).map(stripMarkdown);
|
|
2560
|
+
const symbolParts = parseSymbolParts(result.fqn || fqn);
|
|
2561
|
+
const locationText = `${relFile}:${loc.start_line || ""}`;
|
|
2562
|
+
const connections = result.connections || {};
|
|
2563
|
+
const calledBy = connections.called_by || [];
|
|
2564
|
+
const calls = connections.calls || [];
|
|
2565
|
+
const usedIn = (connections.used_in || []).slice().sort((a, b) => (a.file || "").localeCompare(b.file || ""));
|
|
2566
|
+
|
|
2567
|
+
const crumbs = [
|
|
2568
|
+
{ label: repoName || "repo", action: "repo", value: "" },
|
|
2569
|
+
{ label: relFile, action: "file", value: relFile },
|
|
2570
|
+
];
|
|
2571
|
+
if (symbolParts.className) {
|
|
2572
|
+
crumbs.push({ label: symbolParts.className, action: "class", value: symbolParts.className });
|
|
2573
|
+
}
|
|
2574
|
+
crumbs.push({ label: symbolParts.symbol, action: "symbol", value: result.fqn || fqn });
|
|
2575
|
+
|
|
2576
|
+
symbolViewEl.innerHTML = `
|
|
2577
|
+
<div class="card symbol-card fade-panel">
|
|
2578
|
+
<div class="breadcrumbs">
|
|
2579
|
+
${crumbs.map((c) => `<span class="crumb-link" data-action="${esc(c.action)}" data-value="${esc(c.value)}">${esc(c.label)}</span>`).join("<span class='crumb-sep'>></span>")}
|
|
2580
|
+
</div>
|
|
2581
|
+
<div class="chips">
|
|
2582
|
+
<button class="chip" data-target="called-by-section">Called by: ${calledBy.length}</button>
|
|
2583
|
+
<button class="chip" data-target="calls-section">Calls: ${calls.length}</button>
|
|
2584
|
+
<button class="chip" data-target="used-in-section">Used in: ${usedIn.length}</button>
|
|
2585
|
+
</div>
|
|
2586
|
+
<div class="symbol-title-main">${esc(symbolParts.display)}</div>
|
|
2587
|
+
<div class="path">FQN: ${esc(result.fqn || fqn)}</div>
|
|
2588
|
+
<div class="path">${esc(locationText)}</div>
|
|
2589
|
+
<div class="divider"></div>
|
|
2590
|
+
<div class="section-title">Summary</div>
|
|
2591
|
+
<div>${esc(summary)}</div>
|
|
2592
|
+
<div id="called-by-section" class="divider"></div>
|
|
2593
|
+
<div class="section-title">Called by</div>
|
|
2594
|
+
${renderConnectionBlock(calledBy, (c) => `
|
|
2595
|
+
<div>
|
|
2596
|
+
<span class="conn-arrow">-></span><span class="connection-link" data-fqn="${esc(c.fqn)}">${esc(c.fqn)}</span>
|
|
2597
|
+
<span class="path">${esc(c.file)}:${esc(c.line)}</span>
|
|
2598
|
+
</div>
|
|
2599
|
+
`, "No callers found")}
|
|
2600
|
+
<div id="calls-section" class="divider"></div>
|
|
2601
|
+
<div class="section-title">Calls</div>
|
|
2602
|
+
${renderConnectionBlock(calls, (c) => `
|
|
2603
|
+
<div>
|
|
2604
|
+
${c.clickable
|
|
2605
|
+
? `<span class="conn-arrow">-></span><span class="connection-link" data-fqn="${esc(c.fqn)}">${esc(c.name)}</span>`
|
|
2606
|
+
: `<span class="connection-muted">${esc(c.name)}</span>`
|
|
2607
|
+
}
|
|
2608
|
+
<span class="path">(${esc(c.count)}x)</span>
|
|
2609
|
+
</div>
|
|
2610
|
+
`, "No calls found")}
|
|
2611
|
+
<div class="divider"></div>
|
|
2612
|
+
<div class="section-title">Top Callees</div>
|
|
2613
|
+
${renderConnectionBlock(calls.slice(0, 10), (c) => `
|
|
2614
|
+
<div>
|
|
2615
|
+
<span class="${c.clickable ? "connection-link" : "connection-muted"}" ${c.clickable ? `data-fqn="${esc(c.fqn)}"` : ""}>${esc(c.name)}</span>
|
|
2616
|
+
<span class="path">(${esc(c.count)}x)</span>
|
|
2617
|
+
</div>
|
|
2618
|
+
`, "No callees found")}
|
|
2619
|
+
<div id="used-in-section" class="divider"></div>
|
|
2620
|
+
<div class="section-title">Used in</div>
|
|
2621
|
+
${renderConnectionBlock(usedIn, (u) => `
|
|
2622
|
+
<div>
|
|
2623
|
+
<span class="conn-arrow">-></span><span class="connection-link" data-fqn="${esc(u.fqn)}">${esc(u.fqn)}</span>
|
|
2624
|
+
<span class="path">${esc(u.file)}:${esc(u.line)}</span>
|
|
2625
|
+
</div>
|
|
2626
|
+
`, "No usages found")}
|
|
2627
|
+
<div class="divider"></div>
|
|
2628
|
+
<div class="section-title">Notes</div>
|
|
2629
|
+
${notes.length ? notes.map((n) => `<div>${esc(n)}</div>`).join("") : "<div class='muted'>None</div>"}
|
|
2630
|
+
</div>
|
|
2631
|
+
`;
|
|
2632
|
+
|
|
2633
|
+
bindConnectionLinks(symbolViewEl);
|
|
2634
|
+
bindBreadcrumbs(symbolViewEl, result.fqn || fqn);
|
|
2635
|
+
bindConnectionChips(symbolViewEl);
|
|
2636
|
+
highlightActiveSymbol();
|
|
2637
|
+
await updateUiState({ opened_symbol: (result.fqn || fqn), last_symbol: (result.fqn || fqn) });
|
|
2638
|
+
if (activeTab === "graph" && graphParams().mode === "symbol") {
|
|
2639
|
+
await loadGraph(result.fqn || fqn);
|
|
2640
|
+
}
|
|
2641
|
+
if (activeTab === "impact") {
|
|
2642
|
+
await loadImpact(result.fqn || fqn);
|
|
2643
|
+
}
|
|
2644
|
+
} catch (e) {
|
|
2645
|
+
const errCode = String((e && e.error) || "");
|
|
2646
|
+
if (errCode === "MISSING_ANALYSIS" || errCode === "CACHE_NOT_FOUND") {
|
|
2647
|
+
symbolViewEl.classList.remove("muted");
|
|
2648
|
+
symbolViewEl.innerHTML = renderMissingAnalysisCta("Run Analyze first to unlock symbol intelligence.");
|
|
2649
|
+
bindRunAnalysisNowButton();
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2652
|
+
symbolViewEl.classList.add("muted");
|
|
2653
|
+
symbolViewEl.textContent = redactSecrets((e && (e.error || e.message)) || "Failed to load symbol intelligence");
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
async function refreshForActiveRepo() {
|
|
2658
|
+
clearWorkspaceView("Loading workspace...");
|
|
2659
|
+
architectureCache = null;
|
|
2660
|
+
symbolAiSummaryCache.clear();
|
|
2661
|
+
repoSummary = null;
|
|
2662
|
+
repoSummaryUpdatedAt = "";
|
|
2663
|
+
repoSummaryStatus = "idle";
|
|
2664
|
+
repoSummaryError = "";
|
|
2665
|
+
riskRadar = null;
|
|
2666
|
+
riskRadarUpdatedAt = "";
|
|
2667
|
+
riskRadarStatus = "idle";
|
|
2668
|
+
riskRadarError = "";
|
|
2669
|
+
await loadRepoRegistry();
|
|
2670
|
+
const okMeta = await loadMeta();
|
|
2671
|
+
await loadDataPrivacy();
|
|
2672
|
+
if (!okMeta) return;
|
|
2673
|
+
await loadTree();
|
|
2674
|
+
await loadUiState();
|
|
2675
|
+
if (lastSymbol) {
|
|
2676
|
+
await loadSymbol(lastSymbol);
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
if (recentFiles.length) {
|
|
2680
|
+
await loadFile(recentFiles[0]);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
async function init() {
|
|
2685
|
+
bindSearchInput();
|
|
2686
|
+
bindWorkspaceControls();
|
|
2687
|
+
bindAiSettingsControls();
|
|
2688
|
+
bindRepoInlineControls();
|
|
2689
|
+
bindDataPrivacyControls();
|
|
2690
|
+
bindGraphControls();
|
|
2691
|
+
setActiveTab("details");
|
|
2692
|
+
try {
|
|
2693
|
+
await loadWorkspace();
|
|
2694
|
+
await refreshForActiveRepo();
|
|
2695
|
+
} catch (e) {
|
|
2696
|
+
metaEl.textContent = redactSecrets((e && (e.message || e.error)) || "Workspace unavailable");
|
|
2697
|
+
clearWorkspaceView(metaEl.textContent);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
init();
|
|
2702
|
+
})();
|
|
2703
|
+
|