✨ feat(prototype): 代码阅读问 AI、复制菜单、块按钮只留图标、筛选全选原型

This commit is contained in:
cruldra committed 2026-09-26 20:15:07 +08:00
1 parent 478cace93d
commit 631699a570
4 files changed
+219 -18

No files matched your search

@@ -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 窗口外壳。
+162 -12
View File
@@ -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);
+38 -5
View File
@@ -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`);
});
+15 -1
View File
@@ -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;
};