✨ feat(prototype): 代码阅读原型加代码差异一页(新增 / 修改 / 删除三种卡片、卡片里按值 / 行 / 字标增删、删掉的块原位收起可展开、筛选栏加对比基准和只看改动、右边总览条、侧栏未提交的更改);插件菜单加代码差异
This commit is contained in:
1 parent
8cf623c379
commit
3db44f84ae
4 files changed
+910
-42
No files matched your search
@@ -54,11 +54,25 @@
|
|||||||
| `Prototype / Dockerfile · Dockerfile` | luffy 的 `frontend/client-nextjs/Dockerfile` | 每个 FROM 开一个「阶段」(名称、基础镜像 = 镜像名 + 标签),后面的指令是它的子块;`# syntax=` 是「解析指令」;WORKDIR / USER / EXPOSE 各一行「设置」;ARG「构建参数」、ENV「环境变量」,`$X` 读成变量;连着写的 ENV 并成「名称 / 值」表;`COPY --from=deps` 写「从阶段 deps 复制 … → …」,阶段名 Cmd+单击跳过去;连着写的 COPY 并成「从 / 源 / 到」表,属主相同就写在头一行;CMD「启动」写命令和写法(数组不经过 shell) |
|
| `Prototype / Dockerfile · Dockerfile` | luffy 的 `frontend/client-nextjs/Dockerfile` | 每个 FROM 开一个「阶段」(名称、基础镜像 = 镜像名 + 标签),后面的指令是它的子块;`# syntax=` 是「解析指令」;WORKDIR / USER / EXPOSE 各一行「设置」;ARG「构建参数」、ENV「环境变量」,`$X` 读成变量;连着写的 ENV 并成「名称 / 值」表;`COPY --from=deps` 写「从阶段 deps 复制 … → …」,阶段名 Cmd+单击跳过去;连着写的 COPY 并成「从 / 源 / 到」表,属主相同就写在头一行;CMD「启动」写命令和写法(数组不经过 shell) |
|
||||||
| `Prototype / Dockerfile · Dockerfile.backend` | luffy 的 `backend/docker/Dockerfile.backend` 几段 | LABEL「标签」表;`&&` 连起来的 RUN 每条命令一个子块,命令照 Shell 的命令规则读(apt-get、curl、gpg、uv……),管道写「a → b」;`COPY --from=node:22-slim` 写「从镜像」;12 条连着的 COPY 一张表;`uv sync` 一长串选项画成「选项 / 意思」表,重复的 `--no-install-package` 并成一行;HEALTHCHECK「健康检查」:间隔 / 超时 / 启动后宽限 / 失败几次算不健康 / 检查命令 |
|
| `Prototype / Dockerfile · Dockerfile.backend` | luffy 的 `backend/docker/Dockerfile.backend` 几段 | LABEL「标签」表;`&&` 连起来的 RUN 每条命令一个子块,命令照 Shell 的命令规则读(apt-get、curl、gpg、uv……),管道写「a → b」;`COPY --from=node:22-slim` 写「从镜像」;12 条连着的 COPY 一张表;`uv sync` 一长串选项画成「选项 / 意思」表,重复的 `--no-install-package` 并成一行;HEALTHCHECK「健康检查」:间隔 / 超时 / 启动后宽限 / 失败几次算不健康 / 检查命令 |
|
||||||
|
|
||||||
|
## 代码差异(插件菜单「代码差异」,单独一页 `代码阅读器 · 代码差异`)
|
||||||
|
|
||||||
|
侧栏只列改动的文件时(「未提交的更改」对比上一次提交,「选工单」审查对比 PR 的目标分支),预览的卡片标出这个文件相比基准加了什么、改了什么、删了什么。示例文件是 luffy 的 `backend/src/luffy_agent/core/lock/mutex.py`(改之后 = 现在的真实文件,改之前是编的)。配色照 VS Code 编辑器行号旁的改动色带:新增绿、修改蓝、删除红。
|
||||||
|
|
||||||
|
| 画板 | 设计点 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Prototype / 代码差异 · 全部` | 侧栏「未提交的更改」:标题后面写「未提交的更改」,分「暂存的更改」「更改」两组,`mutex.py` 有改动且选中。筛选栏下面多一行:「对比:上一次提交」、「新增 2 · 修改 3 · 删除 1」、「只看改动」开关、上一处 / 下一处。卡片:文件注释按字标增删;`_RELEASE_LUA` 没变;`_RENEW_LUA`、`renew` 整张新增(绿条、淡绿底、左上角「新增」,`renew` 的子块也是绿条但不挂小标);`try_acquire` 修改(说明多一段、参数多一行 `token_prefix`、输出参数 布尔 → 字符串 \| 空,函数体里 `token` 的值改了);`release` 修改在函数体里(`eval` 多传了 `token`,子块照同样的规则标);删掉的 `_is_owner` 收成一行留在 `renew` 和 `release` 之间。右边缘一条总览条,按行号画六处改动的刻度 |
|
||||||
|
| `Prototype / 代码差异 · 只看改动` | 开关打开:连着没变的块折成一条「… N 块没变」(5 条导入 + `_RELEASE_LUA` 折成「… 6 块没变」,改过的函数里没变的子块同样折) |
|
||||||
|
| `Prototype / 代码差异 · 标记说明` | 四种卡片状态、卡片里的四种标记(改值、加行、删行、文字增删,凑在一张示例卡片里)、删除卡片的收起 / 展开、未跟踪新文件的横幅、审查时「对比:PR 目标分支 master」,每样一句短说明 |
|
||||||
|
|
||||||
|
组件集:`Reader / DiffBar`(`Base=commit | pr`、`Only=off | on`)、`Reader / UnchangedStrip`、`Reader / OverviewRuler`、`Reader / NewFileBanner`、`Diff / mutex.py`(每块一个变体 `Line=<行号>`)、`Diff / 已删除`(`State=collapsed | expanded`)、`Diff / 示例`、`Explorer / UncommittedSidebar`,外加主画板那套窗口外壳和 `Explorer / TreeRow`。卡片就是主画板的卡片(`cardShell`、属性行、参数表、导入项),外面套一层改动色条;表格行多一个记号位放 + / −。
|
||||||
|
|
||||||
|
交互:点「只看改动」在两个窗口间切换;点删掉的块展开 / 收起(组件里连的,说明板上的实例也能点)。「… N 块没变」和上一处 / 下一处只画样子,没连。
|
||||||
|
|
||||||
## 导入与运行
|
## 导入与运行
|
||||||
|
|
||||||
1. 打开 Figma Desktop,打开任意 Design 文件。
|
1. 打开 Figma Desktop,打开任意 Design 文件。
|
||||||
2. `Plugins > Development > Import plugin from manifest…`,选本目录的 `manifest.json`(已经导入过就跳过)。
|
2. `Plugins > Development > Import plugin from manifest…`,选本目录的 `manifest.json`(已经导入过就跳过)。
|
||||||
3. `Plugins > Development > 代码阅读器 Design Lab`,选「主画板」「Shell 脚本」或「Dockerfile」,各自新建一页。改过 `code.js` 后要重启 Figma 才会用新代码。
|
3. `Plugins > Development > 代码阅读器 Design Lab`,选「主画板」「Shell 脚本」「Dockerfile」或「代码差异」,各自新建一页。改过 `code.js` 后要重启 Figma 才会用新代码。
|
||||||
|
|
||||||
## 运行条件
|
## 运行条件
|
||||||
|
|
||||||
@@ -142,3 +156,9 @@ node --test test.js
|
|||||||
- [ ] 文件注释:悬停行的按钮贴在行尾、不挤掉名字;注释灰字紧跟名字、长了截断;顶部输入框居中,看得出是 VS Code 自带的样子
|
- [ ] 文件注释:悬停行的按钮贴在行尾、不挤掉名字;注释灰字紧跟名字、长了截断;顶部输入框居中,看得出是 VS Code 自带的样子
|
||||||
- [ ] 审查:选工单弹框居中在顶部、像 VS Code 自带的快速选择;侧栏标题栏工单号不挤掉按钮;A / M / D 颜色和 VS Code 源代码管理里一致
|
- [ ] 审查:选工单弹框居中在顶部、像 VS Code 自带的快速选择;侧栏标题栏工单号不挤掉按钮;A / M / D 颜色和 VS Code 源代码管理里一致
|
||||||
- [ ] 演示:块的编辑 / 预览、右上角编辑 / 预览、侧栏切文件都能点通
|
- [ ] 演示:块的编辑 / 预览、右上角编辑 / 预览、侧栏切文件都能点通
|
||||||
|
- [ ] 代码差异:改动色条在卡片左边、和行号旁的色带一个颜色;没变的卡片和有色条的卡片左边对齐
|
||||||
|
- [ ] 代码差异:「新增」小标压在卡片左上角边框上、不挡图标;行内增删的底色贴着字、长句照常换行不撑出卡片
|
||||||
|
- [ ] 代码差异:参数表的 + / − 在第一格最前面,没变的行名字和有记号的行对齐
|
||||||
|
- [ ] 代码差异:删掉的块收起时一行、虚线红边半透明;点开后旧内容整张划掉
|
||||||
|
- [ ] 代码差异:筛选栏两行不挤;总览条贴在预览右边缘,六段刻度的位置大致对得上改动在文件里的位置
|
||||||
|
- [ ] 代码差异:点「只看改动」两个窗口互跳,折起的细条比卡片矮很多
|
||||||
+700
-38
@@ -892,6 +892,12 @@ const LUCIDE = {
|
|||||||
"ellipsis": [["circle", { cx: "12", cy: "12", r: "1" }], ["circle", { cx: "19", cy: "12", r: "1" }], ["circle", { cx: "5", cy: "12", r: "1" }]],
|
"ellipsis": [["circle", { cx: "12", cy: "12", r: "1" }], ["circle", { cx: "19", cy: "12", r: "1" }], ["circle", { cx: "5", cy: "12", r: "1" }]],
|
||||||
"columns-2": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }], ["path", { d: "M12 3v18" }]],
|
"columns-2": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2" }], ["path", { d: "M12 3v18" }]],
|
||||||
"x": [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]],
|
"x": [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]],
|
||||||
|
"arrow-up": [["path", { d: "m5 12 7-7 7 7" }], ["path", { d: "M12 19V5" }]],
|
||||||
|
"arrow-down": [["path", { d: "M12 5v14" }], ["path", { d: "m19 12-7 7-7-7" }]],
|
||||||
|
"git-compare": [["circle", { cx: "18", cy: "18", r: "3" }], ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M13 6h3a2 2 0 0 1 2 2v7" }], ["path", { d: "M11 18H8a2 2 0 0 1-2-2V9" }]],
|
||||||
|
"git-commit-horizontal": [["circle", { cx: "12", cy: "12", r: "3" }], ["line", { x1: "3", x2: "9", y1: "12", y2: "12" }], ["line", { x1: "15", x2: "21", y1: "12", y2: "12" }]],
|
||||||
|
"flask-conical-off": [["path", { d: "M10 2v2.343" }], ["path", { d: "M14 2v6.343" }], ["path", { d: "m2 2 20 20" }], ["path", { d: "M20 20a2 2 0 0 1-2 2H4a2 2 0 0 1-1.755-2.96l5.227-9.563" }], ["path", { d: "M6.453 15H15" }], ["path", { d: "M8.5 2h7" }]],
|
||||||
|
"chevrons-up-down": [["path", { d: "m7 15 5 5 5-5" }], ["path", { d: "m7 9 5-5 5 5" }]],
|
||||||
// 代码块种类的图标
|
// 代码块种类的图标
|
||||||
"import": [["path", { d: "M12 3v12" }], ["path", { d: "m8 11 4 4 4-4" }], ["path", { d: "M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4" }]],
|
"import": [["path", { d: "M12 3v12" }], ["path", { d: "m8 11 4 4 4-4" }], ["path", { d: "M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4" }]],
|
||||||
"square-function": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }], ["path", { d: "M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3" }], ["path", { d: "M9 11.2h5.7" }]],
|
"square-function": [["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2" }], ["path", { d: "M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3" }], ["path", { d: "M9 11.2h5.7" }]],
|
||||||
@@ -1538,9 +1544,7 @@ function filterBar(ctx, active) {
|
|||||||
let total = 0;
|
let total = 0;
|
||||||
walkTree(CODE_TREE[FILTER_FILE], () => { total += 1; });
|
walkTree(CODE_TREE[FILTER_FILE], () => { total += 1; });
|
||||||
const node = frame(ctx, `Active=${active}`, { component: true, dir: "HORIZONTAL", width: TREE_W, height: FILTER_BAR_H, gap: 8, align: "CENTER" });
|
const node = frame(ctx, `Active=${active}`, { component: true, dir: "HORIZONTAL", width: TREE_W, height: FILTER_BAR_H, gap: 8, align: "CENTER" });
|
||||||
const label = append(node, frame(ctx, "Label", { dir: "HORIZONTAL", width: FILTER_LABEL_W, height: 20, gap: 4, align: "CENTER" }));
|
filterLabel(ctx, node);
|
||||||
append(label, icon(ctx, "list-filter", COLORS.desc, 14));
|
|
||||||
text(ctx, label, "Text", "筛选", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
|
||||||
for (const facet of facets.keys()) {
|
for (const facet of facets.keys()) {
|
||||||
const on = facet === preset.facet;
|
const on = facet === preset.facet;
|
||||||
const chip = append(node, ctx.lib.filterChip.get(on ? "active" : "default").createInstance());
|
const chip = append(node, ctx.lib.filterChip.get(on ? "active" : "default").createInstance());
|
||||||
@@ -1574,6 +1578,14 @@ function filterBar(ctx, active) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 筛选栏最左边的「筛选」:图标 + 字,定宽,下拉菜单的位置按它算。 */
|
||||||
|
function filterLabel(ctx, parent) {
|
||||||
|
const label = append(parent, frame(ctx, "Label", { dir: "HORIZONTAL", width: FILTER_LABEL_W, height: 20, gap: 4, align: "CENTER" }));
|
||||||
|
append(label, icon(ctx, "list-filter", COLORS.desc, 14));
|
||||||
|
text(ctx, label, "Text", "筛选", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
/** Reader / FilterChip — State=default | active:筛选栏里一个属性的下拉框,文字在实例里改;打开着的那个描蓝边。 */
|
/** Reader / FilterChip — State=default | active:筛选栏里一个属性的下拉框,文字在实例里改;打开着的那个描蓝边。 */
|
||||||
function filterChip(ctx, state) {
|
function filterChip(ctx, state) {
|
||||||
const on = state === "active";
|
const on = state === "active";
|
||||||
@@ -1719,6 +1731,13 @@ const KEY_W = 76;
|
|||||||
|
|
||||||
/** 属性表的一行:左边属性名,右边着色的值;twisty 表示下面跟着一组子行。 */
|
/** 属性表的一行:左边属性名,右边着色的值;twisty 表示下面跟着一组子行。 */
|
||||||
function propRow(ctx, parent, label, segments, o = {}) {
|
function propRow(ctx, parent, label, segments, o = {}) {
|
||||||
|
const row = propLabel(ctx, parent, label, o);
|
||||||
|
richText(ctx, row, "Value", segments);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 属性表一行的左半边(属性名),值由调用方往返回的行里放。 */
|
||||||
|
function propLabel(ctx, parent, label, o = {}) {
|
||||||
const row = filled(ctx, parent, `Prop / ${label}`, { dir: "HORIZONTAL", gap: 12 });
|
const row = filled(ctx, parent, `Prop / ${label}`, { dir: "HORIZONTAL", gap: 12 });
|
||||||
const labelStyle = { size: 12.5, lineHeight: 20, color: COLORS.desc };
|
const labelStyle = { size: 12.5, lineHeight: 20, color: COLORS.desc };
|
||||||
if (o.twisty) {
|
if (o.twisty) {
|
||||||
@@ -1727,11 +1746,9 @@ function propRow(ctx, parent, label, segments, o = {}) {
|
|||||||
text(ctx, key, "Label", label, { ...labelStyle, hug: true });
|
text(ctx, key, "Label", label, { ...labelStyle, hug: true });
|
||||||
}
|
}
|
||||||
else text(ctx, row, "Label", label, { ...labelStyle, width: KEY_W });
|
else text(ctx, row, "Label", label, { ...labelStyle, width: KEY_W });
|
||||||
richText(ctx, row, "Value", segments);
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** 输入参数、导入项这类子行的外框:左边一条竖线,右边一列子行。 */
|
/** 输入参数、导入项这类子行的外框:左边一条竖线,右边一列子行。 */
|
||||||
function subRows(ctx, parent) {
|
function subRows(ctx, parent) {
|
||||||
const group = filled(ctx, parent, "Sub rows", { dir: "HORIZONTAL", gap: 10 });
|
const group = filled(ctx, parent, "Sub rows", { dir: "HORIZONTAL", gap: 10 });
|
||||||
@@ -1745,14 +1762,17 @@ function subRows(ctx, parent) {
|
|||||||
* 属性表里的子表格:带边框和表头,columns 是 [列名, 宽度],宽度 0 的列占剩下的宽度;
|
* 属性表里的子表格:带边框和表头,columns 是 [列名, 宽度],宽度 0 的列占剩下的宽度;
|
||||||
* 格子之间、行之间一条分隔线,表头底色深一档、字用说明色。rows 是 [行名, 每格的着色片段]。
|
* 格子之间、行之间一条分隔线,表头底色深一档、字用说明色。rows 是 [行名, 每格的着色片段]。
|
||||||
*/
|
*/
|
||||||
function propTable(ctx, parent, name, columns, rows) {
|
function propTable(ctx, parent, name, columns, rows, o = {}) {
|
||||||
const wrap = filled(ctx, parent, name, {});
|
const wrap = filled(ctx, parent, name, {});
|
||||||
wrap.paddingLeft = 5;
|
wrap.paddingLeft = 5;
|
||||||
const table = append(wrap, frame(ctx, "Table", { width: 10, radius: 4, stroke: COLORS.borderStrong }), true);
|
const table = append(wrap, frame(ctx, "Table", { width: 10, radius: 4, stroke: COLORS.borderStrong }), true);
|
||||||
table.clipsContent = true;
|
table.clipsContent = true;
|
||||||
const row = (rowName, cells, header) => {
|
// o.marks:代码差异页的表,每行第一格前留一个记号位:新加的行 +、删掉的行 −(整行划掉),没变的空着,列照样对齐
|
||||||
const node = filled(ctx, table, rowName, { dir: "HORIZONTAL", fill: header ? COLORS.side : null });
|
const row = (rowName, cells, header, change) => {
|
||||||
|
const node = filled(ctx, table, rowName, { dir: "HORIZONTAL", fill: header ? COLORS.side : change ? DIFF_COLORS[change] : null,
|
||||||
|
fillOpacity: change ? DIFF_ROW_OPACITY : 1 });
|
||||||
cells.forEach((segments, index) => {
|
cells.forEach((segments, index) => {
|
||||||
|
if (o.marks && index === 0) segments = [{ text: DIFF_MARKS[change] || " ", color: change ? DIFF_COLORS[change] : null }, ...segments];
|
||||||
const [label, width] = columns[index];
|
const [label, width] = columns[index];
|
||||||
const cell = width ? append(node, frame(ctx, label, { width, px: 8, py: 3 })) : filled(ctx, node, label, { px: 8, py: 3 });
|
const cell = width ? append(node, frame(ctx, label, { width, px: 8, py: 3 })) : filled(ctx, node, label, { px: 8, py: 3 });
|
||||||
cell.layoutSizingVertical = "FILL";
|
cell.layoutSizingVertical = "FILL";
|
||||||
@@ -1761,7 +1781,8 @@ function propTable(ctx, parent, name, columns, rows) {
|
|||||||
cell.strokeRightWeight = 1;
|
cell.strokeRightWeight = 1;
|
||||||
cell.strokeTopWeight = cell.strokeBottomWeight = cell.strokeLeftWeight = 0;
|
cell.strokeTopWeight = cell.strokeBottomWeight = cell.strokeLeftWeight = 0;
|
||||||
}
|
}
|
||||||
richText(ctx, cell, "Text", segments);
|
const cellText = richText(ctx, cell, "Text", segments);
|
||||||
|
if (change === "del") cellText.textDecoration = "STRIKETHROUGH";
|
||||||
});
|
});
|
||||||
if (!header) {
|
if (!header) {
|
||||||
node.strokes = paint(COLORS.borderStrong);
|
node.strokes = paint(COLORS.borderStrong);
|
||||||
@@ -1770,7 +1791,7 @@ function propTable(ctx, parent, name, columns, rows) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
row("Header", columns.map(([label]) => [{ text: label, color: COLORS.desc }]), true);
|
row("Header", columns.map(([label]) => [{ text: label, color: COLORS.desc }]), true);
|
||||||
for (const [rowName, cells] of rows) row(rowName, cells);
|
for (const [rowName, cells, change] of rows) row(rowName, cells, false, change);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 输入参数:名称 / 类型 / 默认值三列的表格,每个参数一行。 */
|
/** 输入参数:名称 / 类型 / 默认值三列的表格,每个参数一行。 */
|
||||||
@@ -1851,18 +1872,24 @@ function gridCard(ctx, fileKey, item, state, topLevel) {
|
|||||||
return { node, toggle, title };
|
return { node, toggle, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 块的外壳(组件):折叠箭头位、种类标签、内容区、右边的编辑 / 预览切换。内容由调用方往 content 里放。 */
|
/**
|
||||||
|
* 块的外壳(组件):折叠箭头位、种类标签、内容区、右边的编辑 / 预览切换。内容由调用方往 content 里放。
|
||||||
|
* o.parent:不做成组件,放进这个父节点里占满宽度(代码差异页的卡片外面还套一层改动色条);
|
||||||
|
* o.fold:折叠箭头朝向,默认有子块时朝下;o.noTools:不放右上角的按钮(删掉的块)。
|
||||||
|
*/
|
||||||
function cardShell(ctx, name, spec, o = {}) {
|
function cardShell(ctx, name, spec, o = {}) {
|
||||||
const node = frame(ctx, name, { component: true, dir: "HORIZONTAL", width: TREE_W, py: 8, gap: 10, radius: 8,
|
const node = frame(ctx, name, { component: !o.parent, dir: "HORIZONTAL", width: TREE_W, py: 8, gap: 10, radius: 8,
|
||||||
fill: o.editing ? COLORS.side : spec.color, fillOpacity: o.editing ? 1 : 0.07,
|
fill: o.editing ? COLORS.side : spec.color, fillOpacity: o.editing ? 1 : 0.07,
|
||||||
stroke: o.editing ? COLORS.focus : spec.color, strokeOpacity: o.editing ? 1 : 0.35 });
|
stroke: o.editing ? COLORS.focus : spec.color, strokeOpacity: o.editing ? 1 : 0.35 });
|
||||||
|
if (o.parent) append(o.parent, node, true);
|
||||||
node.paddingLeft = 8;
|
node.paddingLeft = 8;
|
||||||
node.paddingRight = 10;
|
node.paddingRight = 10;
|
||||||
const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" }));
|
const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" }));
|
||||||
if (o.kids) append(fold, icon(ctx, "chevron-down", COLORS.desc, 14, { name: "Fold icon" }));
|
const foldIcon = o.fold || (o.kids ? "chevron-down" : null);
|
||||||
|
if (foldIcon) append(fold, icon(ctx, foldIcon, COLORS.desc, 14, { name: "Fold icon" }));
|
||||||
kindBadge(ctx, node, spec);
|
kindBadge(ctx, node, spec);
|
||||||
const content = append(node, frame(ctx, "Content", { width: 10, gap: 3 }), true);
|
const content = append(node, frame(ctx, "Content", { width: 10, gap: 3 }), true);
|
||||||
const toggle = blockTools(ctx, node, o.editing);
|
const toggle = o.noTools ? null : blockTools(ctx, node, o.editing);
|
||||||
return { node, content, toggle };
|
return { node, content, toggle };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2005,6 +2032,8 @@ function quickInput(ctx) {
|
|||||||
*/
|
*/
|
||||||
function explorerHeader(ctx, state) {
|
function explorerHeader(ctx, state) {
|
||||||
const review = state === "review";
|
const review = state === "review";
|
||||||
|
// 未提交的更改:标题后面写「未提交的更改」,按钮照扩展的 view/title 菜单:选工单、未提交的更改、隐藏测试文件、显示全部、刷新
|
||||||
|
const uncommitted = state === "uncommitted";
|
||||||
const node = frame(ctx, `State=${state}`, { component: true, dir: "HORIZONTAL", width: SIDEBAR_W, py: 9, gap: 8,
|
const node = frame(ctx, `State=${state}`, { component: true, dir: "HORIZONTAL", width: SIDEBAR_W, py: 9, gap: 8,
|
||||||
align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side });
|
align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side });
|
||||||
node.paddingLeft = 20;
|
node.paddingLeft = 20;
|
||||||
@@ -2012,9 +2041,11 @@ function explorerHeader(ctx, state) {
|
|||||||
const heading = append(node, frame(ctx, "Heading", { dir: "HORIZONTAL", gap: 6, align: "CENTER" }));
|
const heading = append(node, frame(ctx, "Heading", { dir: "HORIZONTAL", gap: 6, align: "CENTER" }));
|
||||||
text(ctx, heading, "Title", "代码阅读", { hug: true, size: 11, lineHeight: 16, color: COLORS.fg });
|
text(ctx, heading, "Title", "代码阅读", { hug: true, size: 11, lineHeight: 16, color: COLORS.fg });
|
||||||
if (review) text(ctx, heading, "Description", `审查 #${REVIEW.issue.number}`, { hug: true, size: 11, lineHeight: 16, color: COLORS.desc });
|
if (review) text(ctx, heading, "Description", `审查 #${REVIEW.issue.number}`, { hug: true, size: 11, lineHeight: 16, color: COLORS.desc });
|
||||||
const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 10, align: "CENTER" }));
|
if (uncommitted) text(ctx, heading, "Description", "未提交的更改", { hug: true, size: 11, lineHeight: 16, color: COLORS.desc });
|
||||||
const buttons = [["git-pull-request", "选工单"], ...(review ? [["x", "显示全部"]] : [["file-plus"], ["folder-plus"]]),
|
const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: uncommitted ? 8 : 10, align: "CENTER" }));
|
||||||
["refresh-cw"], ["chevrons-down-up"], ["ellipsis"]];
|
const buttons = uncommitted
|
||||||
|
? [["git-pull-request", "选工单"], ["git-commit-horizontal", "未提交的更改"], ["flask-conical-off", "隐藏测试文件"], ["x", "显示全部"], ["refresh-cw", "刷新"]]
|
||||||
|
: [["git-pull-request", "选工单"], ...(review ? [["x", "显示全部"]] : [["file-plus"], ["folder-plus"]]), ["refresh-cw"], ["chevrons-down-up"], ["ellipsis"]];
|
||||||
for (const [name, label] of buttons) append(actions, icon(ctx, name, COLORS.fg, 16, label ? { name: `Action / ${label}` } : {}));
|
for (const [name, label] of buttons) append(actions, icon(ctx, name, COLORS.fg, 16, label ? { name: `Action / ${label}` } : {}));
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
@@ -2121,9 +2152,8 @@ function editorTitle(ctx, lib, mode) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Editor / Breadcrumb — File=<文件> */
|
/** Editor / Breadcrumb — File=<文件>:file 默认取 FILES 里的示例文件,代码差异页传自己的文件。 */
|
||||||
function breadcrumb(ctx, fileKey) {
|
function breadcrumb(ctx, fileKey, file = FILES[fileKey]) {
|
||||||
const file = FILES[fileKey];
|
|
||||||
const node = frame(ctx, `File=${fileKey}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W, px: 16, py: 3, gap: 4,
|
const node = frame(ctx, `File=${fileKey}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W, px: 16, py: 3, gap: 4,
|
||||||
align: "CENTER", fill: COLORS.editor });
|
align: "CENTER", fill: COLORS.editor });
|
||||||
file.crumbs.forEach((part, index) => {
|
file.crumbs.forEach((part, index) => {
|
||||||
@@ -2273,12 +2303,23 @@ function sidebarContent(ctx, pane, lib, selected, o) {
|
|||||||
text(ctx, message, "Text", `#${issue.number} ${issue.title} · PR #${issue.pr} · 改了 ${issue.files} 个文件`,
|
text(ctx, message, "Text", `#${issue.number} ${issue.title} · PR #${issue.pr} · 改了 ${issue.files} 个文件`,
|
||||||
{ size: 12, lineHeight: 17, color: COLORS.desc });
|
{ size: 12, lineHeight: 17, color: COLORS.desc });
|
||||||
}
|
}
|
||||||
|
rootRow(ctx, pane, workspace.root, 0);
|
||||||
|
return treeRows(ctx, pane, lib, sidebarRows(selected, o), selected, o);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 树的根(工作区 / 仓库)那一行:展开箭头 + 名字,depth 是往里缩几层。 */
|
||||||
|
function rootRow(ctx, pane, name, depth) {
|
||||||
const root = filled(ctx, pane, "Root", { dir: "HORIZONTAL", py: 3, gap: 6, align: "CENTER" });
|
const root = filled(ctx, pane, "Root", { dir: "HORIZONTAL", py: 3, gap: 6, align: "CENTER" });
|
||||||
root.paddingLeft = 8;
|
root.paddingLeft = 8 + depth * 14;
|
||||||
append(root, icon(ctx, "chevron-down", COLORS.fg, 16));
|
append(root, icon(ctx, "chevron-down", COLORS.fg, 16));
|
||||||
text(ctx, root, "Name", workspace.root, { hug: true, medium: true, size: 13, lineHeight: 16, color: COLORS.fgStrong });
|
text(ctx, root, "Name", name, { hug: true, medium: true, size: 13, lineHeight: 16, color: COLORS.fgStrong });
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 树里的一行行(Explorer / TreeRow 的实例),按层级缩进;返回示例文件 → 那一行,有鼠标停着的行另记一个 "hover"。 */
|
||||||
|
function treeRows(ctx, pane, lib, items, selected, o = {}) {
|
||||||
const rows = new Map();
|
const rows = new Map();
|
||||||
for (const item of sidebarRows(selected, o)) {
|
for (const item of items) {
|
||||||
const instance = lib.row.get(rowVariant(item, selected, o.hover)).createInstance();
|
const instance = lib.row.get(rowVariant(item, selected, o.hover)).createInstance();
|
||||||
instance.name = `Row / ${item.name}`;
|
instance.name = `Row / ${item.name}`;
|
||||||
append(pane, instance, true);
|
append(pane, instance, true);
|
||||||
@@ -2351,7 +2392,11 @@ function windowBoard(ctx, page, lib, name, o) {
|
|||||||
return timed(`Prototype / ${name}`, () => windowBoardContent(ctx, page, lib, name, o));
|
return timed(`Prototype / ${name}`, () => windowBoardContent(ctx, page, lib, name, o));
|
||||||
}
|
}
|
||||||
|
|
||||||
function windowBoardContent(ctx, page, lib, name, o) {
|
/**
|
||||||
|
* 窗口外壳:标题栏、活动栏、侧栏、编辑器(标签页 + 面包屑)、状态栏,编辑区里放什么由调用方往 editor 里接着放。
|
||||||
|
* o:width / mode / file(FILES 里那种文件描述)/ root(工作区名)/ crumbs(Editor / Breadcrumb 的变体)/ sidebar(main) 往 main 里放侧栏。
|
||||||
|
*/
|
||||||
|
function windowShell(ctx, page, lib, name, o) {
|
||||||
const width = o.width || WINDOW_W;
|
const width = o.width || WINDOW_W;
|
||||||
const editorWidth = width - ACTIVITY_W - SIDEBAR_W - 1;
|
const editorWidth = width - ACTIVITY_W - SIDEBAR_W - 1;
|
||||||
const screen = frame(ctx, `Prototype / ${name}`, { width, height: WINDOW_H, fill: COLORS.editor });
|
const screen = frame(ctx, `Prototype / ${name}`, { width, height: WINDOW_H, fill: COLORS.editor });
|
||||||
@@ -2360,27 +2405,37 @@ function windowBoardContent(ctx, page, lib, name, o) {
|
|||||||
const title = lib.windowTitle.get("active").createInstance();
|
const title = lib.windowTitle.get("active").createInstance();
|
||||||
title.name = "Title bar";
|
title.name = "Title bar";
|
||||||
append(screen, title, true);
|
append(screen, title, true);
|
||||||
setText(title, "Title", `${FILES[o.file].name} — ${WORKSPACES[FILES[o.file].workspace].root}`);
|
setText(title, "Title", `${o.file.name} — ${o.root}`);
|
||||||
const main = append(screen, frame(ctx, "Main", { dir: "HORIZONTAL", width, height: MAIN_H }));
|
const main = append(screen, frame(ctx, "Main", { dir: "HORIZONTAL", width, height: MAIN_H }));
|
||||||
const activity = lib.activity.get("reader").createInstance();
|
const activity = lib.activity.get("reader").createInstance();
|
||||||
activity.name = "Activity bar";
|
activity.name = "Activity bar";
|
||||||
main.appendChild(activity);
|
main.appendChild(activity);
|
||||||
activity.layoutSizingVertical = "FILL";
|
activity.layoutSizingVertical = "FILL";
|
||||||
const side = sidebar(ctx, main, lib, o.file, o);
|
const side = o.sidebar(main);
|
||||||
divider(ctx, main, { vertical: true });
|
divider(ctx, main, { vertical: true });
|
||||||
const editor = append(main, frame(ctx, "Editor", { width: editorWidth, fill: COLORS.editor }));
|
const editor = append(main, frame(ctx, "Editor", { width: editorWidth, fill: COLORS.editor }));
|
||||||
editor.layoutSizingVertical = "FILL";
|
editor.layoutSizingVertical = "FILL";
|
||||||
const titleBar = lib.editorTitle.get(o.mode).createInstance();
|
const titleBar = lib.editorTitle.get(o.mode).createInstance();
|
||||||
titleBar.name = "Editor title";
|
titleBar.name = "Editor title";
|
||||||
const file = FILES[o.file];
|
setText(titleBar, "Name", o.file.name);
|
||||||
setText(titleBar, "Name", file.name);
|
|
||||||
const ext = named(titleBar, "Ext");
|
const ext = named(titleBar, "Ext");
|
||||||
ext.characters = EXT_LABEL[file.ext];
|
ext.characters = EXT_LABEL[o.file.ext];
|
||||||
ext.fills = paint(COLORS[file.ext]);
|
ext.fills = paint(COLORS[o.file.ext]);
|
||||||
append(editor, titleBar, true);
|
append(editor, titleBar, true);
|
||||||
const crumbs = lib.breadcrumb.get(o.file).createInstance();
|
const crumbs = lib.breadcrumb.get(o.crumbs).createInstance();
|
||||||
crumbs.name = "Breadcrumb";
|
crumbs.name = "Breadcrumb";
|
||||||
append(editor, crumbs, true);
|
append(editor, crumbs, true);
|
||||||
|
const status = lib.status.get(o.mode).createInstance();
|
||||||
|
if (o.mode === "edit") setText(status, "Item / TypeScript", o.file.lang === "python" ? "Python" : "TypeScript");
|
||||||
|
status.name = "Status bar";
|
||||||
|
append(screen, status, true);
|
||||||
|
return { screen, editor, titleBar, side, width, editorWidth };
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowBoardContent(ctx, page, lib, name, o) {
|
||||||
|
const file = FILES[o.file];
|
||||||
|
const { screen, editor, titleBar, side, width, editorWidth } = windowShell(ctx, page, lib, name, { width: o.width, mode: o.mode, file,
|
||||||
|
root: WORKSPACES[file.workspace].root, crumbs: o.file, sidebar: (main) => sidebar(ctx, main, lib, o.file, o) });
|
||||||
let filter;
|
let filter;
|
||||||
if (o.filter) {
|
if (o.filter) {
|
||||||
const row = filled(ctx, editor, "Filter row", { align: "CENTER", px: VIEW_PAD, fill: COLORS.editor });
|
const row = filled(ctx, editor, "Filter row", { align: "CENTER", px: VIEW_PAD, fill: COLORS.editor });
|
||||||
@@ -2402,10 +2457,6 @@ function windowBoardContent(ctx, page, lib, name, o) {
|
|||||||
if (item.kids) tree.findOne((node) => node.name === `Children / L${line}`).visible = false;
|
if (item.kids) tree.findOne((node) => node.name === `Children / L${line}`).visible = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const status = lib.status.get(o.mode).createInstance();
|
|
||||||
if (o.mode === "edit") setText(status, "Item / TypeScript", FILES[o.file].lang === "python" ? "Python" : "TypeScript");
|
|
||||||
status.name = "Status bar";
|
|
||||||
append(screen, status, true);
|
|
||||||
const board = { screen, sidebar: side.pane, rows: side.rows, titleBar, view, filter };
|
const board = { screen, sidebar: side.pane, rows: side.rows, titleBar, view, filter };
|
||||||
if (o.picker) board.picker = reviewPicker(ctx, screen, width);
|
if (o.picker) board.picker = reviewPicker(ctx, screen, width);
|
||||||
if (o.tooltip) board.tooltip = tooltip(ctx, screen, o.tooltip, WORKSPACES[FILES[o.file].workspace].rows);
|
if (o.tooltip) board.tooltip = tooltip(ctx, screen, o.tooltip, WORKSPACES[FILES[o.file].workspace].rows);
|
||||||
@@ -2837,20 +2888,631 @@ const SCRIPT_PAGES = {
|
|||||||
] },
|
] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── 代码差异 ────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// 侧栏只列改动的文件时(「未提交的更改」对比上一次提交、「选工单」审查对比 PR 的目标分支),预览的卡片标出这个文件相比基准改了什么:
|
||||||
|
// - 新增的块:左边绿条、底色淡绿、左上角「新增」,里面不再逐行标;它的子块同样绿条淡绿,不再挂「新增」;
|
||||||
|
// - 修改的块:左边蓝条,只标变了的地方:改了的值旧值红底划线、后面接新值绿底;新加的行淡绿底、左边 +;删掉的行淡红底划线、左边 −;
|
||||||
|
// 文字说明按字、词标增删;子块照同样的规则各标各的;
|
||||||
|
// - 删除的块:留在原来的位置,红色虚线边、半透明,默认收成一行「已删除 · 函数 名字」,点开看旧内容(整体划掉、变淡);
|
||||||
|
// - 没变的块照常;整个文件都是新的(未跟踪)时不逐张标绿,顶部一条横幅。
|
||||||
|
// 筛选栏下面多一行:对比基准、改动统计、「只看改动」(连着没变的块折成一条「… N 块没变」)、上一处 / 下一处;预览右边一条总览条按行号画改动刻度。
|
||||||
|
// 配色照 VS Code 编辑器行号旁的改动色带。示例:luffy 的 backend/src/luffy_agent/core/lock/mutex.py,「改之前」是编的。
|
||||||
|
const DIFF_PAGE_NAME = "代码阅读器 · 代码差异";
|
||||||
|
// Dark Modern 的 editorGutter.addedBackground / modifiedBackground / deletedBackground
|
||||||
|
const DIFF_COLORS = { add: "#2ea043", mod: "#0078d4", del: "#f85149" };
|
||||||
|
const DIFF_LABELS = { add: "新增", mod: "修改", del: "删除" };
|
||||||
|
const DIFF_MARKS = { add: "+ ", del: "− " };
|
||||||
|
// 整张新卡片的底色、新加 / 删掉的整行、行内增删的字,各自的不透明度
|
||||||
|
const DIFF_CARD_OPACITY = 0.1;
|
||||||
|
const DIFF_ROW_OPACITY = 0.14;
|
||||||
|
const DIFF_WORD_OPACITY = 0.32;
|
||||||
|
const DIFF_FILE = { name: "mutex.py", ext: "py", lang: "python", lines: 66,
|
||||||
|
crumbs: ["backend", "src", "luffy_agent", "core", "lock", "mutex.py"] };
|
||||||
|
const DIFF_ROOT = "luffy-agent-platform";
|
||||||
|
const DIFF_BASES = { commit: "上一次提交", pr: "PR 目标分支 master" };
|
||||||
|
// 「未提交的更改」时的侧栏:照源代码管理视图分「暂存的更改」「更改」两组,每组下面仓库根、目录层级照留;mutex.py 选中
|
||||||
|
const lockDirs = (depth) => ["backend", "src", "luffy_agent", "core", "lock"].map((name, index) => ({ depth: depth + index, type: "folder", name, open: true }));
|
||||||
|
const UNCOMMITTED_GROUPS = [
|
||||||
|
{ label: "暂存的更改", rows: [...lockDirs(2), { depth: 7, type: "file", name: "registry.py", git: "M" }] },
|
||||||
|
{ label: "更改", rows: [...lockDirs(2), { depth: 7, type: "file", name: "mutex.py", git: "M", file: "mutex" },
|
||||||
|
{ depth: 7, type: "file", name: "routes.py", git: "M" },
|
||||||
|
{ depth: 3, type: "folder", name: "tests", open: true }, { depth: 4, type: "folder", name: "core", open: true },
|
||||||
|
{ depth: 5, type: "file", name: "test_mutex.py", git: "A" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 卡片的写法:值用 rich 的片段写法,{+…} / {-…} 包住新加 / 删掉的字;写不进片段写法的(带方括号、花括号的)直接给片段:
|
||||||
|
// { segs } 没变、{ parts: [{ segs, change }] } 带增删;{ lines: [行…] } 是多段,一段是片段写法、{ segs },或 { add: 写法 } / { del: 写法 } 整段新加 / 删掉。
|
||||||
|
// 属性行 [属性名, 值, { twisty, table, items, color }];表 { columns, rows },一行是格子的写法,或 { add: 格子 } / { del: 格子 }。
|
||||||
|
// line:改之后的源码行号(删掉的块写名字);at:总览条上画刻度的行范围;change:add / mod / del,没变不写。
|
||||||
|
const seg = (text, key) => ({ text, color: key ? COLORS[key] : undefined });
|
||||||
|
const PARAM_COLUMNS = [["名称", 150], ["类型", 220], ["默认值", 0]];
|
||||||
|
const DIFF_PARAMS = { redis: ["[v|redis]", "[t|Any]", ""], key: ["[v|key]", "[t|字符串]", ""], token: ["[v|token]", "[t|字符串]", ""],
|
||||||
|
ttl: ["[v|ttl_sec]", "[t|整数]", ""], ttlOld: ["[v|ttl]", "[t|整数]", ""], prefix: ["[v|token_prefix]", "[t|字符串]", "[q|\"\"]"] };
|
||||||
|
/** 参数表:名字没变,[改动, 名字] 是新加 / 删掉的一行。 */
|
||||||
|
const diffParams = (...names) => ({ columns: PARAM_COLUMNS,
|
||||||
|
rows: names.map((name) => typeof name === "string" ? DIFF_PARAMS[name] : { [name[0]]: DIFF_PARAMS[name[1]] }) });
|
||||||
|
const fnRows = ({ name, doc, access = "公共", decorator, params, returns }) => [
|
||||||
|
["名称", `[f|${name}]`],
|
||||||
|
["说明", doc, { color: COLORS.comment }],
|
||||||
|
["访问级别", `[k|${access}]`],
|
||||||
|
["异步", "[s|是]"],
|
||||||
|
...(decorator ? [["装饰器", decorator]] : []),
|
||||||
|
["输入参数", `[d|${params.rows.length} 个]`, { twisty: true, table: params }],
|
||||||
|
["输出参数", returns],
|
||||||
|
];
|
||||||
|
const importRowsOf = (source, items) => [["来源", `[t|${source}]`], ["导入项", `[d|${items.length} 个]`, { twisty: true, items }]];
|
||||||
|
const luaScript = (op) => ["\"\"\"", "if redis.call('GET', KEYS[1]) == ARGV[1] then", ` return redis.call('${op}', KEYS[1]${op === "EXPIRE" ? ", ARGV[2]" : ""})`,
|
||||||
|
"else", " return 0", "end", "\"\"\""].join("\n");
|
||||||
|
const luaRows = (name, op) => [["名称", `[u|${name}]`], ["访问级别", "[k|私有]"], ["初始值", { segs: [seg(luaScript(op), "tokenString")] }]];
|
||||||
|
const castHead = () => [seg("等待 ", "tokenControl"), seg("cast", "tokenFunction"), seg("("), seg("\"Awaitable[Any]\"", "tokenString"), seg(", ")];
|
||||||
|
const evalHead = (script) => [seg("redis", "tokenVariable"), seg("."), seg("eval", "tokenFunction"), seg("("), seg(script, "tokenConstant"), seg(", "), seg("1", "tokenLiteral")];
|
||||||
|
const RETURN_INT = "[k|返回] [f|int]([v|result]) [s|等于] [n|1]";
|
||||||
|
const DIFF_BLOCKS = [
|
||||||
|
{ line: 1, at: [1, 6], kind: "comment", change: "mod", text: [
|
||||||
|
"Redis 互斥锁: [c|SET NX EX] 抢锁, Lua 比对 token {+再续期 / }再删.",
|
||||||
|
"{+续期与}放锁{-前}{+都}必须比对 token: 持有者慢过了 TTL, 锁已被别人拿走, 直接 {+EXPIRE / }DEL 会{+替别人续命或}把别人的锁删掉. Redis 客户端由调用方传入 (各模块自己的 [c|get_redis]), 本模块不持有连接.",
|
||||||
|
] },
|
||||||
|
{ line: 8, at: [8, 8], kind: "import", rows: importRowsOf("__future__", ["annotations"]) },
|
||||||
|
{ line: 10, at: [10, 10], kind: "import", rows: importRowsOf("uuid", ["整个模块"]) },
|
||||||
|
{ line: 11, at: [11, 11], kind: "import", rows: importRowsOf("collections.abc", ["AsyncIterator", "Awaitable"]) },
|
||||||
|
{ line: 12, at: [12, 12], kind: "import", rows: importRowsOf("contextlib", ["asynccontextmanager"]) },
|
||||||
|
{ line: 13, at: [13, 13], kind: "import", rows: importRowsOf("typing", ["Any", "cast"]) },
|
||||||
|
{ line: 15, at: [15, 21], kind: "variable", rows: luaRows("_RELEASE_LUA", "DEL") },
|
||||||
|
{ line: 23, at: [23, 29], kind: "variable", change: "add", rows: luaRows("_RENEW_LUA", "EXPIRE") },
|
||||||
|
{ line: 32, at: [32, 41], kind: "function", change: "mod", rows: fnRows({ name: "try_acquire",
|
||||||
|
doc: { lines: ["抢锁; 抢到返回持有者 token, 已被占用返回 None.", { add: "[c|token_prefix] 让等锁的一方读 value 就能认出持有者是哪类操作." }] },
|
||||||
|
params: diffParams("redis", "key", "ttl", ["add", "prefix"]), returns: "{-[t|布尔]}{+[t|字符串] | [t|空]}" }), kids: [
|
||||||
|
{ line: 39, at: [39, 39], kind: "variable", change: "mod", rows: [["名称", "[v|token]"], ["初始值", { parts: [
|
||||||
|
{ change: "del", segs: [seg("uuid", "tokenVariable"), seg("."), seg("uuid4", "tokenFunction"), seg("()."), seg("hex", "tokenVariable")] },
|
||||||
|
{ change: "add", segs: [seg("f\"{token_prefix}{uuid.uuid4().hex}\"", "tokenString")] },
|
||||||
|
] }]] },
|
||||||
|
{ line: 40, at: [40, 40], kind: "variable", rows: [["名称", "[v|ok]"], ["初始值", "[k|等待] [v|redis].[f|set]([v|key], [v|token], [v|nx]=[s|真], [v|ex]=[v|ttl_sec])"]] },
|
||||||
|
{ line: 41, at: [41, 41], kind: "return", code: "[k|返回] [v|token] [k|如果] [v|ok] [s|是] [s|真] [k|否则] [s|空]" },
|
||||||
|
] },
|
||||||
|
{ line: 44, at: [44, 49], kind: "function", change: "add", rows: fnRows({ name: "renew",
|
||||||
|
doc: "只在锁仍归 [c|token] 时把 TTL 续回 [c|ttl_sec]; 返回是否续上.", params: diffParams("redis", "key", "token", "ttl"), returns: "[t|布尔]" }), kids: [
|
||||||
|
{ line: 46, at: [46, 48], kind: "variable", rows: [["名称", "[v|result]"], ["初始值", { segs: [...castHead(), ...evalHead("_RENEW_LUA"),
|
||||||
|
...rich(", [v|key], [v|token], [f|str]([v|ttl_sec])"), seg("))")] }]] },
|
||||||
|
{ line: 49, at: [49, 49], kind: "return", code: RETURN_INT },
|
||||||
|
] },
|
||||||
|
// 改之前在 renew 的位置和 release 之间:一个没人用了的辅助函数
|
||||||
|
{ line: "_is_owner", at: [50, 51], kind: "function", change: "del", rows: fnRows({ name: "_is_owner", access: "私有",
|
||||||
|
doc: "锁仍归 [c|token] 时返回真.", params: diffParams("redis", "key", "token"), returns: "[t|布尔]" }) },
|
||||||
|
{ line: 52, at: [52, 55], kind: "function", change: "mod", rows: fnRows({ name: "release",
|
||||||
|
doc: "只在锁仍归 [c|token] 时删掉; 返回是否真的删了.", params: diffParams("redis", "key", "token"), returns: "[t|布尔]" }), kids: [
|
||||||
|
// 改之前 eval 少传了 token,Lua 里 ARGV[1] 是空的,永远删不掉
|
||||||
|
{ line: 54, at: [54, 54], kind: "variable", change: "mod", rows: [["名称", "[v|result]"], ["初始值", { parts: [
|
||||||
|
{ segs: [...castHead(), ...evalHead("_RELEASE_LUA"), ...rich(", [v|key]")] },
|
||||||
|
{ change: "add", segs: rich(", [v|token]") },
|
||||||
|
{ segs: [seg("))")] },
|
||||||
|
] }]] },
|
||||||
|
{ line: 55, at: [55, 55], kind: "return", code: RETURN_INT },
|
||||||
|
] },
|
||||||
|
{ line: 58, at: [58, 66], kind: "function", collapsed: true, rows: fnRows({ name: "hold",
|
||||||
|
doc: "[c|async with hold(...) as held]: 没抢到时 [c|held] 为 False, 也不会去放锁.", decorator: "[f|@asynccontextmanager]",
|
||||||
|
params: diffParams("redis", "key", "ttl"), returns: "[t|AsyncIterator][[t|布尔]]" }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 一段值 → [{ segs, change }]:片段写法里 {+…} / {-…} 切成新加 / 删掉的一截,{ segs } / { parts } 原样。 */
|
||||||
|
function diffParts(value) {
|
||||||
|
if (value.parts) return value.parts;
|
||||||
|
if (value.segs) return [{ segs: value.segs }];
|
||||||
|
const parts = [];
|
||||||
|
const token = /\{([+-])([^}]*)\}/g;
|
||||||
|
let last = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = token.exec(value))) {
|
||||||
|
if (match.index > last) parts.push({ segs: rich(value.slice(last, match.index)) });
|
||||||
|
parts.push({ segs: rich(match[2]), change: match[1] === "+" ? "add" : "del" });
|
||||||
|
last = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
if (last < value.length) parts.push({ segs: rich(value.slice(last)) });
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 没变的一长截按空格、标点切成几小截(一截不超过 limit 个字),一行放不下时按截换行。 */
|
||||||
|
function wrapChunks(segments, limit = 24) {
|
||||||
|
const pieces = segments.flatMap((segment) => (segment.text.match(/[^\s,,。;;::]*[\s,,。;;::]*/g) || []).filter(Boolean)
|
||||||
|
.map((piece) => ({ ...segment, text: piece })));
|
||||||
|
const chunks = [];
|
||||||
|
let current = [];
|
||||||
|
let length = 0;
|
||||||
|
for (const piece of pieces) {
|
||||||
|
if (length && length + piece.text.length > limit) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = [];
|
||||||
|
length = 0;
|
||||||
|
}
|
||||||
|
current.push(piece);
|
||||||
|
length += piece.text.length;
|
||||||
|
}
|
||||||
|
if (current.length) chunks.push(current);
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一行带行内增删的字:没变的照常,删掉的红底划线,新加的绿底,按截自动换行。
|
||||||
|
* 文字层不能给一段字单独加底色,所以增删的每一截套一个带底色的小框;没变的切成小截,换行才不会撑出卡片。
|
||||||
|
*/
|
||||||
|
function diffLine(ctx, parent, name, parts, color) {
|
||||||
|
const line = append(parent, frame(ctx, name, { dir: "HORIZONTAL", width: 10 }), true);
|
||||||
|
line.layoutWrap = "WRAP";
|
||||||
|
parts.forEach((part, index) => {
|
||||||
|
if (!part.change) {
|
||||||
|
wrapChunks(part.segs).forEach((chunk, at) => richText(ctx, line, `Same ${index + 1}.${at + 1}`, chunk, { hug: true, color }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chip = append(line, frame(ctx, `${part.change === "add" ? "Added" : "Removed"} ${index + 1}`, { dir: "HORIZONTAL", radius: 2,
|
||||||
|
fill: DIFF_COLORS[part.change], fillOpacity: DIFF_WORD_OPACITY }));
|
||||||
|
const words = richText(ctx, chip, "Text", part.segs, { hug: true, color });
|
||||||
|
if (part.change === "del") words.textDecoration = "STRIKETHROUGH";
|
||||||
|
});
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一段字:有行内增删按 diffLine 画,没有就是一个文字层。 */
|
||||||
|
function diffText(ctx, parent, name, value, color) {
|
||||||
|
const parts = diffParts(value);
|
||||||
|
if (parts.some((part) => part.change)) return diffLine(ctx, parent, name, parts, color);
|
||||||
|
return richText(ctx, parent, name, parts.flatMap((part) => part.segs), { color });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多段的值(说明):一列,每段一行;整段新加 / 删掉的套一层底色、左边 + / −。 */
|
||||||
|
function diffLines(ctx, parent, lines, color) {
|
||||||
|
const column = append(parent, frame(ctx, "Value", { width: 10, gap: 2 }), true);
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const whole = line.add !== undefined ? "add" : line.del !== undefined ? "del" : null;
|
||||||
|
if (!whole) {
|
||||||
|
diffText(ctx, column, `Line ${index + 1}`, line, color);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const box = filled(ctx, column, `${DIFF_LABELS[whole]} / Line ${index + 1}`, { px: 4, radius: 2, fill: DIFF_COLORS[whole], fillOpacity: DIFF_ROW_OPACITY });
|
||||||
|
const words = richText(ctx, box, "Text", [{ text: DIFF_MARKS[whole], color: DIFF_COLORS[whole] }, ...rich(line[whole])], { color });
|
||||||
|
if (whole === "del") words.textDecoration = "STRIKETHROUGH";
|
||||||
|
});
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 属性行:左边属性名照旧(propLabel),右边的值带增删;下面跟着的参数表、导入项照旧,表格行带 + / − 记号。 */
|
||||||
|
function diffRow(ctx, content, [label, value, o = {}]) {
|
||||||
|
const row = propLabel(ctx, content, label, o);
|
||||||
|
const color = o.color || COLORS.fg;
|
||||||
|
if (value.lines) diffLines(ctx, row, value.lines, color);
|
||||||
|
else diffText(ctx, row, "Value", value, color);
|
||||||
|
if (o.table) {
|
||||||
|
propTable(ctx, content, "Params", o.table.columns, o.table.rows.map((entry) => {
|
||||||
|
const change = Array.isArray(entry) ? undefined : Object.keys(entry)[0];
|
||||||
|
const cells = (change ? entry[change] : entry).map(rich);
|
||||||
|
return [`Param / ${cells[0].map((part) => part.text).join("")}`, cells, change];
|
||||||
|
}), { marks: true });
|
||||||
|
}
|
||||||
|
if (o.items) importRows(ctx, content, o.items.map((name) => ({ name, alias: "" })));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片内容:注释的段落、一行的语句、属性表的行。 */
|
||||||
|
function diffContent(ctx, content, spec) {
|
||||||
|
(spec.text || []).forEach((paragraph, index) => diffText(ctx, content, `Paragraph ${index + 1}`, paragraph, COLORS.desc));
|
||||||
|
if (spec.code) diffText(ctx, content, "Text", spec.code, COLORS.fg);
|
||||||
|
for (const row of spec.rows || []) diffRow(ctx, content, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 块的外框(组件):左边 3 宽的改动色条,没变的块色条透明、卡片照样对齐;卡片由调用方放进来。 */
|
||||||
|
function diffCardFrame(ctx, name, change) {
|
||||||
|
const node = frame(ctx, name, { component: true, dir: "HORIZONTAL", width: TREE_W, gap: 5 });
|
||||||
|
const bar = append(node, frame(ctx, "Change bar", { width: 3, radius: 1.5, fill: change ? DIFF_COLORS[change] : null }));
|
||||||
|
bar.layoutSizingVertical = "FILL";
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 左上角的「新增」小标,压在卡片边框上。 */
|
||||||
|
function addedBadge(ctx, card) {
|
||||||
|
const badge = frame(ctx, "Badge / 新增", { dir: "HORIZONTAL", px: 5, radius: 3, fill: DIFF_COLORS.add });
|
||||||
|
text(ctx, badge, "Text", DIFF_LABELS.add, { hug: true, medium: true, size: 10, lineHeight: 14, color: "#ffffff" });
|
||||||
|
card.appendChild(badge);
|
||||||
|
badge.layoutPositioning = "ABSOLUTE";
|
||||||
|
badge.x = 6;
|
||||||
|
badge.y = -8;
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff / mutex.py — Line=<行号>:一块,卡片照常(cardShell + 属性行 + 参数表),外面套改动色条。
|
||||||
|
* change:这块的改动(新增的块里的子块也算新增);badge:挂「新增」小标,只挂在新增的那一块上,不挂在它的子块上。
|
||||||
|
*/
|
||||||
|
function diffCard(ctx, spec, change, badge) {
|
||||||
|
const node = diffCardFrame(ctx, `Line=${spec.line}`, change);
|
||||||
|
const { node: card, content } = cardShell(ctx, "Card", BLOCK_KINDS[spec.kind], { parent: node, kids: Boolean(spec.kids),
|
||||||
|
fold: spec.collapsed ? "chevron-right" : null });
|
||||||
|
if (spec.kind === "comment") card.dashPattern = [4, 3];
|
||||||
|
if (change === "add") card.fills = paint(DIFF_COLORS.add, DIFF_CARD_OPACITY);
|
||||||
|
if (badge) addedBadge(ctx, card);
|
||||||
|
diffContent(ctx, content, spec);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 块名:函数名、变量名(属性表第一行),注释、语句用种类名。 */
|
||||||
|
function diffBlockTitle(spec) {
|
||||||
|
return spec.rows ? rich(spec.rows[0][1]).map((part) => part.text).join("") : BLOCK_KINDS[spec.kind].label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff / 已删除 — State=collapsed | expanded:删掉的块留在原来的位置,红色虚线边、半透明,右上角没有按钮。
|
||||||
|
* 收起时一行「已删除 · 函数 名字」;展开后下面是旧内容,整张划掉。点卡片在两种状态间切换。
|
||||||
|
*/
|
||||||
|
function deletedCard(ctx, spec, state) {
|
||||||
|
const open = state === "expanded";
|
||||||
|
const kind = BLOCK_KINDS[spec.kind];
|
||||||
|
const node = diffCardFrame(ctx, `State=${state}`, "del");
|
||||||
|
const { node: card, content } = cardShell(ctx, "Card", kind, { parent: node, fold: open ? "chevron-down" : "chevron-right", noTools: true });
|
||||||
|
card.fills = paint(DIFF_COLORS.del, 0.06);
|
||||||
|
card.strokes = paint(DIFF_COLORS.del, 0.8);
|
||||||
|
card.dashPattern = [4, 3];
|
||||||
|
card.opacity = 0.6;
|
||||||
|
richText(ctx, content, "Head", [{ text: "已删除 · ", color: DIFF_COLORS.del }, { text: `${kind.label} `, color: COLORS.desc },
|
||||||
|
{ text: diffBlockTitle(spec), color: COLORS.tokenFunction }]);
|
||||||
|
if (open) {
|
||||||
|
const old = filled(ctx, content, "Old", { gap: 3 });
|
||||||
|
diffContent(ctx, old, spec);
|
||||||
|
for (const words of old.findAll((item) => item.type === "TEXT")) words.textDecoration = "STRIKETHROUGH";
|
||||||
|
}
|
||||||
|
return { node, toggle: card };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reader / UnchangedStrip — State=folded:「只看改动」打开时,连着没变的块折成一条细条,字在实例里改,点开恢复。 */
|
||||||
|
function unchangedStrip(ctx) {
|
||||||
|
const node = diffCardFrame(ctx, "State=folded", null);
|
||||||
|
const strip = filled(ctx, node, "Strip", { dir: "HORIZONTAL", px: 10, py: 2, gap: 6, radius: 4, align: "CENTER", stroke: COLORS.borderStrong });
|
||||||
|
strip.dashPattern = [3, 3];
|
||||||
|
append(strip, icon(ctx, "chevrons-up-down", COLORS.desc, 12));
|
||||||
|
text(ctx, strip, "Text", "… 3 块没变", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
||||||
|
text(ctx, strip, "Hint", "点开", { hug: true, size: 12, lineHeight: 16, color: COLORS.link });
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最外层的块各算一处改动:新增 / 修改 / 删除各几块。 */
|
||||||
|
function diffStats() {
|
||||||
|
const stats = { add: 0, mod: 0, del: 0 };
|
||||||
|
for (const spec of DIFF_BLOCKS) if (spec.change) stats[spec.change] += 1;
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「新增 2 · 修改 3 · 删除 1」,没有的那种不写。 */
|
||||||
|
function diffStatsText() {
|
||||||
|
return Object.entries(diffStats()).filter(([, count]) => count).map(([change, count]) => `${DIFF_LABELS[change]} ${count}`).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件里现在有几块(删掉的不算),筛选栏右边「共 N 块」。 */
|
||||||
|
function diffBlockCount() {
|
||||||
|
let count = 0;
|
||||||
|
const visit = (specs) => specs.forEach((spec) => {
|
||||||
|
if (spec.change !== "del") count += 1;
|
||||||
|
if (spec.kids) visit(spec.kids);
|
||||||
|
});
|
||||||
|
visit(DIFF_BLOCKS);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_ROW_H = 30;
|
||||||
|
const DIFF_BAR_VARIANTS = ["commit/off", "commit/on", "pr/off"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reader / DiffBar — Base=commit | pr, Only=off | on:只列改动的文件时预览顶部的筛选栏,两行。
|
||||||
|
* 上面一行照常(筛选 + 各属性的下拉框 + 块数);下面一行:对比基准、改动统计、「只看改动」开关、上一处 / 下一处。
|
||||||
|
*/
|
||||||
|
function diffBar(ctx, lib, base, only) {
|
||||||
|
const on = only === "on";
|
||||||
|
const node = frame(ctx, `Base=${base}, Only=${only}`, { component: true, width: TREE_W });
|
||||||
|
const filters = filled(ctx, node, "Filters", { dir: "HORIZONTAL", height: FILTER_BAR_H, gap: 8, align: "CENTER" });
|
||||||
|
filterLabel(ctx, filters);
|
||||||
|
for (const facet of ["种类", "访问级别", "异步"]) {
|
||||||
|
const chip = append(filters, lib.filterChip.get("default").createInstance());
|
||||||
|
chip.name = `Chip / ${facet}`;
|
||||||
|
setText(chip, "Text", facet);
|
||||||
|
}
|
||||||
|
append(filters, frame(ctx, "Spacer", { width: 10, height: 1 }), true);
|
||||||
|
text(ctx, filters, "Summary", `共 ${diffBlockCount()} 块`, { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
||||||
|
const changes = filled(ctx, node, "Changes", { dir: "HORIZONTAL", height: DIFF_ROW_H, gap: 8, align: "CENTER" });
|
||||||
|
append(changes, icon(ctx, "git-compare", COLORS.desc, 14));
|
||||||
|
const baseText = text(ctx, changes, "Base", `对比:${DIFF_BASES[base]}`, { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
||||||
|
baseText.setRangeFills(3, baseText.characters.length, paint(COLORS.fg));
|
||||||
|
text(ctx, changes, "Dot", "·", { hug: true, size: 12, lineHeight: 16, color: COLORS.dim });
|
||||||
|
const statsText = diffStatsText();
|
||||||
|
const stats = text(ctx, changes, "Stats", statsText, { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
||||||
|
for (const [change, count] of Object.entries(diffStats())) {
|
||||||
|
const label = `${DIFF_LABELS[change]} ${count}`;
|
||||||
|
const at = statsText.indexOf(label);
|
||||||
|
if (count && at >= 0) stats.setRangeFills(at, at + label.length, paint(DIFF_COLORS[change]));
|
||||||
|
}
|
||||||
|
append(changes, frame(ctx, "Spacer", { width: 10, height: 1 }), true);
|
||||||
|
const toggle = append(changes, frame(ctx, "Only changes", { dir: "HORIZONTAL", px: 6, py: 3, gap: 6, radius: 4, align: "CENTER",
|
||||||
|
fill: on ? COLORS.toggleOn : null }));
|
||||||
|
const track = append(toggle, frame(ctx, "Switch", { dir: "HORIZONTAL", width: 26, height: 14, px: 2, radius: 7, align: "CENTER",
|
||||||
|
justify: on ? "MAX" : "MIN", fill: on ? COLORS.focus : COLORS.input, stroke: on ? COLORS.focus : COLORS.borderStrong }));
|
||||||
|
dot(ctx, track, "Knob", on ? "#ffffff" : COLORS.desc, 10);
|
||||||
|
text(ctx, toggle, "Label", "只看改动", { hug: true, size: 12, lineHeight: 16, color: on ? COLORS.fgStrong : COLORS.fg });
|
||||||
|
const nav = append(changes, frame(ctx, "Nav", { dir: "HORIZONTAL", gap: 2, align: "CENTER" }));
|
||||||
|
for (const [iconName, label] of [["arrow-up", "上一处"], ["arrow-down", "下一处"]]) {
|
||||||
|
const button = append(nav, frame(ctx, `Nav / ${label}`, { dir: "HORIZONTAL", width: 24, height: 22, radius: 4, align: "CENTER", justify: "CENTER" }));
|
||||||
|
append(button, icon(ctx, iconName, COLORS.fg, 14));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 总览条:编辑区里预览上面是标签页(35)、面包屑(22)、两行筛选栏,总览条从这下面贴到底
|
||||||
|
const DIFF_RULER_W = 14;
|
||||||
|
const DIFF_VIEW_TOP = 35 + 22 + FILTER_BAR_H + DIFF_ROW_H;
|
||||||
|
const DIFF_RULER_H = MAIN_H - DIFF_VIEW_TOP;
|
||||||
|
|
||||||
|
/** Reader / OverviewRuler — File=mutex:预览右边缘的竖条,每处改动按它在文件里的行号画一段刻度;删掉的块画在原来的位置,一条细线。 */
|
||||||
|
function overviewRuler(ctx) {
|
||||||
|
const node = frame(ctx, "File=mutex", { component: true, width: DIFF_RULER_W, height: DIFF_RULER_H, fill: COLORS.editor });
|
||||||
|
node.layoutMode = "NONE";
|
||||||
|
node.strokes = paint(COLORS.border);
|
||||||
|
node.strokeLeftWeight = 1;
|
||||||
|
node.strokeTopWeight = node.strokeBottomWeight = node.strokeRightWeight = 0;
|
||||||
|
const scale = (DIFF_RULER_H - 4) / DIFF_FILE.lines;
|
||||||
|
for (const spec of DIFF_BLOCKS.filter((item) => item.change)) {
|
||||||
|
const [from, to] = spec.at;
|
||||||
|
const tick = ctx.api.createRectangle();
|
||||||
|
tick.name = `Tick / ${DIFF_LABELS[spec.change]} ${spec.line}`;
|
||||||
|
tick.resize(8, spec.change === "del" ? 2 : Math.max(4, Math.round((to - from + 1) * scale)));
|
||||||
|
tick.fills = paint(DIFF_COLORS[spec.change]);
|
||||||
|
node.appendChild(tick);
|
||||||
|
tick.x = 4;
|
||||||
|
tick.y = Math.round((from - 1) * scale);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reader / NewFileBanner — State=untracked:整个文件都是新的(未跟踪)时,预览顶部一条横幅,卡片不再逐张标绿。 */
|
||||||
|
function newFileBanner(ctx) {
|
||||||
|
const node = frame(ctx, "State=untracked", { component: true, dir: "HORIZONTAL", width: TREE_W, px: 12, py: 7, gap: 8, radius: 6,
|
||||||
|
align: "CENTER", fill: DIFF_COLORS.add, fillOpacity: DIFF_ROW_OPACITY, stroke: DIFF_COLORS.add, strokeOpacity: 0.5 });
|
||||||
|
append(node, icon(ctx, "file-plus", DIFF_COLORS.add, 16));
|
||||||
|
text(ctx, node, "Text", "新文件,全部是新增", { hug: true, medium: true, size: 12.5, lineHeight: 18, color: COLORS.fgStrong });
|
||||||
|
text(ctx, node, "Detail", "还没被 git 跟踪,上一次提交里没有这个文件", { hug: true, size: 12, lineHeight: 18, color: COLORS.desc });
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explorer / UncommittedSidebar — File=mutex:「未提交的更改」时的侧栏。标题后面写「未提交的更改」,
|
||||||
|
* 照源代码管理视图分「暂存的更改」「更改」两组(组名后面淡色写几个文件),每组下面仓库根和改动的文件,行和审查侧栏同一套。
|
||||||
|
*/
|
||||||
|
function uncommittedSidebar(ctx, lib) {
|
||||||
|
const node = frame(ctx, "File=mutex", { component: true, width: SIDEBAR_W, height: MAIN_H, fill: COLORS.side });
|
||||||
|
node.clipsContent = true;
|
||||||
|
node.overflowDirection = "VERTICAL";
|
||||||
|
const header = lib.explorerHeader.get("uncommitted").createInstance();
|
||||||
|
header.name = "Header";
|
||||||
|
append(node, header, true);
|
||||||
|
for (const group of UNCOMMITTED_GROUPS) {
|
||||||
|
const head = filled(ctx, node, `Group / ${group.label}`, { dir: "HORIZONTAL", py: 3, gap: 6, align: "CENTER" });
|
||||||
|
head.paddingLeft = 8;
|
||||||
|
append(head, icon(ctx, "chevron-down", COLORS.fg, 16));
|
||||||
|
text(ctx, head, "Label", group.label, { hug: true, size: 13, lineHeight: 16, color: COLORS.fg });
|
||||||
|
text(ctx, head, "Count", String(group.rows.filter((row) => row.type === "file").length), { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
||||||
|
rootRow(ctx, node, DIFF_ROOT, 1);
|
||||||
|
treeRows(ctx, node, lib, group.rows, "mutex");
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 说明板上的示例卡片:一张卡片里凑齐四种行内标记(说明里的字、参数表加行和删行、输出参数改值);ttl 改名 ttl_sec 是编的
|
||||||
|
const DIFF_SAMPLE = { line: "示例", kind: "function", change: "mod", rows: fnRows({ name: "renew",
|
||||||
|
doc: "只在锁仍归 [c|token] 时把 TTL 续回 {-[c|ttl]}{+[c|ttl_sec]}; 返回是否续上.",
|
||||||
|
params: diffParams("redis", "key", "token", ["del", "ttlOld"], ["add", "ttl"]), returns: "{-[t|无]}{+[t|布尔]}" }) };
|
||||||
|
|
||||||
|
/** 说明板上一条说明:左边一个小色块写标记名,右边一句话。 */
|
||||||
|
function diffNote(ctx, parent, change, label, caption) {
|
||||||
|
const row = filled(ctx, parent, `Note / ${label}`, { dir: "HORIZONTAL", gap: 10, align: "CENTER" });
|
||||||
|
const tag = append(row, frame(ctx, "Tag", { dir: "HORIZONTAL", px: 6, radius: 3, fill: change ? DIFF_COLORS[change] : COLORS.input,
|
||||||
|
fillOpacity: change ? DIFF_WORD_OPACITY : 1 }));
|
||||||
|
text(ctx, tag, "Text", label, { hug: true, medium: true, size: 12, lineHeight: 18, color: change ? COLORS.fgStrong : COLORS.fg });
|
||||||
|
text(ctx, row, "Caption", caption, { size: 12.5, lineHeight: 18, color: COLORS.desc });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prototype / 代码差异 · 标记说明:四种卡片状态、卡片里的标记、删除卡片的收起 / 展开、新文件横幅、审查时的对比基准,
|
||||||
|
* 每样一句短说明,下面放组件实例。
|
||||||
|
*/
|
||||||
|
function diffLegend(ctx, page, lib) {
|
||||||
|
const board = frame(ctx, "Prototype / 代码差异 · 标记说明", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 10, fill: COLORS.editor });
|
||||||
|
page.appendChild(board);
|
||||||
|
const section = (title) => text(ctx, board, `Section / ${title}`, title, { medium: true, size: 14, lineHeight: 30, color: COLORS.fgStrong });
|
||||||
|
const place = (component, name) => {
|
||||||
|
const instance = component.createInstance();
|
||||||
|
instance.name = name;
|
||||||
|
append(board, instance, true);
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
section("四种卡片状态");
|
||||||
|
diffNote(ctx, board, "add", "新增", "左边绿条、底色淡绿、左上角「新增」;整张都是新的,里面不再逐行标,子块同样绿条淡绿");
|
||||||
|
place(lib.diffCards.get(23), "State / 新增");
|
||||||
|
diffNote(ctx, board, "mod", "修改", "左边蓝条;卡片里只标变了的地方:说明多了一句、参数多了一行、输出参数改了");
|
||||||
|
place(lib.diffCards.get(32), "State / 修改");
|
||||||
|
diffNote(ctx, board, "del", "删除", "留在原来的位置(原来在哪两块之间就在哪),红色虚线边、半透明");
|
||||||
|
place(lib.deleted.get("collapsed").node, "State / 删除");
|
||||||
|
diffNote(ctx, board, null, "没变", "照常");
|
||||||
|
place(lib.diffCards.get(15), "State / 没变");
|
||||||
|
section("卡片里的标记");
|
||||||
|
diffNote(ctx, board, "del", "改值", "旧值红底划线,后面紧跟新值绿底(输出参数 无 → 布尔)");
|
||||||
|
diffNote(ctx, board, "add", "加行", "整行淡绿底,左边 +(参数 ttl_sec)");
|
||||||
|
diffNote(ctx, board, "del", "删行", "整行淡红底、划掉,左边 −(参数 ttl)");
|
||||||
|
diffNote(ctx, board, "add", "文字增删", "说明、注释按字或词标:删掉的字红底划线,加的字绿底");
|
||||||
|
place(lib.sample.get("sample"), "Marks / 示例");
|
||||||
|
section("删除卡片的收起 / 展开");
|
||||||
|
diffNote(ctx, board, "del", "收起", "默认一行「已删除 · 函数 _is_owner」,点一下展开");
|
||||||
|
place(lib.deleted.get("collapsed").node, "Deleted / 收起");
|
||||||
|
diffNote(ctx, board, "del", "展开", "下面是旧内容,整张划掉、变淡;再点收起");
|
||||||
|
place(lib.deleted.get("expanded").node, "Deleted / 展开");
|
||||||
|
section("整个文件都是新的");
|
||||||
|
diffNote(ctx, board, "add", "新文件", "未跟踪的新文件:顶部一条横幅,卡片不再逐张标绿");
|
||||||
|
place(lib.banner.get("untracked"), "Banner / 新文件");
|
||||||
|
place(lib.diffCards.get(15), "Banner / 卡片照常");
|
||||||
|
section("审查工单时的对比基准");
|
||||||
|
diffNote(ctx, board, null, "审查", "「选工单」审查 PR 时,对比 PR 要合进去的目标分支");
|
||||||
|
place(lib.diffBar.get("pr/off"), "Base / PR");
|
||||||
|
return board;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 块在树里的图层名:Block / L<行号>,删掉的块写名字。 */
|
||||||
|
const diffBlockName = (spec) => typeof spec.line === "number" ? `Block / L${spec.line}` : `Block / ${spec.line}`;
|
||||||
|
|
||||||
|
/** 每一块和它画成什么样:新增的块里的子块也算新增,但「新增」小标只挂在最外面那块。删掉的块单独一套组件。 */
|
||||||
|
function diffEntries(specs, inherited = null, out = []) {
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (spec.change !== "del") out.push({ spec, change: spec.change || inherited, badge: spec.change === "add" });
|
||||||
|
if (spec.kids && !spec.collapsed) diffEntries(spec.kids, spec.change === "add" ? "add" : null, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一层块,竖线和缩进同代码块树(levelList)。only:「只看改动」,连着没变的块折成一条「… N 块没变」,
|
||||||
|
* 没变的块连同子块一起折;有改动的块照常,子块里没变的同样折。
|
||||||
|
*/
|
||||||
|
function diffLevel(ctx, parent, lib, specs, only, name, inherited = null) {
|
||||||
|
const { level, list } = levelList(ctx, parent, name);
|
||||||
|
let run = 0;
|
||||||
|
const fold = () => {
|
||||||
|
if (!run) return;
|
||||||
|
const strip = lib.strip.get("folded").createInstance();
|
||||||
|
strip.name = `Unchanged / ${run}`;
|
||||||
|
append(list, strip, true);
|
||||||
|
setText(strip, "Text", `… ${run} 块没变`);
|
||||||
|
run = 0;
|
||||||
|
};
|
||||||
|
for (const spec of specs) {
|
||||||
|
if (only && !spec.change && !inherited) {
|
||||||
|
run += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fold();
|
||||||
|
const component = spec.change === "del" ? lib.deleted.get("collapsed").node : lib.diffCards.get(spec.line);
|
||||||
|
const instance = component.createInstance();
|
||||||
|
instance.name = diffBlockName(spec);
|
||||||
|
append(list, instance, true);
|
||||||
|
if (spec.kids && !spec.collapsed) diffLevel(ctx, list, lib, spec.kids, only, `Children / L${spec.line}`, spec.change === "add" ? "add" : null);
|
||||||
|
}
|
||||||
|
fold();
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 代码差异页的窗口:侧栏「未提交的更改」、编辑区顶部两行筛选栏、标了改动的代码块树、右边缘的总览条。 */
|
||||||
|
function diffWindow(ctx, page, lib, name, only) {
|
||||||
|
return timed(`Prototype / ${name}`, () => {
|
||||||
|
const { screen, editor, side } = windowShell(ctx, page, lib, name, { mode: "preview", file: DIFF_FILE, root: DIFF_ROOT, crumbs: "mutex",
|
||||||
|
sidebar: (main) => {
|
||||||
|
const pane = lib.uncommittedSidebar.get("mutex").createInstance();
|
||||||
|
pane.name = "Sidebar";
|
||||||
|
append(main, pane);
|
||||||
|
pane.layoutSizingVertical = "FILL";
|
||||||
|
return pane;
|
||||||
|
} });
|
||||||
|
const row = filled(ctx, editor, "Filter row", { align: "CENTER", px: VIEW_PAD, fill: COLORS.editor });
|
||||||
|
row.strokes = paint(COLORS.border);
|
||||||
|
row.strokeBottomWeight = 1;
|
||||||
|
row.strokeTopWeight = row.strokeLeftWeight = row.strokeRightWeight = 0;
|
||||||
|
const bar = lib.diffBar.get(`commit/${only ? "on" : "off"}`).createInstance();
|
||||||
|
bar.name = "Diff bar";
|
||||||
|
append(row, bar);
|
||||||
|
const view = filled(ctx, editor, "Preview view", { align: "CENTER", px: VIEW_PAD });
|
||||||
|
view.layoutSizingVertical = "FILL";
|
||||||
|
view.clipsContent = true;
|
||||||
|
view.overflowDirection = "VERTICAL";
|
||||||
|
const tree = append(view, frame(ctx, `Block tree / ${DIFF_FILE.name}`, { width: TREE_W, py: 20, fill: COLORS.editor }));
|
||||||
|
diffLevel(ctx, tree, lib, DIFF_BLOCKS, only, "Level 0");
|
||||||
|
const ruler = lib.ruler.get("mutex").createInstance();
|
||||||
|
ruler.name = "Overview ruler";
|
||||||
|
editor.appendChild(ruler);
|
||||||
|
ruler.layoutPositioning = "ABSOLUTE";
|
||||||
|
ruler.x = EDITOR_W - DIFF_RULER_W;
|
||||||
|
ruler.y = DIFF_VIEW_TOP;
|
||||||
|
return { screen, sidebar: side, bar, view, tree, ruler };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 插件菜单「代码差异」:一页,只建用得到的组件;两个窗口(只看改动关 / 开)和一张标记说明。 */
|
||||||
|
async function buildDiffPage(ctx, api, page, stage) {
|
||||||
|
stage("生成组件");
|
||||||
|
const sets = [];
|
||||||
|
const lib = {};
|
||||||
|
ctx.lib = lib;
|
||||||
|
const make = setMaker(ctx, page, sets);
|
||||||
|
lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode));
|
||||||
|
lib.tools = make("Reader / BlockTools", MODES.slice().reverse(), 2, (state) => blockToolsComponent(ctx, state));
|
||||||
|
lib.filterChip = make("Reader / FilterChip", ["default"], 1, (state) => filterChip(ctx, state));
|
||||||
|
lib.diffBar = make("Reader / DiffBar", DIFF_BAR_VARIANTS, 3, (entry) => diffBar(ctx, lib, ...entry.split("/")));
|
||||||
|
lib.strip = make("Reader / UnchangedStrip", ["folded"], 1, () => unchangedStrip(ctx));
|
||||||
|
lib.ruler = make("Reader / OverviewRuler", ["mutex"], 1, () => overviewRuler(ctx));
|
||||||
|
lib.banner = make("Reader / NewFileBanner", ["untracked"], 1, () => newFileBanner(ctx));
|
||||||
|
const entries = new Map(diffEntries(DIFF_BLOCKS).map((entry) => [entry.spec.line, entry]));
|
||||||
|
lib.diffCards = make(`Diff / ${DIFF_FILE.name}`, [...entries.keys()], 2, (line) => diffCard(ctx, entries.get(line).spec, entries.get(line).change, entries.get(line).badge));
|
||||||
|
const removed = DIFF_BLOCKS.find((spec) => spec.change === "del");
|
||||||
|
lib.deleted = make("Diff / 已删除", ["collapsed", "expanded"], 2, (state) => deletedCard(ctx, removed, state), (item) => item.node);
|
||||||
|
lib.sample = make("Diff / 示例", ["sample"], 1, () => diffCard(ctx, DIFF_SAMPLE, DIFF_SAMPLE.change, false));
|
||||||
|
lib.row = make("Explorer / TreeRow", ROW_VARIANTS.map(([type, state]) => `${type}/${state}`), 2, (entry) => treeRow(ctx, ...entry.split("/")));
|
||||||
|
lib.explorerHeader = make("Explorer / Header", ["uncommitted"], 1, (state) => explorerHeader(ctx, state));
|
||||||
|
lib.uncommittedSidebar = make("Explorer / UncommittedSidebar", ["mutex"], 1, () => uncommittedSidebar(ctx, lib));
|
||||||
|
lib.windowTitle = make("Window / TitleBar", ["active"], 1, () => windowTitle(ctx));
|
||||||
|
lib.activity = make("Window / ActivityBar", ["reader"], 1, () => activityBar(ctx));
|
||||||
|
lib.status = make("Window / StatusBar", ["preview"], 1, (mode) => statusBar(ctx, mode));
|
||||||
|
lib.editorTitle = make("Editor / TitleBar", ["preview"], 1, (mode) => editorTitle(ctx, lib, mode));
|
||||||
|
lib.breadcrumb = make("Editor / Breadcrumb", ["mutex"], 1, (key) => breadcrumb(ctx, key, DIFF_FILE));
|
||||||
|
|
||||||
|
stage("生成演示画板");
|
||||||
|
const all = diffWindow(ctx, page, lib, "代码差异 · 全部", false);
|
||||||
|
const only = diffWindow(ctx, page, lib, "代码差异 · 只看改动", true);
|
||||||
|
const legend = timed("Prototype / 代码差异 · 标记说明", () => diffLegend(ctx, page, lib));
|
||||||
|
|
||||||
|
stage("连接演示入口");
|
||||||
|
// 「只看改动」在两个窗口间切换;删掉的块点一下展开 / 收起(组件里连,各处实例都能点)
|
||||||
|
const wires = [];
|
||||||
|
const link = (...args) => { wires.push(connect(...args)); };
|
||||||
|
link(named(all.bar, "Only changes"), only.screen, "NAVIGATE");
|
||||||
|
link(named(only.bar, "Only changes"), all.screen, "NAVIGATE");
|
||||||
|
link(lib.deleted.get("collapsed").toggle, lib.deleted.get("expanded").node, "CHANGE_TO");
|
||||||
|
link(lib.deleted.get("expanded").toggle, lib.deleted.get("collapsed").node, "CHANGE_TO");
|
||||||
|
await Promise.all(wires);
|
||||||
|
|
||||||
|
stage("排布画板");
|
||||||
|
const screens = [all.screen, only.screen, legend];
|
||||||
|
let y = 0;
|
||||||
|
for (const screen of screens) {
|
||||||
|
screen.x = 0;
|
||||||
|
screen.y = y;
|
||||||
|
y += screen.height + 120;
|
||||||
|
}
|
||||||
|
let setY = 0;
|
||||||
|
for (const set of sets) {
|
||||||
|
set.x = WINDOW_W + 160;
|
||||||
|
set.y = setY;
|
||||||
|
setY += set.height + 64;
|
||||||
|
}
|
||||||
|
page.flowStartingPoints = [{ nodeId: all.screen.id, name: "代码差异" }];
|
||||||
|
for (const node of page.children) node.expanded = false;
|
||||||
|
api.viewport.scrollAndZoomIntoView([all.screen]);
|
||||||
|
return { clock, page, components: { lib, sets }, board: all.screen, boards: { all, only, legend }, screens, start: all.screen.name };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Entry ───────────────────────────────────────────────────────────────────
|
// ─── Entry ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** command:插件菜单里点的哪一项,main = 主画板,其余见 SCRIPT_PAGES(shell、docker)。 */
|
/** command:插件菜单里点的哪一项,main = 主画板,diff = 代码差异,其余见 SCRIPT_PAGES(shell、docker)。 */
|
||||||
async function build(api, stage = () => {}, command = "main") {
|
async function build(api, stage = () => {}, command = "main") {
|
||||||
clock = new Map();
|
clock = new Map();
|
||||||
stage("加载中文字体");
|
stage("加载中文字体");
|
||||||
const ctx = { api, fonts: await chooseFonts(api) };
|
const ctx = { api, fonts: await chooseFonts(api) };
|
||||||
stage("新建独立页面");
|
stage("新建独立页面");
|
||||||
const name = nextPageName(api.root.children, SCRIPT_PAGES[command] ? SCRIPT_PAGES[command].page : PAGE_NAME);
|
const name = nextPageName(api.root.children, command === "diff" ? DIFF_PAGE_NAME : SCRIPT_PAGES[command] ? SCRIPT_PAGES[command].page : PAGE_NAME);
|
||||||
const page = api.createPage();
|
const page = api.createPage();
|
||||||
page.name = name;
|
page.name = name;
|
||||||
await api.setCurrentPageAsync(page);
|
await api.setCurrentPageAsync(page);
|
||||||
page.backgrounds = paint("#111214");
|
page.backgrounds = paint("#111214");
|
||||||
page.setSharedPluginData(NAMESPACE, "generator", GENERATOR);
|
page.setSharedPluginData(NAMESPACE, "generator", GENERATOR);
|
||||||
|
if (command === "diff") return buildDiffPage(ctx, api, page, stage);
|
||||||
if (SCRIPT_PAGES[command]) return buildScriptPage(ctx, api, page, stage, command);
|
if (SCRIPT_PAGES[command]) return buildScriptPage(ctx, api, page, stage, command);
|
||||||
|
|
||||||
stage("生成组件");
|
stage("生成组件");
|
||||||
@@ -3045,6 +3707,6 @@ async function run(api) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== "undefined") {
|
if (typeof module !== "undefined") {
|
||||||
module.exports = { NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments };
|
module.exports = { DIFF_PAGE_NAME, DIFF_BLOCKS, DIFF_COLORS, DIFF_SAMPLE, UNCOMMITTED_GROUPS, diffParts, diffStatsText, NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments };
|
||||||
}
|
}
|
||||||
if (typeof figma !== "undefined") void run(figma);
|
if (typeof figma !== "undefined") void run(figma);
|
||||||
@@ -23,6 +23,10 @@
|
|||||||
{
|
{
|
||||||
"name": "Dockerfile",
|
"name": "Dockerfile",
|
||||||
"command": "docker"
|
"command": "docker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "代码差异",
|
||||||
|
"command": "diff"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+185
-3
@@ -2,7 +2,7 @@ const assert = require("node:assert/strict");
|
|||||||
const { test } = require("node:test");
|
const { test } = require("node:test");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js");
|
const { DIFF_PAGE_NAME, DIFF_BLOCKS, DIFF_COLORS, DIFF_SAMPLE, UNCOMMITTED_GROUPS, diffParts, diffStatsText, NEXT_DOCKERFILE, BACKEND_DOCKERFILE, DOCKER_PAGE_NAME, SHELL_BLOCKS, SHELL_PAGE_NAME, NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js");
|
||||||
const { fakeFigma } = require("../../figma-lab/fake-figma");
|
const { fakeFigma } = require("../../figma-lab/fake-figma");
|
||||||
|
|
||||||
function action(node) { return node.reactions[0].actions[0]; }
|
function action(node) { return node.reactions[0].actions[0]; }
|
||||||
@@ -100,8 +100,9 @@ test("manifest stays local, without a fabricated plugin ID", () => {
|
|||||||
assert.deepEqual(manifest.editorType, ["figma"]);
|
assert.deepEqual(manifest.editorType, ["figma"]);
|
||||||
assert.deepEqual(manifest.networkAccess.allowedDomains, ["none"]);
|
assert.deepEqual(manifest.networkAccess.allowedDomains, ["none"]);
|
||||||
assert.equal("id" in manifest, false);
|
assert.equal("id" in manifest, false);
|
||||||
// 插件菜单:主画板、Shell 脚本、Dockerfile,各自只生成自己那一页
|
// 插件菜单:主画板、Shell 脚本、Dockerfile、代码差异,各自只生成自己那一页
|
||||||
assert.deepEqual(manifest.menu.map((item) => item.command), ["main", "shell", "docker"]);
|
assert.deepEqual(manifest.menu.map((item) => item.command), ["main", "shell", "docker", "diff"]);
|
||||||
|
assert.equal(manifest.menu.find((item) => item.command === "diff").name, "代码差异");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plugin source passes Figma's load-time scan", () => {
|
test("plugin source passes Figma's load-time scan", () => {
|
||||||
@@ -573,6 +574,187 @@ test("the Dockerfile menu item: stages hold their instructions, RUN chains split
|
|||||||
assert.ok(owned < 3000, `Dockerfile page owns ${owned} layers`);
|
assert.ok(owned < 3000, `Dockerfile page owns ${owned} layers`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 代码差异页:一次生成,下面几个测试共用
|
||||||
|
let diffRun;
|
||||||
|
function diffPage() {
|
||||||
|
if (!diffRun) {
|
||||||
|
const api = fakeFigma();
|
||||||
|
diffRun = build(api, () => {}, "diff").then((result) => ({ api, result }));
|
||||||
|
}
|
||||||
|
return diffRun;
|
||||||
|
}
|
||||||
|
const colorOf = (node) => node.fills.length ? node.fills[0].color : null;
|
||||||
|
const sameColor = (paint, hex) => paint && ["r", "g", "b"].every((key, index) => Math.abs(paint[key] - parseInt(hex.slice(1 + index * 2, 3 + index * 2), 16) / 255) < 1e-6);
|
||||||
|
const texts = (root) => root.findAll((node) => node.type === "TEXT").map((node) => node.characters);
|
||||||
|
|
||||||
|
test("diff markup splits into unchanged, added and removed pieces; stats count the outermost blocks", () => {
|
||||||
|
assert.deepEqual(diffParts("a {+b}c{-d}").map((part) => [part.segs.map((item) => item.text).join(""), part.change]),
|
||||||
|
[["a ", undefined], ["b", "add"], ["c", undefined], ["d", "del"]]);
|
||||||
|
assert.equal(diffStatsText(), "新增 2 · 修改 3 · 删除 1");
|
||||||
|
// 删掉的块留在原来的位置:renew 和 release 之间
|
||||||
|
const lines = DIFF_BLOCKS.map((spec) => spec.line);
|
||||||
|
assert.equal(lines.indexOf("_is_owner"), lines.indexOf(44) + 1);
|
||||||
|
assert.equal(lines.indexOf(52), lines.indexOf("_is_owner") + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the 代码差异 menu item builds its own page with two windows and a legend, under the layer budget", async () => {
|
||||||
|
const { result } = await diffPage();
|
||||||
|
assert.equal(result.page.name, DIFF_PAGE_NAME);
|
||||||
|
assert.deepEqual(result.screens.map((screen) => screen.name),
|
||||||
|
["Prototype / 代码差异 · 全部", "Prototype / 代码差异 · 只看改动", "Prototype / 代码差异 · 标记说明"]);
|
||||||
|
assert.equal(result.page.flowStartingPoints[0].nodeId, result.boards.all.screen.id);
|
||||||
|
let owned = 0;
|
||||||
|
const walk = (node) => { owned += 1; if (node.type !== "INSTANCE") node.children.forEach(walk); };
|
||||||
|
result.page.children.forEach(walk);
|
||||||
|
assert.ok(owned < 3000, `diff page owns ${owned} layers`);
|
||||||
|
const blocks = [...result.screens, ...result.components.sets];
|
||||||
|
for (const block of blocks) assert.equal(block.parent, result.page);
|
||||||
|
for (let i = 0; i < blocks.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < blocks.length; j += 1) assert.equal(overlaps(blocks[i], blocks[j]), false, `${blocks[i].name} overlaps ${blocks[j].name}`);
|
||||||
|
}
|
||||||
|
// 画板里的实例都来自组件集
|
||||||
|
const setIds = new Set(result.components.sets.flatMap((set) => set.children.map((component) => component.id)));
|
||||||
|
for (const screen of result.screens) {
|
||||||
|
for (const instance of screen.findAll((node) => node.type === "INSTANCE")) assert.ok(setIds.has(instance.mainComponentId), `${instance.name} comes from a set`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diff windows: the sidebar lists uncommitted changes in two groups, the bar shows base, stats and the only-changes switch", async () => {
|
||||||
|
const { result } = await diffPage();
|
||||||
|
const { lib } = result.components;
|
||||||
|
const { all, only } = result.boards;
|
||||||
|
// 侧栏:「未提交的更改」,暂存的更改 / 更改两组,mutex.py 有改动且选中
|
||||||
|
const side = all.sidebar;
|
||||||
|
assert.equal(side.mainComponentId, lib.uncommittedSidebar.get("mutex").id);
|
||||||
|
assert.equal(named(side, "Description").characters, "未提交的更改");
|
||||||
|
assert.ok(named(side, "Action / 未提交的更改") && named(side, "Action / 显示全部") && named(side, "Action / 隐藏测试文件"));
|
||||||
|
assert.deepEqual(side.findAll((node) => node.name.startsWith("Group / ")).map((node) => [named(node, "Label").characters, named(node, "Count").characters]),
|
||||||
|
UNCOMMITTED_GROUPS.map((group) => [group.label, String(group.rows.filter((row) => row.type === "file").length)]));
|
||||||
|
const mutexRow = named(side, "Row / mutex.py");
|
||||||
|
assert.equal(mutexRow.mainComponentId, lib.row.get("file/modified-selected").id);
|
||||||
|
assert.equal(named(mutexRow, "Git").characters, "M");
|
||||||
|
assert.equal(named(named(side, "Row / test_mutex.py"), "Git").characters, "A");
|
||||||
|
// 筛选栏:上面照常的筛选,下面对比基准、统计、只看改动、上一处 / 下一处
|
||||||
|
assert.equal(named(all.bar, "Base").characters, "对比:上一次提交");
|
||||||
|
assert.equal(named(all.bar, "Stats").characters, "新增 2 · 修改 3 · 删除 1");
|
||||||
|
assert.equal(named(all.bar, "Stats").rangeFills.length, 3, "each count in its change colour");
|
||||||
|
assert.ok(named(all.bar, "Chip / 种类") && named(all.bar, "Chip / 访问级别"));
|
||||||
|
assert.ok(named(all.bar, "Nav / 上一处") && named(all.bar, "Nav / 下一处"));
|
||||||
|
assert.equal(all.bar.mainComponentId, lib.diffBar.get("commit/off").id);
|
||||||
|
assert.equal(only.bar.mainComponentId, lib.diffBar.get("commit/on").id);
|
||||||
|
assert.equal(named(lib.diffBar.get("pr/off"), "Base").characters, "对比:PR 目标分支 master");
|
||||||
|
// 预览:全部的块照阅读顺序,删掉的块在原位;只看改动时连着没变的块折成细条
|
||||||
|
const order = [];
|
||||||
|
const visit = (specs) => specs.forEach((spec) => {
|
||||||
|
order.push(typeof spec.line === "number" ? `Block / L${spec.line}` : `Block / ${spec.line}`);
|
||||||
|
if (spec.kids && !spec.collapsed) visit(spec.kids);
|
||||||
|
});
|
||||||
|
visit(DIFF_BLOCKS);
|
||||||
|
const placed = (tree) => tree.findAll((node) => node.type === "INSTANCE" && /^(Block|Unchanged) \//.test(node.name));
|
||||||
|
assert.deepEqual(placed(all.tree).map((node) => node.name), order);
|
||||||
|
assert.deepEqual(placed(only.tree).map((node) => node.name.startsWith("Unchanged") ? named(node, "Text").characters : node.name), [
|
||||||
|
"Block / L1", "… 6 块没变", "Block / L23", "Block / L32", "Block / L39", "… 2 块没变",
|
||||||
|
"Block / L44", "Block / L46", "Block / L49", "Block / _is_owner", "Block / L52", "Block / L54", "… 1 块没变", "… 1 块没变"]);
|
||||||
|
assert.equal(named(all.tree, "Block / _is_owner").mainComponentId, lib.deleted.get("collapsed").node.id);
|
||||||
|
// 总览条:六处改动各一段刻度,颜色照改动
|
||||||
|
const ticks = lib.ruler.get("mutex").children;
|
||||||
|
assert.equal(ticks.length, 6);
|
||||||
|
for (const tick of ticks) {
|
||||||
|
const change = Object.entries({ 新增: "add", 修改: "mod", 删除: "del" }).find(([label]) => tick.name.includes(label))[1];
|
||||||
|
assert.ok(sameColor(colorOf(tick), DIFF_COLORS[change]), `${tick.name} colour`);
|
||||||
|
assert.ok(tick.y >= 0 && tick.y + tick.height <= lib.ruler.get("mutex").height);
|
||||||
|
}
|
||||||
|
assert.equal(all.ruler.layoutPositioning, "ABSOLUTE");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diff cards: added cards go green with a badge, modified cards mark only what changed, deleted cards stay in place", async () => {
|
||||||
|
const { result } = await diffPage();
|
||||||
|
const { lib } = result.components;
|
||||||
|
const card = (line) => lib.diffCards.get(line);
|
||||||
|
const bar = (node) => colorOf(named(node, "Change bar"));
|
||||||
|
// 没变:色条透明,没有增删标记
|
||||||
|
assert.equal(bar(card(15)), null);
|
||||||
|
assert.equal(card(15).findAll((node) => /^(Added|Removed) /.test(node.name)).length, 0);
|
||||||
|
// 新增:绿条、淡绿底、「新增」小标;里面不再逐行标;子块同样绿条,但不挂小标
|
||||||
|
assert.ok(sameColor(bar(card(23)), DIFF_COLORS.add));
|
||||||
|
assert.ok(named(card(23), "Badge / 新增"));
|
||||||
|
assert.ok(sameColor(colorOf(named(card(23), "Card")), DIFF_COLORS.add));
|
||||||
|
assert.equal(card(44).findAll((node) => /^(Added|Removed) /.test(node.name) || node.name.startsWith("新增 /")).length, 0);
|
||||||
|
assert.ok(sameColor(bar(card(46)), DIFF_COLORS.add));
|
||||||
|
assert.equal(named(card(46), "Badge / 新增"), null);
|
||||||
|
// 修改:蓝条;说明多一段整段绿,参数表新加一行 +,输出参数旧值红底划线、新值绿底
|
||||||
|
const tryAcquire = card(32);
|
||||||
|
assert.ok(sameColor(bar(tryAcquire), DIFF_COLORS.mod));
|
||||||
|
assert.match(named(named(tryAcquire, "新增 / Line 2"), "Text").characters, /^\+ token_prefix 让等锁的一方/);
|
||||||
|
const added = named(tryAcquire, "Param / token_prefix");
|
||||||
|
assert.ok(sameColor(colorOf(added), DIFF_COLORS.add));
|
||||||
|
assert.equal(named(added, "Text").characters.slice(0, 2), "+ ");
|
||||||
|
assert.equal(named(named(tryAcquire, "Param / redis"), "Text").characters.slice(0, 2), " ", "unchanged rows keep the mark column empty");
|
||||||
|
const output = named(tryAcquire, "Prop / 输出参数");
|
||||||
|
assert.deepEqual(output.findAll((node) => /^(Added|Removed) /.test(node.name)).map((node) => [node.name.split(" ")[0], named(node, "Text").characters]),
|
||||||
|
[["Removed", "布尔"], ["Added", "字符串 | 空"]]);
|
||||||
|
assert.equal(named(named(output, "Removed 1"), "Text").textDecoration, "STRIKETHROUGH");
|
||||||
|
// 文件注释:按字标增删
|
||||||
|
const doc = card(1);
|
||||||
|
assert.deepEqual(doc.findAll((node) => node.name.startsWith("Added ")).map((node) => named(node, "Text").characters),
|
||||||
|
["再续期 / ", "续期与", "都", "EXPIRE / ", "替别人续命或"]);
|
||||||
|
assert.deepEqual(doc.findAll((node) => node.name.startsWith("Removed ")).map((node) => named(node, "Text").characters), ["前"]);
|
||||||
|
// 子块照同样的规则:release 本身没改,函数体里 eval 多传了 token
|
||||||
|
assert.ok(sameColor(bar(card(52)), DIFF_COLORS.mod));
|
||||||
|
assert.deepEqual(card(54).findAll((node) => node.name.startsWith("Added ")).map((node) => named(node, "Text").characters), [", token"]);
|
||||||
|
// 删行:说明板的示例卡片里参数 ttl 删掉,整行划掉、左边 −
|
||||||
|
const removedRow = named(lib.sample.get("sample"), "Param / ttl");
|
||||||
|
assert.ok(sameColor(colorOf(removedRow), DIFF_COLORS.del));
|
||||||
|
assert.equal(named(removedRow, "Text").textDecoration, "STRIKETHROUGH");
|
||||||
|
assert.equal(named(removedRow, "Text").characters.slice(0, 2), "− ");
|
||||||
|
assert.equal(DIFF_SAMPLE.change, "mod");
|
||||||
|
// 删除:红色虚线边、半透明;收起一行,展开是整张划掉的旧内容
|
||||||
|
const collapsed = lib.deleted.get("collapsed");
|
||||||
|
const expanded = lib.deleted.get("expanded");
|
||||||
|
assert.equal(named(collapsed.node, "Head").characters, "已删除 · 函数 _is_owner");
|
||||||
|
assert.ok(collapsed.toggle.dashPattern.length > 0 && collapsed.toggle.opacity < 1);
|
||||||
|
assert.ok(sameColor(bar(collapsed.node), DIFF_COLORS.del));
|
||||||
|
assert.equal(named(collapsed.node, "Old"), null);
|
||||||
|
const old = named(expanded.node, "Old");
|
||||||
|
assert.ok(texts(old).includes("_is_owner"));
|
||||||
|
for (const node of old.findAll((item) => item.type === "TEXT")) assert.equal(node.textDecoration, "STRIKETHROUGH", `${node.name} struck through`);
|
||||||
|
assert.equal(named(collapsed.node, "Tools"), null, "no ask / edit buttons on a deleted block");
|
||||||
|
// 新文件横幅
|
||||||
|
assert.equal(named(lib.banner.get("untracked"), "Text").characters, "新文件,全部是新增");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("diff demo: the only-changes switch flips between the two windows, the deleted card expands in place", async () => {
|
||||||
|
const { api, result } = await diffPage();
|
||||||
|
const { lib } = result.components;
|
||||||
|
const { all, only } = result.boards;
|
||||||
|
// 只看改动:两个窗口互跳 2;删掉的块:收起 ↔ 展开 2;合计 4
|
||||||
|
assert.equal(api.connected, 4);
|
||||||
|
const toOnly = action(named(all.bar, "Only changes"));
|
||||||
|
assert.equal(toOnly.navigation, "NAVIGATE");
|
||||||
|
assert.equal(toOnly.destinationId, only.screen.id);
|
||||||
|
assert.equal(action(named(only.bar, "Only changes")).destinationId, all.screen.id);
|
||||||
|
const collapsed = lib.deleted.get("collapsed");
|
||||||
|
const expanded = lib.deleted.get("expanded");
|
||||||
|
assert.equal(action(collapsed.toggle).navigation, "CHANGE_TO");
|
||||||
|
assert.equal(action(collapsed.toggle).destinationId, expanded.node.id);
|
||||||
|
assert.equal(action(expanded.toggle).destinationId, collapsed.node.id);
|
||||||
|
// 每条连线的目标都在页面上
|
||||||
|
for (const node of result.page.findAll((item) => item.reactions.length > 0)) {
|
||||||
|
const target = api.nodes.get(action(node).destinationId);
|
||||||
|
assert.ok(target, `${node.name} targets a node`);
|
||||||
|
let top = target;
|
||||||
|
while (top.parent && top.parent.type !== "PAGE") top = top.parent;
|
||||||
|
assert.equal(top.parent, result.page, `${node.name} targets a node on the diff page`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("running the diff command twice numbers the page", async () => {
|
||||||
|
const api = fakeFigma();
|
||||||
|
await build(api, () => {}, "diff");
|
||||||
|
const second = await build(api, () => {}, "diff");
|
||||||
|
assert.equal(second.page.name, `${DIFF_PAGE_NAME} 2`);
|
||||||
|
});
|
||||||
|
|
||||||
test("block kinds show as icons, the legend lists every kind with its name", async () => {
|
test("block kinds show as icons, the legend lists every kind with its name", async () => {
|
||||||
const api = fakeFigma();
|
const api = fakeFigma();
|
||||||
const { kindBoard } = await build(api);
|
const { kindBoard } = await build(api);
|
||||||
|
|||||||
Reference in new issue
Block a user