codex-context-map 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 +124 -0
- package/README.zh-CN.md +108 -0
- package/bin/codex-context-map.js +148 -0
- package/package.json +52 -0
- package/site/app.js +581 -0
- package/site/style.css +1226 -0
- package/site/template.html +314 -0
- package/src/core.js +314 -0
- package/src/demo.js +60 -0
- package/src/report.js +25 -0
- package/src/scan.js +170 -0
package/site/app.js
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/* All workspace processing happens in this page. No fetch, telemetry or storage. */
|
|
2
|
+
const $ = (id) => document.getElementById(id);
|
|
3
|
+
const esc = (value) =>
|
|
4
|
+
String(value).replace(
|
|
5
|
+
/[&<>"']/g,
|
|
6
|
+
(c) =>
|
|
7
|
+
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
|
|
8
|
+
c
|
|
9
|
+
],
|
|
10
|
+
);
|
|
11
|
+
let snapshot = validateSnapshot(initialData.snapshot);
|
|
12
|
+
let isDemo = initialData.demo;
|
|
13
|
+
let target = snapshot.defaultTarget;
|
|
14
|
+
let budget = snapshot.settings.maxBytes;
|
|
15
|
+
let fallbackNames = [...snapshot.settings.fallbackNames];
|
|
16
|
+
let compareTarget =
|
|
17
|
+
snapshot.directories.find((d) => d === "apps/web") ??
|
|
18
|
+
snapshot.directories.find((d) => d !== target) ??
|
|
19
|
+
target;
|
|
20
|
+
let view = "map";
|
|
21
|
+
let language = "en";
|
|
22
|
+
let result;
|
|
23
|
+
const english = Object.fromEntries(
|
|
24
|
+
[...document.querySelectorAll("[data-i18n]")].map((el) => [
|
|
25
|
+
el.dataset.i18n,
|
|
26
|
+
el.innerHTML,
|
|
27
|
+
]),
|
|
28
|
+
);
|
|
29
|
+
const chinese = {
|
|
30
|
+
local: "在你的设备上运行",
|
|
31
|
+
eyebrow: "看清 AGENTS.md 的作用范围",
|
|
32
|
+
headline: "看清你的 Agent 读到了什么<span>。</span>",
|
|
33
|
+
subhead: "追踪指令来源,找出覆盖关系,定位截断位置。",
|
|
34
|
+
quickstart: "查看你自己的项目",
|
|
35
|
+
quickhint: "无需账号,无需 API Key,零运行时依赖。",
|
|
36
|
+
demo: "演示",
|
|
37
|
+
importJson: "打开快照",
|
|
38
|
+
export: "导出 JSON",
|
|
39
|
+
importFolder: "打开项目文件夹",
|
|
40
|
+
directories: "项目目录",
|
|
41
|
+
hasRules: "有指令文件",
|
|
42
|
+
hasOverride: "有覆盖文件",
|
|
43
|
+
inherits: "没有本地指令",
|
|
44
|
+
privacyTitle: "文件留在当前设备。",
|
|
45
|
+
privacy: "仅读取指令文件,不上传,也不写入浏览器存储。",
|
|
46
|
+
launchFrom: "模拟从此目录启动 CODEX",
|
|
47
|
+
map: "指令地图",
|
|
48
|
+
compare: "对比目录",
|
|
49
|
+
try: "试试这些场景",
|
|
50
|
+
scenarioOverride: "01 · 隐藏的覆盖",
|
|
51
|
+
scenarioBudget: "02 · 超出长度预算",
|
|
52
|
+
scenarioEmpty: "03 · 空覆盖文件",
|
|
53
|
+
loaded: "贡献内容的文件",
|
|
54
|
+
loadedHint: "按发现顺序排列",
|
|
55
|
+
shadowed: "被遮盖的文件",
|
|
56
|
+
shadowedHint: "同目录中的低优先级文件",
|
|
57
|
+
budgetLabel: "项目指令字节预算",
|
|
58
|
+
limit: "字节上限",
|
|
59
|
+
defaultLimit: "恢复 32 KiB",
|
|
60
|
+
fallbackSettings: "备用文件名",
|
|
61
|
+
fallbackLabel: "按优先级排列,用逗号分隔",
|
|
62
|
+
apply: "应用",
|
|
63
|
+
fallbackHint: "先设置文件名,再打开文件夹。添加新文件名后需重新导入。",
|
|
64
|
+
budgetHint: "按内容字节计算 · 全局指令单独统计",
|
|
65
|
+
chain: "指令加载链",
|
|
66
|
+
chainHint: "较大范围 → 当前目录",
|
|
67
|
+
compareTo: "对比另一目录",
|
|
68
|
+
preview: "合并后的指令预览",
|
|
69
|
+
copy: "复制文本",
|
|
70
|
+
readOnly: "只读",
|
|
71
|
+
boundaries: "这张地图的适用范围",
|
|
72
|
+
boundaryText:
|
|
73
|
+
"这是对单个可信项目的静态发现模拟。它不读取运行中的会话,不判断自然语言冲突,也不包含系统提示、技能、MCP、任务及账号指令或额外环境。请使预算、备用文件名和根目录与实际配置一致。更深目录的文件追加到加载链中,不会删除此前文件。",
|
|
74
|
+
boundaryImport:
|
|
75
|
+
"文件夹导入将所选目录作为显式项目根目录。命令行检测最近的 .git 标记,包括嵌套仓库,并跳过符号链接、给出提示。浏览器文件夹选择器不提供符号链接元数据。全局文件仅由命令行在使用 --global 时读取。",
|
|
76
|
+
semanticsLink: "查看有版本依据的解析规则与限制 ↗",
|
|
77
|
+
independent: "独立项目,与 OpenAI 无隶属关系。",
|
|
78
|
+
feedback: "发现行为不一致?反馈问题 ↗",
|
|
79
|
+
};
|
|
80
|
+
const say = (en, zh) => (language === "zh" ? zh : en);
|
|
81
|
+
const fmt = (value) =>
|
|
82
|
+
value.toLocaleString(language === "zh" ? "zh-CN" : "en-US");
|
|
83
|
+
const statusLabels = {
|
|
84
|
+
loaded: ["Loaded", "已加载"],
|
|
85
|
+
shadowed: ["Shadowed", "被遮盖"],
|
|
86
|
+
partial: ["Cut off", "已截断"],
|
|
87
|
+
excluded: ["No budget", "预算耗尽"],
|
|
88
|
+
empty: ["Empty", "空文件"],
|
|
89
|
+
outside: ["Other directory", "范围以外"],
|
|
90
|
+
unrecognized: ["Not configured", "未配置"],
|
|
91
|
+
};
|
|
92
|
+
const label = (status) =>
|
|
93
|
+
statusLabels[status]?.[language === "zh" ? 1 : 0] ?? status;
|
|
94
|
+
function announce(message, error = false) {
|
|
95
|
+
$("status").textContent = message;
|
|
96
|
+
$("status").className = `status-message visible${error ? " error" : ""}`;
|
|
97
|
+
}
|
|
98
|
+
function applyLanguage() {
|
|
99
|
+
document.documentElement.lang = language === "zh" ? "zh-CN" : "en";
|
|
100
|
+
for (const el of document.querySelectorAll("[data-i18n]"))
|
|
101
|
+
el.innerHTML =
|
|
102
|
+
language === "zh"
|
|
103
|
+
? (chinese[el.dataset.i18n] ?? english[el.dataset.i18n])
|
|
104
|
+
: english[el.dataset.i18n];
|
|
105
|
+
$("language").textContent = language === "zh" ? "EN" : "中文";
|
|
106
|
+
$("search").placeholder = say("Find a directory…", "搜索目录…");
|
|
107
|
+
$("search").setAttribute("aria-label", say("Find a directory", "搜索目录"));
|
|
108
|
+
$("budget").setAttribute(
|
|
109
|
+
"aria-label",
|
|
110
|
+
say("Project byte limit", "项目字节上限"),
|
|
111
|
+
);
|
|
112
|
+
$("compare-target").setAttribute(
|
|
113
|
+
"aria-label",
|
|
114
|
+
say("Comparison directory", "对比目录"),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
function renderTree() {
|
|
118
|
+
const query = $("search").value.trim().toLowerCase();
|
|
119
|
+
const dirs = snapshot.directories.filter((d) =>
|
|
120
|
+
d.toLowerCase().includes(query),
|
|
121
|
+
);
|
|
122
|
+
const shown = dirs.slice(0, 200);
|
|
123
|
+
$("dir-count").textContent = snapshot.directories.length;
|
|
124
|
+
$("tree").innerHTML =
|
|
125
|
+
shown
|
|
126
|
+
.map((d) => {
|
|
127
|
+
const local = snapshot.files.filter(
|
|
128
|
+
(f) => f.scope === "project" && parentPath(f.path) === d,
|
|
129
|
+
);
|
|
130
|
+
const color = local.some(
|
|
131
|
+
(f) => baseName(f.path) === "AGENTS.override.md",
|
|
132
|
+
)
|
|
133
|
+
? "amber"
|
|
134
|
+
: local.length
|
|
135
|
+
? "teal"
|
|
136
|
+
: "gray";
|
|
137
|
+
const depth = d === "." ? 0 : d.split("/").length;
|
|
138
|
+
return `<button class="tree-button${d === target ? " active" : ""}" data-target="${esc(d)}" title="${esc(d)}" aria-label="${esc(d)}"${d === target ? ' aria-current="location"' : ""} style="--indent:${10 + Math.min(depth, 8) * 12}px"><span class="dir-icon" aria-hidden="true">${d === "." ? "▦" : "▱"}</span><span class="dir-name">${esc(d === "." ? snapshot.projectName : baseName(d))}</span><i class="dot ${color}" aria-hidden="true"></i></button>`;
|
|
139
|
+
})
|
|
140
|
+
.join("") ||
|
|
141
|
+
`<p class="small-note">${say("No matching directories.", "没有匹配目录。")}</p>`;
|
|
142
|
+
$("tree-limit").textContent =
|
|
143
|
+
dirs.length > 200
|
|
144
|
+
? say(
|
|
145
|
+
`Showing 200 of ${dirs.length}. Search to narrow the list.`,
|
|
146
|
+
`显示 ${dirs.length} 个目录中的前 200 个,请搜索定位。`,
|
|
147
|
+
)
|
|
148
|
+
: "";
|
|
149
|
+
}
|
|
150
|
+
function fileReason(file) {
|
|
151
|
+
if (file.status === "empty" && file.truncated)
|
|
152
|
+
return say(
|
|
153
|
+
"Only a whitespace prefix fits. Later content is cut off; this file adds no text or budget usage.",
|
|
154
|
+
"只读到了空白前缀,后续内容已被截去。此文件不贡献文本,也不消耗预算。",
|
|
155
|
+
);
|
|
156
|
+
if (file.status === "partial")
|
|
157
|
+
return say(
|
|
158
|
+
`${fmt(file.bytes - file.usedBytes)} bytes lost at this file. Later files have no remaining budget.`,
|
|
159
|
+
`此文件截去了 ${fmt(file.bytes - file.usedBytes)} 字节,后续文件没有剩余预算。`,
|
|
160
|
+
);
|
|
161
|
+
if (file.status === "excluded")
|
|
162
|
+
return say(
|
|
163
|
+
"An earlier file consumed the project budget. This file contributes no text.",
|
|
164
|
+
"此前的文件已耗尽项目预算,此文件没有贡献文本。",
|
|
165
|
+
);
|
|
166
|
+
if (file.status === "empty")
|
|
167
|
+
return say(
|
|
168
|
+
"The first existing filename wins here, even when its content is empty.",
|
|
169
|
+
"这里先选取存在的最高优先级文件,即使其内容为空。",
|
|
170
|
+
);
|
|
171
|
+
if (file.scope === "global")
|
|
172
|
+
return say(
|
|
173
|
+
"Global guidance, outside the project byte budget.",
|
|
174
|
+
"全局指令,不占用项目字节预算。",
|
|
175
|
+
);
|
|
176
|
+
const lower = result.records.filter((f) => f.shadowedBy === file.id);
|
|
177
|
+
if (lower.length)
|
|
178
|
+
return say(
|
|
179
|
+
`Selected before ${lower.map((f) => baseName(f.path)).join(", ")} in this directory.`,
|
|
180
|
+
`在此目录中优先于 ${lower.map((f) => baseName(f.path)).join("、")}。`,
|
|
181
|
+
);
|
|
182
|
+
return file.directory === result.chain[0]
|
|
183
|
+
? say(
|
|
184
|
+
"Project boundary. Broader guidance enters first.",
|
|
185
|
+
"项目边界,较大范围的指令先加载。",
|
|
186
|
+
)
|
|
187
|
+
: say(
|
|
188
|
+
"Added after parent guidance. Parent files remain in the chain.",
|
|
189
|
+
"在上级指令之后追加,上级文件仍在加载链中。",
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
function renderChain() {
|
|
193
|
+
$("chain").innerHTML =
|
|
194
|
+
result.selected
|
|
195
|
+
.map(
|
|
196
|
+
(f, i) =>
|
|
197
|
+
`<article class="chain-entry"><span class="chain-number${f.status !== "loaded" ? " warn" : ""}">${String(i + 1).padStart(2, "0")}</span><div class="file-card"><div class="file-top"><span class="file-icon" aria-hidden="true">≡</span><span class="file-name">${esc((f.scope === "global" ? "$CODEX_HOME/" : "") + f.path)}</span><span class="chip ${f.status}">${label(f.status)}</span><span class="file-bytes">${fmt(f.usedBytes)} / ${fmt(f.bytes)} B</span></div><div class="file-summary">${esc(fileReason(f))}</div><details class="file-source"><summary>${say("Inspect original file", "查看原始文件")}</summary><pre>${esc(f.content || say("(empty file)", "(空文件)"))}</pre></details></div></article>`,
|
|
198
|
+
)
|
|
199
|
+
.join("") ||
|
|
200
|
+
`<div class="empty-state">${say("No instruction file is discovered from this directory. Add an AGENTS.md at the project root or in this directory, then import again.", "此目录未发现指令文件。在项目根目录或此目录添加 AGENTS.md,再重新导入。")}</div>`;
|
|
201
|
+
const ignored = result.records.filter((f) =>
|
|
202
|
+
["shadowed", "outside", "unrecognized"].includes(f.status),
|
|
203
|
+
);
|
|
204
|
+
$("ignored-panel").hidden = !ignored.length;
|
|
205
|
+
$("ignored-title").textContent = say(
|
|
206
|
+
`${ignored.length} other files · shadowed or outside this chain`,
|
|
207
|
+
`另外 ${ignored.length} 个文件:被遮盖或不在当前加载链内`,
|
|
208
|
+
);
|
|
209
|
+
$("ignored").innerHTML = ignored
|
|
210
|
+
.map(
|
|
211
|
+
(f) =>
|
|
212
|
+
`<div class="ignored-item"><span>${esc((f.scope === "global" ? "$CODEX_HOME/" : "") + f.path)}</span><span>${esc(label(f.status))}${f.shadowedBy ? ` · ${esc(f.shadowedBy.replace(/^(project|global):/, ""))}` : ""}</span></div>`,
|
|
213
|
+
)
|
|
214
|
+
.join("");
|
|
215
|
+
}
|
|
216
|
+
function renderFindings() {
|
|
217
|
+
const html = [];
|
|
218
|
+
if (result.hasCutoff)
|
|
219
|
+
html.push(
|
|
220
|
+
`<div class="finding"><span aria-hidden="true">⚑</span><div><b>${say("Some instructions never make it into the preview.", "部分指令未进入预览。")}</b> ${say("Increase the byte limit to see the missing content, then check your Codex configuration.", "提高字节上限可查看缺失内容,再核对实际 Codex 配置。")}</div></div>`,
|
|
221
|
+
);
|
|
222
|
+
for (const finding of result.findings.filter(
|
|
223
|
+
(f) => f.type === "empty-override",
|
|
224
|
+
))
|
|
225
|
+
html.push(
|
|
226
|
+
`<div class="finding"><span aria-hidden="true">⚑</span><div><b>${say("An empty override still shadows its sibling.", "空覆盖文件仍会遮盖同目录文件。")}</b> <code>${esc(finding.path)}</code> ${say("is selected before the regular file in the pinned Codex source model.", "在所依据的 Codex 源码模型中被优先选中。")}</div></div>`,
|
|
227
|
+
);
|
|
228
|
+
if (!snapshot.projectRoots.some((p) => result.chain.includes(p)))
|
|
229
|
+
html.push(
|
|
230
|
+
`<div class="finding info">${say("No project marker: only the launch directory is searched.", "没有项目标记:只检查启动目录。")}</div>`,
|
|
231
|
+
);
|
|
232
|
+
for (const warning of snapshot.warnings)
|
|
233
|
+
html.push(`<div class="finding info">${esc(warning)}</div>`);
|
|
234
|
+
$("findings").innerHTML = html.join("");
|
|
235
|
+
}
|
|
236
|
+
function renderComparison() {
|
|
237
|
+
const other = resolveContext(snapshot, compareTarget, {
|
|
238
|
+
maxBytes: budget,
|
|
239
|
+
fallbackNames,
|
|
240
|
+
});
|
|
241
|
+
const diff = compareContexts(result, other);
|
|
242
|
+
const titles = {
|
|
243
|
+
added: say("Added in comparison directory", "对比目录新增的指令"),
|
|
244
|
+
removed: say("No longer included", "对比目录不再包含的指令"),
|
|
245
|
+
shared: say("Shared instructions", "共同包含的指令"),
|
|
246
|
+
changed: say("Same source, different cutoff", "相同来源,不同截断位置"),
|
|
247
|
+
};
|
|
248
|
+
$("comparison").innerHTML = `<div class="compare-grid">${Object.entries(
|
|
249
|
+
titles,
|
|
250
|
+
)
|
|
251
|
+
.map(
|
|
252
|
+
([key, title]) =>
|
|
253
|
+
`<article class="compare-card ${key}"><h4>${esc(title)} <span class="tag">${diff[key].length}</span></h4><ul>${diff[key].map((p) => `<li>${key === "added" ? "+ " : key === "removed" ? "− " : ""}${esc(p)}</li>`).join("") || `<li>${say("None", "无")}</li>`}</ul></article>`,
|
|
254
|
+
)
|
|
255
|
+
.join(
|
|
256
|
+
"",
|
|
257
|
+
)}</div><p class="compare-delta">${say("Project content change", "项目指令字节变化")}: ${diff.byteDelta >= 0 ? "+" : ""}${fmt(diff.byteDelta)} B · ${say("Both directories use the same settings.", "两个目录使用相同设置。")}</p>`;
|
|
258
|
+
}
|
|
259
|
+
function renderPreview() {
|
|
260
|
+
const lines = result.text.split("\n");
|
|
261
|
+
$("preview-content").innerHTML = result.text
|
|
262
|
+
? lines
|
|
263
|
+
.slice(0, 500)
|
|
264
|
+
.map(
|
|
265
|
+
(line, i) =>
|
|
266
|
+
`<div class="code-line${/^#{1,6} /.test(line) ? " heading" : ""}"><span class="line-number" aria-hidden="true">${i + 1}</span><span class="line-text">${esc(line) || " "}</span></div>`,
|
|
267
|
+
)
|
|
268
|
+
.join("")
|
|
269
|
+
: `<div class="empty-state">${say("No instruction text is included.", "没有包含任何指令文本。")}</div>`;
|
|
270
|
+
$("preview-footnote").textContent =
|
|
271
|
+
(lines.length > 500
|
|
272
|
+
? say(
|
|
273
|
+
"First 500 lines shown · Copy includes all text · ",
|
|
274
|
+
"显示前 500 行 · 复制包含全文 · ",
|
|
275
|
+
)
|
|
276
|
+
: "") +
|
|
277
|
+
say(
|
|
278
|
+
`Global: ${result.globalBytes ? `${fmt(result.globalBytes)} B` : "not included"}`,
|
|
279
|
+
`全局指令:${result.globalBytes ? `${fmt(result.globalBytes)} 字节` : "未包含"}`,
|
|
280
|
+
);
|
|
281
|
+
$("copy-preview").disabled = !result.text;
|
|
282
|
+
}
|
|
283
|
+
function render() {
|
|
284
|
+
result = resolveContext(snapshot, target, {
|
|
285
|
+
maxBytes: budget,
|
|
286
|
+
fallbackNames,
|
|
287
|
+
});
|
|
288
|
+
$("project-name").textContent = snapshot.projectName;
|
|
289
|
+
$("workspace-kind").textContent = isDemo
|
|
290
|
+
? say("DEMO WORKSPACE", "演示项目")
|
|
291
|
+
: say("LOCAL SNAPSHOT", "本地快照");
|
|
292
|
+
$("scenarios").hidden = !isDemo;
|
|
293
|
+
$("current-path").innerHTML =
|
|
294
|
+
`<span style="color:var(--muted)">${esc(snapshot.projectName)}</span>${
|
|
295
|
+
target === "."
|
|
296
|
+
? ""
|
|
297
|
+
: target
|
|
298
|
+
.split("/")
|
|
299
|
+
.map((p) => `<span class="path-slash">/</span>${esc(p)}`)
|
|
300
|
+
.join("")
|
|
301
|
+
}`;
|
|
302
|
+
$("metric-loaded").textContent = String(result.loadedCount).padStart(2, "0");
|
|
303
|
+
$("metric-shadowed").textContent = String(result.shadowedCount).padStart(
|
|
304
|
+
2,
|
|
305
|
+
"0",
|
|
306
|
+
);
|
|
307
|
+
$("metric-budget").innerHTML =
|
|
308
|
+
`${fmt(result.usedBytes)} <span>/ ${fmt(budget)} B</span>`;
|
|
309
|
+
$("meter-fill").style.width =
|
|
310
|
+
`${budget ? (result.usedBytes / budget) * 100 : 0}%`;
|
|
311
|
+
$("meter-fill").className = result.hasCutoff ? "cutoff" : "";
|
|
312
|
+
$("budget").value = budget;
|
|
313
|
+
$("fallbacks").value = fallbackNames.join(", ");
|
|
314
|
+
$("map-panel").hidden = view !== "map";
|
|
315
|
+
$("compare-panel").hidden = view !== "compare";
|
|
316
|
+
$("map-view").classList.toggle("selected", view === "map");
|
|
317
|
+
$("compare-view").classList.toggle("selected", view === "compare");
|
|
318
|
+
$("map-view").setAttribute("aria-pressed", String(view === "map"));
|
|
319
|
+
$("compare-view").setAttribute("aria-pressed", String(view === "compare"));
|
|
320
|
+
$("compare-target").innerHTML = snapshot.directories
|
|
321
|
+
.map((d) => `<option value="${esc(d)}">${esc(d)}</option>`)
|
|
322
|
+
.join("");
|
|
323
|
+
$("compare-target").value = compareTarget;
|
|
324
|
+
renderTree();
|
|
325
|
+
renderChain();
|
|
326
|
+
renderFindings();
|
|
327
|
+
renderPreview();
|
|
328
|
+
if (view === "compare") renderComparison();
|
|
329
|
+
}
|
|
330
|
+
function loadSnapshot(input, demo = false) {
|
|
331
|
+
const validated = validateSnapshot(input);
|
|
332
|
+
snapshot = validated;
|
|
333
|
+
isDemo = demo;
|
|
334
|
+
target = snapshot.defaultTarget;
|
|
335
|
+
budget = snapshot.settings.maxBytes;
|
|
336
|
+
fallbackNames = [...snapshot.settings.fallbackNames];
|
|
337
|
+
compareTarget =
|
|
338
|
+
snapshot.directories.find((d) => d === "apps/web" && d !== target) ??
|
|
339
|
+
snapshot.directories.find((d) => d !== target) ??
|
|
340
|
+
target;
|
|
341
|
+
$("search").value = "";
|
|
342
|
+
view = "map";
|
|
343
|
+
render();
|
|
344
|
+
}
|
|
345
|
+
async function copy(text) {
|
|
346
|
+
try {
|
|
347
|
+
await navigator.clipboard.writeText(text);
|
|
348
|
+
announce(say("Copied to clipboard.", "已复制到剪贴板。"));
|
|
349
|
+
} catch {
|
|
350
|
+
announce(
|
|
351
|
+
say(
|
|
352
|
+
"Clipboard is unavailable. Select the preview text and copy it manually.",
|
|
353
|
+
"剪贴板不可用,请选择预览中的文本后手动复制。",
|
|
354
|
+
),
|
|
355
|
+
true,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function downloadJson() {
|
|
360
|
+
const data = {
|
|
361
|
+
...snapshot,
|
|
362
|
+
defaultTarget: target,
|
|
363
|
+
settings: { maxBytes: budget, fallbackNames },
|
|
364
|
+
};
|
|
365
|
+
const url = URL.createObjectURL(
|
|
366
|
+
new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }),
|
|
367
|
+
);
|
|
368
|
+
const link = document.createElement("a");
|
|
369
|
+
link.href = url;
|
|
370
|
+
link.download = "codex-context-map.json";
|
|
371
|
+
document.body.append(link);
|
|
372
|
+
link.click();
|
|
373
|
+
link.remove();
|
|
374
|
+
setTimeout(() => URL.revokeObjectURL(url), 3000);
|
|
375
|
+
announce(
|
|
376
|
+
say(
|
|
377
|
+
"Exported locally. The snapshot includes instruction text and relative paths; review it before sharing.",
|
|
378
|
+
"已导出到本地。快照包含指令内容和相对路径,分享前请检查。",
|
|
379
|
+
),
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
async function importFolder(list) {
|
|
383
|
+
if (!list.length) return;
|
|
384
|
+
if (list.length > 100000)
|
|
385
|
+
throw new Error(
|
|
386
|
+
say(
|
|
387
|
+
"Too many files. Select a smaller project folder.",
|
|
388
|
+
"文件数量过多,请选择更小的项目目录。",
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
const names = candidateNames(fallbackNames);
|
|
392
|
+
const dirs = new Set(["."]),
|
|
393
|
+
roots = new Set(["."]),
|
|
394
|
+
files = [];
|
|
395
|
+
const skip = new Set([
|
|
396
|
+
".git",
|
|
397
|
+
".hg",
|
|
398
|
+
".svn",
|
|
399
|
+
"node_modules",
|
|
400
|
+
".venv",
|
|
401
|
+
"venv",
|
|
402
|
+
"__pycache__",
|
|
403
|
+
".next",
|
|
404
|
+
".nuxt",
|
|
405
|
+
"coverage",
|
|
406
|
+
"dist",
|
|
407
|
+
"build",
|
|
408
|
+
"target",
|
|
409
|
+
"vendor",
|
|
410
|
+
".codex-context-map",
|
|
411
|
+
".artifacts",
|
|
412
|
+
]);
|
|
413
|
+
let total = 0;
|
|
414
|
+
const folderName = list[0].webkitRelativePath.split("/")[0];
|
|
415
|
+
for (const file of list) {
|
|
416
|
+
const parts = file.webkitRelativePath.split("/").slice(1);
|
|
417
|
+
const gitAt = parts.indexOf(".git");
|
|
418
|
+
if (gitAt >= 0) {
|
|
419
|
+
roots.add(parts.slice(0, gitAt).join("/") || ".");
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
if (parts.slice(0, -1).some((p) => skip.has(p))) continue;
|
|
423
|
+
const relative = normalizePath(parts.join("/"));
|
|
424
|
+
for (const dir of ancestors(parentPath(relative))) dirs.add(dir);
|
|
425
|
+
if (baseName(relative) === ".git") roots.add(parentPath(relative));
|
|
426
|
+
if (dirs.size > MAX_DIRECTORIES)
|
|
427
|
+
throw new Error(
|
|
428
|
+
"Too many directories. Use the CLI with a smaller --root.",
|
|
429
|
+
);
|
|
430
|
+
if (!names.includes(baseName(relative))) continue;
|
|
431
|
+
if (
|
|
432
|
+
files.length >= MAX_FILES ||
|
|
433
|
+
file.size > MAX_BUDGET ||
|
|
434
|
+
total + file.size > MAX_SNAPSHOT_BYTES
|
|
435
|
+
)
|
|
436
|
+
throw new Error("Instruction files exceed import size limits.");
|
|
437
|
+
total += file.size;
|
|
438
|
+
let content;
|
|
439
|
+
try {
|
|
440
|
+
content = new TextDecoder("utf-8", {
|
|
441
|
+
fatal: true,
|
|
442
|
+
ignoreBOM: true,
|
|
443
|
+
}).decode(await file.arrayBuffer());
|
|
444
|
+
} catch {
|
|
445
|
+
throw new Error(`Instruction file is not valid UTF-8: ${relative}`);
|
|
446
|
+
}
|
|
447
|
+
files.push({ path: relative, scope: "project", content });
|
|
448
|
+
}
|
|
449
|
+
for (const root of roots) for (const dir of ancestors(root)) dirs.add(dir);
|
|
450
|
+
loadSnapshot({
|
|
451
|
+
schemaVersion: 1,
|
|
452
|
+
projectName: folderName,
|
|
453
|
+
directories: [...dirs],
|
|
454
|
+
projectRoots: [...roots],
|
|
455
|
+
files,
|
|
456
|
+
defaultTarget: ".",
|
|
457
|
+
settings: { maxBytes: budget, fallbackNames },
|
|
458
|
+
});
|
|
459
|
+
announce(
|
|
460
|
+
say(
|
|
461
|
+
`Read ${files.length} instruction files locally. The selected folder is the project root. Empty directories are not exposed by the folder picker.`,
|
|
462
|
+
`已在本地读取 ${files.length} 个指令文件。所选文件夹作为项目根目录。文件夹选择器不提供空目录。`,
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
$("tree").addEventListener("click", (event) => {
|
|
467
|
+
const button = event.target.closest("[data-target]");
|
|
468
|
+
if (button) {
|
|
469
|
+
target = button.dataset.target;
|
|
470
|
+
render();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
$("search").addEventListener("input", renderTree);
|
|
474
|
+
$("budget").addEventListener("change", () => {
|
|
475
|
+
try {
|
|
476
|
+
if (!/^\d+$/.test($("budget").value))
|
|
477
|
+
throw new Error("Use an integer byte limit.");
|
|
478
|
+
budget = validateBudget(Number($("budget").value));
|
|
479
|
+
render();
|
|
480
|
+
} catch (error) {
|
|
481
|
+
announce(error.message, true);
|
|
482
|
+
$("budget").value = budget;
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
$("reset-budget").addEventListener("click", () => {
|
|
486
|
+
budget = DEFAULT_BUDGET;
|
|
487
|
+
render();
|
|
488
|
+
});
|
|
489
|
+
$("apply-fallbacks").addEventListener("click", () => {
|
|
490
|
+
try {
|
|
491
|
+
const next = $("fallbacks")
|
|
492
|
+
.value.split(",")
|
|
493
|
+
.map((s) => s.trim())
|
|
494
|
+
.filter(Boolean);
|
|
495
|
+
candidateNames(next);
|
|
496
|
+
fallbackNames = next;
|
|
497
|
+
render();
|
|
498
|
+
announce(
|
|
499
|
+
say(
|
|
500
|
+
"Fallback order updated. Re-open the folder to read any newly configured filenames.",
|
|
501
|
+
"备用文件优先级已更新。请重新打开文件夹以读取新增文件名。",
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
announce(error.message, true);
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
$("scenarios").addEventListener("click", (event) => {
|
|
509
|
+
const scenario = event.target.closest("[data-scenario]")?.dataset.scenario;
|
|
510
|
+
if (!scenario) return;
|
|
511
|
+
target = scenario === "empty" ? "tools/lab" : "services/payments";
|
|
512
|
+
budget = scenario === "budget" ? 1024 : DEFAULT_BUDGET;
|
|
513
|
+
view = "map";
|
|
514
|
+
render();
|
|
515
|
+
});
|
|
516
|
+
$("map-view").addEventListener("click", () => {
|
|
517
|
+
view = "map";
|
|
518
|
+
render();
|
|
519
|
+
});
|
|
520
|
+
$("compare-view").addEventListener("click", () => {
|
|
521
|
+
view = "compare";
|
|
522
|
+
render();
|
|
523
|
+
});
|
|
524
|
+
$("compare-target").addEventListener("change", () => {
|
|
525
|
+
compareTarget = $("compare-target").value;
|
|
526
|
+
renderComparison();
|
|
527
|
+
});
|
|
528
|
+
$("language").addEventListener("click", () => {
|
|
529
|
+
language = language === "en" ? "zh" : "en";
|
|
530
|
+
applyLanguage();
|
|
531
|
+
render();
|
|
532
|
+
});
|
|
533
|
+
$("copy-command").addEventListener("click", () =>
|
|
534
|
+
copy("npx codex-context-map . --html context-map.html"),
|
|
535
|
+
);
|
|
536
|
+
$("copy-preview").addEventListener("click", () => copy(result.text));
|
|
537
|
+
$("reset").addEventListener("click", () => {
|
|
538
|
+
loadSnapshot(createDemo(), true);
|
|
539
|
+
announce(
|
|
540
|
+
say(
|
|
541
|
+
"Fictional demo workspace loaded. Your imported data has been replaced in memory.",
|
|
542
|
+
"已加载虚构演示项目,内存中的导入数据已替换。",
|
|
543
|
+
),
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
$("export").addEventListener("click", downloadJson);
|
|
547
|
+
$("import-folder").addEventListener("click", () => {
|
|
548
|
+
$("folder-input").value = "";
|
|
549
|
+
$("folder-input").click();
|
|
550
|
+
});
|
|
551
|
+
$("import-json").addEventListener("click", () => {
|
|
552
|
+
$("json-input").value = "";
|
|
553
|
+
$("json-input").click();
|
|
554
|
+
});
|
|
555
|
+
$("folder-input").addEventListener("change", async (event) => {
|
|
556
|
+
try {
|
|
557
|
+
$("import-folder").disabled = true;
|
|
558
|
+
await importFolder([...event.target.files]);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
announce(error.message, true);
|
|
561
|
+
} finally {
|
|
562
|
+
$("import-folder").disabled = false;
|
|
563
|
+
event.target.value = "";
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
$("json-input").addEventListener("change", async (event) => {
|
|
567
|
+
try {
|
|
568
|
+
const file = event.target.files[0];
|
|
569
|
+
if (!file) return;
|
|
570
|
+
if (file.size > MAX_SNAPSHOT_BYTES * 3)
|
|
571
|
+
throw new Error("Snapshot file is too large.");
|
|
572
|
+
loadSnapshot(JSON.parse(await file.text()));
|
|
573
|
+
announce(say("Snapshot opened locally.", "快照已在本地打开。"));
|
|
574
|
+
} catch (error) {
|
|
575
|
+
announce(error.message, true);
|
|
576
|
+
} finally {
|
|
577
|
+
event.target.value = "";
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
applyLanguage();
|
|
581
|
+
render();
|