diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md index 02e6c59..62e5085 100644 --- a/docs/prototype/code-reader/figma-plugin/README.md +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -7,6 +7,10 @@ - 「预览」把代码拆成一棵代码块树:导入、定义、变量、如果、循环、返回……每一句是一块,嵌套的块往右缩进,左边竖线标出层级。 - 每块默认显示译文,点块右边的「编辑」换成这一块的源码,点「预览」换回来。 - 译文是模板直译:结构照抄原语言,只把关键字、内置类型、运算符换成中文,名字和写法原样保留,每个关键字单独上色、前后有空格。例如 `export async function findEnvFiles(workspaceRoot: string): Promise` 译成 `公共 异步 函数 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[...]`)换行不挤掉默认值列 - [ ] 演示:块的编辑 / 预览、右上角编辑 / 预览、侧栏切文件都能点通 diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js index eb7ebaa..0109f5e 100644 --- a/docs/prototype/code-reader/figma-plugin/code.js +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -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 {"," 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 {"," 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 {"," return chmodAll(workspaceRoot, 0o444)","}","","/** Restore every `.env*` file in the workspace to 0o644 (read-write). */","export async function unlockEnvFiles(workspaceRoot: string): Promise {"," 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); diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js index a2b0b9b..58ea10f 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 { 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", + }); + assert.equal(signature("issueLock", 13).async, false); + assert.equal(signature("envLock", 38).access, "私有"); + // 类型里的内置类型译成中文,其余名字按类型色 + const segments = typeSegments("Promise", "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 () => {