diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md index af1bdb8..2f92bc8 100644 --- a/docs/prototype/code-reader/figma-plugin/README.md +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -27,6 +27,8 @@ | `Prototype / 枚举` | luffy 的 `referral/models.py` 里的 `CommissionStatus` | 枚举卡片:名称 / 访问级别 / 继承,成员收成一张表(成员 / 值 / 说明),说明取行尾注释;Python 用库规则(导入了 enum 才启用),TypeScript 的 enum 由语言插件报结构,画成同一张卡片 | | `Prototype / 库规则 · Celery` | luffy 的 `backend/src/luffy_agent/celery_app.py` | 库规则(导入了 celery 才启用)两张卡片:「Celery 应用」名称 / 应用名 / 消息队列 / 结果存储;「定时任务」应用 + 任务表(任务 / 频率 / 说明),频率换算成「每 5 分钟」「每天」,说明取行尾注释,`**` 展开的另起一行 | | `Prototype / 遍历 · 推导式` | luffy 邀请码工具的 `"".join(secrets.choice(ALPHABET) for _ in range(length))`,外加带条件的列表、字典推导 | 写在表达式里的遍历原地套一个带循环图标和结果类型(生成器 / 列表 / 字典)的小框,框里按读的顺序重排:「遍历 range(length) 的每个 _,仅当 …,取 …」 | +| `Prototype / 侧栏 · 复制菜单` | 同上 | 悬停行行尾第一个按钮「复制」(带小箭头),点开一组复制方式:文件名 / 文件名(不带后缀)/ 相对路径 / 绝对路径,右边淡色预览会复制出什么;文件夹没有「不带后缀」 | +| `Prototype / 问 AI` | routes.py 的 get_user 里的条件、语句、返回 | 块右上角只有图标(问 AI、编辑 / 预览),不带字;问过的问答灰字挂在块下面(问题 + 回答前两行,时间、删除);正在问的那轮正常颜色,回答逐字出来,底下输入框追问 | | `Prototype / 筛选 · 默认` | `envLock.ts` | 预览顶部固定一条筛选栏:「筛选」+ 当前文件出现过的属性各一个下拉框(种类、访问级别、异步、仅类型)+ 右边「共 33 块」 | | `Prototype / 筛选 · 按访问级别` | 同上 | 访问级别下拉菜单打开,只勾「公共」:两个私有常量和私有函数 `chmodAll`(连同函数体)藏起来;条件、返回这些没有访问级别的块照常显示;右边「已隐藏 3 块 · 清除」 | | `Prototype / 筛选 · 按种类` | 同上 | 种类下拉菜单打开,去掉「注释」「导入」:6 块藏起来 | @@ -64,6 +66,8 @@ - `Reader / CeleryAppCard`、`Reader / BeatCard`:Celery 规则的两张卡片。 - `Reader / IterationBlock`:`Sample=generator | list | dict`,三种推导式的返回块。 - `Reader / FilterBar`:`Active=none | access | kind`,预览顶部的筛选栏,打开的下拉菜单是勾选列表,每个值后面是块数。 +- `Reader / BlockTools`:`State=preview | edit`,块右上角的一排图标按钮(问 AI、编辑 / 预览),各种块共用。 +- `Reader / AskPanel`:`State=history | asking`,块下面的问答区。 - `Window / QuickInput`:VS Code 顶部弹出的单行输入框,标题和内容在实例里改。 - `Window / TitleBar`、`Window / ActivityBar`、`Window / StatusBar`、`Editor / TitleBar`、`Editor / Breadcrumb`:VS Code 窗口外壳。 diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js index deda714..a9dc83c 100644 --- a/docs/prototype/code-reader/figma-plugin/code.js +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -816,6 +816,11 @@ const LUCIDE = { "triangle-alert": [["path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }], ["path", { d: "M12 9v4" }], ["path", { d: "M12 17h.01" }]], "circle-x": [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "m15 9-6 6" }], ["path", { d: "m9 9 6 6" }]], "eye": [["path", { d: "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" }], ["circle", { cx: "12", cy: "12", r: "3" }]], + "copy": [["rect", { width: "14", height: "14", x: "8", y: "8", rx: "2", ry: "2" }], ["path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]], + "sticky-note": [["path", { d: "M16 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V8Z" }], ["path", { d: "M15 3v4a2 2 0 0 0 2 2h4" }]], + "sparkles": [["path", { d: "M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z" }]], + "trash-2": [["path", { d: "M3 6h18" }], ["path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }], ["path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }], ["path", { d: "M10 11v6" }], ["path", { d: "M14 11v6" }]], + "send": [["path", { d: "M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z" }], ["path", { d: "m21.854 2.147-10.94 10.939" }]], "message-square-plus": [["path", { d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" }], ["path", { d: "M12 8v6" }], ["path", { d: "M9 11h6" }]], "message-square-text": [["path", { d: "M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" }], ["path", { d: "M7 11h10" }], ["path", { d: "M7 15h6" }], ["path", { d: "M7 7h8" }]], "pencil": [["path", { d: "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }], ["path", { d: "m15 5 4 4" }]], @@ -1033,6 +1038,70 @@ function beatCard(ctx) { return node; } +// ─── 问 AI ─────────────────────────────────────────────────────────────────── +// +// 块右上角「问 AI」→ 块下面展开对话:历史问答灰字在上(问题 + 回答前两行,右边时间和删除), +// 当前这轮正常颜色,回答逐字出来按 Markdown 显示,底下输入框可以追问。问答存进 .spx,下次打开还在,理解了就删。 +// 回答用 Claude Code(claude -p),它能自己去读项目里别的文件,只读不改。示例:routes.py 的 get_user 里那个条件。 +const ASK_SAMPLE = { + block: "if current_user.role != UserRole.ADMIN and current_user.id != user_id:", + history: [ + { question: "为什么普通用户不能看别人的资料?", answer: "这是权限控制:不是管理员、又不是查自己时直接抛 Forbidden(HTTP 403)。管理员不受这条限制,能查任何人。", when: "3 天前" }, + { question: "UserRole 还有哪些角色?", answer: "UserRole 定义在 user/models.py,目前只有 ADMIN 和 USER 两种。", when: "昨天" }, + ], + question: "Forbidden 抛出去以后前端收到什么?", + answer: [["`Forbidden`", "code"], [" 定义在 ", ""], ["`luffy_agent.core.exceptions`", "code"], [",继承 ", ""], ["`AppException`", "code"], + ["。全局异常处理把它转成 HTTP 403,响应体是 ", ""], ['`{"code": "forbidden", "message": "无权查看此用户信息"}`', "code"], ["。前端请求封装里遇到 403 会", ""]], +}; +const AI_COLOR = COLORS.link; + +/** Reader / AskPanel — State=history | asking:块下面的问答区。history 只有灰字历史,asking 多一轮正在回答的问答和输入框。 */ +function askPanel(ctx, state) { + const node = frame(ctx, `State=${state}`, { component: true, width: TREE_W, gap: 8, stroke: AI_COLOR, strokeOpacity: 0.5 }); + node.strokeLeftWeight = 2; + node.strokeTopWeight = node.strokeBottomWeight = node.strokeRightWeight = 0; + node.paddingLeft = 12; + node.paddingTop = node.paddingBottom = 6; + node.paddingRight = 10; + for (const [index, item] of ASK_SAMPLE.history.entries()) { + const entry = filled(ctx, node, `History / ${index + 1}`, { gap: 2 }); + const head = filled(ctx, entry, "Head", { dir: "HORIZONTAL", gap: 6, align: "CENTER" }); + append(head, icon(ctx, "sparkles", COLORS.dim, 12)); + text(ctx, head, "Question", item.question, { size: 12.5, lineHeight: 18, singleLine: true, color: COLORS.desc }); + text(ctx, head, "When", item.when, { hug: true, size: 11, lineHeight: 16, color: COLORS.dim }); + append(head, icon(ctx, "trash-2", COLORS.dim, 13, { name: "Delete" })); + const body = filled(ctx, entry, "Answer box", {}); + body.paddingLeft = 18; + const answer = text(ctx, body, "Answer", item.answer, { size: 12, lineHeight: 18, color: COLORS.dim }); + answer.maxLines = 2; + answer.textTruncation = "ENDING"; + } + if (state === "asking") { + divider(ctx, node, { color: COLORS.border }); + const head = filled(ctx, node, "Current question", { dir: "HORIZONTAL", gap: 6, align: "CENTER" }); + append(head, icon(ctx, "sparkles", AI_COLOR, 12)); + text(ctx, head, "Question", ASK_SAMPLE.question, { size: 12.5, lineHeight: 18, medium: true, color: COLORS.fgStrong }); + const body = filled(ctx, node, "Current answer", {}); + body.paddingLeft = 18; + const answer = text(ctx, body, "Answer", ASK_SAMPLE.answer.map(([part]) => part.replace(/`/g, "")).join("") + " ▍", + { size: 12.5, lineHeight: 20, color: COLORS.fg }); + let start = 0; + for (const [part, style] of ASK_SAMPLE.answer) { + const plain = part.replace(/`/g, ""); + if (style === "code") { + answer.setRangeFontName(start, start + plain.length, ctx.fonts.mono); + answer.setRangeFills(start, start + plain.length, paint(COLORS.tokenFunction)); + } + start += plain.length; + } + useCjkFont(ctx, answer); + const field = filled(ctx, node, "Input", { dir: "HORIZONTAL", px: 8, py: 5, gap: 6, radius: 4, align: "CENTER", fill: COLORS.input, stroke: COLORS.focus }); + text(ctx, field, "Placeholder", "继续问… Enter 发送,Esc 收起", { size: 12.5, lineHeight: 18, color: COLORS.dim }); + append(field, icon(ctx, "send", COLORS.desc, 14)); + } + return node; +} + // ─── 筛选 ──────────────────────────────────────────────────────────────────── // // 每块带几条「属性 = 值」,由语言插件从语法树换算成各语言统一的属性,翻译插件给属性和值起显示名; @@ -1112,6 +1181,10 @@ function filterBar(ctx, active) { if (active !== "none") text(ctx, node, "Clear", "清除", { hug: true, size: 12, lineHeight: 16, color: COLORS.link }); if (preset.facet) { const menu = frame(ctx, "Menu", { width: 200, py: 4, radius: 6, fill: COLORS.widget, stroke: COLORS.widgetBorder }); + const bulk = filled(ctx, menu, "Bulk", { dir: "HORIZONTAL", px: 10, py: 4, gap: 12, align: "CENTER" }); + text(ctx, bulk, "Select all", "全选", { hug: true, size: 12, lineHeight: 16, color: COLORS.link }); + text(ctx, bulk, "Select none", "取消全选", { hug: true, size: 12, lineHeight: 16, color: COLORS.link }); + divider(ctx, menu, { color: COLORS.widgetBorder }); for (const [value, count] of facets.get(preset.facet)) { const checked = preset.allowed[preset.facet].includes(value); const row = filled(ctx, menu, `Option / ${value}`, { dir: "HORIZONTAL", px: 10, py: 4, gap: 8, align: "CENTER" }); @@ -1168,13 +1241,34 @@ function codeBlock(ctx, kind, state) { color: comment ? COLORS.comment : COLORS.code }); plain.visible = !editing; code.visible = editing; - 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 }); + const toggle = blockTools(ctx, node, editing); return { node, toggle }; } +/** 块右上角放一个 Reader / BlockTools 实例,返回里面的切换按钮,演示里连 CHANGE_TO。 */ +function blockTools(ctx, node, editing) { + const tools = ctx.lib.tools.get(editing ? "edit" : "preview").createInstance(); + tools.name = "Tools"; + append(node, tools); + return named(tools, "Toggle"); +} + +/** + * Reader / BlockTools — State=preview | edit:块右上角的一排图标按钮,只放图标、不放字(以后还会加), + * 鼠标停上去有文字提示:「问 AI」、「编辑 / 预览」。 + */ +function blockToolsComponent(ctx, state) { + const node = frame(ctx, `State=${state}`, { component: true, dir: "HORIZONTAL", gap: 2, align: "CENTER" }); + const button = (name, iconName, on) => { + const item = append(node, frame(ctx, name, { dir: "HORIZONTAL", width: 24, height: 22, radius: 4, align: "CENTER", justify: "CENTER", + fill: on ? COLORS.toggleOn : null })); + append(item, icon(ctx, iconName, COLORS.desc, 14)); + }; + button("Ask", "sparkles", false); + button("Toggle", state === "edit" ? "eye" : "pencil", state === "edit"); + return node; +} + /** 一层代码块:左边一条竖线标出这一层,右边是这一层的块,块里嵌套的下一层紧跟在块下面。 */ function blockLevel(ctx, parent, lib, fileKey, nodes, depth, placed, name = `Level ${depth}`) { const level = filled(ctx, parent, name, { dir: "HORIZONTAL", gap: 8 }); @@ -1375,10 +1469,7 @@ function cardShell(ctx, name, spec, o = {}) { 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); - const toggle = append(node, frame(ctx, "Toggle", { dir: "HORIZONTAL", px: 8, py: 2, gap: 4, radius: 4, align: "CENTER", - fill: o.editing ? COLORS.toggleOn : null })); - append(toggle, icon(ctx, o.editing ? "eye" : "pencil", COLORS.desc, 13)); - text(ctx, toggle, "Label", o.editing ? "预览" : "编辑", { hug: true, size: 12, lineHeight: 16, color: COLORS.desc }); + const toggle = blockTools(ctx, node, o.editing); return { node, content, toggle }; } @@ -1486,7 +1577,10 @@ function treeRow(ctx, type, state) { if (modified) text(ctx, node, "Git", "M", { hug: true, size: 12, lineHeight: 16, color: COLORS.gitModified }); if (hovered) { const actions = append(node, frame(ctx, "Actions", { dir: "HORIZONTAL", gap: 2, align: "CENTER" })); - for (const [label, iconName, visible] of [["添加注释", "message-square-plus", true], ["编辑注释", "message-square-text", false]]) { + const copy = append(actions, frame(ctx, "Action / 复制", { dir: "HORIZONTAL", height: 22, px: 3, gap: 1, radius: 5, align: "CENTER" })); + append(copy, icon(ctx, "copy", COLORS.fg, 16)); + append(copy, icon(ctx, "chevron-down", COLORS.fg, 10)); + for (const [label, iconName, visible] of [["添加注释", "sticky-note", true], ["编辑注释", "pencil", false]]) { const button = append(actions, frame(ctx, `Action / ${label}`, { dir: "HORIZONTAL", width: 22, height: 22, radius: 5, align: "CENTER", justify: "CENTER" })); append(button, icon(ctx, iconName, COLORS.fg, 16)); @@ -1616,9 +1710,12 @@ function createComponents(ctx, page) { return map; }; const fileKeys = Object.keys(FILES); + ctx.lib = lib; lib.toggle = make("Reader / ModeToggle", MODES, 2, (mode) => modeToggle(ctx, mode)); + lib.tools = make("Reader / BlockTools", MODES.slice().reverse(), 2, (state) => blockToolsComponent(ctx, state)); lib.block = make("Reader / CodeBlock", BLOCK_VARIANTS.map(([kind, state]) => `${kind}/${state}`), 2, (entry) => codeBlock(ctx, ...entry.split("/")), (item) => item.node); + lib.askPanel = make("Reader / AskPanel", ["history", "asking"], 1, (state) => askPanel(ctx, state)); lib.tableCard = make("Reader / TableCard", ["preview"], 1, () => tableCard(ctx)); lib.enumCard = make("Reader / EnumCard", ["preview"], 1, () => enumCard(ctx)); lib.celeryApp = make("Reader / CeleryAppCard", ["preview"], 1, () => celeryAppCard(ctx)); @@ -1812,6 +1909,7 @@ function windowBoard(ctx, page, lib, name, o) { append(screen, status, true); const board = { screen, rows: side.rows, titleBar, view, filter }; if (o.tooltip) board.tooltip = tooltip(ctx, screen, o.tooltip, WORKSPACES[FILES[o.file].workspace].rows); + if (o.copyMenu) board.copyMenu = copyMenu(ctx, screen, o.copyMenu, WORKSPACES[FILES[o.file].workspace].rows); if (o.quickInput) { const input = lib.quickInput.get("typing").createInstance(); input.name = "Quick input"; @@ -1826,6 +1924,33 @@ function windowBoard(ctx, page, lib, name, o) { return board; } +/** + * 复制按钮点开的菜单:一组复制方式,右边淡色预览会复制出什么。 + * 文件:文件名 / 不带后缀的文件名 / 相对路径 / 绝对路径;文件夹没有「不带后缀」那项。 + */ +function copyMenu(ctx, screen, o, rows) { + const index = rows.findIndex((item) => item.name === o.row); + const item = rows[index]; + const stem = item.type === "file" ? item.name.replace(/\.[^.]+$/, "") : null; + const options = [ + [item.type === "file" ? "复制文件名" : "复制文件夹名", item.name], + ...(stem ? [["复制文件名(不带后缀)", stem]] : []), + ["复制相对路径", o.relative], + ["复制绝对路径", o.absolute], + ]; + const node = frame(ctx, "Copy menu", { width: 420, py: 4, radius: 6, fill: COLORS.widget, stroke: COLORS.widgetBorder }); + for (const [label, value] of options) { + const row = filled(ctx, node, `Copy / ${label}`, { dir: "HORIZONTAL", px: 10, py: 4, gap: 12, align: "CENTER" }); + text(ctx, row, "Label", label, { hug: true, size: 12.5, lineHeight: 18, color: COLORS.fg }); + text(ctx, row, "Value", value, { size: 12, lineHeight: 18, singleLine: true, align: "RIGHT", color: COLORS.dim }); + } + screen.appendChild(node); + node.layoutPositioning = "ABSOLUTE"; + node.x = ACTIVITY_W + SIDEBAR_W - 12 - 22 - 22 - 36; + node.y = TITLE_H + SIDEBAR_HEADER_H + ROW_H * (index + 2) + 2; + return node; +} + /** 悬停提示:贴在某一行下面,靠右对齐(按钮提示)或靠左对齐(这一项的注释全文)。位置按行号算,不读图层尺寸。 */ function tooltip(ctx, screen, o, rows) { const index = rows.findIndex((item) => item.name === o.row); @@ -1868,6 +1993,27 @@ async function build(api, stage = () => {}) { const narrow = windowBoard(ctx, page, lib, "envLock.ts · 预览(编辑区变窄)", { file: "envLock", mode: "preview", width: WINDOW_NARROW }); const filters = Object.fromEntries(Object.entries({ none: "默认", access: "按访问级别", kind: "按种类" }).map(([key, label]) => [key, windowBoard(ctx, page, lib, `筛选 · ${label}`, { file: FILTER_FILE, mode: "preview", filter: key })])); + const askBoard = frame(ctx, "Prototype / 问 AI", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 6, fill: COLORS.editor }); + page.appendChild(askBoard); + for (const [line, state] of [[40, "asking"], [41, null], [45, "history"]]) { + const item = CODE_TREE.routes.flatMap(function flat(node) { return [node, ...(node.kids || []).flatMap(flat)]; }) + .find((node) => lineRange(node.at)[0] === line); + const block = lib.block.get(`${item.kind}/preview`).node.createInstance(); + block.name = `Block / L${line}`; + append(askBoard, block, true); + const translated = translateBlock("routes", item); + const plain = named(block, "Plain"); + plain.characters = translated.text; + paintRanges(plain, translated.ranges); + useCjkFont(ctx, plain); + named(block, "Code").characters = blockCode("routes", item.at); + named(block, "Fold icon").visible = Boolean(item.kids); + if (state) { + const panel = lib.askPanel.get(state).createInstance(); + panel.name = `Ask / L${line}`; + append(askBoard, panel, true); + } + } const tableBoard = frame(ctx, "Prototype / 库规则 · SQLAlchemy 数据表", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor }); page.appendChild(tableBoard); const tableInstance = lib.tableCard.get("preview").createInstance(); @@ -1901,6 +2047,9 @@ async function build(api, stage = () => {}) { quickInput: { title: `添加注释 · ${NOTE_DRAFT.row}`, value: NOTE_DRAFT.text } }), saved: windowBoard(ctx, page, lib, "文件注释 · 已保存", { file: "routes", mode: "preview", hover: NOTE_HOVER, notes: NOTES, tooltip: { row: NOTE_HOVER, lines: [`backend/src/luffy_agent/campaign/${NOTE_HOVER}`, NOTES[NOTE_HOVER]] } }), + copy: windowBoard(ctx, page, lib, "侧栏 · 复制菜单", { file: "routes", mode: "preview", hover: NOTE_HOVER, notes: NOTES, + copyMenu: { row: NOTE_HOVER, relative: `backend/src/luffy_agent/campaign/${NOTE_HOVER}`, + absolute: `/Users/cruldra/Sources/luffy-agent-platform/backend/src/luffy_agent/campaign/${NOTE_HOVER}` } }), }; stage("连接演示入口"); @@ -1929,6 +2078,7 @@ async function build(api, stage = () => {}) { // 文件注释:悬停出的「添加注释」→ 顶部输入框,输入框里回车 → 保存后侧栏显示注释 await connect(named(notes.hover.rows.get("hover"), "Action / 添加注释"), notes.typing.screen, "NAVIGATE"); await connect(named(notes.typing.quickInput, "Field"), notes.saved.screen, "NAVIGATE"); + await connect(named(notes.saved.rows.get("hover"), "Action / 复制"), notes.copy.screen, "NAVIGATE"); // 筛选:默认栏里点「访问级别」「种类」打开各自的下拉菜单;「清除」回到不筛 await connect(named(filters.none.filter, "Chip / 访问级别"), filters.access.screen, "NAVIGATE"); @@ -1938,7 +2088,7 @@ async function build(api, stage = () => {}) { stage("排布画板"); const screens = [...Object.values(boards).flatMap((pair) => [pair.edit.screen, pair.preview.screen]), narrow.screen, - ...Object.values(notes).map((board) => board.screen), ...Object.values(filters).map((board) => board.screen), iteration, tableBoard, enumBoard, celeryBoard]; + ...Object.values(notes).map((board) => board.screen), ...Object.values(filters).map((board) => board.screen), iteration, tableBoard, enumBoard, celeryBoard, askBoard]; let y = 0; for (const screen of screens) { screen.x = 0; @@ -1956,7 +2106,7 @@ async function build(api, stage = () => {}) { { nodeId: filters.none.screen.id, name: "筛选" }]; page.selection = [start]; api.viewport.scrollAndZoomIntoView(page.selection); - return { page, components, boards, narrow, notes, filters, iteration, tableBoard, enumBoard, celeryBoard, screens }; + return { page, components, boards, narrow, notes, filters, iteration, tableBoard, enumBoard, celeryBoard, askBoard, screens }; } async function run(api) { @@ -1975,6 +2125,6 @@ async function run(api) { } if (typeof module !== "undefined") { - module.exports = { BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments }; + module.exports = { ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments }; } if (typeof figma !== "undefined") void run(figma); diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js index 7c3d3ac..fd4d33b 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 { BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js"); +const { ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js"); const { fakeFigma } = require("../../figma-lab/fake-figma"); function action(node) { return node.reactions[0].actions[0]; } @@ -21,12 +21,15 @@ function countBlocks(nodes) { // 窗口:3 个文件 × 编辑 / 预览各 1 个切换 = 6;superwork 的 4 个窗口里侧栏点另一个示例文件 = 4 // (luffy 只有一个示例文件,侧栏没有可跳的) // 文件注释:悬停出的「添加注释」→ 填写,输入框回车 → 已保存 = 2 -// 筛选:默认栏的「访问级别」「种类」各打开一个下拉菜单,两张筛过的画板「清除」回默认 = 4;合计 106 -const EXPECTED_LINKS = 106; +// 筛选:默认栏的「访问级别」「种类」各打开一个下拉菜单,两张筛过的画板「清除」回默认 = 4 +// 侧栏复制:已保存那张悬停行的「复制」→ 复制菜单 = 1;合计 107 +const EXPECTED_LINKS = 107; const EXPECTED_SETS = [ ["Reader / ModeToggle", 2], + ["Reader / BlockTools", 2], ["Reader / CodeBlock", 18], + ["Reader / AskPanel", 2], ["Reader / TableCard", 1], ["Reader / EnumCard", 1], ["Reader / CeleryAppCard", 1], @@ -261,7 +264,7 @@ test("build creates the variant sets, reuses them on boards and wires the flows" for (const [fileKey, nodes] of Object.entries(CODE_TREE)) { const { node, placed } = lib.tree.get(fileKey); assert.equal(placed.length, countBlocks(nodes)); - assert.equal(node.findAll((child) => child.type === "INSTANCE").length, placed.length); + assert.equal(node.findAll((child) => child.type === "INSTANCE" && child.name.startsWith("Block / ")).length, placed.length); const maxDepth = Math.max(...placed.map((entry) => entry.depth)); 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`); @@ -421,6 +424,36 @@ test("the Celery rule draws the app and the beat schedule", async () => { assert.equal(named(named(entry, "频率"), "Text").characters, "每天"); }); +test("block tools are icons only, and asking AI opens a thread under the block with grey history", async () => { + const api = fakeFigma(); + const { components, askBoard } = await build(api); + const tools = components.lib.tools.get("preview"); + assert.deepEqual(tools.children.map((node) => node.name), ["Ask", "Toggle"]); + assert.equal(tools.findAll((node) => node.type === "TEXT").length, 0, "no text labels, icons only"); + const asking = askBoard.findOne((node) => node.name === "Ask / L40"); + assert.equal(asking.findAll((node) => node.name.startsWith("History / ")).length, ASK_SAMPLE.history.length); + assert.equal(named(asking, "Placeholder").characters.startsWith("继续问"), true); + const history = askBoard.findOne((node) => node.name === "Ask / L45"); + assert.equal(history.findAll((node) => node.name === "Input").length, 0, "history-only panel has no input"); +}); + +test("the copy button opens a group of copy options", async () => { + const api = fakeFigma(); + const { notes } = await build(api); + const menu = notes.copy.copyMenu; + assert.deepEqual(menu.children.map((node) => named(node, "Label").characters), + ["复制文件名", "复制文件名(不带后缀)", "复制相对路径", "复制绝对路径"]); + assert.equal(named(named(menu, "Copy / 复制文件名(不带后缀)"), "Value").characters, "order_bonus"); + assert.equal(action(named(notes.saved.rows.get("hover"), "Action / 复制")).destinationId, notes.copy.screen.id); +}); + +test("filter menus offer select all / select none", async () => { + const api = fakeFigma(); + const { filters } = await build(api); + assert.equal(named(filters.kind.filter, "Select all").characters, "全选"); + assert.equal(named(filters.kind.filter, "Select none").characters, "取消全选"); +}); + test("boards and sets sit on the page without overlapping", async () => { const api = fakeFigma(); const result = await build(api); @@ -443,7 +476,7 @@ test("the plugin stays small enough for Figma to build quickly", async () => { if (node.type !== "INSTANCE") node.children.forEach(walk); }; result.page.children.forEach(walk); - // 卡死那次是 5000 多个图层,而且每次读宽度都逼 Figma 重排;侧栏做成共用组件后,现在 20 张画板约 2800 个 + // 卡死那次是 5000 多个图层,而且每次读宽度都逼 Figma 重排;侧栏、块右上角按钮做成共用组件后,现在 21 张画板约 2650 个 assert.ok(owned < 3000, `page owns ${owned} layers outside instances`); }); diff --git a/docs/prototype/figma-lab/fake-figma.js b/docs/prototype/figma-lab/fake-figma.js index 9a4ce0a..1290365 100644 --- a/docs/prototype/figma-lab/fake-figma.js +++ b/docs/prototype/figma-lab/fake-figma.js @@ -6,6 +6,11 @@ const LAYOUT_KEYS = [ "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing", "fontSize", "lineHeight", "textAutoResize", "maxLines", ]; +// 真 Figma 里这些属性只有自动排版容器有、或只有文字有;给别的图层设会抛 "object is not extensible"。 +const FRAME_ONLY = new Set(["layoutMode", "primaryAxisSizingMode", "counterAxisSizingMode", + "paddingLeft", "paddingRight", "paddingTop", "paddingBottom", "itemSpacing"]); +const TEXT_ONLY = new Set(["fontSize", "lineHeight", "textAutoResize", "maxLines"]); +const FRAME_TYPES = new Set(["FRAME", "COMPONENT", "INSTANCE", "COMPONENT_SET"]); const SKIP_COPY = new Set(["id", "parent", "children", "_rev", "_cachedRev", "_val", "_mark", "_flowRev", "_flowCache"]); // 模拟 Figma 文档树与自动排版的尺寸推算, 检查结构、变体配对、实例复用与连线; @@ -79,12 +84,19 @@ function fakeFigma(options = {}) { let value = this[key]; Object.defineProperty(this, key, { get() { return value; }, - set(next) { if (value !== next) { value = next; dirty(this); } }, + set(next) { + if (value === next) return; + if (this._sealed && ((FRAME_ONLY.has(key) && !FRAME_TYPES.has(this.type)) || (TEXT_ONLY.has(key) && this.type !== "TEXT"))) + throw new TypeError(`object is not extensible: ${this.type} has no ${key} (${this.name})`); + value = next; + dirty(this); + }, enumerable: true, configurable: true, }); } nodes.set(this.id, this); + this._sealed = true; } _memo(slot, compute) { if (this._cachedRev[slot] === this._rev) return this._val[slot]; @@ -217,10 +229,12 @@ function fakeFigma(options = {}) { assert.equal(this.type, "COMPONENT"); const copy = (source) => { const node = new Node(source.type); + node._sealed = false; for (const key of Object.keys(source)) { if (SKIP_COPY.has(key)) continue; node[key] = structuredClone(source[key]); } + node._sealed = true; for (const child of source.children) node.appendChild(copy(child)); return node; };