✨ 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

+72 -69
View File
@@ -48,6 +48,38 @@ export interface PendingHook {
feature: string
}
/**
* issue body 里的 spx marker → state JSON 字段。三种 marker 走同一套同步
* 逻辑,这张表是唯一真源(与 cli 的 markerTypeToStateField 对应)。
*
* 路径必须含 `/` 且以对应后缀结尾:cc 在头脑风暴阶段会往 body 里写
* `<!-- spx:spec=... -->` 这种占位说明文,不卡死就会被当成真路径存进去。
*/
const BODY_MARKERS = [
{ type: 'spec', field: 'specFile', ext: 'md' },
{ type: 'plan', field: 'planFile', ext: 'md' },
{ type: 'prototype', field: 'prototypeFile', ext: 'json' },
] as const
/** 本次从 body 里扫出来的 marker 值,按 state JSON 字段名索引。 */
type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string }
function parseBodyMarkers(body: string): MarkerPatch {
const out: MarkerPatch = {}
for (const { type, field, ext } of BODY_MARKERS) {
const re = new RegExp(String.raw`<!--\s*spx:${type}=([^\s/>]*\/[^\s>]*\.${ext})\s*-->`)
const m = body.match(re)
if (m)
out[field] = m[1]
}
return out
}
/** 日志用的一行摘要,例如 `spec=a.md plan=<空> prototype=<空>`。 */
function describeMarkers(patch: MarkerPatch): string {
return BODY_MARKERS.map(({ type, field }) => `${type}=${patch[field] ?? '<空>'}`).join(' ')
}
/**
* A {@link PrWebhookEvent} whose `issueNumber` has been resolved (either from
* the legacy `/webhook/:n` path or via PR-body / branch heuristics).
@@ -779,11 +811,10 @@ class WebhookCoordinator {
}
/**
* Issue `edited` payload: cc updates the issue body with `<!-- spx:spec=... -->`
* / `<!-- spx:plan=... -->` annotation lines as it discovers spec/plan files.
* We scan the body for those markers, diff against the state-JSON comment,
* and (if anything changed) merge the new paths in + patch the open panel
* so the detail view refreshes without a full kanban reload.
* Issue `edited` payload: cc 会往 issue body 里追加 `<!-- spx:spec=... -->`
* / `<!-- spx:plan=... -->` / `<!-- spx:prototype=... -->` 三种 marker。
* 这里把 body 里的 marker 扫出来,与 state JSON comment 里的当前值比对,
* 有变化就合并写回,并给打开着的面板推一次 issue/patch,省掉整板刷新。
*/
private async handleIssueEdited(event: IssueWebhookEvent): Promise<void> {
if (!this.ctx)
@@ -817,17 +848,12 @@ class WebhookCoordinator {
return
}
// 路径必须含 `/` 且以 `.md` 结尾,避免 cc 写的 `<!-- spx:spec=... -->` 占位被当真。
const specMatch = event.body.match(/<!--\s*spx:spec=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
const planMatch = event.body.match(/<!--\s*spx:plan=([^\s/>]*\/[^\s>]*\.md)\s*-->/)
const specFile = specMatch ? specMatch[1] : undefined
const planFile = planMatch ? planMatch[1] : undefined
if (!specFile && !planFile) {
const found = parseBodyMarkers(event.body)
if (Object.keys(found).length === 0) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue=#${event.issueNumber} edited body 无 spx:spec/spx:plan 注释,跳过`,
message: `issue=#${event.issueNumber} edited body 无 spx marker 注释,跳过`,
})
return
}
@@ -852,15 +878,19 @@ class WebhookCoordinator {
})
}
const currentSpec = typeof currentState.specFile === 'string' ? currentState.specFile : undefined
const currentPlan = typeof currentState.planFile === 'string' ? currentState.planFile : undefined
// cc only adds or updates; it never deletes. Treat an absent marker
// (undefined) as "no signal", not "clear" — that way deleting a spec
// line by mistake won't lose the existing pointer.
const nextSpec = specFile ?? currentSpec
const nextPlan = planFile ?? currentPlan
if (currentSpec === nextSpec && currentPlan === nextPlan) {
// cc 只增不删:body 里没有某个 marker 当「没信号」,不当「清空」,
// 免得手滑删掉一行就把已有指针丢了。
const next: MarkerPatch = {}
let changed = false
for (const { field } of BODY_MARKERS) {
const current = typeof currentState[field] === 'string' ? currentState[field] as string : undefined
const value = found[field] ?? current
if (value)
next[field] = value
if (value !== current)
changed = true
}
if (!changed) {
// Gitea state JSON 已是最新(spx 同步写过的情况),跳过 mergeStateJsonComment
// 节省一次 API 写。但 webview 内存里可能还是旧值(webview 不轮询,全靠
// coordinator 推 issue/patch),所以**仍然要推一次 patch** 把当前值同步过去,
@@ -868,24 +898,17 @@ class WebhookCoordinator {
logger.add({
level: 'info',
source: 'webhook',
message: `issue=#${event.issueNumber} edited spec/plan 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (spec=${nextSpec ?? '<空>'} plan=${nextPlan ?? '<空>'})`,
message: `issue=#${event.issueNumber} edited marker 在 state JSON 已同步,跳过 merge 但推 issue/patch 刷 webview (${describeMarkers(next)})`,
})
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: next,
})
if (this.activePanel) {
const patch: { specFile?: string, planFile?: string } = {}
if (nextSpec)
patch.specFile = nextSpec
if (nextPlan)
patch.planFile = nextPlan
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch,
})
}
return
}
// spec/plan 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
// marker 落库由责任人机器执行;规划阶段还没有实施终端,兜底证据
// 额外认规划终端。observe 机器只把新值推给本机 webview。
const ownership = await this.resolveOwnership(
event.issueNumber,
@@ -893,27 +916,14 @@ class WebhookCoordinator {
['实施', '测试', '规划'],
)
if (ownership === 'observe') {
if (this.activePanel) {
const patch: { specFile?: string, planFile?: string } = {}
if (specFile)
patch.specFile = specFile
if (planFile)
patch.planFile = planFile
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch,
})
}
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: found,
})
return
}
const extra: Record<string, unknown> = {}
if (specFile)
extra.specFile = specFile
if (planFile)
extra.planFile = planFile
try {
await mergeStateJsonComment({
host: remote.host,
@@ -921,7 +931,7 @@ class WebhookCoordinator {
repo: remote.repo,
token,
issueNumber: event.issueNumber,
extra,
extra: found,
})
}
catch (err) {
@@ -938,22 +948,15 @@ class WebhookCoordinator {
logger.add({
level: 'info',
source: 'webhook',
message: `issue=#${event.issueNumber} edited spec/plan 已同步`,
details: `spec=${specFile ?? '<未变>'} plan=${planFile ?? '<未变>'}`,
message: `issue=#${event.issueNumber} edited marker 已同步`,
details: describeMarkers(found),
})
if (this.activePanel) {
const patch: { specFile?: string, planFile?: string } = {}
if (specFile)
patch.specFile = specFile
if (planFile)
patch.planFile = planFile
this.activePanel.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch,
})
}
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: found,
})
}
/**