692 lines
32 KiB
TypeScript
692 lines
32 KiB
TypeScript
import type { ReactNode } from 'react'
|
||
import { useMemo, useRef } from 'react'
|
||
import { ArrowRightLeft, CircleSlash, Download, ExternalLink, GitMerge, Loader2, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
|
||
import type { ClaudeProfile } from '../hooks/useIssues'
|
||
import type { Issue, IssueColumn } from '../types'
|
||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||
import { isIssueLocked } from '../lib/dependencies'
|
||
import type { PropertyGroup } from './property-grid'
|
||
import { PropertyGrid } from './property-grid'
|
||
|
||
/** 跨机绝对路径只比 basename(同名 .json)映射到本机 profiles 列表。 */
|
||
function matchProfileOption(
|
||
current: string | undefined,
|
||
profiles: ClaudeProfile[],
|
||
): string {
|
||
const raw = current?.trim()
|
||
if (!raw)
|
||
return ''
|
||
if (profiles.some(p => p.path === raw))
|
||
return raw
|
||
const base = raw.split(/[/\\]/).pop() ?? ''
|
||
if (!base)
|
||
return raw
|
||
const byFile = profiles.find(p => p.path.split(/[/\\]/).pop() === base)
|
||
if (byFile)
|
||
return byFile.path
|
||
const name = base.endsWith('.json') ? base.slice(0, -'.json'.length) : base
|
||
const byName = profiles.find(p => p.name === name)
|
||
return byName?.path ?? raw
|
||
}
|
||
|
||
interface IssueDetailPanelProps {
|
||
issue: Issue | null
|
||
/** 当前所有 issues,用于解析前置依赖锁定态。 */
|
||
allIssues: Issue[]
|
||
/** 全局 autoReview 当前值;用于工单未显式设置 autoReview 时的回退展示。 */
|
||
globalAutoReview: boolean
|
||
onOpenInBrowser: (url: string) => void
|
||
onResumeSession: (sessionId: string, profilePath?: string, cwd?: string, issueNumber?: number) => void
|
||
/** Open a new terminal that runs `codex resume <id>` for the auto-review
|
||
* conversation associated with this issue. */
|
||
onResumeReviewSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||
/** Open a new terminal that runs `claude --resume <id>` for the test
|
||
* conversation associated with this issue. */
|
||
onResumeTestSession: (sessionId: string, issueNumber: number, cwd?: string) => void
|
||
/** Spawn a fresh "测试" cc tab for an issue whose PR has merged. Bound to the
|
||
* Play button next to the "测试会话 id" row (only shown when `issue.pr` exists). */
|
||
onStartTestSession: (issueNumber: number) => void
|
||
/** Open a workspace-relative file in the editor. */
|
||
onOpenFile: (path: string) => void
|
||
/** Kick off the end-to-end implementation flow for the given plan file. */
|
||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||
/** Open the gitea PR page in the browser. */
|
||
onOpenPr: (pr: string) => void
|
||
/** Start a background Claude run that writes the PR diff summary markdown. */
|
||
onGeneratePrDiffSummary: (issueNumber: number) => void
|
||
/** Whether a PR diff summary generation request is in flight for an issue. */
|
||
isPrDiffSummaryRunning: (issueNumber: number) => boolean
|
||
/** Open the workspace-relative worktree path in a new VS Code window. */
|
||
onOpenWorktree: (path: string) => void
|
||
/** Delete the worktree (`git worktree remove`) and clear it from state. */
|
||
onDeleteWorktree: (issueNumber: number, path: string) => void
|
||
/** Pre-merge the issue's feature branch into the main worktree's current branch
|
||
* (`git merge --no-commit --no-ff <branch>`) so the user can inspect locally. */
|
||
onMergeBranch: (issueNumber: number, branch: string) => void
|
||
/** 硬删整个工单 + 关联资源(worktree / PR / feature branch / cc tabs)。
|
||
* 顶部垃圾桶按钮触发,扩展端做 modal confirm,可选。 */
|
||
onDeleteIssue?: (issueNumber: number) => void
|
||
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
||
onCloseIssue: (issueNumber: number) => void
|
||
/** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */
|
||
onResetToTodo: (issueNumber: number) => void
|
||
/** Close the matching session terminal tab. The extension watches
|
||
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
|
||
* makes the X button vanish on its own. */
|
||
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
||
/** Spawn a fresh "规划" cc tab for an issue whose sessionId is still empty
|
||
* (created out-of-band via spx without webhook linking). Bound to the Play
|
||
* button next to the "头脑风暴会话 id" row. */
|
||
onStartBrainstormSession: (issueNumber: number) => void
|
||
/** Persist a per-issue `autoReview` override into the state JSON. */
|
||
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
||
/** 已知 Claude profile 列表,用于「配置文件」下拉选项。 */
|
||
profiles: ClaudeProfile[]
|
||
/** Persist a per-issue `profilePath` override into the state JSON. */
|
||
onUpdateProfilePath: (issueNumber: number, profilePath: string) => void
|
||
/** Persist a per-issue `brainstormProfilePath` override into the state JSON. */
|
||
onUpdateBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
|
||
/** Persist a per-issue `testProfilePath` override into the state JSON. */
|
||
onUpdateTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||
/** Open the in-webview log modal. */
|
||
onOpenLogs: () => void
|
||
/** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */
|
||
me?: string
|
||
onStartHandoff: (issueNumber: number) => void
|
||
onAcceptHandoff: (issueNumber: number) => void
|
||
isHandoffRunning: (issueNumber: number) => boolean
|
||
}
|
||
|
||
/**
|
||
* 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
|
||
*
|
||
* 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid
|
||
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
|
||
* 一个 action 类型字段,双击或选中卡按 Enter 都会触发
|
||
* `claude --resume <id>` 在新终端中恢复对话。
|
||
*/
|
||
export function IssueDetailPanel({
|
||
issue,
|
||
allIssues,
|
||
globalAutoReview,
|
||
onOpenInBrowser,
|
||
onResumeSession,
|
||
onResumeReviewSession,
|
||
onResumeTestSession,
|
||
onStartTestSession,
|
||
onOpenFile,
|
||
onImplement,
|
||
onOpenPr,
|
||
onGeneratePrDiffSummary,
|
||
isPrDiffSummaryRunning,
|
||
onOpenWorktree,
|
||
onDeleteWorktree,
|
||
onMergeBranch,
|
||
onDeleteIssue,
|
||
onCloseIssue,
|
||
onResetToTodo,
|
||
onCloseSessionTab,
|
||
onStartBrainstormSession,
|
||
onUpdateAutoReview,
|
||
profiles,
|
||
onUpdateProfilePath,
|
||
onUpdateBrainstormProfilePath,
|
||
onUpdateTestProfilePath,
|
||
onOpenLogs,
|
||
me,
|
||
onStartHandoff,
|
||
onAcceptHandoff,
|
||
isHandoffRunning,
|
||
}: IssueDetailPanelProps) {
|
||
// 前置工单未完成 → 锁定。锁定时禁用"实施"等启动新工作的动作按钮,
|
||
// 但已存在的会话恢复链接保持可用(属于用户已操作过的入口)。
|
||
const { locked, prerequisiteNumber } = issue
|
||
? isIssueLocked(issue, allIssues)
|
||
: { locked: false, prerequisiteNumber: undefined as number | undefined }
|
||
const lockTitle = locked && prerequisiteNumber != null
|
||
? `等待 #${prerequisiteNumber} 完成`
|
||
: undefined
|
||
const prDiffSummaryRunning = issue ? isPrDiffSummaryRunning(issue.number) : false
|
||
|
||
// 三个会话 id 行(头脑风暴/实施/审查)共用一个"上次触发时间戳"表,
|
||
// 防止用户连续快速双击同一行时触发多次 resume —— extension 端 findExistingTerminal
|
||
// 在前一个 createTerminal 尚未完成时查不到已存在的终端,会重复开 tab。
|
||
// key 用 sessionId 字符串本身(足够区分三种行 + 不同工单)。
|
||
const lastFiredAtRef = useRef<Map<string, number>>(new Map())
|
||
const RESUME_THROTTLE_MS = 800
|
||
const throttleBySessionId = (sid: string): boolean => {
|
||
const now = Date.now()
|
||
const last = lastFiredAtRef.current.get(sid) ?? 0
|
||
if (now - last < RESUME_THROTTLE_MS)
|
||
return false
|
||
lastFiredAtRef.current.set(sid, now)
|
||
return true
|
||
}
|
||
|
||
const schema = useMemo<PropertyGroup[]>(
|
||
() => [
|
||
{
|
||
id: 'state',
|
||
label: 'state JSON',
|
||
actions: (
|
||
<button
|
||
type="button"
|
||
onClick={onOpenLogs}
|
||
title="查看扩展日志"
|
||
aria-label="查看扩展日志"
|
||
className="grid size-5 place-items-center rounded opacity-60 hover:opacity-100"
|
||
>
|
||
<Terminal className="size-3.5" />
|
||
</button>
|
||
),
|
||
properties: [
|
||
{
|
||
key: 'column',
|
||
label: '状态',
|
||
type: 'select',
|
||
readOnly: true,
|
||
description: '工单所在的看板列;写入 issue 最后一条 JSON 评论的 column 字段',
|
||
options: COLUMN_ORDER.map((c: IssueColumn) => ({
|
||
label: `${c}(${COLUMN_LABELS[c]})`,
|
||
value: c,
|
||
})),
|
||
},
|
||
(() => {
|
||
// 头脑风暴会话 id 行有三种状态:
|
||
// 1. sessionId 已存在 + brainstormTabOpen=true → 显示 id + X 关闭 tab
|
||
// 2. sessionId 已存在 + brainstormTabOpen=false → 显示 id(无右侧按钮)
|
||
// 3. sessionId 为空 + column !== 'done' → 显示 Play 按钮(启动一个新规划 cc tab)
|
||
// 4. sessionId 为空 + column === 'done' → 占位 —,不显示按钮
|
||
const hasSession = !!(issue?.sessionId && issue.sessionId.length > 0)
|
||
const canStart = !hasSession && issue?.column !== 'done'
|
||
let secondaryActionIcon: ReactNode | undefined
|
||
let secondaryActionTitle: string | undefined
|
||
let onSecondaryAction: (() => void) | undefined
|
||
if (hasSession && issue?.brainstormTabOpen) {
|
||
secondaryActionIcon = <X className="size-3.5" />
|
||
secondaryActionTitle = '关闭头脑风暴 tab'
|
||
onSecondaryAction = () => {
|
||
if (issue)
|
||
onCloseSessionTab(issue.number, 'brainstorm')
|
||
}
|
||
}
|
||
else if (canStart) {
|
||
secondaryActionIcon = <Play className="size-3.5" />
|
||
secondaryActionTitle = '启动头脑风暴会话'
|
||
onSecondaryAction = () => {
|
||
if (issue)
|
||
onStartBrainstormSession(issue.number)
|
||
}
|
||
}
|
||
return {
|
||
key: 'sessionId',
|
||
label: '头脑风暴会话id',
|
||
type: 'action' as const,
|
||
description: hasSession
|
||
? '双击在新终端运行 claude --resume <id> 恢复对话'
|
||
: '工单尚未关联 cc 会话;点击右侧 ▶ 启动一个新的规划 cc tab',
|
||
actionIcon: <Terminal className="size-3.5" />,
|
||
onAction: (v: unknown) => {
|
||
if (typeof v !== 'string' || v.length === 0)
|
||
return
|
||
if (!throttleBySessionId(v))
|
||
return
|
||
onResumeSession(v, issue?.brainstormProfilePath, undefined, issue?.number)
|
||
},
|
||
secondaryActionIcon,
|
||
secondaryActionTitle,
|
||
onSecondaryAction,
|
||
}
|
||
})(),
|
||
// 实施会话即使 worktree 已被清掉也保留可双击的 resume 入口:
|
||
// extension 端 handleResumeSession 会在 worktree 路径不存在时退回
|
||
// 工作区根目录,并通过 toast 告知用户;description 在两种状态下
|
||
// 给出不同的提示文案。
|
||
{
|
||
key: 'implementSessionId',
|
||
label: '实施会话id',
|
||
type: 'action',
|
||
description: issue?.worktreeExists
|
||
? '双击在新终端运行 claude --resume <id> 恢复实施对话(cwd 为 worktree)'
|
||
: 'worktree 已清理,将在工作区根目录恢复(cc 可能提示原 cwd 不存在)',
|
||
actionIcon: <Terminal className="size-3.5" />,
|
||
onAction: (v) => {
|
||
if (typeof v !== 'string' || v.length === 0)
|
||
return
|
||
if (!throttleBySessionId(v))
|
||
return
|
||
onResumeSession(v, issue?.profilePath, issue?.worktreePath, issue?.number)
|
||
},
|
||
secondaryActionIcon: issue?.implementTabOpen
|
||
? <X className="size-3.5" />
|
||
: undefined,
|
||
secondaryActionTitle: '关闭实施 tab',
|
||
onSecondaryAction: () => {
|
||
if (issue)
|
||
onCloseSessionTab(issue.number, 'implement')
|
||
},
|
||
},
|
||
issue?.reviewSessionFileExists
|
||
? {
|
||
key: 'reviewSessionId',
|
||
label: '审查会话id',
|
||
type: 'action',
|
||
description: '双击在新终端运行 codex resume <id> 查看审查会话(worktree 在则用之,否则工作区根目录)',
|
||
actionIcon: <Terminal className="size-3.5" />,
|
||
onAction: (v) => {
|
||
if (typeof v !== 'string' || v.length === 0 || !issue)
|
||
return
|
||
if (!throttleBySessionId(v))
|
||
return
|
||
onResumeReviewSession(v, issue.number, issue?.worktreePath)
|
||
},
|
||
secondaryActionIcon: issue?.reviewTabOpen
|
||
? <X className="size-3.5" />
|
||
: undefined,
|
||
secondaryActionTitle: '关闭审查 tab',
|
||
onSecondaryAction: () => {
|
||
if (issue)
|
||
onCloseSessionTab(issue.number, 'review')
|
||
},
|
||
}
|
||
: {
|
||
key: 'reviewSessionId',
|
||
label: '审查会话id',
|
||
type: 'string',
|
||
readOnly: true,
|
||
description: '本机没有该 codex 会话文件,无法 resume;仅保留 id 文本',
|
||
},
|
||
(() => {
|
||
// 测试会话 id 行有三种状态(照搬头脑风暴行的三态):
|
||
// 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab
|
||
// 2. testSessionId 已存在 + testTabOpen=false → 显示 id,双击 resume
|
||
// 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab
|
||
// (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
|
||
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
|
||
const canStart = !hasSession && !!issue?.pr
|
||
let secondaryActionIcon: ReactNode | undefined
|
||
let secondaryActionTitle: string | undefined
|
||
let onSecondaryAction: (() => void) | undefined
|
||
if (hasSession && issue?.testTabOpen) {
|
||
secondaryActionIcon = <X className="size-3.5" />
|
||
secondaryActionTitle = '关闭测试 tab'
|
||
onSecondaryAction = () => {
|
||
if (issue)
|
||
onCloseSessionTab(issue.number, 'test')
|
||
}
|
||
}
|
||
else if (canStart) {
|
||
secondaryActionIcon = <Play className="size-3.5" />
|
||
secondaryActionTitle = '启动测试会话'
|
||
onSecondaryAction = () => {
|
||
if (issue)
|
||
onStartTestSession(issue.number)
|
||
}
|
||
}
|
||
return {
|
||
key: 'testSessionId',
|
||
label: '测试会话id',
|
||
type: 'action' as const,
|
||
description: hasSession
|
||
? '双击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
|
||
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
|
||
actionIcon: <Terminal className="size-3.5" />,
|
||
onAction: (v: unknown) => {
|
||
if (typeof v !== 'string' || v.length === 0 || !issue)
|
||
return
|
||
if (!throttleBySessionId(v))
|
||
return
|
||
onResumeTestSession(v, issue.number)
|
||
},
|
||
secondaryActionIcon,
|
||
secondaryActionTitle,
|
||
onSecondaryAction,
|
||
}
|
||
})(),
|
||
{
|
||
key: 'autoReview',
|
||
label: '自动审查',
|
||
type: 'boolean',
|
||
description: `打开新 PR 时是否自动启动 codex 审查;未设置时跟随全局(当前全局:${globalAutoReview ? '开' : '关'})`,
|
||
},
|
||
{
|
||
key: 'color',
|
||
label: '颜色',
|
||
type: 'theme-color',
|
||
readOnly: true,
|
||
description: '首次开会话时随机分配的终端颜色(VS Code ThemeColor),用于该工单所有会话的终端 tab 着色',
|
||
},
|
||
{
|
||
key: 'brainstormProfilePath',
|
||
label: '头脑风暴配置文件',
|
||
type: 'select',
|
||
options: (() => {
|
||
// 占位项:value='' 对应「未设置」。原生 select 在 value 不匹配任何
|
||
// option 时会假显示第一项且 selectedIndex=0,导致点第一项不触发
|
||
// onChange、永远存不进去;占位项让未设置态有真实匹配项。
|
||
// 跨机绝对路径按 basename 对齐本机 profiles,避免「自定义:/Users/...」。
|
||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||
const current = matchProfileOption(issue?.brainstormProfilePath, profiles)
|
||
if (current && !opts.some(o => o.value === current))
|
||
opts.push({ label: `自定义:${current}`, value: current })
|
||
return opts
|
||
})(),
|
||
description: '头脑风暴会话使用的 Claude 配置文件;选「(默认)」用内置默认,与实施/测试互不回退',
|
||
},
|
||
{
|
||
key: 'profilePath',
|
||
label: '实施配置文件',
|
||
type: 'select',
|
||
options: (() => {
|
||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||
const current = matchProfileOption(issue?.profilePath, profiles)
|
||
if (current && !opts.some(o => o.value === current))
|
||
opts.push({ label: `自定义:${current}`, value: current })
|
||
return opts
|
||
})(),
|
||
description: '实施会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/测试互不回退',
|
||
},
|
||
{
|
||
key: 'testProfilePath',
|
||
label: '测试配置文件',
|
||
type: 'select',
|
||
options: (() => {
|
||
const opts = [{ label: '(默认)', value: '' }, ...profiles.map(p => ({ label: p.name, value: p.path }))]
|
||
const current = matchProfileOption(issue?.testProfilePath, profiles)
|
||
if (current && !opts.some(o => o.value === current))
|
||
opts.push({ label: `自定义:${current}`, value: current })
|
||
return opts
|
||
})(),
|
||
description: '测试会话使用的 Claude 配置文件;选「(默认)」用内置默认,与头脑风暴/实施互不回退',
|
||
},
|
||
{
|
||
key: 'specFile',
|
||
label: '规格文件',
|
||
type: 'file-link',
|
||
description: '双击文件名在编辑器打开;cc 通过 issue body 的 <!-- spx:spec=路径 --> 注释实时同步',
|
||
onOpen: (p: string) => onOpenFile(p),
|
||
},
|
||
{
|
||
key: 'planFile',
|
||
label: '计划文件',
|
||
type: 'file-link',
|
||
description: '双击文件名在编辑器打开;点击实施按钮启动实施流程;cc 通过 issue body 的 <!-- spx:plan=路径 --> 注释实时同步',
|
||
onOpen: (p: string) => onOpenFile(p),
|
||
// Hide the "实施" button entirely when there's no plan file.
|
||
secondaryActionIcon: issue?.planFile
|
||
? <Play className="size-3.5" />
|
||
: undefined,
|
||
secondaryActionTitle: locked ? lockTitle : '实施此计划',
|
||
// Disable while running or already done. The user can still click
|
||
// when status === 'failed' or never been run. Re-clicking while
|
||
// 'running' is blocked at the UI to avoid registering duplicate
|
||
// webhooks. To retry after a perceived failure with status still
|
||
// 'running', edit the state-JSON comment manually for now.
|
||
// 锁定态(前置工单未完成)同样禁止启动实施。
|
||
secondaryDisabled:
|
||
!issue?.planFile
|
||
|| issue?.implementStatus === 'running'
|
||
|| issue?.implementStatus === 'done'
|
||
|| locked,
|
||
onSecondaryAction: () => {
|
||
if (!issue?.planFile)
|
||
return
|
||
onImplement(issue.number, issue.planFile, issue.profilePath, issue.sessionId)
|
||
},
|
||
},
|
||
{
|
||
key: 'pr',
|
||
label: '合并请求',
|
||
type: 'pr-link',
|
||
description: '双击在浏览器打开关联的 PR;点击右侧按钮生成 PR 变更摘要;已合并时编号后追加 "(已合并)" 标识',
|
||
onAction: (v) => {
|
||
if (typeof v !== 'string' || v.length === 0)
|
||
return
|
||
// value 形如 "65" 或 "65(已合并)";剥掉合并后缀只把纯编号传出去。
|
||
const pr = v.replace(/\(已合并\)\s*$/, '')
|
||
if (pr.length > 0)
|
||
onOpenPr(pr)
|
||
},
|
||
secondaryActionIcon: issue?.pr && !issue?.prDiffFile
|
||
? <Play className="size-3.5" />
|
||
: undefined,
|
||
secondaryActionTitle: prDiffSummaryRunning
|
||
? 'PR 变更摘要生成中'
|
||
: '生成 PR 变更摘要',
|
||
secondaryDisabled: prDiffSummaryRunning,
|
||
onSecondaryAction: () => {
|
||
if (issue?.pr && !issue.prDiffFile && !prDiffSummaryRunning)
|
||
onGeneratePrDiffSummary(issue.number)
|
||
},
|
||
},
|
||
{
|
||
key: 'prDiffFile',
|
||
label: 'PR 变更摘要',
|
||
type: 'file-link',
|
||
description: '双击文件名在编辑器打开生成的 PR 代码变更摘要',
|
||
onOpen: (p: string) => onOpenFile(p),
|
||
},
|
||
{
|
||
key: 'branch',
|
||
label: '分支',
|
||
type: 'string',
|
||
readOnly: true,
|
||
description: '实施流程创建的分支名',
|
||
secondaryActionIcon: issue?.branch ? <GitMerge className="size-3.5" /> : undefined,
|
||
secondaryActionTitle: '本地预合并 (git merge --no-commit --no-ff)',
|
||
onSecondaryAction: () => {
|
||
if (issue?.branch)
|
||
onMergeBranch(issue.number, issue.branch)
|
||
},
|
||
},
|
||
issue?.worktreeExists
|
||
? {
|
||
key: 'worktreePath',
|
||
label: '工作树',
|
||
type: 'file-link',
|
||
description: '双击文件名在新 VS Code 窗口打开;点击垃圾桶删除 worktree',
|
||
onOpen: (p: string) => onOpenWorktree(p),
|
||
onSecondaryAction: () => issue && onDeleteWorktree(issue.number, issue.worktreePath ?? ''),
|
||
secondaryActionIcon: <Trash2 className="size-3.5" />,
|
||
secondaryActionTitle: '删除 worktree',
|
||
}
|
||
: {
|
||
key: 'worktreePath',
|
||
label: '工作树',
|
||
type: 'string',
|
||
readOnly: true,
|
||
description: '实施流程创建的 git worktree 路径(workspace 相对)',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
[onResumeSession, onResumeReviewSession, onResumeTestSession, onStartTestSession, onOpenFile, onImplement, onOpenPr, onGeneratePrDiffSummary, onOpenWorktree, onDeleteWorktree, onMergeBranch, onCloseSessionTab, onStartBrainstormSession, onOpenLogs, issue, locked, lockTitle, globalAutoReview, prDiffSummaryRunning, profiles],
|
||
)
|
||
|
||
const data = useMemo<Record<string, unknown> | null>(() => {
|
||
if (!issue)
|
||
return null
|
||
return {
|
||
column: issue.column,
|
||
sessionId: issue.sessionId ?? null,
|
||
implementSessionId: issue.implementSessionId ?? null,
|
||
reviewSessionId: issue.reviewSessionId ?? null,
|
||
testSessionId: issue.testSessionId ?? null,
|
||
autoReview: issue.autoReview ?? globalAutoReview,
|
||
color: issue.color ?? null,
|
||
// 展示/选中值按 basename 映射到本机 profiles,避免跨机绝对路径变成「自定义」项。
|
||
brainstormProfilePath: matchProfileOption(issue.brainstormProfilePath, profiles) || null,
|
||
profilePath: matchProfileOption(issue.profilePath, profiles) || null,
|
||
testProfilePath: matchProfileOption(issue.testProfilePath, profiles) || null,
|
||
specFile: issue.specFile ?? null,
|
||
planFile: issue.planFile ?? null,
|
||
prDiffFile: issue.prDiffFile ?? null,
|
||
pr: issue.pr
|
||
? `${issue.pr}${issue.prMerged ? '(已合并)' : ''}`
|
||
: null,
|
||
branch: issue.branch ?? null,
|
||
worktreePath: issue.worktreePath ?? null,
|
||
}
|
||
}, [
|
||
issue,
|
||
globalAutoReview,
|
||
profiles,
|
||
])
|
||
|
||
if (!issue || !data) {
|
||
return (
|
||
<div className="flex h-full items-center justify-center border-t border-[var(--vscode-panel-border)] p-3 text-xs opacity-60">
|
||
按方向键或点击卡片查看详情
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col gap-3 border-t border-[var(--vscode-panel-border)] p-3">
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="flex-1 truncate text-sm font-medium">
|
||
<span className="font-mono opacity-60">
|
||
{issue.source === 'youtrack' ? issue.externalId : `#${issue.number}`}
|
||
</span>
|
||
{issue.source === 'youtrack' && (
|
||
<span
|
||
className="ml-2 inline-flex items-center rounded border border-purple-400/50 bg-purple-400/10 px-1.5 py-0.5 align-middle text-[10px] font-normal text-purple-400"
|
||
title="此工单来自 YouTrack"
|
||
>
|
||
YouTrack
|
||
</span>
|
||
)}
|
||
{' '}
|
||
{issue.title}
|
||
{locked && prerequisiteNumber != null
|
||
? (
|
||
<span
|
||
className="ml-2 inline-flex items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 align-middle text-[10px] font-normal text-[var(--vscode-disabledForeground)]"
|
||
title={lockTitle}
|
||
>
|
||
等待 #
|
||
{prerequisiteNumber}
|
||
{' '}
|
||
完成
|
||
</span>
|
||
)
|
||
: null}
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||
className="inline-flex shrink-0 items-center gap-1 rounded border border-[var(--vscode-panel-border)] px-2 py-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||
>
|
||
<ExternalLink className="size-3.5" />
|
||
{issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
||
</button>
|
||
{/* Close/delete write to gitea by number — hide for youtrack cards to
|
||
avoid acting on a gitea issue of the same number. */}
|
||
{issue.source !== 'youtrack' && (
|
||
<>
|
||
{issue.handoffAttachmentId && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
||
<button
|
||
type="button"
|
||
disabled={isHandoffRunning(issue.number)}
|
||
onClick={() => onAcceptHandoff(issue.number)}
|
||
title={`接管(${issue.handoffFrom ?? '?'} 移交)`}
|
||
aria-label="接管"
|
||
className="inline-flex shrink-0 items-center gap-1 rounded border border-green-500/60 bg-green-500/10 px-2 py-1 text-xs text-green-500 hover:bg-green-500/20 disabled:opacity-50"
|
||
>
|
||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||
接管
|
||
</button>
|
||
)}
|
||
{!issue.handoffAttachmentId && issue.column !== 'done' && (
|
||
<button
|
||
type="button"
|
||
disabled={isHandoffRunning(issue.number)}
|
||
onClick={() => onStartHandoff(issue.number)}
|
||
title="移交给同事"
|
||
aria-label="移交"
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-blue-500/60 hover:bg-blue-500/10 hover:text-blue-500 disabled:opacity-50"
|
||
>
|
||
{isHandoffRunning(issue.number) ? <Loader2 className="size-3.5 animate-spin" /> : <ArrowRightLeft className="size-3.5" />}
|
||
</button>
|
||
)}
|
||
{issue.handoffAttachmentId && !(me !== undefined && (issue.assignees ?? []).includes(me)) && (
|
||
<span className="inline-flex shrink-0 items-center rounded border border-[var(--vscode-panel-border)] px-1.5 py-0.5 text-[10px] opacity-70" title={`${issue.handoffFrom ?? '?'} 已移交,等待 ${(issue.assignees ?? []).join(', ') || '?'} 接管`}>
|
||
待接管
|
||
</span>
|
||
)}
|
||
{(issue.column === 'in-progress' || issue.column === 'review') && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onResetToTodo(issue.number)}
|
||
title="重置为待办"
|
||
aria-label="重置为待办"
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||
>
|
||
<RotateCcw className="size-3.5" />
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
onClick={() => onCloseIssue(issue.number)}
|
||
title="关闭工单"
|
||
aria-label="关闭工单"
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||
>
|
||
<CircleSlash className="size-3.5" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => issue && onDeleteIssue?.(issue.number)}
|
||
title="删除工单(不可撤销)"
|
||
aria-label="删除工单"
|
||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-red-500/60 hover:bg-red-500/10 hover:text-red-500"
|
||
>
|
||
<Trash2 className="size-3.5" />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{issue.source === 'youtrack' && issue.attachments && issue.attachments.length > 0 && (
|
||
<div className="flex flex-col gap-1 text-xs">
|
||
<span className="opacity-60">附件({issue.attachments.length})</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
{issue.attachments.map(att => (
|
||
<button
|
||
key={att.url}
|
||
type="button"
|
||
onClick={() => onOpenInBrowser(att.url)}
|
||
title={att.name}
|
||
className="inline-flex max-w-[200px] items-center gap-1 truncate rounded border border-[var(--vscode-panel-border)] px-2 py-1 hover:bg-black/10 dark:hover:bg-white/10"
|
||
>
|
||
<ExternalLink className="size-3 shrink-0" />
|
||
<span className="truncate">{att.name}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="min-h-0 flex-1">
|
||
<PropertyGrid
|
||
key={issue.number}
|
||
schema={schema}
|
||
data={data}
|
||
hideToolbar
|
||
fillHeight
|
||
onChange={(key, value) => {
|
||
if (key === 'autoReview' && issue && typeof value === 'boolean')
|
||
onUpdateAutoReview(issue.number, value)
|
||
if (key === 'brainstormProfilePath' && issue && typeof value === 'string')
|
||
onUpdateBrainstormProfilePath(issue.number, value)
|
||
if (key === 'profilePath' && issue && typeof value === 'string')
|
||
onUpdateProfilePath(issue.number, value)
|
||
if (key === 'testProfilePath' && issue && typeof value === 'string')
|
||
onUpdateTestProfilePath(issue.number, value)
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|