codebase-onboarder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/onboarder.js +14 -0
- package/bin/postinstall.js +6 -0
- package/cli/commands.js +436 -0
- package/cli/main.js +129 -0
- package/cli/prompt.js +101 -0
- package/cli/ui.js +55 -0
- package/cli/wizard.js +325 -0
- package/package.json +48 -0
- package/public/app.js +1339 -0
- package/public/index.html +455 -0
- package/public/js/about.js +284 -0
- package/public/js/aiDraft.js +163 -0
- package/public/js/analysisPanel.js +270 -0
- package/public/js/analysisReport.js +154 -0
- package/public/js/api.js +264 -0
- package/public/js/atlas.js +98 -0
- package/public/js/blameView.js +41 -0
- package/public/js/codeTab.js +256 -0
- package/public/js/codeViewer.js +184 -0
- package/public/js/components/FileChip.js +102 -0
- package/public/js/components/MetricSparkline.js +89 -0
- package/public/js/components/RiskBadge.js +105 -0
- package/public/js/deepAnalysisView.js +496 -0
- package/public/js/diagramPane.js +154 -0
- package/public/js/diffView.js +352 -0
- package/public/js/docsView.js +566 -0
- package/public/js/fileSourceBrowser.js +47 -0
- package/public/js/flameGraph.js +75 -0
- package/public/js/forceGraph.js +592 -0
- package/public/js/heatmap.js +185 -0
- package/public/js/highlight.js +122 -0
- package/public/js/html.js +50 -0
- package/public/js/insightsView.js +236 -0
- package/public/js/inspector.js +535 -0
- package/public/js/llm.js +345 -0
- package/public/js/mapView.js +159 -0
- package/public/js/markdown.js +119 -0
- package/public/js/mindmap.js +289 -0
- package/public/js/repoFiles.js +44 -0
- package/public/js/sbomView.js +142 -0
- package/public/js/scanCache.js +76 -0
- package/public/js/search.js +874 -0
- package/public/js/serverSettings.js +169 -0
- package/public/js/state.js +192 -0
- package/public/js/tour.js +17 -0
- package/public/js/transitions.js +15 -0
- package/public/js/tree.js +200 -0
- package/public/js/workflowsView.js +95 -0
- package/public/styles.css +2767 -0
- package/public/vendor/mermaid.min.js +3587 -0
- package/public/vendor/monaco/vs/base/browser/ui/codicons/codicon/codicon.ttf +0 -0
- package/public/vendor/monaco/vs/base/worker/workerMain.js +31 -0
- package/public/vendor/monaco/vs/basic-languages/abap/abap.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/apex/apex.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/azcli/azcli.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bat/bat.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/bicep/bicep.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/cameligo/cameligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/clojure/clojure.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/coffee/coffee.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/cpp/cpp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csharp/csharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/csp/csp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/css/css.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/cypher/cypher.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dart/dart.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/dockerfile/dockerfile.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ecl/ecl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/elixir/elixir.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/flow9/flow9.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/freemarker2/freemarker2.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/fsharp/fsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/go/go.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/graphql/graphql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/handlebars/handlebars.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/hcl/hcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/html/html.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ini/ini.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/java/java.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/javascript/javascript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/julia/julia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/kotlin/kotlin.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/less/less.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/lexon/lexon.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/liquid/liquid.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/lua/lua.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/m3/m3.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/markdown/markdown.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mdx/mdx.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mips/mips.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/msdax/msdax.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/mysql/mysql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/objective-c/objective-c.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascal/pascal.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pascaligo/pascaligo.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/perl/perl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pgsql/pgsql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/php/php.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/pla/pla.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/postiats/postiats.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powerquery/powerquery.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/powershell/powershell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/protobuf/protobuf.js +11 -0
- package/public/vendor/monaco/vs/basic-languages/pug/pug.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/python/python.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/qsharp/qsharp.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/r/r.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/razor/razor.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redis/redis.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/redshift/redshift.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/restructuredtext/restructuredtext.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/ruby/ruby.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/rust/rust.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sb/sb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scala/scala.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scheme/scheme.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/scss/scss.js +12 -0
- package/public/vendor/monaco/vs/basic-languages/shell/shell.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/solidity/solidity.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sophia/sophia.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sparql/sparql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/sql/sql.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/st/st.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/swift/swift.js +13 -0
- package/public/vendor/monaco/vs/basic-languages/systemverilog/systemverilog.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/tcl/tcl.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/twig/twig.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typescript/typescript.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/typespec/typespec.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/vb/vb.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/wgsl/wgsl.js +307 -0
- package/public/vendor/monaco/vs/basic-languages/xml/xml.js +10 -0
- package/public/vendor/monaco/vs/basic-languages/yaml/yaml.js +10 -0
- package/public/vendor/monaco/vs/editor/editor.main.css +8 -0
- package/public/vendor/monaco/vs/editor/editor.main.js +798 -0
- package/public/vendor/monaco/vs/language/css/cssMode.js +13 -0
- package/public/vendor/monaco/vs/language/css/cssWorker.js +77 -0
- package/public/vendor/monaco/vs/language/html/htmlMode.js +13 -0
- package/public/vendor/monaco/vs/language/html/htmlWorker.js +454 -0
- package/public/vendor/monaco/vs/language/json/jsonMode.js +19 -0
- package/public/vendor/monaco/vs/language/json/jsonWorker.js +42 -0
- package/public/vendor/monaco/vs/language/typescript/tsMode.js +20 -0
- package/public/vendor/monaco/vs/language/typescript/tsWorker.js +51328 -0
- package/public/vendor/monaco/vs/loader.js +11 -0
- package/public/vendor/monaco/worker-boot.js +9 -0
- package/server/.fuse_hidden0000000800000001 +36 -0
- package/server/apiDiff.js +25 -0
- package/server/apiDocs.js +67 -0
- package/server/apiFile.js +36 -0
- package/server/apiGitBlame.js +63 -0
- package/server/apiMcp.js +110 -0
- package/server/apiScan.js +119 -0
- package/server/apiSearch.js +253 -0
- package/server/apiSettings.js +118 -0
- package/server/apiTools.js +90 -0
- package/server/config.js +227 -0
- package/server/fileSourceNode.js +44 -0
- package/server/gitClone.js +95 -0
- package/server/gitDiff.js +184 -0
- package/server/gitHistory.js +110 -0
- package/server/htmlText.js +44 -0
- package/server/http.js +55 -0
- package/server/httpGuards.js +87 -0
- package/server/index.js +156 -0
- package/server/llmProxy.js +162 -0
- package/server/logger.js +29 -0
- package/server/mcp/analysis.js +209 -0
- package/server/mcp/http.js +213 -0
- package/server/mcp/runner.js +278 -0
- package/server/mcp/server.js +241 -0
- package/server/mcp/standalone.js +42 -0
- package/server/mcp/tools.js +683 -0
- package/server/paths.js +39 -0
- package/server/router.js +218 -0
- package/server/searchIndex.js +118 -0
- package/server/sessions.js +163 -0
- package/server/static.js +59 -0
- package/server/tools/install.js +246 -0
- package/server/tools/parse.js +170 -0
- package/server/tools/platform.js +91 -0
- package/server/tools/registry.js +212 -0
- package/server/tools/scan.js +136 -0
- package/server/tools.js +212 -0
- package/server/tunnel.js +92 -0
- package/shared/analyzer/docs.js +135 -0
- package/shared/analyzer/explainLocal.js +163 -0
- package/shared/analyzer/graph.js +461 -0
- package/shared/analyzer/health.js +215 -0
- package/shared/analyzer/history.js +146 -0
- package/shared/analyzer/languages/csharp.js +39 -0
- package/shared/analyzer/languages/generic.js +131 -0
- package/shared/analyzer/languages/go.js +70 -0
- package/shared/analyzer/languages/index.js +43 -0
- package/shared/analyzer/languages/java.js +39 -0
- package/shared/analyzer/languages/javascript.js +240 -0
- package/shared/analyzer/languages/python.js +120 -0
- package/shared/analyzer/languages/rust.js +42 -0
- package/shared/analyzer/languages/typescript.js +138 -0
- package/shared/analyzer/licenses.js +151 -0
- package/shared/analyzer/metrics.js +120 -0
- package/shared/analyzer/pathUtil.js +69 -0
- package/shared/analyzer/patterns.js +272 -0
- package/shared/analyzer/scan.js +473 -0
- package/shared/analyzer/security.js +187 -0
- package/shared/analyzer/services.js +157 -0
- package/shared/analyzer/stack.js +173 -0
- package/shared/analyzer/tour.js +69 -0
- package/shared/analyzer/util.js +123 -0
- package/shared/analyzer/workflows.js +143 -0
- package/shared/diagram/aiFacts.js +145 -0
- package/shared/diagram/aiMermaid.js +59 -0
- package/shared/diagram/atlas.js +93 -0
- package/shared/diagram/mermaid.js +459 -0
- package/shared/search/query.js +518 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
// The Docs tab: a written account of the repo.
|
|
2
|
+
//
|
|
3
|
+
// Three layers, in order of how much they cost. The overview page and the
|
|
4
|
+
// folder-by-folder sections are written from the import graph and need no
|
|
5
|
+
// network at all. Pressing "Generate documentation" replaces those sentences
|
|
6
|
+
// with a model's, streamed in place. Opening a topic from the contents adds a
|
|
7
|
+
// tab that documents one file or folder in depth, and the bar at the bottom
|
|
8
|
+
// answers questions scoped to whichever of those is on screen.
|
|
9
|
+
//
|
|
10
|
+
// The static sentences live in `/shared/analyzer/docs.js` — pure, and tested.
|
|
11
|
+
// What is here is the DOM: tabs, streaming, and the wiring between them.
|
|
12
|
+
|
|
13
|
+
import { parseFolderDoc, fileFactsLine, fileStaticDoc, folderStaticDoc, docFileRow } from '/shared/analyzer/docs.js';
|
|
14
|
+
import { explainOverview } from '/shared/analyzer/explainLocal.js';
|
|
15
|
+
import { scanIndex } from '/shared/analyzer/graph.js';
|
|
16
|
+
import { escapeHtml } from './html.js';
|
|
17
|
+
import { mdLite, mdRender } from './markdown.js';
|
|
18
|
+
import { state } from './state.js';
|
|
19
|
+
import { streamExplain } from './api.js';
|
|
20
|
+
import { findNode, countFiles } from './tree.js';
|
|
21
|
+
import { readRepoFile, gatherFileContext } from './repoFiles.js';
|
|
22
|
+
import * as llm from './llm.js';
|
|
23
|
+
|
|
24
|
+
let host = null;
|
|
25
|
+
let hooks = {
|
|
26
|
+
onToast: () => {},
|
|
27
|
+
onOpenSettings: () => {},
|
|
28
|
+
onOpenFile: () => {},
|
|
29
|
+
repoOverview: () => '',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function initDocs(options) {
|
|
33
|
+
host = options.host;
|
|
34
|
+
hooks = { ...hooks, ...options };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Facts about one file or folder, bound to the loaded repo. Every prompt and
|
|
38
|
+
// every static sentence on this page goes through these two, so the doc tab and
|
|
39
|
+
// the folder pass can never describe the same file differently.
|
|
40
|
+
const factsLine = (path) => fileFactsLine(path, state.scan, state.facts);
|
|
41
|
+
const staticDoc = (path) => fileStaticDoc(path, state.scan, state.facts);
|
|
42
|
+
const fileRow = (path) => docFileRow(path, state.scan, state.facts);
|
|
43
|
+
|
|
44
|
+
// ---- the page ---------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
// One-time document build per scan; AI briefs update rows in place afterwards.
|
|
47
|
+
export function renderDocs() {
|
|
48
|
+
if (state.docs.rendered) return;
|
|
49
|
+
state.docs.rendered = true;
|
|
50
|
+
|
|
51
|
+
const tabsEl = document.createElement('div');
|
|
52
|
+
tabsEl.className = 'doc-tabs';
|
|
53
|
+
const wrapEl = document.createElement('div');
|
|
54
|
+
const scrollEl = document.createElement('div');
|
|
55
|
+
scrollEl.className = 'doc-scroll';
|
|
56
|
+
scrollEl.append(tabsEl, wrapEl);
|
|
57
|
+
|
|
58
|
+
// Answers stream into this rail, above the bar, only once there's something
|
|
59
|
+
// to show.
|
|
60
|
+
const railEl = document.createElement('div');
|
|
61
|
+
railEl.className = 'doc-qa-rail';
|
|
62
|
+
railEl.hidden = true;
|
|
63
|
+
|
|
64
|
+
// The sticky bottom bar: questions about whatever doc is on screen, with
|
|
65
|
+
// clear/close once a conversation is underway.
|
|
66
|
+
const barEl = document.createElement('form');
|
|
67
|
+
barEl.className = 'doc-askbar';
|
|
68
|
+
barEl.innerHTML = `
|
|
69
|
+
<input type="text" id="docAskInput" class="text-input" placeholder="Ask me anything about this doc…" spellcheck="false" autocomplete="off">
|
|
70
|
+
<button type="submit" class="btn btn-ink">Ask</button>`;
|
|
71
|
+
const clearBtn = document.createElement('button');
|
|
72
|
+
clearBtn.type = 'button';
|
|
73
|
+
clearBtn.className = 'btn btn-sm btn-ghost doc-clear';
|
|
74
|
+
clearBtn.textContent = 'clear';
|
|
75
|
+
clearBtn.title = 'Clear the conversation';
|
|
76
|
+
clearBtn.hidden = true;
|
|
77
|
+
const closeBtn = document.createElement('button');
|
|
78
|
+
closeBtn.type = 'button';
|
|
79
|
+
closeBtn.className = 'btn btn-sm btn-ghost doc-close';
|
|
80
|
+
closeBtn.textContent = 'close';
|
|
81
|
+
closeBtn.title = 'Close the conversation';
|
|
82
|
+
closeBtn.hidden = true;
|
|
83
|
+
clearBtn.addEventListener('click', () => { railEl.innerHTML = ''; });
|
|
84
|
+
closeBtn.addEventListener('click', () => {
|
|
85
|
+
railEl.hidden = true;
|
|
86
|
+
clearBtn.hidden = true;
|
|
87
|
+
closeBtn.hidden = true;
|
|
88
|
+
});
|
|
89
|
+
barEl.append(clearBtn, closeBtn);
|
|
90
|
+
barEl.addEventListener('submit', (event) => {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
askAboutRepo();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
host.innerHTML = '';
|
|
96
|
+
host.append(scrollEl, railEl, barEl);
|
|
97
|
+
state.docs.tabsEl = tabsEl;
|
|
98
|
+
state.docs.wrapEl = wrapEl;
|
|
99
|
+
state.docs.railEl = railEl;
|
|
100
|
+
state.docs.bar = barEl;
|
|
101
|
+
state.docs.pageEl = buildOverviewPage();
|
|
102
|
+
|
|
103
|
+
renderDocStrip();
|
|
104
|
+
showActiveDoc();
|
|
105
|
+
loadReadme();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The landing tab: repo header, topic index, README, the documentation zone
|
|
109
|
+
// (filled on demand by the AI), and the question box at the bottom.
|
|
110
|
+
function buildOverviewPage() {
|
|
111
|
+
const { scan, facts, manifest } = state;
|
|
112
|
+
const root = state.treeData;
|
|
113
|
+
|
|
114
|
+
const page = document.createElement('div');
|
|
115
|
+
page.className = 'doc-page';
|
|
116
|
+
page.innerHTML = `
|
|
117
|
+
<div id="doc-top">
|
|
118
|
+
<h1 class="doc-title">${escapeHtml(scan.name)}</h1>
|
|
119
|
+
<p class="doc-sub">${escapeHtml(scan.root)}</p>
|
|
120
|
+
<p class="doc-sub">${scan.stats.filesParsed} code files · ${scan.stats.edgeCount} connections · scanned in ${scan.stats.tookMs} ms</p>
|
|
121
|
+
<p class="doc-ai-note" id="docAiNote">Pick a topic in the contents for a detailed AI document. “Generate documentation” writes the full folder-by-folder write-up below.</p>
|
|
122
|
+
<p class="doc-overview" id="docOverview">${escapeHtml(explainOverview(scan, facts, manifest).replace(/[*`]/g, ''))}</p>
|
|
123
|
+
</div>
|
|
124
|
+
<nav class="doc-toc" id="docToc"></nav>
|
|
125
|
+
<h2 class="doc-section-h doc-anchor" id="doc-readme-h">In its own words</h2>
|
|
126
|
+
<div class="doc-readme" id="docReadme"><span class="doc-readme-none">Looking for a README…</span></div>
|
|
127
|
+
<h2 class="doc-section-h doc-anchor" id="doc-layout-h">The documentation</h2>
|
|
128
|
+
<div id="docGenZone">
|
|
129
|
+
<p class="doc-gen-empty">Nothing written yet. Press the button and the AI will document this repo folder by folder, right here.</p>
|
|
130
|
+
<button class="btn btn-ink" id="docGenBtn">Generate documentation</button>
|
|
131
|
+
</div>
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
buildToc(page, root);
|
|
135
|
+
|
|
136
|
+
page.addEventListener('click', (event) => {
|
|
137
|
+
const b = event.target.closest('[data-doc-open]');
|
|
138
|
+
if (!b) return;
|
|
139
|
+
const raw = b.dataset.docOpen;
|
|
140
|
+
const i = raw.indexOf(':');
|
|
141
|
+
openDocTab(raw.slice(0, i), raw.slice(i + 1));
|
|
142
|
+
});
|
|
143
|
+
page.querySelector('#docGenBtn').addEventListener('click', writeDocsWithAI);
|
|
144
|
+
|
|
145
|
+
return page;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The contents index: page anchors up top, then every folder and file as a
|
|
149
|
+
// topic — clicking any of them opens its detailed AI tab.
|
|
150
|
+
function buildToc(page, root) {
|
|
151
|
+
const toc = page.querySelector('#docToc');
|
|
152
|
+
const bits = [
|
|
153
|
+
`<span class="doc-toc-h">Get documentation about a topic</span>`,
|
|
154
|
+
`<a href="#doc-top">Overview</a>`,
|
|
155
|
+
`<a href="#doc-readme-h">In its own words</a>`,
|
|
156
|
+
`<span class="doc-toc-group">Folders and files — click for the AI document</span>`,
|
|
157
|
+
];
|
|
158
|
+
let folderCount = 0;
|
|
159
|
+
const parsedSet = new Set(state.scan.files.map((f) => f.path));
|
|
160
|
+
const walk = (node, depth) => {
|
|
161
|
+
if (folderCount >= 30) return;
|
|
162
|
+
folderCount++;
|
|
163
|
+
const indent = Math.min(depth, 3);
|
|
164
|
+
bits.push(`<button class="doc-toc-link doc-toc-folder doc-indent-${indent}" data-doc-open="folder:${escapeHtml(node.path)}">${escapeHtml(node.path ? node.name + '/' : '(repo root)')}</button>`);
|
|
165
|
+
const files = node.files.filter((f) => parsedSet.has(f)).slice(0, 10);
|
|
166
|
+
if (files.length) {
|
|
167
|
+
const links = files
|
|
168
|
+
.map((f) => `<button class="doc-toc-link" data-doc-open="file:${escapeHtml(f)}">${escapeHtml(f.split('/').pop())}</button>`)
|
|
169
|
+
.join('<span class="doc-toc-sep"> · </span>');
|
|
170
|
+
bits.push(`<span class="doc-toc-files doc-indent-${indent}">${links}${node.files.length > files.length ? ' …' : ''}</span>`);
|
|
171
|
+
}
|
|
172
|
+
for (const child of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
173
|
+
walk(child, depth + 1);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
if (root.files.length) walk({ ...root, dirs: new Map() }, 0);
|
|
177
|
+
for (const child of [...root.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
178
|
+
walk(child, 0);
|
|
179
|
+
}
|
|
180
|
+
toc.innerHTML = bits.join('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function loadReadme() {
|
|
184
|
+
const box = host.querySelector('#docReadme');
|
|
185
|
+
if (!box) return;
|
|
186
|
+
for (const name of ['README.md', 'readme.md', 'Readme.md', 'README', 'README.txt']) {
|
|
187
|
+
try {
|
|
188
|
+
const text = await readRepoFile(name);
|
|
189
|
+
if (text) {
|
|
190
|
+
box.innerHTML = mdRender(text);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
/* try the next spelling */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
box.innerHTML = '<span class="doc-readme-none">This repo ships no README — the map and the patterns are all you get.</span>';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- the sticky ask bar: questions scoped to the doc on screen -------------
|
|
201
|
+
|
|
202
|
+
async function askAboutRepo() {
|
|
203
|
+
const input = host.querySelector('#docAskInput');
|
|
204
|
+
const question = input?.value.trim();
|
|
205
|
+
if (!question) return hooks.onToast('Type a question first.');
|
|
206
|
+
if (!llm.isConfigured()) {
|
|
207
|
+
hooks.onOpenSettings();
|
|
208
|
+
hooks.onToast('Add an endpoint and model first.');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
input.value = '';
|
|
212
|
+
const rail = state.docs.railEl;
|
|
213
|
+
rail.hidden = false;
|
|
214
|
+
// reveal the clear/close controls now that a conversation exists
|
|
215
|
+
const bar = state.docs.bar;
|
|
216
|
+
if (bar) {
|
|
217
|
+
const c = bar.querySelector('.doc-clear');
|
|
218
|
+
const x = bar.querySelector('.doc-close');
|
|
219
|
+
if (c) c.hidden = false;
|
|
220
|
+
if (x) x.hidden = false;
|
|
221
|
+
}
|
|
222
|
+
const settings = llm.getSettings();
|
|
223
|
+
|
|
224
|
+
const card = document.createElement('div');
|
|
225
|
+
card.className = 'doc-qa-card';
|
|
226
|
+
card.innerHTML = `<p class="doc-qa-q">“${escapeHtml(question)}”</p><div class="doc-qa-a"><span class="caret"></span></div>`;
|
|
227
|
+
rail.appendChild(card);
|
|
228
|
+
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
229
|
+
|
|
230
|
+
// Scope the answer to whatever is on screen: an open file/folder doc tab
|
|
231
|
+
// gets its facts (and source, for files) sent along; the overview answers
|
|
232
|
+
// from the repo-wide picture.
|
|
233
|
+
const active = state.docs.active || 'overview';
|
|
234
|
+
let file = null;
|
|
235
|
+
let scope = 'the repo overview';
|
|
236
|
+
let overview = hooks.repoOverview() + ' Top-level folders: ' + [...state.treeData.dirs.keys()].join(', ') + '.';
|
|
237
|
+
if (active.startsWith('file:')) {
|
|
238
|
+
const path = active.slice(5);
|
|
239
|
+
file = await gatherFileContext(path);
|
|
240
|
+
scope = path;
|
|
241
|
+
} else if (active.startsWith('folder:')) {
|
|
242
|
+
scope = active.slice(7) + '/';
|
|
243
|
+
overview += ` The user is currently reading the documentation for the folder "${active.slice(7)}".`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const messages = llm.questionMessages({ repoName: state.scan.name, overview, question, file });
|
|
247
|
+
let text = '';
|
|
248
|
+
try {
|
|
249
|
+
for await (const delta of streamExplain({ ...settings, messages, maxTokens: 1200 })) {
|
|
250
|
+
text += delta;
|
|
251
|
+
card.querySelector('.doc-qa-a').innerHTML = mdLite(text) + '<span class="caret"></span>';
|
|
252
|
+
}
|
|
253
|
+
card.querySelector('.doc-qa-a').innerHTML =
|
|
254
|
+
mdLite(text) + `<p class="explain-src">Answered by ${settings.model}, looking at ${escapeHtml(scope)}.</p>`;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
card.querySelector('.doc-qa-a').innerHTML =
|
|
257
|
+
`<p style="color:var(--warn)">${escapeHtml(err.message)} — check the API key drawer, top right.</p>`;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- doc tabs: detailed AI documents per file/folder -----------------------
|
|
262
|
+
|
|
263
|
+
function renderDocStrip() {
|
|
264
|
+
const strip = state.docs.tabsEl;
|
|
265
|
+
if (!strip) return;
|
|
266
|
+
strip.innerHTML = '';
|
|
267
|
+
|
|
268
|
+
const mk = (key, label, closable) => {
|
|
269
|
+
const b = document.createElement('button');
|
|
270
|
+
b.className = 'doc-tab' + (state.docs.active === key ? ' is-active' : '');
|
|
271
|
+
const entry = state.docs.tabs.get(key);
|
|
272
|
+
b.innerHTML = `<span>${escapeHtml(label)}</span>${entry?.status === 'writing' ? '<span class="doc-tab-spin">…</span>' : ''}`;
|
|
273
|
+
b.addEventListener('click', () => {
|
|
274
|
+
state.docs.active = key;
|
|
275
|
+
renderDocStrip();
|
|
276
|
+
showActiveDoc();
|
|
277
|
+
});
|
|
278
|
+
if (closable) {
|
|
279
|
+
const x = document.createElement('span');
|
|
280
|
+
x.className = 'doc-tab-x';
|
|
281
|
+
x.textContent = '×';
|
|
282
|
+
x.title = 'Close this tab';
|
|
283
|
+
x.addEventListener('click', (event) => {
|
|
284
|
+
event.stopPropagation();
|
|
285
|
+
closeDocTab(key);
|
|
286
|
+
});
|
|
287
|
+
b.appendChild(x);
|
|
288
|
+
}
|
|
289
|
+
return b;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
strip.appendChild(mk('overview', 'Overview', false));
|
|
293
|
+
for (const [key, entry] of state.docs.tabs) {
|
|
294
|
+
strip.appendChild(mk(key, entry.title, true));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function openDocTab(kind, path) {
|
|
299
|
+
const key = kind + ':' + path;
|
|
300
|
+
if (!state.docs.tabs.has(key)) {
|
|
301
|
+
const title = (path ? path.split('/').pop() : '(root)') + (kind === 'folder' ? '/' : '');
|
|
302
|
+
state.docs.tabs.set(key, { key, kind, path, title, status: 'idle', text: '', error: '' });
|
|
303
|
+
}
|
|
304
|
+
state.docs.active = key;
|
|
305
|
+
renderDocStrip();
|
|
306
|
+
showActiveDoc();
|
|
307
|
+
const entry = state.docs.tabs.get(key);
|
|
308
|
+
if (entry.status === 'idle') generateDocTab(entry);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function closeDocTab(key) {
|
|
312
|
+
state.docs.tabs.delete(key);
|
|
313
|
+
if (state.docs.active === key) state.docs.active = 'overview';
|
|
314
|
+
renderDocStrip();
|
|
315
|
+
showActiveDoc();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function showActiveDoc() {
|
|
319
|
+
const wrap = state.docs.wrapEl;
|
|
320
|
+
if (!wrap) return;
|
|
321
|
+
if (state.docs.active === 'overview') {
|
|
322
|
+
wrap.replaceChildren(state.docs.pageEl);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const entry = state.docs.tabs.get(state.docs.active);
|
|
326
|
+
if (entry) wrap.replaceChildren(buildPaneEl(entry));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildPaneEl(entry) {
|
|
330
|
+
const pane = document.createElement('div');
|
|
331
|
+
pane.className = 'doc-page doc-detail';
|
|
332
|
+
|
|
333
|
+
const chips = [];
|
|
334
|
+
if (entry.kind === 'file') {
|
|
335
|
+
const f = scanIndex(state.scan).fileAt(entry.path);
|
|
336
|
+
const fin = state.facts.fanIn[entry.path] || 0;
|
|
337
|
+
const fout = state.facts.fanOut[entry.path] || 0;
|
|
338
|
+
chips.push(`<b>${fin}</b> dependents`, `<b>${fout}</b> imports`);
|
|
339
|
+
if (f) chips.push(`<b>${f.functions.length}</b> functions`, `<b>${(f.size / 1024).toFixed(1)}</b> kb`);
|
|
340
|
+
} else {
|
|
341
|
+
const node = findNode(state.treeData, entry.path);
|
|
342
|
+
if (node) chips.push(`<b>${node.files.length}</b> files`, `<b>${node.dirs.size}</b> subfolders`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
pane.innerHTML = `
|
|
346
|
+
<h1 class="doc-title">${escapeHtml(entry.title)}</h1>
|
|
347
|
+
<p class="doc-sub">${escapeHtml(entry.path || '(repo root)')}</p>
|
|
348
|
+
<p class="doc-sub doc-detail-meta">${chips.join(' · ')}</p>
|
|
349
|
+
<div class="doc-detail-actions">
|
|
350
|
+
${entry.kind === 'file' ? '<button class="linklike" data-doc-dd>Open the diagram deep-dive →</button>' : ''}
|
|
351
|
+
<button class="linklike" data-doc-regen>Regenerate</button>
|
|
352
|
+
</div>
|
|
353
|
+
<div class="doc-detail-body"></div>
|
|
354
|
+
`;
|
|
355
|
+
|
|
356
|
+
const body = pane.querySelector('.doc-detail-body');
|
|
357
|
+
if (entry.status === 'error') {
|
|
358
|
+
body.innerHTML = `<p style="color:var(--warn)">${escapeHtml(entry.error)} — hit Regenerate to try again.</p>`;
|
|
359
|
+
} else if (entry.status === 'idle' || (entry.status === 'writing' && !entry.text)) {
|
|
360
|
+
// The static write-up is the floor: something true is on screen before the
|
|
361
|
+
// model answers, and stays there if it never does.
|
|
362
|
+
const fallback = entry.kind === 'file'
|
|
363
|
+
? staticDoc(entry.path)
|
|
364
|
+
: folderStaticDoc(entry.path, state.scan, state.facts, findNode(state.treeData, entry.path)?.dirs.size || 0);
|
|
365
|
+
body.innerHTML = `<p>${escapeHtml(fallback)}</p>`
|
|
366
|
+
+ '<p class="doc-readme-none">Gathering facts and writing<span class="caret"></span></p>';
|
|
367
|
+
} else {
|
|
368
|
+
body.innerHTML = mdRender(entry.text) + (entry.status === 'writing' ? '<span class="caret"></span>' : '');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
pane.addEventListener('click', (event) => {
|
|
372
|
+
if (event.target.closest('[data-doc-dd]')) hooks.onOpenFile(entry.path);
|
|
373
|
+
if (event.target.closest('[data-doc-regen]')) {
|
|
374
|
+
entry.status = 'idle';
|
|
375
|
+
entry.text = '';
|
|
376
|
+
generateDocTab(entry);
|
|
377
|
+
showActiveDoc();
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
return pane;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let docRefreshTimer = null;
|
|
384
|
+
function scheduleDocPaneRefresh(entry) {
|
|
385
|
+
if (state.docs.active !== entry.key || docRefreshTimer) return;
|
|
386
|
+
docRefreshTimer = setTimeout(() => {
|
|
387
|
+
docRefreshTimer = null;
|
|
388
|
+
if (state.docs.active === entry.key) showActiveDoc();
|
|
389
|
+
}, 160);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function generateDocTab(entry) {
|
|
393
|
+
if (!llm.isConfigured()) {
|
|
394
|
+
entry.status = 'error';
|
|
395
|
+
entry.error = 'No endpoint configured — open the API key drawer, top right.';
|
|
396
|
+
showActiveDoc();
|
|
397
|
+
renderDocStrip();
|
|
398
|
+
hooks.onOpenSettings();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const settings = llm.getSettings();
|
|
402
|
+
entry.status = 'writing';
|
|
403
|
+
entry.text = '';
|
|
404
|
+
entry.error = '';
|
|
405
|
+
renderDocStrip();
|
|
406
|
+
try {
|
|
407
|
+
let messages;
|
|
408
|
+
if (entry.kind === 'file') {
|
|
409
|
+
const ctx = await gatherFileContext(entry.path);
|
|
410
|
+
messages = llm.fileDocMessages({ repoName: state.scan.name, overview: hooks.repoOverview(), file: ctx });
|
|
411
|
+
} else {
|
|
412
|
+
const node = findNode(state.treeData, entry.path);
|
|
413
|
+
messages = llm.folderDocDetailedMessages({
|
|
414
|
+
repoName: state.scan.name,
|
|
415
|
+
folder: entry.path,
|
|
416
|
+
subfolders: node ? [...node.dirs.keys()].map((n) => n + '/') : [],
|
|
417
|
+
files: (node?.files || []).slice(0, 18).map(fileRow),
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
for await (const delta of streamExplain({ ...settings, messages, maxTokens: 1800 })) {
|
|
421
|
+
entry.text += delta;
|
|
422
|
+
scheduleDocPaneRefresh(entry);
|
|
423
|
+
}
|
|
424
|
+
entry.status = 'done';
|
|
425
|
+
} catch (err) {
|
|
426
|
+
entry.status = 'error';
|
|
427
|
+
entry.error = err.message;
|
|
428
|
+
}
|
|
429
|
+
renderDocStrip();
|
|
430
|
+
if (state.docs.active === entry.key) showActiveDoc();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---- the folder-by-folder pass --------------------------------------------
|
|
434
|
+
|
|
435
|
+
// Which folders get an AI brief, and with what file facts. Top-level folders
|
|
436
|
+
// by size, capped — one call briefs a folder and everything directly in it.
|
|
437
|
+
function docTargets() {
|
|
438
|
+
const root = state.treeData;
|
|
439
|
+
const targets = [];
|
|
440
|
+
if (root.files.length) {
|
|
441
|
+
targets.push({
|
|
442
|
+
path: '',
|
|
443
|
+
label: '(repo root)',
|
|
444
|
+
subfolders: [...root.dirs.keys()].map((n) => n + '/'),
|
|
445
|
+
files: root.files.slice(0, 18).map(fileRow),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const topDirs = [...root.dirs.values()]
|
|
449
|
+
.map((node) => ({ node, count: countFiles(node) }))
|
|
450
|
+
.sort((a, b) => b.count - a.count)
|
|
451
|
+
.slice(0, 8);
|
|
452
|
+
for (const { node } of topDirs) {
|
|
453
|
+
targets.push({
|
|
454
|
+
path: node.path,
|
|
455
|
+
label: node.path + '/',
|
|
456
|
+
subfolders: [...node.dirs.keys()].map((n) => n + '/'),
|
|
457
|
+
files: node.files.slice(0, 18).map(fileRow),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return targets;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function writeDocsWithAI() {
|
|
464
|
+
if (state.docs.writing) return;
|
|
465
|
+
if (!llm.isConfigured()) {
|
|
466
|
+
hooks.onOpenSettings();
|
|
467
|
+
hooks.onToast('Add an endpoint and model first.');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const settings = llm.getSettings();
|
|
471
|
+
const page = state.docs.pageEl;
|
|
472
|
+
const zone = page?.querySelector('#docGenZone');
|
|
473
|
+
const btn = page?.querySelector('#docGenBtn');
|
|
474
|
+
const note = page?.querySelector('#docAiNote');
|
|
475
|
+
const overviewEl = page?.querySelector('#docOverview');
|
|
476
|
+
if (!zone) return;
|
|
477
|
+
|
|
478
|
+
state.docs.writing = true;
|
|
479
|
+
if (btn) {
|
|
480
|
+
btn.disabled = true;
|
|
481
|
+
btn.textContent = 'writing…';
|
|
482
|
+
}
|
|
483
|
+
zone.querySelectorAll('.doc-generated').forEach((el) => el.remove()); // regenerate = fresh page
|
|
484
|
+
const empty = zone.querySelector('.doc-gen-empty');
|
|
485
|
+
if (empty) empty.hidden = true;
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
// 1. The repo-level overview, streamed in place.
|
|
489
|
+
if (note) note.textContent = 'Writing the overview…';
|
|
490
|
+
const readmeText = host.querySelector('#docReadme')?.innerText?.slice(0, 1500) || '';
|
|
491
|
+
let out = '';
|
|
492
|
+
if (overviewEl) overviewEl.innerHTML = '<span class="caret"></span>';
|
|
493
|
+
const overviewMsgs = llm.repoDocMessages({
|
|
494
|
+
repoName: state.scan.name,
|
|
495
|
+
overview: hooks.repoOverview(),
|
|
496
|
+
readmeExcerpt: readmeText,
|
|
497
|
+
entries: state.facts.entries,
|
|
498
|
+
hubs: state.facts.hubs,
|
|
499
|
+
});
|
|
500
|
+
for await (const delta of streamExplain({ ...settings, messages: overviewMsgs, maxTokens: 900 })) {
|
|
501
|
+
out += delta;
|
|
502
|
+
if (overviewEl) overviewEl.textContent = out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// 2. Folder by folder: each section appears with static sentences first,
|
|
506
|
+
// then the AI's documentation overwrites it as the answer lands.
|
|
507
|
+
const targets = docTargets();
|
|
508
|
+
let done = 0;
|
|
509
|
+
for (const t of targets) {
|
|
510
|
+
done++;
|
|
511
|
+
if (note) note.textContent = `Writing documentation… ${done} of ${targets.length} — ${t.label}`;
|
|
512
|
+
|
|
513
|
+
const node = findNode(state.treeData, t.path);
|
|
514
|
+
const section = document.createElement('section');
|
|
515
|
+
section.className = 'doc-folder doc-generated';
|
|
516
|
+
section.innerHTML = `
|
|
517
|
+
<h3 class="doc-folder-h"><button data-doc-open="folder:${escapeHtml(t.path)}">${escapeHtml(t.label)}</button></h3>
|
|
518
|
+
<p class="doc-folder-brief"><span class="caret"></span></p>
|
|
519
|
+
${t.files.map((f) => `
|
|
520
|
+
<div class="doc-entry${f.parsed ? '' : ' is-unparsed'}">
|
|
521
|
+
<div class="doc-entry-head">
|
|
522
|
+
${f.parsed ? `<button class="doc-entry-name" data-doc-open="file:${escapeHtml(f.path)}">${escapeHtml(f.name)}</button>` : `<span class="doc-entry-name">${escapeHtml(f.name)}</span>`}
|
|
523
|
+
<span class="doc-entry-facts">${escapeHtml(factsLine(f.path))}</span>
|
|
524
|
+
</div>
|
|
525
|
+
<p class="doc-entry-text" data-doc-file="${escapeHtml(f.path)}">${escapeHtml(staticDoc(f.path))}</p>
|
|
526
|
+
</div>`).join('')}
|
|
527
|
+
`;
|
|
528
|
+
zone.appendChild(section);
|
|
529
|
+
section.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
530
|
+
|
|
531
|
+
let text = '';
|
|
532
|
+
const msgs = llm.folderDocMessages({
|
|
533
|
+
repoName: state.scan.name,
|
|
534
|
+
folder: t.path,
|
|
535
|
+
subfolders: t.subfolders,
|
|
536
|
+
files: t.files,
|
|
537
|
+
});
|
|
538
|
+
for await (const delta of streamExplain({ ...settings, messages: msgs, maxTokens: 1600 })) {
|
|
539
|
+
text += delta;
|
|
540
|
+
}
|
|
541
|
+
const parsed = parseFolderDoc(text);
|
|
542
|
+
const folderP = section.querySelector('.doc-folder-brief');
|
|
543
|
+
if (folderP) {
|
|
544
|
+
folderP.textContent = parsed.folderBrief
|
|
545
|
+
|| folderStaticDoc(t.path, state.scan, state.facts, node?.dirs.size || 0);
|
|
546
|
+
}
|
|
547
|
+
for (const [name, brief] of parsed.files) {
|
|
548
|
+
const row = t.files.find((f) => f.name === name);
|
|
549
|
+
if (!row) continue;
|
|
550
|
+
const el = section.querySelector(`[data-doc-file="${CSS.escape(row.path)}"]`);
|
|
551
|
+
if (el) el.textContent = brief;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (note) note.textContent = `Written by ${settings.model} from the real import graph. Press “Regenerate documentation” for a fresh take.`;
|
|
555
|
+
if (btn) btn.textContent = 'Regenerate documentation';
|
|
556
|
+
} catch (err) {
|
|
557
|
+
if (note) note.textContent = 'AI writing stopped: ' + err.message;
|
|
558
|
+
hooks.onToast(err.message);
|
|
559
|
+
} finally {
|
|
560
|
+
state.docs.writing = false;
|
|
561
|
+
if (btn) {
|
|
562
|
+
btn.disabled = false;
|
|
563
|
+
if (btn.textContent === 'writing…') btn.textContent = 'Generate documentation';
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// FileSource over the File System Access API. The directory handle stays
|
|
2
|
+
// alive after the scan so the AI-explain feature can re-read individual
|
|
3
|
+
// files on demand. Chromium only — the caller feature-detects first.
|
|
4
|
+
|
|
5
|
+
export function canPickFolder() {
|
|
6
|
+
return typeof window.showDirectoryPicker === 'function';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function pickDirectory() {
|
|
10
|
+
return window.showDirectoryPicker({ mode: 'read' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function browserFileSource(rootHandle) {
|
|
14
|
+
const handles = new Map([['', rootHandle]]);
|
|
15
|
+
|
|
16
|
+
const source = {
|
|
17
|
+
kind: 'browser',
|
|
18
|
+
root: rootHandle.name,
|
|
19
|
+
name: rootHandle.name,
|
|
20
|
+
|
|
21
|
+
async list(dir) {
|
|
22
|
+
const handle = handles.get(dir);
|
|
23
|
+
if (!handle) return [];
|
|
24
|
+
const out = [];
|
|
25
|
+
try {
|
|
26
|
+
for await (const [name, child] of handle.entries()) {
|
|
27
|
+
const path = dir ? dir + '/' + name : name;
|
|
28
|
+
handles.set(path, child);
|
|
29
|
+
out.push({ name, path, type: child.kind === 'directory' ? 'dir' : 'file' });
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
/* unreadable directory (permissions) — treat as empty */
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async read(path) {
|
|
38
|
+
const handle = handles.get(path);
|
|
39
|
+
if (!handle) throw new Error('No handle for ' + path);
|
|
40
|
+
const file = await handle.getFile();
|
|
41
|
+
if (file.size > 400 * 1024) throw new Error('File too large to read');
|
|
42
|
+
return file.text();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return source;
|
|
47
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export function initFlameGraph(canvas, options) {
|
|
2
|
+
const { layers = [], files = [], facts = {} } = options || {};
|
|
3
|
+
const ctx = canvas.getContext('2d');
|
|
4
|
+
let width = canvas.width;
|
|
5
|
+
let height = canvas.height;
|
|
6
|
+
|
|
7
|
+
function resize() {
|
|
8
|
+
const rect = canvas.parentElement.getBoundingClientRect();
|
|
9
|
+
canvas.width = rect.width;
|
|
10
|
+
canvas.height = rect.height;
|
|
11
|
+
width = canvas.width;
|
|
12
|
+
height = canvas.height;
|
|
13
|
+
render();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getRiskColor(risk) {
|
|
17
|
+
if (risk <= 33) return 'rgb(76, 175, 80)';
|
|
18
|
+
if (risk <= 66) return 'rgb(255, 193, 7)';
|
|
19
|
+
return 'rgb(244, 67, 54)';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let rects = [];
|
|
23
|
+
let rootPath = null;
|
|
24
|
+
let breadcrumb = [];
|
|
25
|
+
|
|
26
|
+
function update() {
|
|
27
|
+
render();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function render() {
|
|
31
|
+
ctx.clearRect(0, 0, width, height);
|
|
32
|
+
if (!layers || layers.length === 0) return;
|
|
33
|
+
|
|
34
|
+
rects = [];
|
|
35
|
+
const layerH = 30;
|
|
36
|
+
const pad = 2;
|
|
37
|
+
|
|
38
|
+
// A real flame graph needs hierarchical data. If layers is just a list of arrays,
|
|
39
|
+
// we'll lay them out horizontally per layer for now.
|
|
40
|
+
|
|
41
|
+
let y = height - layerH;
|
|
42
|
+
|
|
43
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
44
|
+
const layer = layers[i];
|
|
45
|
+
let x = 0;
|
|
46
|
+
let totalWeight = layer.length; // simplified
|
|
47
|
+
|
|
48
|
+
for (const item of layer) {
|
|
49
|
+
const w = (1 / totalWeight) * width;
|
|
50
|
+
const risk = 0; // Find risk from facts if available
|
|
51
|
+
|
|
52
|
+
ctx.fillStyle = getRiskColor(risk);
|
|
53
|
+
ctx.fillRect(x + pad, y + pad, w - pad*2, layerH - pad*2);
|
|
54
|
+
|
|
55
|
+
ctx.fillStyle = '#fff';
|
|
56
|
+
ctx.font = '10px sans-serif';
|
|
57
|
+
const txt = String(item).split('/').pop();
|
|
58
|
+
if (w > 20) ctx.fillText(txt, x + pad + 2, y + 18);
|
|
59
|
+
|
|
60
|
+
rects.push({ x, y, w, h: layerH, path: item });
|
|
61
|
+
x += w;
|
|
62
|
+
}
|
|
63
|
+
y -= layerH;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
window.addEventListener('resize', resize);
|
|
68
|
+
resize();
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
update,
|
|
72
|
+
resize,
|
|
73
|
+
destroy: () => window.removeEventListener('resize', resize)
|
|
74
|
+
};
|
|
75
|
+
}
|