This commit is contained in:
cruldra committed 2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 files changed
+54215

No files matched your search

+383
View File
@@ -0,0 +1,383 @@
import type {
Terminal,
TerminalEditorLocationOptions,
} from 'vscode'
import type { Issue } from '../../gitea/types'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { ViewColumn, window } from 'vscode'
import { logger } from '../../logging/logger'
export function resolveTerminalLocation(panel: KanbanWebviewPanel, preserveFocus: boolean): TerminalEditorLocationOptions {
void panel
// Pin all plugin-managed terminals to editor group 2 (right side of the
// kanban panel in column 1). VS Code creates the group on demand if it
// doesn't exist yet, and stacks new terminals as tabs in that group when
// it does — exactly what we want, no manual scan of existing tabs needed.
return { viewColumn: ViewColumn.Two, preserveFocus }
}
/**
* Inject review feedback into the implementation terminal for `issueNumber`.
* Called from the webhook coordinator's auto-review path. Returns `true`
* when a terminal was found and `sendText` was called; `false` otherwise
* (so the caller can log a warning).
*
* Falls back to looking up by terminal name when the in-memory map miss
* happens — e.g. after a window reload, the impl terminal might still be
* present in `window.terminals` but absent from `this.implTerminals`.
*/
export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: number, text: string, isFirstReview: boolean): boolean {
// `isFirstReview` 参数保留只为兼容签名(调用方仍按 first/再审 区分),
// 方法体本身不再消费它——合并改由用户拖工单到"完成"列时插件 API 触发,
// cc 不允许自行合并。
void isFirstReview
let terminal = panel.implTerminals.get(issueNumber)
if (!terminal) {
// Match by prefix — shell OSC title escapes can append a git branch
// suffix to terminal.name (e.g. "issue-48-实施 5f56026c").
const wantedPrefix = `issue-${issueNumber}-实施`
for (const t of window.terminals) {
if (t.name.startsWith(wantedPrefix)) {
terminal = t
panel.implTerminals.set(issueNumber, t)
trackSessionTerminal(panel, t, issueNumber, 'implement')
break
}
}
}
if (!terminal) {
// 实施会话未开但测试会话开着时,审查反馈交给测试会话。
// 临时使用,不写进 implTerminals(那是实施终端专属的 map)。
const testPrefix = `issue-${issueNumber}-测试`
for (const t of window.terminals) {
if (t.exitStatus === undefined && t.name.startsWith(testPrefix)) {
terminal = t
break
}
}
}
if (!terminal)
return false
// cc 的 TUI 在 raw 模式下,LF (\n) 只算输入框内的换行,CR (\r)
// 才会被识别为 Enter(提交消息)。实测把多行内容 + 末尾 \r 在同一次
// sendText 里发出去时,cc 进入多行输入模式后并不会把紧跟的 \r 当成
// 提交键,结果就是反馈只粘贴到输入框、没提交。
// 拆两次发:先把内容完整推进输入框,250ms 后再独立发一个 \r 作为 Enter。
const body = `\n[审查反馈]\n${text}`
terminal.sendText(body, false)
setTimeout(() => {
terminal!.sendText('\r', false)
}, 250)
return true
}
/**
* Scan `vscode.window.terminals` for an existing terminal whose name
* matches `expectedName`. Matches exact, or `startsWith(expectedName + ' ')`
* so a shell that appended a git branch suffix (e.g. "issue-48-实施
* 5f56026c") still counts. Skips terminals whose process already exited
* (`exitStatus !== undefined`) — those are zombie tabs and shouldn't block
* a fresh spawn.
*
* Why this exists: every session entry (`handleResumeSession`,
* `handleResumeReviewSession`, `handleImplement`) used to dedupe via its
* own in-memory Map (sessionId → Terminal). Panel reload / webview rebuild
* wipes those Maps, but the terminal stays alive in `window.terminals`,
* so re-clicking the link spawned a duplicate tab. Scanning the live
* terminal list survives reloads and prevents cross-map blind spots.
*/
export function findExistingTerminal(panel: KanbanWebviewPanel, expectedName: string): Terminal | undefined {
void panel
return window.terminals.find(
t =>
t.exitStatus === undefined
&& (t.name === expectedName || t.name.startsWith(`${expectedName} `)),
)
}
/**
* Focus an already-open terminal for `sessionId` without stealing focus
* from the kanban. Called when the webview's selection changes via arrow
* keys / clicks — if there's no terminal for this session yet, this is a
* no-op (user has to press Enter to spawn one).
*/
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
// 如果这次 session/focus 是 onDidChangeActiveTerminal 反选触发的回路
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
const REVERSE_LOOP_WINDOW_MS = 200
if (
panel.lastReverseSelectIssueNumber === issueNumber
&& Date.now() - panel.lastReverseSelectAt < REVERSE_LOOP_WINDOW_MS
) {
return
}
// Priority 0: new-issue flow terminal whose name is `issue-new-{nonce}-规划`,
// not `issue-${N}-规划`. The webhook coordinator stitches issueNumber →
// terminal into `newIssueTerminals` via `linkPendingTerminalToIssue`.
// We can't rename the terminal tab (VS Code API limitation), so this
// side-map is the only way to find it by issueNumber.
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
if (newIssueTerm !== window.activeTerminal) {
panel.programmaticTabReveal = newIssueTerm
newIssueTerm.show(true)
}
return
}
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
// the convention (issue-${N}-实施 / issue-${N}-规划 / issue-${N}-审查 / issue-${N}-测试).
const namePriority = [
`issue-${issueNumber}-实施`,
`issue-${issueNumber}-规划`,
`issue-${issueNumber}-审查`,
`issue-${issueNumber}-测试`,
]
for (const name of namePriority) {
const term = findExistingTerminal(panel, name)
if (term) {
// 已经是 active terminal 就不重复 show(避免无谓的回声事件);
// 否则记下程序化 reveal,让随后的 active 变更被吞掉,不回环。
if (term !== window.activeTerminal) {
panel.programmaticTabReveal = term
term.show(true)
}
return
}
}
// 找不到不报错,静默 return(用户没开过终端,正常)。
}
/**
* 反向选中:用户在 column 2 切换 terminal tab 时,从 terminal.name 解析
* issueNumber 并通知 webview 选中对应工单。
*
* 终端名按 `issue-${N}-(规划|实施|审查)` 命名;`issue-new-${nonce}-...`
* 是新建工单流程的占位 tab,没有 issue number,跳过。
*/
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
// Dedupe by reference: OSC title rewrites / shell prompt updates fire
// onDidChangeTabs repeatedly for the same terminal. Skip if it's the
// same reference we just handled.
// 吞掉「我们自己 show() 引发的」active 变更回声:handleSessionFocus 在程序化
// show 前把目标终端记到 programmaticTabReveal,这里命中就 return,不反向选中,
// 从根上打断 选中→聚焦→选中 的死循环(基于引用,不依赖脆弱的时间窗)。
// 每次调用都清空,因此只压制紧随其后的那一次回声,用户真实点 tab 不受影响。
const reveal = panel.programmaticTabReveal
panel.programmaticTabReveal = undefined
if (terminal === reveal)
return
if (terminal === panel.lastActiveTerminalRef)
return
panel.lastActiveTerminalRef = terminal
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
if (m) {
const issueNumber = Number.parseInt(m[1], 10)
if (Number.isFinite(issueNumber)) {
panel.lastReverseSelectAt = Date.now()
panel.lastReverseSelectIssueNumber = issueNumber
panel.postMessage({ type: 'issue/select-by-number', issueNumber })
}
return
}
// Fallback: `issue-new-${shortNonce}-规划` — created via the new-issue flow
// before we knew the issueNumber. Reverse-scan `newIssueTerminals` (which
// was populated by the webhook coordinator via `linkPendingTerminalToIssue`).
if (terminal.name.startsWith('issue-new-')) {
for (const [num, term] of panel.newIssueTerminals) {
if (term === terminal) {
panel.lastReverseSelectAt = Date.now()
panel.lastReverseSelectIssueNumber = num
panel.postMessage({ type: 'issue/select-by-number', issueNumber: num })
return
}
}
}
}
/**
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
*
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
* 按钮能找到对应 terminal。
*/
export function withLiveTerminalTabState(panel: KanbanWebviewPanel, issues: Issue[]): Issue[] {
const liveByIssue = new Map<number, Partial<Pick<Issue, 'brainstormTabOpen' | 'implementTabOpen' | 'reviewTabOpen' | 'testTabOpen'>>>()
const mark = (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void => {
const patch = liveByIssue.get(issueNumber) ?? {}
if (kind === 'brainstorm')
patch.brainstormTabOpen = true
else if (kind === 'implement')
patch.implementTabOpen = true
else if (kind === 'review')
patch.reviewTabOpen = true
else
patch.testTabOpen = true
liveByIssue.set(issueNumber, patch)
}
for (const [terminal, origin] of panel.terminalOrigin) {
if (terminal.exitStatus === undefined)
mark(origin.issueNumber, origin.kind)
}
for (const terminal of window.terminals) {
if (terminal.exitStatus !== undefined)
continue
const match = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)(?:\s|$)/)
if (!match)
continue
const issueNumber = Number.parseInt(match[1], 10)
if (!Number.isFinite(issueNumber))
continue
const kind = match[2] === '规划'
? 'brainstorm'
: match[2] === '实施'
? 'implement'
: match[2] === '审查'
? 'review'
: 'test'
panel.terminalOrigin.set(terminal, { issueNumber, kind })
mark(issueNumber, kind)
}
if (liveByIssue.size === 0)
return issues
return issues.map((issue) => {
const patch = liveByIssue.get(issue.number)
return patch ? { ...issue, ...patch } : issue
})
}
/**
* 登记一个新创建/复用的会话终端,并主动推一条 issue/patch 把对应 tab 打开
* 标志置 true。详情面板侧据此在三行会话 id 右侧渲染关闭按钮。
*
* 复用场景(map 命中、existingByName 命中)也会再调一次,是幂等的:
* 重复设置 terminalOrigin 不影响,重复推 true 在 webview 端的
* `mergeIssuePatch` 也是 no-op。
*/
export function trackSessionTerminal(
panel: KanbanWebviewPanel,
terminal: Terminal,
issueNumber: number,
kind: 'brainstorm' | 'implement' | 'review' | 'test',
): void {
panel.terminalOrigin.set(terminal, { issueNumber, kind })
panel.postMessage({
type: 'issue/patch',
issueNumber,
patch:
kind === 'brainstorm'
? { brainstormTabOpen: true }
: kind === 'implement'
? { implementTabOpen: true }
: kind === 'review'
? { reviewTabOpen: true }
: { testTabOpen: true },
})
}
/**
* onDidCloseTerminal 触发后调用:反查 `terminalOrigin` 找到该 terminal
* 对应的工单 + 会话类型,并推 false 让详情面板隐藏关闭按钮。
*
* 同时从 `terminalOrigin` 删除条目;四个 issue-aware Map 的清理仍由
* 现有 onDidCloseTerminal 循环负责,本方法只关心 webview 通知。
*/
export function untrackClosedTerminal(panel: KanbanWebviewPanel, closed: Terminal): void {
const origin = panel.terminalOrigin.get(closed)
if (!origin)
return
panel.terminalOrigin.delete(closed)
panel.postMessage({
type: 'issue/patch',
issueNumber: origin.issueNumber,
patch:
origin.kind === 'brainstorm'
? { brainstormTabOpen: false }
: origin.kind === 'implement'
? { implementTabOpen: false }
: origin.kind === 'review'
? { reviewTabOpen: false }
: { testTabOpen: false },
})
}
/**
* webview 端"关闭 tab"按钮入口:直接扫 `terminalOrigin` 找匹配
* (issueNumber, kind) 的 terminal 并 `dispose()`。VS Code 随后会发
* onDidCloseTerminal,由统一回调清理四个 issue-aware Map 和推 flag=false
* ——本方法不直接改任何 Map,避免双重清理。
*
* 用 terminalOrigin 而不是分别查四个 Map:审查 tab 在
* `triggerAutoReviewTab` 路径里不会进 `reviewTerminals`(那个 Map 按
* thread_id 索引,此路径还没 thread_id),但会被登记到 terminalOrigin,
* 所以用它做单一真相源最稳。
*/
export function handleCloseSessionTab(panel: KanbanWebviewPanel, issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test'): void {
let terminal: Terminal | undefined
for (const [t, origin] of panel.terminalOrigin) {
if (origin.issueNumber === issueNumber && origin.kind === kind) {
terminal = t
break
}
}
if (!terminal) {
logger.add({
level: 'warn',
source: 'panel',
message: `关闭 ${kind} tab 失败 #${issueNumber}:未找到对应终端`,
})
return
}
terminal.dispose()
}
/**
* Removes and returns the {@link pendingIssueCreations} entry for the
* given nonce, if any. Called by the webhook coordinator when a matching
* `issues opened` payload arrives. Returning `undefined` (and leaving the
* map untouched) signals "no match — treat as external issue creation".
*/
export function takePendingIssueCreation(panel: KanbanWebviewPanel, nonce: string): {
sessionId?: string
profilePath?: string
color: string
workspaceRoot: string
inboxDir: string
terminalName: string
terminal: Terminal
createdAt: number
} | undefined {
const entry = panel.pendingIssueCreations.get(nonce)
if (entry)
panel.pendingIssueCreations.delete(nonce)
return entry
}
/**
* Called by the webhook coordinator after it parses a `spx:nonce=...` token
* out of `issue.body` and matches it to an entry in `pendingIssueCreations`.
* Promotes the in-flight brainstorm terminal into the issueNumber-keyed
* `newIssueTerminals` map so subsequent `handleSessionFocus(issueNumber)`
* and `handleActiveTerminalChanged` calls can find it.
*
* Also drops the terminal into `terminals` keyed by sessionId (when known)
* so `handleResumeSession` reuses the same tab instead of spawning a new
* one. Does NOT remove the pending entry — `takePendingIssueCreation` is
* still responsible for cleanup on its own path.
*/
export function linkPendingTerminalToIssue(panel: KanbanWebviewPanel, nonce: string, issueNumber: number): void {
const pending = panel.pendingIssueCreations.get(nonce)
if (!pending)
return
panel.newIssueTerminals.set(issueNumber, pending.terminal)
if (pending.sessionId && pending.sessionId.length > 0)
panel.terminals.set(pending.sessionId, pending.terminal)
trackSessionTerminal(panel, pending.terminal, issueNumber, 'brainstorm')
}