difftab 0.8.1 → 0.10.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/README.md +2 -1
- package/README.zh-CN.md +1 -1
- package/dist/server/main.js +299 -113
- package/dist/web/app.css +1 -1
- package/dist/web/app.js +13 -120
- package/package.json +2 -1
package/dist/server/main.js
CHANGED
|
@@ -400,6 +400,20 @@ async function emptyTree(root) {
|
|
|
400
400
|
return (format.code === 0 ? format.stdout.trim() : "sha1") === "sha256" ? EMPTY_TREE.sha256 : EMPTY_TREE.sha1;
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/server/shared/protocol.ts
|
|
405
|
+
/**
|
|
406
|
+
* 服务端 SSE 心跳周期(约 15s)。**它是协议的一部分,所以定在这里而不是 http/sse.ts**:
|
|
407
|
+
* 后端拿它当发送周期,前端拿它当「对端还在」的判据。两边各写一个 15000,改周期时只改一边
|
|
408
|
+
* 不会报错、也没有任何用例会红——前端的死连接判定就此静默失准,症状只是「有时候刷不出来」。
|
|
409
|
+
*/
|
|
410
|
+
const HEARTBEAT_MS = 15e3;
|
|
411
|
+
/**
|
|
412
|
+
* 一次搜索最多回多少行命中,到了即 `truncated`。**放在协议里**:前端的截断提示要说出这个数,各写
|
|
413
|
+
* 一份时改了一边,页面上说的就不是真的。
|
|
414
|
+
*/
|
|
415
|
+
const MAX_SEARCH_LINES = 2e3;
|
|
416
|
+
|
|
403
417
|
//#endregion
|
|
404
418
|
//#region src/server/git/worktree.ts
|
|
405
419
|
/**
|
|
@@ -419,7 +433,8 @@ const MAX_LINES = 5e4;
|
|
|
419
433
|
* 只在**已判定为二进制之后**才查——单看扩展名会把一个内容是文本的 `.png`(占位符、被 LFS
|
|
420
434
|
* 换成指针的图)送去 `<img>` 里画成一张破图,而它本来有一份能看的文本 diff。
|
|
421
435
|
*
|
|
422
|
-
* **SVG 刻意不在表里**:它是文本,走文本 diff
|
|
436
|
+
* **SVG 刻意不在表里**:它是文本,走文本 diff 更有信息量;进了表,含 NUL 的 `.svg` 会从二进制
|
|
437
|
+
* 那支被当成图放行。它的预览走 `isSvg` / `previewMimeOf` 那一路。表也是 `/api/blob` 的 `Content-Type`
|
|
423
438
|
* 来源——`nosniff` 之下类型错一个字浏览器就不画,所以值必须是精确的 MIME。
|
|
424
439
|
*/
|
|
425
440
|
const IMAGE_MIME = {
|
|
@@ -434,7 +449,24 @@ const IMAGE_MIME = {
|
|
|
434
449
|
};
|
|
435
450
|
/** 这条路径按扩展名算图片时给 MIME,否则 `null`。`posix.extname` 对 `.png` 这种点开头的名字给空串。 */
|
|
436
451
|
function imageMimeOf(path) {
|
|
437
|
-
return IMAGE_MIME[
|
|
452
|
+
return IMAGE_MIME[extensionOf(path)] ?? null;
|
|
453
|
+
}
|
|
454
|
+
function extensionOf(path) {
|
|
455
|
+
return posix.extname(path).slice(1).toLowerCase();
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* 这条路径的文本 payload 要不要带 `preview`。只看扩展名——它挂在**已判定为文本**的那一支上,
|
|
459
|
+
* 与图片表「二进制之后才查」是对称的两半。
|
|
460
|
+
*/
|
|
461
|
+
function isSvg(path) {
|
|
462
|
+
return extensionOf(path) === "svg";
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* `/api/blob` 认的 MIME:图片表并上 svg。**只给字节端点用**,分类链仍只认 `imageMimeOf`——两张
|
|
466
|
+
* 表合成一张时,含 NUL 的 `.svg` 在 payload 里成了图。
|
|
467
|
+
*/
|
|
468
|
+
function previewMimeOf(path) {
|
|
469
|
+
return isSvg(path) ? "image/svg+xml" : imageMimeOf(path);
|
|
438
470
|
}
|
|
439
471
|
/** 坏请求(路径非法 / 目标不在了)。**不叫 `DiffRequestError`**:三条路都会抛它。 */
|
|
440
472
|
var WorktreeError = class extends Error {
|
|
@@ -512,11 +544,11 @@ async function resolveInRepo(root, path, options) {
|
|
|
512
544
|
* 在 **Buffer 上数**而不是 `split('\n').length`:后者为了得到一个数字,把整份文件又切成 N 个
|
|
513
545
|
* 字符串立刻丢掉,而超限那一路根本不需要正文。
|
|
514
546
|
*/
|
|
515
|
-
function countLines(
|
|
516
|
-
if (
|
|
547
|
+
function countLines(text) {
|
|
548
|
+
if (text.length === 0) return 0;
|
|
517
549
|
let lines = 0;
|
|
518
|
-
for (let i =
|
|
519
|
-
return
|
|
550
|
+
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1)) lines += 1;
|
|
551
|
+
return text.lastIndexOf("\n") === text.length - 1 ? lines : lines + 1;
|
|
520
552
|
}
|
|
521
553
|
/**
|
|
522
554
|
* 工作区里一份内容的身份:体积 + mtime。**内容身份,不是时间戳**——前端拿它当 `<img>` 的
|
|
@@ -540,9 +572,13 @@ function worktreeVersion(info) {
|
|
|
540
572
|
* 的一档。与 `text` 一样把 `buffer` 带出去:`/api/blob` 的新侧就是这条链,不另写一份「lstat →
|
|
541
573
|
* 体积 → 读」的副本——上一份副本连 NUL 那道都没有,一个内容是文本的 `.png` 在 payload 里是
|
|
542
574
|
* 文本、在字节端点上却被当图发出去。
|
|
543
|
-
* - **`version` 是这份内容的身份**(`worktreeVersion
|
|
575
|
+
* - **`version` 是这份内容的身份**(`worktreeVersion`),前端拿它决定要不要重取。`text` 也带:
|
|
576
|
+
* SVG 的预览拿它当 `<img>` 的 `v=`。
|
|
577
|
+
* - **`maxLines` 只给 `/api/blob` 的 SVG 新侧放宽**:行数闸挡的是「文本太长、表格画不动」,对一张
|
|
578
|
+
* 图没有意义;旧侧的 `cat-file blob` 本就不数行,不放宽时一个五万行以上、只改了几行的 SVG
|
|
579
|
+
* 会拿到预览,而 After 那张永远取不回来。
|
|
544
580
|
*/
|
|
545
|
-
async function inspectFile(root, path) {
|
|
581
|
+
async function inspectFile(root, path, { maxLines = MAX_LINES } = {}) {
|
|
546
582
|
const { abs } = await resolveInRepo(root, path, { follow: false });
|
|
547
583
|
let info;
|
|
548
584
|
try {
|
|
@@ -571,7 +607,7 @@ async function inspectFile(root, path) {
|
|
|
571
607
|
};
|
|
572
608
|
}
|
|
573
609
|
const lines = countLines(buffer);
|
|
574
|
-
if (lines >
|
|
610
|
+
if (lines > maxLines) return {
|
|
575
611
|
kind: "too-large",
|
|
576
612
|
size: info.size,
|
|
577
613
|
reason: "lines"
|
|
@@ -581,7 +617,8 @@ async function inspectFile(root, path) {
|
|
|
581
617
|
buffer,
|
|
582
618
|
size: info.size,
|
|
583
619
|
lines,
|
|
584
|
-
executable: (info.mode & 73) !== 0
|
|
620
|
+
executable: (info.mode & 73) !== 0,
|
|
621
|
+
version: worktreeVersion(info)
|
|
585
622
|
};
|
|
586
623
|
}
|
|
587
624
|
|
|
@@ -603,8 +640,9 @@ const FIELDS$1 = 6;
|
|
|
603
640
|
* 都在射程内,而白名单只看子命令;
|
|
604
641
|
* - 末尾的 `--`:`rev` 之后的任何东西都不再被当成选项。
|
|
605
642
|
*
|
|
606
|
-
* `rev`
|
|
607
|
-
*
|
|
643
|
+
* `rev` 是一个完整对象名、字面量 `HEAD`,或两个完整对象名拼成的 `<a>..<b>`,每个对象名都必须已过
|
|
644
|
+
* `assertOid`——它拼在 revision 位置,`GIT_LITERAL_PATHSPECS` 管不到那里,`--output=<路径>` 在那个
|
|
645
|
+
* 位置就是一次写文件。
|
|
608
646
|
*/
|
|
609
647
|
function logArgs(rev, count, skip) {
|
|
610
648
|
return [
|
|
@@ -651,6 +689,14 @@ function parseLog(output) {
|
|
|
651
689
|
}
|
|
652
690
|
return commits;
|
|
653
691
|
}
|
|
692
|
+
/** 取了 `PAGE_SIZE + 1` 条的 `log` 输出切成一页:多出来的那一条只用来判 `hasMore`。 */
|
|
693
|
+
function toPage(stdout) {
|
|
694
|
+
const commits = parseLog(stdout);
|
|
695
|
+
return {
|
|
696
|
+
commits: commits.slice(0, 50),
|
|
697
|
+
hasMore: commits.length > 50
|
|
698
|
+
};
|
|
699
|
+
}
|
|
654
700
|
/**
|
|
655
701
|
* 提交列表的一页。`head` 缺省即「从 HEAD 起」,并把 HEAD 此刻的 oid 回传当锚点;之后每一页
|
|
656
702
|
* 都带着它再 `--skip`——按 HEAD 往下数时,agent 在两次翻页之间提交一次,第二页就重复第一页的
|
|
@@ -668,14 +714,25 @@ async function listCommits(root, query) {
|
|
|
668
714
|
};
|
|
669
715
|
throw new Error("HEAD points to a commit that cannot be read");
|
|
670
716
|
}
|
|
671
|
-
const
|
|
717
|
+
const page = toPage(result.stdout);
|
|
672
718
|
return {
|
|
673
|
-
head: query.head ?? commits[0]?.sha ?? null,
|
|
674
|
-
|
|
675
|
-
hasMore: commits.length > 50
|
|
719
|
+
head: query.head ?? page.commits[0]?.sha ?? null,
|
|
720
|
+
...page
|
|
676
721
|
};
|
|
677
722
|
}
|
|
678
723
|
/**
|
|
724
|
+
* 上游有、HEAD 走不到的提交(`<head>..<upstream>`),最新的在前,至多一页。两端都是请求带来的完整
|
|
725
|
+
* 对象名:前端拿 HEAD oid 与它在 refs 里找到的上游 sha 来问,**不写 `HEAD..@{u}`**——`@{u}` 是又一种
|
|
726
|
+
* 要钉的 revision 语法,而且解析的是此刻的上游,未必是前端徽标画着的那一个。任一端不是本仓库里的
|
|
727
|
+
* 提交时 `log` 非零退出,一律 `not-found`。
|
|
728
|
+
*/
|
|
729
|
+
async function listIncoming(root, query) {
|
|
730
|
+
const range = `${assertOid(query.head)}..${assertOid(query.upstream)}`;
|
|
731
|
+
const result = await runGit(logArgs(range, 51), root);
|
|
732
|
+
if (result.code !== 0) throw new WorktreeError("not-found", "no such commit");
|
|
733
|
+
return toPage(result.stdout);
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
679
736
|
* 把请求里的 sha 落成 `CommitRange`。**对比端只在这里定一次**:三条路各自去找父提交时,将来
|
|
680
737
|
* 改一处(比如合并提交换个父)漏改另一处,文件清单、补丁与图片就是对着三个不同的父在比,而
|
|
681
738
|
* 不报错。
|
|
@@ -830,16 +887,16 @@ async function baseBlobSize(root, baseRef, normalizedPath) {
|
|
|
830
887
|
return Number.isSafeInteger(size) && size >= 0 ? size : null;
|
|
831
888
|
}
|
|
832
889
|
/**
|
|
833
|
-
*
|
|
890
|
+
* 取一侧的字节。**图片表与 svg 之外的扩展名一律 400**——不限扩展名的话它就是一个「下载工作区任意文件」
|
|
834
891
|
* 的端点,而 `/api/file` 读任意文本时至少还经过分类链与两道闸。
|
|
835
892
|
*
|
|
836
893
|
* 两侧都再卡一次 5MB(`MAX_BYTES`):payload 已经按侧卡过,但字节是第二次请求,中间文件可以
|
|
837
|
-
* 长大。`new` 侧走 `inspectFile`——与 payload
|
|
838
|
-
* `maxStdoutBytes` 去撞,超限即就地掐断 git。
|
|
894
|
+
* 长大。`new` 侧走 `inspectFile`——与 payload 同一条链:图片要 `image`(NUL ∧ 扩展名),svg 要
|
|
895
|
+
* `text`,两种分类与扩展名必须对得上;`old` 侧带 `maxStdoutBytes` 去撞,超限即就地掐断 git。
|
|
839
896
|
*/
|
|
840
897
|
async function readImageBytes(repo, path, side, commit) {
|
|
841
898
|
const { root } = repo;
|
|
842
|
-
const mime =
|
|
899
|
+
const mime = previewMimeOf(path);
|
|
843
900
|
if (mime === null) throw new WorktreeError("invalid-path", "not an image");
|
|
844
901
|
if (commit !== void 0) {
|
|
845
902
|
const resolved = await resolveCommit(repo, commit);
|
|
@@ -849,13 +906,14 @@ async function readImageBytes(repo, path, side, commit) {
|
|
|
849
906
|
};
|
|
850
907
|
}
|
|
851
908
|
if (side === "new") {
|
|
852
|
-
const
|
|
909
|
+
const svg = isSvg(path);
|
|
910
|
+
const file = await inspectFile(root, path, svg ? { maxLines: Infinity } : {});
|
|
853
911
|
if (file.kind === "too-large") throw new WorktreeError("too-large", "image too large");
|
|
854
|
-
if (file.kind
|
|
855
|
-
return {
|
|
912
|
+
if (file.kind === "image" && !svg || file.kind === "text" && svg) return {
|
|
856
913
|
buffer: file.buffer,
|
|
857
914
|
mime
|
|
858
915
|
};
|
|
916
|
+
throw new WorktreeError("invalid-path", "not an image");
|
|
859
917
|
}
|
|
860
918
|
const [base, { path: normalized }] = await Promise.all([resolveDiffBase(root), resolveInRepo(root, path, { follow: false })]);
|
|
861
919
|
return {
|
|
@@ -986,39 +1044,46 @@ async function worktreeSize(abs) {
|
|
|
986
1044
|
return (await worktreeStat(abs))?.size ?? null;
|
|
987
1045
|
}
|
|
988
1046
|
/**
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
* `lstat` 工作区(不存在即 `null`——已删除)。重命名的旧侧在
|
|
992
|
-
*
|
|
1047
|
+
* 已跟踪图片(以及 SVG 预览)的两侧元数据。**判据是调用方给的**(图片是 numstat 的 `-\t-` ∧
|
|
1048
|
+
* 扩展名,SVG 是文本 ∧ 扩展名),这里只管命中之后的事:旧侧问基准(`cat-file -s`,不存在即
|
|
1049
|
+
* `null`——新增、或基准是空树),新侧 `lstat` 工作区(不存在即 `null`——已删除)。重命名的旧侧在
|
|
1050
|
+
* `oldPath`,`old.path` 填成它,前端拿着直接问 `/api/blob`,不自己拼。
|
|
993
1051
|
*
|
|
994
1052
|
* **`version` 是内容身份,前端拿它决定要不要重取**:旧侧是基准的 oid(`<base>:<path>` 那个 blob
|
|
995
1053
|
* 只在基准换了之后才可能变,而 oid 是 `resolveDiffBase` 那次 `rev-parse` 顺手就有的),新侧是
|
|
996
1054
|
* 体积 + mtime(`worktreeVersion`)。
|
|
997
1055
|
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
* 会说「too large (file is 120 KB)」,而 Files 里同一个文件正常显示——自相矛盾。
|
|
1056
|
+
* 5MB 那道闸不在这里,在 `imagePayload` / `previewOf`:图片超限是整个 `too-large`,SVG 超限只是
|
|
1057
|
+
* 不带预览。
|
|
1001
1058
|
*/
|
|
1002
|
-
async function
|
|
1059
|
+
async function worktreeImageSides(root, base, path, abs, oldPath) {
|
|
1003
1060
|
const basePath = oldPath ?? path;
|
|
1004
1061
|
const [oldSize, info] = await Promise.all([baseBlobSize(root, base.ref, basePath), worktreeStat(abs)]);
|
|
1005
|
-
return
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1062
|
+
return {
|
|
1063
|
+
old: oldSize === null ? null : {
|
|
1064
|
+
path: basePath,
|
|
1065
|
+
size: oldSize,
|
|
1066
|
+
version: base.oid
|
|
1067
|
+
},
|
|
1068
|
+
new: info === null ? null : {
|
|
1069
|
+
path,
|
|
1070
|
+
size: info.size,
|
|
1071
|
+
version: worktreeVersion(info)
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
/** 两侧里大的那个,取不到给 0。 */
|
|
1076
|
+
function largestSide({ old, new: fresh }) {
|
|
1077
|
+
return Math.max(old?.size ?? 0, fresh?.size ?? 0);
|
|
1014
1078
|
}
|
|
1015
1079
|
/**
|
|
1016
|
-
* 两侧元数据 → payload,工作区与提交两条路共用。**5MB 按侧卡**:任一侧超过就整个回 `too-large
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1080
|
+
* 两侧元数据 → payload,工作区与提交两条路共用。**5MB 按侧卡**:任一侧超过就整个回 `too-large`
|
|
1081
|
+
* ——一张 8MB 的 PNG 说「太大」是真话;`size` 取**两侧里大的那个**:固定报工作区那份时,HEAD 里
|
|
1082
|
+
* 8MB 的图被换成 120KB 的,提示会说「too large (file is 120 KB)」,而 Files 里同一个文件正常显示
|
|
1083
|
+
* ——自相矛盾。两份各写时阈值或取哪一侧的体积漂开,同一张图在两个视图里一个说太大、一个照常画。
|
|
1019
1084
|
*/
|
|
1020
|
-
function imagePayload(
|
|
1021
|
-
const largest =
|
|
1085
|
+
function imagePayload(sides) {
|
|
1086
|
+
const largest = largestSide(sides);
|
|
1022
1087
|
if (largest > 5242880) return {
|
|
1023
1088
|
kind: "too-large",
|
|
1024
1089
|
size: largest,
|
|
@@ -1026,11 +1091,17 @@ function imagePayload(old, fresh) {
|
|
|
1026
1091
|
};
|
|
1027
1092
|
return {
|
|
1028
1093
|
kind: "image",
|
|
1029
|
-
|
|
1030
|
-
new: fresh
|
|
1094
|
+
...sides
|
|
1031
1095
|
};
|
|
1032
1096
|
}
|
|
1033
1097
|
/**
|
|
1098
|
+
* SVG 的预览两侧,与 `imagePayload` 同一道 5MB 闸,**超限只是不带预览**:补丁可以只有几行、照常
|
|
1099
|
+
* 能看,而 `/api/blob` 会拒那张图——带上它等于给一枚点了必失败的开关。
|
|
1100
|
+
*/
|
|
1101
|
+
function previewOf$1(sides) {
|
|
1102
|
+
return largestSide(sides) > 5242880 ? void 0 : sides;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1034
1105
|
* 已跟踪文件的补丁,以及三道拒绝。
|
|
1035
1106
|
*
|
|
1036
1107
|
* **已跟踪那一侧卡的是「补丁多大」,不是「文件多大」**:按文件体积拒绝的话,一个 6MB 的
|
|
@@ -1041,7 +1112,7 @@ function imagePayload(old, fresh) {
|
|
|
1041
1112
|
* 顺序:二进制(numstat)→ 行数(numstat)→ 取补丁并卡字节。二进制先答是因为它比「太大」
|
|
1042
1113
|
* 更具体(一个 8MB 的 PNG 说「文件过大」等于把原因说错了);行数排在取补丁之前,是因为它
|
|
1043
1114
|
* 不用付出取补丁的代价就能拦下 5 万行以上的改动。二进制里扩展名在表里的那一支放行成
|
|
1044
|
-
* `image`(`
|
|
1115
|
+
* `image`(`imagePayload`)——扩展名只在 git 已判定二进制之后才查,单看扩展名会把一个内容是
|
|
1045
1116
|
* 文本的 `.png` 画成破图。
|
|
1046
1117
|
*
|
|
1047
1118
|
* `path` / `oldPath` 是 `resolveInRepo` 归一化后的:图片那一路要把它拼进 `<base>:<path>`,
|
|
@@ -1053,53 +1124,109 @@ function imagePayload(old, fresh) {
|
|
|
1053
1124
|
* **重命名条目必须同时传新旧两个路径**:只传新路径时 git 看不到另一侧、无法配对,会把
|
|
1054
1125
|
* 重命名退化成一个全新增文件。两个路径都来自 status 的 `2 ` 记录,无需额外查询。
|
|
1055
1126
|
*/
|
|
1056
|
-
async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
1127
|
+
async function trackedDiff(root, base, path, abs, oldPath, stat, options) {
|
|
1057
1128
|
return gatedPatch(root, {
|
|
1058
1129
|
range: [base.ref],
|
|
1059
1130
|
path,
|
|
1060
1131
|
oldPath,
|
|
1061
1132
|
stat,
|
|
1062
|
-
|
|
1133
|
+
options,
|
|
1134
|
+
sides: () => worktreeImageSides(root, base, path, abs, oldPath),
|
|
1063
1135
|
size: async () => await worktreeSize(abs) ?? 0
|
|
1064
1136
|
});
|
|
1065
1137
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
const
|
|
1075
|
-
|
|
1076
|
-
...range,
|
|
1077
|
-
"-M",
|
|
1078
|
-
"--",
|
|
1079
|
-
path,
|
|
1080
|
-
oldPath
|
|
1081
|
-
] : [
|
|
1138
|
+
/**
|
|
1139
|
+
* 取补丁那一次的 argv。**`--unified` 与 `--inter-hunk-context=0` 都必须显式**:用户配了 `diff.context`
|
|
1140
|
+
* 时 git 照配置给上下文,前端按 3 行推断的文件末尾随之错位;`diff.interHunkContext` 会把相隔几行的
|
|
1141
|
+
* 两段并成一个 hunk,前端在整份上下文里重算的折叠就与 `-U3` 那份对不上。`-w` 只给这
|
|
1142
|
+
* 一次——numstat 那次带上它时,只改了空白的文件不回记录,`null` 的含义从「不在差异里」变成「没有
|
|
1143
|
+
* 非空白的改动」,而它同时是已跟踪判定与两道闸的入口。
|
|
1144
|
+
*/
|
|
1145
|
+
function patchArgs(source, context) {
|
|
1146
|
+
const { range, path, oldPath, options } = source;
|
|
1147
|
+
return [
|
|
1082
1148
|
"diff",
|
|
1083
1149
|
...range,
|
|
1084
|
-
|
|
1085
|
-
|
|
1150
|
+
`--unified=${context}`,
|
|
1151
|
+
"--inter-hunk-context=0",
|
|
1152
|
+
...options.ignoreWhitespace ? ["-w"] : [],
|
|
1153
|
+
...oldPath ? [
|
|
1154
|
+
"-M",
|
|
1155
|
+
"--",
|
|
1156
|
+
path,
|
|
1157
|
+
oldPath
|
|
1158
|
+
] : ["--", path]
|
|
1086
1159
|
];
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* 带着上限取一次补丁。超限回 `null`——**超限是这一路要的答案而不是意外**:git 被就地掐断,补丁
|
|
1163
|
+
* 一个字节都不会发给前端。非零退出在这里没有第二种解释(路径不存在、基准无效都属于坏请求)。
|
|
1164
|
+
*/
|
|
1165
|
+
async function fetchPatch(root, args) {
|
|
1087
1166
|
let result;
|
|
1088
1167
|
try {
|
|
1089
1168
|
result = await runGit(args, root, { maxStdoutBytes: MAX_BYTES });
|
|
1090
1169
|
} catch (cause) {
|
|
1091
|
-
if (cause instanceof GitError && cause.kind === "overflow") return
|
|
1092
|
-
kind: "too-large",
|
|
1093
|
-
size: await source.size(),
|
|
1094
|
-
reason: "size"
|
|
1095
|
-
};
|
|
1170
|
+
if (cause instanceof GitError && cause.kind === "overflow") return null;
|
|
1096
1171
|
throw cause;
|
|
1097
1172
|
}
|
|
1098
1173
|
if (result.code !== 0) throw new WorktreeError("not-found", "no diff available for this path");
|
|
1099
|
-
return
|
|
1100
|
-
|
|
1101
|
-
|
|
1174
|
+
return result.stdout;
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* 补丁头里任一侧是符号链接(mode 120000)。git 对链接给的「正文」是目标字符串,`/api/blob` 取到的
|
|
1178
|
+
* 也是它——带上预览只会是两张取不回来的图。只看第一个 hunk 之前的头部:正文里出现这串数字不算。
|
|
1179
|
+
*/
|
|
1180
|
+
function touchesSymlink(patch) {
|
|
1181
|
+
const end = patch.indexOf("\n@@");
|
|
1182
|
+
const header = end === -1 ? patch : patch.slice(0, end);
|
|
1183
|
+
return /^(?:(?:old|new|new file|deleted file) mode 120000|index \S+ 120000)$/m.test(header);
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* 文本补丁出来之后,SVG 再挂上预览两侧。**只对 svg 才去问两侧**:那是一次 `cat-file -s` 加一次
|
|
1187
|
+
* `lstat`,其余文本一次都不欠。符号链接不带(`touchesSymlink`)。
|
|
1188
|
+
*/
|
|
1189
|
+
async function withPreview(source, payload) {
|
|
1190
|
+
if (!isSvg(source.path) || touchesSymlink(payload.patch)) return payload;
|
|
1191
|
+
const preview = previewOf$1(await source.sides());
|
|
1192
|
+
return preview === void 0 ? payload : {
|
|
1193
|
+
...payload,
|
|
1194
|
+
preview
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
async function gatedPatch(root, source) {
|
|
1198
|
+
const { path, stat } = source;
|
|
1199
|
+
if (stat?.binary) return imageMimeOf(path) === null ? { kind: "binary" } : imagePayload(await source.sides());
|
|
1200
|
+
if (stat && stat.lines > 5e4) return {
|
|
1201
|
+
kind: "too-large",
|
|
1202
|
+
size: await source.size(),
|
|
1203
|
+
reason: "lines"
|
|
1204
|
+
};
|
|
1205
|
+
/**
|
|
1206
|
+
* 整份上下文是 `--unified=<MAX_LINES>`,**过了「补丁不超过 `MAX_LINES` 行」这道闸就一定是整份
|
|
1207
|
+
* 文件**:没包住整份文件的补丁必有一侧连续 `MAX_LINES` 行上下文被收进来,再加至少一行改动就超了。
|
|
1208
|
+
* 过不了闸(含字节超限)时**退回 3 行上下文再取一次、标 `full: false`**,不回 `too-large`:一个
|
|
1209
|
+
* 6MB 的数据文件改一行,3 行上下文的补丁照样看得见。
|
|
1210
|
+
*/
|
|
1211
|
+
if (source.options.fullContext) {
|
|
1212
|
+
const full = await fetchPatch(root, patchArgs(source, MAX_LINES));
|
|
1213
|
+
if (full !== null && countLines(full) <= 5e4) return withPreview(source, {
|
|
1214
|
+
kind: "text",
|
|
1215
|
+
patch: full,
|
|
1216
|
+
full: true
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
const patch = await fetchPatch(root, patchArgs(source, 3));
|
|
1220
|
+
if (patch === null) return {
|
|
1221
|
+
kind: "too-large",
|
|
1222
|
+
size: await source.size(),
|
|
1223
|
+
reason: "size"
|
|
1102
1224
|
};
|
|
1225
|
+
return withPreview(source, {
|
|
1226
|
+
kind: "text",
|
|
1227
|
+
patch,
|
|
1228
|
+
full: false
|
|
1229
|
+
});
|
|
1103
1230
|
}
|
|
1104
1231
|
/** unified diff 的头四行。未跟踪的一切都是「新增」,左侧恒为 /dev/null。 */
|
|
1105
1232
|
function newFileHeader(path, mode) {
|
|
@@ -1155,7 +1282,15 @@ async function untrackedDiff(root, path) {
|
|
|
1155
1282
|
...head,
|
|
1156
1283
|
`@@ -0,0 +1,${lines.length} @@`,
|
|
1157
1284
|
...body
|
|
1158
|
-
].join("\n")}\n
|
|
1285
|
+
].join("\n")}\n`,
|
|
1286
|
+
...isSvg(path) && { preview: {
|
|
1287
|
+
old: null,
|
|
1288
|
+
new: {
|
|
1289
|
+
path,
|
|
1290
|
+
size: file.size,
|
|
1291
|
+
version: file.version
|
|
1292
|
+
}
|
|
1293
|
+
} }
|
|
1159
1294
|
};
|
|
1160
1295
|
}
|
|
1161
1296
|
async function readDiff(root, query) {
|
|
@@ -1163,7 +1298,7 @@ async function readDiff(root, query) {
|
|
|
1163
1298
|
const oldPath = query.oldPath ? (await resolveInRepo(root, query.oldPath, { follow: false })).path : void 0;
|
|
1164
1299
|
const [base, listed] = await Promise.all([resolveDiffBase(root), inIndex(root, path)]);
|
|
1165
1300
|
const stat = await readNumstat(root, [base.ref], path, oldPath);
|
|
1166
|
-
if (listed || stat !== null) return trackedDiff(root, base, path, abs, oldPath, stat);
|
|
1301
|
+
if (listed || stat !== null) return trackedDiff(root, base, path, abs, oldPath, stat, query);
|
|
1167
1302
|
return untrackedDiff(root, path);
|
|
1168
1303
|
}
|
|
1169
1304
|
/**
|
|
@@ -1187,27 +1322,31 @@ async function readCommitDiff(repo, sha, query) {
|
|
|
1187
1322
|
path,
|
|
1188
1323
|
oldPath,
|
|
1189
1324
|
stat,
|
|
1190
|
-
|
|
1325
|
+
options: query,
|
|
1326
|
+
sides: () => commitImageSides(root, parent, commit.sha, path, oldPath),
|
|
1191
1327
|
size: async () => await baseBlobSize(root, commit.sha, path) ?? await baseBlobSize(root, parent, oldPath ?? path) ?? 0
|
|
1192
1328
|
});
|
|
1193
1329
|
}
|
|
1194
1330
|
/**
|
|
1195
|
-
*
|
|
1331
|
+
* 提交里图片(以及 SVG 预览)的两侧,**都读对象库**:旧侧 `<parent>:<旧路径或路径>`、新侧 `<sha>:<路径>`,与
|
|
1196
1332
|
* 工作区 diff 的旧侧同一条 `cat-file -s`。`version` 是两端的对象名——提交不可变,这两个身份
|
|
1197
1333
|
* 永远不会换内容。
|
|
1198
1334
|
*/
|
|
1199
|
-
async function
|
|
1335
|
+
async function commitImageSides(root, parent, sha, path, oldPath) {
|
|
1200
1336
|
const basePath = oldPath ?? path;
|
|
1201
1337
|
const [oldSize, newSize] = await Promise.all([baseBlobSize(root, parent, basePath), baseBlobSize(root, sha, path)]);
|
|
1202
|
-
return
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1338
|
+
return {
|
|
1339
|
+
old: oldSize === null ? null : {
|
|
1340
|
+
path: basePath,
|
|
1341
|
+
size: oldSize,
|
|
1342
|
+
version: parent
|
|
1343
|
+
},
|
|
1344
|
+
new: newSize === null ? null : {
|
|
1345
|
+
path,
|
|
1346
|
+
size: newSize,
|
|
1347
|
+
version: sha
|
|
1348
|
+
}
|
|
1349
|
+
};
|
|
1211
1350
|
}
|
|
1212
1351
|
|
|
1213
1352
|
//#endregion
|
|
@@ -1229,7 +1368,11 @@ async function readFileContent(root, path) {
|
|
|
1229
1368
|
};
|
|
1230
1369
|
case "text": return {
|
|
1231
1370
|
kind: "text",
|
|
1232
|
-
content: file.buffer.toString("utf8")
|
|
1371
|
+
content: file.buffer.toString("utf8"),
|
|
1372
|
+
...isSvg(path) && { preview: {
|
|
1373
|
+
size: file.size,
|
|
1374
|
+
version: file.version
|
|
1375
|
+
} }
|
|
1233
1376
|
};
|
|
1234
1377
|
}
|
|
1235
1378
|
}
|
|
@@ -1310,20 +1453,6 @@ async function listRefs(root) {
|
|
|
1310
1453
|
return { refs: parseRefs(await runGitStrict(REF_ARGS, root)) };
|
|
1311
1454
|
}
|
|
1312
1455
|
|
|
1313
|
-
//#endregion
|
|
1314
|
-
//#region src/server/shared/protocol.ts
|
|
1315
|
-
/**
|
|
1316
|
-
* 服务端 SSE 心跳周期(约 15s)。**它是协议的一部分,所以定在这里而不是 http/sse.ts**:
|
|
1317
|
-
* 后端拿它当发送周期,前端拿它当「对端还在」的判据。两边各写一个 15000,改周期时只改一边
|
|
1318
|
-
* 不会报错、也没有任何用例会红——前端的死连接判定就此静默失准,症状只是「有时候刷不出来」。
|
|
1319
|
-
*/
|
|
1320
|
-
const HEARTBEAT_MS = 15e3;
|
|
1321
|
-
/**
|
|
1322
|
-
* 一次搜索最多回多少行命中,到了即 `truncated`。**放在协议里**:前端的截断提示要说出这个数,各写
|
|
1323
|
-
* 一份时改了一边,页面上说的就不是真的。
|
|
1324
|
-
*/
|
|
1325
|
-
const MAX_SEARCH_LINES = 2e3;
|
|
1326
|
-
|
|
1327
1456
|
//#endregion
|
|
1328
1457
|
//#region src/server/git/search.ts
|
|
1329
1458
|
/** 查询的长度上限(UTF-16 码元)。再长就不是「搜一个东西」了,而它整条进 argv。 */
|
|
@@ -2492,6 +2621,12 @@ const SECURITY_HEADERS = {
|
|
|
2492
2621
|
].join("; ")
|
|
2493
2622
|
};
|
|
2494
2623
|
/**
|
|
2624
|
+
* `/api/blob` 回 SVG 时换上的 CSP。`<img>` 里的 SVG 本就不跑脚本、不取外链,但直接打开那条 URL
|
|
2625
|
+
* 时它是一份同源文档:`sandbox` 把它放进不透明源,内联脚本与事件属性一律不跑;`style-src
|
|
2626
|
+
* 'unsafe-inline'` 只为 SVG 自带的 `<style>` 与 `style=` 属性——它们是画图的一部分。
|
|
2627
|
+
*/
|
|
2628
|
+
const SVG_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
|
|
2629
|
+
/**
|
|
2495
2630
|
* 把 token 落成 cookie 的 Set-Cookie 值。`HttpOnly; SameSite=Strict` + 随后 302 掉 query,
|
|
2496
2631
|
* 避免 token 长期滞留在浏览器历史、地址栏和日志中。不设 `Secure`——那会让
|
|
2497
2632
|
* http://127.0.0.1 上的 cookie 直接不生效。
|
|
@@ -2561,10 +2696,11 @@ function sanitizeMessage(message, repoRoot) {
|
|
|
2561
2696
|
if (home) out = out.split(home).join("~");
|
|
2562
2697
|
return out.split("\n")[0]?.trim() ?? "";
|
|
2563
2698
|
}
|
|
2564
|
-
function send(res, status, body, contentType) {
|
|
2699
|
+
function send(res, status, body, contentType, headers = {}) {
|
|
2565
2700
|
const payload = typeof body === "string" ? Buffer.from(body, "utf8") : body;
|
|
2566
2701
|
res.writeHead(status, {
|
|
2567
2702
|
...SECURITY_HEADERS,
|
|
2703
|
+
...headers,
|
|
2568
2704
|
"Content-Type": contentType,
|
|
2569
2705
|
"Content-Length": payload.byteLength
|
|
2570
2706
|
});
|
|
@@ -2573,6 +2709,25 @@ function send(res, status, body, contentType) {
|
|
|
2573
2709
|
function sendJson(res, status, body) {
|
|
2574
2710
|
send(res, status, JSON.stringify(body), "application/json; charset=utf-8");
|
|
2575
2711
|
}
|
|
2712
|
+
const PATCH_OPTION_ERROR = "ignoreWhitespace and fullContext only accept 1";
|
|
2713
|
+
/**
|
|
2714
|
+
* 两个补丁开关:缺省即关,**只认 `1`、其余值回 `null`(400)**。与 `/api/search` 的 `case` / `word`
|
|
2715
|
+
* (非 `1` 即关)刻意不同:唯一的调用方是前端,写成 `true` 的笔误在宽松解析下是一个静默不生效的开关。
|
|
2716
|
+
*/
|
|
2717
|
+
function patchOptionsOf(params) {
|
|
2718
|
+
const flag = (name) => {
|
|
2719
|
+
const value = params.get(name);
|
|
2720
|
+
if (value === null) return false;
|
|
2721
|
+
return value === "1" ? true : null;
|
|
2722
|
+
};
|
|
2723
|
+
const ignoreWhitespace = flag("ignoreWhitespace");
|
|
2724
|
+
const fullContext = flag("fullContext");
|
|
2725
|
+
if (ignoreWhitespace === null || fullContext === null) return null;
|
|
2726
|
+
return {
|
|
2727
|
+
ignoreWhitespace,
|
|
2728
|
+
fullContext
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2576
2731
|
async function startServer(repo, options = {}) {
|
|
2577
2732
|
/**
|
|
2578
2733
|
* 出站错误一律经此发出,**sanitize 就做在这里**。只在兜底的 500 分支上做时,「响应体
|
|
@@ -2738,10 +2893,16 @@ async function startServer(repo, options = {}) {
|
|
|
2738
2893
|
sendError(res, 400, "bad-request", "path is required");
|
|
2739
2894
|
return;
|
|
2740
2895
|
}
|
|
2896
|
+
const options = patchOptionsOf(url.searchParams);
|
|
2897
|
+
if (options === null) {
|
|
2898
|
+
sendError(res, 400, "bad-request", PATCH_OPTION_ERROR);
|
|
2899
|
+
return;
|
|
2900
|
+
}
|
|
2741
2901
|
const oldPath = url.searchParams.get("oldPath");
|
|
2742
2902
|
sendJson(res, 200, await readDiff(repo.root, {
|
|
2743
2903
|
path,
|
|
2744
|
-
...oldPath ? { oldPath } : {}
|
|
2904
|
+
...oldPath ? { oldPath } : {},
|
|
2905
|
+
...options
|
|
2745
2906
|
}));
|
|
2746
2907
|
return;
|
|
2747
2908
|
}
|
|
@@ -2766,10 +2927,11 @@ async function startServer(repo, options = {}) {
|
|
|
2766
2927
|
return;
|
|
2767
2928
|
}
|
|
2768
2929
|
/**
|
|
2769
|
-
*
|
|
2770
|
-
* diff 基准里的 blob、`new` 是工作区那份;带 `commit` 时两侧都读对象库(第一父 /
|
|
2771
|
-
* 按表给精确 MIME(`nosniff`
|
|
2772
|
-
* 5MB 那道闸都在 git
|
|
2930
|
+
* 图片与 SVG 预览的字节,**唯一一个正文不是 JSON 的 API 端点**。`path` / `side` 都必填:
|
|
2931
|
+
* `old` 是 diff 基准里的 blob、`new` 是工作区那份;带 `commit` 时两侧都读对象库(第一父 /
|
|
2932
|
+
* 提交本身)。只服务图片扩展名表里的路径与 svg、`Content-Type` 按表给精确 MIME(`nosniff`
|
|
2933
|
+
* 之下类型错一个字浏览器就不画),其余一律 400——判据与 5MB 那道闸都在 git 那侧,这里只把
|
|
2934
|
+
* 字节发出去。前端加的 `v=` 戳这里不看。
|
|
2773
2935
|
*/
|
|
2774
2936
|
case "/api/blob": {
|
|
2775
2937
|
const path = url.searchParams.get("path");
|
|
@@ -2784,7 +2946,8 @@ async function startServer(repo, options = {}) {
|
|
|
2784
2946
|
}
|
|
2785
2947
|
const commit = url.searchParams.get("commit");
|
|
2786
2948
|
const image = await readImageBytes(repo, path, side, commit ?? void 0);
|
|
2787
|
-
|
|
2949
|
+
const headers = isSvg(path) ? { "Content-Security-Policy": SVG_CSP } : {};
|
|
2950
|
+
send(res, 200, image.buffer, image.mime, headers);
|
|
2788
2951
|
return;
|
|
2789
2952
|
}
|
|
2790
2953
|
/**
|
|
@@ -2805,6 +2968,23 @@ async function startServer(repo, options = {}) {
|
|
|
2805
2968
|
}));
|
|
2806
2969
|
return;
|
|
2807
2970
|
}
|
|
2971
|
+
/**
|
|
2972
|
+
* 上游有、HEAD 走不到的提交,至多一页、不翻页。两个参数都必填;是不是完整对象名归 `assertOid`,
|
|
2973
|
+
* 与 `/api/commits` 的 `head` 同一把钥匙。
|
|
2974
|
+
*/
|
|
2975
|
+
case "/api/commits/incoming": {
|
|
2976
|
+
const head = url.searchParams.get("head");
|
|
2977
|
+
const upstream = url.searchParams.get("upstream");
|
|
2978
|
+
if (!head || !upstream) {
|
|
2979
|
+
sendError(res, 400, "bad-request", "head and upstream are required");
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
sendJson(res, 200, await listIncoming(repo.root, {
|
|
2983
|
+
head,
|
|
2984
|
+
upstream
|
|
2985
|
+
}));
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2808
2988
|
case "/api/commit": {
|
|
2809
2989
|
const sha = url.searchParams.get("sha");
|
|
2810
2990
|
if (!sha) {
|
|
@@ -2822,10 +3002,16 @@ async function startServer(repo, options = {}) {
|
|
|
2822
3002
|
sendError(res, 400, "bad-request", "sha and path are required");
|
|
2823
3003
|
return;
|
|
2824
3004
|
}
|
|
3005
|
+
const options = patchOptionsOf(url.searchParams);
|
|
3006
|
+
if (options === null) {
|
|
3007
|
+
sendError(res, 400, "bad-request", PATCH_OPTION_ERROR);
|
|
3008
|
+
return;
|
|
3009
|
+
}
|
|
2825
3010
|
const oldPath = url.searchParams.get("oldPath");
|
|
2826
3011
|
sendJson(res, 200, await readCommitDiff(repo, sha, {
|
|
2827
3012
|
path,
|
|
2828
|
-
...oldPath ? { oldPath } : {}
|
|
3013
|
+
...oldPath ? { oldPath } : {},
|
|
3014
|
+
...options
|
|
2829
3015
|
}));
|
|
2830
3016
|
return;
|
|
2831
3017
|
}
|