diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md index 4cc781e..0d3bc54 100644 --- a/docs/prototype/code-reader/figma-plugin/README.md +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -11,7 +11,7 @@ - 函数:名称、访问级别(公共 / 私有)、异步(是 / 否)、装饰器、输入参数(展开成一张带边框和表头的表格:名称 / 类型 / 默认值三列,每个参数一行)、输出参数。 - 导入:来源、仅类型(是 / 否,只有 TypeScript 有)、导入项(展开成每项一行,有别名写成「promises 作为 fsp」)。 - 常量 / 变量:名称、访问级别(只有最外层的声明有)、类型(写了标注才有)、值 / 初始值。 -- 块左边有种类标签(导入、函数、类型、字段、常量、变量、条件、循环、尝试、捕获、返回、语句、注释),底色也带这个种类的颜色;有子块的块左边有折叠箭头。 +- 块左边只画种类的图标,鼠标停上去提示名称(导入、函数、类型、字段、常量、变量、条件、循环、尝试、捕获、返回、语句、注释……)。图标、颜色、名称由插件定义,库规则认出的块由规则自己给;图标用 lucide,和插件预览页面同一套。块的底色也带这个种类的颜色;有子块的块左边有折叠箭头。 - 译文按词的来源上色,配色和 VS Code 默认深色主题一致:流程关键字(导入 / 来自 / 如果 / 返回 / 遍历 / 等待 / 公共 / 尝试 / 捕获)紫红,声明关键字(常量 / 函数 / 异步 / 接口 / 新建)和运算符(全等 / 非 / 或 / 与)蓝,变量浅蓝,函数黄,类型青,字符串橙,字面量淡绿。源码状态同样上色。 ## 画了什么 @@ -26,6 +26,8 @@ | `Prototype / 库规则 · SQLAlchemy 数据表` | luffy 的 `backend/src/luffy_agent/referral/models.py` 里的 `ReferralRelation` | 库规则(文件导入了 sqlalchemy 才启用)把模型类整块换成「数据表」卡片:名称 / 表名 / 说明 / 继承,字段表(字段 / 类型 / 约束 / 说明)和索引表;`__tablename__`、`__table_args__`、各字段块并进卡片 | | `Prototype / 枚举` | luffy 的 `referral/models.py` 里的 `CommissionStatus` | 枚举卡片:名称 / 访问级别 / 继承,成员收成一张表(成员 / 值 / 说明),说明取行尾注释;Python 用库规则(导入了 enum 才启用),TypeScript 的 enum 由语言插件报结构,画成同一张卡片 | | `Prototype / 库规则 · Celery` | luffy 的 `backend/src/luffy_agent/celery_app.py` | 库规则(导入了 celery 才启用)两张卡片:「Celery 应用」名称 / 应用名 / 消息队列 / 结果存储;「定时任务」应用 + 任务表(任务 / 频率 / 说明),频率换算成「每 5 分钟」「每天」,说明取行尾注释,`**` 展开的另起一行 | +| `Prototype / 库规则 · FastAPI 接口` | luffy 的 `user/routes.py` 里的 `update_user` | 库规则(导入了 fastapi 才启用)把挂在路由对象上的函数整块换成「接口」卡片,函数体照常是子块:名称 / 说明 / 标签 / 异步 / 依赖(`Depends(...)` 注入的参数),然后两张表。请求表一项一行:URL(路由对象的 prefix + 装饰器里的路径)/ 方法 / 路径参数 / 查询参数 / 请求体,没有的项不列。响应表每个状态码一行(状态码 / 内容 / 说明):成功那行来自 `status_code` 和 `response_model`,`responses={…}` 里写的各占一行,函数体里直接抛的异常按异常类上的 `status_code` 归行、说明列出抛出时的消息,有参数时 FastAPI 自动带的 422 淡色。挂到应用上时再叠的前缀(`/api/v1`)在别的文件里,不算 | +| `Prototype / 块种类图标` | — | 每种块一个图标、颜色、名称的图例,库规则带的种类后面淡色写规则名;最上面一块「返回」停着鼠标、图标下面弹出名称提示 | | `Prototype / 遍历 · 推导式` | luffy 邀请码工具的 `"".join(secrets.choice(ALPHABET) for _ in range(length))`,外加带条件的列表、字典推导 | 写在表达式里的遍历原地套一个带循环图标和结果类型(生成器 / 列表 / 字典)的小框,框里按读的顺序重排:「遍历 range(length) 的每个 _,仅当 …,取 …」 | | `Prototype / 侧栏 · 复制菜单` | 同上 | 悬停行行尾第一个按钮「复制」(带小箭头),点开一组复制方式:文件名 / 文件名(不带后缀)/ 相对路径 / 绝对路径,右边淡色预览会复制出什么;文件夹没有「不带后缀」 | | `Prototype / 问 AI` | routes.py 的 get_user 里的条件、语句、返回 | 块右上角只有图标(问 AI、编辑 / 预览),不带字;问过的问答灰字挂在块下面(问题 + 回答前两行,时间、删除);正在问的那轮正常颜色,回答逐字出来,底下输入框追问 | @@ -67,7 +69,9 @@ - `Reader / TableCard`:SQLAlchemy 规则认出的数据表卡片。 - `Reader / EnumCard`:枚举卡片。 - `Reader / CeleryAppCard`、`Reader / BeatCard`:Celery 规则的两张卡片。 +- `Reader / RouteCard`:FastAPI 规则的接口卡片。 - `Reader / IterationBlock`:`Sample=generator | list | dict`,三种推导式的返回块。 +- `Reader / FilterChip`:`State=default | active`,筛选栏里一个属性的下拉框,各筛选栏共用。 - `Reader / FilterBar`:`Active=none | access | kind`,预览顶部的筛选栏,打开的下拉菜单是勾选列表,每个值后面是块数。 - `Reader / BlockTools`:`State=preview | edit`,块右上角的一排图标按钮(问 AI、编辑 / 预览),各种块共用。 - `Reader / AskPanel`:`State=history | asking`,块下面的问答区。 @@ -114,9 +118,10 @@ node --test test.js - [ ] 停在「编辑」状态的块显示上了色的源码、蓝框,中文注释不是方块 - [ ] 不同种类的块一眼能分开:标签颜色、底色、边框都不同;同一种类(比如三个「返回」)颜色一致 - [ ] 译文里的关键字、变量名、函数名颜色和编辑器里对应的代码一致,关键字前后都有空格 -- [ ] 有子块的块左边有折叠箭头,没有子块的没有,标签仍然对齐 +- [ ] 有子块的块左边有折叠箭头,没有子块的没有,种类图标仍然对齐;图标和一行字等高,一眼分得清是哪种 - [ ] 属性表:函数、导入、常量、变量的属性名一列对齐;导入项每项一行,左边竖线;输入参数是带边框和表头的表格,名称 / 类型 / 默认值三列对齐;长类型(`Annotated[...]`)换行不挤掉默认值列 - [ ] 数据表:字段表四列对齐,约束一格里外键目标、删除规则看得清;说明列长了换行不挤别的列 +- [ ] 接口:请求表、响应表列对齐;方法颜色和 Swagger 一致;响应说明列长了换行不挤别的列 - [ ] 遍历:小框和前后文字在同一行里对齐、太长时整体换行;循环图标和「循环」块同色 - [ ] 筛选:筛选栏固定在预览顶部、和代码块左右对齐;下拉菜单盖在代码块上面;藏掉的块不留空位,左边层级竖线跟着变短 - [ ] 文件注释:悬停行的按钮贴在行尾、不挤掉名字;注释灰字紧跟名字、长了截断;顶部输入框居中,看得出是 VS Code 自带的样子 diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js index 548b857..11c9367 100644 --- a/docs/prototype/code-reader/figma-plugin/code.js +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -50,6 +50,7 @@ const COLORS = { gitModified: "#e2c08d", // gitDecoration.modifiedResourceForeground gitAdded: "#81b88b", // gitDecoration.addedResourceForeground gitDeleted: "#c74e39", // gitDecoration.deletedResourceForeground + changeAdded: "#2ea043", // editorGutter.addedBackground code: "#d4d4d4", comment: "#6a9955", folder: "#8b9ba8", @@ -177,22 +178,31 @@ const FILES = { routes: { name: "routes.py", ext: "py", lang: "python", workspace: "luffy", crumbs: ["backend", "src", "luffy_agent", "user", "routes.py"] }, }; -// 代码块的种类:标签文字 + 颜色。同一类语义共用一个颜色(常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),标签区分具体是哪种。 +// 代码块的种类:插件定义的图标 + 颜色 + 名称。块上只画图标,名称是鼠标停上去的提示。 +// 同一类语义共用一个颜色(常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),图标区分具体是哪种。图标取 lucide。 const BLOCK_KINDS = { - import: { label: "导入", color: "#c586c0" }, - function: { label: "函数", color: "#dcdcaa" }, - type: { label: "类型", color: "#4ec9b0" }, - field: { label: "字段", color: "#9cdcfe" }, - const: { label: "常量", color: "#9cdcfe" }, - variable: { label: "变量", color: "#9cdcfe" }, - if: { label: "条件", color: "#ce9178" }, - loop: { label: "循环", color: "#8f9cff" }, - try: { label: "尝试", color: "#f48771" }, - catch: { label: "捕获", color: "#f48771" }, - return: { label: "返回", color: "#89d185" }, - statement: { label: "语句", color: "#9d9d9d" }, - comment: { label: "注释", color: "#6a9955" }, + import: { label: "导入", color: "#c586c0", icon: "import" }, + function: { label: "函数", color: "#dcdcaa", icon: "square-function" }, + type: { label: "类型", color: "#4ec9b0", icon: "braces" }, + field: { label: "字段", color: "#9cdcfe", icon: "tag" }, + const: { label: "常量", color: "#9cdcfe", icon: "lock" }, + variable: { label: "变量", color: "#9cdcfe", icon: "variable" }, + if: { label: "条件", color: "#ce9178", icon: "split" }, + loop: { label: "循环", color: "#8f9cff", icon: "repeat" }, + try: { label: "尝试", color: "#f48771", icon: "shield" }, + catch: { label: "捕获", color: "#f48771", icon: "shield-alert" }, + return: { label: "返回", color: "#89d185", icon: "corner-down-left" }, + statement: { label: "语句", color: "#9d9d9d", icon: "code" }, + comment: { label: "注释", color: "#6a9955", icon: "message-square-text" }, }; +// 插件里还有、原型示例文件里没用到的种类,以及库规则带的种类(规则自己给图标、颜色、名称),图例里一起列出来 +const EXTRA_KINDS = [ + { label: "类", color: "#4ec9b0", icon: "box" }, + { label: "枚举", color: "#b180d7", icon: "list-ordered" }, + { label: "异常", color: "#f14c4c", icon: "triangle-alert" }, + { label: "否则", color: "#ce9178", icon: "corner-down-right" }, + { label: "最终", color: "#f48771", icon: "shield-check" }, +]; // 代码块树。at:源码行号(单行,或 [起, 止]);kind:种类;kids:嵌套在它里面的块; // edit:原型里默认停在「编辑」状态,用来展示两种样子。块里显示的译文由 translateBlock 按模板直译生成。 // 没落进任何块的行只能是空行或收尾括号,测试会检查。 @@ -694,6 +704,26 @@ async function chooseFonts(api) { throw new Error(`没有找到可用的中文字体。请在 Figma 中启用 ${FONT_FAMILIES.join("、")} 之一后重试。`); } +// ─── 用时 ──────────────────────────────────────────────────────────────────── +// +// 生成慢的时候看慢在哪:按名目累计用时,结束提示里列出最慢的组件 / 画板和各类操作。 +let clock = null; + +function timed(label, fn) { + if (!clock) return fn(); + const start = Date.now(); + try { + return fn(); + } + finally { + clock.set(label, (clock.get(label) || 0) + Date.now() - start); + } +} + +function measured(label, fn) { + return function (...args) { return timed(`操作 · ${label}`, () => fn.apply(this, args)); }; +} + /** * Auto-layout container. With `width` that axis is fixed and the other hugs; * without it both axes hug. o: dir / width / height / pad / px / py / gap / @@ -748,13 +778,14 @@ function topBorder(node, color) { * `width`: fixed width, grows in height (table columns). * 不读父节点宽度:每读一次都会逼 Figma 把整棵自动布局树重排一遍。 */ -function text(ctx, parent, name, characters, o = {}) { +const text = measured("建文字层", function text(ctx, parent, name, characters, o = {}) { const node = ctx.api.createText(); node.name = name; - node.fontName = o.font || (o.mono && !CJK.test(characters) ? ctx.fonts.mono : o.medium ? ctx.fonts.medium : ctx.fonts.regular); + const font = o.font || (o.mono ? ctx.fonts.mono : o.medium ? ctx.fonts.medium : ctx.fonts.regular); + node.fontName = font; node.fontSize = o.size || 13; node.lineHeight = { unit: "PIXELS", value: o.lineHeight || Math.round(node.fontSize * 1.5) }; - node.characters = characters; + writeText(ctx, node, characters, font); node.fills = paint(o.color || COLORS.fg); if (o.align) node.textAlignHorizontal = o.align; parent.appendChild(node); @@ -774,18 +805,42 @@ function text(ctx, parent, name, characters, o = {}) { node.maxLines = 1; } return node; -} +}); /** 等宽字体没有中文字形:连续的中文字符换成中文字体。 */ -function useCjkFont(ctx, node) { - const run = /[\u3000-\u9fff\uff00-\uffef]+/g; +const LATIN_RUN = /[^\u3000-\u9fff\uff00-\uffef]+/g; + +/** 把 [start, end) 这段设成等宽字体,只动中文以外的字:等宽字体里没有中文字形。 */ +function monoRange(ctx, node, characters, start, end) { + const part = characters.slice(start, end); let match; - while ((match = run.exec(node.characters))) node.setRangeFontName(match.index, match.index + match[0].length, ctx.fonts.regular); + LATIN_RUN.lastIndex = 0; + while ((match = LATIN_RUN.exec(part))) node.setRangeFontName(start + match.index, start + match.index + match[0].length, ctx.fonts.mono); } -function paintRanges(node, ranges) { - for (const range of ranges) node.setRangeFills(range.start, range.end, paint(range.color)); -} +/** + * 放字,新建的文字层和实例里改字都走这里。font 是这段字想要的字体。 + * 等宽字体里没有中文字形:夹着中文的一段要是先用等宽字体放进去,Figma 得给每个中文去找替代字体,一个文字层就要几十毫秒; + * 所以整段先用中文字体放进去,再把中文以外的部分换回等宽字体。 + */ +const writeText = measured("放字", function writeText(ctx, node, characters, font) { + const mixed = font === ctx.fonts.mono && font !== ctx.fonts.regular && CJK.test(characters); + node.fontName = mixed ? ctx.fonts.regular : font; + node.characters = characters; + if (mixed) monoRange(ctx, node, characters, 0, characters.length); +}); + +const paintRanges = measured("分段上色", function paintRanges(node, ranges, base) { + // 每次上色都是一次往返:同色相邻的片段(中间只隔空白也算)并成一次,和底色一样的不画 + const characters = node.characters; + const merged = []; + for (const range of [...ranges].sort((a, b) => a.start - b.start)) { + const last = merged[merged.length - 1]; + if (last && last.color === range.color && !characters.slice(last.end, range.start).trim()) last.end = range.end; + else merged.push({ ...range }); + } + for (const range of merged) if (range.color !== base && range.end > range.start) node.setRangeFills(range.start, range.end, paint(range.color)); +}); function divider(ctx, parent, o = {}) { const node = ctx.api.createFrame(); @@ -832,6 +887,27 @@ const LUCIDE = { "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" }]], "x": [["path", { d: "M18 6 6 18" }], ["path", { d: "m6 6 12 12" }]], + // 代码块种类的图标 + "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" }]], + "braces": [["path", { d: "M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1" }], ["path", { d: "M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1" }]], + "box": [["path", { d: "M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" }], ["path", { d: "m3.3 7 8.7 5 8.7-5" }], ["path", { d: "M12 22V12" }]], + "tag": [["path", { d: "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" }], ["circle", { cx: "7.5", cy: "7.5", r: ".5", fill: "currentColor" }]], + "lock": [["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2", ry: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]], + "variable": [["path", { d: "M8 21s-4-3-4-9 4-9 4-9" }], ["path", { d: "M16 3s4 3 4 9-4 9-4 9" }], ["line", { x1: "15", x2: "9", y1: "9", y2: "15" }], ["line", { x1: "9", x2: "15", y1: "9", y2: "15" }]], + "split": [["path", { d: "M16 3h5v5" }], ["path", { d: "M8 3H3v5" }], ["path", { d: "M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" }], ["path", { d: "m15 9 6-6" }]], + "corner-down-right": [["path", { d: "m15 10 5 5-5 5" }], ["path", { d: "M4 4v7a4 4 0 0 0 4 4h12" }]], + "shield": [["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]], + "shield-alert": [["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }], ["path", { d: "M12 8v4" }], ["path", { d: "M12 16h.01" }]], + "shield-check": [["path", { d: "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }], ["path", { d: "m9 12 2 2 4-4" }]], + "corner-down-left": [["path", { d: "M20 4v7a4 4 0 0 1-4 4H4" }], ["path", { d: "m9 10-5 5 5 5" }]], + "code": [["path", { d: "m16 18 6-6-6-6" }], ["path", { d: "m8 6-6 6 6 6" }]], + "database": [["ellipse", { cx: "12", cy: "5", rx: "9", ry: "3" }], ["path", { d: "M3 5V19A9 3 0 0 0 21 19V5" }], ["path", { d: "M3 12A9 3 0 0 0 21 12" }]], + "list-ordered": [["path", { d: "M11 5h10" }], ["path", { d: "M11 12h10" }], ["path", { d: "M11 19h10" }], ["path", { d: "M4 4h1v5" }], ["path", { d: "M4 9h2" }], ["path", { d: "M6.5 20H3.4c0-1 2.6-1.925 2.6-3.5a1.5 1.5 0 0 0-2.6-1.02" }]], + "server-cog": [["path", { d: "m10.852 14.772-.383.923" }], ["path", { d: "M13.148 14.772a3 3 0 1 0-2.296-5.544l-.383-.923" }], ["path", { d: "m13.148 9.228.383-.923" }], ["path", { d: "m13.53 15.696-.382-.924a3 3 0 1 1-2.296-5.544" }], ["path", { d: "m14.772 10.852.923-.383" }], ["path", { d: "m14.772 13.148.923.383" }], ["path", { d: "M4.5 10H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-.5" }], ["path", { d: "M4.5 14H4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2h-.5" }], ["path", { d: "M6 18h.01" }], ["path", { d: "M6 6h.01" }], ["path", { d: "m9.228 10.852-.923-.383" }], ["path", { d: "m9.228 13.148-.923.383" }]], + "calendar-clock": [["path", { d: "M16 14v2.2l1.6 1" }], ["path", { d: "M16 2v4" }], ["path", { d: "M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5" }], ["path", { d: "M3 10h5" }], ["path", { d: "M8 2v4" }], ["circle", { cx: "16", cy: "16", r: "6" }]], + "globe": [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }], ["path", { d: "M2 12h20" }]], + "package": [["path", { d: "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }], ["path", { d: "M12 22V12" }], ["polyline", { points: "3.29 7 12 12 20.71 7" }], ["path", { d: "m7.5 4.27 9 5.15" }]], "git-pull-request": [["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: "M6 9v12" }]], "files": [["path", { d: "M20 7h-3a2 2 0 0 1-2-2V2" }], ["path", { d: "M9 18a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h7l4 4v10a2 2 0 0 1-2 2Z" }], ["path", { d: "M3 7.6v12.8A1.6 1.6 0 0 0 4.6 22h9.8" }]], "search": [["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]], @@ -845,15 +921,17 @@ const LUCIDE = { "settings": [["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], ["circle", { cx: "12", cy: "12", r: "3" }]], }; -function icon(ctx, name, color, size, o = {}) { +const icon = measured("画 SVG 图标", function icon(ctx, name, color, size, o = {}) { const shapes = LUCIDE[name]; if (!shapes) throw new Error(`未收录的图标: ${name}`); const body = shapes.map(([tag, attrs]) => `<${tag} ${Object.entries(attrs).map(([key, value]) => `${key}="${value}"`).join(" ")}/>`).join(""); - const svg = `${body}`; + // pad:四周留白(单位同 24 格的画布),图标框不变、图形缩小,和一行字等高又不显得挤 + const pad = o.pad || 0; + const svg = `${body.replaceAll("currentColor", color)}`; const node = ctx.api.createNodeFromSvg(svg); node.name = o.name || `Icon / ${name}`; return node; -} +}); function folderGlyph(ctx) { const node = ctx.api.createNodeFromSvg(``); @@ -862,7 +940,7 @@ function folderGlyph(ctx) { } /** Combine components into a variant set and lay them out in a grid. */ -function componentSet(ctx, page, name, components, columns) { +const componentSet = measured("拼成组件集", function componentSet(ctx, page, name, components, columns) { for (const component of components) page.appendChild(component); const set = ctx.api.combineAsVariants(components, page); set.name = name; @@ -880,7 +958,7 @@ function componentSet(ctx, page, name, components, columns) { } set.resize(columns * (width + gap) + gap, y); return set; -} +}); /** * Click wiring. NAVIGATE: only to a top-level frame on the page, never back @@ -896,11 +974,11 @@ async function connect(source, destination, navigation) { }]); } -function named(root, name) { +const named = measured("按名字找图层", function named(root, name) { const node = root.findOne((item) => item.name === name); if (!node) throw new Error(`找不到图层: ${name}`); return node; -} +}); function setText(root, name, characters) { named(root, name).characters = characters; @@ -923,7 +1001,7 @@ const SQLA_MODEL = { ], indexes: [{ name: "ix_referral_relations_inviter_bind_time", columns: "inviter_user_id, bind_time" }], }; -const TABLE_KIND = { label: "数据表", color: "#e8a860" }; +const TABLE_KIND = { label: "数据表", color: "#e8a860", icon: "database", rule: "SQLAlchemy" }; /** 约束一格:唯一 · 索引 · 外键 sys_user.id(删除时禁止)· 默认 …;「非空」是常态不写,可空才写。 */ function constraintSegments(constraints) { @@ -969,7 +1047,7 @@ const ENUM_SAMPLE = { members: [["PENDING", '"pending"', "待结算"], ["AVAILABLE", '"available"', "可提现"], ["WITHDRAWING", '"withdrawing"', "提现中"], ["WITHDRAWN", '"withdrawn"', "已提现"], ["INVALID", '"invalid"', "已失效"]], }; -const ENUM_KIND = { label: "枚举", color: "#b180d7" }; +const ENUM_KIND = EXTRA_KINDS.find((kind) => kind.label === "枚举"); /** Reader / EnumCard — State=preview:枚举卡片。 */ function enumCard(ctx) { @@ -993,8 +1071,8 @@ function enumCard(ctx) { // 文件导入了 celery 才启用。两张卡片:创建应用(X = Celery(...))和定时任务表(X.conf.beat_schedule = {...}); // 改配置、自动发现任务这些语句照旧。示例:luffy 的 backend/src/luffy_agent/celery_app.py。 const CELERY_APP = { name: "celery_app", app: '"luffy_agent"', broker: "settings.CELERY_BROKER_URL", backend: "settings.CELERY_RESULT_BACKEND" }; -const CELERY_APP_KIND = { label: "Celery 应用", color: "#98c379", badgeWidth: 76 }; -const BEAT_KIND = { label: "定时任务", color: "#56b6c2", badgeWidth: 60 }; +const CELERY_APP_KIND = { label: "Celery 应用", color: "#98c379", icon: "server-cog", rule: "Celery" }; +const BEAT_KIND = { label: "定时任务", color: "#56b6c2", icon: "calendar-clock", rule: "Celery" }; // 频率按调度表达式换算成人话:秒数算式 → 每 N 秒 / 分钟 / 小时 / 天,crontab → 每天 hh:mm 这类;换算不了的照原样。 const BEAT_SCHEDULE = { app: "celery_app", @@ -1041,6 +1119,79 @@ function beatCard(ctx) { return node; } +// ─── 库规则:FastAPI 接口 ──────────────────────────────────────────────────── +// +// 文件导入了 fastapi 才启用。挂在路由对象上的函数(`@router.get(...)` 这类装饰器,router 是 APIRouter / FastAPI)整块换成「接口」卡片,函数体照常是子块。 +// 卡片:名称、说明、标签、异步、依赖(Depends(...) 注入的参数,不是请求带的),然后请求、响应各一张表: +// - 请求:一项一行。URL(路由对象的 prefix + 装饰器里的路径,{参数} 单独上色)、方法、路径参数、查询参数、请求体(模型名);没有的项不列。 +// 参数的位置照 FastAPI 的规则认:路径里有同名的 = 路径参数,Query / Header / Cookie 照写法,其余简单类型 = 查询参数,Pydantic 模型 = 请求体。 +// - 响应:每个状态码一行(状态码 / 内容 / 说明)。成功那行 = status_code(没写就是 200)+ response_model(没写就用返回类型标注); +// 装饰器 responses={…} 里写的各占一行;函数体里直接抛的异常按异常类上的 status_code 归到对应状态码,说明列出抛出时的消息; +// 有参数或请求体时 FastAPI 自动带 422。 +// 挂到应用上时再叠的前缀(include_router、settings.API_V1_PREFIX)在别的文件里,URL 不算。 +// 示例:luffy 的 user/routes.py 的 update_user;Forbidden 在 core/exceptions/schemas.py,status_code = 403。 +const ROUTE_KIND = { label: "接口", color: "#61afef", icon: "globe", rule: "FastAPI" }; +const DATACLASS_KIND = { label: "数据类", color: "#ef7fb0", icon: "package", rule: "dataclasses" }; +// Swagger UI 的方法配色,看接口文档的人最熟 +const HTTP_METHOD_COLORS = { GET: "#61affe", POST: "#49cc90", PUT: "#fca130", PATCH: "#50e3c2", DELETE: "#f93e3e" }; +const ROUTE_SAMPLE = { + name: "update_user", doc: "更新用户信息。", tags: ["用户管理"], async: true, + deps: [["db", "get_db"], ["current_user", "get_current_user"]], + method: "PUT", url: "/users/{user_id}", + pathParams: [["user_id", "Int4Path"]], + body: "UserUpdateRequest", + // 状态码 / 内容 / 说明;auto:FastAPI 自动加的 + responses: [ + { status: 200, content: "UserDetailResponse", note: "成功" }, + { status: 403, content: "Forbidden", note: "无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码" }, + { status: 422, content: "参数校验失败", note: "FastAPI 自动返回", auto: true }, + ], +}; + +/** 路径的着色片段:{参数} 按变量色。 */ +function pathSegments(path) { + return path.split(/(\{[^}]+\})/).filter(Boolean).map((part) => ({ text: part, color: part.startsWith("{") ? COLORS.tokenVariable : COLORS.tokenString })); +} + +/** 「名称: 类型」一串,逗号隔开。 */ +const paramSegments = (params) => params.flatMap(([name, type], index) => [ + ...(index ? [{ text: ", " }] : []), + { text: name, color: COLORS.tokenVariable }, { text: ": " }, ...typeSegments(type, "python"), +]); + +/** Reader / RouteCard — State=preview:FastAPI 规则认出的接口卡片,请求、响应各一张表。 */ +function routeCard(ctx) { + const route = ROUTE_SAMPLE; + const { node, content } = cardShell(ctx, "State=preview", ROUTE_KIND, { kids: true }); + propRow(ctx, content, "名称", [{ text: route.name, color: COLORS.tokenFunction }]); + propRow(ctx, content, "说明", [{ text: route.doc, color: COLORS.comment }]); + propRow(ctx, content, "标签", [{ text: route.tags.join("、"), color: COLORS.tokenString }]); + propRow(ctx, content, "异步", yesNo(route.async)); + propRow(ctx, content, "依赖", route.deps.flatMap(([param, fn], index) => [ + ...(index ? [{ text: "、", color: COLORS.dim }] : []), + { text: fn, color: COLORS.tokenFunction }, + ...(param === "_" ? [] : [{ text: `(${param})`, color: COLORS.desc }]), + ])); + const request = [ + ["URL", pathSegments(route.url)], + ["方法", [{ text: route.method, color: HTTP_METHOD_COLORS[route.method] }]], + ...(route.pathParams.length ? [["路径参数", paramSegments(route.pathParams)]] : []), + ...(route.body ? [["请求体", [{ text: route.body, color: COLORS.tokenType }]]] : []), + ]; + propRow(ctx, content, "请求", [{ text: `${route.method} ${route.url}`, color: COLORS.desc }], { twisty: true }); + propTable(ctx, content, "Request", [["项", 120], ["值", 0]], request.map(([key, value]) => [`Request / ${key}`, [ + [{ text: key, color: COLORS.desc }], + value, + ]])); + propRow(ctx, content, "响应", [{ text: route.responses.map((response) => response.status).join(" / "), color: COLORS.desc }], { twisty: true }); + propTable(ctx, content, "Response", [["状态码", 120], ["内容", 260], ["说明", 0]], route.responses.map((response) => [`Status / ${response.status}`, [ + [{ text: String(response.status), color: response.status < 400 ? COLORS.changeAdded : COLORS.tokenString }], + [{ text: response.content, color: response.auto ? COLORS.desc : COLORS.tokenType }], + [{ text: response.note, color: response.auto ? COLORS.desc : COLORS.comment }], + ]])); + return node; +} + // ─── 问 AI ─────────────────────────────────────────────────────────────────── // // 块右上角「问 AI」→ 块下面展开对话:历史问答灰字在上(问题 + 回答前两行,右边时间和删除), @@ -1086,18 +1237,17 @@ function askPanel(ctx, state) { text(ctx, head, "Question", ASK_SAMPLE.question, { size: 12.5, lineHeight: 18, medium: true, color: COLORS.fgStrong }); const body = filled(ctx, node, "Current answer", {}); body.paddingLeft = 18; - const answer = text(ctx, body, "Answer", ASK_SAMPLE.answer.map(([part]) => part.replace(/`/g, "")).join("") + " ▍", - { size: 12.5, lineHeight: 20, color: COLORS.fg }); + const answerText = ASK_SAMPLE.answer.map(([part]) => part.replace(/`/g, "")).join("") + " ▍"; + const answer = text(ctx, body, "Answer", answerText, { size: 12.5, lineHeight: 20, color: COLORS.fg }); let start = 0; for (const [part, style] of ASK_SAMPLE.answer) { const plain = part.replace(/`/g, ""); if (style === "code") { - answer.setRangeFontName(start, start + plain.length, ctx.fonts.mono); + monoRange(ctx, answer, answerText, start, start + plain.length); answer.setRangeFills(start, start + plain.length, paint(COLORS.tokenFunction)); } start += plain.length; } - useCjkFont(ctx, answer); const field = filled(ctx, node, "Input", { dir: "HORIZONTAL", px: 8, py: 5, gap: 6, radius: 4, align: "CENTER", fill: COLORS.input, stroke: COLORS.focus }); text(ctx, field, "Placeholder", "继续问… Enter 发送,Esc 收起", { size: 12.5, lineHeight: 18, color: COLORS.dim }); append(field, icon(ctx, "send", COLORS.desc, 14)); @@ -1207,10 +1357,9 @@ function filterBar(ctx, active) { text(ctx, label, "Text", "筛选", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc }); for (const facet of facets.keys()) { const on = facet === preset.facet; - const chip = append(node, frame(ctx, `Chip / ${facet}`, { dir: "HORIZONTAL", width: CHIP_W, height: 24, px: 8, gap: 4, radius: 4, - align: "CENTER", fill: COLORS.input, stroke: on ? COLORS.focus : COLORS.borderStrong })); - text(ctx, chip, "Text", on ? preset.chip : facet, { size: 12, lineHeight: 16, singleLine: true, color: on ? COLORS.fgStrong : COLORS.fg }); - append(chip, icon(ctx, "chevron-down", COLORS.desc, 12)); + const chip = append(node, ctx.lib.filterChip.get(on ? "active" : "default").createInstance()); + chip.name = `Chip / ${facet}`; + setText(chip, "Text", on ? preset.chip : facet); } append(node, frame(ctx, "Spacer", { width: 10, height: 1 }), true); const hidden = filteredOut(FILTER_FILE, preset.allowed).length; @@ -1239,6 +1388,16 @@ function filterBar(ctx, active) { return node; } +/** Reader / FilterChip — State=default | active:筛选栏里一个属性的下拉框,文字在实例里改;打开着的那个描蓝边。 */ +function filterChip(ctx, state) { + const on = state === "active"; + const node = frame(ctx, `State=${state}`, { component: true, dir: "HORIZONTAL", width: CHIP_W, height: 24, px: 8, gap: 4, radius: 4, + align: "CENTER", fill: COLORS.input, stroke: on ? COLORS.focus : COLORS.borderStrong }); + text(ctx, node, "Text", "属性", { size: 12, lineHeight: 16, singleLine: true, color: on ? COLORS.fgStrong : COLORS.fg }); + append(node, icon(ctx, "chevron-down", COLORS.desc, 12)); + return node; +} + // ─── Components: 代码块 ────────────────────────────────────────────────────── /** 哪些种类的块画成属性表,以及各自的行。 */ @@ -1268,12 +1427,9 @@ function codeBlock(ctx, kind, state) { if (comment) node.dashPattern = [4, 3]; const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" })); append(fold, icon(ctx, "chevron-down", COLORS.desc, 14, { name: "Fold icon" })); - const badge = append(node, frame(ctx, "Badge", { dir: "HORIZONTAL", width: 44, height: 20, radius: 4, align: "CENTER", justify: "CENTER", - fill: spec.color, fillOpacity: 0.18 })); - text(ctx, badge, "Kind", spec.label, { hug: true, medium: true, size: 11, lineHeight: 14, color: spec.color }); + kindBadge(ctx, node, spec); const content = append(node, frame(ctx, "Content", { width: 10 }), true); const plain = text(ctx, content, "Plain", "导入 { xx } 来自 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20, color: comment ? COLORS.desc : COLORS.fg }); - useCjkFont(ctx, plain); const code = text(ctx, content, "Code", "import { xx } from 'xx'", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20, color: comment ? COLORS.comment : COLORS.code }); plain.visible = !editing; @@ -1282,6 +1438,11 @@ function codeBlock(ctx, kind, state) { return { node, toggle }; } +/** 块左边的种类:只画图标(和一行字等高),名称是鼠标停上去的提示。 */ +function kindBadge(ctx, parent, spec) { + return append(parent, icon(ctx, spec.icon, spec.color, 20, { name: `Kind / ${spec.label}`, pad: 3, weight: 2.2 })); +} + /** 块右上角放一个 Reader / BlockTools 实例,返回里面的切换按钮,演示里连 CHANGE_TO。 */ function blockTools(ctx, node, editing) { const tools = ctx.lib.tools.get(editing ? "edit" : "preview").createInstance(); @@ -1334,13 +1495,12 @@ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Lev append(list, instance, true); const plain = named(instance, "Plain"); const translated = translateBlock(fileKey, item); - plain.characters = translated.text; + writeText(ctx, plain, translated.text, ctx.fonts.mono); paintRanges(plain, translated.ranges); - useCjkFont(ctx, plain); const code = named(instance, "Code"); - code.characters = blockCode(fileKey, item.at); - paintRanges(code, highlightCode(code.characters)); - useCjkFont(ctx, code); + const source = blockCode(fileKey, item.at); + writeText(ctx, code, source, ctx.fonts.mono); + paintRanges(code, highlightCode(source)); if (!item.kids) named(instance, "Fold icon").visible = false; placed.push({ item, instance, depth }); if (item.kids) blockLevel(ctx, list, lib, fileKey, item.kids, depth + 1, placed, `Children / L${line}`); @@ -1352,13 +1512,14 @@ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Lev function richText(ctx, parent, name, segments, o = {}) { const node = text(ctx, parent, name, segments.map((segment) => segment.text).join("") || " ", { font: ctx.fonts.mono, size: 12.5, lineHeight: 20, color: o.color || COLORS.fg, width: o.width, hug: o.hug }); + const ranges = []; let start = 0; for (const segment of segments) { const end = start + segment.text.length; - if (segment.color && end > start) node.setRangeFills(start, end, paint(segment.color)); + if (segment.color) ranges.push({ start, end, color: segment.color }); start = end; } - useCjkFont(ctx, node); + paintRanges(node, ranges, o.color || COLORS.fg); return node; } @@ -1502,9 +1663,7 @@ function cardShell(ctx, name, spec, o = {}) { node.paddingRight = 10; 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 badge = append(node, frame(ctx, "Badge", { dir: "HORIZONTAL", width: spec.badgeWidth || 44, height: 20, radius: 4, align: "CENTER", - justify: "CENTER", fill: spec.color, fillOpacity: 0.18 })); - text(ctx, badge, "Kind", spec.label, { hug: true, medium: true, size: 11, lineHeight: 14, color: spec.color }); + kindBadge(ctx, node, spec); const content = append(node, frame(ctx, "Content", { width: 10, gap: 3 }), true); const toggle = blockTools(ctx, node, o.editing); return { node, content, toggle }; @@ -1746,15 +1905,14 @@ function statusBar(ctx, mode) { return node; } -/** Editor / TitleBar — File=<文件>, Mode=edit | preview:标签页 + 右上角的编辑 / 预览切换 */ -function editorTitle(ctx, lib, fileKey, mode) { - const file = FILES[fileKey]; - const node = frame(ctx, `File=${fileKey}, Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W, +/** Editor / TitleBar — Mode=edit | preview:标签页 + 右上角的编辑 / 预览切换;标签页上的文件名和扩展名在实例里改。 */ +function editorTitle(ctx, lib, mode) { + const node = frame(ctx, `Mode=${mode}`, { component: true, dir: "HORIZONTAL", width: EDITOR_W, align: "CENTER", justify: "SPACE_BETWEEN", fill: COLORS.side }); const tab = append(node, frame(ctx, "Tab", { dir: "HORIZONTAL", px: 12, py: 9, gap: 6, align: "CENTER", fill: COLORS.editor })); topBorder(tab, COLORS.focus); - text(ctx, tab, "Ext", EXT_LABEL[file.ext], { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS[file.ext] }); - text(ctx, tab, "Name", file.name, { hug: true, size: 13, lineHeight: 17, color: COLORS.fgStrong }); + text(ctx, tab, "Ext", EXT_LABEL.ts, { hug: true, medium: true, size: 8, lineHeight: 10, color: COLORS.ts }); + text(ctx, tab, "Name", "file.ts", { hug: true, size: 13, lineHeight: 17, color: COLORS.fgStrong }); append(tab, icon(ctx, "x", COLORS.fg, 14)); const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 10, align: "CENTER" })); actions.paddingRight = 10; @@ -1781,11 +1939,11 @@ function breadcrumb(ctx, fileKey) { function createComponents(ctx, page) { const sets = []; const lib = {}; - const make = (name, entries, columns, build, node = (item) => item) => { + const make = (name, entries, columns, build, node = (item) => item) => timed(name, () => { const map = new Map(entries.map((entry) => [entry, build(entry)])); sets.push(componentSet(ctx, page, name, [...map.values()].map(node), columns)); return map; - }; + }); const fileKeys = Object.keys(FILES); ctx.lib = lib; lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode)); @@ -1797,8 +1955,10 @@ function createComponents(ctx, page) { lib.enumCard = make("Reader / EnumCard", ["preview"], 1, () => enumCard(ctx)); lib.celeryApp = make("Reader / CeleryAppCard", ["preview"], 1, () => celeryAppCard(ctx)); lib.beat = make("Reader / BeatCard", ["preview"], 1, () => beatCard(ctx)); + lib.route = make("Reader / RouteCard", ["preview"], 1, () => routeCard(ctx)); lib.iteration = make("Reader / IterationBlock", ITERATION_SAMPLES.map((sample) => sample.key), 3, (key) => iterationBlock(ctx, ITERATION_SAMPLES.find((sample) => sample.key === key))); + lib.filterChip = make("Reader / FilterChip", ["default", "active"], 2, (state) => filterChip(ctx, state)); lib.filterBar = make("Reader / FilterBar", ["none", "access", "kind"], 3, (active) => filterBar(ctx, active)); lib.row = make("Explorer / TreeRow", ROW_VARIANTS.map(([type, state]) => `${type}/${state}`), 2, (entry) => treeRow(ctx, ...entry.split("/"))); @@ -1809,8 +1969,7 @@ function createComponents(ctx, page) { lib.windowTitle = make("Window / TitleBar", ["active"], 1, () => windowTitle(ctx)); lib.activity = make("Window / ActivityBar", ["reader"], 1, () => activityBar(ctx)); lib.status = make("Window / StatusBar", MODES, 1, (mode) => statusBar(ctx, mode)); - lib.editorTitle = make("Editor / TitleBar", fileKeys.flatMap((fileKey) => MODES.map((mode) => `${fileKey}/${mode}`)), 2, - (entry) => editorTitle(ctx, lib, ...entry.split("/"))); + lib.editorTitle = make("Editor / TitleBar", MODES, 2, (mode) => editorTitle(ctx, lib, mode)); lib.breadcrumb = make("Editor / Breadcrumb", fileKeys, 1, (fileKey) => breadcrumb(ctx, fileKey)); lib.card = new Map(); const setNames = new Set(); @@ -1818,12 +1977,14 @@ function createComponents(ctx, page) { walkTree(nodes, (item, depth) => { if (!GRID_KINDS[item.kind]) return; const line = lineRange(item.at)[0]; - const built = new Map(["preview", "edit"].map((state) => [state, gridCard(ctx, fileKey, item, state, depth === 0)])); - let name = `${GRID_KINDS[item.kind].set} / ${built.get("preview").title}`; - if (setNames.has(name)) name += ` · L${line}`; - setNames.add(name); - sets.push(componentSet(ctx, page, name, [...built.values()].map((card) => card.node), 2)); - lib.card.set(`${fileKey}:${line}`, built); + timed("属性表卡片(函数 / 导入 / 常量 / 变量,共 36 套)", () => { + const built = new Map(["preview", "edit"].map((state) => [state, gridCard(ctx, fileKey, item, state, depth === 0)])); + let name = `${GRID_KINDS[item.kind].set} / ${built.get("preview").title}`; + if (setNames.has(name)) name += ` · L${line}`; + setNames.add(name); + sets.push(componentSet(ctx, page, name, [...built.values()].map((card) => card.node), 2)); + lib.card.set(`${fileKey}:${line}`, built); + }); }); } lib.tree = make("Reader / BlockTree", fileKeys, 2, (fileKey) => blockTree(ctx, lib, fileKey), (item) => item.node); @@ -1925,7 +2086,10 @@ function sidebarContent(ctx, pane, lib, selected, o) { return rows; } -/** 编辑模式:VS Code 原生编辑器,整份源码。 */ +// 整份源码按这么多行拆成一段段文字:往一段长文字上色,每次都要把整段重排一遍,拆短了上色快得多;段与段紧挨着,看上去还是一整块 +const SOURCE_CHUNK_LINES = 8; + +/** 编辑模式:VS Code 原生编辑器,整份源码。上色按整份源码算一次(多行注释跨段也对),再切给各段。 */ function codeView(ctx, editor, fileKey) { const view = filled(ctx, editor, "Code view", { dir: "HORIZONTAL", py: 4, gap: 22 }); view.layoutSizingVertical = "FILL"; @@ -1934,9 +2098,19 @@ function codeView(ctx, editor, fileKey) { const lines = SOURCES[fileKey].map((line) => line || " "); const numbers = append(view, frame(ctx, "Line numbers", { width: 48 })); text(ctx, numbers, "No", lines.map((_, index) => String(index + 1)).join("\n"), { size: 13, lineHeight: 19, mono: true, align: "RIGHT", color: COLORS.dim }); - const source = text(ctx, view, "Source", lines.join("\n"), { hug: true, font: ctx.fonts.mono, size: 13, lineHeight: 19, color: COLORS.code }); - paintRanges(source, highlightCode(source.characters)); - useCjkFont(ctx, source); + const whole = lines.join("\n"); + const ranges = highlightCode(whole); + const source = append(view, frame(ctx, "Source", {})); + let offset = 0; + for (let first = 0; first < lines.length; first += SOURCE_CHUNK_LINES) { + const chunk = lines.slice(first, first + SOURCE_CHUNK_LINES).join("\n"); + const end = offset + chunk.length; + const node = text(ctx, source, `Lines ${first + 1}-${first + SOURCE_CHUNK_LINES > lines.length ? lines.length : first + SOURCE_CHUNK_LINES}`, chunk, + { hug: true, font: ctx.fonts.mono, size: 13, lineHeight: 19, color: COLORS.code }); + paintRanges(node, ranges.filter((range) => range.end > offset && range.start < end) + .map((range) => ({ start: Math.max(range.start, offset) - offset, end: Math.min(range.end, end) - offset, color: range.color }))); + offset = end + 1; + } return view; } @@ -1954,6 +2128,10 @@ function previewView(ctx, editor, lib, fileKey, editorWidth) { /** 一整个 VS Code 窗口:标题栏 / 活动栏 + 侧栏 + 编辑器 / 状态栏。 */ function windowBoard(ctx, page, lib, name, o) { + return timed(`Prototype / ${name}`, () => windowBoardContent(ctx, page, lib, name, o)); +} + +function windowBoardContent(ctx, page, lib, name, o) { const width = o.width || WINDOW_W; const editorWidth = width - ACTIVITY_W - SIDEBAR_W - 1; const screen = frame(ctx, `Prototype / ${name}`, { width, height: WINDOW_H, fill: COLORS.editor }); @@ -1972,8 +2150,13 @@ function windowBoard(ctx, page, lib, name, o) { divider(ctx, main, { vertical: true }); const editor = append(main, frame(ctx, "Editor", { width: editorWidth, fill: COLORS.editor })); editor.layoutSizingVertical = "FILL"; - const titleBar = lib.editorTitle.get(`${o.file}/${o.mode}`).createInstance(); + const titleBar = lib.editorTitle.get(o.mode).createInstance(); titleBar.name = "Editor title"; + const file = FILES[o.file]; + setText(titleBar, "Name", file.name); + const ext = named(titleBar, "Ext"); + ext.characters = EXT_LABEL[file.ext]; + ext.fills = paint(COLORS[file.ext]); append(editor, titleBar, true); const crumbs = lib.breadcrumb.get(o.file).createInstance(); crumbs.name = "Breadcrumb"; @@ -2062,9 +2245,38 @@ function tooltip(ctx, screen, o, rows) { return node; } +/** + * 块种类图例:插件定义的每种块一个图标、颜色、名称;块上只画图标,鼠标停在图标上显示名称。 + * 库规则带的种类由规则自己给,后面淡色写是哪条规则。最上面是一块「返回」停着鼠标的样子。 + */ +function kindLegend(ctx, page, lib) { + const board = frame(ctx, "Prototype / 块种类图标", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 6, fill: COLORS.editor }); + page.appendChild(board); + const demo = filled(ctx, board, "Hover demo", {}); + demo.paddingBottom = 34; + const block = lib.block.get("return/preview").node.createInstance(); + block.name = "Block / 返回"; + append(demo, block, true); + setText(block, "Plain", "返回 结果"); + const tip = frame(ctx, "Tooltip", { px: 8, py: 4, radius: 4, fill: COLORS.widget, stroke: COLORS.widgetBorder }); + text(ctx, tip, "Text", BLOCK_KINDS.return.label, { hug: true, size: 12, lineHeight: 16, color: COLORS.fgStrong }); + demo.appendChild(tip); + tip.layoutPositioning = "ABSOLUTE"; + tip.x = 26; + tip.y = 34; + const rule = [TABLE_KIND, DATACLASS_KIND, CELERY_APP_KIND, BEAT_KIND, ROUTE_KIND]; + for (const spec of [...Object.values(BLOCK_KINDS), ...EXTRA_KINDS, ...rule]) { + const row = filled(ctx, board, `Kind row / ${spec.label}`, { dir: "HORIZONTAL", gap: 10, align: "CENTER" }); + kindBadge(ctx, row, spec); + text(ctx, row, "Label", spec.rule ? `${spec.label} 库规则 · ${spec.rule}` : spec.label, { hug: true, size: 13, lineHeight: 20, color: spec.color }); + } + return board; +} + // ─── Entry ─────────────────────────────────────────────────────────────────── async function build(api, stage = () => {}) { + clock = new Map(); stage("加载中文字体"); const ctx = { api, fonts: await chooseFonts(api) }; stage("新建独立页面"); @@ -2104,10 +2316,9 @@ async function build(api, stage = () => {}) { append(askBoard, block, true); const translated = translateBlock("routes", item); const plain = named(block, "Plain"); - plain.characters = translated.text; + writeText(ctx, plain, translated.text, ctx.fonts.mono); paintRanges(plain, translated.ranges); - useCjkFont(ctx, plain); - named(block, "Code").characters = blockCode("routes", item.at); + writeText(ctx, named(block, "Code"), blockCode("routes", item.at), ctx.fonts.mono); named(block, "Fold icon").visible = Boolean(item.kids); if (state) { const panel = lib.askPanel.get(state).createInstance(); @@ -2132,6 +2343,12 @@ async function build(api, stage = () => {}) { instance.name = name; append(celeryBoard, instance, true); } + const routeBoard = frame(ctx, "Prototype / 库规则 · FastAPI 接口", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor }); + page.appendChild(routeBoard); + const routeInstance = lib.route.get("preview").createInstance(); + routeInstance.name = `Block / ${ROUTE_SAMPLE.name}`; + append(routeBoard, routeInstance, true); + const kindBoard = kindLegend(ctx, page, lib); const iteration = frame(ctx, "Prototype / 遍历 · 推导式", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor }); page.appendChild(iteration); for (const sample of ITERATION_SAMPLES) { @@ -2154,48 +2371,52 @@ async function build(api, stage = () => {}) { }; stage("连接演示入口"); + // 连线各自独立,一起发出去再等,不一条条排队等 Figma 回话 + const wires = []; + const link = (...args) => { wires.push(connect(...args)); }; // 代码块:「编辑」换成源码,「预览」换回译文 for (const built of lib.card.values()) { - await connect(built.get("preview").toggle, built.get("edit").node, "CHANGE_TO"); - await connect(built.get("edit").toggle, built.get("preview").node, "CHANGE_TO"); + link(built.get("preview").toggle, built.get("edit").node, "CHANGE_TO"); + link(built.get("edit").toggle, built.get("preview").node, "CHANGE_TO"); } for (const kind of PLAIN_KINDS) { const preview = lib.block.get(`${kind}/preview`); const edit = lib.block.get(`${kind}/edit`); - await connect(preview.toggle, edit.node, "CHANGE_TO"); - await connect(edit.toggle, preview.node, "CHANGE_TO"); + link(preview.toggle, edit.node, "CHANGE_TO"); + link(edit.toggle, preview.node, "CHANGE_TO"); } // 窗口之间:右上角切编辑 / 预览;侧栏点别的文件就用编辑器打开它 for (const [fileKey, pair] of Object.entries(boards)) { - await connect(named(pair.edit.titleBar, "Seg / 预览"), pair.preview.screen, "NAVIGATE"); - await connect(named(pair.preview.titleBar, "Seg / 编辑"), pair.edit.screen, "NAVIGATE"); + link(named(pair.edit.titleBar, "Seg / 预览"), pair.preview.screen, "NAVIGATE"); + link(named(pair.preview.titleBar, "Seg / 编辑"), pair.edit.screen, "NAVIGATE"); for (const view of [pair.edit, pair.preview]) { for (const [other, target] of Object.entries(boards)) { - if (other !== fileKey && view.rows.has(other)) await connect(view.rows.get(other), target.edit.screen, "NAVIGATE"); + if (other !== fileKey && view.rows.has(other)) link(view.rows.get(other), target.edit.screen, "NAVIGATE"); } } } // 文件注释:悬停出的「添加注释」→ 顶部输入框,输入框里回车 → 保存后侧栏显示注释 - await connect(named(notes.hover.rows.get("hover"), "Action / 添加注释"), notes.typing.screen, "NAVIGATE"); - await connect(named(notes.typing.quickInput, "Field"), notes.saved.screen, "NAVIGATE"); - await connect(named(notes.saved.rows.get("hover"), "Action / 复制"), notes.copy.screen, "NAVIGATE"); + link(named(notes.hover.rows.get("hover"), "Action / 添加注释"), notes.typing.screen, "NAVIGATE"); + link(named(notes.typing.quickInput, "Field"), notes.saved.screen, "NAVIGATE"); + link(named(notes.saved.rows.get("hover"), "Action / 复制"), notes.copy.screen, "NAVIGATE"); // 筛选:默认栏里点「访问级别」「种类」打开各自的下拉菜单;「清除」回到不筛 - await connect(named(filters.none.filter, "Chip / 访问级别"), filters.access.screen, "NAVIGATE"); - await connect(named(filters.none.filter, "Chip / 种类"), filters.kind.screen, "NAVIGATE"); - await connect(named(filters.access.filter, "Clear"), filters.none.screen, "NAVIGATE"); - await connect(named(filters.kind.filter, "Clear"), filters.none.screen, "NAVIGATE"); + link(named(filters.none.filter, "Chip / 访问级别"), filters.access.screen, "NAVIGATE"); + link(named(filters.none.filter, "Chip / 种类"), filters.kind.screen, "NAVIGATE"); + link(named(filters.access.filter, "Clear"), filters.none.screen, "NAVIGATE"); + link(named(filters.kind.filter, "Clear"), filters.none.screen, "NAVIGATE"); // 审查工单:侧栏「选工单」→ 选工单弹框,点工单 → 树里只剩 PR 改过的文件;「显示全部」→ 退出审查 - await connect(named(boards.routes.preview.sidebar, "Action / 选工单"), review.pick.screen, "NAVIGATE"); - await connect(named(review.pick.picker, `Issue / #${REVIEW.issue.number}`), review.files.screen, "NAVIGATE"); - await connect(named(review.files.sidebar, "Action / 显示全部"), boards.routes.preview.screen, "NAVIGATE"); + link(named(boards.routes.preview.sidebar, "Action / 选工单"), review.pick.screen, "NAVIGATE"); + link(named(review.pick.picker, `Issue / #${REVIEW.issue.number}`), review.files.screen, "NAVIGATE"); + link(named(review.files.sidebar, "Action / 显示全部"), boards.routes.preview.screen, "NAVIGATE"); + await Promise.all(wires); stage("排布画板"); const screens = [...Object.values(boards).flatMap((pair) => [pair.edit.screen, pair.preview.screen]), narrow.screen, ...Object.values(notes).map((board) => board.screen), ...Object.values(filters).map((board) => board.screen), - ...Object.values(review).map((board) => board.screen), iteration, tableBoard, enumBoard, celeryBoard, askBoard]; + ...Object.values(review).map((board) => board.screen), iteration, tableBoard, enumBoard, celeryBoard, routeBoard, kindBoard, askBoard]; let y = 0; for (const screen of screens) { screen.x = 0; @@ -2211,19 +2432,35 @@ async function build(api, stage = () => {}) { const start = boards.envLock.preview.screen; page.flowStartingPoints = [{ nodeId: start.id, name: "预览 envLock.ts" }, { nodeId: notes.hover.screen.id, name: "文件注释" }, { nodeId: filters.none.screen.id, name: "筛选" }, { nodeId: boards.routes.preview.screen.id, name: "审查工单" }]; - page.selection = [start]; - api.viewport.scrollAndZoomIntoView(page.selection); - return { page, components, boards, narrow, notes, filters, review, iteration, tableBoard, enumBoard, celeryBoard, askBoard, screens }; + // 不选中任何画板(选中会把它在图层面板里层层展开),顶层全部收起 + for (const node of page.children) node.expanded = false; + api.viewport.scrollAndZoomIntoView([start]); + return { clock, page, components, boards, narrow, notes, filters, review, iteration, tableBoard, enumBoard, celeryBoard, routeBoard, kindBoard, askBoard, screens }; +} + +/** 各阶段用时,写进结束提示,慢了看得出慢在哪一步。 */ +function timingText(marks, end, spent) { + const seconds = (ms) => (ms / 1000).toFixed(1); + const parts = marks.map(([name, at], index) => `${name} ${seconds((marks[index + 1] ? marks[index + 1][1] : end) - at)}`); + const top = (entries, count) => entries.sort((a, b) => b[1] - a[1]).slice(0, count).map(([label, ms]) => `${label} ${seconds(ms)}`).join("、"); + const all = [...spent]; + const ops = all.filter(([label]) => label.startsWith("操作 · ")).map(([label, ms]) => [label.slice(5), ms]); + const parts2 = all.filter(([label]) => !label.startsWith("操作 · ")); + return `用时 ${seconds(end - marks[0][1])} 秒(${parts.join("、")})。最慢的几块:${top(parts2, 6)}。按操作算:${top(ops, ops.length)}`; } async function run(api) { let currentStage = "初始化"; + const marks = []; const notice = api.notify("正在生成原型,请稍候…", { timeout: Infinity }); await new Promise((resolve) => setTimeout(resolve, 50)); try { - await build(api, (stage) => { currentStage = stage; }); + const { clock: spent } = await build(api, (stage) => { + currentStage = stage; + marks.push([stage, Date.now()]); + }); notice.cancel(); - api.closePlugin("已生成「代码阅读」的组件与演示画板;选中「Prototype / envLock.ts · 预览」后点演示。"); + api.closePlugin(`已生成「代码阅读」的组件与演示画板,${timingText(marks, Date.now(), spent)}。从「Prototype / envLock.ts · 预览」开始演示。`); } catch (error) { const message = error instanceof Error ? error.message : String(error); notice.cancel(); @@ -2232,6 +2469,6 @@ async function run(api) { } if (typeof module !== "undefined") { - module.exports = { 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 = { 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); diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js index 4edd0cf..bbd115a 100644 --- a/docs/prototype/code-reader/figma-plugin/test.js +++ b/docs/prototype/code-reader/figma-plugin/test.js @@ -2,7 +2,7 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); const fs = require("node:fs"); const path = require("node:path"); -const { 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 { 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"); function action(node) { return node.reactions[0].actions[0]; } @@ -35,7 +35,9 @@ const EXPECTED_SETS = [ ["Reader / EnumCard", 1], ["Reader / CeleryAppCard", 1], ["Reader / BeatCard", 1], + ["Reader / RouteCard", 1], ["Reader / IterationBlock", 3], + ["Reader / FilterChip", 2], ["Reader / FilterBar", 3], ["Explorer / TreeRow", 10], ["Window / QuickInput", 1], @@ -45,7 +47,7 @@ const EXPECTED_SETS = [ ["Window / TitleBar", 1], ["Window / ActivityBar", 1], ["Window / StatusBar", 2], - ["Editor / TitleBar", 6], + ["Editor / TitleBar", 2], ["Editor / Breadcrumb", 3], ["Import / 'node:fs'", 2], ["Import / 'vscode'", 2], @@ -280,15 +282,15 @@ test("build creates the variant sets, reuses them on boards and wires the flows" assert.equal(instance.mainComponentId, variant.id); const parsed = translateBlock(fileKey, item); assert.equal(named(instance, "Plain").characters, parsed.text); - assert.equal(named(instance, "Plain").rangeFills.length, parsed.ranges.length); + // 同色相邻的片段并成一次上色,次数不多于片段数 + const fills = named(instance, "Plain").rangeFills.length; + assert.ok(fills <= parsed.ranges.length && (fills > 0) === (parsed.ranges.length > 0), `L${lineRange(item.at)[0]} painted ${fills} of ${parsed.ranges.length}`); assert.equal(named(instance, "Code").characters, blockCode(fileKey, item.at)); assert.ok(named(instance, "Code").rangeFills.length > 0, "code is highlighted"); assert.equal(named(instance, "Fold icon").visible, Boolean(item.kids), "only blocks with children show the fold arrow"); - assert.equal(named(instance, "Kind").characters, BLOCK_KINDS[item.kind].label); + assert.ok(named(instance, `Kind / ${BLOCK_KINDS[item.kind].label}`), "the kind shows as its icon"); } } - const issueComment = lib.tree.get("issueLock").placed.find(({ item }) => item.kind === "comment").instance; - assert.ok(named(issueComment, "Code").rangeFonts.length >= 2, "Chinese comment lines use the CJK font"); const { envLock, issueLock } = result.boards; const toPreview = action(named(envLock.edit.titleBar, "Seg / 预览")); @@ -311,9 +313,10 @@ test("build creates the variant sets, reuses them on boards and wires the flows" for (const instance of instances) assert.ok(setIds.has(instance.mainComponentId), `${instance.name} comes from a set`); } // 编辑窗口显示整份源码,预览窗口显示代码块树 - assert.equal(named(envLock.edit.view, "Source").characters.split("\n").length, 61); - assert.equal(named(issueLock.edit.view, "Source").characters.split("\n").length, 25); - assert.ok(named(issueLock.edit.view, "Source").rangeFonts.length >= 2); + // 源码拆成一段段文字,拼起来就是整份源码 + const sourceText = (view) => named(view, "Source").children.map((node) => node.characters).join("\n"); + assert.equal(sourceText(envLock.edit.view), SOURCES.envLock.map((line) => line || " ").join("\n")); + assert.equal(sourceText(issueLock.edit.view).split("\n").length, 25); assert.equal(named(envLock.preview.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id); assert.equal(named(result.narrow.view, "Block tree / envLock.ts").mainComponentId, lib.tree.get("envLock").node.id); assert.equal(named(issueLock.preview.view, "Block tree / issueLock.ts").mainComponentId, lib.tree.get("issueLock").node.id); @@ -395,7 +398,7 @@ test("the SQLAlchemy rule turns a model class into a table card", async () => { const api = fakeFigma(); const { tableBoard } = await build(api); const card = tableBoard.findOne((node) => node.name === `Block / ${SQLA_MODEL.name}`); - assert.equal(named(card, "Kind").characters, "数据表"); + assert.ok(named(card, "Kind / 数据表")); assert.deepEqual(card.findAll((node) => node.name.startsWith("Prop / ")).map((node) => node.name), ["Prop / 名称", "Prop / 表名", "Prop / 说明", "Prop / 继承", "Prop / 字段", "Prop / 索引"]); assert.equal(card.findAll((node) => node.name.startsWith("Column / ")).length, SQLA_MODEL.columns.length); @@ -407,7 +410,7 @@ test("enums get their own card with a member table", async () => { const api = fakeFigma(); const { enumBoard } = await build(api); const card = enumBoard.findOne((node) => node.name === `Block / ${ENUM_SAMPLE.name}`); - assert.equal(named(card, "Kind").characters, "枚举"); + assert.ok(named(card, "Kind / 枚举")); assert.equal(card.findAll((node) => node.name.startsWith("Member / ")).length, ENUM_SAMPLE.members.length); assert.equal(named(named(card.findOne((node) => node.name === "Member / PENDING"), "说明"), "Text").characters, "待结算"); }); @@ -416,16 +419,66 @@ test("the Celery rule draws the app and the beat schedule", async () => { const api = fakeFigma(); const { celeryBoard } = await build(api); const app = celeryBoard.findOne((node) => node.name === "Block / celery_app"); - assert.equal(named(app, "Kind").characters, "Celery 应用"); + assert.ok(named(app, "Kind / Celery 应用")); assert.deepEqual(app.findAll((node) => node.name.startsWith("Prop / ")).map((node) => node.name), ["Prop / 名称", "Prop / 应用名", "Prop / 消息队列", "Prop / 结果存储"]); const beat = celeryBoard.findOne((node) => node.name === "Block / beat_schedule"); - assert.equal(named(beat, "Kind").characters, "定时任务"); + assert.ok(named(beat, "Kind / 定时任务")); assert.equal(beat.findAll((node) => node.name.startsWith("Entry / ")).length, BEAT_SCHEDULE.entries.length + 1); const entry = beat.findOne((node) => node.name === "Entry / deployment.notify_expiring_deployments"); assert.equal(named(named(entry, "频率"), "Text").characters, "每天"); }); +test("the FastAPI rule turns a route into a card with a request table and a status-code table", async () => { + const api = fakeFigma(); + const { routeBoard, components } = await build(api); + assert.equal(routeBoard.children.length, 1); + const card = components.lib.route.get("preview"); + assert.equal(named(named(card, "Prop / 依赖"), "Value").characters, "get_db(db)、get_current_user(current_user)"); + // 请求:一项一行 + const cell = (row, column) => named(named(named(card, row), column), "Text").characters; + assert.deepEqual(named(card, "Request").findAll((node) => node.name.startsWith("Request / ")).map((node) => node.name.slice(10)), + ["URL", "方法", "路径参数", "请求体"]); + assert.equal(cell("Request / URL", "值"), "/users/{user_id}"); + assert.equal(cell("Request / 方法", "值"), "PUT"); + assert.equal(cell("Request / 路径参数", "值"), "user_id: Int4Path"); + assert.equal(cell("Request / 请求体", "值"), "UserUpdateRequest"); + // 响应:每个状态码一行 + assert.deepEqual(named(card, "Response").findAll((node) => node.name.startsWith("Status / ")).map((node) => node.name.slice(9)), + ROUTE_SAMPLE.responses.map((response) => String(response.status))); + assert.equal(cell("Status / 200", "内容"), "UserDetailResponse"); + assert.equal(cell("Status / 403", "内容"), "Forbidden"); + assert.match(cell("Status / 403", "说明"), /无权修改角色/); +}); + +test("monospace text with Chinese is written in the CJK font first, then everything else switched to mono", async () => { + // 等宽字体里没有中文字形:先用它放中文,Figma 要给每个中文找替代字体,慢 + const api = fakeFigma({ fonts: [{ family: "Noto Sans SC", style: "Regular" }, { family: "Noto Sans SC", style: "Medium" }, + { family: "JetBrains Mono", style: "Regular" }] }); + const { components } = await build(api); + const comment = components.lib.tree.get("issueLock").placed.find(({ item }) => item.kind === "comment").instance; + const code = named(comment, "Code"); + assert.equal(code.fontName.family, "Noto Sans SC"); + assert.ok(code.rangeFonts.length >= 2); + for (const { start, end, font } of code.rangeFonts) { + assert.equal(font.family, "JetBrains Mono"); + assert.doesNotMatch(code.characters.slice(start, end), /[\u3000-\u9fff]/, "no Chinese inside a mono range"); + } + const plain = named(components.lib.tree.get("envLock").placed.find(({ item }) => item.kind === "return").instance, "Code"); + assert.equal(plain.fontName.family, "JetBrains Mono", "code without Chinese stays mono as a whole"); + assert.equal(plain.rangeFonts.length, 0); +}); + +test("block kinds show as icons, the legend lists every kind with its name", async () => { + const api = fakeFigma(); + const { kindBoard } = await build(api); + const rows = kindBoard.findAll((node) => node.name.startsWith("Kind row / ")); + assert.ok(rows.length >= Object.keys(BLOCK_KINDS).length + 5); + for (const row of rows) assert.ok(row.findOne((node) => node.name.startsWith("Kind / ")), `${row.name} has its icon`); + assert.equal(named(named(kindBoard, "Tooltip"), "Text").characters, BLOCK_KINDS.return.label); + assert.equal(new Set(Object.values(BLOCK_KINDS).map((kind) => kind.icon)).size, Object.keys(BLOCK_KINDS).length, "each kind has its own icon"); +}); + test("block tools are icons only, and asking AI opens a thread under the block with grey history", async () => { const api = fakeFigma(); const { components, askBoard } = await build(api); @@ -505,7 +558,7 @@ test("the plugin stays small enough for Figma to build quickly", async () => { if (node.type !== "INSTANCE") node.children.forEach(walk); }; result.page.children.forEach(walk); - // 卡死那次是 5000 多个图层,而且每次读宽度都逼 Figma 重排;侧栏、块右上角按钮做成共用组件后,现在 23 张画板约 2800 个 + // 卡死那次是 5000 多个图层,而且每次读宽度都逼 Figma 重排;侧栏、块右上角按钮、筛选下拉框、编辑器标题栏做成共用组件后,现在 24 张画板约 2960 个 assert.ok(owned < 3000, `page owns ${owned} layers outside instances`); });