✨ feat(prototype): 代码阅读原型函数、导入、变量块改成属性表
This commit is contained in:
1 parent
fd3ea03273
commit
0c4c9b0fba
3 files changed
+641
-35
No files matched your search
@@ -7,6 +7,10 @@
|
||||
- 「预览」把代码拆成一棵代码块树:导入、定义、变量、如果、循环、返回……每一句是一块,嵌套的块往右缩进,左边竖线标出层级。
|
||||
- 每块默认显示译文,点块右边的「编辑」换成这一块的源码,点「预览」换回来。
|
||||
- 译文是模板直译:结构照抄原语言,只把关键字、内置类型、运算符换成中文,名字和写法原样保留,每个关键字单独上色、前后有空格。例如 `export async function findEnvFiles(workspaceRoot: string): Promise<string[]>` 译成 `公共 异步 函数 findEnvFiles(workspaceRoot: 字符串): Promise<字符串[]>`。注释原文照搬。
|
||||
- 函数、导入、常量、变量这四种块不显示一行译文,而是树形属性表,类型里的内置类型译成中文(`string` → 字符串、`str` → 字符串),值照常译:
|
||||
- 函数:名称、访问级别(公共 / 私有)、异步(是 / 否)、装饰器、输入参数(展开成每个参数一行:名称 / 类型 / 默认值三列,左边一条竖线)、输出参数。
|
||||
- 导入:来源、仅类型(是 / 否,只有 TypeScript 有)、导入项(展开成每项一行,有别名写成「promises 作为 fsp」)。
|
||||
- 常量 / 变量:名称、访问级别(只有最外层的声明有)、类型(写了标注才有)、值 / 初始值。
|
||||
- 块左边有种类标签(导入、函数、类型、字段、常量、变量、条件、循环、尝试、捕获、返回、语句、注释),底色也带这个种类的颜色;有子块的块左边有折叠箭头。
|
||||
- 译文按词的来源上色,配色和 VS Code 默认深色主题一致:流程关键字(导入 / 来自 / 如果 / 返回 / 遍历 / 等待 / 公共 / 尝试 / 捕获)紫红,声明关键字(常量 / 函数 / 异步 / 接口 / 新建)和运算符(全等 / 非 / 或 / 与)蓝,变量浅蓝,函数黄,类型青,字符串橙,字面量淡绿。源码状态同样上色。
|
||||
|
||||
@@ -17,6 +21,7 @@
|
||||
| `Prototype / envLock.ts · 编辑` | `vscode/src/files/envLock.ts` | 活动栏新图标选中,侧栏文件树,文件在编辑器里打开,右上角「编辑」亮 |
|
||||
| `Prototype / envLock.ts · 预览` | 同上 | 代码块树:函数里套如果、循环里套尝试 / 出错时,最深 4 层;一块停在「编辑」状态做对照 |
|
||||
| `Prototype / issueLock.ts · 编辑 / 预览` | `vscode/src/panel/issueLock.ts` | 中文注释块、多行返回语句块 |
|
||||
| `Prototype / routes.py · 编辑 / 预览` | luffy-agent-platform 的 `backend/src/luffy_agent/user/routes.py` | Python:带装饰器、默认值、返回类型的 FastAPI 路由函数,属性表;`is not None` → 不是 空 |
|
||||
| `Prototype / envLock.ts · 预览(编辑区变窄)` | — | 窗口只有 1000 宽时块跟着变窄,长句换行 |
|
||||
|
||||
## 导入与运行
|
||||
@@ -37,7 +42,8 @@
|
||||
|
||||
组件集(画板右侧一列):
|
||||
|
||||
- `Reader / CodeBlock`:13 种 `Kind` × `State=preview | edit`,共 26 个。两种状态都带「译文」和「源码」两层文字,只切换显示哪层,所以实例里改过的两段文字切状态时都保留。
|
||||
- `Reader / CodeBlock`:属性表以外的 9 种 `Kind` × `State=preview | edit`,共 18 个。两种状态都带「译文」和「源码」两层文字,只切换显示哪层,所以实例里改过的两段文字切状态时都保留。
|
||||
- `Function / <函数名>`、`Import / <来源>`、`Const / <名称>`、`Variable / <名称>`:每块一套 `State=preview | edit`,预览是属性表,编辑是这一块的源码。行数因块而异,所以每块一个组件集;重名的变量后面加行号。
|
||||
- `Reader / BlockTree`:每个示例文件一整棵代码块树,预览画板直接放它的实例。
|
||||
- `Reader / ModeToggle`:编辑器右上角的「编辑 / 预览」。
|
||||
- `Explorer / TreeRow`、`Explorer / Header`:侧栏文件树。
|
||||
@@ -46,7 +52,7 @@
|
||||
## 数值来源
|
||||
|
||||
- 颜色:VS Code Dark Modern 主题,`code.js` 的 `COLORS` 里注明了对应的主题变量;层级竖线用 `editorIndentGuide.background`。
|
||||
- 源码:两个示例文件的快照,写在 `code.js` 的 `SOURCES`。
|
||||
- 源码:三个示例文件的快照,写在 `code.js` 的 `SOURCES`。Figma 加载插件前会扫描代码,出现 `import (` 就拒绝运行,所以快照里 `import` 后面的括号写成 `(`(测试会检查)。
|
||||
- 代码块怎么拆、每块的种类:写在 `code.js` 的 `CODE_TREE`,测试会检查每一行源码都落进了某一块(空行和收尾括号除外)。
|
||||
- 译文:`translateBlock` 按 `WORD_ZH`(关键字、内置类型)和 `OPERATOR_ZH`(运算符)逐词替换生成,原型只覆盖两个示例文件用到的 TypeScript 写法。
|
||||
- 种类颜色:`BLOCK_KINDS`;高亮配色:`COLORS` 里 `token*` 几项,取自 VS Code Dark+。源码上色是原型里的简易规则,真实插件里颜色直接来自语法树。
|
||||
@@ -81,4 +87,5 @@ node --test test.js
|
||||
- [ ] 不同种类的块一眼能分开:标签颜色、底色、边框都不同;同一种类(比如三个「返回」)颜色一致
|
||||
- [ ] 译文里的关键字、变量名、函数名颜色和编辑器里对应的代码一致,关键字前后都有空格
|
||||
- [ ] 有子块的块左边有折叠箭头,没有子块的没有,标签仍然对齐
|
||||
- [ ] 属性表:函数、导入、常量、变量的属性名一列对齐;导入项每项一行,左边竖线;输入参数下每个参数一行,名称 / 类型 / 默认值三列对齐,左边竖线从第一个参数连到最后一个;长类型(`Annotated[...]`)换行不挤掉默认值列
|
||||
- [ ] 演示:块的编辑 / 预览、右上角编辑 / 预览、侧栏切文件都能点通
|
||||
+515
-21
@@ -44,20 +44,22 @@ const COLORS = {
|
||||
tokenControl: "#c586c0", // keyword.control:import / from / export / if / for / return / try / catch / await
|
||||
tokenStorage: "#569cd6", // storage:const / function / async / interface / new / true / false
|
||||
tokenVariable: "#9cdcfe", // variable
|
||||
tokenConstant: "#4fc1ff", // variable.other.constant:全大写的常量名
|
||||
tokenFunction: "#dcdcaa", // entity.name.function
|
||||
tokenType: "#4ec9b0", // entity.name.type
|
||||
tokenString: "#ce9178", // string
|
||||
tokenLiteral: "#b5cea8", // constant.numeric
|
||||
ts: "#3178c6", tsx: "#4fc1ff", json: "#cbcb41", md: "#519aba",
|
||||
ts: "#3178c6", tsx: "#4fc1ff", json: "#cbcb41", md: "#519aba", py: "#4b8bbe", toml: "#c0754a",
|
||||
};
|
||||
const FONT_FAMILIES = ["PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC"];
|
||||
const MONO_FONTS = [{ family: "JetBrains Mono", style: "Regular" }, { family: "SF Mono", style: "Regular" }, { family: "Menlo", style: "Regular" }];
|
||||
const CJK = /[ -鿿-]/;
|
||||
|
||||
// 源码快照(示例内容),生成于 2026-09-24:vscode/src/files/envLock.ts、vscode/src/panel/issueLock.ts
|
||||
// 源码快照(示例内容),生成于 2026-09-25:superwork 的 vscode/src/files/envLock.ts、vscode/src/panel/issueLock.ts,luffy-agent-platform 的 backend/src/luffy_agent/user/routes.py
|
||||
const SOURCES = {
|
||||
envLock: ["/**"," * .env file lock / unlock helpers."," *"," * Recursively scans the main workspace folder for `.env*` files (skipping"," * `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or"," * 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`."," *"," * No persistent watcher — re-scan happens on every toggle, so newly created"," * `.env` files are only picked up the next time the user clicks the button."," */","","import { promises as fsp } from 'node:fs'","import { RelativePattern, workspace } from 'vscode'","","const ENV_PATTERN = '**/.env*'","const EXCLUDE_PATTERN = '{**/node_modules/**,**/.git/**,**/.claude/**}'","","/**"," * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`"," * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute"," * fs paths. Empty array if the workspace folder isn't currently open."," */","export async function findEnvFiles(workspaceRoot: string): Promise<string[]> {"," const folder = workspace.workspaceFolders?.find(f => f.uri.fsPath === workspaceRoot)"," if (!folder)"," return []"," const include = new RelativePattern(folder, ENV_PATTERN)"," const uris = await workspace.findFiles(include, EXCLUDE_PATTERN)"," return uris.map(u => u.fsPath)","}","","export interface EnvLockResult {"," total: number"," ok: string[]"," failed: { path: string, reason: string }[]","}","","async function chmodAll(workspaceRoot: string, mode: number): Promise<EnvLockResult> {"," const files = await findEnvFiles(workspaceRoot)"," const result: EnvLockResult = { total: files.length, ok: [], failed: [] }"," for (const f of files) {"," try {"," await fsp.chmod(f, mode)"," result.ok.push(f)"," }"," catch (err) {"," result.failed.push({ path: f, reason: err instanceof Error ? err.message : String(err) })"," }"," }"," return result","}","","/** Set every `.env*` file in the workspace to 0o444 (read-only). */","export async function lockEnvFiles(workspaceRoot: string): Promise<EnvLockResult> {"," return chmodAll(workspaceRoot, 0o444)","}","","/** Restore every `.env*` file in the workspace to 0o644 (read-write). */","export async function unlockEnvFiles(workspaceRoot: string): Promise<EnvLockResult> {"," return chmodAll(workspaceRoot, 0o644)","}"],
|
||||
issueLock: ["import type { Issue } from '../gitea/types'","","export interface LockedReason {"," locked: boolean"," prerequisiteNumber?: number"," prerequisiteColumn?: string","}","","/**"," * 纯判定:工单被未完成的前置挡住才算锁定。快照里找不到工单或前置一律放行,"," * 与 webview 的 `isIssueLocked` 保持同一语义。"," */","export function evaluateLockedReason(issues: Issue[], issueNumber: number): LockedReason {"," const issue = issues.find(i => i.number === issueNumber)"," if (!issue || issue.prerequisite === undefined)"," return { locked: false }"," const prereq = issues.find(i => i.number === issue.prerequisite)"," if (!prereq || prereq.column === 'done')"," return { locked: false }"," return {"," locked: true,"," prerequisiteNumber: prereq.number,"," prerequisiteColumn: prereq.column,"," }","}"],
|
||||
routes: ["\"\"\"用户管理路由。","","提供用户自助查看 / 更新资料与上传头像接口。","\"\"\"","","from __future__ import annotations","","from typing import Annotated","","from fastapi import APIRouter, Depends, File, UploadFile","from sqlalchemy.ext.asyncio import AsyncSession","","from luffy_agent.core.db import get_db","from luffy_agent.core.exceptions import Forbidden","from luffy_agent.core.params import Int4Path","from luffy_agent.user.auth.deps import get_current_user","","from .models import User, UserRole","from .schemas import \u0028"," UserAvatarUploadResponse,"," UserDetailResponse,"," UserUpdateRequest,",")","from .services import user_service","","router = APIRouter(tags=[\"用户管理\"], prefix=\"/users\")","","","@router.get(\"/{user_id}\", response_model=UserDetailResponse)","async def get_user("," user_id: Int4Path,"," db: AsyncSession = Depends(get_db),"," current_user: User = Depends(get_current_user),",") -> UserDetailResponse:"," \"\"\"获取用户详情。",""," 管理员可查看任意用户,普通用户只能查看自己。"," \"\"\""," # 非管理员只能查看自己"," if current_user.role != UserRole.ADMIN and current_user.id != user_id:"," raise Forbidden(\"无权查看此用户信息\")",""," user = await user_service.get_user(db, user_id=user_id)",""," return UserDetailResponse("," id=user.id,"," username=user.username,"," display_name=user.display_name,"," role=user.role.value,"," email=user.email,"," phone=user.phone,"," bio=user.bio,"," avatar_url=user.avatar_url,"," is_active=user.is_active,"," created_at=user.created_at,"," updated_at=user.updated_at,"," )","","","@router.put(\"/{user_id}\", response_model=UserDetailResponse)","async def update_user("," user_id: Int4Path,"," body: UserUpdateRequest,"," db: AsyncSession = Depends(get_db),"," current_user: User = Depends(get_current_user),",") -> UserDetailResponse:"," \"\"\"更新用户信息。",""," 管理员可更新任意用户,普通用户只能更新自己的基础资料。"," \"\"\""," # 非管理员只能更新自己"," if current_user.role != UserRole.ADMIN and current_user.id != user_id:"," raise Forbidden(\"无权更新此用户信息\")",""," if current_user.role != UserRole.ADMIN:"," if body.role is not None:"," raise Forbidden(\"无权修改角色\")"," if body.is_active is not None:"," raise Forbidden(\"无权修改启用状态\")"," if body.password is not None:"," raise Forbidden(\"无权修改密码\")",""," user = await user_service.update_user("," db,"," user_id=user_id,"," display_name=body.display_name,"," email=body.email,"," phone=body.phone,"," bio=body.bio,"," avatar_url=body.avatar_url,"," role=body.role,"," is_active=body.is_active,"," password=body.password,"," )",""," return UserDetailResponse("," id=user.id,"," username=user.username,"," display_name=user.display_name,"," role=user.role.value,"," email=user.email,"," phone=user.phone,"," bio=user.bio,"," avatar_url=user.avatar_url,"," is_active=user.is_active,"," created_at=user.created_at,"," updated_at=user.updated_at,"," )","","","@router.post(\"/me/avatar\", response_model=UserAvatarUploadResponse)","async def upload_current_user_avatar("," file: Annotated[UploadFile, File(..., description=\"头像文件\")],"," db: AsyncSession = Depends(get_db),"," current_user: User = Depends(get_current_user),",") -> UserAvatarUploadResponse:"," \"\"\"上传当前登录用户头像。\"\"\""," payload = await file.read()"," avatar_url = await user_service.upload_avatar("," db,"," user_id=current_user.id,"," file_name=file.filename or \"avatar.bin\","," content_type=file.content_type,"," payload=payload,"," )"," return UserAvatarUploadResponse(avatar_url=avatar_url)"],
|
||||
};
|
||||
|
||||
// 文件树示例:本仓库的真实目录,只展开示例文件所在的路径。M 标记是示例状态。
|
||||
@@ -96,7 +98,32 @@ const TREE = [
|
||||
{ depth: 2, type: "file", name: "package.json" },
|
||||
{ depth: 2, type: "file", name: "README.md" },
|
||||
];
|
||||
const EXT_LABEL = { ts: "TS", tsx: "TSX", json: "{}", md: "M↓" };
|
||||
// luffy-agent-platform 的真实目录,只展开 routes.py 所在的路径。
|
||||
const LUFFY_TREE = [
|
||||
{ depth: 1, type: "folder", name: "backend", open: true },
|
||||
{ depth: 2, type: "folder", name: "src", open: true },
|
||||
{ depth: 3, type: "folder", name: "luffy_agent", open: true },
|
||||
{ depth: 4, type: "folder", name: "agent" },
|
||||
{ depth: 4, type: "folder", name: "billing" },
|
||||
{ depth: 4, type: "folder", name: "commerce" },
|
||||
{ depth: 4, type: "folder", name: "core" },
|
||||
{ depth: 4, type: "folder", name: "user", open: true },
|
||||
{ depth: 5, type: "folder", name: "auth" },
|
||||
{ depth: 5, type: "file", name: "__init__.py" },
|
||||
{ depth: 5, type: "file", name: "models.py" },
|
||||
{ depth: 5, type: "file", name: "routes.py", file: "routes" },
|
||||
{ depth: 5, type: "file", name: "schemas.py" },
|
||||
{ depth: 5, type: "file", name: "services.py" },
|
||||
{ depth: 2, type: "file", name: "pyproject.toml" },
|
||||
{ depth: 1, type: "folder", name: "docs" },
|
||||
{ depth: 1, type: "folder", name: "frontend" },
|
||||
{ depth: 1, type: "file", name: "README.md" },
|
||||
];
|
||||
const WORKSPACES = {
|
||||
superwork: { root: "superwork", rows: TREE },
|
||||
luffy: { root: "luffy-agent-platform", rows: LUFFY_TREE },
|
||||
};
|
||||
const EXT_LABEL = { ts: "TS", tsx: "TSX", json: "{}", md: "M↓", py: "PY", toml: "TM" };
|
||||
|
||||
// 活动栏:VS Code 自带的几个 + Superpowers 的小火箭 + 新注册的「代码阅读」
|
||||
const ACTIVITY = [
|
||||
@@ -111,8 +138,9 @@ const ACTIVITY = [
|
||||
];
|
||||
|
||||
const FILES = {
|
||||
envLock: { name: "envLock.ts", ext: "ts", crumbs: ["vscode", "src", "files", "envLock.ts"] },
|
||||
issueLock: { name: "issueLock.ts", ext: "ts", crumbs: ["vscode", "src", "panel", "issueLock.ts"] },
|
||||
envLock: { name: "envLock.ts", ext: "ts", lang: "typescript", workspace: "superwork", crumbs: ["vscode", "src", "files", "envLock.ts"] },
|
||||
issueLock: { name: "issueLock.ts", ext: "ts", lang: "typescript", workspace: "superwork", crumbs: ["vscode", "src", "panel", "issueLock.ts"] },
|
||||
routes: { name: "routes.py", ext: "py", lang: "python", workspace: "luffy", crumbs: ["backend", "src", "luffy_agent", "user", "routes.py"] },
|
||||
};
|
||||
|
||||
// 代码块的种类:标签文字 + 颜色。同一类语义共用一个颜色(常量 / 变量 / 字段都是数据,尝试 / 捕获都是异常处理),标签区分具体是哪种。
|
||||
@@ -199,6 +227,50 @@ const CODE_TREE = {
|
||||
{ at: [20, 24], kind: "return" },
|
||||
] },
|
||||
],
|
||||
routes: [
|
||||
{ at: [1, 4], kind: "comment" },
|
||||
{ at: 6, kind: "import" },
|
||||
{ at: 8, kind: "import" },
|
||||
{ at: 10, kind: "import" },
|
||||
{ at: 11, kind: "import" },
|
||||
{ at: 13, kind: "import" },
|
||||
{ at: 14, kind: "import" },
|
||||
{ at: 15, kind: "import" },
|
||||
{ at: 16, kind: "import" },
|
||||
{ at: 18, kind: "import" },
|
||||
{ at: [19, 23], kind: "import" },
|
||||
{ at: 24, kind: "import" },
|
||||
{ at: 26, kind: "variable" },
|
||||
{ at: [29, 34], kind: "function", kids: [
|
||||
{ at: [35, 38], kind: "comment" },
|
||||
{ at: 39, kind: "comment" },
|
||||
{ at: 40, kind: "if", kids: [
|
||||
{ at: 41, kind: "statement" },
|
||||
] },
|
||||
{ at: 43, kind: "variable", edit: true },
|
||||
{ at: [45, 57], kind: "return" },
|
||||
] },
|
||||
{ at: [60, 66], kind: "function", kids: [
|
||||
{ at: [67, 70], kind: "comment" },
|
||||
{ at: 71, kind: "comment" },
|
||||
{ at: 72, kind: "if", kids: [
|
||||
{ at: 73, kind: "statement" },
|
||||
] },
|
||||
{ at: 75, kind: "if", kids: [
|
||||
{ at: 76, kind: "if", kids: [{ at: 77, kind: "statement" }] },
|
||||
{ at: 78, kind: "if", kids: [{ at: 79, kind: "statement" }] },
|
||||
{ at: 80, kind: "if", kids: [{ at: 81, kind: "statement" }] },
|
||||
] },
|
||||
{ at: [83, 94], kind: "variable" },
|
||||
{ at: [96, 108], kind: "return" },
|
||||
] },
|
||||
{ at: [111, 116], kind: "function", kids: [
|
||||
{ at: 117, kind: "comment" },
|
||||
{ at: 118, kind: "variable" },
|
||||
{ at: [119, 125], kind: "variable" },
|
||||
{ at: 126, kind: "return" },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const MODES = ["edit", "preview"];
|
||||
|
||||
@@ -235,11 +307,11 @@ const WORD_ZH = {
|
||||
true: ["真", "tokenStorage"], false: ["假", "tokenStorage"], null: ["空", "tokenStorage"], undefined: ["未定义", "tokenStorage"],
|
||||
string: ["字符串", "tokenType"], number: ["数值", "tokenType"], boolean: ["布尔", "tokenType"], void: ["无", "tokenType"],
|
||||
};
|
||||
const OPERATOR_ZH = { "===": "全等", "!==": "不全等", "&&": "与", "||": "或", "!": "非" };
|
||||
const OPERATOR_ZH = { "===": "全等", "!==": "不全等", "&&": "并且", "||": "或者", "!": "非" };
|
||||
|
||||
function stripComment(code) {
|
||||
return code.split("\n")
|
||||
.map((line) => line.replace(/^\s*(\/\*\*?|\*\/|\*|\/\/)\s?/, "").replace(/\s*\*\/\s*$/, ""))
|
||||
.map((line) => line.replace(/^\s*("""|'''|\/\*\*?|\*\/|\*|\/\/|#)\s?/, "").replace(/\s*("""|'''|\*\/)\s*$/, ""))
|
||||
.filter((line) => line.trim())
|
||||
.join(" ")
|
||||
.trim();
|
||||
@@ -249,7 +321,16 @@ function stripComment(code) {
|
||||
function translateBlock(fileKey, node) {
|
||||
const code = blockCode(fileKey, node.at);
|
||||
if (node.kind === "comment") return { text: stripComment(code), ranges: [] };
|
||||
const flat = code.replace(/\s*\n\s*/g, " ").replace(/,\s*}/g, " }").replace(/\s*\{$/, "");
|
||||
return translateCode(code, FILES[fileKey].lang);
|
||||
}
|
||||
|
||||
/** 一段源码的译文和每个词的颜色。 */
|
||||
function translateCode(code, lang) {
|
||||
return lang === "python" ? translatePython(code) : translateTypeScript(code);
|
||||
}
|
||||
|
||||
function translateTypeScript(code) {
|
||||
const flat = flatten(code);
|
||||
const token = /('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")|(\b0[oxb][0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)|(===|!==|&&|\|\||!(?!=))|(\[\])/gi;
|
||||
const ranges = [];
|
||||
let text = "";
|
||||
@@ -279,15 +360,253 @@ function translateBlock(fileKey, node) {
|
||||
return { text: text + flat.slice(last), ranges };
|
||||
}
|
||||
|
||||
const CODE_CONTROL = new Set(["import", "from", "export", "if", "else", "for", "of", "in", "return", "try", "catch", "await", "throw", "while", "as"]);
|
||||
const CODE_STORAGE = new Set(["const", "let", "var", "function", "async", "interface", "type", "class", "new", "instanceof", "typeof", "true", "false", "null", "undefined"]);
|
||||
/** 多行合成一行:括号里换行不留空格、右括号前的尾逗号去掉;块头结尾包住子块的 { 或 : 去掉。 */
|
||||
function flatten(code) {
|
||||
return code
|
||||
.replace(/([(\[])\s*\n\s*/g, "$1")
|
||||
.replace(/,?\s*\n\s*([)\]])/g, "$1")
|
||||
.replace(/\s*\n\s*/g, " ")
|
||||
.replace(/,\s*}/g, " }")
|
||||
.replace(/\s*[{:]$/, "");
|
||||
}
|
||||
|
||||
// 模板直译(原型版,Python):规则同 TypeScript;is not → 不是、not in → 不在 整体译。
|
||||
const PY_WORD_ZH = {
|
||||
from: ["从", "tokenControl"], import: ["导入", "tokenControl"], as: ["作为", "tokenControl"], return: ["返回", "tokenControl"],
|
||||
if: ["如果", "tokenControl"], elif: ["否则如果", "tokenControl"], else: ["否则", "tokenControl"], for: ["遍历", "tokenControl"],
|
||||
while: ["当", "tokenControl"], try: ["尝试", "tokenControl"], except: ["捕获", "tokenControl"], finally: ["最终", "tokenControl"],
|
||||
raise: ["抛出", "tokenControl"], with: ["使用", "tokenControl"], yield: ["产出", "tokenControl"], await: ["等待", "tokenControl"],
|
||||
pass: ["跳过", "tokenControl"], def: ["函数", "tokenStorage"], async: ["异步", "tokenStorage"], class: ["类", "tokenStorage"],
|
||||
lambda: ["匿名函数", "tokenStorage"], in: ["在", "tokenStorage"], not: ["非", "tokenStorage"], and: ["并且", "tokenStorage"],
|
||||
or: ["或者", "tokenStorage"], is: ["是", "tokenStorage"], None: ["空", "tokenStorage"], True: ["真", "tokenStorage"], False: ["假", "tokenStorage"],
|
||||
};
|
||||
const PY_OPERATOR_ZH = { "==": "等于", "!=": "不等于" };
|
||||
|
||||
function translatePython(code) {
|
||||
const flat = flatten(code);
|
||||
const token = /([rbfu]*"""[\s\S]*?"""|[rbfu]*'''[\s\S]*?'''|[rbfu]*"(?:[^"\\]|\\.)*"|[rbfu]*'(?:[^'\\]|\\.)*')|(\b\d+(?:\.\d+)?\b)|([A-Za-z_]\w*)|(==|!=)/gi;
|
||||
const ranges = [];
|
||||
let text = "";
|
||||
let last = 0;
|
||||
const push = (value, color) => {
|
||||
ranges.push({ start: text.length, end: text.length + value.length, color });
|
||||
text += value;
|
||||
};
|
||||
let match;
|
||||
while ((match = token.exec(flat))) {
|
||||
const [whole, string, number, word, operator] = match;
|
||||
text += flat.slice(last, match.index);
|
||||
last = match.index + whole.length;
|
||||
const property = flat[match.index - 1] === ".";
|
||||
if (string) push(string, COLORS.tokenString);
|
||||
else if (number) push(number, COLORS.tokenLiteral);
|
||||
else if (operator) push(PY_OPERATOR_ZH[operator], COLORS.tokenStorage);
|
||||
else if (!property && word === "is" && /^\s+not\b/.test(flat.slice(last))) {
|
||||
push("不是", COLORS.tokenStorage);
|
||||
last += flat.slice(last).match(/^\s+not/)[0].length;
|
||||
token.lastIndex = last;
|
||||
}
|
||||
else if (!property && word === "not" && /^\s+in\b/.test(flat.slice(last))) {
|
||||
push("不在", COLORS.tokenStorage);
|
||||
last += flat.slice(last).match(/^\s+in/)[0].length;
|
||||
token.lastIndex = last;
|
||||
}
|
||||
else if (!property && PY_WORD_ZH[word]) push(PY_WORD_ZH[word][0], COLORS[PY_WORD_ZH[word][1]]);
|
||||
else if (/^[A-Z]/.test(word)) push(word, COLORS.tokenType);
|
||||
else if (/^\s*\(/.test(flat.slice(last, last + 4))) push(word, COLORS.tokenFunction);
|
||||
else push(word, COLORS.tokenVariable);
|
||||
}
|
||||
return { text: text + flat.slice(last), ranges };
|
||||
}
|
||||
|
||||
// ─── 函数签名 → 属性表 ───────────────────────────────────────────────────────
|
||||
|
||||
const BUILTIN_TYPES_ZH = {
|
||||
typescript: { string: "字符串", number: "数值", boolean: "布尔", void: "无", any: "任意", unknown: "未知" },
|
||||
python: { str: "字符串", int: "整数", float: "浮点数", bool: "布尔", list: "列表", dict: "字典", set: "集合", tuple: "元组", bytes: "字节串", None: "空" },
|
||||
};
|
||||
|
||||
/** 跳过字符串,找与 open 位置的括号配对的右括号。 */
|
||||
function matchingParen(text, open) {
|
||||
let depth = 0;
|
||||
for (let index = open; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char === "\"" || char === "'") {
|
||||
const end = text.indexOf(char, index + 1);
|
||||
index = end < 0 ? text.length : end;
|
||||
}
|
||||
else if ("([{".includes(char)) depth += 1;
|
||||
else if (")]}".includes(char)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
throw new Error(`括号不配对: ${text}`);
|
||||
}
|
||||
|
||||
/** 在最外层按分隔符切开(括号、尖括号、字符串里的不算)。返回每段在原文里的起点。 */
|
||||
function topLevelIndexes(text, separator) {
|
||||
const found = [];
|
||||
let depth = 0;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const char = text[index];
|
||||
if (char === "\"" || char === "'") {
|
||||
const end = text.indexOf(char, index + 1);
|
||||
index = end < 0 ? text.length : end;
|
||||
}
|
||||
else if ("([{".includes(char) || (char === "<" && text[index - 1] !== "=")) depth += 1;
|
||||
else if (")]}".includes(char) || (char === ">" && text[index - 1] !== "=")) depth -= 1;
|
||||
else if (depth === 0 && char === separator && text[index + 1] !== "=" && text[index + 1] !== ">" && text[index - 1] !== "=" && text[index - 1] !== "!") found.push(index);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function parseParam(raw) {
|
||||
const param = raw.trim().replace(/\s+/g, " ");
|
||||
if (!param) return null;
|
||||
const eq = topLevelIndexes(param, "=")[0] ?? -1;
|
||||
const head = eq >= 0 ? param.slice(0, eq) : param;
|
||||
const colon = topLevelIndexes(head, ":")[0] ?? -1;
|
||||
return {
|
||||
name: (colon >= 0 ? head.slice(0, colon) : head).trim(),
|
||||
type: colon >= 0 ? head.slice(colon + 1).trim() : "",
|
||||
value: eq >= 0 ? param.slice(eq + 1).trim() : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从函数块的头部(装饰器 + 签名)拆出名称、访问级别、是否异步、装饰器、参数和返回类型。
|
||||
* 访问级别:TypeScript 看有没有 export;Python 看名字是不是单下划线开头。
|
||||
*/
|
||||
function parseSignature(fileKey, item) {
|
||||
const lang = FILES[fileKey].lang;
|
||||
const lines = blockCode(fileKey, item.at).split("\n");
|
||||
const decorators = [];
|
||||
while (lines.length && lines[0].trim().startsWith("@")) decorators.push(lines.shift().trim());
|
||||
const head = lines.join("\n");
|
||||
const match = lang === "python"
|
||||
? /^(async\s+)?def\s+(\w+)\s*\(/.exec(head)
|
||||
: /^(export\s+)?(default\s+)?(async\s+)?function\s*\*?\s*(\w+)\s*\(/.exec(head);
|
||||
if (!match) throw new Error(`不是函数声明: ${head}`);
|
||||
const name = lang === "python" ? match[2] : match[4];
|
||||
const exported = lang === "python" ? !/^_(?!_)/.test(name) : Boolean(match[1]);
|
||||
const open = match.index + match[0].length - 1;
|
||||
const close = matchingParen(head, open);
|
||||
const inside = head.slice(open + 1, close);
|
||||
const cuts = [-1, ...topLevelIndexes(inside, ","), inside.length];
|
||||
const params = cuts.slice(1).map((end, index) => parseParam(inside.slice(cuts[index] + 1, end))).filter(Boolean);
|
||||
const rest = head.slice(close + 1);
|
||||
const returns = lang === "python" ? /->\s*([\s\S]+?)\s*:\s*$/.exec(rest) : /^\s*:\s*([\s\S]+?)\s*\{?\s*$/.exec(rest);
|
||||
return {
|
||||
name,
|
||||
access: exported ? "公共" : "私有",
|
||||
async: Boolean(lang === "python" ? match[1] : match[3]),
|
||||
decorators,
|
||||
params,
|
||||
returns: returns ? returns[1].replace(/\s+/g, " ") : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入语句拆成来源、是否仅类型、导入项(名称 + 别名)。
|
||||
* 默认导入记作「默认导出」,import * as x 记作「全部」,Python 的 import x 记作「整个模块」。
|
||||
*/
|
||||
function parseImport(fileKey, item) {
|
||||
const code = flatten(blockCode(fileKey, item.at));
|
||||
const names = (list) => list.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
|
||||
const [name, alias] = part.split(/\s+as\s+/);
|
||||
return { name: name === "*" ? "全部" : name, alias: alias || "" };
|
||||
});
|
||||
if (FILES[fileKey].lang === "python") {
|
||||
const from = /^from\s+(\S+)\s+import\s+\(?([^)]*)\)?$/.exec(code);
|
||||
if (from) return { source: from[1], typeOnly: null, items: names(from[2]) };
|
||||
const whole = /^import\s+(\S+)(?:\s+as\s+(\w+))?$/.exec(code);
|
||||
if (!whole) throw new Error(`不是导入语句: ${code}`);
|
||||
return { source: whole[1], typeOnly: null, items: [{ name: "整个模块", alias: whole[2] || "" }] };
|
||||
}
|
||||
const match = /^import\s+(type\s+)?(?:(.+?)\s+from\s+)?('[^']*'|"[^"]*")$/.exec(code);
|
||||
if (!match) throw new Error(`不是导入语句: ${code}`);
|
||||
const items = [];
|
||||
const clauses = match[2] || "";
|
||||
const cuts = [-1, ...topLevelIndexes(clauses, ","), clauses.length];
|
||||
for (const clause of cuts.slice(1).map((end, index) => clauses.slice(cuts[index] + 1, end).trim()).filter(Boolean)) {
|
||||
if (clause.startsWith("{")) items.push(...names(clause.slice(1, -1)));
|
||||
else if (clause.startsWith("*")) items.push(...names(clause));
|
||||
else items.push({ name: "默认导出", alias: clause });
|
||||
}
|
||||
return { source: match[3], typeOnly: Boolean(match[1]), items };
|
||||
}
|
||||
|
||||
/**
|
||||
* 变量声明拆成名称、类型标注、值。访问级别只对最外层的声明有意义:
|
||||
* TypeScript 看有没有 export,Python 看名字是不是单下划线开头;块里的局部变量没有访问级别。
|
||||
*/
|
||||
function parseVariable(fileKey, item, topLevel) {
|
||||
const lang = FILES[fileKey].lang;
|
||||
const code = flatten(blockCode(fileKey, item.at));
|
||||
const match = lang === "python" ? /^()()([\s\S]*)$/.exec(code) : /^(export\s+)?(const|let|var)\s+([\s\S]*)$/.exec(code);
|
||||
if (!match) throw new Error(`不是变量声明: ${code}`);
|
||||
const { name, type, value } = parseParam(match[3]);
|
||||
const exported = lang === "python" ? !/^_(?!_)/.test(name) : Boolean(match[1]);
|
||||
return { name, type, value, access: topLevel ? (exported ? "公共" : "私有") : "" };
|
||||
}
|
||||
|
||||
/** 译文(文字 + 着色范围)转成着色片段。 */
|
||||
function segmentsOf(translated) {
|
||||
const segments = [];
|
||||
let last = 0;
|
||||
for (const range of translated.ranges) {
|
||||
if (range.start > last) segments.push({ text: translated.text.slice(last, range.start) });
|
||||
segments.push({ text: translated.text.slice(range.start, range.end), color: range.color });
|
||||
last = range.end;
|
||||
}
|
||||
if (last < translated.text.length) segments.push({ text: translated.text.slice(last) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** 类型的着色片段:内置类型译成中文,名字按类型色,调用按函数色,字符串按字符串色。 */
|
||||
function typeSegments(type, lang) {
|
||||
const token = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|([A-Za-z_$][\w$]*)/g;
|
||||
const segments = [];
|
||||
let last = 0;
|
||||
let match;
|
||||
while ((match = token.exec(type))) {
|
||||
if (match.index > last) segments.push({ text: type.slice(last, match.index) });
|
||||
last = match.index + match[0].length;
|
||||
const [whole, string, word] = match;
|
||||
const builtin = type[match.index - 1] !== "." && BUILTIN_TYPES_ZH[lang][word];
|
||||
if (string) segments.push({ text: string, color: COLORS.tokenString });
|
||||
else if (builtin) segments.push({ text: builtin, color: COLORS.tokenType });
|
||||
else if (/^[A-Z]/.test(word)) segments.push({ text: whole, color: COLORS.tokenType });
|
||||
else if (/^\s*\(/.test(type.slice(last, last + 3))) segments.push({ text: whole, color: COLORS.tokenFunction });
|
||||
else segments.push({ text: whole, color: COLORS.tokenVariable });
|
||||
}
|
||||
if (last < type.length) segments.push({ text: type.slice(last) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** 源码的着色片段(默认值、装饰器)。 */
|
||||
function codeSegments(code) {
|
||||
const segments = [];
|
||||
let last = 0;
|
||||
for (const range of highlightCode(code)) {
|
||||
if (range.start > last) segments.push({ text: code.slice(last, range.start) });
|
||||
segments.push({ text: code.slice(range.start, range.end), color: range.color });
|
||||
last = range.end;
|
||||
}
|
||||
if (last < code.length) segments.push({ text: code.slice(last) });
|
||||
return segments;
|
||||
}
|
||||
|
||||
const CODE_CONTROL = new Set(["import", "from", "export", "if", "elif", "else", "for", "of", "in", "return", "try", "catch", "except", "finally", "raise", "with", "await", "throw", "while", "as", "yield", "pass"]);
|
||||
const CODE_STORAGE = new Set(["const", "let", "var", "function", "def", "async", "interface", "type", "class", "new", "instanceof", "typeof", "lambda", "not", "and", "or", "is", "true", "false", "null", "undefined", "None", "True", "False"]);
|
||||
|
||||
/**
|
||||
* 原型用的简易上色,只为画出效果:注释、字符串、数字、关键字、大写开头当类型、后面跟括号当函数、其余当变量。
|
||||
* 真实插件里源码的颜色直接来自语法树。
|
||||
*/
|
||||
function highlightCode(code) {
|
||||
const token = /(\/\*[\s\S]*?\*\/|\/\/[^\n]*)|('(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`)|(\b0[oxb][0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)/gi;
|
||||
const token = /(\/\*[\s\S]*?\*\/|\/\/[^\n]*|#[^\n]*)|("""[\s\S]*?"""|'''[\s\S]*?'''|'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|`(?:[^`\\]|\\.)*`)|(\b0[oxb][0-9a-f]+\b|\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)/gi;
|
||||
const ranges = [];
|
||||
let match;
|
||||
while ((match = token.exec(code))) {
|
||||
@@ -392,6 +711,7 @@ function topBorder(node, color) {
|
||||
|
||||
/**
|
||||
* Text. Default: fills the parent width and grows in height. `hug`: both axes hug (labels, values).
|
||||
* `width`: fixed width, grows in height (table columns).
|
||||
* 不读父节点宽度:每读一次都会逼 Figma 把整棵自动布局树重排一遍。
|
||||
*/
|
||||
function text(ctx, parent, name, characters, o = {}) {
|
||||
@@ -408,6 +728,11 @@ function text(ctx, parent, name, characters, o = {}) {
|
||||
node.textAutoResize = "WIDTH_AND_HEIGHT";
|
||||
return node;
|
||||
}
|
||||
if (o.width) {
|
||||
node.resize(o.width, node.lineHeight.value);
|
||||
node.textAutoResize = "HEIGHT";
|
||||
return node;
|
||||
}
|
||||
node.textAutoResize = "HEIGHT";
|
||||
node.layoutSizingHorizontal = "FILL";
|
||||
if (o.singleLine) {
|
||||
@@ -538,12 +863,21 @@ function setText(root, name, characters) {
|
||||
|
||||
// ─── Components: 代码块 ──────────────────────────────────────────────────────
|
||||
|
||||
/** 哪些种类的块画成属性表,以及各自的行。 */
|
||||
const GRID_KINDS = {
|
||||
function: { set: "Function", rows: functionRows },
|
||||
import: { set: "Import", rows: importGridRows },
|
||||
const: { set: "Const", rows: variableRows },
|
||||
variable: { set: "Variable", rows: variableRows },
|
||||
};
|
||||
|
||||
/**
|
||||
* Reader / CodeBlock — Kind=<种类>, State=preview | edit。
|
||||
* Reader / CodeBlock — Kind=<种类>, State=preview | edit:画成属性表以外的块。
|
||||
* 左边折叠箭头(没有子块的块在实例里把箭头藏起来)、种类标签,中间是译文 / 源码,右边是切换按钮。
|
||||
* 两种状态都带 Plain(译文)和 Code(源码)两个文字层,只切换显示哪个:实例里改过的两段文字切状态时都保留。
|
||||
*/
|
||||
const BLOCK_VARIANTS = Object.keys(BLOCK_KINDS).flatMap((kind) => ["preview", "edit"].map((state) => [kind, state]));
|
||||
const PLAIN_KINDS = Object.keys(BLOCK_KINDS).filter((kind) => !GRID_KINDS[kind]);
|
||||
const BLOCK_VARIANTS = PLAIN_KINDS.flatMap((kind) => ["preview", "edit"].map((state) => [kind, state]));
|
||||
function codeBlock(ctx, kind, state) {
|
||||
const spec = BLOCK_KINDS[kind];
|
||||
const comment = kind === "comment";
|
||||
@@ -586,8 +920,18 @@ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed) {
|
||||
line.layoutSizingVertical = "FILL";
|
||||
const list = append(level, frame(ctx, "Blocks", { width: 10, gap: 8 }), true);
|
||||
for (const item of nodes) {
|
||||
const instance = lib.block.get(`${item.kind}/${item.edit ? "edit" : "preview"}`).node.createInstance();
|
||||
instance.name = `Block / L${lineRange(item.at)[0]}`;
|
||||
const state = item.edit ? "edit" : "preview";
|
||||
const line = lineRange(item.at)[0];
|
||||
if (lib.card.has(`${fileKey}:${line}`)) {
|
||||
const card = lib.card.get(`${fileKey}:${line}`).get(state).node.createInstance();
|
||||
card.name = `Block / L${line}`;
|
||||
append(list, card, true);
|
||||
placed.push({ item, instance: card, depth });
|
||||
if (item.kids) blockLevel(ctx, list, lib, fileKey, item.kids, depth + 1, placed);
|
||||
continue;
|
||||
}
|
||||
const instance = lib.block.get(`${item.kind}/${state}`).node.createInstance();
|
||||
instance.name = `Block / L${line}`;
|
||||
append(list, instance, true);
|
||||
const plain = named(instance, "Plain");
|
||||
const translated = translateBlock(fileKey, item);
|
||||
@@ -605,6 +949,136 @@ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed) {
|
||||
return level;
|
||||
}
|
||||
|
||||
/** 一段着色文字:片段拼成一个文本节点,按片段上色,中文部分换中文字体。 */
|
||||
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 });
|
||||
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));
|
||||
start = end;
|
||||
}
|
||||
useCjkFont(ctx, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
const KEY_W = 76;
|
||||
|
||||
/** 属性表的一行:左边属性名,右边着色的值;twisty 表示下面跟着一组子行。 */
|
||||
function propRow(ctx, parent, label, segments, o = {}) {
|
||||
const row = filled(ctx, parent, `Prop / ${label}`, { dir: "HORIZONTAL", gap: 12 });
|
||||
const labelStyle = { size: 12.5, lineHeight: 20, color: COLORS.desc };
|
||||
if (o.twisty) {
|
||||
const key = append(row, frame(ctx, "Key", { dir: "HORIZONTAL", width: KEY_W, height: 20, gap: 4, align: "CENTER" }));
|
||||
append(key, icon(ctx, "chevron-down", COLORS.desc, 12));
|
||||
text(ctx, key, "Label", label, { ...labelStyle, hug: true });
|
||||
}
|
||||
else text(ctx, row, "Label", label, { ...labelStyle, width: KEY_W });
|
||||
richText(ctx, row, "Value", segments);
|
||||
return row;
|
||||
}
|
||||
|
||||
|
||||
/** 输入参数、导入项这类子行的外框:左边一条竖线,右边一列子行。 */
|
||||
function subRows(ctx, parent) {
|
||||
const group = filled(ctx, parent, "Sub rows", { dir: "HORIZONTAL", gap: 10 });
|
||||
group.paddingLeft = 5;
|
||||
const guide = append(group, frame(ctx, "Sub guide", { width: 1, fill: COLORS.guide }));
|
||||
guide.layoutSizingVertical = "FILL";
|
||||
return append(group, frame(ctx, "List", { width: 10, gap: 2 }), true);
|
||||
}
|
||||
|
||||
/** 输入参数的子行:每个参数一行,名称 / 类型 / 默认值三列对齐。 */
|
||||
function paramRows(ctx, parent, params, lang) {
|
||||
const list = subRows(ctx, parent);
|
||||
for (const param of params) {
|
||||
const row = filled(ctx, list, `Param / ${param.name}`, { dir: "HORIZONTAL", gap: 12 });
|
||||
richText(ctx, row, "Name", [{ text: param.name, color: COLORS.tokenVariable }], { width: 150 });
|
||||
richText(ctx, row, "Type", param.type ? typeSegments(param.type, lang) : [{ text: "未标注", color: COLORS.desc }], { width: 220 });
|
||||
richText(ctx, row, "Default", param.value ? [{ text: "默认 ", color: COLORS.desc }, ...segmentsOf(translateCode(param.value, lang))] : []);
|
||||
}
|
||||
}
|
||||
|
||||
/** 导入项的子行:每项一行,有别名时写成「名称 作为 别名」。 */
|
||||
function importRows(ctx, parent, items) {
|
||||
const list = subRows(ctx, parent);
|
||||
for (const item of items) {
|
||||
const placeholder = /^[\u3000-\u9fff]/.test(item.name);
|
||||
richText(ctx, list, `Item / ${item.alias || item.name}`, [
|
||||
{ text: item.name, color: placeholder ? COLORS.desc : /^[A-Z]/.test(item.name) ? COLORS.tokenType : COLORS.tokenVariable },
|
||||
...(item.alias ? [{ text: " 作为 ", color: COLORS.tokenControl }, { text: item.alias, color: COLORS.tokenVariable }] : []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const countSegments = (items) => [{ text: items.length ? `${items.length} 个` : "无", color: COLORS.desc }];
|
||||
const yesNo = (value) => [{ text: value ? "是" : "否", color: COLORS.tokenStorage }];
|
||||
|
||||
/** 函数:名称 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行)/ 输出参数。 */
|
||||
function functionRows(ctx, content, fileKey, item) {
|
||||
const lang = FILES[fileKey].lang;
|
||||
const signature = parseSignature(fileKey, item);
|
||||
propRow(ctx, content, "名称", [{ text: signature.name, color: COLORS.tokenFunction }]);
|
||||
propRow(ctx, content, "访问级别", [{ text: signature.access, color: COLORS.tokenControl }]);
|
||||
propRow(ctx, content, "异步", yesNo(signature.async));
|
||||
if (signature.decorators.length) propRow(ctx, content, "装饰器", codeSegments(signature.decorators.join("\n")));
|
||||
propRow(ctx, content, "输入参数", countSegments(signature.params), { twisty: signature.params.length > 0 });
|
||||
if (signature.params.length) paramRows(ctx, content, signature.params, lang);
|
||||
propRow(ctx, content, "输出参数", signature.returns ? typeSegments(signature.returns, lang) : [{ text: "未标注", color: COLORS.desc }]);
|
||||
return signature.name;
|
||||
}
|
||||
|
||||
/** 导入:来源 / 仅类型(TypeScript)/ 导入项(每项一行)。 */
|
||||
function importGridRows(ctx, content, fileKey, item) {
|
||||
const spec = parseImport(fileKey, item);
|
||||
propRow(ctx, content, "来源", [{ text: spec.source, color: spec.typeOnly === null ? COLORS.tokenType : COLORS.tokenString }]);
|
||||
if (spec.typeOnly !== null) propRow(ctx, content, "仅类型", yesNo(spec.typeOnly));
|
||||
propRow(ctx, content, "导入项", countSegments(spec.items), { twisty: spec.items.length > 0 });
|
||||
if (spec.items.length) importRows(ctx, content, spec.items);
|
||||
return spec.source;
|
||||
}
|
||||
|
||||
/** 常量 / 变量:名称 / 访问级别(最外层才有)/ 类型(有标注才有)/ 值。 */
|
||||
function variableRows(ctx, content, fileKey, item, topLevel) {
|
||||
const lang = FILES[fileKey].lang;
|
||||
const spec = parseVariable(fileKey, item, topLevel);
|
||||
const constant = /^[A-Z][A-Z0-9_]*$/.test(spec.name);
|
||||
propRow(ctx, content, "名称", [{ text: spec.name, color: constant ? COLORS.tokenConstant : COLORS.tokenVariable }]);
|
||||
if (spec.access) propRow(ctx, content, "访问级别", [{ text: spec.access, color: COLORS.tokenControl }]);
|
||||
if (spec.type) propRow(ctx, content, "类型", typeSegments(spec.type, lang));
|
||||
propRow(ctx, content, item.kind === "const" ? "值" : "初始值", segmentsOf(translateCode(spec.value, lang)));
|
||||
return spec.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* <种类> / <名字> — State=preview | edit。函数、导入、常量、变量不显示一行译文,而是树形属性表,
|
||||
* 各自的行见 GRID_KINDS;编辑状态显示这一块的源码。行数因块而异,所以每块一个组件集。
|
||||
*/
|
||||
function gridCard(ctx, fileKey, item, state, topLevel) {
|
||||
const spec = BLOCK_KINDS[item.kind];
|
||||
const editing = state === "edit";
|
||||
const node = frame(ctx, `State=${state}`, { component: true, dir: "HORIZONTAL", width: TREE_W, py: 8, gap: 10, radius: 8,
|
||||
fill: editing ? COLORS.side : spec.color, fillOpacity: editing ? 1 : 0.07,
|
||||
stroke: editing ? COLORS.focus : spec.color, strokeOpacity: editing ? 1 : 0.35 });
|
||||
node.paddingLeft = 8;
|
||||
node.paddingRight = 10;
|
||||
node.setSharedPluginData(NAMESPACE, "block", `${fileKey}:${lineRange(item.at)[0]}`);
|
||||
const fold = append(node, frame(ctx, "Fold", { dir: "HORIZONTAL", width: 14, height: 20, align: "CENTER", justify: "CENTER" }));
|
||||
if (item.kids) 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 });
|
||||
const content = append(node, frame(ctx, "Content", { width: 10, gap: 3 }), true);
|
||||
if (editing) richText(ctx, content, "Code", codeSegments(blockCode(fileKey, item.at)), { color: COLORS.code });
|
||||
const title = editing ? "" : GRID_KINDS[item.kind].rows(ctx, content, fileKey, item, topLevel);
|
||||
const toggle = append(node, frame(ctx, "Toggle", { dir: "HORIZONTAL", px: 8, py: 2, gap: 4, radius: 4, align: "CENTER",
|
||||
fill: editing ? COLORS.toggleOn : null }));
|
||||
append(toggle, icon(ctx, editing ? "eye" : "pencil", COLORS.desc, 13));
|
||||
text(ctx, toggle, "Label", editing ? "预览" : "编辑", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc });
|
||||
return { node, toggle, title };
|
||||
}
|
||||
|
||||
/** Reader / BlockTree — File=<文件>:预览模式下编辑区里的整棵代码块树 */
|
||||
function blockTree(ctx, lib, fileKey) {
|
||||
const node = frame(ctx, `File=${fileKey}`, { component: true, width: TREE_W, py: 20, fill: COLORS.editor });
|
||||
@@ -775,6 +1249,20 @@ function createComponents(ctx, page) {
|
||||
lib.editorTitle = make("Editor / TitleBar", fileKeys.flatMap((fileKey) => MODES.map((mode) => `${fileKey}/${mode}`)), 2,
|
||||
(entry) => editorTitle(ctx, lib, ...entry.split("/")));
|
||||
lib.breadcrumb = make("Editor / Breadcrumb", fileKeys, 1, (fileKey) => breadcrumb(ctx, fileKey));
|
||||
lib.card = new Map();
|
||||
const setNames = new Set();
|
||||
for (const [fileKey, nodes] of Object.entries(CODE_TREE)) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
lib.tree = make("Reader / BlockTree", fileKeys, 2, (fileKey) => blockTree(ctx, lib, fileKey), (item) => item.node);
|
||||
return { lib, sets };
|
||||
}
|
||||
@@ -790,6 +1278,7 @@ function rowVariant(item, selected) {
|
||||
|
||||
/** 侧栏:「代码阅读」视图,外观照搬 VS Code 资源管理器。 */
|
||||
function sidebar(ctx, parent, lib, selected) {
|
||||
const workspace = WORKSPACES[FILES[selected].workspace];
|
||||
const pane = append(parent, frame(ctx, "Sidebar", { width: SIDEBAR_W, fill: COLORS.side }));
|
||||
pane.layoutSizingVertical = "FILL";
|
||||
pane.clipsContent = true;
|
||||
@@ -800,9 +1289,9 @@ function sidebar(ctx, parent, lib, selected) {
|
||||
const root = filled(ctx, pane, "Root", { dir: "HORIZONTAL", py: 3, gap: 6, align: "CENTER" });
|
||||
root.paddingLeft = 8;
|
||||
append(root, icon(ctx, "chevron-down", COLORS.fg, 16));
|
||||
text(ctx, root, "Name", "superwork", { hug: true, medium: true, size: 13, lineHeight: 16, color: COLORS.fgStrong });
|
||||
text(ctx, root, "Name", workspace.root, { hug: true, medium: true, size: 13, lineHeight: 16, color: COLORS.fgStrong });
|
||||
const rows = new Map();
|
||||
for (const item of TREE) {
|
||||
for (const item of workspace.rows) {
|
||||
const instance = lib.row.get(rowVariant(item, selected)).createInstance();
|
||||
instance.name = `Row / ${item.name}`;
|
||||
append(pane, instance, true);
|
||||
@@ -856,7 +1345,7 @@ function windowBoard(ctx, page, lib, name, o) {
|
||||
const title = lib.windowTitle.get("active").createInstance();
|
||||
title.name = "Title bar";
|
||||
append(screen, title, true);
|
||||
setText(title, "Title", `${FILES[o.file].name} — superwork`);
|
||||
setText(title, "Title", `${FILES[o.file].name} — ${WORKSPACES[FILES[o.file].workspace].root}`);
|
||||
const main = append(screen, frame(ctx, "Main", { dir: "HORIZONTAL", width, height: MAIN_H }));
|
||||
const activity = lib.activity.get("reader").createInstance();
|
||||
activity.name = "Activity bar";
|
||||
@@ -874,6 +1363,7 @@ function windowBoard(ctx, page, lib, name, o) {
|
||||
append(editor, crumbs, true);
|
||||
const view = o.mode === "edit" ? codeView(ctx, editor, o.file) : previewView(ctx, editor, lib, o.file, editorWidth);
|
||||
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);
|
||||
return { screen, rows: side.rows, titleBar, view };
|
||||
@@ -908,7 +1398,11 @@ async function build(api, stage = () => {}) {
|
||||
|
||||
stage("连接演示入口");
|
||||
// 代码块:「编辑」换成源码,「预览」换回译文
|
||||
for (const kind of Object.keys(BLOCK_KINDS)) {
|
||||
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");
|
||||
}
|
||||
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");
|
||||
@@ -920,7 +1414,7 @@ async function build(api, stage = () => {}) {
|
||||
await connect(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) await connect(view.rows.get(other), target.edit.screen, "NAVIGATE");
|
||||
if (other !== fileKey && view.rows.has(other)) await connect(view.rows.get(other), target.edit.screen, "NAVIGATE");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -962,6 +1456,6 @@ async function run(api) {
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = { build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode };
|
||||
module.exports = { 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);
|
||||
+117
-12
@@ -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 { build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode } = require("./code.js");
|
||||
const { 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]; }
|
||||
@@ -16,21 +16,59 @@ function countBlocks(nodes) {
|
||||
return count;
|
||||
}
|
||||
|
||||
// 代码块:13 种各「编辑 ↔ 预览」2 条 = 26
|
||||
// 窗口:2 个文件 × 编辑 / 预览各 1 个切换 = 4;4 个窗口里侧栏点另一个文件 = 4;合计 34
|
||||
const EXPECTED_LINKS = 34;
|
||||
// 代码块:属性表以外的 9 种各「编辑 ↔ 预览」2 条 = 18
|
||||
// 属性表:8 个函数、14 条导入、2 个常量、12 个变量,共 36 块各 2 条 = 72
|
||||
// 窗口:3 个文件 × 编辑 / 预览各 1 个切换 = 6;superwork 的 4 个窗口里侧栏点另一个示例文件 = 4
|
||||
// (luffy 只有一个示例文件,侧栏没有可跳的);合计 100
|
||||
const EXPECTED_LINKS = 100;
|
||||
|
||||
const EXPECTED_SETS = [
|
||||
["Reader / ModeToggle", 2],
|
||||
["Reader / CodeBlock", 26],
|
||||
["Reader / CodeBlock", 18],
|
||||
["Explorer / TreeRow", 6],
|
||||
["Explorer / Header", 1],
|
||||
["Window / TitleBar", 1],
|
||||
["Window / ActivityBar", 1],
|
||||
["Window / StatusBar", 2],
|
||||
["Editor / TitleBar", 4],
|
||||
["Editor / Breadcrumb", 2],
|
||||
["Reader / BlockTree", 2],
|
||||
["Editor / TitleBar", 6],
|
||||
["Editor / Breadcrumb", 3],
|
||||
["Import / 'node:fs'", 2],
|
||||
["Import / 'vscode'", 2],
|
||||
["Const / ENV_PATTERN", 2],
|
||||
["Const / EXCLUDE_PATTERN", 2],
|
||||
["Function / findEnvFiles", 2],
|
||||
["Variable / folder", 2],
|
||||
["Variable / include", 2],
|
||||
["Variable / uris", 2],
|
||||
["Function / chmodAll", 2],
|
||||
["Variable / files", 2],
|
||||
["Variable / result", 2],
|
||||
["Function / lockEnvFiles", 2],
|
||||
["Function / unlockEnvFiles", 2],
|
||||
["Import / '../gitea/types'", 2],
|
||||
["Function / evaluateLockedReason", 2],
|
||||
["Variable / issue", 2],
|
||||
["Variable / prereq", 2],
|
||||
["Import / __future__", 2],
|
||||
["Import / typing", 2],
|
||||
["Import / fastapi", 2],
|
||||
["Import / sqlalchemy.ext.asyncio", 2],
|
||||
["Import / luffy_agent.core.db", 2],
|
||||
["Import / luffy_agent.core.exceptions", 2],
|
||||
["Import / luffy_agent.core.params", 2],
|
||||
["Import / luffy_agent.user.auth.deps", 2],
|
||||
["Import / .models", 2],
|
||||
["Import / .schemas", 2],
|
||||
["Import / .services", 2],
|
||||
["Variable / router", 2],
|
||||
["Function / get_user", 2],
|
||||
["Variable / user", 2],
|
||||
["Function / update_user", 2],
|
||||
["Variable / user · L83", 2],
|
||||
["Function / upload_current_user_avatar", 2],
|
||||
["Variable / payload", 2],
|
||||
["Variable / avatar_url", 2],
|
||||
["Reader / BlockTree", 3],
|
||||
];
|
||||
|
||||
test("manifest stays local, without a fabricated plugin ID", () => {
|
||||
@@ -43,6 +81,13 @@ test("manifest stays local, without a fabricated plugin ID", () => {
|
||||
assert.equal("id" in manifest, false);
|
||||
});
|
||||
|
||||
test("plugin source passes Figma's load-time scan", () => {
|
||||
// Figma 加载插件前扫描源码文本,含动态 import 或 HTML 注释写法就整个拒绝运行,连 try/catch 都进不去。
|
||||
const source = fs.readFileSync(path.join(__dirname, "code.js"), "utf8");
|
||||
assert.doesNotMatch(source, /(^|[^.]|\.\.\.)\bimport\s*(\(|\/[/*])/);
|
||||
assert.doesNotMatch(source, /<!--|-->/);
|
||||
});
|
||||
|
||||
test("code block trees cover every meaningful source line exactly once", () => {
|
||||
assert.equal(SOURCES.envLock.length, 61);
|
||||
assert.equal(SOURCES.issueLock.length, 25);
|
||||
@@ -92,6 +137,12 @@ test("template translation keeps the source structure and only translates words"
|
||||
assert.equal(at("envLock", 53).text, "Set every `.env*` file in the workspace to 0o444 (read-only).");
|
||||
assert.equal(at("issueLock", 14).text, "常量 issue = issues.find(i => i.number 全等 issueNumber)");
|
||||
assert.equal(at("issueLock", 20).text, "返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }");
|
||||
assert.equal(at("routes", 6).text, "从 __future__ 导入 annotations");
|
||||
assert.equal(at("routes", 19).text, "从 .schemas 导入 (UserAvatarUploadResponse, UserDetailResponse, UserUpdateRequest)");
|
||||
assert.equal(at("routes", 40).text, "如果 current_user.role 不等于 UserRole.ADMIN 并且 current_user.id 不等于 user_id");
|
||||
assert.equal(at("routes", 76).text, "如果 body.role 不是 空");
|
||||
assert.equal(at("routes", 41).text, '抛出 Forbidden("无权查看此用户信息")');
|
||||
assert.equal(at("routes", 35).text, "获取用户详情。 管理员可查看任意用户,普通用户只能查看自己。");
|
||||
|
||||
// 每个词单独上色,关键字不和前后的字粘在一起
|
||||
const signature = at("envLock", 23);
|
||||
@@ -119,8 +170,56 @@ test("template translation keeps the source structure and only translates words"
|
||||
assert.equal(colorOf("uris"), "#9cdcfe");
|
||||
});
|
||||
|
||||
const blockAt = (fileKey, line) => {
|
||||
let found;
|
||||
walkTree(CODE_TREE[fileKey], (node) => { if (lineRange(node.at)[0] === line) found = node; });
|
||||
return found;
|
||||
};
|
||||
|
||||
test("function signatures become property grids", () => {
|
||||
const signature = (fileKey, line) => parseSignature(fileKey, blockAt(fileKey, line));
|
||||
assert.deepEqual(signature("routes", 29), {
|
||||
name: "get_user",
|
||||
access: "公共",
|
||||
async: true,
|
||||
decorators: ['@router.get("/{user_id}", response_model=UserDetailResponse)'],
|
||||
params: [
|
||||
{ name: "user_id", type: "Int4Path", value: "" },
|
||||
{ name: "db", type: "AsyncSession", value: "Depends(get_db)" },
|
||||
{ name: "current_user", type: "User", value: "Depends(get_current_user)" },
|
||||
],
|
||||
returns: "UserDetailResponse",
|
||||
});
|
||||
assert.deepEqual(signature("routes", 111).params[0], { name: "file", type: 'Annotated[UploadFile, File(..., description="头像文件")]', value: "" });
|
||||
assert.deepEqual(signature("envLock", 23), {
|
||||
name: "findEnvFiles", access: "公共", async: true, decorators: [], params: [{ name: "workspaceRoot", type: "string", value: "" }], returns: "Promise<string[]>",
|
||||
});
|
||||
assert.equal(signature("issueLock", 13).async, false);
|
||||
assert.equal(signature("envLock", 38).access, "私有");
|
||||
// 类型里的内置类型译成中文,其余名字按类型色
|
||||
const segments = typeSegments("Promise<string[]>", "typescript");
|
||||
assert.equal(segments.map((segment) => segment.text).join(""), "Promise<字符串[]>");
|
||||
assert.equal(segments.find((segment) => segment.text === "字符串").color, "#4ec9b0");
|
||||
assert.equal(typeSegments("list[str] | None", "python").map((segment) => segment.text).join(""), "列表[字符串] | 空");
|
||||
});
|
||||
|
||||
test("imports and variable declarations become property grids", () => {
|
||||
assert.deepEqual(parseImport("envLock", blockAt("envLock", 12)), { source: "'node:fs'", typeOnly: false, items: [{ name: "promises", alias: "fsp" }] });
|
||||
assert.deepEqual(parseImport("issueLock", blockAt("issueLock", 1)), { source: "'../gitea/types'", typeOnly: true, items: [{ name: "Issue", alias: "" }] });
|
||||
assert.deepEqual(parseImport("routes", blockAt("routes", 19)).items.map((item) => item.name),
|
||||
["UserAvatarUploadResponse", "UserDetailResponse", "UserUpdateRequest"]);
|
||||
assert.equal(parseImport("routes", blockAt("routes", 19)).typeOnly, null);
|
||||
// 最外层的声明才有访问级别
|
||||
assert.deepEqual(parseVariable("envLock", blockAt("envLock", 15), true), { name: "ENV_PATTERN", type: "", value: "'**/.env*'", access: "私有" });
|
||||
assert.deepEqual(parseVariable("envLock", blockAt("envLock", 40), false),
|
||||
{ name: "result", type: "EnvLockResult", value: "{ total: files.length, ok: [], failed: [] }", access: "" });
|
||||
assert.equal(parseVariable("routes", blockAt("routes", 26), true).access, "公共");
|
||||
assert.equal(parseVariable("routes", blockAt("routes", 119), false).value,
|
||||
'await user_service.upload_avatar(db, user_id=current_user.id, file_name=file.filename or "avatar.bin", content_type=file.content_type, payload=payload)');
|
||||
});
|
||||
|
||||
test("sample files appear in the sidebar tree", () => {
|
||||
for (const key of Object.keys(FILES)) assert.equal(TREE.filter((row) => row.file === key).length, 1);
|
||||
for (const [key, file] of Object.entries(FILES)) assert.equal(WORKSPACES[file.workspace].rows.filter((row) => row.file === key).length, 1);
|
||||
assert.deepEqual(Object.keys(CODE_TREE), Object.keys(FILES));
|
||||
});
|
||||
|
||||
@@ -136,7 +235,7 @@ test("build creates the variant sets, reuses them on boards and wires the flows"
|
||||
|
||||
// 代码块:预览显示大白话,编辑显示源码;按钮在两种状态间切换
|
||||
const { lib } = result.components;
|
||||
for (const kind of Object.keys(BLOCK_KINDS)) {
|
||||
for (const kind of PLAIN_KINDS) {
|
||||
const preview = lib.block.get(`${kind}/preview`);
|
||||
const edit = lib.block.get(`${kind}/edit`);
|
||||
assert.equal(named(preview.node, "Plain").visible, true);
|
||||
@@ -157,7 +256,12 @@ test("build creates the variant sets, reuses them on boards and wires the flows"
|
||||
assert.equal(node.findAll((child) => child.name === "Guide").length, 1 + placed.filter((entry) => entry.item.kids).length);
|
||||
assert.ok(maxDepth >= 2, `${fileKey} nests at least three levels`);
|
||||
for (const { item, instance } of placed) {
|
||||
const variant = lib.block.get(`${item.kind}/${item.edit ? "edit" : "preview"}`).node;
|
||||
const state = item.edit ? "edit" : "preview";
|
||||
if (lib.card.has(`${fileKey}:${lineRange(item.at)[0]}`)) {
|
||||
assert.equal(instance.mainComponentId, lib.card.get(`${fileKey}:${lineRange(item.at)[0]}`).get(state).node.id);
|
||||
continue;
|
||||
}
|
||||
const variant = lib.block.get(`${item.kind}/${state}`).node;
|
||||
assert.equal(instance.mainComponentId, variant.id);
|
||||
const parsed = translateBlock(fileKey, item);
|
||||
assert.equal(named(instance, "Plain").characters, parsed.text);
|
||||
@@ -222,7 +326,8 @@ 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);
|
||||
assert.ok(owned < 1500, `page owns ${owned} layers outside instances`);
|
||||
// 卡死那次是 5000 多个图层;现在 3 个示例文件、36 张属性表约 2200 个
|
||||
assert.ok(owned < 2500, `page owns ${owned} layers outside instances`);
|
||||
});
|
||||
|
||||
test("running twice numbers the new page and keeps the old one intact", async () => {
|
||||
|
||||
Reference in new issue
Block a user