dsh-mobile-plugin 0.1.96 → 0.1.97

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.
Files changed (41) hide show
  1. package/README.md +73 -50
  2. package/config/agent-presets/standard/agent.cordis.yml +13 -0
  3. package/lib/git/exec.js +129 -0
  4. package/lib/git/package.json +3 -0
  5. package/lib/git/parse.js +247 -0
  6. package/lib/git/read.js +617 -0
  7. package/lib/mobile/archive-clean.js +31 -1
  8. package/lib/mobile/channels.js +583 -0
  9. package/lib/mobile/events.js +10 -8
  10. package/lib/mobile/git-repos.js +210 -0
  11. package/lib/mobile/git.js +603 -0
  12. package/lib/mobile/history-derive.js +79 -1
  13. package/lib/mobile/host-bridge/endpoints.js +3 -0
  14. package/lib/mobile/host-bridge/event-synthesis.js +109 -1
  15. package/lib/mobile/host-bridge/shims.js +9 -2
  16. package/lib/mobile/host-settings.js +41 -0
  17. package/lib/mobile/inbox-clean.js +337 -0
  18. package/lib/mobile/pairing.js +18 -1
  19. package/lib/mobile/questions.js +31 -9
  20. package/lib/mobile/schedule.js +440 -0
  21. package/lib/mobile/session-cache.js +18 -0
  22. package/lib/mobile/session-file.js +192 -0
  23. package/lib/mobile/session-model-options.js +184 -0
  24. package/lib/mobile/session-models.js +24 -2
  25. package/lib/mobile/session-schedule-mark.js +70 -0
  26. package/lib/mobile/session-snapshot.js +12 -1
  27. package/lib/mobile/sessions.js +8 -0
  28. package/lib/mobile/stats-collect.cjs +257 -0
  29. package/lib/mobile/stats-ledger.cjs +407 -0
  30. package/lib/mobile/stats.js +38 -0
  31. package/lib/mobile/stream.js +18 -10
  32. package/lib/mobile/uploads.js +24 -6
  33. package/lib/mobile/workspaces.js +30 -6
  34. package/lib/panel/index.html +4 -4
  35. package/lib/relay/lan-proxy.js +10 -1
  36. package/lib/routes/control-plane.js +38 -0
  37. package/lib/routes/mobile-api.js +70 -1
  38. package/lib/session-cleanup.cjs +8 -0
  39. package/lib/shared/sse-write.js +17 -6
  40. package/lib/shell/updater.js +3 -3
  41. package/package.json +3 -2
@@ -0,0 +1,617 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Git 只读能力的实现:每个函数对应移动端一屏要的数据,返回的就是下发的形状。
5
+ *
6
+ * 全部只在**已识别出来的仓库根**上跑(根由调用方校验,见 lib/mobile/git.js),参数里只有
7
+ * 相对仓库根的路径,路径的合法性在调用方那侧先过一遍。
8
+ *
9
+ * 体积与耗时上的取舍写在各自函数的注释里:diff 单文件封顶 200KB、历史一页最多 200 条、
10
+ * 概览的文件遍历有上限。宁可少给一部分并明说(`truncated` / `hasMore`),也不让手机等十秒。
11
+ */
12
+
13
+ const { readFile, stat } = require('node:fs/promises');
14
+ const { basename, extname, join } = require('node:path');
15
+
16
+ const { runGit, runGitStrict } = require('./exec');
17
+ const {
18
+ RECORD,
19
+ UNIT,
20
+ parseDiffText,
21
+ parseLogZ,
22
+ parseNameStatusZ,
23
+ parseNumstatZ,
24
+ parseStatusZ,
25
+ } = require('./parse');
26
+
27
+ /** 单文件 diff 的字节上限:超过就只给前几段完整的 hunk。 */
28
+ const MAX_DIFF_BYTES = 200 * 1024;
29
+ /** 历史一页的条数上限。 */
30
+ const MAX_LOG_LIMIT = 200;
31
+ const DEFAULT_LOG_LIMIT = 30;
32
+ /** 概览遍历的上限:文件数、单个文件大小、累计读取量。 */
33
+ const MAX_OVERVIEW_FILES = 4000;
34
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
35
+ const MAX_TOTAL_BYTES = 24 * 1024 * 1024;
36
+ /** 语言表的展示条数。超出的**并进「其他」**(不是悄悄丢掉,那样百分比就不合 100 了)。 */
37
+ const MAX_LANGUAGES = 10;
38
+ /** GitHub 元信息的超时。 */
39
+ const GITHUB_TIMEOUT_MS = 4000;
40
+ /** 概览的缓存时长:这张卡要扫一遍文件与历史,几秒级的活不该跟着每次下拉重算。 */
41
+ const OVERVIEW_CACHE_MS = 60 * 1000;
42
+
43
+ /** 仓库根 → { at, value }。条目数等于用户打开过的仓库数,不需要淘汰。 */
44
+ const overviewCache = new Map();
45
+
46
+ /** 扩展名 → 语言名。够覆盖常见仓库;认不出的并进「其他」。 */
47
+ const LANGUAGES = new Map([
48
+ ['ts', 'TypeScript'], ['tsx', 'TypeScript'], ['js', 'JavaScript'], ['jsx', 'JavaScript'],
49
+ ['mjs', 'JavaScript'], ['cjs', 'JavaScript'], ['py', 'Python'], ['rb', 'Ruby'],
50
+ ['go', 'Go'], ['rs', 'Rust'], ['java', 'Java'], ['kt', 'Kotlin'], ['swift', 'Swift'],
51
+ ['c', 'C'], ['h', 'C'], ['cc', 'C++'], ['cpp', 'C++'], ['hpp', 'C++'], ['cs', 'C#'],
52
+ ['php', 'PHP'], ['sh', 'Shell'], ['bash', 'Shell'], ['zsh', 'Shell'], ['ps1', 'PowerShell'],
53
+ ['css', 'CSS'], ['scss', 'SCSS'], ['less', 'Less'], ['html', 'HTML'], ['vue', 'Vue'],
54
+ ['svelte', 'Svelte'], ['json', 'JSON'], ['yml', 'YAML'], ['yaml', 'YAML'], ['toml', 'TOML'],
55
+ ['md', 'Markdown'], ['mdx', 'Markdown'], ['sql', 'SQL'], ['dart', 'Dart'], ['lua', 'Lua'],
56
+ ['ex', 'Elixir'], ['exs', 'Elixir'], ['scala', 'Scala'], ['clj', 'Clojure'], ['hs', 'Haskell'],
57
+ ['m', 'Objective-C'], ['mm', 'Objective-C'], ['plist', 'Plist'], ['gradle', 'Gradle'],
58
+ ['xml', 'XML'], ['proto', 'Protocol Buffers'],
59
+ ]);
60
+
61
+ /** 一眼就是「别人写的 / 构建出来的」的目录:这些目录里的字节不计入语言占比。 */
62
+ const VENDOR_PATH_RE = /(^|\/)(vendor|node_modules|third_party|dist|build|Pods)\//;
63
+ /** 数据而不是代码:抓包日志、数据集这类,按扩展名排除(它们不是「用什么语言写的」)。 */
64
+ const DATA_EXT = new Set(['log', 'jsonl', 'ndjson']);
65
+ /** 生成物的形状判据:够大、并且平均每行超长(压缩/内联过的产物一行就是几万字符)。 */
66
+ const MINIFIED_MIN_BYTES = 64 * 1024;
67
+ const MINIFIED_AVG_LINE = 2000;
68
+ const MINIFIED_MAX_LINES = 3;
69
+
70
+ /** 认识的二进制扩展名:语言占比只算文本,这些一个字节都不计入(否则图片会把「其他」撑成大头)。 */
71
+ const BINARY_EXT = new Set([
72
+ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'bmp', 'heic', 'psd', 'ai', 'sketch',
73
+ 'pdf', 'zip', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar', 'tar',
74
+ 'apk', 'aab', 'ipa', 'jar', 'class', 'so', 'dylib', 'dll', 'exe', 'a', 'o', 'wasm',
75
+ 'mp3', 'mp4', 'mov', 'm4a', 'wav', 'webm', 'avi', 'mkv',
76
+ 'ttf', 'otf', 'woff', 'woff2', 'eot',
77
+ 'keystore', 'jks', 'p12', 'p8', 'cer', 'der', 'mobileprovision', 'provisionprofile',
78
+ 'db', 'sqlite', 'sqlite3', 'realm', 'bin', 'dat', 'lockb',
79
+ ]);
80
+
81
+ /** 一行 hunk 的边界:截断时只保留完整的 hunk,不把半行喂给前端。 */
82
+ function cutDiff(text, maxBytes) {
83
+ if (Buffer.byteLength(text) <= maxBytes) return { text, truncated: false };
84
+ let sliced = text.slice(0, maxBytes);
85
+ const boundary = sliced.lastIndexOf('\n@@');
86
+ if (boundary > 0) sliced = sliced.slice(0, boundary + 1);
87
+ return { text: sliced, truncated: true };
88
+ }
89
+
90
+ /** 仓库摘要:分支、是否 detached、上游、领先落后、脏文件数。 */
91
+ async function repoOf(root) {
92
+ const { stdout } = await runGitStrict(root, [
93
+ 'status', '--porcelain=v2', '-z', '--branch', '--untracked-files=all',
94
+ ], { timeoutMs: 8000 });
95
+ const { entries, branch } = parseStatusZ(stdout);
96
+ return {
97
+ isRepo: true,
98
+ root,
99
+ name: basename(root),
100
+ branch: branch.head,
101
+ detached: !branch.head,
102
+ head: branch.oid ? branch.oid.slice(0, 7) : null,
103
+ upstream: branch.upstream || null,
104
+ ahead: branch.upstream ? branch.ahead : null,
105
+ behind: branch.upstream ? branch.behind : null,
106
+ dirtyCount: entries.length,
107
+ conflictedCount: entries.filter((entry) => entry.conflicted).length,
108
+ };
109
+ }
110
+
111
+ /**
112
+ * 变更清单:已暂存 / 未暂存 / 未跟踪 / 冲突四组。
113
+ *
114
+ * 增删行数来自 `--numstat`(二进制给 `-`,归一成 `binary: true` 与 0),未跟踪文件没有 diff,
115
+ * 增删一律给 0;前端要显示多少行得等它点进 diff(那里会把整个文件当新增读出来)。
116
+ */
117
+ async function statusOf(root) {
118
+ const [statusResult, unstagedResult, stagedResult] = await Promise.all([
119
+ runGitStrict(root, ['status', '--porcelain=v2', '-z', '--untracked-files=all'], { timeoutMs: 8000 }),
120
+ runGit(root, ['diff', '--numstat', '-z', '--find-renames'], { timeoutMs: 8000 }),
121
+ runGit(root, ['diff', '--cached', '--numstat', '-z', '--find-renames'], { timeoutMs: 8000 }),
122
+ ]);
123
+ const { entries } = parseStatusZ(statusResult.stdout);
124
+ const unstagedStats = new Map(parseNumstatZ(unstagedResult.stdout).map((item) => [item.path, item]));
125
+ const stagedStats = new Map(parseNumstatZ(stagedResult.stdout).map((item) => [item.path, item]));
126
+
127
+ const staged = [];
128
+ const unstaged = [];
129
+ const untracked = [];
130
+ const conflicted = [];
131
+ for (const entry of entries) {
132
+ const base = { path: entry.path, ...(entry.oldPath ? { oldPath: entry.oldPath } : {}) };
133
+ if (entry.conflicted) {
134
+ conflicted.push({ ...base, status: 'U', additions: 0, deletions: 0, binary: false, stage: 'unstaged' });
135
+ continue;
136
+ }
137
+ if (entry.untracked) {
138
+ untracked.push({ ...base, status: '?', additions: 0, deletions: 0, binary: false, stage: 'untracked' });
139
+ continue;
140
+ }
141
+ if (entry.x !== '.' && entry.x !== ' ') {
142
+ const stats = stagedStats.get(entry.path) || {};
143
+ staged.push({
144
+ ...base,
145
+ status: entry.x,
146
+ additions: stats.additions ?? 0,
147
+ deletions: stats.deletions ?? 0,
148
+ binary: Boolean(stats.binary),
149
+ stage: 'staged',
150
+ });
151
+ }
152
+ if (entry.y !== '.' && entry.y !== ' ') {
153
+ const stats = unstagedStats.get(entry.path) || {};
154
+ unstaged.push({
155
+ ...base,
156
+ status: entry.y,
157
+ additions: stats.additions ?? 0,
158
+ deletions: stats.deletions ?? 0,
159
+ binary: Boolean(stats.binary),
160
+ stage: 'unstaged',
161
+ });
162
+ }
163
+ }
164
+ return { staged, unstaged, untracked, conflicted };
165
+ }
166
+
167
+ /** 未跟踪文件的 diff:git 不认为它有过改动,直接把这个文件当「全部新增」读出来。 */
168
+ async function untrackedDiff(root, path) {
169
+ const full = join(root, path);
170
+ let info;
171
+ try {
172
+ info = await stat(full);
173
+ } catch {
174
+ const failure = new Error('文件不存在');
175
+ failure.code = 'NOT_FOUND';
176
+ throw failure;
177
+ }
178
+ if (info.size > MAX_DIFF_BYTES) {
179
+ return { binary: false, truncated: true, hunks: [{ header: `@@ -0,0 +1,${0} @@`, lines: [] }] };
180
+ }
181
+ let text = '';
182
+ try {
183
+ text = await readFile(full, 'utf8');
184
+ } catch {
185
+ const failure = new Error('读不了这个文件');
186
+ failure.code = 'NOT_FOUND';
187
+ throw failure;
188
+ }
189
+ if (text.includes('\u0000')) return { binary: true, truncated: false, hunks: [] };
190
+ const lines = text.split('\n');
191
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
192
+ return {
193
+ binary: false,
194
+ truncated: false,
195
+ hunks: [{
196
+ header: `@@ -0,0 +1,${lines.length} @@`,
197
+ lines: lines.map((line, index) => ({ kind: 'add', text: line, oldLine: null, newLine: index + 1 })),
198
+ }],
199
+ };
200
+ }
201
+
202
+ /**
203
+ * 单文件 diff。三个来源:工作区(默认)、索引(`staged`)、某次提交(`commit`)。
204
+ *
205
+ * 二进制不画假行:给 `binary: true` 与空 hunk,由界面自己说一句。
206
+ */
207
+ async function diffOf(root, { path, staged = false, commit = null }) {
208
+ if (commit) {
209
+ const result = await runGit(root, [
210
+ 'show', '--no-color', '--no-ext-diff', '--format=', '--find-renames', '--unified=3', commit, '--', path,
211
+ ], { timeoutMs: 10000 });
212
+ if (result.code !== 0) {
213
+ const failure = new Error((result.stderr || '').trim() || '取不到这次提交的 diff');
214
+ failure.code = 'GIT_FAILED';
215
+ throw failure;
216
+ }
217
+ return { binary: false, truncated: false, ...parseDiffText(result.stdout) };
218
+ }
219
+ if (staged) {
220
+ const result = await runGit(root, [
221
+ 'diff', '--cached', '--no-color', '--no-ext-diff', '--find-renames', '--unified=3', '--', path,
222
+ ], { timeoutMs: 8000 });
223
+ if (result.code !== 0) {
224
+ const failure = new Error((result.stderr || '').trim() || '取不到 diff');
225
+ failure.code = 'GIT_FAILED';
226
+ throw failure;
227
+ }
228
+ return { binary: false, truncated: false, ...parseDiffText(result.stdout) };
229
+ }
230
+ const tracked = await runGitStrict(root, ['ls-files', '-z', '--', path], { timeoutMs: 8000 });
231
+ if (!tracked.stdout) {
232
+ // 未跟踪:索引里没有它,git diff 对它一个字都不吐。
233
+ const untracked = await untrackedDiff(root, path);
234
+ return { path, status: '?', additions: untracked.hunks.length ? untracked.hunks[0].lines.length : 0, deletions: 0, ...untracked };
235
+ }
236
+ const result = await runGit(root, [
237
+ 'diff', '--no-color', '--no-ext-diff', '--find-renames', '--unified=3', '--', path,
238
+ ], { timeoutMs: 8000 });
239
+ if (result.code !== 0) {
240
+ const failure = new Error((result.stderr || '').trim() || '取不到 diff');
241
+ failure.code = 'GIT_FAILED';
242
+ throw failure;
243
+ }
244
+ const cut = cutDiff(result.stdout, MAX_DIFF_BYTES);
245
+ const parsed = parseDiffText(cut.text);
246
+ const additions = parsed.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.kind === 'add').length, 0);
247
+ const deletions = parsed.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.kind === 'del').length, 0);
248
+ return { binary: parsed.binary, truncated: cut.truncated, additions, deletions, hunks: parsed.hunks };
249
+ }
250
+
251
+ /**
252
+ * 提交历史(一页)。分页用 `skip` 而不是游标:这一页只做「往下翻」,不做跨设备稳定分页,
253
+ * 少一份游标就少一处对不齐;多给一条用来判 `hasMore`。
254
+ */
255
+ async function logOf(root, { limit = DEFAULT_LOG_LIMIT, skip = 0 } = {}) {
256
+ const pageSize = Math.min(Math.max(Number(limit) || DEFAULT_LOG_LIMIT, 1), MAX_LOG_LIMIT);
257
+ const offset = Math.max(Number(skip) || 0, 0);
258
+ const result = await runGit(root, [
259
+ 'log', `--max-count=${pageSize + 1}`, `--skip=${offset}`, '--numstat', '-z', '--no-color',
260
+ `--format=${RECORD}%H${UNIT}%h${UNIT}%an${UNIT}%aI${UNIT}%s`,
261
+ ], { timeoutMs: 12000, maxBytes: 8 * 1024 * 1024 });
262
+ if (result.code !== 0) {
263
+ // 空仓库(还没有第一个提交)不是错误:历史就是空的。
264
+ if (/does not have any commits|ambiguous argument/i.test(result.stderr || '')) {
265
+ return { commits: [], hasMore: false };
266
+ }
267
+ const failure = new Error((result.stderr || '').trim() || '取不到提交历史');
268
+ failure.code = 'GIT_FAILED';
269
+ throw failure;
270
+ }
271
+ const commits = parseLogZ(result.stdout);
272
+ const hasMore = commits.length > pageSize;
273
+ return { commits: commits.slice(0, pageSize), hasMore };
274
+ }
275
+
276
+ /** 单次提交:信息 + 文件清单(带增删与改名前的路径)。 */
277
+ async function showOf(root, hash) {
278
+ const [meta, body, stats, names] = await Promise.all([
279
+ runGitStrict(root, ['show', '-s', `--format=%H${UNIT}%h${UNIT}%an${UNIT}%aI${UNIT}%s`, hash], { timeoutMs: 8000 }),
280
+ runGitStrict(root, ['show', '-s', '--format=%B', hash], { timeoutMs: 8000 }),
281
+ runGit(root, ['show', '--numstat', '-z', '--format=', '--find-renames', hash], { timeoutMs: 10000 }),
282
+ runGit(root, ['show', '--name-status', '-z', '--format=', '--find-renames', hash], { timeoutMs: 10000 }),
283
+ ]);
284
+ const fields = meta.stdout.trim().split(UNIT);
285
+ const statByPath = new Map(parseNumstatZ(stats.stdout).map((item) => [item.path, item]));
286
+ const files = parseNameStatusZ(names.stdout).map((entry) => {
287
+ const info = statByPath.get(entry.path) || {};
288
+ return {
289
+ path: entry.path,
290
+ ...(entry.oldPath ? { oldPath: entry.oldPath } : {}),
291
+ status: entry.status,
292
+ additions: info.additions ?? 0,
293
+ deletions: info.deletions ?? 0,
294
+ binary: Boolean(info.binary),
295
+ };
296
+ });
297
+ return {
298
+ commit: {
299
+ hash: fields[0] || hash,
300
+ shortHash: fields[1] || hash.slice(0, 7),
301
+ author: fields[2] || '',
302
+ time: fields[3] || '',
303
+ subject: fields[4] || '',
304
+ message: body.stdout.trim(),
305
+ filesChanged: files.length,
306
+ additions: files.reduce((sum, file) => sum + file.additions, 0),
307
+ deletions: files.reduce((sum, file) => sum + file.deletions, 0),
308
+ files,
309
+ },
310
+ };
311
+ }
312
+
313
+ /**
314
+ * 分支清单:本地(可切、可新建)与远端(只读)。
315
+ *
316
+ * 领先 / 落后对每条有上游的分支都给,来源是 git 自己的 `%(upstream:track)`,只认里面的两个
317
+ * 数字(措辞随版本变,数字不会);认不出就给 null,界面按「没上游」显示。
318
+ */
319
+ async function branchesOf(root) {
320
+ const [refs, summary] = await Promise.all([
321
+ runGitStrict(root, [
322
+ 'for-each-ref', 'refs/heads', 'refs/remotes',
323
+ '--format=%(refname)%00%(refname:short)%00%(HEAD)%00%(upstream:short)%00%(objectname:short)%00%(committerdate:iso-strict)%00%(upstream:track)',
324
+ ], { timeoutMs: 10000 }),
325
+ repoOf(root),
326
+ ]);
327
+ const branches = [];
328
+ for (const line of refs.stdout.split('\n')) {
329
+ if (!line) continue;
330
+ const [full, short, head, upstream, shortHash, time, track] = line.split('\0');
331
+ if (!short) continue;
332
+ const remote = full.startsWith('refs/remotes/');
333
+ if (remote && /\/HEAD$/.test(full)) continue;
334
+ const aheadBehind = /ahead (\d+)/.test(track || '') || /behind (\d+)/.test(track || '')
335
+ ? {
336
+ ahead: Number((/ahead (\d+)/.exec(track) || [0, 0])[1]),
337
+ behind: Number((/behind (\d+)/.exec(track) || [0, 0])[1]),
338
+ }
339
+ : null;
340
+ branches.push({
341
+ name: short,
342
+ remote,
343
+ current: head === '*',
344
+ upstream: upstream || null,
345
+ lastCommitShort: shortHash || null,
346
+ lastCommitTime: time || null,
347
+ ahead: aheadBehind ? aheadBehind.ahead : null,
348
+ behind: aheadBehind ? aheadBehind.behind : null,
349
+ });
350
+ }
351
+ return { current: summary.branch, detached: summary.detached, branches };
352
+ }
353
+
354
+ /**
355
+ * 所有远端(origin 排第一,其余按名字)。只读配置,不联网。
356
+ *
357
+ * 为什么不是只看 origin:开发机的 origin 常常是本地镜像 / 另一个远端,真正的 GitHub 仓库挂在
358
+ * upstream 上;只认 origin 会出现「明明关联了 GitHub,页面却说没有」。
359
+ */
360
+ async function remotesOf(root) {
361
+ const list = await runGit(root, ['remote'], { timeoutMs: 4000 });
362
+ const names = (list.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean);
363
+ names.sort((a, b) => (a === 'origin' ? -1 : b === 'origin' ? 1 : a < b ? -1 : a > b ? 1 : 0));
364
+ const remotes = [];
365
+ for (const name of names) {
366
+ const url = await runGit(root, ['config', '--get', `remote.${name}.url`], { timeoutMs: 4000 });
367
+ const value = (url.stdout || '').trim();
368
+ if (value) remotes.push({ name, url: value });
369
+ }
370
+ return remotes;
371
+ }
372
+
373
+ /** 从远端地址里认出 github 的 owner/repo;不是 github 就返回 null。 */
374
+ function githubTargetOf(remote) {
375
+ if (!remote) return null;
376
+ const https = /^(?:https?:\/\/)(?:[^@/]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(remote);
377
+ if (https) return { owner: https[1], repo: https[2] };
378
+ const ssh = /^(?:ssh:\/\/)?git@github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/.exec(remote);
379
+ if (ssh) return { owner: ssh[1], repo: ssh[2] };
380
+ return null;
381
+ }
382
+
383
+ /**
384
+ * GitHub 元信息(星标 / Fork / 关注)。
385
+ *
386
+ * 只取这三个数字,**不取星标曲线**:曲线要把 stargazers 一页页翻完(100 条一页),几千星的
387
+ * 仓库等于几十次请求,撞上匿名限流就是整页变慢;数字本身一次请求就够。
388
+ *
389
+ * 「读不到」要和「没有 GitHub 远端」分开说:私有仓库的匿名请求回 404,跟「这个项目根本没关联
390
+ * GitHub」是两件事,前端那句话必须说对。有 `GITHUB_TOKEN` / `GH_TOKEN` 环境变量时带上它 ——
391
+ * 私有仓库靠这一个头就能读,不必在 App 里输任何东西。
392
+ */
393
+ async function githubOf(root) {
394
+ const remotes = await remotesOf(root);
395
+ const remoteUrl = remotes.length > 0 ? remotes[0].url : null;
396
+ const hit = remotes.find((item) => githubTargetOf(item.url));
397
+ if (!hit) return { remote: remoteUrl, github: null, githubRepo: null, githubError: 'not-github' };
398
+ const target = githubTargetOf(hit.url);
399
+ const repo = `${target.owner}/${target.repo}`;
400
+ const token = (process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '').trim();
401
+ const controller = new AbortController();
402
+ const timer = setTimeout(() => controller.abort(), GITHUB_TIMEOUT_MS);
403
+ try {
404
+ const response = await fetch(`https://api.github.com/repos/${target.owner}/${target.repo}`, {
405
+ headers: {
406
+ accept: 'application/vnd.github+json',
407
+ 'user-agent': 'dsh-mobile-plugin',
408
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
409
+ },
410
+ signal: controller.signal,
411
+ });
412
+ if (!response.ok) return { remote: remoteUrl, github: null, githubRepo: repo, githubError: 'unreadable' };
413
+ const body = await response.json();
414
+ return {
415
+ remote: remoteUrl,
416
+ githubRepo: repo,
417
+ githubError: null,
418
+ github: {
419
+ owner: target.owner,
420
+ repo: target.repo,
421
+ stars: Number(body.stargazers_count) || 0,
422
+ forks: Number(body.forks_count) || 0,
423
+ watchers: Number(body.subscribers_count ?? body.watchers_count) || 0,
424
+ starHistory: [],
425
+ },
426
+ };
427
+ } catch {
428
+ return { remote: remoteUrl, github: null, githubRepo: repo, githubError: 'unreadable' };
429
+ } finally {
430
+ clearTimeout(timer);
431
+ }
432
+ }
433
+
434
+ /** 已跟踪文件清单(概览统计用),有上限。 */
435
+ async function trackedFiles(root) {
436
+ const result = await runGit(root, ['ls-files', '-z'], { timeoutMs: 12000, maxBytes: 16 * 1024 * 1024 });
437
+ if (result.code !== 0) return { files: [], truncated: false };
438
+ const files = result.stdout.split('\0').filter(Boolean);
439
+ const truncated = files.length > MAX_OVERVIEW_FILES;
440
+ return { files: truncated ? files.slice(0, MAX_OVERVIEW_FILES) : files, truncated };
441
+ }
442
+
443
+ /** 语言占比(按字节,与 GitHub 同口径)与代码行数。 */
444
+ async function codeBreakdown(root, files) {
445
+ const bytesByLanguage = new Map();
446
+ let lines = 0;
447
+ let totalBytes = 0;
448
+ let counters = { files: 0, skipped: 0, binary: 0, data: 0, vendored: 0, generated: 0 };
449
+ for (const file of files) {
450
+ let info;
451
+ try {
452
+ info = await stat(join(root, file));
453
+ } catch {
454
+ counters.skipped += 1;
455
+ continue;
456
+ }
457
+ if (!info.isFile()) continue;
458
+ counters.files += 1;
459
+ const ext = extname(file).slice(1).toLowerCase();
460
+ // 图片 / 压缩包 / 字体这些不算「语言」:GitHub 的语言条也不统计它们。
461
+ if (BINARY_EXT.has(ext)) {
462
+ counters.binary += 1;
463
+ continue;
464
+ }
465
+ // 数据(抓包日志之类)不是「用什么语言写的」,构建产物与第三方目录也不算。
466
+ if (DATA_EXT.has(ext)) {
467
+ counters.data += 1;
468
+ continue;
469
+ }
470
+ if (VENDOR_PATH_RE.test(file)) {
471
+ counters.vendored += 1;
472
+ continue;
473
+ }
474
+ let text = null;
475
+ if (info.size <= MAX_FILE_BYTES && totalBytes + info.size <= MAX_TOTAL_BYTES) {
476
+ try {
477
+ text = await readFile(join(root, file), 'utf8');
478
+ totalBytes += info.size;
479
+ } catch {
480
+ counters.skipped += 1;
481
+ }
482
+ }
483
+ // 读到了才发现是二进制(扩展名不认识的那种)也不计语言;没读的大文件按扩展名算文本。
484
+ if (text !== null && text.includes('\u0000')) {
485
+ counters.binary += 1;
486
+ continue;
487
+ }
488
+ // 压缩 / 内联过的产物:几百 KB 只有一两行,或者平均每行上万字符(面板产物就是内联成一行的
489
+ // 一坨 JS+CSS)。不认出来,这两份 HTML 会独占语言条一成多。
490
+ if (text !== null && info.size >= MINIFIED_MIN_BYTES) {
491
+ let newlines = 0;
492
+ for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) newlines += 1;
493
+ const averageLine = info.size / Math.max(1, newlines + 1);
494
+ if (newlines <= MINIFIED_MAX_LINES || averageLine > MINIFIED_AVG_LINE) {
495
+ counters.generated += 1;
496
+ continue;
497
+ }
498
+ }
499
+ const language = LANGUAGES.get(ext) || '其他';
500
+ bytesByLanguage.set(language, (bytesByLanguage.get(language) || 0) + info.size);
501
+ if (text === null) continue;
502
+ for (let index = 0; index < text.length; index += 1) if (text.charCodeAt(index) === 10) lines += 1;
503
+ }
504
+ const total = [...bytesByLanguage.values()].reduce((sum, value) => sum + value, 0);
505
+ const ranked = [...bytesByLanguage.entries()].sort((a, b) => b[1] - a[1]);
506
+ const head = ranked.slice(0, MAX_LANGUAGES);
507
+ const tail = ranked.slice(MAX_LANGUAGES);
508
+ if (tail.length > 0) {
509
+ // 排不进前十的并进「其他」,而不是丢掉:丢掉的字节会让百分比加起来不到 100,
510
+ // 看上去像统计出错;并进去之后「其他」至少是个诚实的兜底。
511
+ const folded = tail.reduce((sum, [, bytes]) => sum + bytes, 0);
512
+ const otherIndex = head.findIndex(([name]) => name === '其他');
513
+ if (otherIndex >= 0) head[otherIndex][1] += folded;
514
+ else head.push(['其他', folded]);
515
+ }
516
+ const languages = head.map(([name, bytes]) => ({ name, percent: total > 0 ? Math.round((bytes / total) * 1000) / 10 : 0 }));
517
+ return { languages, lines, files: counters.files };
518
+ }
519
+
520
+ /** 历史规模与活动:提交数、贡献者、首末提交、标签、近 12 周每周提交数、近 30 天增删。 */
521
+ async function activityOf(root) {
522
+ const [counts, authors, last, roots, tags, recent] = await Promise.all([
523
+ runGit(root, ['rev-list', '--count', 'HEAD'], { timeoutMs: 10000 }),
524
+ runGit(root, ['log', '--format=%ae', '--no-merges'], { timeoutMs: 15000, maxBytes: 8 * 1024 * 1024 }),
525
+ runGit(root, ['log', '-1', '--format=%aI'], { timeoutMs: 6000 }),
526
+ runGit(root, ['rev-list', '--max-parents=0', 'HEAD'], { timeoutMs: 8000 }),
527
+ runGit(root, ['for-each-ref', 'refs/tags', '--format=.'], { timeoutMs: 8000 }),
528
+ runGit(root, ['log', '--since=12.weeks', '--numstat', '-z', '--format=' + RECORD + '%aI'], { timeoutMs: 15000, maxBytes: 16 * 1024 * 1024 }),
529
+ ]);
530
+ const commits = Number((counts.stdout || '').trim()) || 0;
531
+ const contributors = new Set((authors.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean)).size;
532
+ const lastCommit = (last.stdout || '').trim() || null;
533
+ let firstCommit = null;
534
+ const rootCommit = (roots.stdout || '').split('\n').map((line) => line.trim()).find(Boolean);
535
+ if (rootCommit) {
536
+ const shown = await runGit(root, ['show', '-s', '--format=%aI', rootCommit], { timeoutMs: 6000 });
537
+ firstCommit = (shown.stdout || '').trim() || null;
538
+ }
539
+ const tagCount = (tags.stdout || '').split('\n').filter(Boolean).length;
540
+
541
+ const now = Date.now();
542
+ const week = 7 * 24 * 60 * 60 * 1000;
543
+ const weeklyCommits = new Array(12).fill(0);
544
+ let additions30d = 0;
545
+ let deletions30d = 0;
546
+ for (const block of (recent.stdout || '').split(RECORD)) {
547
+ const trimmed = block.replace(/^\n/, '');
548
+ if (!trimmed) continue;
549
+ const [time, ...stats] = trimmed.split('\0');
550
+ const when = Date.parse(time);
551
+ if (!Number.isFinite(when)) continue;
552
+ const weeksAgo = Math.floor((now - when) / week);
553
+ if (weeksAgo >= 0 && weeksAgo < 12) weeklyCommits[11 - weeksAgo] += 1;
554
+ if (now - when <= 30 * 24 * 60 * 60 * 1000) {
555
+ for (const item of parseNumstatZ(stats.join('\0'))) {
556
+ additions30d += item.additions;
557
+ deletions30d += item.deletions;
558
+ }
559
+ }
560
+ }
561
+ return { commits, contributors, firstCommit, lastCommit, tags: tagCount, weeklyCommits, additions30d, deletions30d };
562
+ }
563
+
564
+ /**
565
+ * 仓库概览:远端、语言占比、代码量、近 12 周活动,外加(能取到时)GitHub 的三个数字。
566
+ *
567
+ * 这张卡最贵(这台仓库实测扫一遍约 3 秒),所以:① 结果按仓库缓存一分钟,`fresh` 跳过;
568
+ * ② 每项都自己兜底 —— 语言统计读文件有上限,历史统计失败就退化成 0,GitHub 取不到就整块
569
+ * 不给。它不该把整页拖垮。
570
+ */
571
+ async function overviewOf(root, options = {}) {
572
+ const cached = overviewCache.get(root);
573
+ if (cached && !options.fresh && Date.now() - cached.at < OVERVIEW_CACHE_MS) return cached.value;
574
+ const { files, truncated } = await trackedFiles(root);
575
+ const [breakdown, activity, githubView] = await Promise.all([
576
+ codeBreakdown(root, files),
577
+ activityOf(root),
578
+ githubOf(root),
579
+ ]);
580
+ const value = {
581
+ remote: githubView.remote,
582
+ githubRepo: githubView.githubRepo,
583
+ githubError: githubView.githubError,
584
+ ...(githubView.github ? { github: githubView.github } : {}),
585
+ languages: breakdown.languages,
586
+ code: {
587
+ files: breakdown.files,
588
+ lines: breakdown.lines,
589
+ commits: activity.commits,
590
+ contributors: activity.contributors,
591
+ firstCommit: activity.firstCommit,
592
+ lastCommit: activity.lastCommit,
593
+ tags: activity.tags,
594
+ weeklyCommits: activity.weeklyCommits,
595
+ additions30d: activity.additions30d,
596
+ deletions30d: activity.deletions30d,
597
+ truncated,
598
+ },
599
+ };
600
+ overviewCache.set(root, { at: Date.now(), value });
601
+ return value;
602
+ }
603
+
604
+ module.exports = {
605
+ MAX_DIFF_BYTES,
606
+ MAX_LOG_LIMIT,
607
+ DEFAULT_LOG_LIMIT,
608
+ LANGUAGES,
609
+ repoOf,
610
+ statusOf,
611
+ diffOf,
612
+ logOf,
613
+ showOf,
614
+ branchesOf,
615
+ overviewOf,
616
+ githubTargetOf,
617
+ };
@@ -20,8 +20,25 @@ const { withIdempotency, requestIdOf } = require('./idempotency.js');
20
20
  const { canonicalDeviceKey, rememberDeletedSessionIds } = require('./session-cache.js');
21
21
  const { invalidateSessionList } = require('./sessions.js');
22
22
  const { unwrap } = require('./envelope.js');
23
+ const { collectStats } = require('./stats-collect.cjs');
23
24
  const { queueArchivedClearIds } = require('../session-cleanup.cjs');
24
25
 
26
+ /**
27
+ * 清理前这次补采的预算。
28
+ *
29
+ * 统计页那次只给 `DEFAULT_BUDGET_MS`(1.5 秒)是为了别让用户点开统计转好久;清理是
30
+ * 不可恢复的删除,值得多等几秒把账补全。上限卡在 App 单发请求的 8 秒总预算之内
31
+ * (`app/src/api/core.ts` 的 `REQUEST_TIMEOUT_MS`),否则手机先报超时、清理由这边照旧
32
+ * 执行,用户会以为没成功。
33
+ *
34
+ * 同时这次补采跳过「最后一句」预览(`skipPreview`):那一步要读会话日志,是整轮里
35
+ * 最重的一趟。删日志前先把数字记住,比给即将消失的卡片多留一行摘要重要。
36
+ *
37
+ * 极端多的归档(几千条)仍可能到点停手 —— 彻底兜住要把补采挪到请求之外,那是另一套
38
+ * 生命周期;这里先把「几百条」这个真实量级覆盖掉,不让它再沿用统计页的 1.5 秒。
39
+ */
40
+ const CLEAR_ARCHIVED_COLLECT_BUDGET_MS = 6_000;
41
+
25
42
  /**
26
43
  * POST /api/mobile/v1/workspace/clear-archived(token)
27
44
  * body: { confirm: true } → { ok: true, deleted: N }。confirm 不为 true 一律 400。
@@ -44,6 +61,19 @@ async function handleClearArchived(ctx, req, res) {
44
61
  const archivedIds = Array.isArray(workspaceResponse.value?.archivedSessionIds) ? workspaceResponse.value.archivedSessionIds : [];
45
62
  const selected = archivedIds.filter((id) => typeof id === 'string' && id);
46
63
  if (selected.length === 0) return { status: 200, body: { ok: true, deleted: 0 } };
64
+ // 清理之前先把这批会话的用量最后采一次:之后再读它们的统计就没了(日志要删),
65
+ // 而统计账本要保留历史 —— 漏掉这一刀,最后一段用量会凭空消失。
66
+ // 采不上不挡清理:统计偏旧好过用户点不动这个按钮。
67
+ try {
68
+ await collectStats(ctx, {
69
+ sessionIds: selected,
70
+ force: true,
71
+ budgetMs: CLEAR_ARCHIVED_COLLECT_BUDGET_MS,
72
+ skipPreview: true,
73
+ });
74
+ } catch {
75
+ /* 旁路 */
76
+ }
47
77
  // 排不进队列就整次失败(路由回 500):回「已清理」却没排上,重启之后它们原样回来。
48
78
  queueArchivedClearIds(selected);
49
79
  // 宿主那份快照可能还带着它们,先在本进程压住:手机列表与归档列表立刻看不到。
@@ -55,4 +85,4 @@ async function handleClearArchived(ctx, req, res) {
55
85
  sendJson(res, outcome.status, outcome.body);
56
86
  }
57
87
 
58
- module.exports = { handleClearArchived };
88
+ module.exports = { handleClearArchived, CLEAR_ARCHIVED_COLLECT_BUDGET_MS };