✨ feat(vscode): 工单挂原型插件,详情面板一键导入 Figma

- prototypeFile 走通 state → Issue → webview 全链路(gitea/youtrack 两个
  loader、两份 KNOWN_STATE_FIELDS、issue/patch、webview types),按 planFile
  同款当共享字段;路径校验换成 .json 版,允许绝对路径
- 详情面板「计划文件」下加「原型插件路径」行:双击开 manifest.json,
  行尾按钮「在 Figma 中导入并打开」;没挂插件时只显示空态
- src/figma/:把 manifest 写进 Figma 桌面版的 localFileExtensions 再拉起
  Figma。坑是 Figma 退出会用内存状态覆盖 settings.json,所以写之前先
  osascript 优雅退出并轮询等它真没了(10s 等不到就让用户手动关,不强杀);
  写前备份 settings.json.bak-<时间戳>;已导入过就跳过写入直接打开;
  非 macOS 只把路径复制到剪贴板
- webhook 里 spec/plan 的 marker 同步改成表驱动,顺带认第三种 prototype
- 提示词:prototype.md 要求画完挂 marker;brainstorm-continue.md 新增
  {prototypeRef} 占位符,有原型插件时让续聊先读它别从零重画,没有时
  连同整段抹掉

Claude-Session: https://claude.ai/code/session_01Kgmb1LvrPmFX3NoUDB4zZC
This commit is contained in:
cruldra committed 2026-09-15 00:11:49 +08:00
1 parent def5f4e88c
commit 142af57d9d
24 files changed
+628 -85

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
/**
* 「在 Figma 中导入并打开」:把工单挂着的 Figma 插件 manifest.json 写进
* Figma 桌面版的已导入插件清单,然后拉起 Figma。
*
* 为什么要自己改配置文件:Figma 没有命令行导入开发插件的入口,UI 上得
* 「Plugins → Development → Import plugin from manifest」一路点。清单本身
* 就是 `~/Library/Application Support/Figma/settings.json` 里的一个数组,
* 直接往里加一条即可。
*
* 最大的坑:**Figma 退出时会用内存里的状态整个覆盖 settings.json**。开着
* Figma 写进去,用户一关 Figma 就全没了。所以写之前必须先把 Figma 关掉,
* 而且要等进程真的退出——不弹窗问用户,问了也只能答「关」。
*
* 只支持 macOS:路径和 `osascript` / `open -a` 都是 mac 专有。
*/
import { execFile } from 'node:child_process'
import * as fs from 'node:fs'
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import process from 'node:process'
import { env, window } from 'vscode'
import { logger } from '../logging/logger'
import { appendManifestEntry, asExtensionEntries } from './localExtensions'
/** Figma 桌面版的设置文件(含已导入开发插件清单)。 */
function figmaSettingsPath(): string {
return path.join(os.homedir(), 'Library', 'Application Support', 'Figma', 'settings.json')
}
/** 等 Figma 退出的上限;到点还没退就让用户自己关,绝不 kill -9。 */
const QUIT_TIMEOUT_MS = 10_000
const QUIT_POLL_MS = 400
function run(cmd: string, args: string[]): Promise<{ code: number, stdout: string, stderr: string }> {
return new Promise((resolve) => {
execFile(cmd, args, { timeout: 15_000 }, (err, stdout, stderr) => {
// pgrep 没匹配到进程也是非零退出,所以这里不把非零当失败,交给调用方判。
const code = err ? Number((err as NodeJS.ErrnoException).code ?? 1) : 0
resolve({
code: Number.isFinite(code) ? code : 1,
stdout: (stdout ?? '').toString(),
stderr: (stderr ?? '').toString(),
})
})
})
}
async function isFigmaRunning(): Promise<boolean> {
const { stdout } = await run('pgrep', ['-x', 'Figma'])
return stdout.trim().length > 0
}
/**
* 优雅退出 Figma 并轮询等它真的没了。超时返回 false(调用方报错让用户手动关),
* 不强杀——Figma 被 SIGKILL 会丢用户没保存的画布。
*/
async function quitFigmaAndWait(): Promise<boolean> {
await run('osascript', ['-e', 'quit app "Figma"'])
const deadline = Date.now() + QUIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (!await isFigmaRunning())
return true
await new Promise(resolve => setTimeout(resolve, QUIT_POLL_MS))
}
return !await isFigmaRunning()
}
/** 读 manifest.json 的 `name` 当 Figma 里显示的插件名;没有就用目录名兜底。 */
async function readPluginName(manifestAbs: string): Promise<string> {
const raw = await fsp.readFile(manifestAbs, 'utf8')
const parsed: unknown = JSON.parse(raw)
const name = parsed && typeof parsed === 'object'
? (parsed as { name?: unknown }).name
: undefined
if (typeof name === 'string' && name.trim().length > 0)
return name.trim()
return path.basename(path.dirname(manifestAbs))
}
function fail(message: string, err?: unknown): void {
const details = err === undefined
? undefined
: err instanceof Error
? (err.stack ?? err.message)
: String(err)
logger.add({ level: 'error', source: 'figma', message, details })
void window.showErrorMessage(message)
}
/**
* 主流程。`manifestPath` 可以是仓库相对路径也可以是绝对路径。
* 任何一步失败都出错误提示 + 记日志,不静默。
*/
export async function importPluginIntoFigma(opts: {
issueNumber: number
manifestPath: string
workspaceRoot: string
}): Promise<void> {
const { issueNumber, manifestPath, workspaceRoot } = opts
const manifestAbs = path.isAbsolute(manifestPath)
? manifestPath
: path.join(workspaceRoot, manifestPath)
if (!fs.existsSync(manifestAbs)) {
fail(`#${issueNumber} 的原型插件不存在:${manifestAbs}`)
return
}
// 非 mac 没有这条路径可走,把绝对路径给用户,让他自己在 Figma 里导入。
if (process.platform !== 'darwin') {
await env.clipboard.writeText(manifestAbs)
void window.showWarningMessage(
`当前系统不支持自动导入 Figma 插件,manifest 路径已复制到剪贴板,请在 Figma 里手动导入:${manifestAbs}`,
)
return
}
const settingsPath = figmaSettingsPath()
if (!fs.existsSync(settingsPath)) {
fail(`没找到 Figma 配置文件:${settingsPath}。先装 Figma 桌面版并至少启动一次。`)
return
}
let pluginName: string
try {
pluginName = await readPluginName(manifestAbs)
}
catch (err) {
fail(`读取 manifest.json 失败:${manifestAbs}`, err)
return
}
let settings: Record<string, unknown>
try {
const parsed: unknown = JSON.parse(await fsp.readFile(settingsPath, 'utf8'))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
throw new TypeError('settings.json 顶层不是对象')
settings = parsed as Record<string, unknown>
}
catch (err) {
fail(`读取 Figma 配置失败:${settingsPath}`, err)
return
}
const entries = asExtensionEntries(settings.localFileExtensions)
const result = appendManifestEntry(entries, { manifestPath: manifestAbs, name: pluginName })
if (result.added) {
// 先关 Figma 再写,否则退出时的内存快照会把这次写入盖掉。
if (await isFigmaRunning()) {
logger.add({ level: 'info', source: 'figma', message: '写配置前先退出 Figma(退出会覆盖 settings.json)' })
if (!await quitFigmaAndWait()) {
fail(`Figma 在 ${QUIT_TIMEOUT_MS / 1000} 秒内没有退出,请手动关掉 Figma 后重试。`)
return
}
}
try {
const backup = `${settingsPath}.bak-${Date.now()}`
await fsp.copyFile(settingsPath, backup)
settings.localFileExtensions = result.entries
await fsp.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8')
logger.add({
level: 'info',
source: 'figma',
message: `#${issueNumber} 原型插件已写入 Figma 配置:${pluginName}`,
details: `manifest=${manifestAbs} backup=${backup}`,
})
}
catch (err) {
fail(`写入 Figma 配置失败:${settingsPath}`, err)
return
}
}
else {
logger.add({
level: 'info',
source: 'figma',
message: `#${issueNumber} 原型插件已在 Figma 清单中,跳过写入:${result.name}`,
details: `manifest=${manifestAbs}`,
})
}
const open = await run('open', ['-a', 'Figma'])
if (open.code !== 0) {
fail(`拉起 Figma 失败:${open.stderr.trim() || `exit ${open.code}`}`)
return
}
void window.showInformationMessage(
result.added
? `原型插件「${result.name}」已导入 Figma,在 Plugins > Development 里跑它`
: `原型插件「${result.name}」此前已导入,在 Plugins > Development 里跑它`,
)
}
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
appendManifestEntry,
asExtensionEntries,
buildManifestEntry,
findManifestEntry,
nextExtensionId,
} from './localExtensions'
/** 造一条 manifest 条目(id 与 codeFileId 成对,和 Figma 真实清单一致)。 */
function manifest(id: number, manifestPath: string) {
return {
id,
manifestPath,
lastKnownName: `plugin-${id}`,
lastKnownPluginId: '',
fileMetadata: { type: 'manifest' as const, codeFileId: id + 1, uiFileIds: [] },
}
}
/** Figma 加载插件后补的 code.js 条目,占的是 manifest 的 codeFileId。 */
function code(id: number, manifestFileId: number, manifestPath: string) {
return {
id,
manifestPath,
fileMetadata: { type: 'code' as const, manifestFileId },
}
}
describe('nextExtensionId', () => {
it('空清单从 1 开始', () => {
expect(nextExtensionId([])).toBe(1)
})
it('取已有条目 id 最大值 +1', () => {
expect(nextExtensionId([manifest(1, '/a/manifest.json'), code(2, 1, '/a/code.js')])).toBe(3)
})
it('codeFileId 预定的号也要算进去(code 条目还没建出来时)', () => {
// id 只到 11,但 11 的 codeFileId 已经把 12 预定了 → 下一个必须是 13
expect(nextExtensionId([
manifest(9, '/a/manifest.json'),
code(10, 9, '/a/code.js'),
manifest(11, '/b/manifest.json'),
])).toBe(13)
})
it('顺序乱了也按最大值算', () => {
expect(nextExtensionId([
code(8, 7, '/b/code.js'),
manifest(3, '/a/manifest.json'),
manifest(7, '/b/manifest.json'),
])).toBe(9)
})
})
describe('findManifestEntry', () => {
const entries = [manifest(1, '/a/manifest.json'), manifest(3, '/b/manifest.json')]
it('按 manifestPath 精确命中', () => {
expect(findManifestEntry(entries, '/b/manifest.json')?.id).toBe(3)
})
it('路径不同就不算重复(同名不同目录)', () => {
expect(findManifestEntry(entries, '/c/manifest.json')).toBeUndefined()
})
})
describe('buildManifestEntry', () => {
it('codeFileId 紧跟 id,给 Figma 之后建 code 条目占号', () => {
expect(buildManifestEntry({ id: 19, manifestPath: '/x/manifest.json', name: 'X Lab' })).toEqual({
id: 19,
manifestPath: '/x/manifest.json',
lastKnownName: 'X Lab',
lastKnownPluginId: '',
fileMetadata: { type: 'manifest', codeFileId: 20, uiFileIds: [] },
})
})
})
describe('appendManifestEntry', () => {
it('新插件追加到末尾,id 接着往下发', () => {
const entries = [manifest(1, '/a/manifest.json'), code(2, 1, '/a/code.js')]
const got = appendManifestEntry(entries, { manifestPath: '/b/manifest.json', name: 'B Lab' })
expect(got.added).toBe(true)
expect(got.entries).toHaveLength(3)
expect(got.entries[2]).toMatchObject({ id: 3, manifestPath: '/b/manifest.json', lastKnownName: 'B Lab' })
// 原数组不被改动
expect(entries).toHaveLength(2)
})
it('已导入过 → 不追加,名字用清单里记的那个', () => {
const entries = [manifest(1, '/a/manifest.json')]
const got = appendManifestEntry(entries, { manifestPath: '/a/manifest.json', name: '新名字' })
expect(got.added).toBe(false)
expect(got.entries).toBe(entries)
expect(got.name).toBe('plugin-1')
})
})
describe('asExtensionEntries', () => {
it('不是数组 → 空清单(Figma 没装过插件时该字段可能缺失)', () => {
expect(asExtensionEntries(undefined)).toEqual([])
expect(asExtensionEntries({})).toEqual([])
})
it('丢掉缺 id / manifestPath 的脏条目', () => {
expect(asExtensionEntries([
manifest(1, '/a/manifest.json'),
{ id: 'x', manifestPath: '/b/manifest.json' },
{ id: 2 },
null,
])).toHaveLength(1)
})
})
+116
View File
@@ -0,0 +1,116 @@
/**
* Figma 桌面版「已导入的开发插件」清单的纯逻辑:算新 id、查重、造条目。
*
* 清单落在 `~/Library/Application Support/Figma/settings.json` 的
* `localFileExtensions` 数组里,一条 manifest 条目长这样:
*
* ```json
* { "id": 9, "manifestPath": "/abs/.../manifest.json",
* "lastKnownName": "Xxx Design Lab", "lastKnownPluginId": "",
* "fileMetadata": { "type": "manifest", "codeFileId": 10, "uiFileIds": [] } }
* ```
*
* 坑:id 是**整个数组共享**的递增计数器,manifest 条目占一个 id,Figma 之后
* 给它的 code.js 建条目时占 `fileMetadata.codeFileId` 那个 id。所以算下一个
* id 时不能只看已有条目的 id,还得把被 codeFileId / manifestFileId 预定掉的
* 号算进去,否则新插件会和 Figma 迟早要建的 code 条目撞号。
*
* 这里不碰文件系统,好单测;读写与关 Figma 在 importPlugin.ts。
*/
export interface FigmaFileMetadata {
type: 'manifest' | 'code' | string
/** manifest 条目专有:预留给同插件 code.js 条目的 id。 */
codeFileId?: number
/** code 条目专有:反指所属 manifest 条目的 id。 */
manifestFileId?: number
uiFileIds?: number[]
}
export interface FigmaLocalFileExtension {
id: number
manifestPath: string
lastKnownName?: string
lastKnownPluginId?: string
fileMetadata?: FigmaFileMetadata
[key: string]: unknown
}
/** 数组里混着 Figma 写的杂项,只把「有数字 id + 字符串 manifestPath」的当条目。 */
export function asExtensionEntries(value: unknown): FigmaLocalFileExtension[] {
if (!Array.isArray(value))
return []
return value.filter((e): e is FigmaLocalFileExtension =>
!!e
&& typeof e === 'object'
&& typeof (e as FigmaLocalFileExtension).id === 'number'
&& typeof (e as FigmaLocalFileExtension).manifestPath === 'string',
)
}
/**
* 下一个可用 id:已有条目 id 与被 codeFileId / manifestFileId 预定的 id 取最大再 +1。
* 空清单从 1 开始(Figma 自己也是从 1 起)。
*/
export function nextExtensionId(entries: FigmaLocalFileExtension[]): number {
let max = 0
for (const e of entries) {
for (const n of [e.id, e.fileMetadata?.codeFileId, e.fileMetadata?.manifestFileId]) {
if (typeof n === 'number' && Number.isFinite(n) && n > max)
max = n
}
}
return max + 1
}
/** 按 manifestPath 精确查重;已导入过就返回那条,用来跳过写入。 */
export function findManifestEntry(
entries: FigmaLocalFileExtension[],
manifestPath: string,
): FigmaLocalFileExtension | undefined {
return entries.find(e => e.manifestPath === manifestPath)
}
/** 造一条 manifest 条目;code 条目留给 Figma 自己建,这里只把 id 号占住。 */
export function buildManifestEntry(opts: {
id: number
manifestPath: string
name: string
}): FigmaLocalFileExtension {
return {
id: opts.id,
manifestPath: opts.manifestPath,
lastKnownName: opts.name,
lastKnownPluginId: '',
fileMetadata: {
type: 'manifest',
codeFileId: opts.id + 1,
uiFileIds: [],
},
}
}
export interface AppendResult {
/** 追加后的完整条目数组;已存在时原样返回。 */
entries: FigmaLocalFileExtension[]
/** false = 已导入过,调用方跳过写文件直接拉起 Figma。 */
added: boolean
/** 这条插件在 Figma 里的显示名(新建用传入名,已存在用清单里记的名)。 */
name: string
}
/** 查重 → 算 id → 追加,一次算完,写文件的那端只管落盘。 */
export function appendManifestEntry(
entries: FigmaLocalFileExtension[],
opts: { manifestPath: string, name: string },
): AppendResult {
const existing = findManifestEntry(entries, opts.manifestPath)
if (existing)
return { entries, added: false, name: existing.lastKnownName || opts.name }
const entry = buildManifestEntry({
id: nextExtensionId(entries),
manifestPath: opts.manifestPath,
name: opts.name,
})
return { entries: [...entries, entry], added: true, name: opts.name }
}