Files
superwork/vscode/src/panel/KanbanPanel.ts
T

1137 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
ExtensionContext,
Terminal,
TerminalEditorLocationOptions,
WebviewPanel,
} from 'vscode'
import type { HookContext } from '../git/worktreeHooks'
import type { Issue } from '../gitea/types'
import type { IssueRef } from '../issues/stateRouter'
import type { KanbanScope } from './kanbanScope'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { randomBytes } from 'node:crypto'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
import { deleteToken, getToken } from '../auth/secrets'
import { detectRepo } from '../git/remote'
import {
GiteaApiError,
} from '../gitea/api'
import { loadIssues } from '../gitea/issueLoader'
import { overlayLocalIssueState } from '../issues/localState'
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
import { logger } from '../logging/logger'
import { annotateReviewSessionFileExists } from '../sessions/codexSessions'
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
import { webhookCoordinator } from '../webhook/coordinator'
import { loadYouTrackIssues } from '../youtrack/issueLoader'
import * as issues from './handlers/issues'
import * as managedSessions from './handlers/managedSessions'
import * as prFiles from './handlers/prFiles'
import * as profileAssets from './handlers/profileAssets'
import * as sessions from './handlers/sessions'
import * as settings from './handlers/settings'
import * as terminals from './handlers/terminals'
import { focusedTerminal, FocusedTerminalTracker } from './handlers/terminalTabs'
import * as toolbar from './handlers/toolbar'
import * as worktree from './handlers/worktree'
import * as youtrackIssues from './handlers/youtrackIssues'
import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
import { KANBAN_SCOPE_KEY } from './kanbanScope'
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
export { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../cc/profiles'
export class KanbanWebviewPanel {
static readonly viewType = 'superpowers.kanbanPanel'
private static current: KanbanWebviewPanel | undefined
private readonly panel: WebviewPanel
private readonly disposables: { dispose: () => void }[] = []
/**
* `issue.number` → its source identity, rebuilt on every `loadAndPush`.
* Lets the workflow handlers (which only receive a number from the webview)
* route state persistence to the right tracker. YouTrack numbers are
* synthetic and collision-free (see `youtrack/issueLoader`), so the number
* alone is an unambiguous key.
*/
private issueRefs = new Map<number, { source: 'gitea' | 'youtrack', externalId?: string }>()
/**
* sessionId → its dedicated terminal in the editor area. Populated when the
* user presses Enter on a card; entry is removed when the terminal is
* closed (so a re-resume spawns a fresh tab). Letting selection changes
* `terminal.show(true)` an existing entry is what gives the user "switch
* card → switch terminal tab" behaviour.
*/
// internal: handler 模块访问
readonly terminals = new Map<string, Terminal>()
/**
* issueNumber → its implementation cc terminal (the one spawned by
* `handleImplement`). Used by the auto-review flow to `sendText` review
* feedback back into the running implementation conversation. Kept
* separate from `terminals` because at spawn time we don't yet have a
* sessionId, and even later the impl session id lives in a different
* field of the state JSON.
*/
// internal: handler 模块访问
readonly implTerminals = new Map<number, Terminal>()
/**
* codex review terminals keyed by review session id (codex thread_id).
* Lets `handleResumeReviewSession` reuse an existing tab instead of
* spawning a fresh one on every click.
*/
// internal: handler 模块访问
readonly reviewTerminals = new Map<string, Terminal>()
/**
* 会话管理 tab 里 resume / create 出来的 cc 终端,keyed by managed sessionId。
* 让列表行知道某会话的 tab 是否正开着(tabOpen),并支持「关闭 tab」与删除时
* dispose 终端。onDidCloseTerminal 命中后按值反查删除条目。
*/
// internal: handler 模块访问
readonly managedTerminals = new Map<string, Terminal>()
/**
* Tracks in-flight "create issue" runs keyed by the nonce embedded in the
* cc prompt. Populated synchronously in `handleIssueCreate` before the
* terminal is shown; the session watcher fills in `sessionId` once cc
* starts writing its jsonl. The webhook coordinator drains entries via
* `takePendingIssueCreation` when the corresponding `issues opened`
* payload arrives, then merges the column / sessionId / brainstormProfilePath /
* color into the state-JSON comment and cleans up the inbox tmpdir.
*/
// internal: handler 模块访问
readonly pendingIssueCreations = new Map<string, {
sessionId?: string
brainstormProfilePath?: string
/** Palette id (e.g. `terminal.ansiBlue`) — same shape stored in state JSON. */
color: string
workspaceRoot: string
inboxDir: string
terminalName: string
/**
* The brainstorm terminal created synchronously in `handleIssueCreate`.
* Stored here so `linkPendingTerminalToIssue` can promote it into
* `newIssueTerminals` once the webhook tells us the issueNumber.
*/
terminal: Terminal
createdAt: number
}>()
/**
* issueNumber → the brainstorm terminal originally created via the new-issue
* flow (its name is `issue-new-{shortNonce}-规划`, NOT `issue-${N}-规划`).
* Populated by `linkPendingTerminalToIssue` after the webhook coordinator
* matches the nonce in `issue.body` to an entry in `pendingIssueCreations`.
*
* VS Code's Terminal API has no rename method, so we can't fix the tab name
* after the fact. Instead we keep this side-map so `handleSessionFocus`
* (card → terminal) and `handleActiveTerminalChanged` (terminal → card) can
* still find the tab and round-trip selection.
*/
// internal: handler 模块访问
readonly newIssueTerminals = new Map<number, Terminal>()
/**
* Terminal → 它属于哪个 issue 的哪类会话 tab。
*
* 仅用于详情面板"会话 id 行右侧的关闭按钮"特性:在四个 issue-aware Map
* (`terminals` / `implTerminals` / `reviewTerminals` / `newIssueTerminals`)
* 任意一项 set 时同步登记,`onDidCloseTerminal` 命中后反查这里再推
* `issue/patch { brainstorm|implement|reviewTabOpen: false }` 给 webview。
*
* 用普通 Map 而不是 WeakMaponDidCloseTerminal 需要把 closed Terminal
* 作为 key 查;WeakMap 不暴露 has/get 之外的迭代 API 但其实 get 就够用,
* 不过 VS Code 文档里 Terminal 实例生命周期与 Map 中的引用不冲突,普通
* Map 更易于 inspect / debug。条目数顶天就是当前活跃 tab 数,无压力。
*/
// internal: handler 模块访问
readonly terminalOrigin = new Map<Terminal, { issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test' }>()
/**
* 防 handleResumeSession/handleResumeReviewSession 在 await 钩子/终端创建期间被重入触发,
* 导致同一 sessionId 开多个 tab。key 形如 `${issueNumber}:brainstorm|implement|review`。
*
* 触发场景:impl-tab-pre-create 钩子里 `pgrep -f` 等 pycharm 真 PID 最多 15s
* 这段时间用户再点同一会话 id(前端 800ms 节流早已过期),第二次进来时
* `findExistingTerminal` 还查不到(前一次 createTerminal 尚未发生),两次都
* 走到 createTerminal → 重复 tab。审查会话同理(codex resume 启动有几秒延迟)。
*
* 锁的释放时机 = 方法返回(包括 throw 时也释放,try/finally 保证);不持久化,
* 重启自动清空。
*/
// internal: handler 模块访问
readonly resumeInFlight = new Set<string>()
/**
* Concurrency lock for the "提交当前代码" button. The webview already
* disables the button while `commit/state running=true` is in flight, but
* we keep a server-side lock as defense against duplicate messages.
*/
// internal: handler 模块访问
commitRunning = false
/**
* Whether the workspace git working tree currently has any uncommitted
* changes (working tree, index, or untracked). Populated by
* `setupGitWatcher()` via the built-in `vscode.git` extension and pushed to
* the webview as `commit/has-changes`. The webview only renders the
* "提交代码" toolbar button while this is true (or while a commit run is
* in flight, so users can still see the spinner). Defaults to `false` so
* the button stays hidden until we've actually observed the repo state.
*/
// internal: handler 模块访问
hasGitChanges = false
/**
* Disposable returned by `repository.state.onDidChange` while we're
* watching the workspace repo. Stored so we can detach the listener when
* the panel is disposed (otherwise the git extension would keep a strong
* reference to our callback closure for the lifetime of the editor).
*/
// internal: handler 模块访问
gitStateDisposable: { dispose: () => void } | undefined
/**
* 最近一次终端 tab 切换触发反选 webview 的时间戳(ms epoch)。
* `handleSessionFocus` 收到 webview 回发的 session/focus 时检查这个,
* 距离 <200ms 且工单号相同 → 跳过优先级跳转,避免点审查 tab 自动弹回实施 tab。
*/
// internal: handler 模块访问
lastReverseSelectAt = 0
// internal: handler 模块访问
lastReverseSelectIssueNumber = -1
/**
* 当前有焦点的会话终端 tab。由 tab 事件推导「活动分组的活动 tab」再做 diff,
* 只有焦点真正落到另一个终端 tab 时才反向选中工单:OSC title 改写不会重复
* 触发,单 tab 分组从看板点回来(无 isActive / activeTerminal 变化)也能触发。
*/
private readonly focusedTerminalTracker = new FocusedTerminalTracker<Terminal>()
// internal: context 供 handler 模块访问
private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) {
this.panel = panel
this.panel.webview.options = {
enableScripts: true,
localResourceRoots: [
Uri.joinPath(this.context.extensionUri, 'dist', 'webview-ui'),
],
}
this.panel.webview.html = this.buildHtml()
// Let the always-on webhook coordinator know who to refresh + toast for.
webhookCoordinator.setActivePanel(this)
this.disposables.push(
this.panel.onDidDispose(() => this.dispose()),
this.panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
// Forward every new log entry to the webview so the log modal stays
// live without polling.
logger.onEntry(entry => this.postMessage({ type: 'logs/append', entry })),
// Drop tracked terminals when the user closes them, so the next Enter
// on that card spawns a fresh tab instead of trying to .show() a dead
// handle.
window.onDidCloseTerminal((closed) => {
for (const [sid, t] of this.terminals) {
if (t === closed) {
this.terminals.delete(sid)
break
}
}
for (const [n, t] of this.implTerminals) {
if (t === closed) {
this.implTerminals.delete(n)
break
}
}
for (const [sid, t] of this.reviewTerminals) {
if (t === closed) {
this.reviewTerminals.delete(sid)
break
}
}
for (const [n, t] of this.newIssueTerminals) {
if (t === closed) {
this.newIssueTerminals.delete(n)
break
}
}
// 会话管理 tab 的终端:按值反查删条目,再重推列表让 tabOpen 变 false。
for (const [sid, t] of this.managedTerminals) {
if (t === closed) {
this.managedTerminals.delete(sid)
void managedSessions.pushManagedSessions(this)
break
}
}
// 先抓 origin 再 untrackuntrackClosedTerminal 会从 map 里删 entry)。
// 实施/测试 tab 关闭均触发 post-close 钩子,fire-and-forget 不阻塞 listener。
const origin = this.terminalOrigin.get(closed)
// 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。
this.untrackClosedTerminal(closed)
if (origin?.kind === 'implement' || origin?.kind === 'test')
void this.dispatchImplTabPostCloseAsync(origin.issueNumber)
// 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。
const conflictMatch = closed.name.match(/^issue-(\d+)-冲突解决/)
if (conflictMatch)
void this.dispatchImplTabPostCloseAsync(Number(conflictMatch[1]))
}),
// 用户把焦点切到 column 2 的终端 tab 时,反向选中看板上对应的工单卡片。
// 会话终端一律开在编辑区(见 resolveTerminalLocation),tabs API 即唯一真相:
// 分组内切 tab 走 onDidChangeTabs,分组激活(单 tab 分组从看板点回来)
// 走 onDidChangeTabGroups,两者都只是触发一次重新推导。
window.tabGroups.onDidChangeTabs(() => this.syncFocusedTerminalTab()),
window.tabGroups.onDidChangeTabGroups(() => this.syncFocusedTerminalTab()),
)
// Start observing the workspace repo so we can hide the "提交代码"
// toolbar button whenever the working tree is clean. Fire-and-forget —
// `setupGitWatcher` handles its own activation + retry logic.
toolbar.setupGitWatcher(this)
}
private syncFocusedTerminalTab(): void {
const term = focusedTerminal(
window.tabGroups.activeTabGroup.activeTab,
input => input instanceof TabInputTerminal,
window.terminals,
)
const activated = this.focusedTerminalTracker.sync(term)
if (activated)
this.handleActiveTerminalChanged(activated)
}
static createOrShow(context: ExtensionContext): void {
if (KanbanWebviewPanel.current) {
KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active)
return
}
const panel = window.createWebviewPanel(
KanbanWebviewPanel.viewType,
'Superpowers Kanban',
ViewColumn.Active,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
Uri.joinPath(context.extensionUri, 'dist', 'webview-ui'),
],
},
)
KanbanWebviewPanel.current = new KanbanWebviewPanel(context, panel)
}
/** Triggers a fresh load on the currently open panel, if any. */
static refresh(): void {
KanbanWebviewPanel.current?.loadAndPush().catch(() => {})
}
private handleMessage(msg: WebviewToExtension): void {
if (msg.type === 'issues/refresh') {
void this.loadAndPush()
return
}
if (msg.type === 'issues/set-scope') {
// 视图偏好按工作区持久化;切换即重拉。
void this.context.workspaceState.update(KANBAN_SCOPE_KEY, msg.scope)
void this.loadAndPush()
return
}
if (msg.type === 'settings/save') {
void settings.handleSettingsSave(this, msg)
return
}
if (msg.type === 'settings/edit-request') {
void settings.handleEditSettingsRequest(this)
return
}
if (msg.type === 'issue/create') {
void issues.handleIssueCreate(this, msg.userRequest, msg.images, msg.brainstormProfilePath)
return
}
if (msg.type === 'profiles/list') {
void settings.handleProfilesList(this)
return
}
if (msg.type === 'profile-asset/save') {
void profileAssets.handleSaveProfileAsset(this, msg)
return
}
if (msg.type === 'profile-asset/load') {
void profileAssets.handleLoadProfileAsset(this, msg)
return
}
if (msg.type === 'toast/open-url') {
void env.openExternal(Uri.parse(msg.url))
return
}
if (msg.type === 'session/resume') {
void sessions.handleResumeSession(this, msg.sessionId, msg.profilePath, msg.cwd, msg.issueNumber)
return
}
if (msg.type === 'session/focus') {
terminals.handleSessionFocus(this, msg.issueNumber)
return
}
if (msg.type === 'session/resume-review') {
void sessions.handleResumeReviewSession(this, msg.sessionId, msg.issueNumber, msg.cwd)
return
}
if (msg.type === 'session/start-test') {
void sessions.handleStartTestSession(this, msg.issueNumber)
return
}
if (msg.type === 'session/resume-test') {
void sessions.handleResumeTestSession(this, msg.sessionId, msg.issueNumber, msg.cwd)
return
}
if (msg.type === 'editor/open-file') {
void issues.handleOpenFile(this, msg.path)
return
}
if (msg.type === 'issue/implement') {
void this.handleImplement(msg.issueNumber, msg.planFile, msg.profilePath, msg.sessionId)
return
}
if (msg.type === 'pr/open') {
void issues.handleOpenPr(this, msg.pr)
return
}
if (msg.type === 'issue/generate-pr-diff-summary') {
void issues.handleGeneratePrDiffSummary(this, msg.issueNumber)
return
}
if (msg.type === 'worktree/open') {
void worktree.handleOpenWorktree(this, msg.path)
return
}
if (msg.type === 'worktree/delete') {
void worktree.handleDeleteWorktree(this, msg.issueNumber, msg.path)
return
}
if (msg.type === 'git/merge-preview') {
void worktree.handleMergePreview(this, msg.issueNumber, msg.branch)
return
}
if (msg.type === 'column/change') {
if (msg.source === 'youtrack' && msg.externalId)
void youtrackIssues.handleYouTrackColumnChange(this, msg.externalId, msg.toColumn)
else
void issues.handleColumnChange(this, msg.issueNumber, msg.toColumn)
return
}
if (msg.type === 'youtrack/list-projects') {
void youtrackIssues.handleListProjects(this, msg.baseUrl, msg.token)
return
}
if (msg.type === 'youtrack/import') {
void youtrackIssues.handleYouTrackImport(this)
return
}
if (msg.type === 'dependency/set') {
void issues.handleSetDependency(this, msg.issueNumber, msg.prerequisiteNumber)
return
}
if (msg.type === 'dependency/clear') {
void issues.handleClearDependency(this, msg.issueNumber, msg.prerequisiteNumber)
return
}
if (msg.type === 'issue/update-auto-review') {
void issues.handleUpdateAutoReview(this, msg.issueNumber, msg.value)
return
}
if (msg.type === 'issue/update-profile-path') {
void issues.handleUpdateProfilePath(this, msg.issueNumber, msg.profilePath)
return
}
if (msg.type === 'issue/update-brainstorm-profile-path') {
void issues.handleUpdateBrainstormProfilePath(this, msg.issueNumber, msg.brainstormProfilePath)
return
}
if (msg.type === 'issue/update-test-profile-path') {
void issues.handleUpdateTestProfilePath(this, msg.issueNumber, msg.testProfilePath)
return
}
if (msg.type === 'logs/fetch') {
this.postMessage({ type: 'logs/snapshot', entries: logger.snapshot() })
return
}
if (msg.type === 'logs/clear') {
logger.clear()
this.postMessage({ type: 'logs/cleared' })
return
}
if (msg.type === 'commit/run') {
void toolbar.handleCommitRun(this)
return
}
if (msg.type === 'session/close-tab') {
terminals.handleCloseSessionTab(this, msg.issueNumber, msg.kind)
return
}
if (msg.type === 'branch-sync/check') {
void this.handleBranchSyncCheck()
return
}
if (msg.type === 'branch-sync/run') {
void toolbar.handleBranchSyncRun(this)
return
}
if (msg.type === 'env-lock/check') {
void this.handleEnvLockCheck()
return
}
if (msg.type === 'env-lock/toggle') {
void toolbar.handleEnvLockToggle(this)
return
}
if (msg.type === 'issue/delete') {
void issues.handleDeleteIssue(this, msg.issueNumber)
return
}
if (msg.type === 'issue/close') {
void issues.handleCloseIssue(this, msg.issueNumber)
return
}
if (msg.type === 'brainstorm/start') {
void sessions.handleStartBrainstormSession(this, msg.issueNumber)
return
}
if (msg.type === 'profiles/get') {
void settings.handleProfilesGet(this)
return
}
if (msg.type === 'profiles/save') {
void settings.handleProfilesSave(this, msg.data)
return
}
if (msg.type === 'profiles/open') {
void settings.handleProfilesOpen(this, msg.value)
return
}
if (msg.type === 'managed-sessions/get') {
void managedSessions.handleManagedSessionsGet(this)
return
}
if (msg.type === 'managed-sessions/create') {
void managedSessions.handleManagedSessionsCreate(this, msg.profilePath, msg.name, msg.prompt)
return
}
if (msg.type === 'managed-sessions/rename') {
void managedSessions.handleManagedSessionsRename(this, msg.sessionId, msg.name)
return
}
if (msg.type === 'managed-sessions/resume') {
void managedSessions.handleManagedSessionsResume(this, msg.sessionId, msg.profilePath)
return
}
if (msg.type === 'managed-sessions/delete') {
void managedSessions.handleManagedSessionsDelete(this, msg.sessionId)
return
}
if (msg.type === 'managed-sessions/close-tab') {
managedSessions.handleManagedSessionsCloseTab(this, msg.sessionId)
return
}
if (msg.type === 'managed-sessions/list-importable') {
void managedSessions.handleManagedSessionsListImportable(this)
return
}
if (msg.type === 'managed-sessions/import') {
void managedSessions.handleManagedSessionsImport(this, msg.sessionId, msg.name)
return
}
if (msg.type === 'pr-files/get') {
void prFiles.handleGetPrFiles(this, msg.issueNumber)
return
}
if (msg.type === 'pr-file-diff/get') {
void prFiles.handleGetPrFileDiff(this, {
issueNumber: msg.issueNumber,
path: msg.path,
previousPath: msg.previousPath,
})
return
}
if (msg.type === 'pr-file-summary/generate') {
void prFiles.handleGeneratePrFileSummary(this, {
issueNumber: msg.issueNumber,
path: msg.path,
previousPath: msg.previousPath,
})
return
}
if (msg.type === 'pr-review/set') {
void prFiles.handleSetPrReviewConfirmed(this, msg)
return
}
if (msg.type === 'pr-diff-mode/get') {
prFiles.handleGetPrDiffMode(this)
return
}
if (msg.type === 'pr-diff-mode/set') {
void prFiles.handleSetPrDiffMode(this, msg.mode)
}
}
// internal: handler 模块访问
resolveTerminalLocation(preserveFocus: boolean): TerminalEditorLocationOptions {
return terminals.resolveTerminalLocation(this, preserveFocus)
}
/**
* Resolve the ThemeColor to use for an issue's terminal/tab. Reads the
* stored color from the issue's state JSON; if absent or invalid, picks a
* random palette entry and asynchronously persists it back so future
* sessions reuse the same tone.
*
* Always returns a ThemeColor (never undefined) — caller is expected to
* only call this once it has a usable workspace + Gitea remote + token.
* Persistence failures are logged and swallowed.
*/
// internal: handler 模块访问
async resolveIssueIcon(issueNumber: number): Promise<{ themeColor: ThemeColor, iconUri: Uri }> {
// Helper: produce both representations from a palette id.
const pack = (id: string) => ({
themeColor: new ThemeColor(id),
iconUri: themeColorIdToIconUri(id),
})
// Deterministic fallback when we can't talk to gitea (no workspace, no
// remote, no token). Matches what `issueTerminalColor(issueNumber)` would
// pick so the icon is still stable across sessions.
const fallbackId = PALETTE[((issueNumber % PALETTE.length) + PALETTE.length) % PALETTE.length]
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
if (!workspaceRoot)
return pack(fallbackId)
const remote = await detectRepo(workspaceRoot)
if (!remote)
return pack(fallbackId)
const token = await getToken(this.context, remote.host)
if (!token)
return pack(fallbackId)
let stored: string | undefined
try {
const state = await this.readIssueState(issueNumber)
if (typeof state.color === 'string' && state.color.length > 0)
stored = state.color
}
catch (err) {
console.warn('[superpowers] failed to read state JSON for color:', err)
}
const { id, isNew } = resolveIssueColor(stored)
if (isNew) {
// Fire-and-forget: don't block terminal creation on the network round
// trip. Failures are non-fatal — the next session just picks again.
void this.mergeIssueState(issueNumber, { color: id })
.then(() => {
this.postMessage({
type: 'issue/patch',
issueNumber,
patch: { color: id },
})
})
.catch((err) => {
console.warn('[superpowers] failed to persist issue color:', err)
})
}
return pack(id)
}
/**
* 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`.
*/
// internal: handler 模块访问
injectIntoImplTerminal(issueNumber: number, text: string, isFirstReview: boolean): boolean {
return terminals.injectIntoImplTerminal(this, issueNumber, text, isFirstReview)
}
/**
* 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.
*/
// internal: handler 模块访问
findExistingTerminal(expectedName: string): Terminal | undefined {
return terminals.findExistingTerminal(this, expectedName)
}
/**
* Open (or re-use) the review terminal tab for an issue and send the codex
* review command. Used by the webhook auto-review flow so the user can watch
* codex work in real time instead of it running headless in the background.
*
* 只有已持久化 reviewSessionId 时才注入 "PR 更新了,再审一下"tab 活着但
* session 尚未落盘时 noop,避免首次审查被竞态污染。详见
* `decideAutoReviewPath` / `sessions.triggerAutoReviewTab`。
*/
public async triggerAutoReviewTab(opts: {
issueNumber: number
prNumber: string
prompt: string
/** workspace-relative or absolute path; if missing/invalid we fall back to workspaceRoot. */
worktreePath: string
workspaceRoot: string
}): Promise<boolean> {
return sessions.triggerAutoReviewTab(this, opts)
}
/**
* 反向选中:用户在 column 2 切换 terminal tab 时,从 terminal.name 解析
* issueNumber 并通知 webview 选中对应工单。
*
* 终端名按 `issue-${N}-(规划|实施|审查)` 命名;`issue-new-${nonce}-...`
* 是新建工单流程的占位 tab,没有 issue number,跳过。
*/
// internal: handler 模块访问
handleActiveTerminalChanged(terminal: Terminal): void {
terminals.handleActiveTerminalChanged(this, terminal)
}
/**
* Resolve a workspace-relative path against the current workspace root and
* open it in VS Code. Markdown files (`.md`) are opened in the rendered
* preview via `markdown.showPreview`; other file types fall back to
* `vscode.open`. Surfaces failures as an error toast (e.g. file deleted on
* disk after we recorded its path).
*/
/**
* Kick off the end-to-end "implement this plan" flow:
* 1. Detect repo + token, compute a stable feature_name from the plan path.
* 2. Move the issue to in-progress and stamp branch/worktreePath/status
* into the state-JSON comment.
* 3. Confirm the always-on webhook coordinator is bound to the current
* port, then register a gitea webhook scoped to the feature branch
* and hand the bookkeeping to the coordinator.
* 4. Spawn `claude` in an editor-tab terminal with a /goal prompt that
* tells cc to create the worktree, implement the plan, and emit
* `<request_review>$pr_no</request_review>` when done.
*
* The webhook coordinator (see src/webhook/coordinator.ts) receives the
* resulting PR event, writes back the PR number, and deletes the gitea
* hook — even if this panel has since been closed.
*/
// internal: handler 模块访问
async handleImplement(
issueNumber: number,
planFile: string,
profilePath?: string,
_sessionId?: string,
): Promise<void> {
return sessions.handleImplement(this, issueNumber, planFile, profilePath, _sessionId)
}
// internal: handler 模块访问
dispatchWorktreeHook(
phase: 'post-create' | 'pre-remove' | 'impl-tab-pre-create' | 'impl-tab-post-close',
ctx: HookContext,
): Promise<void> {
return worktree.dispatchWorktreeHook(this, phase, ctx)
}
// internal: onDidCloseTerminal fire-and-forget 调用,保留薄委派
private dispatchImplTabPostCloseAsync(issueNumber: number): Promise<void> {
return worktree.dispatchImplTabPostCloseAsync(this, issueNumber)
}
/**
* Server-side lock check for prerequisite gating. Fetches a fresh issues
* snapshot (we don't trust webview state) and reports whether
* `issueNumber` is blocked by an unfinished prerequisite. Mirrors the
* webview-side `isIssueLocked` predicate: locked iff the issue has a
* prerequisite that exists in the current snapshot and is not yet in the
* `done` column.
*
* Fail-open: any error fetching issues is logged and we return
* `{ locked: false }` so a transient Gitea hiccup can't lock the user out
* of starting a session.
*/
// internal: handler 模块访问
async resolveLockedReason(
issueNumber: number,
): Promise<{ locked: boolean, prerequisiteNumber?: number, prerequisiteColumn?: string }> {
return issues.resolveLockedReason(this, issueNumber)
}
/**
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
*
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
* 按钮能找到对应 terminal。
*/
// internal: handler 模块访问
withLiveTerminalTabState(issues: Issue[]): Issue[] {
return terminals.withLiveTerminalTabState(this, issues)
}
/**
* Reloads issues from gitea and pushes to the webview. Public so the
* always-on webhook coordinator can refresh us when a PR event arrives.
*/
async loadAndPush(): Promise<void> {
this.postMessage({ type: 'issues/loading' })
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
// eslint-disable-next-line no-console
console.log('[superpowers/panel] loadAndPush workspaceRoot=', workspaceRoot)
if (!workspaceRoot) {
this.postMessage({
type: 'issues/error',
message: '请先打开一个工作区文件夹',
})
return
}
const remote = await detectRepo(workspaceRoot)
if (!remote) {
this.postMessage({
type: 'issues/error',
message: '当前工作区没有 Gitea 远程仓库',
})
return
}
const { host, owner, repo } = remote
const token = await getToken(this.context, host)
if (!token) {
const s = getSettings(this.context)
this.postMessage({
type: 'settings/show',
host,
tokenSaved: false,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
devBranch: s.devBranch,
autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
pycharmCommand: s.pycharmCommand,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
codexModel: s.codexModel,
codexReasoningEffort: s.codexReasoningEffort,
profilesDirectory: s.profilesDirectory,
})
return
}
const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? 'mine'
try {
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope })
// YouTrack is a best-effort second source: a failure here must never
// break the gitea board, so swallow it into a toast + log.
let youtrackList: Issue[] = []
try {
youtrackList = await loadYouTrackIssues(this.context)
}
catch (ytErr) {
const message = ytErr instanceof Error ? ytErr.message : String(ytErr)
logger.add({ level: 'error', source: 'youtrack', message: '加载 YouTrack 问题失败', details: message })
this.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `YouTrack 同步失败:${message}`,
dismissOnTimer: 6000,
})
}
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,附加本机 codex
// 会话文件存在性,再补 live terminal 态。
const issues = this.withLiveTerminalTabState(
await annotateReviewSessionFileExists(
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
),
)
this.issueRefs = new Map(
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
)
const ytSettings = getSettings(this.context)
this.postMessage({
type: 'issues/update',
issues,
scope,
globalAutoReview: ytSettings.autoReview,
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
})
// Re-publish the cached git state so a slow webview boot (or a
// refresh after git events already fired) still picks up the latest
// value. Cheap — the webview discards if equal.
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
}
catch (err) {
if (err instanceof GiteaApiError && err.status === 401) {
await deleteToken(this.context, host)
const s = getSettings(this.context)
this.postMessage({
type: 'settings/show',
host,
errorMessage: 'Token 无效或已过期,请重新填写',
tokenSaved: false,
webhookPort: s.webhookPort,
webhookSecret: s.webhookSecret,
brainstormPrompt: s.brainstormPrompt,
brainstormContinuePrompt: s.brainstormContinuePrompt,
implementPlanPrompt: s.implementPlanPrompt,
autoReview: s.autoReview,
reviewPrompt: s.reviewPrompt,
devBranch: s.devBranch,
autoBuildBranch: s.autoBuildBranch,
worktreeDirectory: s.worktreeDirectory,
worktreeOpenWith: s.worktreeOpenWith,
pycharmCommand: s.pycharmCommand,
worktreePostCreateScript: s.worktreePostCreateScript,
worktreePreRemoveScript: s.worktreePreRemoveScript,
implTabPreCreateScript: s.implTabPreCreateScript,
implTabPostCloseScript: s.implTabPostCloseScript,
conflictResolutionProfilePath: s.conflictResolutionProfilePath,
commitProfilePath: await getEffectiveCommitProfilePath(this.context),
codexModel: s.codexModel,
codexReasoningEffort: s.codexReasoningEffort,
profilesDirectory: s.profilesDirectory,
})
return
}
const baseMessage = err instanceof Error ? err.message : String(err)
const message = `${baseMessage}\n\n[debug] host=${host} owner=${owner} repo=${repo}`
this.postMessage({ type: 'issues/error', message })
}
}
/**
* Resolve an issue number to its source identity, for routing state
* persistence. Unknown numbers default to gitea (back-compat / pre-load).
*/
// internal: handler 模块访问
refFor(issueNumber: number): IssueRef {
const entry = this.issueRefs.get(issueNumber)
return { source: entry?.source ?? 'gitea', number: issueNumber, externalId: entry?.externalId }
}
/** True when the board number belongs to a YouTrack-sourced card. */
// internal: handler 模块访问
isYouTrackIssue(issueNumber: number): boolean {
return this.issueRefs.get(issueNumber)?.source === 'youtrack'
}
/** Read workflow state from the issue's tracker (gitea or youtrack). */
// internal: handler 模块访问
readIssueState(issueNumber: number): Promise<Record<string, unknown>> {
return readIssueState(this.context, this.refFor(issueNumber))
}
/** Merge workflow state into the issue's tracker (gitea or youtrack). */
// internal: handler 模块访问
mergeIssueState(issueNumber: number, extra: Record<string, unknown>): Promise<void> {
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
}
/**
* Resolve/close the issue in its tracker. Returns false when a youtrack
* close command can't be determined.
*/
// internal: handler 模块访问
closeIssueByNumber(issueNumber: number): Promise<boolean> {
return closeIssueByRef(this.context, this.refFor(issueNumber))
}
// internal: handler 模块访问
resolveImplementProfilePath(issueLevelProfilePath: string | undefined): string {
return sessions.resolveImplementProfilePath(this, issueLevelProfilePath)
}
/**
* Compute how far the remote auto-build branch is behind the remote dev
* branch, then push a `branch-sync/status` to the webview. Called on
* webview init, after `settings/save`, and after a successful sync.
*
* Empty `autoBuildBranch` setting means "use devBranch" — collapsing to
* the equal-branch case in `checkBranchSync`, which marks it unavailable.
*/
// internal: handler 模块访问
handleBranchSyncCheck(): Promise<void> {
return toolbar.handleBranchSyncCheck(this)
}
/**
* Push the current env-lock state to the webview. State persists in
* `workspaceState` under `'envLocked'`. First call (key not yet set)
* defaults to locked AND eagerly chmods all `.env*` files to 0o444 so
* UI and filesystem agree; subsequent calls trust the stored value.
* The file count is recomputed via a fresh scan each time so the toolbar
* reflects what the next toggle will actually act on.
*/
private handleEnvLockCheck(): Promise<void> {
return toolbar.handleEnvLockCheck(this)
}
/** Forces the open panel (if any) into the setup-auth state. */
static requestEditAuth(): void {
if (KanbanWebviewPanel.current)
settings.requestEditAuth(KanbanWebviewPanel.current)
}
postMessage(msg: ExtensionToWebview): void {
void this.panel.webview.postMessage(msg)
}
/**
* 登记一个新创建/复用的会话终端,并主动推一条 issue/patch 把对应 tab 打开
* 标志置 true。详情面板侧据此在三行会话 id 右侧渲染关闭按钮。
*
* 复用场景(map 命中、existingByName 命中)也会再调一次,是幂等的:
* 重复设置 terminalOrigin 不影响,重复推 true 在 webview 端的
* `mergeIssuePatch` 也是 no-op。
*/
// internal: handler 模块访问
trackSessionTerminal(
terminal: Terminal,
issueNumber: number,
kind: 'brainstorm' | 'implement' | 'review' | 'test',
): void {
terminals.trackSessionTerminal(this, terminal, issueNumber, kind)
}
/**
* onDidCloseTerminal 触发后调用:反查 `terminalOrigin` 找到该 terminal
* 对应的工单 + 会话类型,并推 false 让详情面板隐藏关闭按钮。
*
* 同时从 `terminalOrigin` 删除条目;四个 issue-aware Map 的清理仍由
* 现有 onDidCloseTerminal 循环负责,本方法只关心 webview 通知。
*/
// internal: handler 模块访问
untrackClosedTerminal(closed: Terminal): void {
terminals.untrackClosedTerminal(this, closed)
}
/**
* 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".
*/
// internal: handler 模块访问
takePendingIssueCreation(nonce: string): {
sessionId?: string
brainstormProfilePath?: string
color: string
workspaceRoot: string
inboxDir: string
terminalName: string
terminal: Terminal
createdAt: number
} | undefined {
return terminals.takePendingIssueCreation(this, nonce)
}
/**
* 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.
*/
// internal: handler 模块访问
public linkPendingTerminalToIssue(nonce: string, issueNumber: number): void {
terminals.linkPendingTerminalToIssue(this, nonce, issueNumber)
}
private dispose(): void {
KanbanWebviewPanel.current = undefined
webhookCoordinator.setActivePanel(undefined)
this.gitStateDisposable?.dispose()
this.gitStateDisposable = undefined
while (this.disposables.length) {
const d = this.disposables.pop()
d?.dispose()
}
this.panel.dispose()
}
private buildHtml(): string {
const distRoot = Uri.joinPath(this.context.extensionUri, 'dist', 'webview-ui')
const indexPath = path.join(distRoot.fsPath, 'index.html')
let html = fs.readFileSync(indexPath, 'utf-8')
const nonce = makeNonce()
const cspSource = this.panel.webview.cspSource
// 重写 src 和 href 的相对路径为 webview URI。vite 产物是固定名(无 hash),
// webview 按 URL 缓存资源——必须按文件 mtime 加 ?v= 版本号,否则 reload 后
// 仍会跑缓存里的旧 index.js / index.css。
html = html.replace(/(src|href)="(\/[^"]+|\.\/[^"]+|[^"/][^"]*)"/g, (_m, attr, p) => {
const cleaned = p.replace(/^\.?\//, '')
const fileUri = Uri.joinPath(distRoot, cleaned)
let version = ''
try {
version = `?v=${fs.statSync(fileUri.fsPath).mtimeMs}`
}
catch {
// 资源文件不存在则不加版本号
}
const uri = this.panel.webview.asWebviewUri(fileUri)
return `${attr}="${uri}${version}"`
})
// 给所有 <script> 标签注入 nonce
html = html.replace(/<script(\s[^>]*)?>/g, (_m, attrs) => {
const a = attrs ?? ''
return `<script nonce="${nonce}"${a}>`
})
// 注入 CSP
const csp = `default-src 'none'; img-src ${cspSource} data:; style-src ${cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}'; font-src ${cspSource};`
html = html.replace(
/<head>/,
`<head>\n <meta http-equiv="Content-Security-Policy" content="${csp}">`,
)
return html
}
}
// internal: handler 模块访问
export function makeNonce(): string {
return randomBytes(16).toString('base64').replace(/[+/=]/g, '')
}