@h5l0/codelens 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.
@@ -0,0 +1,107 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 行数统计
3
+ // 遍历仓库内应当统计的文件,逐个数行并按分类归档,产出树形图数据。
4
+ // 读文件用有限的并发;单个文件读失败只跳过它,不拖垮整次统计。
5
+ // ---------------------------------------------------------------------------
6
+ import { readFile } from 'node:fs/promises';
7
+ import { extname, join } from 'node:path';
8
+ import { firstMatch } from './glob.js';
9
+ import { listFiles } from './scan.js';
10
+ import { repoName } from './util.js';
11
+ /** 二进制与资源文件后缀,直接跳过。 */
12
+ const BINARY_EXT = new Set([
13
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico', '.icns', '.svg',
14
+ '.pdf', '.zip', '.tar', '.gz', '.tgz', '.bz2', '.7z', '.rar', '.xz', '.zst',
15
+ '.wasm', '.node', '.so', '.dll', '.exe', '.dylib', '.a', '.o', '.obj',
16
+ '.class', '.jar', '.pyc', '.pyo',
17
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
18
+ '.mp3', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.wav', '.ogg', '.flac',
19
+ '.db', '.db-shm', '.db-wal', '.sqlite', '.sqlite3', '.bak',
20
+ '.xls', '.xlsx', '.doc', '.docx', '.ppt', '.pptx', '.ods',
21
+ ]);
22
+ /** 单文件上限,超过视为数据快照而非代码。 */
23
+ const MAX_BYTES = 3 * 1024 * 1024;
24
+ /** 同时打开的文件数,再高也超不过 node 的文件线程池。 */
25
+ const CONCURRENCY = 8;
26
+ async function countFile(root, file, categories, skipped) {
27
+ const { path, size } = file;
28
+ if (BINARY_EXT.has(extname(path).toLowerCase())) {
29
+ skipped.binary += 1;
30
+ return undefined;
31
+ }
32
+ if (size > MAX_BYTES) {
33
+ skipped.large += 1;
34
+ return undefined;
35
+ }
36
+ let content;
37
+ try {
38
+ content = await readFile(join(root, path), 'utf8');
39
+ }
40
+ catch {
41
+ // 权限不足、被别的进程占用、读到一半被删:跳过这个文件就行
42
+ skipped.unreadable += 1;
43
+ return undefined;
44
+ }
45
+ if (content.includes('\0')) {
46
+ skipped.binary += 1;
47
+ return undefined;
48
+ }
49
+ const lines = content.split('\n');
50
+ if (lines[lines.length - 1] === '') {
51
+ lines.pop();
52
+ }
53
+ if (lines.length === 0) {
54
+ return undefined;
55
+ }
56
+ let nonBlank = 0;
57
+ for (const line of lines) {
58
+ if (line.trim() !== '') {
59
+ nonBlank += 1;
60
+ }
61
+ }
62
+ return { path, lines: lines.length, nonBlank, cat: firstMatch(path, categories, categories[0]?.id ?? 'app') ?? 'app' };
63
+ }
64
+ export async function buildLoc(root, profile, opts) {
65
+ const scan = listFiles({ root, ignore: profile.ignore, useGitignore: opts.useGitignore });
66
+ const skipped = { binary: 0, large: 0, unreadable: 0 };
67
+ const counted = new Array(scan.entries.length).fill(undefined);
68
+ let next = 0;
69
+ const worker = async () => {
70
+ for (;;) {
71
+ const index = next;
72
+ next += 1;
73
+ const entry = scan.entries[index];
74
+ if (!entry) {
75
+ return;
76
+ }
77
+ counted[index] = await countFile(root, entry, profile.categories, skipped);
78
+ }
79
+ };
80
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, scan.entries.length) }, worker));
81
+ const files = counted.filter((file) => file !== undefined);
82
+ const sum = (pick) => files.reduce((acc, f) => acc + pick(f), 0);
83
+ return {
84
+ data: {
85
+ generatedAt: new Date().toISOString(),
86
+ root: repoName(root),
87
+ profile: profile.name,
88
+ categories: profile.categories.map(({ id, label, labelKey, hue, sat, defaultOn }) => ({ id, label, labelKey, hue, sat, defaultOn })),
89
+ totals: { files: files.length, lines: sum((f) => f.lines), nonBlank: sum((f) => f.nonBlank) },
90
+ skipped,
91
+ files,
92
+ },
93
+ scan,
94
+ };
95
+ }
96
+ /** 统计失败时的空清单,让日历视图仍可打开。 */
97
+ export function emptyLoc(root, profile) {
98
+ return {
99
+ generatedAt: new Date().toISOString(),
100
+ root: repoName(root),
101
+ profile: profile.name,
102
+ categories: profile.categories.map(({ id, label, labelKey, hue, sat, defaultOn }) => ({ id, label, labelKey, hue, sat, defaultOn })),
103
+ totals: { files: 0, lines: 0, nonBlank: 0 },
104
+ skipped: { binary: 0, large: 0, unreadable: 0 },
105
+ files: [],
106
+ };
107
+ }
@@ -0,0 +1,363 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 配置档
3
+ // --profile 选择一份配置:内置的 all(不区分)与 web(常见前后端目录),
4
+ // 或在 codelens.config.json(也可用 --profile 直接指向 json 文件)里自定义,
5
+ // 用 groups 划分前后端等分组,用 categories 划分模块与文件类别。
6
+ // 内置项的 label 是英文,labelKey 供页面按语言覆盖;用户配置的 label 原样使用。
7
+ // ---------------------------------------------------------------------------
8
+ import { readFileSync, statSync } from 'node:fs';
9
+ import { join, resolve } from 'node:path';
10
+ // ---------------------------------------------------------------------------
11
+ // 默认分类
12
+ // 未配置 categories 时按这套规则划分,顺序即优先级,最后一项是兜底类。
13
+ // ---------------------------------------------------------------------------
14
+ export const DEFAULT_CATEGORIES = [
15
+ {
16
+ id: 'test',
17
+ label: 'Tests',
18
+ labelKey: 'category.test',
19
+ hue: 152,
20
+ sat: 46,
21
+ match: [
22
+ '**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}',
23
+ '**/{test,spec}.{ts,tsx,js,jsx,mts,cts,mjs,cjs}',
24
+ '**/__tests__/**',
25
+ '**/tests/**',
26
+ ],
27
+ },
28
+ {
29
+ id: 'generated',
30
+ label: 'Generated',
31
+ labelKey: 'category.generated',
32
+ hue: 220,
33
+ sat: 8,
34
+ defaultOn: false,
35
+ match: [
36
+ 'src/generated/**',
37
+ '**/*.min.js',
38
+ '**/*.map',
39
+ '**/*lock.json',
40
+ '**/*lock.yaml',
41
+ '**/*.lock',
42
+ '**/*.schema.json',
43
+ '**/.dep-baseline.json',
44
+ ],
45
+ },
46
+ { id: 'script', label: 'Scripts', labelKey: 'category.script', hue: 32, sat: 62, match: ['scripts/**'] },
47
+ {
48
+ id: 'doc',
49
+ label: 'Docs',
50
+ labelKey: 'category.doc',
51
+ hue: 268,
52
+ sat: 46,
53
+ defaultOn: false,
54
+ match: ['**/*.{md,mdx,txt,rst}', 'doc/**', 'docs/**'],
55
+ },
56
+ {
57
+ id: 'config',
58
+ label: 'Config',
59
+ labelKey: 'category.config',
60
+ hue: 332,
61
+ sat: 52,
62
+ defaultOn: false,
63
+ match: [
64
+ '**/package.json',
65
+ '**/tsconfig*.json',
66
+ '**/.env*',
67
+ '**/env.example',
68
+ '**/{Dockerfile,dockerfile}*',
69
+ '**/{docker-compose,Docker-Compose}*.{yml,yaml}',
70
+ '**/.{editorconfig,gitignore,gitattributes,dockerignore,npmrc,nvmrc}',
71
+ '**/*.{yml,yaml,toml,prisma,jsonc}',
72
+ '**/*.config.{ts,tsx,js,jsx,mts,cts,mjs,cjs}',
73
+ '**/*.config.json',
74
+ 'data/config/**',
75
+ '.github/**',
76
+ ],
77
+ },
78
+ { id: 'app', label: 'Application code', labelKey: 'category.app', hue: 214, sat: 58 },
79
+ ];
80
+ // ---------------------------------------------------------------------------
81
+ // 内置配置档
82
+ // ---------------------------------------------------------------------------
83
+ /** 未配置的兜底档:不做任何分组,整个仓库一起统计。 */
84
+ const PROFILE_ALL = { label: 'All', labelKey: 'profile.all' };
85
+ /** 常见前后端目录布局:前端目录归前端,其余归后端。 */
86
+ const PROFILE_WEB = {
87
+ label: 'Frontend / backend',
88
+ labelKey: 'profile.web',
89
+ groups: [
90
+ {
91
+ id: 'frontend',
92
+ label: 'Frontend',
93
+ labelKey: 'profile.frontend',
94
+ hue: 268,
95
+ sat: 46,
96
+ match: [
97
+ 'frontend/**',
98
+ 'web/**',
99
+ 'client/**',
100
+ 'ui/**',
101
+ 'apps/web/**',
102
+ 'packages/ui/**',
103
+ '**/*.{html,css,scss,less,vue,svelte}',
104
+ ],
105
+ },
106
+ { id: 'backend', label: 'Backend', labelKey: 'profile.backend', hue: 214, sat: 58, match: ['**'] },
107
+ ],
108
+ };
109
+ const BUILTIN_PROFILES = { all: PROFILE_ALL, web: PROFILE_WEB };
110
+ /** 保留 id:all 是聚合键,其余几个会撞上对象的原型属性。 */
111
+ const RESERVED_IDS = new Set(['all', '__proto__', 'prototype', 'constructor']);
112
+ const hasOwn = (obj, key) => Object.hasOwn(obj, key);
113
+ function fail(where, message) {
114
+ throw new Error(`config ${where}: ${message}`);
115
+ }
116
+ function asObject(value, where) {
117
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
118
+ fail(where, 'must be an object');
119
+ }
120
+ return value;
121
+ }
122
+ function asString(value, where) {
123
+ if (typeof value !== 'string' || value === '') {
124
+ fail(where, 'must be a non-empty string');
125
+ }
126
+ return value;
127
+ }
128
+ function asStringArray(value, where) {
129
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item === '')) {
130
+ fail(where, 'must be an array of strings');
131
+ }
132
+ return value;
133
+ }
134
+ function asHue(value, where, fallback) {
135
+ if (value === undefined) {
136
+ return fallback;
137
+ }
138
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 360) {
139
+ fail(where, 'must be a number between 0 and 360');
140
+ }
141
+ return value;
142
+ }
143
+ function asPercent(value, where, fallback) {
144
+ if (value === undefined) {
145
+ return fallback;
146
+ }
147
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 100) {
148
+ fail(where, 'must be a number between 0 and 100');
149
+ }
150
+ return value;
151
+ }
152
+ function parseGroups(value, where) {
153
+ if (value === undefined) {
154
+ return [];
155
+ }
156
+ if (!Array.isArray(value)) {
157
+ fail(where, 'must be an array');
158
+ }
159
+ return value.map((item, i) => {
160
+ const at = `${where}[${i}]`;
161
+ const obj = asObject(item, at);
162
+ return {
163
+ id: asString(obj.id, `${at}.id`),
164
+ label: asString(obj.label, `${at}.label`),
165
+ hue: asHue(obj.hue, `${at}.hue`, 214),
166
+ sat: asPercent(obj.sat, `${at}.sat`, 50),
167
+ match: asStringArray(obj.match, `${at}.match`),
168
+ };
169
+ });
170
+ }
171
+ function parseCategories(value, where) {
172
+ if (value === undefined) {
173
+ return DEFAULT_CATEGORIES;
174
+ }
175
+ if (!Array.isArray(value) || value.length === 0) {
176
+ fail(where, 'must be a non-empty array');
177
+ }
178
+ return value.map((item, i) => {
179
+ const at = `${where}[${i}]`;
180
+ const obj = asObject(item, at);
181
+ const category = {
182
+ id: asString(obj.id, `${at}.id`),
183
+ label: asString(obj.label, `${at}.label`),
184
+ hue: asHue(obj.hue, `${at}.hue`, 214),
185
+ sat: asPercent(obj.sat, `${at}.sat`, 50),
186
+ };
187
+ if (obj.match !== undefined) {
188
+ category.match = asStringArray(obj.match, `${at}.match`);
189
+ }
190
+ if (obj.defaultOn !== undefined) {
191
+ if (typeof obj.defaultOn !== 'boolean') {
192
+ fail(`${at}.defaultOn`, 'must be a boolean');
193
+ }
194
+ category.defaultOn = obj.defaultOn;
195
+ }
196
+ return category;
197
+ });
198
+ }
199
+ function parseEntry(value, where) {
200
+ const obj = asObject(value, where);
201
+ const entry = {
202
+ groups: parseGroups(obj.groups, `${where}.groups`),
203
+ categories: parseCategories(obj.categories, `${where}.categories`),
204
+ ignore: obj.ignore === undefined ? [] : asStringArray(obj.ignore, `${where}.ignore`),
205
+ };
206
+ if (obj.label !== undefined) {
207
+ entry.label = asString(obj.label, `${where}.label`);
208
+ }
209
+ return entry;
210
+ }
211
+ function readJson(path) {
212
+ let text;
213
+ try {
214
+ text = readFileSync(path, 'utf8');
215
+ }
216
+ catch (err) {
217
+ throw new Error(`failed to read config file ${path}: ${err instanceof Error ? err.message : String(err)}`);
218
+ }
219
+ try {
220
+ // 配置里允许写注释与尾随逗号,README 的示例就是这么给的
221
+ return JSON.parse(stripJsonc(text));
222
+ }
223
+ catch (err) {
224
+ throw new Error(`failed to parse config file ${path}: ${err instanceof Error ? err.message : String(err)}`);
225
+ }
226
+ }
227
+ /**
228
+ * 去掉 jsonc 的注释与尾随逗号:字符串、注释、逗号在同一趟里处理,
229
+ * 字符串里的 `//`、`/*` 与拖尾的 `,` 都原样保留。
230
+ */
231
+ function stripJsonc(text) {
232
+ let out = '';
233
+ let i = 0;
234
+ while (i < text.length) {
235
+ const ch = text[i];
236
+ if (ch === '"') {
237
+ out += ch;
238
+ i += 1;
239
+ while (i < text.length) {
240
+ const inner = text[i];
241
+ out += inner;
242
+ i += 1;
243
+ if (inner === '\\') {
244
+ out += text[i] ?? '';
245
+ i += 1;
246
+ }
247
+ else if (inner === '"') {
248
+ break;
249
+ }
250
+ }
251
+ continue;
252
+ }
253
+ if (ch === '/' && text[i + 1] === '/') {
254
+ while (i < text.length && text[i] !== '\n') {
255
+ i += 1;
256
+ }
257
+ continue;
258
+ }
259
+ if (ch === '/' && text[i + 1] === '*') {
260
+ i += 2;
261
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) {
262
+ i += 1;
263
+ }
264
+ i += 2;
265
+ continue;
266
+ }
267
+ if (ch === ',') {
268
+ let j = i + 1;
269
+ while (j < text.length && /\s/.test(text[j])) {
270
+ j += 1;
271
+ }
272
+ if (text[j] === '}' || text[j] === ']') {
273
+ i += 1;
274
+ continue;
275
+ }
276
+ }
277
+ out += ch;
278
+ i += 1;
279
+ }
280
+ return out;
281
+ }
282
+ /**
283
+ * 按名字解析配置档:
284
+ * 1. 名字指向 json 文件时直接当档用;
285
+ * 2. 否则先找配置文件里的 profiles[名字],再找内置档。
286
+ */
287
+ export function loadProfile(opts) {
288
+ const { root, name } = opts;
289
+ const configPath = opts.configPath
290
+ ? resolve(root, opts.configPath)
291
+ : join(root, 'codelens.config.json');
292
+ let entry;
293
+ let usedConfig;
294
+ if (isProfileFile(name)) {
295
+ const file = resolve(root, name);
296
+ entry = parseEntry(readJson(file), file);
297
+ usedConfig = file;
298
+ }
299
+ else if (opts.configPath || exists(configPath)) {
300
+ const file = asObject(readJson(configPath), configPath);
301
+ const profiles = file.profiles === undefined ? {} : asObject(file.profiles, `profiles of ${configPath}`);
302
+ if (hasOwn(profiles, name)) {
303
+ entry = parseEntry(profiles[name], `profiles.${name} of ${configPath}`);
304
+ usedConfig = configPath;
305
+ }
306
+ else if (hasOwn(BUILTIN_PROFILES, name)) {
307
+ // 配置里没有这一档就回退内置档:仓库里放了一份自定义配置,
308
+ // 不该让默认的 `codelens` / `--profile web` 直接跑不起来
309
+ entry = BUILTIN_PROFILES[name];
310
+ }
311
+ else {
312
+ fail(configPath, `has no profile named ${name}; available: ${names(profiles, BUILTIN_PROFILES)}`);
313
+ }
314
+ }
315
+ else if (hasOwn(BUILTIN_PROFILES, name)) {
316
+ entry = BUILTIN_PROFILES[name];
317
+ }
318
+ else {
319
+ throw new Error(`no profile named ${name}; available: ${names(undefined, BUILTIN_PROFILES)}`);
320
+ }
321
+ const groups = entry.groups ?? [];
322
+ const ids = new Set();
323
+ for (const group of groups) {
324
+ if (ids.has(group.id)) {
325
+ fail(`groups of ${usedConfig ?? name}`, `contain a duplicate id: ${group.id}`);
326
+ }
327
+ if (RESERVED_IDS.has(group.id)) {
328
+ fail(`groups of ${usedConfig ?? name}`, `must not use the reserved id: ${group.id}`);
329
+ }
330
+ ids.add(group.id);
331
+ }
332
+ for (const category of entry.categories ?? []) {
333
+ if (RESERVED_IDS.has(category.id)) {
334
+ fail(`categories of ${usedConfig ?? name}`, `must not use the reserved id: ${category.id}`);
335
+ }
336
+ }
337
+ return {
338
+ name,
339
+ label: entry.label ?? (groups.length > 0 ? groups.map((group) => group.label).join(' / ') : 'All'),
340
+ groups,
341
+ categories: entry.categories ?? DEFAULT_CATEGORIES,
342
+ ignore: [...(entry.ignore ?? []), ...opts.exclude],
343
+ configPath: usedConfig,
344
+ };
345
+ }
346
+ function names(profiles, builtin) {
347
+ return [...Object.keys(profiles ?? {}), ...Object.keys(builtin)].join(', ');
348
+ }
349
+ function isProfileFile(name) {
350
+ return name.endsWith('.json') || name.includes('/') || name.includes('\\');
351
+ }
352
+ function exists(path) {
353
+ try {
354
+ return statSync(path).isFile();
355
+ }
356
+ catch {
357
+ return false;
358
+ }
359
+ }
360
+ /** 供帮助信息使用:列出内置配置档。 */
361
+ export function builtinProfileNames() {
362
+ return Object.keys(BUILTIN_PROFILES);
363
+ }
@@ -0,0 +1,125 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 文件枚举
3
+ // 优先用 git 的索引加未跟踪文件列表,天然遵守 .gitignore(含全局与 .git/info/exclude);
4
+ // git 缺失或目录不是仓库时退回递归遍历,用同一套 gitignore 语义自行过滤。
5
+ // 枚举时顺手取回文件大小,行数统计不必再 stat 一遍。
6
+ // ---------------------------------------------------------------------------
7
+ import { execFileSync } from 'node:child_process';
8
+ import { readdirSync, statSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { createGlob } from './glob.js';
11
+ import { isIgnored, loadIgnoreFile } from './gitignore.js';
12
+ /** git 调用统一关掉可能执行外部程序的配置,扫描不受信任的目录时更安全。 */
13
+ export const GIT_SAFE_CONFIG = ['-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false'];
14
+ /** 依赖、构建产物与虚拟环境目录,任何模式下都跳过。 */
15
+ const SKIP_DIRS = new Set([
16
+ '.git', '.hg', '.svn',
17
+ 'node_modules', 'bower_components', 'jspm_packages',
18
+ 'dist', 'build', 'out', 'coverage', 'vendor', 'target',
19
+ '__pycache__', '.venv', 'venv', '.tox', '.pytest_cache', '.mypy_cache', '.ruff_cache',
20
+ '.next', '.nuxt', '.svelte-kit', '.angular', '.turbo', '.parcel-cache', '.vite', '.vitest',
21
+ '.cache', '.gradle', '.m2', '.cargo', '.terraform', '.dart_tool', '.stack-work',
22
+ '.idea', '.vscode', 'tmp', 'temp',
23
+ ]);
24
+ /**
25
+ * 生成「这个路径要统计吗」的判定。除文件名外的任一段命中 SKIP_DIRS 就跳过
26
+ * (目录名与文件同名的情形按文件保留),命中忽略 glob 的目录连同内容一起跳过:
27
+ * `--exclude mydata` 与 `--exclude mydata/` 都能排除 mydata 下的全部文件。
28
+ */
29
+ export function createPathFilter(ignore) {
30
+ if (ignore.length === 0) {
31
+ return (path) => !path.split('/').slice(0, -1).some((seg) => SKIP_DIRS.has(seg));
32
+ }
33
+ const matchers = ignore.map((pattern) => createGlob(pattern));
34
+ const dirCache = new Map();
35
+ const ignoredDir = (dir) => {
36
+ const hit = dirCache.get(dir);
37
+ if (hit !== undefined) {
38
+ return hit;
39
+ }
40
+ const value = matchers.some((matcher) => matcher(dir));
41
+ dirCache.set(dir, value);
42
+ return value;
43
+ };
44
+ return (path) => {
45
+ const parts = path.split('/');
46
+ const dirs = parts.slice(0, -1);
47
+ if (dirs.some((seg) => SKIP_DIRS.has(seg))) {
48
+ return false;
49
+ }
50
+ let prefix = '';
51
+ for (const dir of dirs) {
52
+ prefix = prefix ? `${prefix}/${dir}` : dir;
53
+ if (ignoredDir(prefix)) {
54
+ return false;
55
+ }
56
+ }
57
+ return !matchers.some((matcher) => matcher(path));
58
+ };
59
+ }
60
+ function listViaGit(root) {
61
+ try {
62
+ const out = execFileSync('git', ['-C', root, ...GIT_SAFE_CONFIG, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], { maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
63
+ return out.toString('utf8').split('\0').filter(Boolean);
64
+ }
65
+ catch {
66
+ // 不是仓库或 git 不可用时退回遍历
67
+ return undefined;
68
+ }
69
+ }
70
+ function walk(root, opts) {
71
+ const files = [];
72
+ const visit = (dir, relDir, rules) => {
73
+ const scoped = opts.useGitignore ? [...rules, ...loadIgnoreFile(dir, relDir)] : rules;
74
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
75
+ if (ent.isSymbolicLink() || (!ent.isDirectory() && !ent.isFile())) {
76
+ continue;
77
+ }
78
+ const rel = relDir ? `${relDir}/${ent.name}` : ent.name;
79
+ if (ent.isDirectory()) {
80
+ if (SKIP_DIRS.has(ent.name)) {
81
+ continue;
82
+ }
83
+ if (opts.useGitignore && isIgnored(rel, true, scoped)) {
84
+ continue;
85
+ }
86
+ visit(join(dir, ent.name), rel, scoped);
87
+ continue;
88
+ }
89
+ if (opts.useGitignore && isIgnored(rel, false, scoped)) {
90
+ continue;
91
+ }
92
+ const stat = statSync(join(dir, ent.name), { throwIfNoEntry: false });
93
+ if (stat?.isFile()) {
94
+ files.push({ path: rel, size: stat.size });
95
+ }
96
+ }
97
+ };
98
+ visit(root, '', []);
99
+ return files;
100
+ }
101
+ /** 列出仓库内应当统计的文件。 */
102
+ export function listFiles(opts) {
103
+ const keep = createPathFilter(opts.ignore);
104
+ if (opts.useGitignore) {
105
+ const viaGit = listViaGit(opts.root);
106
+ if (viaGit) {
107
+ const entries = [];
108
+ for (const path of viaGit) {
109
+ if (!keep(path)) {
110
+ continue;
111
+ }
112
+ // 索引里可能残留磁盘上已删除的文件
113
+ const stat = statSync(join(opts.root, path), { throwIfNoEntry: false });
114
+ if (stat?.isFile()) {
115
+ entries.push({ path, size: stat.size });
116
+ }
117
+ }
118
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
119
+ return { entries, mode: 'git' };
120
+ }
121
+ }
122
+ const entries = walk(opts.root, opts).filter((entry) => keep(entry.path));
123
+ entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
124
+ return { entries, mode: 'walk' };
125
+ }
@@ -0,0 +1,5 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 核心数据类型
3
+ // CLI 生成数据、浏览器读取数据,两侧共用同一份定义。
4
+ // ---------------------------------------------------------------------------
5
+ export {};
@@ -0,0 +1,8 @@
1
+ // ---------------------------------------------------------------------------
2
+ // 通用工具
3
+ // ---------------------------------------------------------------------------
4
+ /** 取仓库目录名,用作看板标题。 */
5
+ export function repoName(root) {
6
+ const parts = root.split(/[\\/]/).filter(Boolean);
7
+ return parts[parts.length - 1] ?? root;
8
+ }
@@ -0,0 +1 @@
1
+ :root{--cw: 36px;--ch: 26px;--gap: 4px;--ink: #1a1f27;--sub: #5b6470;--muted: #676f7b;--line: #e8ecf0;--cell-bg: #edf0f3;--green: #2b8f4f;--red: #d4513f;--accent: #2f63c8;--track: #848e9a}*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,PingFang SC,Microsoft YaHei,sans-serif;margin:0;color:var(--ink);background:#f7f8fa;-webkit-font-smoothing:antialiased}header{position:sticky;top:0;z-index:20;background:#ffffffd9;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}.header-inner{max-width:980px;margin:0 auto;padding:16px 28px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}h1{font-size:18px;margin:0;font-weight:650;letter-spacing:.2px}h1 .range{font-size:12px;font-weight:400;color:var(--muted);margin-left:10px}.header-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.seg{position:relative;display:flex;background:#edf0f3;border-radius:999px;padding:3px}.seg[hidden]{display:none}.seg-pill{position:absolute;top:3px;bottom:3px;border-radius:999px;background:#fff;box-shadow:0 1px 3px #10182824;transition:left .22s cubic-bezier(.4,0,.2,1),width .22s cubic-bezier(.4,0,.2,1)}.seg button{position:relative;z-index:1;border:0;background:transparent;padding:6px 16px;font-size:13px;color:var(--sub);cursor:pointer;border-radius:999px;font-family:inherit;transition:color .18s}.seg button.active{color:var(--ink);font-weight:600}main{max-width:980px;margin:0 auto;padding:24px 28px 90px}.stats{display:grid;gap:14px;margin-top:18px;grid-template-columns:repeat(auto-fit,minmax(170px,1fr))}.stat{background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px 18px;min-width:0;transition:border-color .15s,background .15s}.stats.live .stat{border-color:#c3d2ee;background:#fbfcff}.stat .k{font-size:12px;color:var(--muted)}.stat .v{font-size:21px;font-weight:650;margin-top:4px;font-variant-numeric:tabular-nums;white-space:nowrap}.stat .s{font-size:11px;color:var(--muted);margin-top:3px}.stats.live .stat .s{visibility:hidden}.stat .v .plus{color:var(--green)}.stat .v .minus{color:var(--red)}.commits-card{background:#fff;border:1px solid var(--line);border-radius:12px;margin-top:14px;overflow:hidden}.commits-head{font-size:12px;color:var(--muted);padding:10px 18px;border-bottom:1px solid #f0f2f5}.commits-head.live{color:var(--accent);font-weight:600}.commit-list{height:168px;overflow-y:auto}.commit{display:flex;align-items:baseline;gap:12px;padding:7px 18px;font-size:13px}.commit+.commit{border-top:1px solid #f6f8fa}.commit .hash{font-family:ui-monospace,Cascadia Code,Consolas,monospace;font-size:12px;color:var(--accent);flex-shrink:0}.commit .subj{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--ink)}.commit .nums{flex-shrink:0;font-size:12px;font-variant-numeric:tabular-nums}.commit .nums .plus{color:var(--green)}.commit .nums .minus{color:var(--red)}.commit-empty{padding:14px 18px;font-size:13px;color:var(--muted)}.card{background:#fff;border:1px solid var(--line);border-radius:14px;padding:24px 26px 18px;box-shadow:0 1px 2px #10182808,0 6px 24px #1018280d}.cal-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;font-size:12px;color:var(--sub);flex-wrap:wrap}.cal-head .cal-range{color:var(--ink);font-weight:650;font-variant-numeric:tabular-nums}.cal-head .cal-legend{display:flex;align-items:center;gap:18px}.calendar-wrap{display:flex;align-items:flex-start;gap:10px}.dow-labels{display:flex;flex-direction:column;gap:var(--gap);font-size:10px;color:var(--muted);padding-top:24px}.dow-labels span{height:var(--ch);line-height:var(--ch)}.grid-area{flex:1;overflow:hidden;padding-bottom:4px}.grid-area>.month-row,.grid-area>.grid{width:fit-content;margin-left:auto}.month-row{display:grid;gap:var(--gap);font-size:11px;color:var(--sub);height:20px;margin-bottom:4px}.month-label{white-space:nowrap}.grid{display:flex;flex-direction:column;gap:var(--gap)}.row{display:flex;gap:var(--gap)}.cell{width:var(--cw);height:var(--ch);border:0;padding:0;font:inherit;border-radius:5px;background:var(--cell-bg);display:flex;flex-direction:column;align-items:flex-start;overflow:hidden;cursor:pointer;transition:transform .08s ease,box-shadow .08s ease,opacity .18s ease}.cell.pad{background:transparent;cursor:default}.cell.out{background:#f8fafb}.cell.today{box-shadow:inset 0 0 0 1.5px #1a1f2766}.cell.selected{box-shadow:inset 0 0 0 1.5px var(--accent)}.cell:not(.pad):hover{transform:scale(1.3);box-shadow:0 3px 10px #10182833;z-index:5}.cell:focus-visible{outline:2px solid var(--accent);outline-offset:2px;z-index:6}@media(prefers-reduced-motion:reduce){.cell{transition:none}.cell:not(.pad):hover{transform:none}}.hbar{display:block;height:50%}.hbar.add{background:var(--green)}.hbar.del{background:var(--red)}.cell.dim:not(.pad){opacity:.35}.legend-item{display:flex;align-items:center;gap:7px}.sw{display:inline-block;width:12px;height:12px}.scrub{margin-top:16px;padding-top:12px;border-top:1px solid #f0f2f5}.scrub-track{position:relative;display:flex;width:fit-content;margin-left:auto;cursor:grab;border-radius:3px}.scrub.dragging .scrub-track{cursor:grabbing}.scrub-track:focus-visible{outline:2px solid var(--accent);outline-offset:3px}.scrub-cells{display:flex;gap:2px;height:20px;-webkit-mask-image:linear-gradient(90deg,transparent,#000 min(64px,50%),#000 calc(100% - min(64px,50%)),transparent);mask-image:linear-gradient(90deg,transparent,#000 min(64px,50%),#000 calc(100% - min(64px,50%)),transparent)}.scrub-cells .sc{flex:0 0 auto;width:5px;height:20px;border-radius:1.5px;background:#e6eaee;overflow:hidden}.scrub-cells .sc.void{background:transparent}.scrub-cells .sc i{display:block;width:100%;height:100%;background:var(--green)}.scrub-frame{position:absolute;top:-2px;bottom:-2px;border:1.5px solid var(--accent);border-radius:4px;background:#2f63c812;pointer-events:none}.load-error{max-width:980px;margin:60px auto;padding:0 28px;font-size:14px;color:var(--sub);line-height:2}.load-error code{background:#edf0f3;border-radius:5px;padding:2px 8px;font-family:ui-monospace,Consolas,monospace;font-size:13px}.load-error button{display:block;margin-top:12px;font:inherit;font-size:13px;color:var(--accent);background:#fff;border:1px solid var(--line);border-radius:8px;padding:6px 14px;cursor:pointer;transition:background .15s}.load-error button:hover{background:#f4f6f8}.loc-bar{display:flex;align-items:center;justify-content:space-between;gap:10px 22px;flex-wrap:wrap;margin:2px 4px 12px}.loc-summary{font-size:13px;color:var(--sub)}.loc-summary b{color:var(--ink);font-variant-numeric:tabular-nums}.loc-summary .total{color:var(--muted);font-variant-numeric:tabular-nums}.loc-switches{display:flex;align-items:center;flex-wrap:wrap;gap:10px 20px;margin-left:auto}.sw-item{position:relative;display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--sub);cursor:pointer;-webkit-user-select:none;user-select:none;transition:color .15s}.sw-item input{position:absolute;opacity:0;width:0;height:0}.sw-item .track{position:relative;width:32px;height:18px;border-radius:999px;background:var(--track);transition:background .18s;flex-shrink:0}.sw-item .track:after{content:"";position:absolute;left:2px;top:2px;width:14px;height:14px;border-radius:50%;background:#fff;box-shadow:0 1px 2px #10182833;transition:transform .18s}.sw-item input:checked+.track{background:var(--accent)}.sw-item input:checked+.track:after{transform:translate(14px)}.sw-item input:focus-visible+.track{outline:2px solid #b9cdf3;outline-offset:2px}.sw-item:has(input:checked){color:var(--ink)}.tm-card{padding:0;overflow:hidden}.tm-crumbs{display:flex;align-items:center;gap:1px;flex-wrap:wrap;padding:9px 14px;border-bottom:1px solid #f0f2f5}.tm-crumbs .crumb{border:0;background:transparent;font-family:ui-monospace,Cascadia Code,Consolas,monospace;font-size:12px;color:var(--accent);cursor:pointer;padding:2px 6px;border-radius:6px;transition:background .15s}.tm-crumbs button.crumb:hover{background:#eef3fd}.tm-crumbs .crumb.current{color:var(--ink);font-weight:600;cursor:default;padding:2px 4px}.tm-crumbs .crumb.preview{color:var(--muted);cursor:default;padding:2px 4px}.tm-crumbs .crumb-sep{color:var(--muted);font-size:12px}.tm-crumbs .crumbs-meta{margin-left:auto;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums}.tm-wrap{position:relative;height:min(70vh,720px);min-height:420px;background:#f4f6f8;overflow:hidden}.tm-stage{position:absolute;inset:0}.tm-stage.entering{transition:transform .3s cubic-bezier(.22,.61,.36,1),opacity .18s ease-out}.tm-stage.leaving{transition:opacity .24s ease-out;opacity:0;pointer-events:none}@media(prefers-reduced-motion:reduce){.tm-stage.entering,.tm-stage.leaving{transition:none}}.tm-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:13px;color:var(--muted)}.tm-node{position:absolute;overflow:hidden;border:1px solid rgba(255,255,255,.8);background:var(--c);color:var(--fg);cursor:pointer}.tm-node.is-file{cursor:default}.tm-node.hot{filter:brightness(1.14);z-index:12;box-shadow:0 0 0 1.5px #10182859 inset}.tm-node:focus-visible{outline:0;box-shadow:inset 0 0 0 2px #fff,inset 0 0 0 5px #0009;z-index:13}.tm-head{position:absolute;left:0;right:0;top:0;height:15px;display:flex;align-items:center;gap:6px;padding:0 4px;font-size:10.5px;line-height:15px;white-space:nowrap;overflow:hidden;pointer-events:none}.tm-name{overflow:hidden;text-overflow:ellipsis}.tm-val{margin-left:auto;flex-shrink:0;font-variant-numeric:tabular-nums}.tm-body{position:absolute;left:0;right:0;bottom:0}.tm-badge{position:absolute;left:5px;right:5px;bottom:4px;font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none}.tm-legend{display:flex;align-items:center;flex-wrap:wrap;gap:8px 16px;padding:12px 18px;border-top:1px solid #f0f2f5}.tm-legend .lg{display:inline-flex;align-items:center;gap:7px;border:0;background:transparent;font-family:inherit;font-size:12px;color:var(--sub);cursor:pointer;padding:2px 6px;border-radius:6px;transition:background .15s,color .15s}.tm-legend .lg:hover{background:#f4f6f8}.tm-legend .lg.off{color:var(--muted)}.tm-legend .sq{width:11px;height:11px;border-radius:3px;background:var(--c);flex-shrink:0}.tm-legend .lg.off .sq{background:transparent;box-shadow:inset 0 0 0 2px var(--c)}.tm-legend .lg-val{color:var(--muted);font-variant-numeric:tabular-nums}